32 Commits
Author SHA1 Message Date
ARIA c7a16d51e3 gateway setup: offer self-signed TLS cert generation (no openssl needed)
CI / Gateway plugin tests (push) Successful in 4m55s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
hermes gateway setup now asks 'Set up TLS now?' when IRIS_HTTP_CERT is
not in .env (default No, Yes for an all-interfaces bind). Accepting
generates a 10-year RSA-2048 self-signed cert with SANs (advertised LAN
IP, hostname, loopback) under ~/.hermes/iris/ via hermes' existing
cryptography dependency, saves IRIS_HTTP_CERT/IRIS_HTTP_KEY, and prints
the SHA-256 fingerprint in openssl format for the app's confirm-and-pin
dialog. The pairing URL/QR printed afterwards already advertise https.

- key created 0600 from the start (no umask window)
- save_env_value inside the best-effort guard (unwritable .env warns)
- leftover cert without env var -> overwrite confirmation (protects the
  app's pinned fingerprint)
- bind wildcards (0.0.0.0 / ::) never become SANs; :: gets the same
  default-Yes as 0.0.0.0 (pairing._unroutable parity)

Tests: 5 new (cert generation incl. openssl fingerprint cross-check,
accept/decline, no re-prompt, default-follows-bind, overwrite prompt).
Docs: install.md Part 2 table + Part 4 Option B.
2026-08-24 22:46:27 +02:00
ARIA b1c9bac7d8 docs+plugin: HTTP-only transport cleanup, install guide, review fixes
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s
- docs/install.md: new end-to-end guide for non-technical users
  (gateway install, app install, LAN/TLS/remote connection, push,
  options, troubleshooting); docs/setup.md now points to it
- README: new 'Install the gateway' section; pairing section updated
  for HTTP transport (8791, QR scan on Android)
- rename IRIS_WS_HOST -> IRIS_HTTP_HOST (clean rename, no compat
  fallback); drop dead DEFAULT_PORT=8790
- setup.py: advertise https:// in the printed/QR server URL when
  IRIS_HTTP_CERT is set
- ws_probe.py/e2e.py: default --url http://127.0.0.1:8791, env
  IRIS_WS_URL -> IRIS_HTTP_URL, honor explicit port + https scheme
- plugin.yaml: IRIS_HTTP_* env names, description no longer says
  'WebSocket server'
- docs 03/09/12/19: fix stale WS-era refs (ws_server.py cites,
  8790 smoke test, WSS->HTTPS, 'HTTP fallback' reframed as the
  only transport)
- AGENTS.md: symlink name android -> iris (matches actual install)
- test: adapter reads IRIS_HTTP_HOST/CERT/KEY from env; legacy
  IRIS_WS_* names are not consulted (95/95 pass)
2026-08-24 22:22:02 +02:00
ARIA a61b47a947 docs: replace stale 'android' name mentions with 'iris'
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m59s
The plugin is named 'iris' (IrisAdapter, IRIS_HOME_CHANNEL, label Iris),
but several docs still referred to it as the android platform/plugin and
to the product as 'the Android app'. Rename name-mentions to iris/IRIS
and product-mentions to 'Iris app'; keep legitimate OS references
(androidApp, Android SDK, Android 10, androidx, test_android.py, ...).

Also includes pi-lens markdown-lint autofixes (table spacing, trailing
newlines) in the touched files.
2026-08-24 21:44:02 +02:00
ARIA 597a28050f TLS: fingerprint-confirm flow for self-signed gateway certs (issue #14)
CI / Gateway plugin tests (push) Successful in 5m29s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m22s
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.
2026-08-24 21:30:42 +02:00
ARIA f90e40a3fc Fix README limit claims to match reality (issue #13)
CI / Gateway plugin tests (push) Failing after 1m4s
CI / Kotlin tests (android host + desktop) (push) Successful in 8m3s
- 'No file limit' -> 100 MB default, configurable via max_upload_bytes
- 'No character limit' -> 'No 4,096-character limit like Telegram'
  (hard frame-body cap: 1 MiB)
- Note that limits are set on the gateway side (hermes), not in the app
- Sync docs/playstore-listing.md (same overclaim)
2026-08-24 21:05:13 +02:00
Pakobbix 330e63e941 Merge pull request 'Split adapter.py monolith into focused modules; restore Ruff complexity defaults' (#15) from refactor/adapter-split into master
CI / Kotlin tests (android host + desktop) (push) Successful in 7m41s
CI / Gateway plugin tests (push) Successful in 8m46s
Reviewed-on: #15
2026-08-24 19:03:15 +00:00
ARIA b8e756c3dd Split adapter.py monolith into focused modules; restore Ruff complexity defaults (issue #12)
CI / Gateway plugin tests (pull_request) Successful in 4m59s
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 7m5s
adapter.py was a 3,493-line monolith. Split it into focused modules with
clear separation of responsibilities, bringing it down to ~857 lines:

- Module-level helpers: hooks, classify, pickers, commands, setup,
  defaults, secrets
- Frame-handler mixins: inbound, tool_frames, push_frames, media_frames,
  picker_frames, channel_frames, query_frames
- mixin_base: IrisAdapterBase (declaration-only base for shared attrs)
- adapter.py now holds only IrisAdapter (the composition of the 7 mixins
  + BasePlatformAdapter), register(), and test-facing re-exports

The mixins come before BasePlatformAdapter in the MRO so their methods
override the base; super() calls (e.g. send_image) still resolve to
BasePlatformAdapter. No circular imports; dispatch.py and http_server.py
(instance-method callers) are unaffected.

Ruff complexity ceilings (PLR0911/0912/0913/0915) restored to Ruff's
built-in defaults (12/50/6/5) instead of "just above the current maxima",
which ratchets the bar down as code grows. The existing genuinely-complex
functions (frame builders mirroring the wire schema, the QR matrix builder,
the dispatch table) carry an explicit `# noqa: PLR09xx` marking them as
reviewed, frozen exceptions; new code is held to the default ceilings.

All 125 tests green (94 test_android + 31 test_android_http); no new ruff
errors introduced.
2026-08-24 20:55:00 +02:00
ARIA 7faaf2aa1c Per-device tokens with revocation (issue #11)
CI / Gateway plugin tests (push) Successful in 5m5s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m50s
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).
2026-08-24 19:37:44 +02:00
ARIA 746d809d48 Default push backend to ntfy; FCM opt-in with privacy warning (issue #10)
CI / Gateway plugin tests (push) Successful in 5m3s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m6s
- IRIS_PUSH_BACKEND now defaults to ntfy (keeps push metadata on your own
  infrastructure); FCM is opt-in via IRIS_PUSH_BACKEND=fcm
- build_push_backend(): ntfy for empty/unknown names, FCM only on explicit 'fcm'
- gateway setup: warn when FCM is chosen (metadata routed via Google's servers)
- README: privacy note + dedicated push section; new docs/playstore-listing.md
  with the FCM/ntfy privacy note for the Play Store listing
- docs: 00/02/03/08/12/16 + setup.md updated to ntfy-default wording
- tests: default-backend assertion updated (86/86 pass)
2026-08-24 19:04:40 +02:00
ARIA 70282dfb65 fix(release): use Forgejo-style /assets and /tags API routes
CI / Gateway plugin tests (push) Successful in 4m55s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m48s
The server (gitea.zephyre.one) exposes a Forgejo-compatible API:
- release attachments live at POST /releases/{id}/assets, not /attachments
- tag deletion is DELETE /tags/{tag}, not DELETE /git/refs/tags/{tag}
(verified against the live API: /attachments 404s, /assets and /tags exist)
2026-08-23 19:53:17 +02:00
ARIA 44e8c7322e fix(release): delete leftover tag on re-run; support \\n in changelog input
CI / Gateway plugin tests (push) Successful in 5m0s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m43s
- Gitea's DELETE /releases/:id does not remove the tag, so re-running the
  workflow for the same version failed with 409 (curl exit 22). The
  re-run safety block now also deletes the tag via git/refs/tags.
- Replace curl -sf with an api() wrapper that prints Gitea's error body
  on HTTP >= 400 instead of failing silently.
- The workflow_dispatch changelog input is single-line (Gitea has no
  multiline input type); convert literal \\n to real newlines and
  document it in the input description.
2026-08-23 19:03:59 +02:00
ARIA 29d0c1a73f Fix two gateway test failures: outbox lane scoping + SSE teardown race
CI / Gateway plugin tests (push) Successful in 5m46s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
- outbox: delete_message/message_info now match the exact lane first
  (a flat-lane delete/lookup with thread_id=None sees only frames with
  no thread_id) and fall back to the message_id across all lanes only
  when the exact lane matches nothing. Previously lane=None meant
  'any lane' in the first pass, so a flat-lane delete also removed
  same-id frames from threads (test expected 3 removed, got 4).

- http_server: the SSE live loop skipped queued frames when stop() set
  sub.closed before the handler thread reached the loop (descheduled
  under load between the initial hello/status writes and the loop).
  The loop now drains frames queued before the close, so the
  status{restarting} teardown broadcast always reaches the client
  before EOF (test_disconnect_broadcasts_status_restarting was flaky
  ~70% under CPU load).
2026-08-23 14:45:09 +02:00
ARIA d801a18db5 fix FCM push notifications
CI / Gateway plugin tests (push) Failing after 6m27s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m2s
2026-08-23 14:32:40 +02:00
Pakobbix 560d9c19b3 Merge pull request 'feat(app): copy messages via bubble context menu + selection toolbar' (#9) from feat/message-copy-context-menu into master
CI / Kotlin tests (android host + desktop) (push) Successful in 9m0s
CI / Gateway plugin tests (push) Failing after 9m18s
Reviewed-on: #9
2026-08-23 11:10:30 +00:00
ARIA 32db7fc4e8 feat(app): copy messages via bubble context menu + selection toolbar
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 8m18s
CI / Gateway plugin tests (pull_request) Failing after 9m55s
Long-press (touch) / right-click (desktop) on a finalized message now
opens a context menu anchored to the bubble: Copy / Select messages /
Delete. The multi-select toolbar gains a Copy button that joins the
selected messages in display order. Copies raw markdown so formatting
survives pasting into other markdown apps; blank text is a no-op.
Cancelling a menu-opened delete confirm clears the staged selection so
the bubble doesn't stay highlighted.
2026-08-23 13:08:44 +02:00
ARIA 863ab34915 fix(app): apply whole-review fixes (HIGH/MEDIUM/LOW) + dead code & stale comments
CI / Gateway plugin tests (push) Failing after 6m35s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
HIGH:
- ntfy listener: replace blocking exhausted() loop with SSE read + capped
  exponential-backoff reconnect; 60s read timeout
- GatewayClient.stop(): reset HTTP leg (http, httpCursor, sseFailures,
  usingLongPoll, lastAck)
- non-atomic shared state -> synchronized/@Volatile/AtomicLong/
  CopyOnWriteArrayList

MEDIUM:
- mediaId path-traversal guard (isValidMediaId) at network/app/fs boundaries
- loadFromCache: move ts=0 pending bubbles to end, keep stored order
- attachment placeholder tracked by identity, not filename
- optimistic ChannelStore updates on favorite/icon/automation/default
- secure-store caching (desktop map, android store)
- secret passed to keyring via stdin (macOS + Linux)
- SecureStore.clear() clears deviceId/syncCursor/fcmToken/ntfy*
- random ids for system messages; SSE EOF reconnect delay
- PowerShell $ escaping; dispose() cancels job before saving flows
- wire up "Forget pairing" in Settings
- move machine-specific org.gradle.java.home to user-level gradle.properties

LOW + dead code + stale comments:
- .aac->audio/aac; locale-fixed cost/size; 3-digit hex; hour+ latency
- Backdrop.DEFAULT defined once; notification id 24-bit; channel id cap
- remove dead FileSource, unused protocol/theme/media constants, empty
  onDispose, SDK_INT<O guard, hostFromUrl
- fix stale WS/SSE, M1/M5, and milestone KDoc comments

Verified: Kotlin desktop+android host tests, 100/100 Python gateway tests,
LSP clean, installed & running on device.
2026-08-23 12:57:35 +02:00
ARIA a4e4a4ea63 Fix flaky message deletion: exact lane match in outbox history + message_id fallback on delete
CI / Kotlin tests (android host + desktop) (push) Successful in 7m4s
CI / Gateway plugin tests (push) Failing after 8m46s
A flat-lane history (thread_id=None) returned frames from ALL threads, so
auto-threaded messages leaked into the flat lane on restart. Deleting them
from the flat lane then sent thread_id=None, which matched nothing in
outbox.delete_message/message_info (exact lane match) -> removed=0, no
session-store purge, and the messages resurrected from the outbox on the
next app restart.

- history: exact lane match (flat lane shows only flat-lane frames, per
  docs/06 §6.3)
- delete_message / message_info: fall back to the unique message_id (uuid4)
  when the exact lane matches nothing, so deletes with a stale/missing
  thread_id still remove the frames and the purge finds its row
2026-08-23 11:13:51 +02:00
ARIA ca622d3a39 added a watchdog to force the SSE stream open
CI / Gateway plugin tests (push) Successful in 6m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m18s
2026-08-23 10:56:37 +02:00
ARIA abbed438ec fix(app): don't re-show notification banners on app reopen
CI / Gateway plugin tests (push) Successful in 6m6s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m44s
The persisted sync cursor only advanced on sync.done, so on every
restart the event stream re-delivered all frames consumed since the
last sync.done - and replayed notification frames re-showed their
banner (e.g. 'thread deleted' again after close/reopen).

Track the consumed outbox high-water cursor in the controller,
persist it (debounced + on backgrounding), and skip notification
frames whose cursor is at/below the mark (also covers the duplicate
delivery of SSE catch-up + explicit sync replay on reconnect).
2026-08-23 01:42:55 +02:00
ARIA 6458c3183c feat(app): unread message indicator (channel view, header, hamburger)
CI / Gateway plugin tests (push) Successful in 6m18s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
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.
2026-08-23 01:34:42 +02:00
ARIA 9c50f2dbc1 feat(approvals): render exec approvals as interactive picker buttons
CI / Gateway plugin tests (push) Successful in 5m2s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
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.
2026-08-23 01:16:13 +02:00
ARIA 487fd1c83c One-line tool cards in truncated mode
CI / Gateway plugin tests (push) Successful in 5m0s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m27s
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.
2026-08-23 01:01:04 +02:00
ARIA 4e9f1d028a docs(07): document media size limits, storage locations, outbound asymmetry
CI / Gateway plugin tests (push) Successful in 4m48s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s
2026-08-23 00:48:25 +02:00
ARIA 742916903b Live agent todo list: compact scrollable strip above the composer
CI / Gateway plugin tests (push) Successful in 5m5s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m47s
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.
2026-08-23 00:23:02 +02:00
ARIAandClaude Opus 4.8 1ff2ef380c Render single-select clarify prompts as interactive pickers
CI / Gateway plugin tests (push) Successful in 5m2s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m34s
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>
2026-08-22 23:51:14 +02:00
ARIAandClaude Opus 4.8 82c5a20848 Add interactive choice-picker menus for finite-choice slash commands
CI / Gateway plugin tests (push) Successful in 4m48s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s
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>
2026-08-22 23:33:53 +02:00
ARIA 34a64d6e53 AGENTS.md: clarify commit/push scope (all changes, except hermes-agent/)
CI / Gateway plugin tests (push) Successful in 6m7s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m50s
2026-08-22 22:46:41 +02:00
ARIA 7a6d922d12 Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
CI / Kotlin tests (android host + desktop) (push) Successful in 8m5s
CI / Gateway plugin tests (push) Successful in 9m47s
2026-08-22 22:43:13 +02:00
ARIA 27dc7917f2 Added log handling for failed phone connection to the gateway
CI / Gateway plugin tests (push) Successful in 4m50s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m45s
2026-08-22 21:08:06 +02:00
ARIA 4866f14231 chat: natural reading scroll for user and agent messages
CI / Gateway plugin tests (push) Successful in 4m46s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m14s
- 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).
2026-08-22 20:55:35 +02:00
ARIA 8fba00ba7f android: shrink composer pill; float it over the message list
CI / Gateway plugin tests (push) Successful in 4m59s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m50s
- reduce composer size (padding, corner radius, send button 40->36dp)
- move composer plane (back-to-bottom button, attachments, banners,
  slash drawer, input pill) into a bottom overlay so messages scroll
  behind it; overlay height drives the list's bottom content padding
2026-08-22 20:36:33 +02:00
ARIA 7f0bdcbbc1 Per-tool emoji on tool cards (gateway-resolved via hermes get_tool_emoji)
CI / Gateway plugin tests (push) Successful in 5m6s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m45s
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
2026-08-22 20:26:38 +02:00
118 changed files with 9807 additions and 3524 deletions

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
run: | run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent cd hermes-agent
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
kotlin: kotlin:
+30 -10
View File
@@ -8,7 +8,7 @@ on:
required: true required: true
type: string type: string
changelog: changelog:
description: "Release notes (markdown, shown on the release page)" description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
required: false required: false
type: string type: string
@@ -40,7 +40,7 @@ jobs:
run: | run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent cd hermes-agent
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
kotlin: kotlin:
@@ -178,19 +178,38 @@ jobs:
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}" REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}" TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH") # The dispatch input is a single-line field; turn literal \n into real newlines.
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
TAG="v$VERSION" TAG="v$VERSION"
API="$SERVER/api/v1/repos/$REPO" API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN" AUTH="Authorization: token $TOKEN"
# Re-run safety: drop a previous release (and its tag) for this version. # curl wrapper: on HTTP >= 400, print the response body (Gitea's error
OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') # message) before failing — plain `curl -f` hides it (exit 22).
if [ -n "$OLD_ID" ]; then api() {
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null local code body
body=$(mktemp)
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
if [ "${code:0:1}" != "2" ]; then
echo "API error $code: $(cat "$body")" >&2
rm -f "$body"
return 1
fi fi
cat "$body"
rm -f "$body"
}
# Re-run safety: drop a previous release AND its tag for this version.
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
# makes the POST below fail with 409.)
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
if [ -n "$OLD_ID" ]; then
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
fi
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
# Gitea creates the tag at the default branch HEAD automatically. # Gitea creates the tag at the default branch HEAD automatically.
RELEASE_ID=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \ RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
"$API/releases" \ "$API/releases" \
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \ -d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
'{tag_name:$tag, title:$title, body:$body}')" \ '{tag_name:$tag, title:$title, body:$body}')" \
@@ -200,7 +219,8 @@ jobs:
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
echo "Uploading $(basename "$f")" echo "Uploading $(basename "$f")"
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \ # Forgejo-style API: release assets live under /assets, not /attachments.
"$API/releases/$RELEASE_ID/attachments" > /dev/null api -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/assets" > /dev/null
done done
echo "Done: $SERVER/$REPO/releases/tag/$TAG" echo "Done: $SERVER/$REPO/releases/tag/$TAG"
+9 -8
View File
@@ -3,7 +3,8 @@
## Hard rules ## Hard rules
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home. - `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
- The plugin is installed by symlink: `~/.hermes/plugins/android` → `<repo>/gateway-plugin` (already set up on this machine). - **Commit/push scope:** when asked to "commit and push all changes," that means **all** changes in the working tree — it does NOT matter whether a change was made this session or earlier. Stage everything (`git add .`) and commit; do not cherry-pick or second-guess which files are "yours." The only exception is `hermes-agent/` (git-ignored, never staged).
- The plugin is installed by symlink: `~/.hermes/plugins/iris` → `<repo>/gateway-plugin` (already set up on this machine).
## Layout ## Layout
@@ -14,26 +15,26 @@
## Commands ## Commands
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`). - `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
- Gateway: `hermes gateway setup` (one-time; generates `ANDROID_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`. - Gateway: `hermes gateway setup` (one-time; generates `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below). - Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb). - Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite). - Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
- Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set). - Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`. - WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`. - E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
## Environment / pairing quirks ## Environment / pairing quirks
- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry. - Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. Pairing is manual URL + token entry; on **Android** there's also a QR-scan button (camera) that fills URL + token from the gateway's pairing QR. Desktop has no camera, so it's manual entry only.
- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP. - HTTP default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_HTTP_HOST` to the gateway's LAN IP.
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds. - `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy. - `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
- JDK 17; no system Gradle — always the wrapper (`./gradlew`). - **JDK 21** is required (the desktop Markdown renderer ships Java-21 bytecode); no system Gradle — always the wrapper (`./gradlew`). The JDK-21 home is machine-specific and set per machine (NOT committed): add `org.gradle.java.home=/path/to/jdk21` to `~/.gradle/gradle.properties`, or `export JAVA_HOME=/path/to/jdk21` before running `./gradlew`.
- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works. - Desktop jpackage on Linux/JDK 21 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
## Testing quirks ## Testing quirks
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `ANDROID_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`. - `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `IRIS_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design. - e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`. - ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
- ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates. - ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates.
+2 -2
View File
@@ -58,7 +58,7 @@ jobs:
run: | run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent cd hermes-agent
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
kotlin: kotlin:
@@ -153,7 +153,7 @@ jobs:
run: | run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent cd hermes-agent
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
kotlin: kotlin:
+68 -23
View File
@@ -2,14 +2,19 @@
A chat app for [hermes-agent](https://github.com/NousResearch/hermes-agent): a **native Android app** and a **desktop app** (Linux, macOS, Windows) — both built from one shared Kotlin codebase (Compose Multiplatform). A chat app for [hermes-agent](https://github.com/NousResearch/hermes-agent): a **native Android app** and a **desktop app** (Linux, macOS, Windows) — both built from one shared Kotlin codebase (Compose Multiplatform).
Iris pairs with your running `hermes gateway` over a private WebSocket and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications. Iris pairs with your running `hermes gateway` over a private, token-authenticated connection and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications.
## Features ## Features
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app - **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
- **Absolute Privacy!** — everything stays on your own infrastructure - **Absolute Privacy!** — everything stays on your own infrastructure
- **No file limit** (push: ntfy by default; FCM is opt-in and routes push metadata via Google —
- **No character limit** see [Push notifications](#push-notifications))
- **100 MB file uploads by default** — configurable on the gateway via
`max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set
on the gateway side (hermes), not in the app
- **No 4,096-character message limit like Telegram** — messages travel over
your own gateway (hard frame-body cap: 1 MiB)
- **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting… - **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting…
- **HTML Artifact Preview** — agent-sent HTML/CSS/JS rendered in an in-app WebView - **HTML Artifact Preview** — agent-sent HTML/CSS/JS rendered in an in-app WebView
- **All settings live in the app**, not in hermes `config.yml`! Change everything on the fly. - **All settings live in the app**, not in hermes `config.yml`! Change everything on the fly.
@@ -24,11 +29,11 @@ Iris pairs with your running `hermes gateway` over a private WebSocket and gives
## How it works ## How it works
``` ```
hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (Android / Desktop) hermes-agent ──> hermes gateway ──(HTTP :8791)──> Iris app (Android / Desktop)
``` ```
- `gateway-plugin/` is a hermes **platform plugin** (`android`). It runs inside the - `gateway-plugin/` is a hermes **platform plugin** (`android`). It runs inside the
`hermes gateway` process and opens a WebSocket server the apps connect to. `hermes gateway` process and opens an HTTP server the apps connect to.
Zero new Python dependencies, zero hermes-core changes. Zero new Python dependencies, zero hermes-core changes.
- `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the - `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the
code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp` code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp`
@@ -36,14 +41,51 @@ hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (And
- The app is a first-class hermes *messaging platform*, so everything the gateway - The app is a first-class hermes *messaging platform*, so everything the gateway
already does just works: slash commands, cron delivery, `send_message` routing, already does just works: slash commands, cron delivery, `send_message` routing,
coexistence with Telegram/Discord/etc. coexistence with Telegram/Discord/etc.
- Push notifications: FCM (primary) or ntfy (fallback). - Push notifications: ntfy (default) or FCM (opt-in).
## Push notifications
Push wakes a backgrounded/offline device; on reconnect the app syncs the
outbox, so nothing is lost.
- **ntfy (default)** — push metadata stays on your own infrastructure
(self-hosted ntfy recommended). This is the backend for truly private
communication.
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push
metadata (notification title, device token) is routed through **Google's
servers**. If you want truly private communication, use ntfy instead.
Setup: [`docs/install.md`](docs/install.md); details:
[`docs/08-push.md`](docs/08-push.md).
## Install the gateway
Three commands on the machine where hermes runs:
```bash
cd hermes-agent && uv sync # 1. hermes with its venv (separate project, not this repo)
# 2. install the Iris plugin — the #gateway-plugin suffix points the
# installer at the plugin subfolder of this monorepo
hermes plugins install git@gitea.zephyre.one:ARIA/iris_x_hermes.git#gateway-plugin
# 3. generate the pairing token + server URL, then run the gateway
hermes gateway setup
hermes gateway
```
`hermes gateway setup` prints the **server URL** and **pairing token / QR**
the app needs on its Connect screen.
All options (LAN binding, TLS, push, device allowlist) and the full app
pairing walkthrough: [`docs/install.md`](docs/install.md).
## Build from source ## Build from source
### Prerequisites ### Prerequisites
| Where | You need | | Where | You need |
|---|---| | --- | --- |
| Gateway host | [hermes-agent](https://github.com/NousResearch/hermes-agent) with its venv (`uv sync`) | | Gateway host | [hermes-agent](https://github.com/NousResearch/hermes-agent) with its venv (`uv sync`) |
| Android build machine | JDK 17, Android SDK (`sdk.dir` in `app/local.properties` or `ANDROID_HOME`), ADB with a connected device | | Android build machine | JDK 17, Android SDK (`sdk.dir` in `app/local.properties` or `ANDROID_HOME`), ADB with a connected device |
| Desktop build machine | JDK 17 only | | Desktop build machine | JDK 17 only |
@@ -52,19 +94,21 @@ No system Gradle needed — both apps use the project wrapper (`./gradlew`).
### 1. Gateway (on the gateway host) ### 1. Gateway (on the gateway host)
If you're developing from a checkout, skip `hermes plugins install` and
symlink the plugin so it always tracks your working tree:
```bash ```bash
# hermes-agent is a separate project (not part of this repo)
cd hermes-agent && uv sync cd hermes-agent && uv sync
# install the Iris plugin into the live hermes home
mkdir -p ~/.hermes/plugins mkdir -p ~/.hermes/plugins
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
hermes gateway status # should list "android" hermes gateway status # should list the Iris platform
hermes gateway setup # generates ANDROID_TOKEN, prints the server URL hermes gateway setup # generates IRIS_TOKEN, prints server URL + pairing QR
hermes gateway # run the gateway hermes gateway # run the gateway
``` ```
(Otherwise see [Install the gateway](#install-the-gateway) above.)
### 2. Android app ### 2. Android app
```bash ```bash
@@ -86,21 +130,22 @@ cd app
On the app's **Connect** screen: On the app's **Connect** screen:
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (printed by `hermes gateway setup`). 1. **Server URL** — `http://<gateway-ip>:8791` (printed by `hermes gateway setup`).
2. **Pairing token** — from the setup output, or `ANDROID_TOKEN` in `~/.hermes/.env` 2. **Pairing token** — from the setup output, or `IRIS_TOKEN` in `~/.hermes/.env`
on the gateway host. on the gateway host.
3. **Test & Connect.** 3. **Test & Connect.**
Notes: Notes:
- The app has **no QR scanner** — pairing is manual URL + token entry. - **Android** has a **Scan QR** button that reads the QR printed by
- The default bind is `127.0.0.1` (desktop on the same machine only). For a phone `hermes gateway setup` and pre-fills URL + token; desktop uses manual entry.
on the LAN, set `ANDROID_WS_HOST` to the gateway's LAN IP. - The default bind is `127.0.0.1` (desktop on the same machine only). For a
- Remote access: Tailscale/WireGuard, or a reverse proxy with CA-signed WSS phone on the LAN, set `IRIS_HTTP_HOST` to the gateway's LAN IP.
(`ANDROID_WS_CERT` / `ANDROID_WS_KEY`). - Remote access: Tailscale/WireGuard, or a reverse proxy/tunnel with TLS
(`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`).
Full walkthrough, push setup (FCM/ntfy), and troubleshooting: Full walkthrough, push setup (ntfy/FCM), TLS, and troubleshooting:
[`docs/setup.md`](docs/setup.md). [`docs/install.md`](docs/install.md).
## Contributing ## Contributing
@@ -112,7 +157,7 @@ Contributions are welcome! Before you start:
2. **Know the layout.** 2. **Know the layout.**
| Path | What | | Path | What |
|---|---| | --- | --- |
| `gateway-plugin/` | Python hermes platform plugin (`android`); `protocol.py` is the frame source of truth | | `gateway-plugin/` | Python hermes platform plugin (`android`); `protocol.py` is the frame source of truth |
| `app/shared` | KMP module with most of the client code (shared by Android + Desktop) | | `app/shared` | KMP module with most of the client code (shared by Android + Desktop) |
| `app/androidApp` | Thin Android shell (package `dev.iris.app`) | | `app/androidApp` | Thin Android shell (package `dev.iris.app`) |
@@ -8,7 +8,16 @@
<!-- M5: ntfy listener foreground service (dataSync type on API 34). --> <!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- QR pairing (docs/20): the in-app scanner reads the camera. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- M-3: cleartext (http://) is required because the gateway is a LAN host
addressed by IP (e.g. 192.168.x.x), not a domain. Android's
network_security_config can only scope cleartext to domain names, not
IP ranges, so a per-host allowlist isn't possible for this use case.
The token is still required for auth; traffic is only ever sent to the
user-configured gateway on the local network. -->
<application <application
android:label="Iris" android:label="Iris"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -38,8 +47,22 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="iris" android:host="chat" /> <data android:scheme="iris" android:host="chat" />
</intent-filter> </intent-filter>
<!-- QR pairing (docs/20): iris://pair deep link (scanned QR / link). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="iris" android:host="pair" />
</intent-filter>
</activity> </activity>
<!-- QR pairing (docs/20): full-screen scanner launched from the
Connect screen. Not exported; started only by our own app. -->
<activity
android:name="iris.platform.QrScanActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). --> <!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -16,11 +16,16 @@ import iris.platform.AndroidEnv
import iris.platform.AndroidSecureStore import iris.platform.AndroidSecureStore
import iris.platform.AppBridge import iris.platform.AppBridge
import iris.platform.syncNtfyListener import iris.platform.syncNtfyListener
import iris.util.PairLink
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val deepLinkChatId = mutableStateOf<String?>(null) private val deepLinkChatId = mutableStateOf<String?>(null)
private val deepLinkThreadId = mutableStateOf<String?>(null) private val deepLinkThreadId = mutableStateOf<String?>(null)
// QR pairing (docs/20): a parsed iris://pair link to prefill the Connect
// screen with.
private val pendingPair = mutableStateOf<PairLink?>(null)
private val notificationPermission = private val notificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ } registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
@@ -37,7 +42,13 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
val chatId by deepLinkChatId val chatId by deepLinkChatId
val threadId by deepLinkThreadId val threadId by deepLinkThreadId
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId) val pair by pendingPair
IrisApp(
store = store,
deepLinkChatId = chatId,
deepLinkThreadId = threadId,
deepLinkPair = pair,
)
} }
} }
@@ -64,6 +75,14 @@ class MainActivity : ComponentActivity() {
* extras or an iris://chat/<id>?thread=<tid> URI). */ * extras or an iris://chat/<id>?thread=<tid> URI). */
private fun handleDeepLink(intent: Intent?) { private fun handleDeepLink(intent: Intent?) {
val data = intent?.data val data = intent?.data
// QR pairing (docs/20): iris://pair?host=…&port=…&secure=…&token=… —
// the whole payload is in the query string; parse it with the shared
// PairLink parser.
if (data?.scheme == "iris" && data.host == "pair") {
val link = PairLink.parse(data.toString())
if (link != null) pendingPair.value = link
return
}
val chatId = val chatId =
intent?.getStringExtra("chat_id") intent?.getStringExtra("chat_id")
?: data?.pathSegments?.firstOrNull() ?: data?.pathSegments?.firstOrNull()
@@ -19,9 +19,12 @@ import iris.IrisApp
import iris.net.GatewayClient import iris.net.GatewayClient
import iris.platform.DesktopBridge import iris.platform.DesktopBridge
import iris.platform.DesktopSecureStore import iris.platform.DesktopSecureStore
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.Color import java.awt.Color
import java.awt.Graphics2D import java.awt.Graphics2D
import javax.imageio.ImageIO
import java.awt.RenderingHints import java.awt.RenderingHints
import java.awt.SystemTray import java.awt.SystemTray
import java.awt.event.WindowEvent import java.awt.event.WindowEvent
@@ -29,10 +32,7 @@ import java.awt.event.WindowFocusListener
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.File import java.io.File
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay import javax.imageio.ImageIO
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** /**
* M6: desktop shell (docs/11 §11.2/§11.3). * M6: desktop shell (docs/11 §11.2/§11.3).
@@ -48,6 +48,7 @@ private val windowJson = File(System.getProperty("user.home"), ".iris/window.jso
// Window/taskbar icon (src/main/resources/icon.png). // Window/taskbar icon (src/main/resources/icon.png).
private class IrisDesktop private class IrisDesktop
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap() private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range. // Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
@@ -87,14 +88,36 @@ fun main() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
delay(2_000) delay(2_000)
val state = DesktopBridge.controller?.client?.state?.value val state =
val (color, tooltip) = when (state) { DesktopBridge.controller
?.client
?.state
?.value
val (color, tooltip) =
when (state) {
null, null,
is GatewayClient.State.Disconnected -> TRAY_OFFLINE to "Iris — offline" is GatewayClient.State.Disconnected,
-> {
TRAY_OFFLINE to "Iris — offline"
}
is GatewayClient.State.Connecting, is GatewayClient.State.Connecting,
is GatewayClient.State.Reconnecting -> TRAY_CONNECTING to "Iris — connecting…" is GatewayClient.State.Reconnecting,
is GatewayClient.State.Connected -> TRAY_CONNECTED to "Iris — connected" -> {
is GatewayClient.State.AuthFailed -> TRAY_AUTH_FAILED to "Iris — auth failed" TRAY_CONNECTING to "Iris — connecting…"
}
is GatewayClient.State.Connected -> {
TRAY_CONNECTED to "Iris — connected"
}
is GatewayClient.State.AuthFailed -> {
TRAY_AUTH_FAILED to "Iris — auth failed"
}
is GatewayClient.State.TlsConfirmRequired -> {
TRAY_AUTH_FAILED to "Iris — gateway certificate needs confirmation"
}
} }
trayColor = color trayColor = color
trayTooltip = tooltip trayTooltip = tooltip
@@ -126,7 +149,8 @@ fun main() {
state = loadWindowState(), state = loadWindowState(),
) { ) {
composeWindow = window composeWindow = window
window.addWindowFocusListener(object : WindowFocusListener { window.addWindowFocusListener(
object : WindowFocusListener {
override fun windowGainedFocus(e: WindowEvent) { override fun windowGainedFocus(e: WindowEvent) {
DesktopBridge.foreground = true DesktopBridge.foreground = true
} }
@@ -134,7 +158,8 @@ fun main() {
override fun windowLostFocus(e: WindowEvent) { override fun windowLostFocus(e: WindowEvent) {
DesktopBridge.foreground = false DesktopBridge.foreground = false
} }
}) },
)
IrisApp(store) IrisApp(store)
} }
} }
+6 -2
View File
@@ -2,8 +2,12 @@ org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
# Desktop targets the Java 21 runtime (Markdown renderer 0.44.0 is # Desktop targets the Java 21 runtime (Markdown renderer 0.44.0 is
# Java-21 bytecode). AGP is JDK-21-compatible, so the Android build is # Java-21 bytecode). AGP is JDK-21-compatible, so the Android build is
# unaffected (its bytecode target stays JVM 17 via minSdk/jvmTarget). # unaffected (its bytecode target stays JVM 17 via minSdk/jvmTarget).
# Point this at your local JDK 21 if the path differs. #
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk # The JDK-21 home is machine-specific, so it is NOT hardcoded here (a
# committed path would break every other checkout). Set it per machine via
# one of:
# - ~/.gradle/gradle.properties -> org.gradle.java.home=/path/to/jdk21
# - or export JAVA_HOME=/path/to/jdk21 before running ./gradlew
org.gradle.caching=true org.gradle.caching=true
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
+10
View File
@@ -29,6 +29,9 @@ val kcefVersion = "2025.03.23"
val markdownVersion = "0.44.0" val markdownVersion = "0.44.0"
// Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16). // Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16).
val sqldelightVersion = "2.3.2" val sqldelightVersion = "2.3.2"
// CameraX + ML Kit for in-app QR pairing (docs/20). Android-only.
val cameraxVersion = "1.5.1"
val mlKitVersion = "16.1.1"
kotlin { kotlin {
android { android {
@@ -111,6 +114,13 @@ kotlin {
implementation("androidx.media3:media3-ui:1.11.0") implementation("androidx.media3:media3-ui:1.11.0")
// SAF picker (rememberLauncherForActivityResult). // SAF picker (rememberLauncherForActivityResult).
implementation("androidx.activity:activity-compose:1.13.0") implementation("androidx.activity:activity-compose:1.13.0")
// CameraX + ML Kit for QR pairing (docs/20): the scanner activity
// uses the camera2 CameraX backend and ML Kit's barcode model.
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-view:$cameraxVersion")
implementation("com.google.mlkit:barcode-scanning:$mlKitVersion")
// M5: FCM push (inert without a Firebase project / google-services.json; // M5: FCM push (inert without a Firebase project / google-services.json;
// the ntfy listener is the fallback). The google-services plugin is // the ntfy listener is the fallback). The google-services plugin is
// applied conditionally in the app module. // applied conditionally in the app module.
@@ -1,6 +1,7 @@
package iris.platform package iris.platform
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -15,7 +16,9 @@ actual fun setActiveController(controller: Any?) {
actual fun syncNtfyListener(backend: String) { actual fun syncNtfyListener(backend: String) {
val context = AndroidEnv.context val context = AndroidEnv.context
val intent = Intent(context, NtfyListenerService::class.java) val intent = Intent(context, NtfyListenerService::class.java)
val store = AndroidSecureStore(context) // L-18: reuse one AndroidSecureStore instead of rebuilding it (and
// re-running EncryptedSharedPreferences.create + migration) on every call.
val store = ntfyStore(context)
if (backend == "ntfy" && store.ntfyTopic.isNotBlank()) { if (backend == "ntfy" && store.ntfyTopic.isNotBlank()) {
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} else { } else {
@@ -25,6 +28,18 @@ actual fun syncNtfyListener(backend: String) {
} }
} }
private val ntfyStoreLock = Any()
@Volatile
private var cachedNtfyStore: AndroidSecureStore? = null
private fun ntfyStore(context: Context): AndroidSecureStore {
cachedNtfyStore?.let { return it }
return synchronized(ntfyStoreLock) {
cachedNtfyStore ?: AndroidSecureStore(context).also { cachedNtfyStore = it }
}
}
actual fun postSystemNotification( actual fun postSystemNotification(
chatId: String?, chatId: String?,
chatName: String?, chatName: String?,
@@ -33,7 +48,7 @@ actual fun postSystemNotification(
threadId: String?, threadId: String?,
) { ) {
val context = AndroidEnv.context val context = AndroidEnv.context
val id = chatId ?: "android:default" val id = chatId ?: "default"
// POST_NOTIFICATIONS is a runtime permission on API 33+. // POST_NOTIFICATIONS is a runtime permission on API 33+.
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED != PackageManager.PERMISSION_GRANTED
@@ -76,6 +76,14 @@ class AndroidSecureStore(
get() = prefs.getString(KEY_TOKEN, "").orEmpty() get() = prefs.getString(KEY_TOKEN, "").orEmpty()
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply() set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
override var deviceToken: String
get() = prefs.getString(KEY_DEVICE_TOKEN, "").orEmpty()
set(value) = prefs.edit().putString(KEY_DEVICE_TOKEN, value.trim()).apply()
override var pinnedCertFingerprint: String
get() = prefs.getString(KEY_PINNED_CERT, "").orEmpty()
set(value) = prefs.edit().putString(KEY_PINNED_CERT, value.trim()).apply()
override val deviceId: String override val deviceId: String
get() { get() {
var id = prefs.getString(KEY_DEVICE_ID, null) var id = prefs.getString(KEY_DEVICE_ID, null)
@@ -176,13 +184,28 @@ class AndroidSecureStore(
) { ) {
serverUrl = url serverUrl = url
this.token = token this.token = token
// A (re-)pair may target a different gateway: the old per-device
// token is dead there. The next hello.ack re-mints/returns it.
deviceToken = ""
} }
override fun clear() { override fun clear() {
// M-10: clearing pairing must also wipe the device identity + push
// state, otherwise a re-pair to a different gateway would keep the old
// deviceId/syncCursor/ntfyTopic and the server would treat the new
// pairing as the same device.
prefs prefs
.edit() .edit()
.remove(KEY_URL) .remove(KEY_URL)
.remove(KEY_TOKEN) .remove(KEY_TOKEN)
.remove(KEY_DEVICE_TOKEN)
.remove(KEY_DEVICE_ID)
.remove(KEY_SYNC_CURSOR)
.remove(KEY_FCM_TOKEN)
.remove(KEY_NTFY_TOPIC)
.remove(KEY_NTFY_SERVER)
.remove(KEY_PUSH_BACKEND)
.remove(KEY_PINNED_CERT)
.apply() .apply()
} }
@@ -191,12 +214,14 @@ class AndroidSecureStore(
const val SECURE_PREFS_NAME = "iris_secure" const val SECURE_PREFS_NAME = "iris_secure"
const val KEY_URL = "server_url" const val KEY_URL = "server_url"
const val KEY_TOKEN = "token" const val KEY_TOKEN = "token"
const val KEY_DEVICE_TOKEN = "device_token"
const val KEY_DEVICE_ID = "device_id" const val KEY_DEVICE_ID = "device_id"
const val KEY_SYNC_CURSOR = "sync_cursor" const val KEY_SYNC_CURSOR = "sync_cursor"
const val KEY_FCM_TOKEN = "fcm_token" const val KEY_FCM_TOKEN = "fcm_token"
const val KEY_NTFY_TOPIC = "ntfy_topic" const val KEY_NTFY_TOPIC = "ntfy_topic"
const val KEY_NTFY_SERVER = "ntfy_server" const val KEY_NTFY_SERVER = "ntfy_server"
const val KEY_PUSH_BACKEND = "push_backend" const val KEY_PUSH_BACKEND = "push_backend"
const val KEY_PINNED_CERT = "pinned_cert_fingerprint"
const val KEY_THREADS_ENABLED = "threads_enabled" const val KEY_THREADS_ENABLED = "threads_enabled"
const val KEY_TOOL_DETAIL = "tool_detail" const val KEY_TOOL_DETAIL = "tool_detail"
const val KEY_STREAMING_ENABLED = "streaming_enabled" const val KEY_STREAMING_ENABLED = "streaming_enabled"
@@ -22,7 +22,10 @@ import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/" private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/"
@Composable @Composable
actual fun PlatformWebView(html: String, modifier: Modifier) { actual fun PlatformWebView(
html: String,
modifier: Modifier,
) {
val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL) val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL)
state.webSettings.androidWebSettings.domStorageEnabled = true state.webSettings.androidWebSettings.domStorageEnabled = true
WebView(state, modifier = modifier) WebView(state, modifier = modifier)
@@ -33,16 +36,18 @@ actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val currentOnBack = rememberUpdatedState(onBack) val currentOnBack = rememberUpdatedState(onBack)
DisposableEffect(dispatcher) { DisposableEffect(dispatcher) {
if (dispatcher != null) { val callback =
val callback = object : OnBackPressedCallback(true) { dispatcher?.let { d ->
val c =
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
currentOnBack.value() currentOnBack.value()
} }
} }
dispatcher.addCallback(callback) d.addCallback(c)
onDispose { callback.remove() } c
} }
onDispose { } onDispose { callback?.remove() }
} }
return this return this
} }
@@ -14,7 +14,14 @@ object AppBridge {
@Volatile @Volatile
var controller: IrisController? = null var controller: IrisController? = null
/** True while the launcher activity is resumed (set by MainActivity). */ /** True while the launcher activity is resumed (set by MainActivity).
* A change is forwarded to the controller (M8: unread clear on focus). */
@Volatile @Volatile
var foreground: Boolean = false var foreground: Boolean = false
set(value) {
if (field != value) {
field = value
controller?.setForeground(value)
}
}
} }
@@ -11,9 +11,9 @@ import iris.net.GatewayClient
* *
* - [onNewToken]: persist the rotated token and push it to the server via * - [onNewToken]: persist the rotated token and push it to the server via
* `fcm.register` (so the next push targets the current token). * `fcm.register` (so the next push targets the current token).
* - [onMessageReceived]: the data payload drives a silent sync. When the app * - [onMessageReceived]: posts a system notification from the data payload.
* is foregrounded the SSE path already delivered the frame (in-app banner), * When the app is foregrounded the SSE path already delivered the frame
* so we only post a system notification when backgrounded. * (in-app banner), so we only post a notification when backgrounded.
* *
* Inert without a Firebase project (no google-services.json): the service is * Inert without a Firebase project (no google-services.json): the service is
* declared in the manifest but never receives messages, and the app falls * declared in the manifest but never receives messages, and the app falls
@@ -53,7 +53,7 @@ class IrisFirebaseMessagingService : FirebaseMessagingService() {
// exception: the app must display them itself. // exception: the app must display them itself.
if (message.notification != null) return if (message.notification != null) return
val data = message.data val data = message.data
val chatId = data["chat_id"] ?: "android:default" val chatId = data["chat_id"] ?: "default"
val threadId = data["thread_id"] val threadId = data["thread_id"]
val title = data["title"] ?: "Iris" val title = data["title"] ?: "Iris"
val body = data["body"] ?: data["title"].orEmpty() val body = data["body"] ?: data["title"].orEmpty()
@@ -5,7 +5,6 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
/** /**
@@ -21,15 +20,22 @@ object IrisNotifications {
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT" const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
private const val NOTIF_ID_BASE = 1_000_000 private const val NOTIF_ID_BASE = 1_000_000
fun ensureChannel(context: Context, chatId: String, chatName: String? = null) { // L-31: notification channel ids are capped at 64 chars (Android limit) and
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return // are user-visible, so a long server-provided chatId must be truncated.
private fun channelIdFor(chatId: String): String = (CHANNEL_PREFIX + chatId).take(64)
fun ensureChannel(
context: Context,
chatId: String,
chatName: String? = null,
) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val id = CHANNEL_PREFIX + chatId val id = channelIdFor(chatId)
val name = chatName ?: chatId val name = chatName ?: chatId
if (nm.getNotificationChannel(id) == null) { if (nm.getNotificationChannel(id) == null) {
nm.createNotificationChannel( nm.createNotificationChannel(
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT) NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = "Iris messages for $name" } .apply { description = "Iris messages for $name" },
) )
} }
} }
@@ -43,14 +49,18 @@ object IrisNotifications {
threadId: String?, threadId: String?,
) { ) {
ensureChannel(context, chatId, chatName) ensureChannel(context, chatId, chatName)
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff) // L-32: 24-bit hash (was 16-bit) to reduce the chance two chatIds map
val intent = Intent(ACTION_OPEN_CHAT).apply { // to the same notification id and clobber each other.
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffffff)
val intent =
Intent(ACTION_OPEN_CHAT).apply {
setPackage(context.packageName) setPackage(context.packageName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("chat_id", chatId) putExtra("chat_id", chatId)
if (threadId != null) putExtra("thread_id", threadId) if (threadId != null) putExtra("thread_id", threadId)
} }
val pi = PendingIntent.getActivity( val pi =
PendingIntent.getActivity(
context, context,
id, id,
intent, intent,
@@ -59,7 +69,8 @@ object IrisNotifications {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify( nm.notify(
id, id,
NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId) NotificationCompat
.Builder(context, channelIdFor(chatId))
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
@@ -11,11 +11,13 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import iris.protocol.IrisJson import iris.protocol.IrisJson
import iris.util.IrisLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@@ -28,18 +30,31 @@ import java.util.concurrent.TimeUnit
* *
* A foreground service that subscribes to this device's ntfy topic and posts * A foreground service that subscribes to this device's ntfy topic and posts
* a system notification for each push. The structured payload rides in the * a system notification for each push. The structured payload rides in the
* `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body * `X-Data` SSE field (JSON: chat_id, kind, cursor, thread_id); the message
* is the short preview. When the app is foregrounded the WS path already * body is the short preview. When the app is foregrounded the SSE path
* delivered the frame, so the service skips posting to avoid a duplicate. * already delivered the frame, so the service skips posting to avoid a
* duplicate.
*
* The stream is reconnected with capped exponential backoff when it drops
* (EOF, network error, or a non-2xx response) — `START_STICKY` alone only
* restarts the service after process death, not after a failed read.
*/ */
class NtfyListenerService : Service() { class NtfyListenerService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var streamJob: Job? = null private var streamJob: Job? = null
private val client = OkHttpClient.Builder() private val client =
.readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream OkHttpClient
.Builder()
// ntfy sends keep-alive comments every ~10 s; a 60 s read timeout
// detects a half-open connection instead of hanging forever.
.readTimeout(60, TimeUnit.SECONDS)
.build() .build()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
startForeground(NOTIF_ID, foregroundNotification()) startForeground(NOTIF_ID, foregroundNotification())
streamJob?.cancel() streamJob?.cancel()
streamJob = scope.launch { stream() } streamJob = scope.launch { stream() }
@@ -60,24 +75,56 @@ class NtfyListenerService : Service() {
if (topic.isBlank()) return if (topic.isBlank()) return
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/") val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
val url = "$server/$topic" val url = "$server/$topic"
val request = Request.Builder() val request =
Request
.Builder()
.url(url) .url(url)
.header("Accept", "text/event-stream") .header("Accept", "text/event-stream")
.build() .build()
var backoff = 1_000L
while (true) {
try { try {
client.newCall(request).execute().use { resp -> client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return if (!resp.isSuccessful) {
IrisLog.w("ntfy stream HTTP ${resp.code}")
} else {
val body = resp.body ?: return val body = resp.body ?: return
val source = body.source() readEvents(body.source())
}
}
} catch (e: Exception) {
IrisLog.w("ntfy stream dropped: ${e.message}")
}
// Stream ended (EOF, error, or non-2xx): back off and reconnect.
delay(backoff)
backoff = (backoff * 2).coerceAtMost(30_000L)
}
}
/**
* Read ntfy SSE events until EOF. `readUtf8Line()` returns null at EOF;
* do NOT use `source.exhausted()` here — it reads until EOF and would
* block forever on a live stream.
*/
private fun readEvents(source: okio.BufferedSource) {
var data: String? = null var data: String? = null
var title: String? = null var title: String? = null
var msgBody: String? = null var msgBody: String? = null
while (!source.exhausted()) { while (true) {
val line = source.readUtf8Line() ?: break val line = source.readUtf8Line() ?: break
when { when {
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim() line.startsWith("X-Data:") -> {
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim() data = line.removePrefix("X-Data:").trim()
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim() }
line.startsWith("X-Title:") -> {
title = line.removePrefix("X-Title:").trim()
}
line.startsWith("data:") -> {
msgBody = line.removePrefix("data:").trim()
}
line.isEmpty() -> { line.isEmpty() -> {
// Event boundary: process the accumulated message. // Event boundary: process the accumulated message.
data?.let { handleData(it, title, msgBody) } data?.let { handleData(it, title, msgBody) }
@@ -88,19 +135,19 @@ class NtfyListenerService : Service() {
} }
} }
} }
} catch (_: Exception) {
// Stream dropped; the service is START_STICKY so the system
// restarts it. If it keeps failing, the WS path still works.
}
}
private fun handleData(dataJson: String, title: String?, msgBody: String?) { private fun handleData(
val data = try { dataJson: String,
title: String?,
msgBody: String?,
) {
val data =
try {
IrisJson.instance.decodeFromString<JsonObject>(dataJson) IrisJson.instance.decodeFromString<JsonObject>(dataJson)
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
val chatId = data?.str("chat_id") ?: "android:default" val chatId = data?.str("chat_id") ?: "default"
val threadId = data?.str("thread_id") val threadId = data?.str("thread_id")
// The short preview rides in the SSE `data:` field; fall back to the // The short preview rides in the SSE `data:` field; fall back to the
// X-Title, then a generic label. // X-Title, then a generic label.
@@ -126,11 +173,12 @@ class NtfyListenerService : Service() {
LISTENER_CHANNEL, LISTENER_CHANNEL,
"Iris push listener", "Iris push listener",
NotificationManager.IMPORTANCE_MIN, NotificationManager.IMPORTANCE_MIN,
) ),
) )
} }
} }
return NotificationCompat.Builder(context, LISTENER_CHANNEL) return NotificationCompat
.Builder(context, LISTENER_CHANNEL)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Iris") .setContentTitle("Iris")
.setContentText("Listening for messages") .setContentText("Listening for messages")
@@ -146,5 +194,4 @@ class NtfyListenerService : Service() {
} }
/** Read a string field from a JSON object (null when absent / not a string). */ /** Read a string field from a JSON object (null when absent / not a string). */
private fun JsonObject?.str(key: String): String? = private fun JsonObject?.str(key: String): String? = (this?.get(key) as? JsonPrimitive)?.content
(this?.get(key) as? JsonPrimitive)?.content
@@ -0,0 +1,49 @@
package iris.platform
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/**
* Android QR scanner (docs/20): launches [QrScanActivity] and returns the
* scanned text (or null on cancel) to [onResult].
*/
@Composable
actual fun QrScanButton(onResult: (String?) -> Unit) {
val context = LocalContext.current
val launcher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
val text =
if (result.resultCode == Activity.RESULT_OK) {
result.data?.getStringExtra(QrScanActivity.EXTRA_QR)
} else {
null
}
onResult(text)
}
Button(onClick = {
val activity = context.resolveActivity()
if (activity != null) {
launcher.launch(Intent(activity, QrScanActivity::class.java))
}
}) {
Text("Scan QR")
}
}
/** Walk a (possibly wrapped) context to the hosting [Activity], if any. */
private fun Context.resolveActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.resolveActivity()
else -> null
}
@@ -0,0 +1,136 @@
package iris.platform
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.ViewGroup
import androidx.activity.ComponentActivity
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
/**
* Full-screen QR scanner (docs/20). Launched from the Connect screen's
* "Scan QR" button; returns the raw QR text via [EXTRA_QR] on
* [Activity.RESULT_OK], or [Activity.RESULT_CANCELED] on back/cancel.
*
* Uses the camera2 CameraX backend + ML Kit's barcode model. The camera is
* stopped in [onDestroy].
*/
class QrScanActivity : ComponentActivity() {
companion object {
/** Intent extra carrying the scanned QR text. */
const val EXTRA_QR = "qr"
}
private lateinit var previewView: PreviewView
private var cameraProvider: ProcessCameraProvider? = null
private var settled = false
private val permissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) startCamera() else finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
previewView =
PreviewView(this).apply {
layoutParams =
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
setContentView(previewView)
onBackPressedDispatcher.addCallback(this) {
if (!settled) setResult(Activity.RESULT_CANCELED)
finish()
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
) {
startCamera()
} else {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
private fun startCamera() {
val future = ProcessCameraProvider.getInstance(this)
future.addListener(
{
val provider = future.get()
cameraProvider = provider
val preview =
Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val analyzer =
ImageAnalysis
.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
try {
provider.unbindAll()
provider.bindToLifecycle(
this,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analyzer,
)
} catch (e: Exception) {
// No usable camera (e.g. headless emulator): bail out.
finish()
return@addListener
}
analyzer.setAnalyzer(ContextCompat.getMainExecutor(this)) { proxy ->
analyzeImage(proxy)
}
},
ContextCompat.getMainExecutor(this),
)
}
private fun analyzeImage(proxy: ImageProxy) {
val mediaImage = proxy.image
if (mediaImage == null) {
proxy.close()
return
}
val inputImage = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees)
BarcodeScanning
.getClient()
.process(inputImage)
.addOnSuccessListener { barcodes ->
val text = barcodes.firstOrNull { it.rawValue != null }?.rawValue
if (text != null) finishWithResult(text)
}.addOnCompleteListener { proxy.close() }
}
private fun finishWithResult(text: String) {
if (settled) return
settled = true
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_QR, text))
finish()
}
override fun onDestroy() {
cameraProvider?.unbindAll()
super.onDestroy()
}
}
@@ -10,6 +10,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -26,19 +27,21 @@ import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme import iris.ui.theme.IrisTheme
import iris.ui.theme.LocalUserTheme import iris.ui.theme.LocalUserTheme
import iris.ui.theme.rememberBackgroundImage import iris.ui.theme.rememberBackgroundImage
import iris.util.PairLink
/** /**
* Root composable shared by the Android and Desktop shells. * Root composable shared by the Android and Desktop shells.
* *
* M1: routes between the Connect screen (unpaired / auth failed) and the * Routes between the Connect screen (unpaired / auth failed) and the main
* Chat screen (paired). Later milestones add the channel list, search, * app (paired), which hosts the channel list, chat, search, settings, and
* settings, and media (docs/10-android-app.md). * media (docs/10-android-app.md).
*/ */
@Composable @Composable
fun IrisApp( fun IrisApp(
store: SecureStore, store: SecureStore,
deepLinkChatId: String? = null, deepLinkChatId: String? = null,
deepLinkThreadId: String? = null, deepLinkThreadId: String? = null,
deepLinkPair: PairLink? = null,
) { ) {
val controller = remember(store) { IrisController(store) } val controller = remember(store) { IrisController(store) }
DisposableEffect(controller) { DisposableEffect(controller) {
@@ -66,7 +69,8 @@ fun IrisApp(
// untouched, so the UI keeps its proportions at any size. // untouched, so the UI keeps its proportions at any size.
CompositionLocalProvider( CompositionLocalProvider(
LocalUserTheme provides theme, LocalUserTheme provides theme,
LocalDensity provides Density( LocalDensity provides
Density(
density = baseDensity.density, density = baseDensity.density,
fontScale = baseDensity.fontScale * fontScale, fontScale = baseDensity.fontScale * fontScale,
), ),
@@ -95,20 +99,49 @@ fun IrisApp(
) )
} }
val s = state val s = state
// QR pairing (docs/20): an iris://pair deep link prefills
// the Connect screen, taking priority over stored creds.
val pairUrl = deepLinkPair?.url ?: store.serverUrl
val pairToken = deepLinkPair?.token ?: store.token
when (s) { when (s) {
GatewayClient.State.Disconnected -> GatewayClient.State.Disconnected -> {
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token) key(deepLinkPair) {
is GatewayClient.State.AuthFailed -> ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
}
}
is GatewayClient.State.AuthFailed -> {
key(deepLinkPair) {
ConnectScreen( ConnectScreen(
controller, controller,
prefillUrl = store.serverUrl, prefillUrl = pairUrl,
prefillToken = store.token, prefillToken = pairToken,
initialError = "Pairing rejected: ${s.message}", initialError = "Pairing rejected: ${s.message}",
) )
}
}
// TLS: the gateway's certificate is untrusted and not
// the pinned one (docs/09 §9.4) — ask the user to
// confirm its fingerprint (SSH host-key style).
is GatewayClient.State.TlsConfirmRequired -> {
key(deepLinkPair) {
ConnectScreen(
controller,
prefillUrl = pairUrl,
prefillToken = pairToken,
initialError = "Gateway certificate needs confirmation — verify its fingerprint on the gateway host, then confirm below.",
tlsFingerprint = s.fingerprint,
)
}
}
// Connecting / Reconnecting / Connected all render the chat; the header // Connecting / Reconnecting / Connected all render the chat; the header
// status bubble + connection banner show the link state // status bubble + connection banner show the link state
// without blocking the view (M7's full-screen spinner is gone). // without blocking the view (M7's full-screen spinner is gone).
else -> ChatScreen(controller) else -> {
ChatScreen(controller)
}
} }
// M9: full-screen HTML artifact preview (opened from an // M9: full-screen HTML artifact preview (opened from an
// artifact card in the chat); covers everything while open. // artifact card in the chat); covers everything while open.
@@ -71,6 +71,54 @@ class ChannelStore {
_channels.value.filter { it.chatId != p.chatId && it.parentChatId != p.chatId } _channels.value.filter { it.chatId != p.chatId && it.parentChatId != p.chatId }
} }
// ── Optimistic local updates (M-7) ────────────────────────────────────
//
// The gateway only broadcasts channel.created/renamed/deleted — there are
// no favorite/icon/automation/default events. So a toggle sent by THIS
// device would not update its own UI until a full channel.list re-fetch.
// These apply the change locally (optimistically); a later channel.list /
// hello.ack re-seed reconciles any divergence (e.g. a server rejection).
/** Toggle the cosmetic favorite flag locally. */
fun setFavorite(
chatId: String,
on: Boolean,
) = update(chatId) { it.copy(favorite = on) }
/** Toggle the automation flag locally. */
fun setAutomation(
chatId: String,
on: Boolean,
) = update(chatId) { it.copy(automation = on) }
/** Set the icon (base64) and/or avatar color locally; null clears a field. */
fun setIcon(
chatId: String,
icon: String?,
color: String?,
) = update(chatId) { it.copy(icon = icon, color = color) }
/** Make [chatId] the default channel locally (clearing the previous one). */
fun setDefault(chatId: String) {
_channels.value =
sorted(
_channels.value.map {
when {
it.chatId == chatId -> it.copy(isDefault = true)
it.isDefault -> it.copy(isDefault = false)
else -> it
}
},
)
}
private fun update(
chatId: String,
transform: (ChannelInfo) -> ChannelInfo,
) {
_channels.value = sorted(_channels.value.map { if (it.chatId == chatId) transform(it) else it })
}
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> = private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
list.sortedWith( list.sortedWith(
compareByDescending<ChannelInfo> { it.isDefault } compareByDescending<ChannelInfo> { it.isDefault }
@@ -36,7 +36,7 @@ class ChatDb(
* tool cards interleaved at their anchored position). */ * tool cards interleaved at their anchored position). */
fun loadLanes(): Map<String, List<ChatItem>> = fun loadLanes(): Map<String, List<ChatItem>> =
synchronized(lock) { synchronized(lock) {
val messages = linkedMapOf<String, MutableList<MessageItem>>() val messages = linkedMapOf<String, MutableList<ChatItem>>()
for (row in db.cacheQueries.allMessages().executeAsList()) { for (row in db.cacheQueries.allMessages().executeAsList()) {
val item = decodeMessage(row.payload) ?: continue val item = decodeMessage(row.payload) ?: continue
messages.getOrPut(row.lane) { mutableListOf() }.add(item) messages.getOrPut(row.lane) { mutableListOf() }.add(item)
@@ -96,6 +96,12 @@ class ChatDb(
is ToolItem -> { is ToolItem -> {
db.cacheQueries.upsertTool(lane, item.id, toolSeq++.toLong(), json.encodeToString(item)) db.cacheQueries.upsertTool(lane, item.id, toolSeq++.toLong(), json.encodeToString(item))
} }
// Picker cards ride in the message table (JSON
// payload; decodeMessage picks the type back out).
is PickerItem -> {
db.cacheQueries.upsertMessage(lane, item.id, item.ts, json.encodeToString(item))
}
} }
} }
} }
@@ -159,12 +165,18 @@ class ChatDb(
} }
} }
private fun decodeMessage(payload: String): MessageItem? = private fun decodeMessage(payload: String): ChatItem? =
try { try {
json.decodeFromString<MessageItem>(payload).sanitizeForRestore() json.decodeFromString<MessageItem>(payload).sanitizeForRestore()
} catch (_: Exception) {
// Not a message payload — a picker card (MessageItem requires
// "role", PickerItem requires "title": the two never cross-decode).
try {
json.decodeFromString<PickerItem>(payload)
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
}
private fun decodeTool(payload: String): ToolItem? = private fun decodeTool(payload: String): ToolItem? =
try { try {
@@ -8,6 +8,8 @@ import iris.protocol.MessagePayload
import iris.protocol.MessageStartPayload import iris.protocol.MessageStartPayload
import iris.protocol.MessageStopPayload import iris.protocol.MessageStopPayload
import iris.protocol.MessageUpdatePayload import iris.protocol.MessageUpdatePayload
import iris.protocol.PickerChoice
import iris.protocol.PickerChoicePayload
import iris.protocol.ROLE_ASSISTANT import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ROLE_USER import iris.protocol.ROLE_USER
import iris.protocol.RuntimeMeta import iris.protocol.RuntimeMeta
@@ -17,9 +19,13 @@ import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_START import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE import iris.protocol.TYPE_MESSAGE_UPDATE
import iris.protocol.TYPE_PICKER_CHOICE
import iris.protocol.TYPE_TODO_UPDATE
import iris.protocol.TYPE_TOOL_END import iris.protocol.TYPE_TOOL_END
import iris.protocol.TYPE_TOOL_PROGRESS import iris.protocol.TYPE_TOOL_PROGRESS
import iris.protocol.TYPE_TOOL_START import iris.protocol.TYPE_TOOL_START
import iris.protocol.TodoItem
import iris.protocol.TodoUpdatePayload
import iris.protocol.ToolEndPayload import iris.protocol.ToolEndPayload
import iris.protocol.ToolProgressPayload import iris.protocol.ToolProgressPayload
import iris.protocol.ToolStartPayload import iris.protocol.ToolStartPayload
@@ -103,6 +109,9 @@ data class ToolItem(
val name: String, val name: String,
val preview: String? = null, val preview: String? = null,
val args: JsonElement? = null, val args: JsonElement? = null,
/** Cosmetic per-tool glyph from the gateway (hermes get_tool_emoji);
* null for unknown tools / older frames — the UI falls back to 🔧. */
val emoji: String? = null,
val note: String? = null, val note: String? = null,
val done: Boolean = false, val done: Boolean = false,
val ok: Boolean = true, val ok: Boolean = true,
@@ -111,6 +120,20 @@ data class ToolItem(
val anchorId: String? = null, val anchorId: String? = null,
) : ChatItem ) : ChatItem
/** An interactive choice picker card (one tap → one value), sent by
* finite-choice slash commands (/reasoning, /fast, …) via `picker.choice`.
* [selected] is the value the user tapped (null = still pending); the
* server's reply arrives as a normal message afterwards.
* [Serializable]: persisted as a JSON payload in the local cache (ChatDb). */
@Serializable
data class PickerItem(
override val id: String, // picker_id (unique; the picker.select key)
val title: String,
val choices: List<PickerChoice> = emptyList(),
val ts: Long = 0,
val selected: String? = null,
) : ChatItem
class ChatStore { class ChatStore {
/** lane key -> chronological items. */ /** lane key -> chronological items. */
private val _lanes = MutableStateFlow<Map<String, List<ChatItem>>>(emptyMap()) private val _lanes = MutableStateFlow<Map<String, List<ChatItem>>>(emptyMap())
@@ -120,7 +143,25 @@ class ChatStore {
private val _currentLane = MutableStateFlow(DEFAULT_LANE) private val _currentLane = MutableStateFlow(DEFAULT_LANE)
val currentLane: StateFlow<String> = _currentLane.asStateFlow() val currentLane: StateFlow<String> = _currentLane.asStateFlow()
private var localSeq = 0 /** The agent's live todo list per lane (todo.update; last-write-wins).
* Ephemeral: not persisted — the gateway re-sends a snapshot when the
* app reconnects, and the next `todo` tool call refreshes it. */
private val _todos = MutableStateFlow<Map<String, List<TodoItem>>>(emptyMap())
val todos: StateFlow<Map<String, List<TodoItem>>> = _todos.asStateFlow()
/** Unread message count per lane (M8: unread indicator). Ephemeral
* (in-memory): a process death resets it, and the `sync` delta re-counts
* genuinely new messages on reconnect. A lane absent from the map has
* no unread messages. */
private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
/** Single lock for the lane/todo/unread maps: they are mutated from the
* UI thread (addPending via send) and the frame-collector thread
* (Dispatchers.Default). A non-atomic read-modify-write loses a frame
* that lands between the read and the write (e.g. a streaming delta
* dropped while the user sends). */
private val lock = Any()
/** When false, `message.start`/`message.update` frames are ignored and each /** When false, `message.start`/`message.update` frames are ignored and each
* reply materializes as a single final message on `message.stop` * reply materializes as a single final message on `message.stop`
@@ -129,15 +170,16 @@ class ChatStore {
var streamingEnabled: Boolean = true var streamingEnabled: Boolean = true
companion object { companion object {
const val DEFAULT_LANE = "android:default" const val DEFAULT_LANE = "default"
fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}" fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
} }
// ── Lane helpers ────────────────────────────────────────────────────── // ── Lane helpers ──────────────────────────────────────────────────────
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because /** Lane key for a (chat, thread) pair. Uses `::` as the separator so a
* chat ids already contain a single `:` (e.g. `android:chan_1`). */ * thread lane can never collide with a chat id (chat ids are direct,
* e.g. `chan_1`, and never contain `:`). */
fun laneKey( fun laneKey(
chatId: String, chatId: String,
threadId: String?, threadId: String?,
@@ -162,10 +204,27 @@ class ChatStore {
lane: String, lane: String,
transform: (List<ChatItem>) -> List<ChatItem>, transform: (List<ChatItem>) -> List<ChatItem>,
) { ) {
synchronized(lock) {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
map[lane] = transform(map[lane].orEmpty()) map[lane] = transform(map[lane].orEmpty())
_lanes.value = map _lanes.value = map
} }
}
/** Apply [transform] to every lane, writing back only when something
* changed. Callers must hold [lock]. */
private fun mapLanes(transform: (List<ChatItem>) -> List<ChatItem>) {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = transform(list)
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
// ── Optimistic send ─────────────────────────────────────────────────── // ── Optimistic send ───────────────────────────────────────────────────
@@ -193,8 +252,10 @@ class ChatStore {
lane: String, lane: String,
text: String, text: String,
) { ) {
localSeq++ // randomId (not a process-local seq): system messages are persisted,
val id = "sys_$localSeq" // and a seq that resets on restart would re-mint sys_0 and collide
// with the restored one (upsert overwrite).
val id = randomId("sys")
updateLane(lane) { updateLane(lane) {
it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true) it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true)
} }
@@ -213,8 +274,10 @@ class ChatStore {
TYPE_TOOL_START -> onToolStart(lane, frame) TYPE_TOOL_START -> onToolStart(lane, frame)
TYPE_TOOL_PROGRESS -> onToolProgress(lane, frame) TYPE_TOOL_PROGRESS -> onToolProgress(lane, frame)
TYPE_TOOL_END -> onToolEnd(lane, frame) TYPE_TOOL_END -> onToolEnd(lane, frame)
TYPE_TODO_UPDATE -> onTodoUpdate(lane, frame)
TYPE_COMMENTARY -> onCommentary(lane, frame) TYPE_COMMENTARY -> onCommentary(lane, frame)
TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame) TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame)
TYPE_PICKER_CHOICE -> onPickerChoice(lane, frame)
else -> Unit else -> Unit
} }
} }
@@ -315,6 +378,7 @@ class ChatStore {
val (chatId, threadId) = parseLane(lane) val (chatId, threadId) = parseLane(lane)
if (threadId == null) return if (threadId == null) return
val flatLane = chatId val flatLane = chatId
synchronized(lock) {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
val flatList = map[flatLane].orEmpty() val flatList = map[flatLane].orEmpty()
val idx = val idx =
@@ -322,10 +386,11 @@ class ChatStore {
it is MessageItem && it.role == ROLE_USER && it.text == p.text && it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
(it.pending || it.status == MsgStatus.Failed) (it.pending || it.status == MsgStatus.Failed)
} }
if (idx < 0) return if (idx < 0) return@synchronized
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) } map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
_lanes.value = map _lanes.value = map
} }
}
/** Merge server media refs into existing items, keeping local paths. */ /** Merge server media refs into existing items, keeping local paths. */
private fun mergeMedia( private fun mergeMedia(
@@ -450,6 +515,7 @@ class ChatStore {
name = p.name, name = p.name,
preview = p.preview, preview = p.preview,
args = p.args, args = p.args,
emoji = p.emoji,
anchorId = anchorId, anchorId = anchorId,
) )
} }
@@ -493,6 +559,20 @@ class ChatStore {
} }
} }
// ── todo.update (the agent's live todo list, last-write-wins) ─────────
private fun onTodoUpdate(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<TodoUpdatePayload>() ?: return
synchronized(lock) {
val map = _todos.value.toMutableMap()
if (p.todos.isEmpty()) map.remove(lane) else map[lane] = p.todos
_todos.value = map
}
}
// ── commentary (dimmed interim beat) ────────────────────────────────── // ── commentary (dimmed interim beat) ──────────────────────────────────
private fun onCommentary( private fun onCommentary(
@@ -571,10 +651,8 @@ class ChatStore {
mediaId: String, mediaId: String,
localPath: String, localPath: String,
) { ) {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) {
val updated =
list.map { item -> list.map { item ->
if (item is MessageItem) { if (item is MessageItem) {
item.copy( item.copy(
@@ -587,20 +665,84 @@ class ChatStore {
item item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
// ── picker.choice (interactive slash-command menu) ──────────────────────
/**
* Append a choice-picker card to [lane]. Idempotent by picker id: the
* frame is outboxed, so a sync replay of an already-rendered picker is a
* no-op (a locally resolved card is never re-opened by a replay).
*/
private fun onPickerChoice(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<PickerChoicePayload>() ?: return
updateLane(lane) { list ->
if (list.any { it.id == p.pickerId }) {
list
} else {
list +
PickerItem(
id = p.pickerId,
title = p.title,
choices = p.choices,
ts = nowMillis(),
)
}
}
}
/** Mark the picker [pickerId] as answered with [value] (all lanes; the
* picker id is unique). Optimistic: the server's reply message follows
* as a normal message; an expired picker (gateway restart) simply never
* replies. */
fun resolvePicker(
pickerId: String,
value: String,
) {
synchronized(lock) {
mapLanes { list ->
list.map { item ->
if (item is PickerItem && item.id == pickerId && item.selected == null) {
item.copy(selected = value)
} else {
item
}
}
}
}
}
/** M8: a new message arrived in [lane] that the user hasn't seen —
* increment its unread count. */
fun markUnread(lane: String) {
synchronized(lock) {
val map = _unread.value.toMutableMap()
map[lane] = (map[lane] ?: 0) + 1
_unread.value = map
}
}
/** M8: the user is now viewing [lane]'s newest content — clear its unread
* count. Idempotent (a lane with no unread is a no-op). */
fun markLaneRead(lane: String) {
synchronized(lock) {
val map = _unread.value.toMutableMap()
if (map.remove(lane) != null) _unread.value = map
}
}
/** M8: unread count for a single lane (0 when none). */
fun unreadFor(lane: String): Int = _unread.value[lane] ?: 0
/** M5: mark the user message [messageId] as read (read.receipt). */ /** M5: mark the user message [messageId] as read (read.receipt). */
fun markRead(messageId: String) { fun markRead(messageId: String) {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) {
val updated =
list.map { item -> list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER && if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Read item.status != MsgStatus.Read
@@ -610,22 +752,16 @@ class ChatStore {
item item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
/** M7: mark a single user message as failed (the send never reached the /** M7: mark a single user message as failed (the send never reached the
* gateway — network drop, or the gateway rejected it); tap the bubble * gateway — network drop, or the gateway rejected it); tap the bubble
* to retry. */ * to retry. */
fun failMessage(messageId: String) { fun failMessage(messageId: String) {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) {
val updated =
list.map { item -> list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER && if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Failed item.status != MsgStatus.Failed
@@ -635,12 +771,8 @@ class ChatStore {
item item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
/** /**
@@ -651,17 +783,10 @@ class ChatStore {
*/ */
fun removeMessages(messageIds: Set<String>) { fun removeMessages(messageIds: Set<String>) {
if (messageIds.isEmpty()) return if (messageIds.isEmpty()) return
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list -> list.filterNot { it.id in messageIds } }
for ((lane, list) in map) {
val updated = list.filterNot { it.id in messageIds }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
}
/** /**
* Finalize in-flight items after the gateway goes away (restart / network * Finalize in-flight items after the gateway goes away (restart / network
@@ -671,30 +796,23 @@ class ChatStore {
* message.stop) will never arrive to close them. * message.stop) will never arrive to close them.
*/ */
fun finalizeInterrupted() { fun finalizeInterrupted() {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) {
val updated =
list.map { item -> list.map { item ->
when (item) { when (item) {
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
is MessageItem -> if (item.streaming) item.copy(streaming = false) else item is MessageItem -> if (item.streaming) item.copy(streaming = false) else item
is PickerItem -> item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
/** M7: mark all pending user messages as failed (gateway error frame). */ /** M7: mark all pending user messages as failed (gateway error frame). */
fun failPending() { fun failPending() {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) {
val updated =
list.map { item -> list.map { item ->
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) { if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
item.copy(pending = false, status = MsgStatus.Failed) item.copy(pending = false, status = MsgStatus.Failed)
@@ -702,12 +820,8 @@ class ChatStore {
item item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
/** M7: re-arm a failed user message for a retry send. */ /** M7: re-arm a failed user message for a retry send. */
@@ -783,6 +897,8 @@ class ChatStore {
// A resolved ts of 0 means the anchor itself is ts-less // A resolved ts of 0 means the anchor itself is ts-less
// (lane start) — sort with it (end) instead of to the top. // (lane start) — sort with it (end) instead of to the top.
is ToolItem -> tsOf[item.anchorId]?.takeIf { it > 0 } ?: Long.MAX_VALUE is ToolItem -> tsOf[item.anchorId]?.takeIf { it > 0 } ?: Long.MAX_VALUE
is PickerItem -> item.ts.takeIf { it > 0 } ?: Long.MAX_VALUE
} }
} }
} }
@@ -797,10 +913,27 @@ class ChatStore {
*/ */
fun loadFromCache(lanes: Map<String, List<ChatItem>>) { fun loadFromCache(lanes: Map<String, List<ChatItem>>) {
if (lanes.isEmpty()) return if (lanes.isEmpty()) return
_lanes.value = lanes synchronized(lock) {
// The cache is ordered by (ts, id); pending/failed sends are
// persisted with ts = 0, so the DB returns them FIRST — but in
// memory addPending appends them to the END of the lane. Move the
// ts=0 message bubbles to the end (preserving their relative
// order); everything else keeps its stored order, so a restored
// lane looks like the live one until loadHistory re-sorts it
// after a connect.
_lanes.value =
lanes.mapValues { (_, items) ->
val (zeroTs, rest) = items.partition { (it as? MessageItem)?.ts == 0L }
rest + zeroTs
}
}
} }
fun clear() { fun clear() {
synchronized(lock) {
_lanes.value = emptyMap() _lanes.value = emptyMap()
_unread.value = emptyMap()
_todos.value = emptyMap()
}
} }
} }
@@ -2,16 +2,26 @@ package iris.data
/** /**
* Pairing settings storage. The token is a secret: platform actuals keep it * Pairing settings storage. The token is a secret: platform actuals keep it
* in secure storage (EncryptedSharedPreferences on Android — M5; plain * in secure storage (EncryptedSharedPreferences on Android, OS keyring or an
* SharedPreferences for M1 dev, file on desktop). * encrypted file on desktop).
*/ */
interface SecureStore { interface SecureStore {
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */ /** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
var serverUrl: String var serverUrl: String
/** ANDROID_TOKEN presented in the auth header. */ /** IRIS_TOKEN presented in the auth header (bootstrap / fallback). */
var token: String var token: String
/** Per-device token minted at pairing (hello.ack ``device_token``,
* docs/09 §9.3). Presented INSTEAD of [token] when non-empty; the
* gateway can revoke it per device. Empty until the first hello.ack. */
var deviceToken: String
/** SHA-256 fingerprint (colon-separated pairs) of the gateway's TLS
* certificate, confirmed by the user on first pair (docs/09 §9.4).
* Empty = nothing pinned (CA-signed certs need no pin). */
var pinnedCertFingerprint: String
/** Stable app-generated device id (persisted). */ /** Stable app-generated device id (persisted). */
val deviceId: String val deviceId: String
@@ -1,10 +0,0 @@
package iris.media
/** A readable local file (expect/actual; JVM impl in jvmMain). */
expect class FileSource(path: String) : AutoCloseable {
/** Total size in bytes. */
fun size(): Long
/** Read up to [buf.size] bytes into [buf]; returns bytes read or -1 at EOF. */
fun read(buf: ByteArray): Int
}
@@ -6,15 +6,17 @@ import iris.protocol.KIND_IMAGE
import iris.protocol.KIND_VIDEO import iris.protocol.KIND_VIDEO
/** Map a MIME type to a media kind (docs/07 §7.1). */ /** Map a MIME type to a media kind (docs/07 §7.1). */
fun kindFromMime(mime: String): String = when { fun kindFromMime(mime: String): String =
when {
mime.startsWith("image/") -> KIND_IMAGE mime.startsWith("image/") -> KIND_IMAGE
mime.startsWith("video/") -> KIND_VIDEO mime.startsWith("video/") -> KIND_VIDEO
mime.startsWith("audio/") -> KIND_AUDIO mime.startsWith("audio/") -> KIND_AUDIO
else -> KIND_DOCUMENT else -> KIND_DOCUMENT
} }
/** Best-effort file extension for a MIME type (cache file naming). */ /** Best-effort file extension for a MIME type (cache file naming). */
fun extForMime(mime: String): String = when { fun extForMime(mime: String): String =
when {
mime == "image/jpeg" -> ".jpg" mime == "image/jpeg" -> ".jpg"
mime == "image/png" -> ".png" mime == "image/png" -> ".png"
mime == "image/webp" -> ".webp" mime == "image/webp" -> ".webp"
@@ -34,4 +36,13 @@ fun extForMime(mime: String): String = when {
mime == "application/zip" -> ".zip" mime == "application/zip" -> ".zip"
mime == "text/plain" -> ".txt" mime == "text/plain" -> ".txt"
else -> ".bin" else -> ".bin"
} }
/**
* Validate a server-provided media id before it is used in a file path or a
* `GET /v1/media/{id}` URL (M-2 / S-1). The id comes from the gateway's
* `media.offer` frame; a value like `../../x` would write outside the media
* directory and break the pull URL. Only a conservative token charset is
* accepted.
*/
fun isValidMediaId(id: String): Boolean = id.length in 1..128 && id.all { it.isLetterOrDigit() || it == '_' || it == '-' }
@@ -8,11 +8,11 @@ import iris.protocol.ServerCaps
import iris.protocol.messageSendFrame import iris.protocol.messageSendFrame
import iris.protocol.syncFrame import iris.protocol.syncFrame
import iris.util.IrisLog import iris.util.IrisLog
import iris.util.nowMillis
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -26,14 +26,13 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.random.Random import kotlin.random.Random
/** /**
* HTTP client for the hermes android gateway (docs/19). * HTTP client for the hermes iris gateway (docs/19).
* *
* HTTP is the only transport: send via `POST /v1/frame`, receive over SSE * HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`. * `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
@@ -41,7 +40,9 @@ import kotlin.random.Random
* - connect: health probe + SSE hello (the HTTP hello.ack) * - connect: health probe + SSE hello (the HTTP hello.ack)
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect * - reconnect: exponential backoff + jitter; re-hello on every (re)connect
* - events: server frames on [events] * - events: server frames on [events]
* - request/response correlation by id * - correlation: the POST body carries the synchronous reply (e.g. read
* receipt, errors); everything else arrives on the event stream, and the
* app reconciles by frame id (docs/19 §19.7)
*/ */
class GatewayClient( class GatewayClient(
private val scope: CoroutineScope, private val scope: CoroutineScope,
@@ -66,6 +67,13 @@ class GatewayClient(
data class AuthFailed( data class AuthFailed(
val message: String, val message: String,
) : State ) : State
/** The gateway's TLS certificate is untrusted and doesn't match the
* pinned fingerprint (docs/09 §9.4). Terminal: the connect loop
* stops and the UI asks the user to confirm the fingerprint. */
data class TlsConfirmRequired(
val fingerprint: String,
) : State
} }
private val _state = MutableStateFlow<State>(State.Disconnected) private val _state = MutableStateFlow<State>(State.Disconnected)
@@ -78,14 +86,30 @@ class GatewayClient(
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128) private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
val events: SharedFlow<Frame> = _events.asSharedFlow() val events: SharedFlow<Frame> = _events.asSharedFlow()
// TLS: the pinning trust manager wraps the platform default (CA-signed
// certs behave as before); a self-signed gateway cert is accepted only
// after the user confirms its fingerprint (docs/09 §9.4). The pin is
// read live from the store so a fresh confirm takes effect without
// rebuilding the client.
private val pinningTm = PinningTrustManager { store.pinnedCertFingerprint }
private val client: OkHttpClient = private val client: OkHttpClient =
OkHttpClient OkHttpClient
.Builder() .Builder()
.pingInterval(20, TimeUnit.SECONDS) .pingInterval(20, TimeUnit.SECONDS)
.sslSocketFactory(pinningSslSocketFactory(pinningTm), pinningTm)
.build() .build()
private var connectJob: Job? = null private var connectJob: Job? = null
private var nextRequestId = 1 private var nextRequestId = 1
// Incremented from the SSE callback thread (onHttpHello) and the UI
// thread (sendFrame/sendMessage) — keep it atomic or ids collide and
// request/response correlation breaks.
private fun nextId(): Int = synchronized(this) { nextRequestId++ }
// Written by poke() (UI thread) and the connect loop (Default).
@Volatile
private var attempt = 0 private var attempt = 0
// Set by poke() (app returned to the foreground): the connect loop's // Set by poke() (app returned to the foreground): the connect loop's
@@ -97,19 +121,33 @@ class GatewayClient(
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a // start(). Drives Connecting (first dial) vs Reconnecting (redial after a
// drop) so the UI can show the right status without a blocking screen. // drop) so the UI can show the right status without a blocking screen.
private var hasConnected = false private var hasConnected = false
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
// HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is // HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is
// the resume cursor (SSE id / outbox high-water mark). // the resume cursor (SSE id / outbox high-water mark), updated from the
// SSE/poll callback threads.
private var http: HttpGateway? = null private var http: HttpGateway? = null
@Volatile
private var httpCursor: Long = 0 private var httpCursor: Long = 0
private var sseFailures = 0 private var sseFailures = 0
private var usingLongPoll = false private var usingLongPoll = false
// Last hello.ack payload — used to restore State.Connected after a // Last hello.ack payload — used to restore State.Connected after a
// reconnect state race in the connect loop. // reconnect state race in the connect loop. Written by the SSE callback
// thread, read by the long-poll loop.
@Volatile
private var lastAck: HelloAckPayload? = null private var lastAck: HelloAckPayload? = null
// Epoch ms of the last frame delivered by the receive stream (SSE or
// long-poll). The watchdog uses this to detect a STALE stream: a live
// connection (heartbeats / poll answers keep it open, so the read timeout
// never fires) that has stopped delivering frames — the state a gateway
// restart can leave the app in, where sent messages sit at "sending…"
// because their echo is never delivered (issue #6). Set when the receive
// loop (re)starts so the first window isn't treated as stale.
@Volatile
private var lastFrameMs: Long = 0L
/** /**
* Fired promptly the moment the SSE hello (hello.ack) is received — on * Fired promptly the moment the SSE hello (hello.ack) is received — on
* every (re)connect. Used for time-critical work that must not wait for * every (re)connect. Used for time-critical work that must not wait for
@@ -133,11 +171,22 @@ class GatewayClient(
connectJob = scope.launch { connectLoop() } connectJob = scope.launch { connectLoop() }
} }
/** Stop the connect loop. */ /**
* Stop the connect loop and reset the HTTP leg. Without the reset, a
* re-pair to a *different* gateway would keep using the cached
* [HttpGateway] (old URL, old token, old resume cursor) until the process
* is killed.
*/
fun stop() { fun stop() {
connectJob?.cancel() connectJob?.cancel()
connectJob = null connectJob = null
_state.value = State.Disconnected _state.value = State.Disconnected
http?.close()
http = null
httpCursor = 0
sseFailures = 0
usingLongPoll = false
lastAck = null
} }
/** /**
@@ -161,7 +210,9 @@ class GatewayClient(
private suspend fun connectLoop() { private suspend fun connectLoop() {
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
val url = store.serverUrl.trim() val url = store.serverUrl.trim()
val token = store.token // Per-device token when the gateway minted one (docs/09 §9.3),
// else the shared IRIS_TOKEN (bootstrap).
val token = store.deviceToken.ifBlank { store.token }
if (url.isBlank() || token.isBlank()) { if (url.isBlank() || token.isBlank()) {
_state.value = State.Disconnected _state.value = State.Disconnected
return return
@@ -174,6 +225,7 @@ class GatewayClient(
try { try {
gw.health() gw.health()
} catch (e: Exception) { } catch (e: Exception) {
if (failTls(e)) return
false false
} }
if (!healthOk) { if (!healthOk) {
@@ -186,6 +238,7 @@ class GatewayClient(
sseFailures = 0 sseFailures = 0
usingLongPoll = false usingLongPoll = false
httpCursor = store.syncCursor httpCursor = store.syncCursor
lastFrameMs = nowMs()
// Provisional Connected state (previous caps/channels) until the // Provisional Connected state (previous caps/channels) until the
// SSE hello arrives with the real ones. // SSE hello arrives with the real ones.
val prev = _state.value val prev = _state.value
@@ -210,6 +263,10 @@ class GatewayClient(
try { try {
gw.health() gw.health()
} catch (e: Exception) { } catch (e: Exception) {
if (failTls(e)) {
receiveJob.cancel()
break
}
false false
} }
probeFailures = if (ok) 0 else probeFailures + 1 probeFailures = if (ok) 0 else probeFailures + 1
@@ -217,26 +274,42 @@ class GatewayClient(
receiveJob.cancel() receiveJob.cancel()
break break
} }
// Stale-stream watchdog: the gateway is up (health ok) but
// the receive stream has delivered no frames for a while —
// a live-but-dead connection the read timeout can't see.
// Force a fresh (re)connect so parked frames (e.g. our own
// echo) are re-delivered from the outbox.
if (lastFrameMs > 0L && nowMs() - lastFrameMs > STALE_STREAM_TIMEOUT_MS) {
IrisLog.w("receive stream stale (no frames for ${STALE_STREAM_TIMEOUT_MS}ms); forcing reconnect")
receiveJob.cancel()
break
}
} }
receiveJob.join() receiveJob.join()
} }
// Terminal auth failure: don't redial with the same bad token. // Terminal failures: don't redial with the same bad token / the
if (_state.value is State.AuthFailed) return // same untrusted certificate (the UI asks the user to act).
if (_state.value is State.AuthFailed || _state.value is State.TlsConfirmRequired) return
} }
} }
// ── HTTP receive leg ────────────────────────────────────────────────── // ── HTTP receive leg ──────────────────────────────────────────────────
/** Lazily build the HTTP client from the stored URL. */ /** Lazily build the HTTP client from the stored URL. Synchronized: two
private fun httpGateway(): HttpGateway? { * threads racing the check-then-create would leak a gateway (and its
* OkHttp clients). */
private fun httpGateway(): HttpGateway? =
synchronized(this) {
val url = store.serverUrl.trim() val url = store.serverUrl.trim()
val token = store.token val token = store.deviceToken.ifBlank { store.token }
if (url.isBlank() || token.isBlank()) return null if (url.isBlank() || token.isBlank()) return@synchronized null
return http http
?: HttpGateway( ?: HttpGateway(
client, client,
HttpGateway.deriveHttpUrl(url), HttpGateway.deriveHttpUrl(url),
token, // Live provider: a device token minted by the next
// hello.ack is picked up without rebuilding the client.
token = { store.deviceToken.ifBlank { store.token } },
store.deviceId, store.deviceId,
deviceName = store.deviceName, deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } }, fcmToken = { store.fcmToken.ifBlank { null } },
@@ -264,6 +337,7 @@ class GatewayClient(
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)") _state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
return return
} catch (e: Exception) { } catch (e: Exception) {
if (failTls(e)) return
markStreamLost() markStreamLost()
IrisLog.w("http poll failed: ${e.message}") IrisLog.w("http poll failed: ${e.message}")
delay(backoff) delay(backoff)
@@ -276,13 +350,21 @@ class GatewayClient(
onHello = { onHttpHello(it) }, onHello = { onHttpHello(it) },
onFrame = { emitHttpFrame(it) }, onFrame = { emitHttpFrame(it) },
onCursor = { if (it > httpCursor) httpCursor = it }, onCursor = { if (it > httpCursor) httpCursor = it },
// A keep-alive comment proves the stream is alive —
// count it toward liveness so an idle-but-healthy
// stream isn't force-reconnected by the stale
// watchdog every 3 minutes.
onKeepAlive = { lastFrameMs = nowMs() },
) )
// Clean EOF: reconnect immediately. // Clean EOF: back off briefly so a server that keeps
// closing cleanly can't tight-loop the reconnect.
backoff = 1_000L backoff = 1_000L
delay(backoff)
} catch (e: HttpGateway.HttpAuthException) { } catch (e: HttpGateway.HttpAuthException) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)") _state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
return return
} catch (e: Exception) { } catch (e: Exception) {
if (failTls(e)) return
sseFailures++ sseFailures++
markStreamLost() markStreamLost()
if (sseFailures >= 2) { if (sseFailures >= 2) {
@@ -301,12 +383,19 @@ class GatewayClient(
/** The SSE `event: hello` (the HTTP hello.ack). */ /** The SSE `event: hello` (the HTTP hello.ack). */
private fun onHttpHello(ack: HelloAckPayload) { private fun onHttpHello(ack: HelloAckPayload) {
lastAck = ack lastAck = ack
// Per-device token (docs/09 §9.3): minted at pairing, stable across
// (re)connects. Store it — from the next request on the app presents
// it instead of the shared IRIS_TOKEN, so the gateway can revoke
// THIS device without touching the others.
if (ack.deviceToken.isNotBlank() && ack.deviceToken != store.deviceToken) {
store.deviceToken = ack.deviceToken
}
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor) val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
_state.value = connected _state.value = connected
// M5: reconnect catch-up — replay frames parked while offline. // M5: reconnect catch-up — replay frames parked while offline.
val local = store.syncCursor val local = store.syncCursor
if (local < ack.syncCursor) { if (local < ack.syncCursor) {
val id = nextRequestId++ val id = nextId()
scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) } scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) }
} }
// Prompt fast path (before the possibly-starved state collector). // Prompt fast path (before the possibly-starved state collector).
@@ -321,22 +410,52 @@ class GatewayClient(
/** The receive stream is open again (long-poll answered): the link is /** The receive stream is open again (long-poll answered): the link is
* back. Long-poll has no hello, so restore the Connected state from the * back. Long-poll has no hello, so restore the Connected state from the
* last hello.ack (the SSE path gets a fresh one). */ * last hello.ack (the SSE path gets a fresh one). Fire [onHelloAck] too:
* the long-poll path has no `event: hello`, so without this the app's
* "on connected" side effects (resend queued offline sends, load the
* active lane's history) never run when a gateway restart lands while
* the app is on the long-poll fallback — queued messages would sit at
* "sending…" forever until an app restart (issue #6). */
private fun restoreConnected() { private fun restoreConnected() {
if (_state.value !is State.Reconnecting) return if (_state.value !is State.Reconnecting) return
_state.value = val connected =
lastAck?.let { State.Connected(it.serverCaps, it.channels, it.lastPushedCursor) } lastAck?.let { State.Connected(it.serverCaps, it.channels, it.lastPushedCursor) }
?: State.Connected(ServerCaps(), emptyList()) ?: State.Connected(ServerCaps(), emptyList())
_state.value = connected
onHelloAck?.invoke(connected)
} }
/** Deliver an HTTP-leg frame to the same sinks as any other frame. */ /** Deliver an HTTP-leg frame to the same sinks as any other frame. */
private fun emitHttpFrame(frame: Frame) { private fun emitHttpFrame(frame: Frame) {
_events.tryEmit(frame) lastFrameMs = nowMs()
frame.id?.let { id -> if (!_events.tryEmit(frame)) {
pending[id]?.complete(frame) // The collector is starved beyond the 128-frame buffer: the frame
// is dropped. Log it — a silent drop here loses user-visible
// content (echoes, deltas, notifications).
IrisLog.e("events buffer full — dropped frame ${frame.type} (id=${frame.id})")
} }
} }
/** Deliver the POST body's synchronous reply (or null) and handle a 401.
* Shared by [sendMessage] and [sendFrame]. */
private fun handlePostResult(res: HttpGateway.PostResult?) {
res?.frame?.let { emitHttpFrame(it) }
if (res?.status == 401) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
}
}
/** Terminal TLS failure: the presented certificate is untrusted and not
* the pinned one (docs/09 §9.4). Retry can't succeed — stop the connect
* loop and let the UI ask the user to confirm the fingerprint. True when
* the state was set. */
private fun failTls(e: Exception): Boolean {
val tls = tlsFingerprintRequired(e) ?: return false
IrisLog.w("tls: untrusted gateway certificate (fingerprint ${tls.fingerprint})")
_state.value = State.TlsConfirmRequired(tls.fingerprint)
return true
}
// ── Outbound ────────────────────────────────────────────────────────── // ── Outbound ──────────────────────────────────────────────────────────
/** Send a text message (fire-and-forget; the server echoes it back). /** Send a text message (fire-and-forget; the server echoes it back).
@@ -360,7 +479,7 @@ class GatewayClient(
onResult?.invoke(0) onResult?.invoke(0)
return return
} }
val id = nextRequestId++ val id = nextId()
scope.launch { scope.launch {
val res = val res =
httpGateway()?.postFrame( httpGateway()?.postFrame(
@@ -369,10 +488,7 @@ class GatewayClient(
// The synchronous reply (e.g. the read receipt, or an error frame // The synchronous reply (e.g. the read receipt, or an error frame
// on 4xx) comes back in the POST body, not on the event stream — // on 4xx) comes back in the POST body, not on the event stream —
// deliver it or it is lost (docs/19 §19.7). // deliver it or it is lost (docs/19 §19.7).
res?.frame?.let { emitHttpFrame(it) } handlePostResult(res)
if (res?.status == 401) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
}
onResult?.invoke(res?.status ?: 0) onResult?.invoke(res?.status ?: 0)
} }
} }
@@ -408,8 +524,12 @@ class GatewayClient(
} }
companion object { companion object {
const val UPLOAD_TIMEOUT_MS = 120_000L /** No frames (or keep-alive comments) from the receive stream for
const val PULL_TIMEOUT_MS = 300_000L * this long (gateway still
* healthy) = stale stream: force a fresh (re)connect. 3 min keeps an
* idle app from churning while still recovering a stuck stream well
* before a user notices (issue #6). */
const val STALE_STREAM_TIMEOUT_MS = 180_000L
} }
/** /**
@@ -419,16 +539,13 @@ class GatewayClient(
*/ */
fun sendFrame(frame: Frame): Int { fun sendFrame(frame: Frame): Int {
if (_state.value !is State.Connected) return -1 if (_state.value !is State.Connected) return -1
val id = nextRequestId++ val id = nextId()
scope.launch { scope.launch {
val res = httpGateway()?.postFrame(frame.copy(id = id)) val res = httpGateway()?.postFrame(frame.copy(id = id))
// Single-frame responses (commands.catalog, channel.list, search, // Single-frame responses (commands.catalog, channel.list, search,
// history, sync, errors) come back in the POST body, not on the // history, sync, errors) come back in the POST body, not on the
// event stream — deliver it or it is lost (docs/19 §19.7). // event stream — deliver it or it is lost (docs/19 §19.7).
res?.frame?.let { emitHttpFrame(it) } handlePostResult(res)
if (res?.status == 401) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
}
} }
return id return id
} }
@@ -450,7 +567,10 @@ class GatewayClient(
HttpGateway( HttpGateway(
client, client,
HttpGateway.deriveHttpUrl(url), HttpGateway.deriveHttpUrl(url),
token, // An already-paired device presents its per-device token
// (docs/09 §9.3); a fresh pairing falls back to the entered
// shared token (bootstrap).
token = { store.deviceToken.ifBlank { token } },
store.deviceId, store.deviceId,
deviceName = store.deviceName, deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } }, fcmToken = { store.fcmToken.ifBlank { null } },
@@ -488,13 +608,15 @@ class GatewayClient(
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(IllegalStateException("connection failed: ${e.message}")) // Unwrap the pinning manager's signal so the Connect
// screen can offer the fingerprint confirm dialog.
Result.failure(tlsFingerprintRequired(e) ?: IllegalStateException("connection failed: ${e.message}"))
} finally { } finally {
job.cancel() job.cancel()
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(tlsFingerprintRequired(e) ?: e)
} }
} }
@@ -518,4 +640,6 @@ class GatewayClient(
val capped = minOf(base, 30_000L) val capped = minOf(base, 30_000L)
return capped + Random.nextLong(0, 500) return capped + Random.nextLong(0, 500)
} }
private fun nowMs(): Long = nowMillis()
} }
@@ -1,6 +1,7 @@
package iris.net package iris.net
import iris.media.Sha256 import iris.media.Sha256
import iris.media.isValidMediaId
import iris.protocol.ErrorPayload import iris.protocol.ErrorPayload
import iris.protocol.Frame import iris.protocol.Frame
import iris.protocol.HelloAckPayload import iris.protocol.HelloAckPayload
@@ -37,7 +38,11 @@ import java.util.concurrent.TimeUnit
class HttpGateway( class HttpGateway(
private val client: OkHttpClient, private val client: OkHttpClient,
private val baseUrl: String, private val baseUrl: String,
private val token: String, /** Live auth-token provider, read per request: the per-device token
* (docs/09 §9.3) when the gateway minted one, else the shared
* IRIS_TOKEN (bootstrap). A lambda so a freshly issued device token is
* picked up without rebuilding the client. */
private val token: () -> String,
private val deviceId: String, private val deviceId: String,
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway /** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
* upserts it into the device registry on every SSE open — the HTTP * upserts it into the device registry on every SSE open — the HTTP
@@ -60,6 +65,19 @@ class HttpGateway(
private val pollClient: OkHttpClient = client.pollClient() private val pollClient: OkHttpClient = client.pollClient()
private val mediaClient: OkHttpClient = client.mediaClient() private val mediaClient: OkHttpClient = client.mediaClient()
/** Close the per-purpose clients (and their idle connections). The
* shared base [client] is owned by the caller. */
fun close() {
healthClient.dispatcher.executorService.shutdown()
streamClient.dispatcher.executorService.shutdown()
pollClient.dispatcher.executorService.shutdown()
mediaClient.dispatcher.executorService.shutdown()
healthClient.connectionPool.evictAll()
streamClient.connectionPool.evictAll()
pollClient.connectionPool.evictAll()
mediaClient.connectionPool.evictAll()
}
/** POST /v1/frame result. [frame] is the handler's synchronous reply /** POST /v1/frame result. [frame] is the handler's synchronous reply
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain * (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
* 202 accept-and-ack. */ * 202 accept-and-ack. */
@@ -109,7 +127,7 @@ class HttpGateway(
val b = val b =
Headers Headers
.Builder() .Builder()
.add("Authorization", "Bearer $token") .add("Authorization", "Bearer ${token()}")
.add("X-Iris-Device", deviceId) .add("X-Iris-Device", deviceId)
// Device registration (docs/19): the gateway upserts name + push // Device registration (docs/19): the gateway upserts name + push
// tokens from these headers on every SSE open (COALESCE — absent // tokens from these headers on every SSE open (COALESCE — absent
@@ -195,8 +213,10 @@ class HttpGateway(
* leg is proven; the server may still replay a large outbox before the * leg is proven; the server may still replay a large outbox before the
* hello); [onHello] fires for `event: hello` (the HTTP hello.ack); * hello); [onHello] fires for `event: hello` (the HTTP hello.ack);
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox * [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
* cursor) when present. Returns on clean EOF; throws [IOException] on * cursor) when present; [onKeepAlive] for SSE comment lines (the
* open/read failure. Callbacks run on the IO thread. * heartbeat) so callers can count keep-alives toward liveness. Returns
* on clean EOF; throws [IOException] on open/read failure. Callbacks run
* on the IO thread.
*/ */
suspend fun events( suspend fun events(
cursor: Long, cursor: Long,
@@ -204,6 +224,7 @@ class HttpGateway(
onHello: (HelloAckPayload) -> Unit, onHello: (HelloAckPayload) -> Unit,
onFrame: (Frame) -> Unit, onFrame: (Frame) -> Unit,
onCursor: (Long) -> Unit, onCursor: (Long) -> Unit,
onKeepAlive: (() -> Unit)? = null,
) { ) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val request = val request =
@@ -257,10 +278,10 @@ class HttpGateway(
} }
line.startsWith(":") -> { line.startsWith(":") -> {
Unit // heartbeat comment
onKeepAlive?.invoke()
} }
// heartbeat comment
line.startsWith("id:") -> { line.startsWith("id:") -> {
line line
.removePrefix("id:") .removePrefix("id:")
@@ -399,6 +420,11 @@ class HttpGateway(
onChunk: suspend (ByteArray) -> Unit, onChunk: suspend (ByteArray) -> Unit,
): Result<Unit> = ): Result<Unit> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Server-controlled id: reject path-traversal / URL-breaking
// values before they reach the request line (M-2 / S-1).
if (!isValidMediaId(mediaId)) {
return@withContext Result.failure(IllegalStateException("invalid media id"))
}
val request = val request =
Request Request
.Builder() .Builder()
@@ -0,0 +1,118 @@
package iris.net
import java.security.KeyStore
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* TLS certificate pinning for self-signed gateways (docs/09 §9.4).
*
* A gateway behind `IRIS_HTTP_CERT` may present a
* self-signed certificate the platform doesn't trust. Instead of forcing the
* user to install it into the system trust store, the app shows the
* certificate's SHA-256 fingerprint on first pair (SSH host-key style); once
* the user confirms it, the fingerprint is pinned in secure storage and
* [PinningTrustManager] accepts exactly that certificate from then on.
*
* Hostname verification is NOT bypassed: OkHttp runs its own hostname check
* on top of the trust manager, so a pinned cert still has to match the URL's
* host. A *changed* certificate (different fingerprint) fails again with
* [TlsFingerprintRequired] — the user must re-confirm, like a changed SSH
* host key.
*/
/**
* The gateway presented a certificate the platform doesn't trust and that
* doesn't match the pinned fingerprint. Carries the SHA-256 fingerprint the
* user must confirm. Thrown by [PinningTrustManager] during the handshake;
* the JSSE/OkHttp layers wrap it, so find it with [tlsFingerprintRequired].
*/
class TlsFingerprintRequired(
val fingerprint: String,
) : CertificateException("gateway certificate not trusted (fingerprint $fingerprint)")
/**
* SHA-256 of the certificate's DER encoding, colon-separated byte pairs
* (SSH host-key style) — the string the user verifies on the gateway host.
*/
fun certFingerprint(cert: X509Certificate): String =
MessageDigest
.getInstance("SHA-256")
.digest(cert.encoded)
.joinToString(":") { String.format("%02X", it) }
/**
* Wraps the platform default trust manager:
* - CA-signed certs behave exactly as before (the default manager decides).
* - A cert the default manager REJECTS is accepted only when its fingerprint
* equals the user-confirmed pin ([pinnedFingerprint], read live so a fresh
* pin is picked up without rebuilding the client).
* - Anything else fails with [TlsFingerprintRequired] carrying the
* presented fingerprint, so the UI can offer the confirm dialog.
*/
class PinningTrustManager(
private val pinnedFingerprint: () -> String,
) : X509TrustManager {
private val default: X509TrustManager = defaultTrustManager()
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
default.checkClientTrusted(chain, authType)
}
override fun getAcceptedIssuers(): Array<X509Certificate> = default.acceptedIssuers
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
try {
default.checkServerTrusted(chain, authType)
} catch (e: CertificateException) {
val fp = certFingerprint(chain.first())
if (pinnedFingerprint().isNotBlank() && fp == pinnedFingerprint()) return
throw TlsFingerprintRequired(fp)
}
}
}
/** The platform default server trust manager (the JDK's CA store). */
fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return (factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager)
?: error("no X509TrustManager in the default trust store")
}
/** An [SSLSocketFactory] that trusts via [tm] (the pinning manager). */
fun pinningSslSocketFactory(tm: X509TrustManager): SSLSocketFactory {
val ctx = SSLContext.getInstance("TLS")
ctx.init(null, arrayOf<TrustManager>(tm), SecureRandom())
return ctx.socketFactory
}
/**
* Unwrap a [TlsFingerprintRequired] from a (possibly nested) transport
* exception: the trust manager throws it during the handshake and the
* JSSE/OkHttp layers wrap it in SSLHandshakeException/IOException. Null when
* the failure has nothing to do with an untrusted gateway certificate.
*/
fun tlsFingerprintRequired(e: Throwable): TlsFingerprintRequired? {
var t: Throwable? = e
var depth = 0
while (t != null && depth < 10) {
if (t is TlsFingerprintRequired) return t
t = t.cause
depth++
}
return null
}
@@ -6,7 +6,7 @@ package iris.platform
* The controller (commonMain) needs to know whether the app is in the * The controller (commonMain) needs to know whether the app is in the
* foreground (to decide between an in-app banner and a system notification) * foreground (to decide between an in-app banner and a system notification)
* and to post a system notification when a `notification` frame arrives while * and to post a system notification when a `notification` frame arrives while
* the app is backgrounded but the WS is still live. * the app is backgrounded but the gateway connection is still live.
*/ */
/** True when the app's UI is visible (Android: activity resumed). */ /** True when the app's UI is visible (Android: activity resumed). */
@@ -0,0 +1,13 @@
package iris.platform
import androidx.compose.runtime.Composable
/**
* A "Scan QR" button that launches the platform QR scanner and delivers the
* scanned text (or null if the user cancelled) to [onResult] (docs/20).
*
* Android: opens [QrScanActivity] (CameraX + ML Kit). Desktop: renders nothing
* (the Connect screen hides it there).
*/
@Composable
expect fun QrScanButton(onResult: (String?) -> Unit)
@@ -12,9 +12,9 @@ import kotlinx.serialization.json.put
/** /**
* Wire protocol frames (mirror of gateway-plugin/protocol.py). * Wire protocol frames (mirror of gateway-plugin/protocol.py).
* See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send, * See docs/04-wire-protocol.md. Defines all frame types and payloads:
* error, ping/pong, typing. M2: message.start/update/stop, tool.start/ * hello/hello.ack, message (send + streaming), tool cards, commentary,
* progress/end, commentary, reasoning (on message / message.stop). * reasoning, channels, media, notifications, sync, and error frames.
*/ */
const val PROTOCOL_VERSION = 1 const val PROTOCOL_VERSION = 1
@@ -47,6 +47,11 @@ const val TYPE_MESSAGE_DELETED = "message.deleted"
const val TYPE_TOOL_START = "tool.start" const val TYPE_TOOL_START = "tool.start"
const val TYPE_TOOL_PROGRESS = "tool.progress" const val TYPE_TOOL_PROGRESS = "tool.progress"
const val TYPE_TOOL_END = "tool.end" const val TYPE_TOOL_END = "tool.end"
// Agent todo list (live planning state; ephemeral, never outboxed — a
// reconnecting device re-learns it from the snapshot after hello.ack).
const val TYPE_TODO_UPDATE = "todo.update"
const val TYPE_COMMENTARY = "commentary" const val TYPE_COMMENTARY = "commentary"
// M4 — media (offer; upload/pull are HTTP, docs/19 §19.15) // M4 — media (offer; upload/pull are HTTP, docs/19 §19.15)
@@ -76,24 +81,19 @@ const val TYPE_SEARCH_RESULTS = "search.results"
// Slash-command catalog (the composer's "/" drawer) // Slash-command catalog (the composer's "/" drawer)
const val TYPE_COMMANDS_CATALOG = "commands.catalog" const val TYPE_COMMANDS_CATALOG = "commands.catalog"
// Interactive pickers (slash-command choice menus, e.g. /reasoning, /fast)
const val TYPE_PICKER_CHOICE = "picker.choice"
const val TYPE_PICKER_SELECT = "picker.select"
const val TYPE_SYNC = "sync" const val TYPE_SYNC = "sync"
const val TYPE_SYNC_DONE = "sync.done" const val TYPE_SYNC_DONE = "sync.done"
const val TYPE_HISTORY = "history" const val TYPE_HISTORY = "history"
// ── Error codes ─────────────────────────────────────────────────────────
const val ERR_AUTH = "auth"
const val ERR_NOT_FOUND = "not_found"
const val ERR_UNSUPPORTED = "unsupported"
const val ERR_INTERNAL = "internal"
const val ERR_MEDIA_TOO_LARGE = "media_too_large"
// M4 — media kinds (docs/07 §7.1) // M4 — media kinds (docs/07 §7.1)
const val KIND_IMAGE = "image" const val KIND_IMAGE = "image"
const val KIND_AUDIO = "audio" const val KIND_AUDIO = "audio"
const val KIND_VIDEO = "video" const val KIND_VIDEO = "video"
const val KIND_DOCUMENT = "document" const val KIND_DOCUMENT = "document"
const val KIND_VOICE = "voice"
// ── Roles ─────────────────────────────────────────────────────────────── // ── Roles ───────────────────────────────────────────────────────────────
@@ -176,6 +176,11 @@ data class HelloAckPayload(
* push backend (0 = never). Sync-replayed frames at/below it must not * push backend (0 = never). Sync-replayed frames at/below it must not
* re-post system notifications (dedupe, docs/08 §8.7). */ * re-post system notifications (dedupe, docs/08 §8.7). */
@SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0, @SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0,
/** Per-device token minted at pairing (docs/09 §9.3). The app stores it
* and presents it INSTEAD of the shared IRIS_TOKEN from then on; the
* gateway can revoke it per device. Empty when the gateway didn't
* issue one (legacy). */
@SerialName("device_token") val deviceToken: String = "",
) )
// ── message (server -> app) ───────────────────────────────────────────── // ── message (server -> app) ─────────────────────────────────────────────
@@ -271,6 +276,9 @@ data class ToolStartPayload(
val name: String, val name: String,
val preview: String? = null, val preview: String? = null,
val args: JsonElement? = null, val args: JsonElement? = null,
/** Cosmetic per-tool glyph resolved server-side (hermes get_tool_emoji);
* absent for unknown tools — the UI falls back to its default. */
val emoji: String? = null,
) )
@Serializable @Serializable
@@ -289,6 +297,30 @@ data class ToolEndPayload(
@SerialName("output_preview") val outputPreview: String? = null, @SerialName("output_preview") val outputPreview: String? = null,
) )
// ── todo.update (server -> app): the agent's live todo list ────────────────
/** One item of the agent's todo list (hermes `todo` tool). [status] is one
* of [TODO_PENDING], [TODO_IN_PROGRESS], [TODO_COMPLETED], [TODO_CANCELLED]. */
@Serializable
data class TodoItem(
val id: String,
val content: String,
val status: String,
)
const val TODO_PENDING = "pending"
const val TODO_IN_PROGRESS = "in_progress"
const val TODO_COMPLETED = "completed"
const val TODO_CANCELLED = "cancelled"
/** The agent's FULL current todo list for a lane (last-write-wins; the
* gateway emits it whenever the `todo` tool completes — the tool result is
* authoritative even for merge writes — and as a snapshot on connect). */
@Serializable
data class TodoUpdatePayload(
val todos: List<TodoItem> = emptyList(),
)
// ── M2: commentary frame (server -> app) ──────────────────────────────── // ── M2: commentary frame (server -> app) ────────────────────────────────
@Serializable @Serializable
@@ -410,6 +442,27 @@ data class CommandsCatalogPayload(
val commands: List<SlashCommand> = emptyList(), val commands: List<SlashCommand> = emptyList(),
) )
// ── picker.choice / picker.select (interactive slash-command menus) ────────
/** One option in a choice picker. [isCurrent] marks the active value (the
* UI shows a ✓ on it, like Telegram's inline keyboards). */
@Serializable
data class PickerChoice(
val value: String,
val label: String = value,
@SerialName("is_current") val isCurrent: Boolean = false,
)
/** An interactive choice picker (one tap → one value), sent by finite-choice
* slash commands (/reasoning, /fast, …). The app renders it as a card with
* buttons and answers via [pickerSelectFrame]. */
@Serializable
data class PickerChoicePayload(
@SerialName("picker_id") val pickerId: String,
val title: String,
val choices: List<PickerChoice> = emptyList(),
)
// ── M3: sync (reconnect catch-up) ─────────────────────────────────────── // ── M3: sync (reconnect catch-up) ───────────────────────────────────────
@Serializable @Serializable
@@ -459,12 +512,9 @@ data class MessageDeletedPayload(
// ── M5: push / notifications ──────────────────────────────────────────── // ── M5: push / notifications ────────────────────────────────────────────
/** Notification kinds (mirror of protocol.NOTIF_*). */ /** Notification kinds (mirror of protocol.NOTIF_*). */
const val NOTIF_MESSAGE = "message"
const val NOTIF_APPROVAL = "approval" const val NOTIF_APPROVAL = "approval"
const val NOTIF_CLARIFY = "clarify" const val NOTIF_CLARIFY = "clarify"
const val NOTIF_CRON = "cron" const val NOTIF_CRON = "cron"
const val NOTIF_CHANNEL = "channel"
const val NOTIF_OUTBOX_PRUNED = "outbox_pruned"
/** Kinds that stay on screen until dismissed (docs/08 §8.3). */ /** Kinds that stay on screen until dismissed (docs/08 §8.3). */
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON) val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
@@ -637,6 +687,24 @@ fun searchFrame(
* Answered by a `commands.catalog` frame carrying the same id. */ * Answered by a `commands.catalog` frame carrying the same id. */
fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG) fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG)
/** Answer an interactive picker (picker.choice) with the chosen value.
* The server runs the command's selection callback and delivers its reply
* as a normal message in the picker's chat. */
fun pickerSelectFrame(
id: Int,
pickerId: String,
value: String,
): Frame =
Frame(
id = id,
type = TYPE_PICKER_SELECT,
payload =
buildJsonObject {
put("picker_id", pickerId)
put("value", value)
},
)
fun syncFrame( fun syncFrame(
id: Int, id: Int,
cursor: Long, cursor: Long,
@@ -8,6 +8,7 @@ import iris.data.MessageItem
import iris.data.MsgStatus import iris.data.MsgStatus
import iris.data.SecureStore import iris.data.SecureStore
import iris.media.MediaCache import iris.media.MediaCache
import iris.media.isValidMediaId
import iris.media.kindFromMime import iris.media.kindFromMime
import iris.net.GatewayClient import iris.net.GatewayClient
import iris.platform.PickedFile import iris.platform.PickedFile
@@ -45,16 +46,17 @@ import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HISTORY import iris.protocol.TYPE_HISTORY
import iris.protocol.TYPE_MEDIA_OFFER import iris.protocol.TYPE_MEDIA_OFFER
import iris.protocol.TYPE_MESSAGE import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_DELETE
import iris.protocol.TYPE_MESSAGE_DELETED import iris.protocol.TYPE_MESSAGE_DELETED
import iris.protocol.TYPE_MESSAGE_START import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE import iris.protocol.TYPE_MESSAGE_UPDATE
import iris.protocol.TYPE_NOTIFICATION import iris.protocol.TYPE_NOTIFICATION
import iris.protocol.TYPE_PICKER_CHOICE
import iris.protocol.TYPE_READ_RECEIPT import iris.protocol.TYPE_READ_RECEIPT
import iris.protocol.TYPE_SEARCH_RESULTS import iris.protocol.TYPE_SEARCH_RESULTS
import iris.protocol.TYPE_STATUS import iris.protocol.TYPE_STATUS
import iris.protocol.TYPE_SYNC_DONE import iris.protocol.TYPE_SYNC_DONE
import iris.protocol.TYPE_TODO_UPDATE
import iris.protocol.TYPE_TOOL_END import iris.protocol.TYPE_TOOL_END
import iris.protocol.TYPE_TOOL_PROGRESS import iris.protocol.TYPE_TOOL_PROGRESS
import iris.protocol.TYPE_TOOL_START import iris.protocol.TYPE_TOOL_START
@@ -71,6 +73,7 @@ import iris.protocol.channelSetDefaultFrame
import iris.protocol.commandsCatalogFrame import iris.protocol.commandsCatalogFrame
import iris.protocol.historyFrame import iris.protocol.historyFrame
import iris.protocol.messageDeleteFrame import iris.protocol.messageDeleteFrame
import iris.protocol.pickerSelectFrame
import iris.protocol.searchFrame import iris.protocol.searchFrame
import iris.protocol.syncFrame import iris.protocol.syncFrame
import iris.ui.theme.Backdrop import iris.ui.theme.Backdrop
@@ -79,6 +82,7 @@ import iris.ui.theme.UserTheme
import iris.util.IrisLog import iris.util.IrisLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -86,6 +90,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Collections
import java.util.concurrent.atomic.AtomicLong
import kotlin.random.Random import kotlin.random.Random
/** /**
@@ -134,14 +140,15 @@ class IrisController(
} }
/** Home channel id (from hello.ack; default until then). */ /** Home channel id (from hello.ack; default until then). */
private val _homeChannel = MutableStateFlow("android:default") private val _homeChannel = MutableStateFlow("default")
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow() val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
/** Lanes whose full history has been loaded this session (in-memory; reset /** Lanes whose full history has been loaded this session (in-memory; reset
* on a process death, which is exactly when a reload is needed). The * on a process death, which is exactly when a reload is needed). The
* `sync` delta can seed a lane with recent frames without it being opened, * `sync` delta can seed a lane with recent frames without it being opened,
* so "lane is empty" is not a reliable first-open signal. */ * so "lane is empty" is not a reliable first-open signal. Synchronized:
private val historyLoaded = mutableSetOf<String>() * written by the frame collector, read by the UI thread (loadHistory). */
private val historyLoaded = Collections.synchronizedSet(mutableSetOf<String>())
/** Gateway health state (M5: status frame; null = never received). /** Gateway health state (M5: status frame; null = never received).
* Note: "restarting" is deliberately NOT stored here — it posts the * Note: "restarting" is deliberately NOT stored here — it posts the
@@ -149,6 +156,42 @@ class IrisController(
private val _gatewayStatus = MutableStateFlow<String?>(null) private val _gatewayStatus = MutableStateFlow<String?>(null)
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow() val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
// ── M8: unread indicator ──────────────────────────────────────────────
/** True while the current lane's newest content sits at the bottom of the
* viewport (set by the UI from its scroll state). Used to decide whether
* an incoming message in the current lane is "being read" (not unread).
* @Volatile: written on the UI thread, read on the frame-handler
* coroutine (Dispatchers.Default). */
@Volatile
var currentLaneAtBottom: Boolean = true
/** App foreground state (M8: clear the current lane's unread when the app
* is focused and the user is at the bottom). Mirrors the platform bridge
* ([isAppForeground]); the bridges push changes via [setForeground]. */
private val _foreground = MutableStateFlow(isAppForeground())
val foreground: StateFlow<Boolean> = _foreground.asStateFlow()
/** Bridge entry point: the platform shell reports a focus change. */
fun setForeground(fg: Boolean) {
_foreground.value = fg
}
/** M8: a finalized assistant message arrived in [lane]. Count it as unread
* unless the user is actively reading that lane right now (it is the
* current lane, the app is focused, and the newest content is at the
* bottom of the viewport). */
private fun noteIncomingAssistantMessage(lane: String) {
val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom
if (!beingRead) chat.markUnread(lane)
}
/** M8: the user is now viewing the current lane's newest content — clear
* its unread count. */
fun markCurrentLaneRead() {
chat.markLaneRead(chat.currentLane.value)
}
// Latches the restart pair: set when the "restarting" notice is posted // Latches the restart pair: set when the "restarting" notice is posted
// (on the gateway's status{restarting} frame), consumed by the matching // (on the gateway's status{restarting} frame), consumed by the matching
// "online" notice on the next reconnect. A plain network drop never sets // "online" notice on the next reconnect. A plain network drop never sets
@@ -170,6 +213,37 @@ class IrisController(
* via push (live frames carry no cursor and are never suppressed). */ * via push (live frames carry no cursor and are never suppressed). */
private fun isPushedReplay(frame: iris.protocol.Frame): Boolean = frame.cursor?.let { it <= lastPushedCursor } ?: false private fun isPushedReplay(frame: iris.protocol.Frame): Boolean = frame.cursor?.let { it <= lastPushedCursor } ?: false
/** M5: highest outbox cursor this device has CONSUMED (applied from the
* event stream). The stream resumes from [store.syncCursor] on every
* (re)connect, so this high-water mark is persisted there (debounced +
* on backgrounding): without it, a restart re-delivers every frame
* consumed since the last `sync.done` — and replayed `notification`
* frames re-showed their banner on every app reopen (docs/08 §8.4).
* @Volatile: written on the frame-handler coroutine, read on the
* foreground/state collectors. */
@Volatile
private var consumedCursor: Long = store.syncCursor
/** Debounced persist of [consumedCursor] (one store write per burst,
* not per frame). */
private var cursorSaveJob: Job? = null
private fun persistCursorDebounced() {
cursorSaveJob?.cancel()
cursorSaveJob =
scope.launch {
delay(CACHE_SAVE_DEBOUNCE_MS)
// maxOf: sync.done may have advanced the store past us.
store.syncCursor = maxOf(store.syncCursor, consumedCursor)
}
}
/** Flush [consumedCursor] to the store now (app backgrounding / exit). */
private fun persistCursorNow() {
cursorSaveJob?.cancel()
store.syncCursor = maxOf(store.syncCursor, consumedCursor)
}
// ── M3: threads toggle (per-app for now; per-channel lands later) ───── // ── M3: threads toggle (per-app for now; per-channel lands later) ─────
// Persisted (Settings → "Threads"). // Persisted (Settings → "Threads").
private val _threadsEnabled = MutableStateFlow(store.threadsEnabled) private val _threadsEnabled = MutableStateFlow(store.threadsEnabled)
@@ -229,6 +303,9 @@ class IrisController(
} }
companion object { companion object {
/** Max simultaneous banners; persistent ones are exempt from the cap. */
private const val MAX_BANNERS = 5
const val FONT_SCALE_MIN = 0.8f const val FONT_SCALE_MIN = 0.8f
const val FONT_SCALE_MAX = 1.5f const val FONT_SCALE_MAX = 1.5f
@@ -340,8 +417,12 @@ class IrisController(
// ── M4: media ───────────────────────────────────────────────────────── // ── M4: media ─────────────────────────────────────────────────────────
private val mediaCache = MediaCache(mediaCacheBaseDir()) private val mediaCache = MediaCache(mediaCacheBaseDir())
/** A composer attachment: picked file being uploaded (or uploaded). */ /** A composer attachment: picked file being uploaded (or uploaded).
* [id] is a per-attachment identity used to apply the upload result to
* the right placeholder — matching by filename would cross-update two
* files picked with the same name (M-6). */
data class PendingAttachment( data class PendingAttachment(
val id: String,
val filename: String, val filename: String,
val mime: String, val mime: String,
val size: Long, val size: Long,
@@ -370,7 +451,10 @@ class IrisController(
private val _banners = MutableStateFlow<List<Banner>>(emptyList()) private val _banners = MutableStateFlow<List<Banner>>(emptyList())
val banners: StateFlow<List<Banner>> = _banners.asStateFlow() val banners: StateFlow<List<Banner>> = _banners.asStateFlow()
private var bannerSeq = 0L
// Atomic: pushBanner can be called from the frame collector and the UI
// thread; a lost update would mint a duplicate banner id.
private val bannerSeq = AtomicLong(0)
fun dismissBanner(id: Long) { fun dismissBanner(id: Long) {
_banners.value = _banners.value.filterNot { it.id == id } _banners.value = _banners.value.filterNot { it.id == id }
@@ -384,8 +468,13 @@ class IrisController(
threadId: String?, threadId: String?,
) { ) {
val persistent = kind in HIGH_PRIORITY_NOTIF_KINDS val persistent = kind in HIGH_PRIORITY_NOTIF_KINDS
val banner = Banner(bannerSeq++, kind, title, body, chatId, threadId, persistent) val banner = Banner(bannerSeq.getAndIncrement(), kind, title, body, chatId, threadId, persistent)
_banners.value = (_banners.value + banner).takeLast(5) // Persistent banners (approval / clarify / cron) are never evicted by
// the cap; only the transient ones compete for the remaining slots.
val merged = _banners.value + banner
val keptPersistent = merged.filter { it.persistent }
val room = (MAX_BANNERS - keptPersistent.size).coerceAtLeast(0)
_banners.value = keptPersistent + merged.filterNot { it.persistent }.takeLast(room)
if (!persistent) { if (!persistent) {
scope.launch { scope.launch {
delay(5_000) delay(5_000)
@@ -408,7 +497,7 @@ class IrisController(
) { ) {
if (isAppForeground()) return if (isAppForeground()) return
if (text.isBlank()) return if (text.isBlank()) return
val id = chatId ?: "android:default" val id = chatId ?: "default"
val chatName = channels.byId(id)?.name val chatName = channels.byId(id)?.name
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId) postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
} }
@@ -462,9 +551,29 @@ class IrisController(
scope.launch { scope.launch {
chat.currentLane.debounce(CACHE_SAVE_DEBOUNCE_MS).collect { chatDb.metaPut(META_LAST_LANE, it) } chat.currentLane.debounce(CACHE_SAVE_DEBOUNCE_MS).collect { chatDb.metaPut(META_LAST_LANE, it) }
} }
// M8: when the app returns to the foreground and the user is at the
// bottom of the current lane, its newest content is on screen — clear
// any unread that accumulated while backgrounded. (The UI separately
// clears on scroll-to-bottom while already focused.)
scope.launch {
foreground.collect { fg ->
if (!fg) persistCursorNow()
if (fg && currentLaneAtBottom) markCurrentLaneRead()
}
}
scope.launch { scope.launch {
client.events.collect { frame -> client.events.collect { frame ->
try { try {
// Consume the frame's outbox cursor BEFORE dispatching:
// a frame that arrives a second time (SSE catch-up and
// the explicit sync replay both carry the cursor) must
// not re-notify — only its first consumption may.
val cursor = frame.cursor
val alreadyConsumed = cursor != null && cursor <= consumedCursor
if (cursor != null && cursor > consumedCursor) {
consumedCursor = cursor
persistCursorDebounced()
}
when (frame.type) { when (frame.type) {
TYPE_TOOL_START, TYPE_TOOL_START,
TYPE_TOOL_PROGRESS, TYPE_TOOL_PROGRESS,
@@ -480,6 +589,13 @@ class IrisController(
if (frame.cursor == null) chat.onFrame(frame) if (frame.cursor == null) chat.onFrame(frame)
} }
TYPE_TODO_UPDATE -> {
// The agent's live todo list (last-write-wins). Ephemeral:
// never outboxed, so it never carries a cursor and is
// always applied (a reconnect snapshot just re-sets it).
chat.onFrame(frame)
}
TYPE_MESSAGE, TYPE_MESSAGE,
TYPE_MESSAGE_START, TYPE_MESSAGE_START,
TYPE_MESSAGE_UPDATE, TYPE_MESSAGE_UPDATE,
@@ -501,7 +617,13 @@ class IrisController(
when (frame.type) { when (frame.type) {
TYPE_MESSAGE_STOP -> { TYPE_MESSAGE_STOP -> {
frame.payloadAs<MessageStopPayload>()?.let { frame.payloadAs<MessageStopPayload>()?.let {
if (!isPushedReplay(frame)) { // M8: a finalized streaming reply is new
// content — count it as unread unless the
// user is reading this lane right now.
frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
}
if (!alreadyConsumed && !isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText) notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
} }
} }
@@ -509,11 +631,17 @@ class IrisController(
TYPE_MESSAGE -> { TYPE_MESSAGE -> {
frame.payloadAs<MessagePayload>()?.let { frame.payloadAs<MessagePayload>()?.let {
if (it.role == ROLE_ASSISTANT && !isPushedReplay(frame)) { if (it.role == ROLE_ASSISTANT) {
// M8: a finalized (non-streaming) reply.
frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
}
if (!alreadyConsumed && !isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text) notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
} }
} }
} }
}
else -> { else -> {
Unit Unit
@@ -570,14 +698,25 @@ class IrisController(
_searching.value = false _searching.value = false
} }
TYPE_PICKER_CHOICE -> {
// Interactive slash-command menu (/reasoning, /fast, …):
// rendered as a tappable card in the lane.
chat.onFrame(frame)
}
TYPE_COMMANDS_CATALOG -> { TYPE_COMMANDS_CATALOG -> {
frame.payloadAs<CommandsCatalogPayload>()?.let { _slashCommands.value = it.commands } frame.payloadAs<CommandsCatalogPayload>()?.let { _slashCommands.value = it.commands }
} }
TYPE_SYNC_DONE -> { TYPE_SYNC_DONE -> {
// Replayed frames already flowed through [events]; the // Replayed frames already flowed through [events]; the
// cursor is authoritative server-side (outbox). // cursor is authoritative server-side (outbox). Keep the
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor } // consumed high-water mark in step (it may be ahead of
// us — live frames consumed after the replay started).
frame.payloadAs<SyncDonePayload>()?.let {
consumedCursor = maxOf(consumedCursor, it.cursor)
store.syncCursor = maxOf(store.syncCursor, it.cursor)
}
} }
TYPE_HISTORY -> { TYPE_HISTORY -> {
@@ -602,7 +741,7 @@ class IrisController(
) )
// Mark the lane loaded only when the response // Mark the lane loaded only when the response
// is actually processed: if the request or // is actually processed: if the request or
// response is lost in a WS drop, the lane // response is lost in a connection drop, the lane
// stays unmarked and the next (re)connect // stays unmarked and the next (re)connect
// retries it. // retries it.
historyLoaded.add(lane) historyLoaded.add(lane)
@@ -616,8 +755,13 @@ class IrisController(
TYPE_NOTIFICATION -> { TYPE_NOTIFICATION -> {
frame.payloadAs<NotificationPayload>()?.let { p -> frame.payloadAs<NotificationPayload>()?.let { p ->
// alreadyConsumed: this frame was applied earlier
// (its cursor is at/below the high-water mark) —
// a duplicate delivery (restart catch-up or the
// sync replay) must not re-show the banner.
if (!alreadyConsumed) {
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId) pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
// M5: WS is live but the app is backgrounded — the // M5: the connection is live but the app is backgrounded — the
// in-app banner is invisible, so mirror to a system // in-app banner is invisible, so mirror to a system
// notification (the push backend only fires when // notification (the push backend only fires when
// there is no live subscriber). Suppressed for // there is no live subscriber). Suppressed for
@@ -628,6 +772,7 @@ class IrisController(
} }
} }
} }
}
TYPE_TYPING -> { TYPE_TYPING -> {
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on } frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
@@ -650,7 +795,7 @@ class IrisController(
if (st.state == "restarting") { if (st.state == "restarting") {
// Gateway is going down (restart/stop): // Gateway is going down (restart/stop):
// post the notice IMMEDIATELY — the // post the notice IMMEDIATELY — the
// socket can take up to the ping timeout // connection can take up to the ping timeout
// (~20 s) to actually drop, and waiting // (~20 s) to actually drop, and waiting
// for that transition would delay the // for that transition would delay the
// message. No banner for this state: the // message. No banner for this state: the
@@ -756,7 +901,7 @@ class IrisController(
} }
/** /**
* Fast-path connect handler (runs on the WS thread via [GatewayClient.onHelloAck], * Fast-path connect handler (runs on the gateway callback thread via [GatewayClient.onHelloAck],
* promptly on every (re)connect). Seeds the channel directory and loads the * promptly on every (re)connect). Seeds the channel directory and loads the
* active lane's full history. The lane is the last-viewed one (restored from * active lane's full history. The lane is the last-viewed one (restored from
* the local cache) if it still exists on the server, else the home channel. * the local cache) if it still exists on the server, else the home channel.
@@ -765,7 +910,10 @@ class IrisController(
* refreshes (skipped on a plain reconnect via historyLoaded). * refreshes (skipped on a plain reconnect via historyLoaded).
*/ */
private fun onConnectedLane(connected: GatewayClient.State.Connected) { private fun onConnectedLane(connected: GatewayClient.State.Connected) {
channels.setAll(connected.channels) // Never wipe the directory with an empty list: the long-poll restore
// path carries no channels when there was no prior SSE hello (lastAck
// null), and the cached directory is still valid then.
if (connected.channels.isNotEmpty()) channels.setAll(connected.channels)
val home = connected.channels.firstOrNull { it.isDefault }?.chatId val home = connected.channels.firstOrNull { it.isDefault }?.chatId
_homeChannel.value = home ?: ChatStore.DEFAULT_LANE _homeChannel.value = home ?: ChatStore.DEFAULT_LANE
if (home == null) return if (home == null) return
@@ -803,6 +951,18 @@ class IrisController(
// ── M3: channel directory ops (server is authoritative) ─────────────── // ── M3: channel directory ops (server is authoritative) ───────────────
/** Answer an interactive choice picker (picker.choice card). Marks the
* card resolved locally (optimistic) and sends picker.select; the
* server's reply arrives as a normal message. An expired picker
* (gateway restart) simply never replies. */
fun selectPicker(
pickerId: String,
value: String,
) {
chat.resolvePicker(pickerId, value)
client.sendFrame(pickerSelectFrame(0, pickerId, value))
}
fun createChannel(name: String) { fun createChannel(name: String) {
val trimmed = name.trim() val trimmed = name.trim()
if (trimmed.isEmpty()) return if (trimmed.isEmpty()) return
@@ -828,6 +988,7 @@ class IrisController(
} }
fun setDefaultChannel(chatId: String) { fun setDefaultChannel(chatId: String) {
channels.setDefault(chatId)
client.sendFrame(channelSetDefaultFrame(0, chatId)) client.sendFrame(channelSetDefaultFrame(0, chatId))
} }
@@ -836,6 +997,7 @@ class IrisController(
chatId: String, chatId: String,
on: Boolean, on: Boolean,
) { ) {
channels.setFavorite(chatId, on)
client.sendFrame(channelFavoriteFrame(0, chatId, on)) client.sendFrame(channelFavoriteFrame(0, chatId, on))
} }
@@ -845,6 +1007,7 @@ class IrisController(
chatId: String, chatId: String,
on: Boolean, on: Boolean,
) { ) {
channels.setAutomation(chatId, on)
client.sendFrame(channelSetAutomationFrame(0, chatId, on)) client.sendFrame(channelSetAutomationFrame(0, chatId, on))
} }
@@ -855,6 +1018,7 @@ class IrisController(
icon: String?, icon: String?,
color: String?, color: String?,
) { ) {
channels.setIcon(chatId, icon, color)
client.sendFrame(channelIconFrame(0, chatId, icon, color)) client.sendFrame(channelIconFrame(0, chatId, icon, color))
} }
@@ -913,9 +1077,10 @@ class IrisController(
val lane = chat.laneKey(chatId, threadId) val lane = chat.laneKey(chatId, threadId)
if (lane in historyLoaded) return if (lane in historyLoaded) return
// Newest page, sized to restore a full working view on restart / first // Newest page, sized to restore a full working view on restart / first
// open (older pages are reachable via scroll-up pagination). The lane // latest page only (older pages are not paginated in the current UI).
// The lane
// is marked loaded when the response arrives (TYPE_HISTORY), not here — // is marked loaded when the response arrives (TYPE_HISTORY), not here —
// a request lost in a WS drop must be retryable on reconnect. // a request lost in a connection drop must be retryable on reconnect.
client.sendFrame(historyFrame(0, chatId, threadId, limit = 200)) client.sendFrame(historyFrame(0, chatId, threadId, limit = 200))
} }
@@ -1008,8 +1173,9 @@ class IrisController(
* gateway error frame): queued (Pending) or failed bubbles that go out * gateway error frame): queued (Pending) or failed bubbles that go out
* automatically on the next (re)connect. In-memory only — a process * automatically on the next (re)connect. In-memory only — a process
* death leaves them as tap-to-retry (the local cache restore already * death leaves them as tap-to-retry (the local cache restore already
* marks pending sends failed). */ * marks pending sends failed). Synchronized: written by the send-result
private val networkFailed = mutableSetOf<String>() * callback (UI thread) and the frame collector. */
private val networkFailed = Collections.synchronizedSet(mutableSetOf<String>())
/** Resend queued/failed user messages of [lane] now that the link is /** Resend queued/failed user messages of [lane] now that the link is
* back: a message the server already has (the POST response was lost in * back: a message the server already has (the POST response was lost in
@@ -1063,8 +1229,13 @@ class IrisController(
/** Stage a picked file: upload it, then keep it as a pending attachment. */ /** Stage a picked file: upload it, then keep it as a pending attachment. */
fun attachFile(picked: PickedFile) { fun attachFile(picked: PickedFile) {
val kind = kindFromMime(picked.mime) val kind = kindFromMime(picked.mime)
// Per-attachment identity: the upload result is applied to THIS
// placeholder by id, so two files with the same name don't
// cross-update (M-6).
val id = "att_${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
val placeholder = val placeholder =
PendingAttachment( PendingAttachment(
id = id,
filename = picked.name, filename = picked.name,
mime = picked.mime, mime = picked.mime,
size = picked.size, size = picked.size,
@@ -1084,7 +1255,7 @@ class IrisController(
) )
_attachments.value = _attachments.value =
_attachments.value.map { _attachments.value.map {
if (it.filename == picked.name && it.uploading) { if (it.id == id && it.uploading) {
result.fold( result.fold(
{ ref -> it.copy(uploading = false, mediaRef = ref) }, { ref -> it.copy(uploading = false, mediaRef = ref) },
{ e -> it.copy(uploading = false, error = e.message) }, { e -> it.copy(uploading = false, error = e.message) },
@@ -1103,6 +1274,12 @@ class IrisController(
/** Pull offered media into the local cache and record the path. */ /** Pull offered media into the local cache and record the path. */
private fun pullMedia(offer: MediaOfferPayload) { private fun pullMedia(offer: MediaOfferPayload) {
scope.launch { scope.launch {
// Server-controlled id: reject path-traversal values before they
// reach the cache or the pull URL (M-2 / S-1).
if (!isValidMediaId(offer.mediaId)) {
IrisLog.w("rejecting media offer with invalid id: ${offer.mediaId.take(40)}")
return@launch
}
// Already cached? Skip the pull. // Already cached? Skip the pull.
mediaCache.path(offer.mediaId, offer.mime)?.let { mediaCache.path(offer.mediaId, offer.mime)?.let {
chat.setMediaLocalPath(offer.mediaId, it) chat.setMediaLocalPath(offer.mediaId, it)
@@ -1130,6 +1307,14 @@ class IrisController(
return Result.success(Unit) return Result.success(Unit)
} }
/** TLS fingerprint confirm (docs/09 §9.4): pin the gateway's presented
* certificate so its self-signed cert is accepted from now on. The
* caller re-runs [connect] (or the connect loop picks the pin up on its
* next attempt — the trust manager reads the store live). */
fun confirmTlsFingerprint(fingerprint: String) {
store.pinnedCertFingerprint = fingerprint
}
fun forget() { fun forget() {
store.clear() store.clear()
// A different gateway means a different chat universe — wipe the cache. // A different gateway means a different chat universe — wipe the cache.
@@ -1141,11 +1326,13 @@ class IrisController(
fun dispose() { fun dispose() {
client.stop() client.stop()
// Final synchronous flush so the newest frames survive the process // M-17: cancel the debounce job FIRST so a pending save can't fire
// death (the debounce window may still hold unsaved changes). // after our final flush and clobber it; then do the final synchronous
// flush so the newest frames survive the process death (the debounce
// window may still hold unsaved changes).
job.cancel()
chatDb.saveLanes(chat.lanes.value) chatDb.saveLanes(chat.lanes.value)
chatDb.saveChannels(channels.channels.value) chatDb.saveChannels(channels.channels.value)
job.cancel()
} }
} }
File diff suppressed because it is too large. Load diff
@@ -11,10 +11,12 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -24,11 +26,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import iris.net.TlsFingerprintRequired
import iris.platform.QrScanButton
import iris.platform.isDesktop
import iris.state.IrisController import iris.state.IrisController
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.util.PairLink
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -41,6 +48,9 @@ fun ConnectScreen(
prefillUrl: String = "", prefillUrl: String = "",
prefillToken: String = "", prefillToken: String = "",
initialError: String? = null, initialError: String? = null,
/** Gateway certificate fingerprint awaiting user confirmation (docs/09
* §9.4) — shown as a confirm dialog on entry. */
tlsFingerprint: String? = null,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Default is a cleartext (non-TLS) URL because the typical gateway is on // Default is a cleartext (non-TLS) URL because the typical gateway is on
@@ -49,6 +59,31 @@ fun ConnectScreen(
var token by remember { mutableStateOf(prefillToken) } var token by remember { mutableStateOf(prefillToken) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(initialError) } var error by remember { mutableStateOf(initialError) }
// Fingerprint the user still has to confirm (self-signed gateway cert,
// docs/09 §9.4): set on entry (TlsConfirmRequired state) or when a
// connect attempt fails with an untrusted certificate.
var pendingFingerprint by remember { mutableStateOf(tlsFingerprint) }
// "Test & Connect" (also the dialog's confirm action): real hello test,
// then save + (re)connect. An untrusted gateway certificate surfaces as
// the fingerprint confirm dialog instead of a plain error.
fun doConnect() {
if (busy) return
busy = true
error = null
scope.launch {
val result = controller.connect(url.trim(), token.trim())
busy = false
if (result.isFailure) {
val ex = result.exceptionOrNull()
if (ex is TlsFingerprintRequired) {
pendingFingerprint = ex.fingerprint
} else {
error = ex?.message ?: "connection failed"
}
}
}
}
Column( Column(
modifier = modifier =
@@ -91,26 +126,29 @@ fun ConnectScreen(
value = token, value = token,
onValueChange = { token = it }, onValueChange = { token = it },
label = { Text("Pairing token") }, label = { Text("Pairing token") },
placeholder = { Text("ANDROID_TOKEN (64 hex)") }, placeholder = { Text("IRIS_TOKEN (64 hex)") },
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (!isDesktop) {
Spacer(modifier = Modifier.height(12.dp))
QrScanButton { raw ->
if (raw != null) {
val link = PairLink.parse(raw)
if (link != null) {
url = link.url
token = link.token
} else {
error = "Couldn't read that QR code."
}
}
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
onClick = { onClick = { doConnect() },
if (busy) return@Button
busy = true
error = null
scope.launch {
val result = controller.connect(url.trim(), token.trim())
busy = false
if (result.isFailure) {
error = result.exceptionOrNull()?.message ?: "connection failed"
}
}
},
enabled = !busy, enabled = !busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -129,10 +167,49 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" + "Find the token in ~/.hermes/.env (IRIS_TOKEN) or run\n" +
"hermes gateway setup on the gateway host.", "hermes gateway setup on the gateway host.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
// Fingerprint confirm (docs/09 §9.4): the gateway presents a certificate
// this device doesn't trust (self-signed). The user verifies the
// fingerprint on the gateway host, then confirms — the pin is stored in
// secure storage and the connect is retried, like an SSH host key.
if (pendingFingerprint != null) {
AlertDialog(
onDismissRequest = { pendingFingerprint = null },
title = { Text("Confirm gateway certificate") },
text = {
Column {
Text(
"The gateway presents a certificate this device doesn't trust. " +
"Verify the fingerprint on the gateway host, then confirm to pin it.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
pendingFingerprint!!,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
}
},
confirmButton = {
Button(
onClick = {
val fp = pendingFingerprint!!
pendingFingerprint = null
controller.confirmTlsFingerprint(fp)
doConnect()
},
) { Text("Confirm & pin") }
},
dismissButton = {
TextButton(onClick = { pendingFingerprint = null }) { Text("Cancel") }
},
)
}
} }
@@ -78,6 +78,7 @@ fun SettingsScreen(
val fontSizeScale by controller.fontSizeScale.collectAsState() val fontSizeScale by controller.fontSizeScale.collectAsState()
val theme = LocalUserTheme.current val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) } var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showForgetConfirm by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize().background(theme.background)) { Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column( Column(
@@ -391,6 +392,24 @@ fun SettingsScreen(
} }
} }
} }
Text(
"Connection",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
Text("🔌 Forget pairing", fontSize = 14.sp)
Text(
"Clears the gateway token and wipes local chat history",
fontSize = 12.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { showForgetConfirm = true }) {
Text("Forget pairing", fontSize = 12.sp)
}
}
} }
when (pickerTarget) { when (pickerTarget) {
@@ -437,6 +456,32 @@ fun SettingsScreen(
Unit Unit
} }
} }
if (showForgetConfirm) {
AlertDialog(
onDismissRequest = { showForgetConfirm = false },
title = { Text("Forget pairing?") },
text = {
Text(
"This clears the gateway token and wipes all local chat history on this device. You'll need to pair again to reconnect.",
fontSize = 13.sp,
)
},
confirmButton = {
TextButton(onClick = {
showForgetConfirm = false
controller.forget()
}) {
Text("Forget")
}
},
dismissButton = {
TextButton(onClick = { showForgetConfirm = false }) {
Text("Cancel")
}
},
)
}
} }
} }
@@ -1,8 +1,9 @@
package iris.ui.theme package iris.ui.theme
/** /**
* A bundled backdrop image shipped in the app (Android `assets/backdrops/`, * A bundled backdrop image shipped in the app (Android: `androidApp` module
* desktop classpath `backdrops/`), loaded via [iris.platform.readBackdropBytes]. * `assets/backdrops/`; desktop: classpath `backdrops/`), loaded via
* [iris.platform.readBackdropBytes].
* *
* Bundled backdrops are referenced in [UserTheme.backgroundImagePath] by the * Bundled backdrops are referenced in [UserTheme.backgroundImagePath] by the
* sentinel path `backdrop://<id>` (see [path]); user-picked images use a real * sentinel path `backdrop://<id>` (see [path]); user-picked images use a real
@@ -18,9 +19,12 @@ data class Backdrop(
companion object { companion object {
const val PATH_PREFIX = "backdrop://" const val PATH_PREFIX = "backdrop://"
/** Default background for new chats / fresh installs. */
val DEFAULT = Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli")
val ALL: List<Backdrop> = val ALL: List<Backdrop> =
listOf( listOf(
Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli"), DEFAULT,
Backdrop("pexels-bogdankrupin-12049700", "Bogdan Krupin"), Backdrop("pexels-bogdankrupin-12049700", "Bogdan Krupin"),
Backdrop("pexels-bosichong-27940302", "Bosi Chong"), Backdrop("pexels-bosichong-27940302", "Bosi Chong"),
Backdrop("pexels-farhan-najeer-644774196-32490483", "Farhan Najeer"), Backdrop("pexels-farhan-najeer-644774196-32490483", "Farhan Najeer"),
@@ -28,9 +32,6 @@ data class Backdrop(
Backdrop("pexels-steve-29390703", "Steve"), Backdrop("pexels-steve-29390703", "Steve"),
) )
/** Default background for new chats / fresh installs. */
val DEFAULT: Backdrop = ALL.first { it.id == "pexels-yunszyveli-12368637" }
/** True when [path] references a bundled backdrop. */ /** True when [path] references a bundled backdrop. */
fun isBackdropPath(path: String?): Boolean = !path.isNullOrBlank() && path.startsWith(PATH_PREFIX) fun isBackdropPath(path: String?): Boolean = !path.isNullOrBlank() && path.startsWith(PATH_PREFIX)
@@ -35,8 +35,6 @@ object IrisColors {
val textDim = Color(0xFF8A93A6) val textDim = Color(0xFF8A93A6)
// Bubbles // Bubbles
val bubbleUser = primary
val bubbleAssistant = Color(0xFF2A2E3B)
val bubbleCommentary = Color(0xFF23262F) val bubbleCommentary = Color(0xFF23262F)
// Panels, chips, rows // Panels, chips, rows
@@ -47,7 +45,6 @@ object IrisColors {
val divider = Color(0xFF2A2E3B) val divider = Color(0xFF2A2E3B)
// Status // Status
val statusGrey = Color(0xFF9E9E9E)
val statusAmber = Color(0xFFFFC107) val statusAmber = Color(0xFFFFC107)
val statusGreen = Color(0xFF4CAF50) val statusGreen = Color(0xFF4CAF50)
val statusRed = Color(0xFFF44336) val statusRed = Color(0xFFF44336)
@@ -0,0 +1,97 @@
package iris.util
/**
* A parsed `iris://pair` deep link: the gateway URL to connect to plus the
* one-time pairing token. Produced by [PairLink.parse] from a scanned QR or a
* tapped `iris://pair` link (docs/20).
*/
data class PairLink(
val url: String,
val token: String,
) {
companion object {
/** Default gateway HTTP port (docs/19). */
const val DEFAULT_PORT = 8791
/**
* Parse a pairing link into a [PairLink], or return null on any
* malformation.
*
* - scheme must be `iris` (case-insensitive); the URI host must be `pair`.
* - required query params: `host` (non-empty) and `token` (non-empty).
* - `port` defaults to [DEFAULT_PORT]; must be 1–65535 when present.
* - `secure` defaults to `0`; `1` selects https.
* - `host`/`token` are percent-decoded (the Python side `quote()`s them).
*/
fun parse(raw: String): PairLink? {
val qIdx = raw.indexOf('?')
val authority = if (qIdx >= 0) raw.substring(0, qIdx) else raw
val query = if (qIdx >= 0) raw.substring(qIdx + 1) else ""
// authority is "iris://pair": scheme, then "://", then the URI host.
val sep = authority.indexOf("://")
if (sep <= 0) return null
val scheme = authority.substring(0, sep)
val uriHost = authority.substring(sep + 3)
if (scheme.lowercase() != "iris") return null
if (uriHost.lowercase() != "pair") return null
val params = parseQuery(query)
val host = params["host"]?.let { percentDecode(it) }?.trim().orEmpty()
val token = params["token"]?.let { percentDecode(it) }?.trim().orEmpty()
if (host.isEmpty() || token.isEmpty()) return null
val portRaw = params["port"]
val port =
if (portRaw.isNullOrEmpty()) {
DEFAULT_PORT
} else {
portRaw.toIntOrNull() ?: return null
}
if (port !in 1..65535) return null
val secure = params["secure"]?.toIntOrNull() ?: 0
val urlScheme = if (secure == 1) "https" else "http"
return PairLink("$urlScheme://$host:$port", token)
}
/** Split a `k=v&k=v` query string into a map (values may be empty). */
private fun parseQuery(query: String): Map<String, String> {
if (query.isEmpty()) return emptyMap()
val map = LinkedHashMap<String, String>()
for (pair in query.split('&')) {
if (pair.isEmpty()) continue
val eq = pair.indexOf('=')
if (eq < 0) {
map[pair] = ""
} else {
map[pair.substring(0, eq)] = pair.substring(eq + 1)
}
}
return map
}
/**
* Percent-decode `%XX` sequences (byte-wise; pairing payloads are ASCII
* — IPs and 64-hex tokens). A `%` that does not start a valid
* two-hex-digit escape is kept literally.
*/
private fun percentDecode(s: String): String {
val sb = StringBuilder(s.length)
var i = 0
while (i < s.length) {
val c = s[i]
if (c == '%' && i + 2 < s.length) {
val code = s.substring(i + 1, i + 3).toIntOrNull(16)
if (code != null) {
sb.append(code.toChar())
i += 3
continue
}
}
sb.append(c)
i++
}
return sb.toString()
}
}
}
@@ -11,9 +11,3 @@ expect fun localDayKey(epochMillis: Long): String
/** Current wall-clock time in epoch milliseconds (for locally generated items). */ /** Current wall-clock time in epoch milliseconds (for locally generated items). */
expect fun nowMillis(): Long expect fun nowMillis(): Long
/** Host part of a pairing URL (the "host:port" of the full gateway ws URL). */
fun hostFromUrl(url: String): String {
val noScheme = url.trim().substringAfter("://")
return noScheme.substringBefore("/").ifBlank { url.trim() }
}
@@ -5,7 +5,7 @@
CREATE TABLE message ( CREATE TABLE message (
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
id TEXT NOT NULL, -- message id (server id, or local_/sys_ for optimistic) id TEXT NOT NULL, -- message id (server id, or local<rand>/sys<rand> for optimistic)
ts INTEGER NOT NULL, -- epoch millis (0 for optimistic, not yet echoed) ts INTEGER NOT NULL, -- epoch millis (0 for optimistic, not yet echoed)
payload TEXT NOT NULL, -- serialized MessageItem payload TEXT NOT NULL, -- serialized MessageItem
PRIMARY KEY (lane, id) PRIMARY KEY (lane, id)
@@ -13,7 +13,7 @@ CREATE TABLE message (
CREATE TABLE tool ( CREATE TABLE tool (
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
id TEXT NOT NULL, -- local tool card id (tool_N) id TEXT NOT NULL, -- local tool card id (tool<rand>)
seq INTEGER NOT NULL, -- card order within the lane (lane position) seq INTEGER NOT NULL, -- card order within the lane (lane position)
payload TEXT NOT NULL, -- serialized ToolItem (carries its anchor_id) payload TEXT NOT NULL, -- serialized ToolItem (carries its anchor_id)
PRIMARY KEY (lane, id) PRIMARY KEY (lane, id)
@@ -2,10 +2,14 @@ package iris.data
import iris.protocol.Frame import iris.protocol.Frame
import iris.protocol.IrisJson import iris.protocol.IrisJson
import iris.protocol.TYPE_TODO_UPDATE
import iris.protocol.TYPE_TOOL_START import iris.protocol.TYPE_TOOL_START
import iris.protocol.TodoItem
import iris.protocol.TodoUpdatePayload
import iris.protocol.ToolStartPayload import iris.protocol.ToolStartPayload
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull
class ChatStoreCacheTest { class ChatStoreCacheTest {
@Test @Test
@@ -13,7 +17,7 @@ class ChatStoreCacheTest {
val store = ChatStore() val store = ChatStore()
store.loadFromCache( store.loadFromCache(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "hi", ts = 1), MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
@@ -21,15 +25,15 @@ class ChatStoreCacheTest {
), ),
), ),
) )
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
} }
@Test @Test
fun loadFromCacheEmptyIsNoOp() { fun loadFromCacheEmptyIsNoOp() {
val store = ChatStore() val store = ChatStore()
store.addPending("hello", "android:default") store.addPending("hello", "default")
store.loadFromCache(emptyMap()) store.loadFromCache(emptyMap())
assertEquals(1, store.lanes.value["android:default"]!!.size) assertEquals(1, store.lanes.value["default"]!!.size)
} }
@Test @Test
@@ -39,7 +43,7 @@ class ChatStoreCacheTest {
// to it, and the final answer. // to it, and the final answer.
store.loadFromCache( store.loadFromCache(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "count", ts = 100), MessageItem(id = "m1", role = "user", text = "count", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"),
@@ -51,13 +55,13 @@ class ChatStoreCacheTest {
// the tool card between the user message and the answer — not push // the tool card between the user message and the answer — not push
// it to the end. // it to the end.
store.loadHistory( store.loadHistory(
"android:default", "default",
listOf( listOf(
MessageItem(id = "m1", role = "user", text = "count", ts = 100), MessageItem(id = "m1", role = "user", text = "count", ts = 100),
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200), MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
), ),
) )
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
} }
@Test @Test
@@ -65,7 +69,7 @@ class ChatStoreCacheTest {
val store = ChatStore() val store = ChatStore()
store.loadFromCache( store.loadFromCache(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "count", ts = 100), MessageItem(id = "m1", role = "user", text = "count", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", done = true), ToolItem(id = "tool_1", index = 0, name = "bash", done = true),
@@ -73,11 +77,11 @@ class ChatStoreCacheTest {
), ),
) )
store.loadHistory( store.loadHistory(
"android:default", "default",
listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)), listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)),
) )
// A card whose anchor is unknown falls to the end (degenerate case). // A card whose anchor is unknown falls to the end (degenerate case).
assertEquals(listOf("m1", "tool_1"), store.lanes.value["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "tool_1"), store.lanes.value["default"]!!.map { it.id })
} }
@Test @Test
@@ -87,7 +91,7 @@ class ChatStoreCacheTest {
// card minted by the PREVIOUS process. // card minted by the PREVIOUS process.
store.loadFromCache( store.loadFromCache(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "hi", ts = 1), MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
@@ -99,7 +103,7 @@ class ChatStoreCacheTest {
store.onFrame( store.onFrame(
Frame( Frame(
type = TYPE_TOOL_START, type = TYPE_TOOL_START,
chatId = "android", chatId = "iris:other",
payload = payload =
IrisJson.instance.encodeToJsonElement( IrisJson.instance.encodeToJsonElement(
ToolStartPayload.serializer(), ToolStartPayload.serializer(),
@@ -107,7 +111,45 @@ class ChatStoreCacheTest {
), ),
), ),
) )
val ids = store.lanes.value["android:default"]!!.map { it.id } val ids = store.lanes.value["default"]!!.map { it.id }
assertEquals(ids.size, ids.toSet().size) assertEquals(ids.size, ids.toSet().size)
} }
@Test
fun todoUpdateSetsLaneListLastWriteWins() {
val store = ChatStore()
assertNull(store.todos.value["default"])
store.onFrame(todoFrame("default", listOf(TodoItem("1", "a", "in_progress"))))
assertEquals(listOf("1"), store.todos.value["default"]!!.map { it.id })
// A second update REPLACES the list (the gateway always sends the
// full list), and other lanes are untouched.
store.onFrame(
todoFrame(
"default",
listOf(TodoItem("1", "a", "completed"), TodoItem("2", "b", "pending")),
),
)
store.onFrame(todoFrame("chan_7", listOf(TodoItem("9", "z", "pending"))))
assertEquals(listOf("1", "2"), store.todos.value["default"]!!.map { it.id })
assertEquals("completed", store.todos.value["default"]!![0].status)
assertEquals(listOf("9"), store.todos.value["chan_7"]!!.map { it.id })
// An empty list clears the lane (the agent dropped its plan).
store.onFrame(todoFrame("default", emptyList()))
assertNull(store.todos.value["default"])
assertEquals(listOf("9"), store.todos.value["chan_7"]!!.map { it.id })
}
private fun todoFrame(
chatId: String,
todos: List<TodoItem>,
): Frame =
Frame(
type = TYPE_TODO_UPDATE,
chatId = chatId,
payload =
IrisJson.instance.encodeToJsonElement(
TodoUpdatePayload.serializer(),
TodoUpdatePayload(todos = todos),
),
)
} }
@@ -0,0 +1,108 @@
package iris.data
import iris.protocol.Frame
import iris.protocol.IrisJson
import iris.protocol.PickerChoice
import iris.protocol.PickerChoicePayload
import iris.protocol.TYPE_PICKER_CHOICE
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ChatStorePickerTest {
private fun pickerFrame(
pickerId: String,
title: String = "Reasoning",
chatId: String = "chan_1",
): Frame =
Frame(
type = TYPE_PICKER_CHOICE,
chatId = chatId,
payload =
IrisJson.instance.encodeToJsonElement(
PickerChoicePayload.serializer(),
PickerChoicePayload(
pickerId = pickerId,
title = title,
choices =
listOf(
PickerChoice(value = "low", label = "Low"),
PickerChoice(value = "high", label = "High", isCurrent = true),
),
),
),
)
@Test
fun onPickerChoiceAddsItem() {
val store = ChatStore()
store.onFrame(pickerFrame("pk_1"))
val items = store.lanes.value["chan_1"]!!
assertEquals(1, items.size)
val picker = assertIs<PickerItem>(items[0])
assertEquals("pk_1", picker.id)
assertEquals("Reasoning", picker.title)
assertEquals(2, picker.choices.size)
assertNull(picker.selected)
}
@Test
fun onPickerChoiceIsIdempotent() {
val store = ChatStore()
store.onFrame(pickerFrame("pk_1"))
store.onFrame(pickerFrame("pk_1")) // duplicate (e.g. outbox re-delivery)
val items = store.lanes.value["chan_1"]!!
assertEquals(1, items.size)
assertEquals("pk_1", items[0].id)
}
@Test
fun resolvePickerMarksSelected() {
val store = ChatStore()
store.onFrame(pickerFrame("pk_1"))
store.resolvePicker("pk_1", "low")
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
assertEquals("low", picker.selected)
}
@Test
fun resolvePickerIsOneShot() {
val store = ChatStore()
store.onFrame(pickerFrame("pk_1"))
store.resolvePicker("pk_1", "low")
store.resolvePicker("pk_1", "high") // second tap is ignored
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
assertEquals("low", picker.selected)
}
@Test
fun resolvePickerUnknownIdIsNoop() {
val store = ChatStore()
store.onFrame(pickerFrame("pk_1"))
store.resolvePicker("pk_missing", "low") // stale select after restart
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
assertNull(picker.selected)
}
@Test
fun pickerItemSerializationRoundTrip() {
val item =
PickerItem(
id = "pk_1",
title = "Reasoning",
choices =
listOf(
PickerChoice(value = "low", label = "Low"),
PickerChoice(value = "high", label = "High", isCurrent = true),
),
ts = 1234L,
selected = "high",
)
val json = IrisJson.instance.encodeToString(PickerItem.serializer(), item)
val back = IrisJson.instance.decodeFromString(PickerItem.serializer(), json)
assertEquals(item, back)
assertTrue(back.choices[1].isCurrent)
}
}
@@ -0,0 +1,70 @@
package iris.data
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* M8: per-lane unread tracking (the data behind the channel-view / header
* unread indicator). The increment/clear *policy* (which incoming message is
* "being read") lives in IrisController; here we cover the store's count
* semantics that the UI and controller build on.
*/
class ChatStoreUnreadTest {
@Test
fun markUnreadIncrementsPerLane() {
val store = ChatStore()
assertEquals(0, store.unreadFor("default"))
store.markUnread("default")
store.markUnread("default")
store.markUnread("chan_7")
assertEquals(2, store.unreadFor("default"))
assertEquals(1, store.unreadFor("chan_7"))
// A lane that never had a message stays at 0.
assertEquals(0, store.unreadFor("chan_9"))
}
@Test
fun markLaneReadClearsOnlyThatLane() {
val store = ChatStore()
store.markUnread("default")
store.markUnread("chan_7")
store.markLaneRead("default")
assertEquals(0, store.unreadFor("default"))
// Other lanes are untouched.
assertEquals(1, store.unreadFor("chan_7"))
}
@Test
fun markLaneReadIsIdempotent() {
val store = ChatStore()
store.markLaneRead("default") // no unread -> no-op
assertEquals(0, store.unreadFor("default"))
store.markUnread("default")
store.markLaneRead("default")
store.markLaneRead("default") // clearing twice is safe
assertEquals(0, store.unreadFor("default"))
}
@Test
fun unreadMapReflectsCounts() {
val store = ChatStore()
assertNull(store.unread.value["default"])
store.markUnread("default")
assertEquals(1, store.unread.value["default"])
store.markLaneRead("default")
// A cleared lane is removed from the map (absent == 0 unread).
assertNull(store.unread.value["default"])
}
@Test
fun clearWipesUnread() {
val store = ChatStore()
store.markUnread("default")
store.markUnread("chan_7")
store.clear()
assertEquals(0, store.unreadFor("default"))
assertEquals(0, store.unreadFor("chan_7"))
assertEquals(emptyMap(), store.unread.value)
}
}
@@ -0,0 +1,163 @@
package iris.net
import com.sun.net.httpserver.HttpsConfigurator
import com.sun.net.httpserver.HttpsServer
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.net.InetSocketAddress
import java.security.KeyFactory
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Base64
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
/**
* docs/09 §9.4: end-to-end test of the fingerprint-confirm flow over a real
* TLS handshake: a local HTTPS server presents the embedded self-signed
* certificate (SAN: 127.0.0.1) to an OkHttp client wired exactly like
* [GatewayClient] (pinning socket factory + trust manager).
*
* 1. Unpinned: the handshake fails and [tlsFingerprintRequired] unwraps the
* presented fingerprint from the nested exception.
* 2. After "confirming" (setting the pin): the SAME client connects — the
* pin is read live, no client rebuild.
*
* The embedded key is a throwaway test key, not a secret.
*/
class TlsPinningIntegrationTest {
private companion object {
// Self-signed with SAN DNS:localhost, IP:127.0.0.1 (OkHttp's hostname
// verifier requires a SAN; CN-only certs are rejected even when pinned).
val CERT_PEM =
"""
-----BEGIN CERTIFICATE-----
MIIC+zCCAeOgAwIBAgIUGXu+y1gH9kUWHAFlHOzrN3h2TRQwDQYJKoZIhvcNAQEL
BQAwGDEWMBQGA1UEAwwNaXJpcy1pbnQtdGVzdDAeFw0yNjA4MjQxOTE1MTJaFw0z
NjA4MjExOTE1MTJaMBgxFjAUBgNVBAMMDWlyaXMtaW50LXRlc3QwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtqNGuSQm7iO2GuQ+TemTZsThzPVkWrfdF
/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zcfuJqwYjCc6aOv1I9Fz2C
Eb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13Heptg6ZBcwISpE+78WVFT
qIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7bGN3YXO9TSbjogSQbzRs
PS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eCqXn5Q9avxk/VVYxjQL9a
GqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7OrYzJ4v7AgMBAAGjPTA7
MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQUA9qqz6IWojYd
WSbngx8h5vq9CTYwDQYJKoZIhvcNAQELBQADggEBAGNIGSCEy1A42UNUd+xREsHm
EmBJ7TYzxJjmweByJwdK5GjxmpaOXgcBjUb8O0Fzm8+4P2DDr/CXhv+aNYUcSCJ3
Xf5cBIXzJmTVvkLzdNpCmB9w2d66J2ZQYxCOZ4pUzvcXI6gK7qkrB2HALq2LYGtE
dxVocLizu+FGtf4ve+CuCZs3/tJaQPZYzP4UqV1oVfkg3hV+Yg1oFYFfrAelsFkw
zNjVh2jTwFiEpK5E/4OJtQaThOUkbkNcSc50ATYkPau9mA1IUTsOU/UNjMTbYtG0
Ht5KgYObXkwm44X0rgiGHgW61wqgIEa5ogfVIeCJzHwHNcn2J9yYlgYsZ/o8vrU=
-----END CERTIFICATE-----
""".trimIndent()
val KEY_PEM =
"""
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtqNGuSQm7iO2G
uQ+TemTZsThzPVkWrfdF/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zc
fuJqwYjCc6aOv1I9Fz2CEb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13H
eptg6ZBcwISpE+78WVFTqIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7
bGN3YXO9TSbjogSQbzRsPS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eC
qXn5Q9avxk/VVYxjQL9aGqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7
OrYzJ4v7AgMBAAECggEABKYo2uQcrRcY2Mr6hkW4DnXmn3ssd+V3YbnJgm4bZbd5
PS4GaeJ9RmfP1wDmOZ3dgQUY6S1574XOScbh097ThPUop9iqYHVPUbyc5n8hGoZd
sSZGzGB9CPSrdUXvmy0FwjZcTiOg/SRszcrT/w+xrDIBOy+L7diMS1OPMWp1Uz9r
yHHxpjMtALToaMUHMNCRjyFRR0fgqGWnfwRVAwdKMxMQJZ0IiUXyuqgpdIBHZC+g
WIcntUDCiglHtuI34eoQQVVMhEm2ylaAq2tBaR7z3wGtXbbfdyWUi/DIkhS5o4HN
ux7AYF87WU87/0I8GpEQHdAFQKoqVgR8uaZmJ613YQKBgQDmcGZdYg4UtcjTVSDE
BHsLnFzapSjDebVsR2XWoztcvQIduqsh+2zV8RN3UGIOd7l9tEGWMm7rFFVOOs/f
utCAd4v5ZWtSs/KtSuL6PqbFuvD9jGVPaqsSAe/2WmAtG8vA/JIndFDC5CNo/Hem
Tj7XGAmEPKgTRLR9NY1fu0we2wKBgQDA7BemZXViw4RiEjUcsqX8yuFPGKlMyoCK
ieHsIO05dLD+s6BD7r8ang0twttwhCM4RoumxjEbEbRYMhu0EJKiC+wl53EM1Vcu
OBQAlgKMVGXKmp0K/moYOIdvb4JuHoITaYhKPyO+2/2rKLK3h+swnYJDrpOcOK7H
yqy1qeMBYQKBgQDRt+GxgxfFiVtn2cWkH1/MRVXMNxtOK2oNTT1FhfD0iZ9vZv9w
Qd3fJzPMFn/nItbRrEc0ZlnD4BFyzNt6hg5TnHjrVH3EGrj1NX40uOgWc/f3CNr6
190w2kqFLeLxqqZY0IRDG/yUIgSH+5z44aUXJG0kx/8+6fxJJ3+ubErumQKBgAZF
JhegYIpPNHRDhzphjAeFSIFbmdUHF9po1NDp2QvvAPmmOOU8UzW4QVFlbeBgSwy/
LjbDZkEs+CGNr1zQ1RMzM/+fYAs8u9Kiu/Ow7HBHJe/JyqTa0/Ppkm1KwIB3uV6M
JYPUPYMsfzga4IQahMhVtjAg8mc3aGbR7X8SAHDBAoGAOXIpfZ+mLejIlw4xUXQI
MMcw57cTWPQgU6w+YHt0njY4c5GcMKBxrbBMLFv0oeBj/2ZzBxw5TSWdXpA/4j3z
OBPuigr2mnlhJR8ahq1s0BSHhQbw7TIbazALi2cZ8Mdf7/hEIzuyY4efnyV7W+RO
sZ1EltfJHT57a0ub22mRtVc=
-----END PRIVATE KEY-----
""".trimIndent()
// `openssl x509 -noout -fingerprint -sha256` over CERT_PEM.
const val EXPECTED_FINGERPRINT =
"9B:25:54:2F:55:1B:20:32:34:B9:CF:E2:BB:DE:B3:E4:74:01:BF:FE:0F:5C:39:56:BE:F7:5E:E7:72:70:EB:44"
fun pemBody(pem: String): ByteArray {
val base64 = pem.replace(Regex("-----[A-Z ]+-----"), "").replace(" ", "").replace("\n", "")
return Base64.getDecoder().decode(base64)
}
}
@Test
fun selfSignedGatewayRequiresConfirmThenPins() {
val cert =
CertificateFactory
.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(CERT_PEM.toByteArray()))
.let { it as java.security.cert.X509Certificate }
val key =
KeyFactory
.getInstance("RSA")
.generatePrivate(PKCS8EncodedKeySpec(pemBody(KEY_PEM)))
// Local HTTPS server presenting the self-signed cert.
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
ks.load(null, null)
ks.setKeyEntry("iris", key, CharArray(0), arrayOf(cert))
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(ks, CharArray(0))
val serverCtx = SSLContext.getInstance("TLS")
serverCtx.init(kmf.keyManagers, null, null)
val server = HttpsServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.httpsConfigurator = HttpsConfigurator(serverCtx)
server.createContext("/v1/health") { exchange ->
val body = "ok".toByteArray()
exchange.sendResponseHeaders(200, body.size.toLong())
exchange.responseBody.use { it.write(body) }
}
server.start()
// The client is wired exactly like GatewayClient: pinning socket
// factory + trust manager, pin read live from a mutable holder.
var pin = ""
val tm = PinningTrustManager { pin }
val client = OkHttpClient.Builder().sslSocketFactory(pinningSslSocketFactory(tm), tm).build()
val request = Request.Builder().url("https://127.0.0.1:${server.address.port}/v1/health").build()
try {
// 1. Unpinned: the handshake fails; the unwrap finds the
// presented fingerprint in the nested exception chain.
val e =
assertFailsWith<Exception> {
client.newCall(request).execute().use { it.body!!.string() }
}
val tls = tlsFingerprintRequired(e)
assertNotNull(tls, "expected TlsFingerprintRequired nested in: $e")
assertEquals(EXPECTED_FINGERPRINT, tls.fingerprint)
// 2. User "confirms" the fingerprint: the SAME client now
// connects (the pin is read live, no client rebuild).
pin = EXPECTED_FINGERPRINT
client.newCall(request).execute().use { response ->
assertEquals(200, response.code)
assertEquals("ok", response.body!!.string())
}
} finally {
server.stop(0)
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}
}
@@ -0,0 +1,129 @@
package iris.net
import java.io.ByteArrayInputStream
import java.io.IOException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* docs/09 §9.4: unit tests for the TLS fingerprint-confirm flow.
*
* The embedded certificate is a self-signed cert (CN=iris-test-gateway) the
* platform trust store does NOT contain, so it exercises the exact path a
* self-signed gateway hits: default trust manager rejects → the pinning
* manager either offers the fingerprint for confirmation or accepts the
* user-confirmed pin.
*/
class TlsPinningTest {
private companion object {
// Self-signed, 10-year validity — generated with:
// openssl req -x509 -newkey rsa:2048 -nodes -subj "/CN=iris-test-gateway"
val SELF_SIGNED_PEM =
"""
-----BEGIN CERTIFICATE-----
MIIDGTCCAgGgAwIBAgIUTNKIelZvTA7iFA+jx819cazOkE0wDQYJKoZIhvcNAQEL
BQAwHDEaMBgGA1UEAwwRaXJpcy10ZXN0LWdhdGV3YXkwHhcNMjYwODI0MTkxMDA3
WhcNMzYwODIxMTkxMDA3WjAcMRowGAYDVQQDDBFpcmlzLXRlc3QtZ2F0ZXdheTCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANZ2zK/jRQFB+dHSCfXVm9pp
9+scyP3CjQr7Ec6b/aNfBKGoOXM5m8fvmYjJefeWgBLpr8I+g0BIn2+BNK80Tp3V
LlHiu3DQMmPfd7XTVQhmq19pjEYYsCcZ8QnPZ/WwMSRaQar0NKK6TS2MOHA8VdEs
BjeQoiTczO+HXlzXf20nhEtnWfNc3RBM0y6GIu+eKKKb9Hiri6LdpecQ9pGdxLXc
HZP6SjM5FH/prqoVPGV+Q1wCh6K0iwUjCGrsO0QDFvoe4W2eLG1QW6LEpNj7ym66
UmVG3aB9q3zpZ4Cc3kHVV43QqSgp+t5BtBLIWM0bnZ2IPbdSexpI22+AANliY3UC
AwEAAaNTMFEwHQYDVR0OBBYEFKUZIe8CbNWYSNkZBMRKRIYpGErJMB8GA1UdIwQY
MBaAFKUZIe8CbNWYSNkZBMRKRIYpGErJMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADggEBACJLF9A7OKWQU3wBWw00ezf6zdQcZHJvGcBTr0WSDg5/QNsj
GD8Dz8Feu1zCVEKXAxB3NaiO7IS/S/kR8Oo0SVs55JEfW5BHGs5Mdt74/Ch8khy/
Wvvj2BZakmqyW5LZkxlIPEoyhhyoCSBGQIpmCDQXKAlSrUZj9gaAn4uaBOVBIu4S
vIQZTtouhc1rX+Ov0HgwBCBbDPL2pBjkUUKqUrD249eyL2+qTWLAf6J56x6UYFAA
I2Eg5W3bTqLYz8CbnmKrR7KhfTAmkgCY1HIQpWoIVAsXRmaebzPsYeGK5gf6tnP2
vn2/tqbereUqqCMRr0wBZjaJzPnu46N43yOGrEs=
-----END CERTIFICATE-----
""".trimIndent()
// `openssl x509 -noout -fingerprint -sha256` over the same cert.
const val EXPECTED_FINGERPRINT =
"C8:16:8E:7A:7F:9D:6E:20:07:6F:85:50:F7:B3:E4:B4:9C:DB:70:CA:D8:18:1B:4B:50:FA:5D:FC:4A:62:8C:FA"
val CERT: X509Certificate by lazy {
CertificateFactory
.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(SELF_SIGNED_PEM.toByteArray()))
.let { it as X509Certificate }
}
}
@Test
fun certFingerprintMatchesOpenSsl() {
assertEquals(EXPECTED_FINGERPRINT, certFingerprint(CERT))
}
@Test
fun unpinnedSelfSignedCertOffersFingerprintForConfirmation() {
val tm = PinningTrustManager { "" }
val e =
assertFailsWith<TlsFingerprintRequired> {
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
}
@Test
fun confirmedPinAcceptsTheSelfSignedCert() {
val tm = PinningTrustManager { EXPECTED_FINGERPRINT }
// Must not throw: the user confirmed exactly this certificate.
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
@Test
fun wrongPinStillFailsWithThePresentedFingerprint() {
val tm = PinningTrustManager { "DE:AD:BE:EF" }
val e =
assertFailsWith<TlsFingerprintRequired> {
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
}
@Test
fun pinIsReadLive() {
// The provider is a lambda: a pin saved AFTER the manager was built
// (the confirm dialog's action) takes effect without rebuilding it.
var pin = ""
val tm = PinningTrustManager { pin }
assertFailsWith<TlsFingerprintRequired> {
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
pin = EXPECTED_FINGERPRINT
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
@Test
fun unwrapFindsNestedTlsFingerprintRequired() {
val inner = TlsFingerprintRequired(EXPECTED_FINGERPRINT)
// The JSSE/OkHttp layers wrap the trust manager's exception in an
// (SSL) handshake IOException — the unwrap must find it nested.
val wrapped = IOException("PKIX path building failed", inner)
val found = tlsFingerprintRequired(wrapped)
assertNotNull(found)
assertEquals(EXPECTED_FINGERPRINT, found.fingerprint)
// Unrelated failures must not be misread as a confirm request.
assertNull(tlsFingerprintRequired(IOException("remote host closed connection")))
assertNull(tlsFingerprintRequired(IllegalStateException("gateway unreachable")))
}
@Test
fun pinningSocketFactoryCreatesSockets() {
val tm = PinningTrustManager { "" }
val factory = pinningSslSocketFactory(tm)
val socket = factory.createSocket()
assertTrue(socket.javaClass.name.contains("SSL"))
socket.close()
}
}
@@ -0,0 +1,31 @@
package iris.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
/** Wire tests for the hello.ack payload (docs/04). */
class HelloAckWireTest {
@Test
fun helloAckDeserializesDeviceToken() {
val raw =
"""
{"v":1,"type":"hello.ack","payload":{"sync_cursor":5,
"last_pushed_cursor":3,"device_token":"9f2c64hex"}}
""".trimIndent()
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
assertEquals("hello.ack", frame.type)
val p = frame.payloadAs<HelloAckPayload>()
assertEquals("9f2c64hex", p?.deviceToken)
assertEquals(5L, p?.syncCursor)
assertEquals(3L, p?.lastPushedCursor)
}
@Test
fun helloAckDefaultsDeviceTokenToEmpty() {
// Legacy gateways (pre per-device tokens) omit the field entirely.
val raw = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":1}}"""
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
val p = frame.payloadAs<HelloAckPayload>()
assertEquals("", p?.deviceToken)
}
}
@@ -1,6 +1,7 @@
package iris.platform package iris.platform
import iris.state.IrisController import iris.state.IrisController
import java.util.concurrent.CopyOnWriteArrayList
/** /**
* M6: bridge between the desktop shell (tray / window) and the shared * M6: bridge between the desktop shell (tray / window) and the shared
@@ -12,19 +13,35 @@ typealias NotificationListener =
(chatId: String?, title: String, body: String, threadId: String?) -> Unit (chatId: String?, title: String, body: String, threadId: String?) -> Unit
object DesktopBridge { object DesktopBridge {
/** True while the window has focus (set by the shell). A change is
* forwarded to the controller (M8: unread clear on focus). */
@Volatile @Volatile
var foreground: Boolean = true var foreground: Boolean = true
set(value) {
if (field != value) {
field = value
controller?.setForeground(value)
}
}
@Volatile @Volatile
var controller: IrisController? = null var controller: IrisController? = null
private val notificationListeners = mutableListOf<NotificationListener>() // M-13: CopyOnWriteArrayList — listeners are added from the UI thread and
// iterated from the OkHttp callback thread; a plain mutableListOf's
// .toList() copy is not atomic with a concurrent add.
private val notificationListeners = CopyOnWriteArrayList<NotificationListener>()
fun onNotification(listener: NotificationListener) { fun onNotification(listener: NotificationListener) {
notificationListeners.add(listener) notificationListeners.add(listener)
} }
fun notifyListeners(chatId: String?, title: String, body: String, threadId: String?) { fun notifyListeners(
notificationListeners.toList().forEach { it(chatId, title, body, threadId) } chatId: String?,
title: String,
body: String,
threadId: String?,
) {
notificationListeners.forEach { it(chatId, title, body, threadId) }
} }
} }
@@ -1,7 +1,6 @@
package iris.platform package iris.platform
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -35,7 +34,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonArray
@@ -62,7 +60,6 @@ import kotlin.random.Random
*/ */
@Composable @Composable
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) { actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
var busy by remember { mutableStateOf(false) }
Column( Column(
modifier = modifier =
Modifier Modifier
@@ -71,16 +68,10 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
) { ) {
Text("Attach a file:", fontSize = 13.sp) Text("Attach a file:", fontSize = 13.sp)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button( // pickFile() blocks the UI thread (JFileChooser is modal), so there's
onClick = { // no in-flight state to show while the dialog is open.
busy = true Button(onClick = { onPicked(pickFile()) }) {
val picked = pickFile() Text("Choose file…")
busy = false
onPicked(picked)
},
enabled = !busy,
) {
Text(if (busy) "Choosing…" else "Choose file…")
} }
} }
} }
@@ -138,7 +129,8 @@ private fun guessMime(name: String): String {
"mov" -> "video/quicktime" "mov" -> "video/quicktime"
"mkv" -> "video/x-matroska" "mkv" -> "video/x-matroska"
"mp3" -> "audio/mpeg" "mp3" -> "audio/mpeg"
"m4a", "aac" -> "audio/mp4" "m4a" -> "audio/mp4"
"aac" -> "audio/aac"
"ogg", "opus" -> "audio/ogg" "ogg", "opus" -> "audio/ogg"
"wav" -> "audio/wav" "wav" -> "audio/wav"
"flac" -> "audio/flac" "flac" -> "audio/flac"
@@ -59,7 +59,7 @@ object DesktopNotifier {
"\$n = New-Object System.Windows.Forms.NotifyIcon; " + "\$n = New-Object System.Windows.Forms.NotifyIcon; " +
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " + "\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
"\$n.Visible = \$true; " + "\$n.Visible = \$true; " +
"\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " + "\$n.ShowBalloonTip(4000, \"${psEsc(title)}\", \"${psEsc(body)}\", " +
"[System.Windows.Forms.ToolTipIcon]::Info); " + "[System.Windows.Forms.ToolTipIcon]::Info); " +
"Start-Sleep -Milliseconds 4500; \$n.Dispose()", "Start-Sleep -Milliseconds 4500; \$n.Dispose()",
) )
@@ -71,4 +71,16 @@ object DesktopNotifier {
} }
private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ") private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
// M-16: PowerShell uses backtick escaping (not backslash) and treats `$`
// as a variable reference, so a body containing `$foo` would be mangled or
// error. Escape backtick first (so the backticks we add aren't doubled),
// then `$` and `"`. Newlines collapse to spaces (balloon tips are single
// line).
private fun psEsc(s: String): String =
s
.replace("`", "``")
.replace("$", "`$")
.replace("\"", "`\"")
.replace("\n", " ")
} }
@@ -27,7 +27,30 @@ class DesktopSecureStore : SecureStore {
private val baseDir = File(System.getProperty("user.home"), ".iris") private val baseDir = File(System.getProperty("user.home"), ".iris")
private val settingsFile = File(baseDir, "settings.json") private val settingsFile = File(baseDir, "settings.json")
private val legacyFile = File(baseDir, "pairing.json") private val legacyFile = File(baseDir, "pairing.json")
private val secret = SecretBackend(baseDir) private val secret =
SecretBackend(
baseDir,
keyringService = "iris-gateway-token",
keyringLabel = "Iris gateway token",
keyringAttr = "iris",
encFileName = "pairing.enc",
)
// Per-device token (docs/09 §9.3): a second secret slot, same backends.
private val deviceSecret =
SecretBackend(
baseDir,
keyringService = "iris-device-token",
keyringLabel = "Iris device token",
keyringAttr = "iris-device",
encFileName = "device_token.enc",
)
// M-8: cache the parsed settings so hot-path getters (serverUrl/token per
// connect attempt) don't re-read + re-parse the file on every access.
// Invalidated on every save(). DesktopSecureStore is a per-process
// singleton (created once in Main.kt), so a per-instance cache is safe.
private var cached: Settings? = null
@Serializable @Serializable
private data class Settings( private data class Settings(
@@ -50,6 +73,7 @@ class DesktopSecureStore : SecureStore {
val fontSizeScale: Float = 1.0f, val fontSizeScale: Float = 1.0f,
val runtimeFooterEnabled: Boolean = false, val runtimeFooterEnabled: Boolean = false,
val runtimeFooterFields: String = "", val runtimeFooterFields: String = "",
val pinnedCertFingerprint: String = "",
) )
init { init {
@@ -77,11 +101,15 @@ class DesktopSecureStore : SecureStore {
), ),
) )
if (legacy.token.isNotBlank()) secret.write(legacy.token) if (legacy.token.isNotBlank()) secret.write(legacy.token)
} // L-49: only delete the legacy file after a successful migration;
// a parse failure (legacy == null) must not destroy the data.
legacyFile.delete() legacyFile.delete()
} }
}
private fun load(): Settings = private fun load(): Settings {
cached?.let { return it }
val s =
if (settingsFile.exists()) { if (settingsFile.exists()) {
try { try {
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText()) IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
@@ -91,10 +119,14 @@ class DesktopSecureStore : SecureStore {
} else { } else {
Settings() Settings()
} }
cached = s
return s
}
private fun save(data: Settings) { private fun save(data: Settings) {
baseDir.mkdirs() baseDir.mkdirs()
settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data)) settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data))
cached = data
} }
override var serverUrl: String override var serverUrl: String
@@ -110,6 +142,12 @@ class DesktopSecureStore : SecureStore {
if (value.isBlank()) secret.clear() else secret.write(value.trim()) if (value.isBlank()) secret.clear() else secret.write(value.trim())
} }
override var deviceToken: String
get() = deviceSecret.read().orEmpty()
set(value) {
if (value.isBlank()) deviceSecret.clear() else deviceSecret.write(value.trim())
}
override val deviceId: String override val deviceId: String
get() { get() {
val d = load() val d = load()
@@ -206,7 +244,7 @@ class DesktopSecureStore : SecureStore {
} }
override var backgroundMode: String override var backgroundMode: String
get() = load().backgroundMode.ifBlank { "color" } get() = load().backgroundMode.ifBlank { BackgroundMode.Image.name.lowercase() }
set(value) { set(value) {
val d = load() val d = load()
save(d.copy(backgroundMode = value)) save(d.copy(backgroundMode = value))
@@ -247,6 +285,13 @@ class DesktopSecureStore : SecureStore {
save(d.copy(runtimeFooterFields = value)) save(d.copy(runtimeFooterFields = value))
} }
override var pinnedCertFingerprint: String
get() = load().pinnedCertFingerprint
set(value) {
val d = load()
save(d.copy(pinnedCertFingerprint = value.trim()))
}
override fun savePairing( override fun savePairing(
url: String, url: String,
token: String, token: String,
@@ -254,12 +299,31 @@ class DesktopSecureStore : SecureStore {
val d = load() val d = load()
save(d.copy(serverUrl = url.trim())) save(d.copy(serverUrl = url.trim()))
if (token.isBlank()) secret.clear() else secret.write(token.trim()) if (token.isBlank()) secret.clear() else secret.write(token.trim())
// A (re-)pair may target a different gateway: the old per-device
// token is dead there. The next hello.ack re-mints/returns it.
deviceSecret.clear()
} }
override fun clear() { override fun clear() {
// M-10: clearing pairing must also wipe the device identity + push
// state, otherwise a re-pair to a different gateway would keep the old
// deviceId/syncCursor/ntfyTopic and the server would treat the new
// pairing as the same device.
val d = load() val d = load()
save(d.copy(serverUrl = "")) save(
d.copy(
serverUrl = "",
deviceId = "",
syncCursor = 0L,
fcmToken = "",
ntfyTopic = "",
ntfyServer = "",
pushBackend = "",
pinnedCertFingerprint = "",
),
)
secret.clear() secret.clear()
deviceSecret.clear()
} }
} }
@@ -278,20 +342,33 @@ private data class PairingData(
/** /**
* Token storage: OS keyring when available, else an AES-GCM encrypted file. * Token storage: OS keyring when available, else an AES-GCM encrypted file.
* All backend failures degrade to the encrypted file (never plaintext). * All backend failures degrade to the encrypted file (never plaintext).
*
* Parameterized so the shared gateway token and the per-device token
* (docs/09 §9.3) each get their own keyring entry / encrypted file.
*/ */
private class SecretBackend( private class SecretBackend(
private val baseDir: File, private val baseDir: File,
private val keyringService: String,
private val keyringLabel: String,
private val keyringAttr: String,
encFileName: String,
) { ) {
private val encFile = File(baseDir, "pairing.enc") private val encFile = File(baseDir, encFileName)
private val keyFile = File(baseDir, ".key") private val keyFile = File(baseDir, ".key")
private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available } private val keyring: KeyringBackend? =
KeyringBackend(keyringService, keyringLabel, keyringAttr).takeIf { it.available }
fun read(): String? = keyring?.read() ?: readEncrypted() fun read(): String? = keyring?.read() ?: readEncrypted()
fun write(value: String) { fun write(value: String) {
if (keyring != null) { if (keyring != null) {
keyring.write(value) keyring.write(value)
if (keyring.read() == value) return if (keyring.read() == value) {
// L-50: the keyring now holds the secret; drop the stale
// encrypted file so the old token can't be read back.
encFile.delete()
return
}
// Keyring accepted the write but did not persist it (e.g. KWallet // Keyring accepted the write but did not persist it (e.g. KWallet
// without a live daemon). Drop the stale entry and fall back to // without a live daemon). Drop the stale entry and fall back to
// the encrypted file so the token survives a restart. // the encrypted file so the token survives a restart.
@@ -355,7 +432,11 @@ private class SecretBackend(
} }
/** OS keyring via the platform CLI (best effort). */ /** OS keyring via the platform CLI (best effort). */
private class KeyringBackend { private class KeyringBackend(
private val service: String,
private val label: String,
private val attr: String,
) {
private val os = System.getProperty("os.name").lowercase() private val os = System.getProperty("os.name").lowercase()
private val isMac = os.contains("mac") private val isMac = os.contains("mac")
private val isLinux = os.contains("linux") private val isLinux = os.contains("linux")
@@ -374,9 +455,9 @@ private class KeyringBackend {
fun read(): String? = fun read(): String? =
try { try {
if (isMac) { if (isMac) {
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w")) out(listOf("security", "find-generic-password", "-a", "iris", "-s", service, "-w"))
} else { } else {
out(listOf("secret-tool", "lookup", "app", "iris")) out(listOf("secret-tool", "lookup", "app", attr))
} }
} catch (_: Exception) { } catch (_: Exception) {
null null
@@ -384,28 +465,28 @@ private class KeyringBackend {
fun write(value: String) { fun write(value: String) {
try { try {
// M-9: pass the secret on stdin, not as a CLI argument. A trailing
// argument is visible in the process list (`ps`); `secret-tool
// store` and `security add-generic-password -w` both read the
// secret from stdin when no value argument is given.
val cmd =
if (isMac) { if (isMac) {
ProcessBuilder( listOf(
"security", "security",
"add-generic-password", "add-generic-password",
"-U", "-U",
"-a", "-a",
"iris", "iris",
"-s", "-s",
"iris-gateway-token", service,
"-w", "-w",
value, )
).inheritIO().start().waitFor()
} else { } else {
ProcessBuilder( listOf("secret-tool", "store", "--label=$label", "app", attr)
"secret-tool", }
"store", ProcessBuilder(cmd).start().apply {
"--label=Iris gateway token", outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
"app", waitFor()
"iris",
"token",
value,
).inheritIO().start().waitFor()
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -420,14 +501,14 @@ private class KeyringBackend {
"-a", "-a",
"iris", "iris",
"-s", "-s",
"iris-gateway-token", service,
).inheritIO().start().waitFor() ).inheritIO().start().waitFor()
} else { } else {
ProcessBuilder( ProcessBuilder(
"secret-tool", "secret-tool",
"clear", "clear",
"app", "app",
"iris", attr,
).inheritIO().start().waitFor() ).inheritIO().start().waitFor()
} }
} catch (_: Exception) { } catch (_: Exception) {
@@ -0,0 +1,12 @@
package iris.platform
import androidx.compose.runtime.Composable
/**
* Desktop has no camera, so there is nothing to scan. The Connect screen hides
* this button on desktop (`isDesktop`), so this is never rendered.
*/
@Composable
actual fun QrScanButton(onResult: (String?) -> Unit) {
// No-op on desktop.
}
@@ -1,16 +0,0 @@
package iris.media
import java.io.File
actual class FileSource actual constructor(path: String) : AutoCloseable {
private val file = File(path)
private val input = file.inputStream()
actual fun size(): Long = file.length()
actual fun read(buf: ByteArray): Int = input.read(buf)
override fun close() {
input.close()
}
}
@@ -5,22 +5,44 @@ import java.io.FileOutputStream
actual interface MediaWriter : AutoCloseable { actual interface MediaWriter : AutoCloseable {
actual val path: String actual val path: String
actual fun write(bytes: ByteArray) actual fun write(bytes: ByteArray)
} }
actual class MediaCache actual constructor(baseDir: String) { actual class MediaCache actual constructor(
baseDir: String,
) {
private val maxBytes: Long = 500L * 1024 * 1024 private val maxBytes: Long = 500L * 1024 * 1024
private val mediaDir: File = File(baseDir, "media").apply { mkdirs() } private val mediaDir: File = File(baseDir, "media").apply { mkdirs() }
actual fun path(mediaId: String, mime: String): String? { /**
val f = File(mediaDir, "$mediaId${extForMime(mime)}") * Resolve the cache file for [mediaId]. The id is server-controlled
* (`media.offer`); reject path-traversal values so a hostile gateway can't
* write outside [mediaDir] (M-2 / S-1). Returns null when the id is
* invalid.
*/
private fun fileFor(
mediaId: String,
mime: String,
): File? = if (!isValidMediaId(mediaId)) null else File(mediaDir, "$mediaId${extForMime(mime)}")
actual fun path(
mediaId: String,
mime: String,
): String? {
val f = fileFor(mediaId, mime) ?: return null
return if (f.exists()) f.absolutePath else null return if (f.exists()) f.absolutePath else null
} }
actual fun openWriter(mediaId: String, mime: String): MediaWriter = actual fun openWriter(
JvmMediaWriter(File(mediaDir, "$mediaId${extForMime(mime)}"), this) mediaId: String,
mime: String,
): MediaWriter = JvmMediaWriter(fileFor(mediaId, mime) ?: throw IllegalArgumentException("invalid media id"), this)
actual fun remove(mediaId: String, mime: String) { actual fun remove(
mediaId: String,
mime: String,
) {
path(mediaId, mime)?.let { File(it).delete() } path(mediaId, mime)?.let { File(it).delete() }
} }
@@ -36,7 +58,10 @@ actual class MediaCache actual constructor(baseDir: String) {
} }
} }
private class JvmMediaWriter(private val target: File, private val cache: MediaCache) : MediaWriter { private class JvmMediaWriter(
private val target: File,
private val cache: MediaCache,
) : MediaWriter {
private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part")) private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part"))
override val path: String get() = target.absolutePath override val path: String get() = target.absolutePath
@@ -43,24 +43,24 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveLanes( db.saveLanes(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
msg("m1", ts = 100), msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"),
msg("m2", role = "assistant", text = "hi", ts = 200), msg("m2", role = "assistant", text = "hi", ts = 200),
), ),
"android:default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)), "default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)),
), ),
) )
val loaded = db.loadLanes() val loaded = db.loadLanes()
assertEquals(setOf("android:default", "android:default::thr_1"), loaded.keys) assertEquals(setOf("default", "default::thr_1"), loaded.keys)
// Tool cards are persisted and restored at their anchored position // Tool cards are persisted and restored at their anchored position
// (after the message they follow, before the answer). // (after the message they follow, before the answer).
assertEquals(listOf("m1", "tool_1", "m2"), loaded["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "tool_1", "m2"), loaded["default"]!!.map { it.id })
assertEquals(listOf("m3"), loaded["android:default::thr_1"]!!.map { it.id }) assertEquals(listOf("m3"), loaded["default::thr_1"]!!.map { it.id })
// Messages ordered by ts. // Messages ordered by ts.
assertEquals(100L, (loaded["android:default"]!![0] as MessageItem).ts) assertEquals(100L, (loaded["default"]!![0] as MessageItem).ts)
assertEquals(200L, (loaded["android:default"]!![2] as MessageItem).ts) assertEquals(200L, (loaded["default"]!![2] as MessageItem).ts)
} }
@Test @Test
@@ -68,7 +68,7 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveLanes( db.saveLanes(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
msg("m1", ts = 100), msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"),
@@ -77,7 +77,7 @@ class ChatDbTest {
), ),
), ),
) )
assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["default"]!!.map { it.id })
} }
@Test @Test
@@ -85,7 +85,7 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveLanes( db.saveLanes(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
msg("m1", ts = 100), msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"), ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"),
@@ -93,7 +93,7 @@ class ChatDbTest {
), ),
), ),
) )
assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["default"]!!.map { it.id })
} }
@Test @Test
@@ -101,7 +101,7 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveLanes( db.saveLanes(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
msg("m1", ts = 100), msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"),
@@ -109,7 +109,7 @@ class ChatDbTest {
), ),
), ),
) )
val lane = db.loadLanes()["android:default"]!! val lane = db.loadLanes()["default"]!!
// The process died before tool.end — the open card is closed as // The process died before tool.end — the open card is closed as
// interrupted, not left spinning. // interrupted, not left spinning.
val open = lane.first { it.id == "tool_1" } as ToolItem val open = lane.first { it.id == "tool_1" } as ToolItem
@@ -123,9 +123,9 @@ class ChatDbTest {
@Test @Test
fun saveLanesReplacesPreviousSnapshot() { fun saveLanesReplacesPreviousSnapshot() {
val db = newDb() val db = newDb()
db.saveLanes(mapOf("android:default" to listOf(msg("m1"), msg("m2")))) db.saveLanes(mapOf("default" to listOf(msg("m1"), msg("m2"))))
db.saveLanes(mapOf("android:default" to listOf(msg("m2")))) db.saveLanes(mapOf("default" to listOf(msg("m2"))))
assertEquals(listOf("m2"), db.loadLanes()["android:default"]!!.map { it.id }) assertEquals(listOf("m2"), db.loadLanes()["default"]!!.map { it.id })
} }
@Test @Test
@@ -133,7 +133,7 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveLanes( db.saveLanes(
mapOf( mapOf(
"android:default" to "default" to
listOf( listOf(
msg("p1", status = MsgStatus.Pending, pending = true, ts = 0), msg("p1", status = MsgStatus.Pending, pending = true, ts = 0),
msg("s1", role = "assistant", streaming = true, ts = 500), msg("s1", role = "assistant", streaming = true, ts = 500),
@@ -141,7 +141,7 @@ class ChatDbTest {
), ),
), ),
) )
val lane = db.loadLanes()["android:default"]!! val lane = db.loadLanes()["default"]!!
val msgItem = { id: String -> lane.first { it.id == id } as MessageItem } val msgItem = { id: String -> lane.first { it.id == id } as MessageItem }
// A pending send becomes failed (tap to retry); the gateway never // A pending send becomes failed (tap to retry); the gateway never
// acknowledged it before the process died. // acknowledged it before the process died.
@@ -156,7 +156,7 @@ class ChatDbTest {
@Test @Test
fun systemMessagesAreNotPersisted() { fun systemMessagesAreNotPersisted() {
val db = newDb() val db = newDb()
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true)))) db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true))))
assertTrue(db.loadLanes().isEmpty()) assertTrue(db.loadLanes().isEmpty())
} }
@@ -182,8 +182,8 @@ class ChatDbTest {
), ),
), ),
) )
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(item))) db.saveLanes(mapOf("default" to listOf<ChatItem>(item)))
val loaded = db.loadLanes()["android:default"]!!.first() as MessageItem val loaded = db.loadLanes()["default"]!!.first() as MessageItem
assertEquals("gpt", loaded.runtime?.model) assertEquals("gpt", loaded.runtime?.model)
assertEquals("/tmp/a.png", loaded.media.first().localPath) assertEquals("/tmp/a.png", loaded.media.first().localPath)
} }
@@ -193,26 +193,26 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveChannels( db.saveChannels(
listOf( listOf(
ChannelInfo(chatId = "android:default", name = "General", isDefault = true), ChannelInfo(chatId = "default", name = "General", isDefault = true),
ChannelInfo(chatId = "android:chan_1", name = "Work"), ChannelInfo(chatId = "chan_1", name = "Work"),
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "android:chan_1"), ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "chan_1"),
), ),
) )
val loaded = db.loadChannels() val loaded = db.loadChannels()
assertEquals(3, loaded.size) assertEquals(3, loaded.size)
assertEquals("Work", loaded.first { it.chatId == "android:chan_1" }.name) assertEquals("Work", loaded.first { it.chatId == "chan_1" }.name)
assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind) assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind)
assertEquals(true, loaded.first { it.chatId == "android:default" }.isDefault) assertEquals(true, loaded.first { it.chatId == "default" }.isDefault)
} }
@Test @Test
fun metaRoundTripAndClearAll() { fun metaRoundTripAndClearAll() {
val db = newDb() val db = newDb()
assertNull(db.metaGet("last_lane")) assertNull(db.metaGet("last_lane"))
db.metaPut("last_lane", "android:chan_1") db.metaPut("last_lane", "chan_1")
assertEquals("android:chan_1", db.metaGet("last_lane")) assertEquals("chan_1", db.metaGet("last_lane"))
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(msg("m1")))) db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("m1"))))
db.saveChannels(listOf(ChannelInfo(chatId = "android:default", name = "General"))) db.saveChannels(listOf(ChannelInfo(chatId = "default", name = "General")))
db.clearAll() db.clearAll()
assertTrue(db.loadLanes().isEmpty()) assertTrue(db.loadLanes().isEmpty())
assertEquals(emptyList<ChannelInfo>(), db.loadChannels()) assertEquals(emptyList<ChannelInfo>(), db.loadChannels())
@@ -0,0 +1,89 @@
package iris.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Parser tests for `iris://pair` links (docs/20). The payload shape mirrors
* `pairing.qr_payload` on the gateway side.
*/
class PairLinkTest {
private val token = "ab".repeat(32) // 64 hex chars
@Test
fun parsesValidLink() {
val link = PairLink.parse("iris://pair?host=192.168.1.50&port=8791&secure=0&token=$token")
assertEquals("http://192.168.1.50:8791", link?.url)
assertEquals(token, link?.token)
}
@Test
fun defaultsPortTo8791() {
val link = PairLink.parse("iris://pair?host=10.0.0.5&token=$token")
assertEquals("http://10.0.0.5:8791", link?.url)
}
@Test
fun secureOneSelectsHttps() {
val link = PairLink.parse("iris://pair?host=10.0.0.5&port=8443&secure=1&token=$token")
assertEquals("https://10.0.0.5:8443", link?.url)
}
@Test
fun schemeIsCaseInsensitive() {
val link = PairLink.parse("IRIS://pair?host=10.0.0.5&token=$token")
assertEquals("http://10.0.0.5:8791", link?.url)
}
@Test
fun percentDecodesHost() {
// A hostname with a space would be %20 on the wire.
val link = PairLink.parse("iris://pair?host=my%20host&token=$token")
assertEquals("http://my host:8791", link?.url)
}
@Test
fun rejectsMissingToken() {
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=8791"))
}
@Test
fun rejectsEmptyToken() {
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&token="))
}
@Test
fun rejectsMissingHost() {
assertNull(PairLink.parse("iris://pair?port=8791&token=$token"))
}
@Test
fun rejectsBadPort() {
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=0&token=$token"))
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=70000&token=$token"))
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=abc&token=$token"))
}
@Test
fun rejectsWrongScheme() {
assertNull(PairLink.parse("foo://pair?host=10.0.0.5&token=$token"))
}
@Test
fun rejectsWrongHost() {
assertNull(PairLink.parse("iris://chat?host=10.0.0.5&token=$token"))
}
@Test
fun rejectsNoAuthority() {
assertNull(PairLink.parse("pair?host=10.0.0.5&token=$token"))
}
@Test
fun acceptsBoundaryPorts() {
assertTrue(PairLink.parse("iris://pair?host=h&port=1&token=$token") != null)
assertTrue(PairLink.parse("iris://pair?host=h&port=65535&token=$token") != null)
}
}
+10 -10
View File
@@ -11,8 +11,8 @@ create.
## Goals ## Goals
- **Native feel.** Real Android app (Kotlin/Compose), not a WebView. Desktop - **Native feel.** Real native app (Iris on Android, Kotlin/Compose), not a
app that is the same app, resized for a big screen. WebView. Desktop app that is the same app, resized for a big screen.
- **First-class gateway citizen.** The app is a hermes *messaging platform*, so - **First-class gateway citizen.** The app is a hermes *messaging platform*, so
everything the gateway already does "just works": slash commands, cron everything the gateway already does "just works": slash commands, cron
delivery, `send_message` routing, coexistence with Telegram/Discord/etc. delivery, `send_message` routing, coexistence with Telegram/Discord/etc.
@@ -40,13 +40,13 @@ Everything in the feature checklist below.
## Feature checklist → where it's handled ## Feature checklist → where it's handled
| Requirement | Gateway plugin | App | | Requirement | Gateway plugin | App |
|---|---|---| | --- | --- | --- |
| Input box, auto-grow (max height) | — | Compose `TextField` + bounded `heightIn` | | Input box, auto-grow (max height) | — | Compose `TextField` + bounded `heightIn` |
| Menu button → all slash commands | Dispatches `/…`; serves command catalog | Bottom-sheet menu + `/` autocomplete | | Menu button → all slash commands | Dispatches `/…`; serves command catalog | Bottom-sheet menu + `/` autocomplete |
| Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing | | Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing |
| Reasoning shown before message | Captures + splits reasoning | Collapsible "Reasoning" block above message | | Reasoning shown before message | Captures + splits reasoning | Collapsible "Reasoning" block above message |
| Intermediate messages | Forwards `Commentary` events | Distinct dimmed bubble | | Intermediate messages | Forwards `Commentary` events | Distinct dimmed bubble |
| Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=android:<chat>[:<thread>]` | Channel list, thread toggle, "new channel" | | Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=iris:<chat>[:<thread>]` | Channel list, thread toggle, "new channel" |
| Search ("everywhere" / "this chat/channel") | FTS5 session search bridge | Search UI + scope toggle | | Search ("everywhere" / "this chat/channel") | FTS5 session search bridge | Search UI + scope toggle |
| Attach media (music/video/images/docs) | Inbound cache; outbound `send_*` | Pickers + chunked upload + preview | | Attach media (music/video/images/docs) | Inbound cache; outbound `send_*` | Pickers + chunked upload + preview |
| Push notifications | FCM (primary) / ntfy (fallback) | FCM token / ntfy topic + notification service | | Push notifications | FCM (primary) / ntfy (fallback) | FCM token / ntfy topic + notification service |
@@ -55,9 +55,9 @@ Everything in the feature checklist below.
## Locked decisions (from planning) ## Locked decisions (from planning)
| Decision | Choice | | Decision | Choice |
|---|---| | --- | --- |
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) | | Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
| Push backend | **Both** — FCM primary, ntfy fallback (`ANDROID_PUSH_BACKEND`) | | Push backend | **Both** — ntfy default, FCM optional (`IRIS_PUSH_BACKEND`) |
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) | | Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) |
| Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) | | Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) |
@@ -66,7 +66,7 @@ Everything in the feature checklist below.
1. **`hermes-agent/` is a read-only research reference.** It lives next to this 1. **`hermes-agent/` is a read-only research reference.** It lives next to this
folder for study only. It is **git-ignored** and must **never** be committed, folder for study only. It is **git-ignored** and must **never** be committed,
pushed, or included in any artifact. Our plugin is *installed* into a live pushed, or included in any artifact. Our plugin is *installed* into a live
hermes home (`~/.hermes/plugins/android`); we never edit hermes core files. hermes home (`~/.hermes/plugins/iris`); we never edit hermes core files.
2. **ADB is available and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S, 2. **ADB is available and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S,
Android 10 / API 29). Use `adb install` / `adb logcat` / `adb shell am start` Android 10 / API 29). Use `adb install` / `adb logcat` / `adb shell am start`
to install, launch, and debug the app on-device throughout the build. to install, launch, and debug the app on-device throughout the build.
@@ -74,7 +74,7 @@ Everything in the feature checklist below.
## Verified environment state (2026-08-19) ## Verified environment state (2026-08-19)
| Item | State | | Item | State |
|---|---| | --- | --- |
| OS | CachyOS (Arch-based), `pacman` present | | OS | CachyOS (Arch-based), `pacman` present |
| JDK | **Not installed** → Milestone M0 (`pacman -S jdk17-openjdk`) | | JDK | **Not installed** → Milestone M0 (`pacman -S jdk17-openjdk`) |
| Android SDK | **Not installed** → M0 (cmdline-tools + sdkmanager) | | Android SDK | **Not installed** → M0 (cmdline-tools + sdkmanager) |
@@ -88,6 +88,6 @@ Everything in the feature checklist below.
## Naming ## Naming
- Product/effort name: **Iris × Hermes** (folder `iris_x_hermes`). - Product/effort name: **Iris × Hermes** (folder `iris_x_hermes`).
- hermes platform name: **`android`** (the plugin registers `Platform("android")`). - hermes platform name: **`iris`** (the plugin registers `Platform("iris")`).
- WS default port: **8790** (configurable). - WS default port: **8790** (configurable).
- Default chat id: **`android:default`** (the home channel). - Default chat id: **`default`** (the home channel).
+9 -9
View File
@@ -11,8 +11,8 @@
│ │ │ │ │ │ │ │ │ │
│ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │ │ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │
│ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │ │ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │
│ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │ │ │ │ IRIS PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
│ │ │ AndroidAdapter │ JSON │ ws://host:8790/ws │ │ │ │ │ │ IrisAdapter │ JSON │ ws://host:8790/ws │ │ │
│ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │ │ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │
│ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │ │ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │
│ │ │ • media cache │ │ WSS │ │ │ │ │ • media cache │ │ WSS │ │
@@ -26,7 +26,7 @@
│ │ Google FCM cloud │ │ │ Google FCM cloud │
▼ │ │ │ ▼ │ │ │
┌────────────────────────┐ │ ▼ │ ┌────────────────────────┐ │ ▼ │
│ ANDROID APP │◄──┴── (wake) ┌──────────┐ │ IRIS APP (ANDROID) │◄──┴── (wake) ┌──────────┐
│ (Kotlin / Compose) │ WSS │ PHONE │ │ (Kotlin / Compose) │ WSS │ PHONE │
│ • WS client (OkHttp) │◄───────────►│ MIX 2S │ │ • WS client (OkHttp) │◄───────────►│ MIX 2S │
│ • ExoPlayer │ │ (API 29) │ │ • ExoPlayer │ │ (API 29) │
@@ -40,8 +40,8 @@
## Process model ## Process model
- **One `hermes gateway` process** hosts the agent core, the session store, the - **One `hermes gateway` process** hosts the agent core, the session store, the
cron scheduler, *and* our `android` platform plugin. The plugin's WebSocket cron scheduler, *and* our `iris` platform plugin. The plugin's WebSocket
server runs on the gateway's asyncio loop (started in `AndroidAdapter.connect()`). server runs on the gateway's asyncio loop (started in `IrisAdapter.connect()`).
- **The app is a client.** It *initiates* the WS connection to the gateway - **The app is a client.** It *initiates* the WS connection to the gateway
(outbound), so no inbound port is needed on the phone. For LAN/remote access (outbound), so no inbound port is needed on the phone. For LAN/remote access
the user points the app at the gateway's LAN IP / Tailscale name / a WSS the user points the app at the gateway's LAN IP / Tailscale name / a WSS
@@ -56,7 +56,7 @@ serves Telegram/Discord/… and cron) and the **`tui_gateway`** JSON-RPC backend
(used by the TUI and the existing Electron desktop app). We deliberately use the (used by the TUI and the existing Electron desktop app). We deliberately use the
**messaging gateway** because: **messaging gateway** because:
1. **Cron delivery is native.** Cron jobs resolve `deliver=android:<chat>[:<thread>]` 1. **Cron delivery is native.** Cron jobs resolve `deliver=iris:<chat>[:<thread>]`
through the platform registry and call our adapter's `send()`. No bridging. through the platform registry and call our adapter's `send()`. No bridging.
2. **`send_message` tool routing** works out of the box (plugin 2. **`send_message` tool routing** works out of the box (plugin
`parse_target_ref_fn`). `parse_target_ref_fn`).
@@ -72,7 +72,7 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
## Key architectural decisions + rationale ## Key architectural decisions + rationale
| Decision | Rationale | | Decision | Rationale |
|---|---| | --- | --- |
| **Community-style platform plugin** (`register(ctx)` → `ctx.register_platform`) | Zero hermes-core changes; survives hermes updates; follows `ADDING_A_PLATFORM.md` "Plugin Path". | | **Community-style platform plugin** (`register(ctx)` → `ctx.register_platform`) | Zero hermes-core changes; survives hermes updates; follows `ADDING_A_PLATFORM.md` "Plugin Path". |
| **Single WS transport** for chat, streaming, tools, media, pairing, FCM-token | One connection, one auth, one dependency (`websockets`, already core). Media as chunked binary frames avoids adding an HTTP server. | | **Single WS transport** for chat, streaming, tools, media, pairing, FCM-token | One connection, one auth, one dependency (`websockets`, already core). Media as chunked binary frames avoids adding an HTTP server. |
| **`websockets` + `httpx` only** | Both are hermes *core* deps → the plugin adds **zero** new Python dependencies (respects hermes supply-chain pinning policy). | | **`websockets` + `httpx` only** | Both are hermes *core* deps → the plugin adds **zero** new Python dependencies (respects hermes supply-chain pinning policy). |
@@ -80,13 +80,13 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
| **Reasoning split in the adapter** | The gateway prepends reasoning to the final text when `show_reasoning` is on; the adapter splits the stable prefix into a `reasoning` field so the app renders a clean collapsible block. | | **Reasoning split in the adapter** | The gateway prepends reasoning to the final text when `show_reasoning` is on; the adapter splits the stable prefix into a `reasoning` field so the app renders a clean collapsible block. |
| **Channels/threads = `chat_id`/`thread_id`** | The gateway's `SessionSource` already models this; cron delivery already targets `platform:chat_id:thread_id`. We map app concepts onto existing primitives. | | **Channels/threads = `chat_id`/`thread_id`** | The gateway's `SessionSource` already models this; cron delivery already targets `platform:chat_id:thread_id`. We map app concepts onto existing primitives. |
| **SQLite outbox + sync cursor** | Offline delivery + reconnect catch-up without re-reading full history. | | **SQLite outbox + sync cursor** | Offline delivery + reconnect catch-up without re-reading full history. |
| **Compose Multiplatform** | Desktop is "the Android app, tweaked" → share protocol/state/UI; only platform services + layout differ. | | **Compose Multiplatform** | Desktop is "the Iris app, tweaked" → share protocol/state/UI; only platform services + layout differ. |
## Data flow (one turn) ## Data flow (one turn)
1. App sends `message.send {text}` (or `/cmd`). 1. App sends `message.send {text}` (or `/cmd`).
2. Plugin builds a `MessageEvent` (+ `media_urls` if attachments) → 2. Plugin builds a `MessageEvent` (+ `media_urls` if attachments) →
`AndroidAdapter.handle_message(event)`. `IrisAdapter.handle_message(event)`.
3. Gateway resolves the session (`chat_id`/`thread_id`), runs the agent. 3. Gateway resolves the session (`chat_id`/`thread_id`), runs the agent.
4. Agent streams: `stream_delta_callback` → `GatewayStreamConsumer` → 4. Agent streams: `stream_delta_callback` → `GatewayStreamConsumer` →
`adapter.send()` (first) / `adapter.edit_message()` (updates) → `adapter.send()` (first) / `adapter.edit_message()` (updates) →
+11 -8
View File
@@ -13,10 +13,10 @@ iris_x_hermes/
│ │
├── hermes-agent/ # ⚠️ READ-ONLY REFERENCE — NEVER PUSHED (git-ignored) ├── hermes-agent/ # ⚠️ READ-ONLY REFERENCE — NEVER PUSHED (git-ignored)
│ │
├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/android ├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/iris
│ ├── plugin.yaml # manifest (kind: platform, env vars, home channel) │ ├── plugin.yaml # manifest (kind: platform, env vars, home channel)
│ ├── __init__.py │ ├── __init__.py
│ ├── adapter.py # AndroidAdapter(BasePlatformAdapter) + register(ctx) │ ├── adapter.py # IrisAdapter(BasePlatformAdapter) + register(ctx)
│ ├── ws_server.py # websockets server, connection registry, framing │ ├── ws_server.py # websockets server, connection registry, framing
│ ├── protocol.py # frame schemas (source of truth, mirrored in Kotlin) │ ├── protocol.py # frame schemas (source of truth, mirrored in Kotlin)
│ ├── media.py # inbound cache + outbound chunked streaming │ ├── media.py # inbound cache + outbound chunked streaming
@@ -50,9 +50,10 @@ iris_x_hermes/
## Module responsibilities ## Module responsibilities
### `gateway-plugin/` (Python) ### `gateway-plugin/` (Python)
- **`plugin.yaml`** — manifest: `name: android-platform`, `kind: platform`,
- **`plugin.yaml`** — manifest: `name: iris-platform`, `kind: platform`,
`requires_env` / `optional_env` (surfaced in `hermes config`/setup). `requires_env` / `optional_env` (surfaced in `hermes config`/setup).
- **`adapter.py`** — `AndroidAdapter(BasePlatformAdapter)` + `register(ctx)`. - **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`.
The heart of the plugin. See `03-gateway-plugin.md`. The heart of the plugin. See `03-gateway-plugin.md`.
- **`ws_server.py`** — `websockets` server, per-device connection registry, - **`ws_server.py`** — `websockets` server, per-device connection registry,
frame encode/decode, heartbeat, broadcast routing to all connected devices. frame encode/decode, heartbeat, broadcast routing to all connected devices.
@@ -61,13 +62,14 @@ iris_x_hermes/
- **`media.py`** — inbound chunked upload → `cache_*_from_bytes`; outbound - **`media.py`** — inbound chunked upload → `cache_*_from_bytes`; outbound
`media.offer`/`media.pull` chunked streaming. `media.offer`/`media.pull` chunked streaming.
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor. - **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
- **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and - **`push.py`** — `PushBackend` interface; `NtfyBackend` (default) and
`NtfyBackend` (reuses hermes ntfy publish). Selected by `ANDROID_PUSH_BACKEND`. `FcmBackend` (httpx, FCM HTTP v1). Selected by `IRIS_PUSH_BACKEND`.
- **`pairing.py`** — token generation/verification (constant-time), device - **`pairing.py`** — token generation/verification (constant-time), device
registry (SQLite), QR payload. registry (SQLite), QR payload.
- **`search.py`** — FTS5 query bridge over the hermes session store. - **`search.py`** — FTS5 query bridge over the hermes session store.
### `app/shared` (Kotlin KMP) ### `app/shared` (Kotlin KMP)
- **`commonMain`** — protocol models (kotlinx-serialization), `GatewayClient` - **`commonMain`** — protocol models (kotlinx-serialization), `GatewayClient`
(OkHttp WS), repositories (Room), ViewModels (StateFlow), and the Compose UI (OkHttp WS), repositories (Room), ViewModels (StateFlow), and the Compose UI
(design system, screens). ~80% of app code. (design system, screens). ~80% of app code.
@@ -77,13 +79,14 @@ iris_x_hermes/
window management, `MediaPlayer` actual. window management, `MediaPlayer` actual.
### `app/androidApp` / `app/desktopApp` ### `app/androidApp` / `app/desktopApp`
Thin shells: `Application`/`MainActivity` (Android) and `main()`/window Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
(Desktop). They compose the `shared` UI and inject platform services. (Desktop). They compose the `shared` UI and inject platform services.
## Build systems ## Build systems
- **Python plugin:** no build step (pure Python, stdlib + hermes core deps). - **Python plugin:** no build step (pure Python, stdlib + hermes core deps).
Installed by copying/symlinking into `~/.hermes/plugins/android`. Tested with Installed by copying/symlinking into `~/.hermes/plugins/iris`. Tested with
hermes's `scripts/run_tests.sh`. hermes's `scripts/run_tests.sh`.
- **Kotlin/CMP:** Gradle (Kotlin DSL) with the Compose Multiplatform plugin. - **Kotlin/CMP:** Gradle (Kotlin DSL) with the Compose Multiplatform plugin.
`./gradlew :androidApp:installDebug`, `./gradlew :desktopApp:run`, `./gradlew :androidApp:installDebug`, `./gradlew :desktopApp:run`,
@@ -124,7 +127,7 @@ keystore.jks
## Install layout (runtime) ## Install layout (runtime)
- **Plugin:** `~/.hermes/plugins/android/` ← copy of `gateway-plugin/` - **Plugin:** `~/.hermes/plugins/iris/` ← copy of `gateway-plugin/`
(or a symlink for dev). Discovered by hermes's `PluginManager`. (or a symlink for dev). Discovered by hermes's `PluginManager`.
- **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`. - **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`.
- **App (desktop, dev):** `./gradlew :desktopApp:run`. - **App (desktop, dev):** `./gradlew :desktopApp:run`.
+77 -70
View File
@@ -1,6 +1,6 @@
# 03 — Gateway Plugin (Python) # 03 — Gateway Plugin (Python)
The plugin is a **community-style hermes platform plugin** named `android`. The plugin is a **community-style hermes platform plugin** named `iris`.
It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md` It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md`
and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`. and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
**Zero hermes-core changes. Zero new Python dependencies** (`websockets` and **Zero hermes-core changes. Zero new Python dependencies** (`websockets` and
@@ -11,8 +11,8 @@ and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
## 3.1 `plugin.yaml` (manifest) ## 3.1 `plugin.yaml` (manifest)
```yaml ```yaml
name: android-platform name: iris-platform
label: Android label: Iris
kind: platform kind: platform
version: 0.1.0 version: 0.1.0
description: > description: >
@@ -22,63 +22,63 @@ description: >
channels/threads, media, FTS5 search, and FCM/ntfy push. channels/threads, media, FTS5 search, and FCM/ntfy push.
author: <you> author: <you>
requires_env: requires_env:
- name: ANDROID_TOKEN - name: IRIS_TOKEN
description: "Shared pairing token the app presents on connect" description: "Shared pairing token the app presents on connect"
prompt: "Android pairing token" prompt: "Iris pairing token"
password: true password: true
optional_env: optional_env:
- name: ANDROID_WS_HOST - name: IRIS_HTTP_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)" description: "HTTP bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host" prompt: "HTTP host"
password: false password: false
- name: ANDROID_WS_PORT - name: IRIS_HTTP_PORT
description: "WS port (default 8790)" description: "HTTP port (default 8791)"
prompt: "WS port" prompt: "HTTP port"
password: false password: false
- name: ANDROID_HOME_CHANNEL - name: IRIS_HOME_CHANNEL
description: "Default chat id for cron/notification delivery (default android:default)" description: "Default chat id for cron/notification delivery (default default)"
prompt: "Home channel" prompt: "Home channel"
password: false password: false
- name: ANDROID_ALLOWED_USERS - name: IRIS_ALLOWED_USERS
description: "Comma-separated allowed device_ids (empty = token-only auth)" description: "Comma-separated allowed device_ids (empty = token-only auth)"
prompt: "Allowed device ids" prompt: "Allowed device ids"
password: false password: false
- name: ANDROID_ALLOW_ALL_USERS - name: IRIS_ALLOW_ALL_USERS
description: "Allow any paired device (dev only)" description: "Allow any paired device (dev only)"
prompt: "Allow all devices? (true/false)" prompt: "Allow all devices? (true/false)"
password: false password: false
- name: ANDROID_PUSH_BACKEND - name: IRIS_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy" description: "Push backend: ntfy (default, keeps metadata off Google) or fcm"
prompt: "Push backend" prompt: "Push backend"
password: false password: false
- name: ANDROID_FCM_SERVICE_ACCOUNT - name: IRIS_FCM_SERVICE_ACCOUNT
description: "Path to Firebase service-account JSON (FCM HTTP v1)" description: "Path to Firebase service-account JSON (FCM HTTP v1)"
prompt: "FCM service account path" prompt: "FCM service account path"
password: true password: true
- name: ANDROID_FCM_SERVER_KEY - name: IRIS_FCM_SERVER_KEY
description: "Legacy FCM server key (fallback if no service account)" description: "Legacy FCM server key (fallback if no service account)"
prompt: "FCM server key" prompt: "FCM server key"
password: true password: true
- name: NTFY_TOPIC - name: NTFY_TOPIC
description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)" description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)"
prompt: "ntfy topic" prompt: "ntfy topic"
password: false password: false
- name: NTFY_SERVER_URL - name: NTFY_SERVER_URL
description: "ntfy server URL (default https://ntfy.sh)" description: "ntfy server URL (default https://ntfy.sh)"
prompt: "ntfy server URL" prompt: "ntfy server URL"
password: false password: false
- name: ANDROID_WS_CERT - name: IRIS_HTTP_CERT
description: "TLS cert path for WSS (optional)" description: "TLS cert path for HTTPS (optional)"
prompt: "WSS cert" prompt: "HTTPS cert"
password: false password: false
- name: ANDROID_WS_KEY - name: IRIS_HTTP_KEY
description: "TLS key path for WSS (optional)" description: "TLS key path for HTTPS (optional)"
prompt: "WSS key" prompt: "HTTPS key"
password: false password: false
``` ```
Behavioral (non-secret) settings live in `config.yaml` under Behavioral (non-secret) settings live in `config.yaml` under
`gateway.platforms.android.extra` (host, port, home_channel, outbox retention, `gateway.platforms.iris.extra` (host, port, home_channel, outbox retention,
max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets
only.) only.)
@@ -87,21 +87,21 @@ only.)
```python ```python
def register(ctx): def register(ctx):
ctx.register_platform( ctx.register_platform(
name="android", name="iris",
label="Android", label="Iris",
adapter_factory=lambda cfg: AndroidAdapter(cfg), adapter_factory=lambda cfg: IrisAdapter(cfg),
check_fn=check_requirements, # passive: websockets importable + token set check_fn=check_requirements, # passive: websockets importable + token set
validate_config=validate_config, # host/port/token present validate_config=validate_config, # host/port/token present
is_connected=is_connected, is_connected=is_connected,
required_env=["ANDROID_TOKEN"], required_env=["IRIS_TOKEN"],
install_hint="No extra packages needed (websockets + httpx are core deps)", install_hint="No extra packages needed (websockets + httpx are core deps)",
setup_fn=interactive_setup, # hermes gateway setup flow setup_fn=interactive_setup, # hermes gateway setup flow
env_enablement_fn=_env_enablement, # seed extra + home_channel from env env_enablement_fn=_env_enablement, # seed extra + home_channel from env
cron_deliver_env_var="ANDROID_HOME_CHANNEL", cron_deliver_env_var="IRIS_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch) standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
parse_target_ref_fn=_parse_target_ref, # "android:<chat>[:<thread>]" parse_target_ref_fn=_parse_target_ref, # "iris:<chat>[:<thread>]"
allowed_users_env="ANDROID_ALLOWED_USERS", allowed_users_env="IRIS_ALLOWED_USERS",
allow_all_env="ANDROID_ALLOW_ALL_USERS", allow_all_env="IRIS_ALLOW_ALL_USERS",
max_message_length=0, # 0 = no limit (WS has none) max_message_length=0, # 0 = no limit (WS has none)
emoji="📱", emoji="📱",
pii_safe=False, pii_safe=False,
@@ -123,26 +123,29 @@ Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`):
`ensure_deps_fn`. `ensure_deps_fn`.
- **`check_requirements()`** — passive probe: `import websockets` succeeds and - **`check_requirements()`** — passive probe: `import websockets` succeeds and
`ANDROID_TOKEN` is set. Never installs. `IRIS_TOKEN` is set. Never installs.
- **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra` - **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra`
(host/port/home_channel/push_backend) + a `home_channel` key (host/port/home_channel/push_backend) + a `home_channel` key
`{"chat_id": "android:default", "name": "Default"}` so `hermes gateway status` `{"chat_id": "default", "name": "Default"}` so `hermes gateway status`
and cron home-channel resolution work without instantiating the adapter. and cron home-channel resolution work without instantiating the adapter.
- **`_parse_target_ref(ref)`** — if `ref` starts with `android:`, return - **`_parse_target_ref(ref)`** — the core strips the platform prefix first, so
`(chat_id, thread_id)` parsed from `android:<chat>[:<thread>]`; else `None`. `ref` is the direct chat id (e.g. `chan_7`, `default`) with an optional
`:t_<n>` thread suffix; friendly names resolve via the channel directory.
Returns `(chat_id, thread_id)` or `None`.
- **`interactive_setup()`** — prompts for token (or generates one), host/port, - **`interactive_setup()`** — prompts for token (or generates one), host/port,
push backend + credentials, prints a QR code (pairing) and the app URL. push backend + credentials, prints a QR code (pairing) and the app URL.
## 3.3 `AndroidAdapter(BasePlatformAdapter)` ## 3.3 `IrisAdapter(BasePlatformAdapter)`
Constructor: `super().__init__(config=config, platform=Platform("android"))`. Constructor: `super().__init__(config=config, platform=Platform("iris"))`.
Reads `config.extra` (env overrides win). Initializes: WS server (not started Reads `config.extra` (env overrides win). Initializes: WS server (not started
until `connect()`), connection registry, outbox (SQLite under until `connect()`), connection registry, outbox (SQLite under
`get_hermes_home()/"android"`), push backend, pairing store, channel directory. `get_hermes_home()/"iris"`), push backend, pairing store, channel directory.
### Lifecycle ### Lifecycle
- **`connect(*, is_reconnect=False) -> bool`** - **`connect(*, is_reconnect=False) -> bool`**
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("android", key)`) - Acquire scoped lock (`gateway.status.acquire_scoped_lock("iris", key)`)
so two profiles can't bind the same port/identity. so two profiles can't bind the same port/identity.
- Start the `websockets` server on `host:port` (TLS if cert/key set). - Start the `websockets` server on `host:port` (TLS if cert/key set).
- `_mark_connected()`; return True. - `_mark_connected()`; return True.
@@ -150,6 +153,7 @@ until `connect()`), connection registry, outbox (SQLite under
- Stop server, close all device sockets, release lock, `_mark_disconnected()`. - Stop server, close all device sockets, release lock, `_mark_disconnected()`.
### Inbound (app → agent) ### Inbound (app → agent)
- WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via - WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via
`self.build_source(chat_id, chat_name, chat_type, user_id, user_name, `self.build_source(chat_id, chat_name, chat_type, user_id, user_name,
thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…, thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…,
@@ -171,6 +175,7 @@ until `connect()`), connection registry, outbox (SQLite under
- `sync {cursor}` → `outbox.py` → replay frames since cursor. - `sync {cursor}` → `outbox.py` → replay frames since cursor.
### Outbound (agent → app) ### Outbound (agent → app)
- **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`** - **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`**
- Split reasoning prefix (see `05-streaming.md`) → `reasoning` field. - Split reasoning prefix (see `05-streaming.md`) → `reasoning` field.
- If **any** device is connected: broadcast `message` frame to all. - If **any** device is connected: broadcast `message` frame to all.
@@ -195,7 +200,9 @@ until `connect()`), connection registry, outbox (SQLite under
channel directory, return it (used by cron "continuable" threads). channel directory, return it (used by cron "continuable" threads).
### Streaming hooks ### Streaming hooks
The main gateway drives delivery through the **legacy callback path**: The main gateway drives delivery through the **legacy callback path**:
- `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) + - `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) +
`edit_message()` (updates) → `message.start` / `message.update`. `edit_message()` (updates) → `message.start` / `message.update`.
- `tool_progress_callback` → progress queue → `send_progress_messages` → - `tool_progress_callback` → progress queue → `send_progress_messages` →
@@ -208,29 +215,28 @@ The adapter tracks per-chat **turn state** (in-turn, current streaming
`message` vs `tool.*` vs `commentary`. The exact classification markers are `message` vs `tool.*` vs `commentary`. The exact classification markers are
verified empirically in M2 (see `13-testing.md`). verified empirically in M2 (see `13-testing.md`).
## 3.4 WebSocket server (`ws_server.py`) ## 3.4 HTTP server (`http_server.py`)
- Library: **`websockets`** (core dep, v15). `websockets.serve(handler, host, - Library: **stdlib `http.server`** (`ThreadingHTTPServer` +
port, ssl=ctx)`. `BaseHTTPRequestHandler`) in a daemon thread; bridges into the gateway's
- **Handler** per connection: asyncio loop via `asyncio.run_coroutine_threadsafe`. Optional TLS via
1. Await first frame; must be `hello {token, device_id, device_name, caps, `ssl.SSLContext` (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`). Full design:
fcm_token?}`. Verify token (constant-time) + allowlist. On failure: send `19-http-fallback-transport.md`.
`error {code:"auth"}` and close. - **Auth:** `Authorization: Bearer <token>` (constant-time `verify_token`)
2. On success: register in connection registry - device allowlist via `X-Iris-Device`; `401` on failure.
(`device_id → {ws, caps, fcm_token}`), send - **Endpoints:** `GET /v1/health` (unauthenticated liveness),
`hello.ack {server_caps, sync_cursor, channels[]}`. `POST /v1/frame` (any JSON frame the protocol accepts),
3. Loop: decode frames, dispatch to adapter inbound handlers. `GET /v1/events?cursor=N` (SSE: outbox catch-up + live frames),
4. On close: deregister; if no devices remain, ensure pending outbox `GET /v1/poll?cursor=N` (long-poll fallback), `POST /v1/media` +
frames have push fired. `GET /v1/media/{id}` (media upload/pull).
- **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected - **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected
devices (no per-chat subscribe; single-user model). Global frames devices (no per-chat subscribe; single-user model). Global frames
(`channel.*`, `status`) also broadcast to all. (`channel.*`, `status`) also broadcast to all.
- **Heartbeat:** WS ping/pong + app-level `ping`/`pong`; dead peers reaped. - **Limits:** 64 KiB request body cap, per-device token-bucket rate limit
- **Backpressure:** per-connection send queue with a bounded buffer; drop (20/s, burst 40) → `429`; media uploads bounded by the per-upload total
`message.update` (coalesce to latest) under pressure, never drop cap. No CORS (app clients only).
`message`/`tool.end`/`notification`.
## 3.5 State & storage (all under `get_hermes_home()/"android"`) ## 3.5 State & storage (all under `get_hermes_home()/"iris"`)
> Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe). > Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe).
> Never hardcode `~/.hermes`. > Never hardcode `~/.hermes`.
@@ -245,19 +251,20 @@ verified empirically in M2 (see `13-testing.md`).
## 3.6 Config resolution ## 3.6 Config resolution
- **Secrets (`.env`):** `ANDROID_TOKEN`, `ANDROID_FCM_SERVICE_ACCOUNT`, - **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
`ANDROID_FCM_SERVER_KEY`, `ANDROID_WS_CERT/KEY`, `NTFY_TOPIC` (if secret). `IRIS_FCM_SERVER_KEY`, `IRIS_HTTP_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.android.extra`):** `host`, - **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`, `http_port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
`max_upload_bytes`, `tls`. `max_upload_bytes`, `http_cert`/`http_key`.
- Env vars override `config.yaml` (hermes convention). Read secrets with the - Env vars override `config.yaml` (hermes convention). Read secrets with the
scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`) scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`)
so multiplexed profiles don't leak each other's tokens. so multiplexed profiles don't leak each other's tokens.
## 3.7 Failure & lifecycle safety ## 3.7 Failure & lifecycle safety
- WS server bind failure → `_set_fatal_error("bind_failed", …, retryable=True)`. - HTTP server bind failure → non-fatal: log a warning, disable the HTTP leg,
- All outbound sends are best-effort; a dead socket latches and the frame falls show it in the inspector (the plugin keeps working for other platforms).
to the outbox. - All outbound sends are best-effort; a dead stream latches and the frame
- `disconnect()` cancels the server task and closes sockets cleanly. falls to the outbox.
- `disconnect()` stops the HTTP server and closes streams cleanly.
- Token/PII redaction in all logs (hermes PII policy). - Token/PII redaction in all logs (hermes PII policy).
+68 -21
View File
@@ -12,7 +12,7 @@ Every frame:
"v": 1, "v": 1,
"id": 42, // optional; present on requests + their responses "id": 42, // optional; present on requests + their responses
"type": "message", // frame type (below) "type": "message", // frame type (below)
"chat_id": "android:default", // optional; scope for chat-scoped frames "chat_id": "default", // optional; scope for chat-scoped frames
"thread_id": "t_123", // optional "thread_id": "t_123", // optional
"payload": { } // type-specific object "payload": { } // type-specific object
} }
@@ -43,7 +43,8 @@ Pairing succeeded.
"search":true,"push":"fcm","pickers":true}, "search":true,"push":"fcm","pickers":true},
"sync_cursor":1042, "sync_cursor":1042,
"last_pushed_cursor":1040, "last_pushed_cursor":1040,
"channels":[{"chat_id":"android:default","name":"Default","kind":"default","is_default":true}] "device_token":"9f2c…(64 hex)",
"channels":[{"chat_id":"default","name":"Default","kind":"default","is_default":true}]
}} }}
``` ```
@@ -52,12 +53,18 @@ device via the push backend (0 = never). The app skips system notifications
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
woke the device via push (dedupe, `08-push.md` §8.7). woke the device via push (dedupe, `08-push.md` §8.7).
`device_token` is the per-device token minted at pairing (docs/09 §9.3):
the app stores it and presents it in the `Authorization` header INSTEAD of
the shared `IRIS_TOKEN` from then on, so the gateway can revoke one device
without affecting the others. Empty when the gateway didn't issue one
(legacy).
### `message` ### `message`
A final / standalone message. A final / standalone message.
```json ```json
{"type":"message","chat_id":"android:default","thread_id":null, {"type":"message","chat_id":"default","thread_id":null,
"payload":{ "payload":{
"message_id":"m_9001","role":"assistant", "message_id":"m_9001","role":"assistant",
"text":"Here is the answer…", "text":"Here is the answer…",
@@ -107,7 +114,7 @@ them drop the message(s) from their cache. Also outboxed, so a device that was
offline learns of the deletion on its next `sync`. offline learns of the deletion on its next `sync`.
```json ```json
{"type":"message.deleted","id":30,"chat_id":"android:default","thread_id":null, {"type":"message.deleted","id":30,"chat_id":"default","thread_id":null,
"payload":{"message_ids":["m_9001","m_9002"]}} "payload":{"message_ids":["m_9001","m_9002"]}}
``` ```
@@ -126,7 +133,7 @@ truncated / nothing).
```json ```json
{"type":"tool.start","chat_id":"…","payload":{ {"type":"tool.start","chat_id":"…","payload":{
"index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"}}} "index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"},"emoji":"💻"}}
{"type":"tool.progress","chat_id":"…","payload":{"index":3,"name":"terminal","note":"running…"}} {"type":"tool.progress","chat_id":"…","payload":{"index":3,"name":"terminal","note":"running…"}}
{"type":"tool.end","chat_id":"…","payload":{"index":3,"name":"terminal","ok":true,"duration":12.4, {"type":"tool.end","chat_id":"…","payload":{"index":3,"name":"terminal","ok":true,"duration":12.4,
"output_preview":"12 passed"}} "output_preview":"12 passed"}}
@@ -135,6 +142,36 @@ truncated / nothing).
`args` may be large; the app truncates per its setting. `output_preview` is a `args` may be large; the app truncates per its setting. `output_preview` is a
short tail (full output is not streamed — it lives in agent history). short tail (full output is not streamed — it lives in agent history).
`emoji` is a cosmetic per-tool glyph resolved server-side via hermes'
`get_tool_emoji` (active-skin overrides, then the tool registry's per-tool
`emoji` — e.g. `read_file` 📖, `write_file` ✍️, `terminal` 💻). Omitted when
the tool is unknown, so the app falls back to its own default glyph.
### `todo.update`
The agent's **full current todo list** for a chat/thread lane (last-write-wins).
Emitted whenever the `todo` tool completes — the tool *result* is the
authoritative list, which also covers `merge` writes (whose args carry only
the changed items) and read-only calls — and, as a **snapshot**, right after
`hello` when a device opens its event stream.
```json
{"type":"todo.update","chat_id":"default","thread_id":null,"payload":{
"todos":[
{"id":"1","content":"Scaffold the module","status":"completed"},
{"id":"2","content":"Wire the store","status":"in_progress"},
{"id":"3","content":"Add tests","status":"pending"}
]}}
```
`status` is one of `pending | in_progress | completed | cancelled`. The frame
is **ephemeral**: it is never outboxed, so a reconnecting device learns the
current list from the snapshot (not a replay), and a gateway restart drops it
(the agent re-emits on the next `todo` call). The app renders it as a compact
scrollable strip above the composer (max 3 lines; auto-scrolls to the item
whose status just changed) and hides it once the list is empty or fully
resolved.
### `typing` / `typing.stop` ### `typing` / `typing.stop`
```json ```json
@@ -156,6 +193,16 @@ In-app banner (foreground) and/or push mirror (background).
Interactive prompts. App renders a native picker; answers via `picker.select`. Interactive prompts. App renders a native picker; answers via `picker.select`.
`picker.choice` is implemented (the generic finite-choice menu used by
`/reasoning`, `/fast`, and any future finite-choice slash command — hermes
calls the adapter's `send_choice_picker` when the platform supports it).
The server runs the command's selection callback on `picker.select` and
delivers its reply as a normal `message` in the picker's chat. The frame is
outboxed (a reconnecting device re-renders a still-pending picker); pending
state is in-memory only, so a gateway restart expires it (a stale
`picker.select` is a no-op). With no live device the adapter reports failure
and hermes falls back to the text status card.
```json ```json
{"type":"picker.model","chat_id":"…","payload":{ {"type":"picker.model","chat_id":"…","payload":{
"picker_id":"pm_1","current_model":"qwen3-27b","current_provider":"local", "picker_id":"pm_1","current_model":"qwen3-27b","current_provider":"local",
@@ -171,7 +218,7 @@ Channel directory updates. **Broadcast to all connected devices** (no explicit
subscribe; the server pushes to every open WS). subscribe; the server pushes to every open WS).
```json ```json
{"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports", {"type":"channel.created","payload":{"chat_id":"chan_7","name":"Cron Reports",
"kind":"channel","parent_chat_id":null}} "kind":"channel","parent_chat_id":null}}
``` ```
@@ -185,7 +232,7 @@ title.
Response to a `history` request. Returns a page of messages for a chat/thread. Response to a `history` request. Returns a page of messages for a chat/thread.
```json ```json
{"type":"history","id":20,"chat_id":"android:default","thread_id":null, {"type":"history","id":20,"chat_id":"default","thread_id":null,
"payload":{ "payload":{
"messages":[ "messages":[
{"message_id":"m_8990","role":"user","text":"Hi","ts":1723990000000}, {"message_id":"m_8990","role":"user","text":"Hi","ts":1723990000000},
@@ -239,9 +286,9 @@ Response to a `commands.complete` request. Autocomplete matches for a typed pref
Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`. Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`.
```json ```json
{"type":"agent.busy","chat_id":"android:default","thread_id":null, {"type":"agent.busy","chat_id":"default","thread_id":null,
"payload":{"reason":"processing"}} "payload":{"reason":"processing"}}
{"type":"agent.idle","chat_id":"android:default","thread_id":null,"payload":{}} {"type":"agent.idle","chat_id":"default","thread_id":null,"payload":{}}
``` ```
`reason` ∈ `processing | tool | waiting_input | cron`. `reason` ∈ `processing | tool | waiting_input | cron`.
@@ -251,7 +298,7 @@ Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`
```json ```json
{"type":"search.results","id":7,"payload":{ {"type":"search.results","id":7,"payload":{
"query":"deploy","scope":"all","hits":[ "query":"deploy","scope":"all","hits":[
{"message_id":"m_123","chat_id":"android:chan_7","thread_id":null, {"message_id":"m_123","chat_id":"chan_7","thread_id":null,
"role":"assistant","snippet":"…deploy the service…","ts":1723900000000}]}} "role":"assistant","snippet":"…deploy the service…","ts":1723900000000}]}}
``` ```
@@ -270,7 +317,7 @@ The gateway acknowledges that the agent has received and started processing
the user's message. The app uses it to show ✓✓ on user bubbles. the user's message. The app uses it to show ✓✓ on user bubbles.
```json ```json
{"type":"read.receipt","chat_id":"android:default","payload":{"message_id":"m_9001"}} {"type":"read.receipt","chat_id":"default","payload":{"message_id":"m_9001"}}
``` ```
Emitted to the originating connection when a `message.send` is accepted for Emitted to the originating connection when a `message.send` is accepted for
@@ -314,7 +361,7 @@ First frame; auth + caps.
```json ```json
{"type":"hello","payload":{ {"type":"hello","payload":{
"token":"<ANDROID_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S", "token":"<IRIS_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S",
"caps":{"min_protocol":1,"media":true,"push":"fcm"}, "caps":{"min_protocol":1,"media":true,"push":"fcm"},
"fcm_token":"<FCM token>","ntfy_topic":"<topic, if ntfy>"}} "fcm_token":"<FCM token>","ntfy_topic":"<topic, if ntfy>"}}
``` ```
@@ -324,7 +371,7 @@ First frame; auth + caps.
Send text (or a `/slash-command`). Send text (or a `/slash-command`).
```json ```json
{"type":"message.send","id":10,"chat_id":"android:default","thread_id":null, {"type":"message.send","id":10,"chat_id":"default","thread_id":null,
"payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"], "payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"],
"auto_thread":false}} "auto_thread":false}}
``` ```
@@ -378,8 +425,8 @@ Answer an interactive picker.
```json ```json
{"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}} {"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}}
{"type":"channel.rename","id":15,"chat_id":"android:chan_7","payload":{"name":"Reports"}} {"type":"channel.rename","id":15,"chat_id":"chan_7","payload":{"name":"Reports"}}
{"type":"channel.set_default","id":16,"chat_id":"android:chan_7","payload":{}} {"type":"channel.set_default","id":16,"chat_id":"chan_7","payload":{}}
``` ```
`channel.delete` is a **hard delete**: the channel/thread row is removed from `channel.delete` is a **hard delete**: the channel/thread row is removed from
@@ -391,7 +438,7 @@ removes its threads. The default channel cannot be deleted.
```json ```json
{"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}} {"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}}
{"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"android:chan_7","thread_id":null}} {"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"chan_7","thread_id":null}}
``` ```
`scope` ∈ `all | chat`. `scope` ∈ `all | chat`.
@@ -403,7 +450,7 @@ broadcasts to other devices (for multi-device ✓✓ sync). The app uses it to
mark messages as read locally (✓✓ on user bubbles). mark messages as read locally (✓✓ on user bubbles).
```json ```json
{"type":"read.receipt","payload":{"chat_id":"android:default","message_id":"m_9001"}} {"type":"read.receipt","payload":{"chat_id":"default","message_id":"m_9001"}}
``` ```
### `history` ### `history`
@@ -411,7 +458,7 @@ mark messages as read locally (✓✓ on user bubbles).
Load a page of messages for a chat/thread (initial open, scroll-up pagination). Load a page of messages for a chat/thread (initial open, scroll-up pagination).
```json ```json
{"type":"history","id":20,"chat_id":"android:default","thread_id":null, {"type":"history","id":20,"chat_id":"default","thread_id":null,
"payload":{"before_message_id":"m_8990","limit":50}} "payload":{"before_message_id":"m_8990","limit":50}}
``` ```
@@ -428,7 +475,7 @@ message already gone (pruned by retention) still yields a `message.deleted`
broadcast so live caches drop it. broadcast so live caches drop it.
```json ```json
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null, {"type":"message.delete","id":30,"chat_id":"default","thread_id":null,
"payload":{"message_ids":["m_9001","m_9002"]}} "payload":{"message_ids":["m_9001","m_9002"]}}
``` ```
@@ -453,7 +500,7 @@ Autocomplete for a typed `/prefix`.
Stop the current agent turn (abort generation / tool execution). Stop the current agent turn (abort generation / tool execution).
```json ```json
{"type":"agent.stop","id":23,"chat_id":"android:default","thread_id":null,"payload":{}} {"type":"agent.stop","id":23,"chat_id":"default","thread_id":null,"payload":{}}
``` ```
### `agent.steer` ### `agent.steer`
@@ -461,7 +508,7 @@ Stop the current agent turn (abort generation / tool execution).
Inject a steering message mid-turn (redirects the agent without a new turn). Inject a steering message mid-turn (redirects the agent without a new turn).
```json ```json
{"type":"agent.steer","id":24,"chat_id":"android:default","thread_id":null, {"type":"agent.steer","id":24,"chat_id":"default","thread_id":null,
"payload":{"text":"Actually, focus on the error case."}} "payload":{"text":"Actually, focus on the error case."}}
``` ```
+3 -3
View File
@@ -28,7 +28,7 @@ finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
while the user is at the bottom. while the user is at the bottom.
**Streaming on/off.** Two levels: **Streaming on/off.** Two levels:
- **Gateway side:** hermes `display.platforms.android.streaming` (default - **Gateway side:** hermes `display.platforms.iris.streaming` (default
follows global). When off, the app just gets one final `message` frame. follows global). When off, the app just gets one final `message` frame.
- **App side (per device):** Settings → "Streaming" toggle (default on). When - **App side (per device):** Settings → "Streaming" toggle (default on). When
off, the app ignores `message.start`/`message.update` frames and off, the app ignores `message.start`/`message.update` frames and
@@ -49,11 +49,11 @@ chosen by `reasoning_style` (`gateway/display_config.py:37`):
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>` - `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style) - `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
**Plugin config.** Set for the `android` platform: **Plugin config.** Set for the `iris` platform:
```yaml ```yaml
display: display:
platforms: platforms:
android: iris:
show_reasoning: true show_reasoning: true
reasoning_style: code # we split on the code-fence form reasoning_style: code # we split on the code-fence form
``` ```
+12 -12
View File
@@ -9,10 +9,10 @@ gateway identity concepts**.
| App concept | hermes primitive | Example | | App concept | hermes primitive | Example |
| --- | --- | --- | | --- | --- | --- |
| Default chat | home channel `chat_id` | `android:default` | | Default chat | home channel `chat_id` | `default` |
| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=android:default, thread_id=t_12` | | A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=default, thread_id=t_12` |
| A user-created channel | a new `chat_id` | `android:chan_7` | | A user-created channel | a new `chat_id` | `chan_7` |
| A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=android:chan_7, thread_id=t_31` | | A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=chan_7, thread_id=t_31` |
- **`chat_id`** = the conversation lane (a channel or the default chat). - **`chat_id`** = the conversation lane (a channel or the default chat).
- **`thread_id`** = an optional sub-lane within a `chat_id` (topic-like). - **`thread_id`** = an optional sub-lane within a `chat_id` (topic-like).
@@ -23,10 +23,10 @@ gateway identity concepts**.
## 6.2 Default chat ## 6.2 Default chat
- On first connect, the plugin ensures a **default channel** exists: - On first connect, the plugin ensures a **default channel** exists:
`chat_id = ANDROID_HOME_CHANNEL` (default `android:default`), `kind=default`, `chat_id = IRIS_HOME_CHANNEL` (default `default`), `kind=default`,
`is_default=true`, name "Default". `is_default=true`, name "Default".
- It is also the **cron home channel** (`cron_deliver_env_var= - It is also the **cron home channel** (`cron_deliver_env_var=
ANDROID_HOME_CHANNEL`), so `deliver=android` (bare) routes here. IRIS_HOME_CHANNEL`), so `deliver=iris` (bare) routes here.
- The app opens the default chat on launch. - The app opens the default chat on launch.
## 6.3 Threads (toggle for overview) ## 6.3 Threads (toggle for overview)
@@ -37,7 +37,7 @@ gateway identity concepts**.
- **Threads OFF** — flat conversation; all messages use `thread_id=null`. - **Threads OFF** — flat conversation; all messages use `thread_id=null`.
- **Threads ON** — the app groups the conversation into topic-like lanes. - **Threads ON** — the app groups the conversation into topic-like lanes.
Each new "topic" mints a `thread_id` (via `channel.create {kind:thread, Each new "topic" mints a `thread_id` (via `channel.create {kind:thread,
parent_chat_id:android:default}` or an implicit thread). The UI shows a parent_chat_id:default}` or an implicit thread). The UI shows a
topic switcher (like Telegram topics) above the message list. topic switcher (like Telegram topics) above the message list.
- Threads are **app-organized** but **gateway-real**: each `thread_id` is a - Threads are **app-organized** but **gateway-real**: each `thread_id` is a
distinct hermes session lane, so context is isolated per thread and cron can distinct hermes session lane, so context is isolated per thread and cron can
@@ -82,7 +82,7 @@ lane (nothing to title / session-scoped, not conversation starters).
- **Requirement:** the user creates new channels so **cron job outputs can be - **Requirement:** the user creates new channels so **cron job outputs can be
delegated to them** instead of the default chat. delegated to them** instead of the default chat.
- **`channel.create {name, kind:"channel"}`** → plugin mints - **`channel.create {name, kind:"channel"}`** → plugin mints
`chat_id = android:chan_<n>`, stores in directory, broadcasts `chat_id = chan_<n>`, stores in directory, broadcasts
`channel.created` to all devices. The new channel appears in the channel list. `channel.created` to all devices. The new channel appears in the channel list.
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the - **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
directory (rename broadcasts `channel.renamed`; delete is a **hard delete** directory (rename broadcasts `channel.renamed`; delete is a **hard delete**
@@ -104,9 +104,9 @@ lane (nothing to title / session-scoped, not conversation starters).
- **Cron targeting** (the key payoff): because the plugin registers - **Cron targeting** (the key payoff): because the plugin registers
`parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the `parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the
`send_message` tool can target any channel/thread: `send_message` tool can target any channel/thread:
- `deliver="android"` → home (default) channel. - `deliver="iris"` → home (default) channel.
- `deliver="android:android:chan_7"` → that channel. - `deliver="iris:chan_7"` → that channel.
- `deliver="android:android:chan_7:t_31"` → that channel's thread. - `deliver="iris:chan_7:t_31"` → that channel's thread.
- In-chat: the agent's `cronjob` tool can be told "deliver to the *Cron - In-chat: the agent's `cronjob` tool can be told "deliver to the *Cron
Reports* channel"; the gateway resolves the name via the channel directory. Reports* channel"; the gateway resolves the name via the channel directory.
- **In-app affordance:** each channel's menu has "Set as cron target" / shows a - **In-app affordance:** each channel's menu has "Set as cron target" / shows a
@@ -118,7 +118,7 @@ lane (nothing to title / session-scoped, not conversation starters).
- Cron resolves delivery targets in `cron/scheduler.py:2148` - Cron resolves delivery targets in `cron/scheduler.py:2148`
(`_resolve_single_delivery_target`). For `platform:chat_id[:thread_id]` it (`_resolve_single_delivery_target`). For `platform:chat_id[:thread_id]` it
calls `tools.send_message_tool.resolve_send_target`, which uses our calls `tools.send_message_tool.resolve_send_target`, which uses our
`parse_target_ref_fn` to parse `android:<chat>[:<thread>]`. `parse_target_ref_fn` to parse `iris:<chat>[:<thread>]`.
- Delivery then calls the **live adapter's `send(chat_id, text, …)`** (gateway - Delivery then calls the **live adapter's `send(chat_id, text, …)`** (gateway
running) → our WS `message` frame (or outbox+push if the app is offline). running) → our WS `message` frame (or outbox+push if the app is offline).
- Cron deliveries are framed with a `[Cron delivery: <name>]` header by hermes; - Cron deliveries are framed with a `[Cron delivery: <name>]` header by hermes;
+51 -7
View File
@@ -41,10 +41,12 @@ receipt (don't trust the client) using hermes helpers
(`gateway/platforms/base.py:2337`). The agent's vision/audio tools can then (`gateway/platforms/base.py:2337`). The agent's vision/audio tools can then
read the file. read the file.
**Limits:** `get_inbound_media_max_bytes()` / `validate_inbound_media_size` **Limits:** two caps apply — the plugin's `max_upload_bytes` (default
(`base.py:758/779`) enforce the cap; over-limit → 413 + 100 MiB) and hermes's `gateway.max_inbound_media_bytes` (default 128 MiB,
`error {code:"media_too_large"}`. (The 1 MiB `MAX_BODY_BYTES` cap applies to enforced by `get_inbound_media_max_bytes()` / `validate_inbound_media_size`,
JSON *frame* bodies only, not media uploads.) `base.py:758/779`); over-limit → 413 + `error {code:"media_too_large"}`.
Effective limit is the **min** of both; see §7.7. (The 1 MiB
`MAX_BODY_BYTES` cap applies to JSON *frame* bodies only, not media uploads.)
**Single-shot:** no chunking/resumability — HTTP carries the body; single-user **Single-shot:** no chunking/resumability — HTTP carries the body; single-user
scale makes a one-shot upload sufficient. scale makes a one-shot upload sufficient.
@@ -94,8 +96,50 @@ delivery-path check is re-run **at pull time**, not just at offer time.
## 7.6 App-side storage ## 7.6 App-side storage
- Cache dir: app-specific external cache (`getExternalCacheDir()/media`). - Cache dir: app-specific external cache (`getExternalCacheDir()/media`,
- LRU eviction by size (configurable, default 500 MB) so old media doesn't fill internal `cacheDir` fallback; desktop: `~/.iris/cache/media`).
the device. - LRU eviction by size (hardcoded 500 MB, `MediaCacheJvm.kt` `maxBytes`) so old
media doesn't fill the device.
- A `MediaRepository` tracks `{media_id, local_path, kind, size, ts}` in Room so - A `MediaRepository` tracks `{media_id, local_path, kind, size, ts}` in Room so
bubbles can re-render players after process death. bubbles can re-render players after process death.
## 7.7 Size limits & where files live
**Inbound (app → agent) — two caps, effective limit is the min:**
| Cap | Default | Set via | Enforced by |
| --- | --- | --- | --- |
| Plugin `max_upload_bytes` | 100 MiB | `gateway.platforms.iris.extra.max_upload_bytes` (config.yaml) | `http_server.py` (Content-Length pre-check) + `media.py` `UploadSession.feed` (mid-stream) → `media_too_large` |
| Hermes `gateway.max_inbound_media_bytes` | 128 MiB | `gateway.max_inbound_media_bytes` (config.yaml) | `validate_inbound_media_size` inside `cache_*_from_bytes` (`base.py:758/779`) → `media_too_large` |
Both are **pure config** — raising the limit needs no code change in the plugin
or hermes-agent. The app itself has no upload cap.
**Why the caps exist:** the upload is streamed to a temp file (disk, bounded
RAM in flight), but at completion the plugin reads the **entire blob into
memory** (`UploadSession.read_bytes()` → `cache_*_from_bytes` →
`write_bytes`), so peak RAM ≈ file size per upload. Hermes's cap exists to
prevent OOM-killing the gateway (comment at `base.py:740-749`).
**Inbound storage (gateway host):**
- In flight: temp file `upl_*` under `~/.hermes/iris/media/tmp` (removed after
completion or failure).
- After caching: hermes media cache — `~/.hermes/cache/images/img_<uuid12><ext>`,
`cache/audio/audio_<uuid12><ext>`, `cache/videos/video_<uuid12><ext>`,
`cache/documents/doc_<uuid12>_<original filename>`. Video/image/audio lose
their original filename; documents keep it.
- Lifetime: hermes `cleanup_video_cache(max_age_hours=24)` deletes videos older
than 24 h.
**Outbound (agent → app) — no size cap at the gateway.** `media.offer` carries
metadata; `GET /v1/media/{id}` streams the full file in 256 KiB chunks
regardless of size. The only constraints are the delivery-path re-validation at
pull time and the 24 h offer TTL (`MediaStore.prune_outbound`).
**The effective outbound limit is set by the app:** the device media cache is
LRU-capped at 500 MB (§7.6) and `evict()` runs right after each pull completes.
A single file > 500 MB makes the eviction loop delete everything — *including
the file it just downloaded*. So files > ~500 MB arrive but are immediately
discarded. To retain large agent→app files, raise `maxBytes` in
`MediaCacheJvm.kt` (or exempt files from eviction).
+44 -8
View File
@@ -1,17 +1,51 @@
# 08 — Push Notifications, Outbox & Sync # 08 — Push Notifications, Outbox & Sync
The gateway can't reach a sleeping phone directly. Push goes through a cloud The gateway can't reach a sleeping phone directly. Push goes through a cloud
relay. **Decision: FCM primary, ntfy fallback** (`ANDROID_PUSH_BACKEND`). relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
Privacy: FCM push metadata (notification title, device token) is routed
through Google's servers — for truly private communication use ntfy
(self-hosted), which keeps everything on your own infrastructure.
## 8.1 When push fires ## 8.1 When push fires
- A frame targets a `chat_id` whose device is **disconnected** (WS closed) → - A frame targets a `chat_id` whose device is **disconnected** (no live
drop to **outbox** + fire **push**. SSE/long-poll subscriber) → drop to **outbox** + fire **push** — with one
refinement, *turn-aware push* (below).
- Also fire push for high-priority foreground events the user should see even if - Also fire push for high-priority foreground events the user should see even if
the app is backgrounded (approvals, clarifies, cron completions) — the app the app is backgrounded (approvals, clarifies, cron completions) — the app
decides whether to also show an in-app banner. decides whether to also show an in-app banner.
- If the device is **connected**, no push (the live frame is enough). - If the device is **connected**, no push (the live frame is enough).
### 8.1.1 Turn-aware push (one push per turn, final answer as body)
An agent turn can span minutes and emit several completed status messages
("researching X…", "found Y…", "writing findings…", final answer). Pushing each
parked message would spam an offline user with the steps in between. So while
the agent's turn is **in flight** (hermes holds the typing indicator on from
turn start until the handler's `finally` at turn end), normal-priority
`message` / `message.stop` / `media.offer` frames that park with no live
device are **held back** per chat instead of pushing; the latest one is pushed
when the turn ends (`stop_typing`), so the offline user gets **one push with
the final answer**. Details:
- Turn state is tracked per `chat_id` from the typing indicator
(`send_typing` → in flight, `stop_typing` → ended; hermes fires
`stop_typing` in the handler's `finally`, after the final send, so the
flush always sees the final frame).
- The held-back frame is still parked in the outbox — sync catch-up is
unaffected; only the push is deferred.
- **High-priority notifications** (approval/clarify/cron) push immediately,
even mid-turn — they need user action.
- If the device **reconnects mid-turn** (SSE/long-poll open), the held-back
push is dropped: the app syncs the parked frames and must not get a
duplicate push at turn end.
- If the turn ends while the device is live, nothing is pushed (the frames
were delivered live / synced).
- Turns without a typing indicator (e.g. typing disabled in config) and
non-turn deliveries (cron, standalone sends) push immediately as before.
- Best-effort: a gateway crash mid-turn loses the held-back push (the frames
remain in the outbox and sync on reconnect).
## 8.2 `PushBackend` interface (`push.py`) ## 8.2 `PushBackend` interface (`push.py`)
```python ```python
@@ -23,13 +57,14 @@ class PushBackend(Protocol):
def configured(self) -> bool: ... def configured(self) -> bool: ...
``` ```
Selected at adapter init by `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`). Selected at adapter init by `IRIS_PUSH_BACKEND` (`ntfy` default, `fcm`).
### 8.2.1 `FcmBackend` (optional; metadata via Google)
### 8.2.1 `FcmBackend` (primary)
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service - **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
account** (`ANDROID_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
OAuth2 access token (cached, refreshed before expiry). OAuth2 access token (cached, refreshed before expiry).
- Fallback: legacy **server key** (`ANDROID_FCM_SERVER_KEY`) if no service - Fallback: legacy **server key** (`IRIS_FCM_SERVER_KEY`) if no service
account (simpler, but legacy). account (simpler, but legacy).
- Target = the device's **FCM token** (registered via `hello` / - Target = the device's **FCM token** (registered via `hello` /
`fcm.register`, stored in `devices.db`). `fcm.register`, stored in `devices.db`).
@@ -42,7 +77,8 @@ Selected at adapter init by `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`).
- Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few - Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few
devices). devices).
### 8.2.2 `NtfyBackend` (fallback, self-host friendly) ### 8.2.2 `NtfyBackend` (default, self-host friendly)
- Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter). - Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter).
- Publish to `NTFY_TOPIC` on `NTFY_SERVER_URL` (default `https://ntfy.sh`) via - Publish to `NTFY_TOPIC` on `NTFY_SERVER_URL` (default `https://ntfy.sh`) via
`httpx` POST, with an `X-Title` / `X-Message` / `X-Tag` / `X-Priority` and a `httpx` POST, with an `X-Title` / `X-Message` / `X-Tag` / `X-Priority` and a
+87 -39
View File
@@ -13,21 +13,24 @@
## 9.2 Pairing flow ## 9.2 Pairing flow
1. **Generate a token.** `hermes gateway setup` (our `interactive_setup`) either 1. **Generate a token.** `hermes gateway setup` (our `interactive_setup`) either
uses an existing `ANDROID_TOKEN` or generates a fresh high-entropy token uses an existing `IRIS_TOKEN` or generates a fresh high-entropy token
(e.g. 32 bytes → 64 hex chars) and stores it in `.env`. (e.g. 32 bytes → 64 hex chars) and stores it in `.env`.
2. **Present to the app.** Two options: 2. **Present to the app.** Two options:
- **QR code:** the setup prints a QR encoding - **QR code:** the setup prints a QR encoding
`iris://pair?host=<lan-ip>&port=8790&token=<token>` (or a WSS URL). The `iris://pair?host=<lan-ip>&port=8791&secure=0&token=<token>` (or a WSS
phone scans it with the app's camera (or a system scanner) → pre-fills URL when `secure=1`). The phone scans it with the app's **Scan QR**
button (or a system scanner → `iris://pair` deep link) → pre-fills
settings. settings.
- **Manual:** user types the server URL + token in the app's Connect screen. - **Manual:** user types the server URL + token in the app's Connect screen.
3. **App connects.** First WS frame is `hello {token, device_id, device_name, 3. **App connects.** First WS frame is `hello {token, device_id, device_name,
caps, fcm_token?}`. caps, fcm_token?}`.
4. **Server verifies.** Constant-time compare of `token` vs `ANDROID_TOKEN` 4. **Server verifies.** Constant-time compare of `token` vs `IRIS_TOKEN`
(`hmac.compare_digest`). Optionally check `device_id` against (`hmac.compare_digest`). Optionally check `device_id` against
`ANDROID_ALLOWED_USERS` (if set) or `ANDROID_ALLOW_ALL_USERS`. `IRIS_ALLOWED_USERS` (if set) or `IRIS_ALLOW_ALL_USERS`.
5. **On success:** register the device in `devices.db`, send `hello.ack`. 5. **On success:** register the device in `devices.db`, **mint its
**On failure:** send `error {code:"auth"}` and close. per-device token** (if it has none yet) and return it in
`hello.ack.device_token`. **On failure:** send `error {code:"auth"}`
and close.
`device_id` is a stable, app-generated UUID (persisted in the app's `device_id` is a stable, app-generated UUID (persisted in the app's
secure storage). It identifies the device for routing + push, **not** as a secure storage). It identifies the device for routing + push, **not** as a
@@ -35,37 +38,81 @@ security principal (the token is).
## 9.3 Auth model ## 9.3 Auth model
- **Token = the security principal.** Any connection presenting the valid - **Two tokens, one principal per device.**
`ANDROID_TOKEN` is authorized (it's the user's own token). - **Shared `IRIS_TOKEN` (bootstrap):** the setup token from
- **Allowlist (optional):** `ANDROID_ALLOWED_USERS` (comma-separated `hermes gateway setup`. It authorizes *pairing* — a NEW device (no row
`device_id`s) restricts which *devices* may connect even with the token — in `devices.db` yet) presents it to connect, and the gateway mints a
useful if the token is shared. `ANDROID_ALLOW_ALL_USERS=true` disables the per-device token for it (returned in `hello.ack.device_token`). It
allowlist (dev only). keeps working for devices that never received a per-device token
- **Per-device tokens (stretch):** mint a unique token per device at pairing (legacy apps), so an upgrade never bricks a pairing.
(revocable) instead of one shared token. v1 uses the shared token + optional - **Per-device token (revocable):** minted once at pairing
device allowlist. (`DeviceRegistry.issue_token`, 64 hex chars, stored in the `devices`
- **Re-pairing:** rotating `ANDROID_TOKEN` invalidates all devices; they must table of `devices.db`). The app stores it in secure storage and
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR. presents it INSTEAD of the shared token from the next request on
(`Authorization: Bearer <device-token>`). Both tokens are compared in
constant time (`verify_token`); a revoked device is rejected before
either comparison runs.
- **Per-device revocation.** Two control surfaces (run on the gateway host):
- **Setup flow** — `hermes gateway setup` → *Iris*: on an existing setup
(devices already paired) it asks **"Remove a paired device?"** (default
No). If yes: a numbered select menu (name, device id, last seen) whose
LAST option is *Exit* (leaves the removal loop, continues the setup);
picking a device asks for confirmation, then returns to the menu so
several devices can be removed in a row.
- **CLI** — `gateway-plugin/tools/iris_devices.py`:
- `list` — paired devices (id, name, token minted?, last seen) + revoked ids.
- `revoke <device_id>` — drops the device's row (token, push tokens,
cursor) AND adds its id to the `revoked` denylist: the device can no
longer connect with its device token **or** the shared token, while
every other device is unaffected. This is the isolation primitive a
shared token alone can't provide (a compromised device can't be cut
off without rotating the token for everyone).
- `unrevoke <device_id>` — removes it from the denylist so it can pair
again (a fresh token is minted at the next pairing).
- `reissue <device_id>` — rotates the device's token (the old one stops
working; the app picks up the new one on its next (re)connect via
`hello.ack`).
Re-pairing a revoked device also works by giving the app a fresh
`device_id` (e.g. `adb shell pm clear dev.iris.app`), which bootstraps
with the shared token like any new device.
- **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated
`device_id`s) restricts which *devices* may connect even with a valid
token — useful if the shared token is exposed. `IRIS_ALLOW_ALL_USERS=true`
disables the allowlist (dev only).
- **Re-pairing / rotation.** Rotating `IRIS_TOKEN` no longer invalidates
paired devices: they authenticate with their per-device tokens, which
survive the rotation. Only bootstrap of NEW devices needs the new shared
token. (Legacy devices without a per-device token still re-pair, as
before.)
## 9.4 Transport security ## 9.4 Transport security
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home - **Default (LAN/dev):** plain `http://` on the trusted LAN. Fine for a home
network. network.
- **WSS (recommended for remote):** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY` - **HTTPS (recommended for remote):** set `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`
(self-signed or CA-signed). The app pins/accepts the cert (self-signed → user (self-signed or CA-signed). CA-signed certs work out of the box.
confirms fingerprint on first pair, like a SSH host key). For a **self-signed** cert the app shows its SHA-256 fingerprint on first
pair (like a SSH host key); once the user confirms it, the fingerprint is
pinned in secure storage (`SecureStore.pinnedCertFingerprint`) and the
app's `PinningTrustManager` accepts exactly that certificate from then on
(hostname verification still applies). A *changed* certificate fails
again with a fresh confirm request — the user must re-confirm, like a
changed SSH host key. No system trust-store install needed. Note: the cert
must carry a **SAN** for the URL host (OkHttp's hostname verifier rejects
CN-only certs even when pinned) — e.g. `openssl req -x509 ... -addext
"subjectAltName=DNS:myhost,IP:192.168.1.10"`.
- **Remote reachability options** (documented, user's choice): - **Remote reachability options** (documented, user's choice):
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP; - **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
app connects over the private mesh. No public exposure. app connects over the private mesh. No public exposure.
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS - **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
at the edge, forward WS to `127.0.0.1:8790`. at the edge, forward to `127.0.0.1:8791`.
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort. - **Public bind** (`0.0.0.0`) + HTTPS + strong token — last resort.
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames - **HTTP transport (docs/19):** the gateway serves the same frames over plain
over plain HTTP (`ANDROID_HTTP_PORT`, default 8791) for the app's HTTP (`IRIS_HTTP_PORT`, default 8791) — the only device-facing transport.
fallback transport. It is a *second door with the same lock*: the same It shares the same lock as everything else: the same Bearer token
Bearer token (constant-time `verify_token`) + the same device allowlist (constant-time `verify_token`) + the same device allowlist
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as (`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit.
the WS. Optional TLS via `ANDROID_HTTP_CERT` / `ANDROID_HTTP_KEY`. Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
`GET /v1/health` is unauthenticated by design (liveness only — it must `GET /v1/health` is unauthenticated by design (liveness only — it must
not reflect tokens, device ids, or versions). not reflect tokens, device ids, or versions).
- The app stores the server URL + (for self-signed) the pinned cert fingerprint - The app stores the server URL + (for self-signed) the pinned cert fingerprint
@@ -73,7 +120,7 @@ security principal (the token is).
## 9.5 Secret & PII handling ## 9.5 Secret & PII handling
- **Tokens/keys never logged.** Redact `ANDROID_TOKEN`, FCM tokens/keys, ntfy - **Tokens/keys never logged.** Redact `IRIS_TOKEN`, FCM tokens/keys, ntfy
tokens in all log output (hermes PII policy; `agent/redact.py` patterns). tokens in all log output (hermes PII policy; `agent/redact.py` patterns).
- **`device_id`** is a random UUID (not PII). `device_name` is user-chosen. - **`device_id`** is a random UUID (not PII). `device_name` is user-chosen.
- **Media pull** is gated by hermes `validate_media_delivery_path` + delivery - **Media pull** is gated by hermes `validate_media_delivery_path` + delivery
@@ -84,7 +131,7 @@ security principal (the token is).
## 9.6 Profile safety ## 9.6 Profile safety
- All plugin state lives under `get_hermes_home()/"android"` (profile-aware). - All plugin state lives under `get_hermes_home()/"iris"` (profile-aware).
- Secrets are read with the scope-aware `_get_scoped_secret` pattern (see - Secrets are read with the scope-aware `_get_scoped_secret` pattern (see
`plugins/platforms/irc/adapter.py:42`) so multiplexed profiles don't leak `plugins/platforms/irc/adapter.py:42`) so multiplexed profiles don't leak
each other's tokens (fail-closed under `gateway.multiplex_profiles`). each other's tokens (fail-closed under `gateway.multiplex_profiles`).
@@ -110,16 +157,17 @@ M7 research pass. "verified" = implemented and covered by
"gap" = known limitation with the planned mitigation. "gap" = known limitation with the planned mitigation.
| # | Item | Status | Evidence / mitigation | | # | Item | Status | Evidence / mitigation |
|---|------|--------|-----------------------| | --- | ------ | -------- | ----------------------- |
| 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` | | 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` |
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`ws_server.py:47`, `broadcast`/`send_to`). Inbound: per-connection token bucket on JSON frames (20/s, burst 40) → `error {code:"rate_limited"}` + close on exceed (`ws_server.py:55`, `_TokenBucket`, `_on_frame`); binary upload chunks exempt (see gap 1) | | 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`http_server.py`, `broadcast`/`send_to`). Inbound: per-device token bucket on JSON frames (20/s, burst 40) → `429` on exceed (`http_server.py`, `_rate_limited`); media uploads bounded by the per-request body cap + per-upload total cap (see gap 1) |
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | `serve(max_size=adapter.max_upload_bytes)` (`ws_server.py:139`); per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` | | 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | Per-request body cap in `http_server.py`; per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` |
| 4 | Verify media sha256 + re-sniff MIME (don't trust client) | verified | `media.py:317` (`complete_upload` digest check), `media.py:147` (`reclassify_kind`); `test_upload_sha256_mismatch_rejected`, `test_reclassify_kind_does_not_trust_client` | | 4 | Verify media sha256 + re-sniff MIME (don't trust client) | verified | `media.py:317` (`complete_upload` digest check), `media.py:147` (`reclassify_kind`); `test_upload_sha256_mismatch_rejected`, `test_reclassify_kind_does_not_trust_client` |
| 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned | | 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned |
| 6 | WSS + cert pinning for remote | gap (partial) | WSS supported server-side (`ANDROID_WS_CERT`/`ANDROID_WS_KEY`, `ws_server.py:122`); the app builds a default `OkHttpClient` with no `CertificatePinner` (`app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt:87`). Mitigation: remote access requires CA-signed WSS until pinning lands; LAN `ws://` stays the default | | 6 | HTTPS + cert pinning for remote | implemented | TLS supported server-side (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`, `http_server.py`); the app pins self-signed certs via a fingerprint-confirm flow: `PinningTrustManager` wraps the platform default trust manager, a rejected cert is accepted only when its SHA-256 fingerprint matches the user-confirmed pin in `SecureStore.pinnedCertFingerprint`, anything else fails with `TlsFingerprintRequired` → confirm dialog on the Connect screen (`app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt`, `GatewayClient.State.TlsConfirmRequired`); hostname verification still applies (the cert needs a SAN for the URL host). CA-signed certs work out of the box; LAN `http://` stays the default. Tests: `TlsPinningTest`, `TlsPinningIntegrationTest` |
| 7 | Outbox retention cap + prune | verified | `gateway-plugin/outbox.py:48` (`retention_hours` default 72h, `max_rows` cap, `take_overflow_pruned`); `test_outbox_row_cap_prunes_oldest` | | 7 | Outbox retention cap + prune | verified | `gateway-plugin/outbox.py:48` (`retention_hours` default 72h, `max_rows` cap, `take_overflow_pruned`); `test_outbox_row_cap_prunes_oldest` |
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `ANDROID_TOKEN`/`ANDROID_WS_CERT`/`ANDROID_WS_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) | | 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `IRIS_TOKEN`/`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: token bucket in `ws_server.py` (JSON frames only). Binary upload chunks are exempt — a 100 MB upload is 400 × 256 KiB frames in a tight loop and would exhaust any sane bucket; uploads are already bounded by per-frame `max_size` + the per-upload total cap | | 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: per-device token bucket in `http_server.py` (`_rate_limited`). Media uploads are bounded by the per-request body cap + the per-upload total cap |
| 10 | Gap: Android token storage | implemented | `AndroidSecureStore` → `EncryptedSharedPreferences` (MasterKey AES256_GCM) with one-time migration of the plain `iris` prefs (read old key → write encrypted → delete old key); dep in `app/shared/build.gradle.kts` (`app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt`) | | 10 | Gap: Android token storage | implemented | `AndroidSecureStore` → `EncryptedSharedPreferences` (MasterKey AES256_GCM) with one-time migration of the plain `iris` prefs (read old key → write encrypted → delete old key); dep in `app/shared/build.gradle.kts` (`app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt`) |
| 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` | | 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` |
| 12 | Gap: in-app QR scanner | gap | Pairing is manual URL+token only; the server prints a QR (`gateway-plugin/adapter.py:648-654`) that any system scanner can read. Plan: in-app camera scan later | | 12 | Gap: in-app QR scanner | implemented | `hermes gateway setup` renders a terminal QR (`gateway-plugin/qr.py`, pure-stdlib encoder) and the app's Connect screen has a **Scan QR** button (CameraX + ML Kit, `QrScanActivity`) plus an `iris://pair` deep link (`PairLink.parse`); `docs/20` |
| 13 | Gap: per-device tokens (revocation) | implemented | `DeviceRegistry.issue_token` mints a 64-hex per-device token at pairing (stored in `devices.db`, returned in `hello.ack.device_token`); the app stores it in secure storage and presents it instead of the shared `IRIS_TOKEN` (bootstrap path unchanged). `tools/iris_devices.py revoke <device_id>` drops the device + denylists its id (rejected even with the shared token); `unrevoke`/`reissue` for re-pairing/rotation. `docs/09` §9.3 |
+10 -2
View File
@@ -1,4 +1,4 @@
# 10 — Android App (Kotlin + Jetpack Compose) # 10 — Iris App (Android; Kotlin + Jetpack Compose)
Native client. Lives in the Compose Multiplatform project at `app/`; the bulk of Native client. Lives in the Compose Multiplatform project at `app/`; the bulk of
the code is in `app/shared` (commonMain) so the Desktop app reuses it. the code is in `app/shared` (commonMain) so the Desktop app reuses it.
@@ -115,7 +115,7 @@ app/shared/src/
- `ToolCard` renders `tool.start/progress/end` frames. - `ToolCard` renders `tool.start/progress/end` frames.
- The gateway always supplies the **full** tool data: it forces - The gateway always supplies the **full** tool data: it forces
`display.platforms.android.tool_progress: verbose` (so the progress line `display.platforms.iris.tool_progress: verbose` (so the progress line
carries the full args JSON → `tool.start.args`) and captures each completed carries the full args JSON → `tool.start.args`) and captures each completed
call via the `post_tool_call` hook (→ `tool.end` `output_preview` / call via the `post_tool_call` hook (→ `tool.end` `output_preview` /
`duration` / `ok`). The app decides how much to show. `duration` / `ok`). The app decides how much to show.
@@ -317,5 +317,13 @@ Storage: `AndroidSqliteDriver` (app database dir) on Android,
- First launch → **Connect**: server URL + token (or scan QR). "Test connection" - First launch → **Connect**: server URL + token (or scan QR). "Test connection"
does a real `hello` (not just a TCP probe — per hermes desktop guidance, the does a real `hello` (not just a TCP probe — per hermes desktop guidance, the
auth leg must be exercised). On success → save (secure storage) → main. auth leg must be exercised). On success → save (secure storage) → main.
- **Scan QR** (Android only, `docs/20`): a button below the token field opens
`QrScanActivity` (CameraX + ML Kit, on-device, no Play services), requests the
`CAMERA` permission, and pre-fills URL + token from the decoded
`iris://pair…` payload via `PairLink.parse`. It never auto-connects — the
user still taps "Test & Connect". A non-pairing QR sets an error and leaves
the fields untouched. The same payload also arrives as an `iris://pair` deep
link (system scanner / other phones) and pre-fills the screen the same way.
Hidden on desktop (no camera).
- States: connecting / connected / reconnecting / degraded / auth-failed — each - States: connecting / connected / reconnecting / degraded / auth-failed — each
with honest copy and a way out. with honest copy and a way out.
+4 -4
View File
@@ -1,13 +1,13 @@
# 11 — Desktop App (Kotlin + Compose Multiplatform) # 11 — Desktop App (Kotlin + Compose Multiplatform)
The desktop app is **the Android app, tweaked for a big screen**. It reuses the The desktop app is **the Iris app, tweaked for a big screen**. It reuses the
entire `app/shared` module (protocol, network, repositories, state, most UI) and entire `app/shared` module (protocol, network, repositories, state, most UI) and
only adds desktop platform services + a wider default layout. only adds desktop platform services + a wider default layout.
## 11.1 What's shared vs desktop-specific ## 11.1 What's shared vs desktop-specific
| Layer | Shared (commonMain) | Desktop-specific (desktopMain) | | Layer | Shared (commonMain) | Desktop-specific (desktopMain) |
|---|---|---| | --- | --- | --- |
| Protocol / WS client | ✅ | — | | Protocol / WS client | ✅ | — |
| Repositories / state | ✅ | — | | Repositories / state | ✅ | — |
| Most Compose UI | ✅ | layout tweaks, keyboard shortcuts | | Most Compose UI | ✅ | layout tweaks, keyboard shortcuts |
@@ -62,9 +62,9 @@ only adds desktop platform services + a wider default layout.
- The desktop app connects to the **same** gateway WS server as the phone (the - The desktop app connects to the **same** gateway WS server as the phone (the
user's home server / Tailscale). It does **not** spawn its own backend (unlike user's home server / Tailscale). It does **not** spawn its own backend (unlike
hermes's existing Electron desktop, which spawns `hermes serve`) — our hermes's existing Electron desktop, which spawns `hermes serve`) — our
desktop is a pure client of the messaging gateway, matching the Android app. desktop is a pure client of the messaging gateway, matching the Iris app.
## 11.5 Parity checklist (same functionality as Android) ## 11.5 Parity checklist (same functionality as the Iris app)
- [ ] Streaming, reasoning block, tool cards (3-level verbosity), commentary. - [ ] Streaming, reasoning block, tool cards (3-level verbosity), commentary.
- [ ] Channels + threads + new channel + cron target. - [ ] Channels + threads + new channel + cron target.
+40 -28
View File
@@ -9,6 +9,7 @@ First-time setup on a machine (verified baseline: CachyOS/Arch, `pacman`,
pacman -S jdk17-openjdk pacman -S jdk17-openjdk
java -version # expect 17.x java -version # expect 17.x
``` ```
(Compose Multiplatform + current AGP are happy on JDK 17. Use 17 to match the (Compose Multiplatform + current AGP are happy on JDK 17. Use 17 to match the
Android toolchain; 21 also works but 17 is the safe floor.) Android toolchain; 21 also works but 17 is the safe floor.)
@@ -28,10 +29,12 @@ export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-
sdkmanager --licenses sdkmanager --licenses
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"
``` ```
Persist `ANDROID_HOME`/`PATH` in `~/.bashrc`. ADB is already installed system-wide; Persist `ANDROID_HOME`/`PATH` in `~/.bashrc`. ADB is already installed system-wide;
`platform-tools` from the SDK is fine too (whichever is first on `PATH`). `platform-tools` from the SDK is fine too (whichever is first on `PATH`).
Create `app/local.properties`: Create `app/local.properties`:
``` ```
sdk.dir=/home/<you>/android-sdk sdk.dir=/home/<you>/android-sdk
``` ```
@@ -39,10 +42,12 @@ sdk.dir=/home/<you>/android-sdk
## 12.3 Gradle ## 12.3 Gradle
No system install — use the project wrapper: No system install — use the project wrapper:
```bash ```bash
cd app cd app
./gradlew tasks # first run downloads the wrapper distribution ./gradlew tasks # first run downloads the wrapper distribution
``` ```
(The wrapper version is pinned in `app/gradle/wrapper/gradle-wrapper.properties`.) (The wrapper version is pinned in `app/gradle/wrapper/gradle-wrapper.properties`.)
## 12.4 hermes environment (for the plugin + running the gateway) ## 12.4 hermes environment (for the plugin + running the gateway)
@@ -53,15 +58,19 @@ uv sync # creates .venv with all core deps (websockets, httpx,
source .venv/bin/activate source .venv/bin/activate
hermes --version # sanity hermes --version # sanity
``` ```
- Run the gateway with the plugin: - Run the gateway with the plugin:
```bash ```bash
# install the plugin (dev: symlink) # install the plugin (dev: symlink)
mkdir -p ~/.hermes/plugins mkdir -p ~/.hermes/plugins
ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/android ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/iris
hermes gateway status # should list "android" hermes gateway status # should list "iris"
hermes gateway # run hermes gateway # run
``` ```
- Tests use hermes's hermetic runner (never bare `pytest`): - Tests use hermes's hermetic runner (never bare `pytest`):
```bash ```bash
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
``` ```
@@ -73,43 +82,46 @@ hermes --version # sanity
`dev.iris.app`). Download `google-services.json` → `app/androidApp/`. `dev.iris.app`). Download `google-services.json` → `app/androidApp/`.
3. Create a **service account** (Project settings → Service accounts → Generate 3. Create a **service account** (Project settings → Service accounts → Generate
new private key) → download the JSON. Store its path in new private key) → download the JSON. Store its path in
`ANDROID_FCM_SERVICE_ACCOUNT` (in `~/.hermes/.env`). `IRIS_FCM_SERVICE_ACCOUNT` (in `~/.hermes/.env`).
4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and 4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and
registers it via `hello` / `fcm.register`. registers it via `hello` / `fcm.register`.
> Skip Firebase → set `ANDROID_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` / > Skip Firebase → the default is already ntfy: leave `IRIS_PUSH_BACKEND` unset
> `NTFY_SERVER_URL` (self-host ntfy or use ntfy.sh). See `08-push.md`. > (or set it to `ntfy`) and configure `NTFY_TOPIC` / `NTFY_SERVER_URL`
> (self-host ntfy or use ntfy.sh). See `08-push.md`.
## 12.6 Environment variables (summary) ## 12.6 Environment variables (summary)
**Secrets (`~/.hermes/.env`):** **Secrets (`~/.hermes/.env`):**
``` ```
ANDROID_TOKEN=<64-hex> IRIS_TOKEN=<64-hex>
ANDROID_PUSH_BACKEND=fcm # or ntfy IRIS_PUSH_BACKEND=ntfy # default; fcm = opt-in (metadata via Google)
ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
# ANDROID_FCM_SERVER_KEY=<legacy key> # fallback if no service account # IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
# NTFY_TOPIC=iris-push # when ntfy # NTFY_TOPIC=iris-push # when ntfy
# NTFY_SERVER_URL=https://ntfy.sh # NTFY_SERVER_URL=https://ntfy.sh
# ANDROID_WS_CERT=/path/cert.pem # WSS # IRIS_HTTP_CERT=/path/cert.pem # HTTPS
# ANDROID_WS_KEY=/path/key.pem # IRIS_HTTP_KEY=/path/key.pem
``` ```
**Behavioral (`~/.hermes/config.yaml`):** **Behavioral (`~/.hermes/config.yaml`):**
```yaml ```yaml
gateway: gateway:
platforms: platforms:
android: iris:
enabled: true enabled: true
extra: extra:
host: 127.0.0.1 # 0.0.0.0 for LAN host: 127.0.0.1 # 0.0.0.0 for LAN
port: 8790 http_port: 8791
home_channel: android:default home_channel: default
push_backend: fcm push_backend: fcm
outbox_retention_hours: 72 outbox_retention_hours: 72
max_upload_bytes: 104857600 # 100 MB max_upload_bytes: 104857600 # 100 MB
display: display:
platforms: platforms:
android: iris:
show_reasoning: true show_reasoning: true
reasoning_style: code reasoning_style: code
streaming: true streaming: true
@@ -120,19 +132,19 @@ display:
```bash ```bash
# 1. gateway up with plugin # 1. gateway up with plugin
hermes gateway status | grep -i android hermes gateway status | grep -i iris
# 2. a raw WS client can pair + echo # 2. the HTTP server answers (unauthenticated liveness)
python - <<'PY' curl -s http://127.0.0.1:8791/v1/health
import asyncio, json, websockets # -> {"ok": true}
async def main():
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws: # 3. a frame round-trip with the pairing token
await ws.send(json.dumps({"v":1,"type":"hello","payload":{ curl -s -X POST http://127.0.0.1:8791/v1/frame \
"token":"<ANDROID_TOKEN>","device_id":"test","device_name":"probe", -H "Authorization: Bearer <IRIS_TOKEN>" -H "X-Iris-Device: probe" \
"caps":{"min_protocol":1}}})) -H "Content-Type: application/json" \
print("recv:", await ws.recv()) -d '{"v":1,"type":"commands.catalog","id":1,"payload":{}}'
asyncio.run(main())
PY
``` ```
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
Expect `{"ok": true}` from the health probe and a `commands.catalog` reply
frame from the POST. If you get `401`, the token/host/port is
wrong. wrong.
+10 -6
View File
@@ -10,16 +10,18 @@ without the app (critical for verifying frame shapes early).
into the hermes `tests/gateway/test_android.py` pattern when running under into the hermes `tests/gateway/test_android.py` pattern when running under
hermes's suite). hermes's suite).
- **Run with hermes's hermetic runner** (never bare `pytest`): - **Run with hermes's hermetic runner** (never bare `pytest`):
```bash ```bash
cd hermes-agent cd hermes-agent
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
scripts/run_tests.sh # full suite (CI parity) scripts/run_tests.sh # full suite (CI parity)
``` ```
- Coverage to write (behavioral, not change-detector — per hermes test policy): - Coverage to write (behavioral, not change-detector — per hermes test policy):
- `register(ctx)` produces a valid `PlatformEntry` (name, cron env var, - `register(ctx)` produces a valid `PlatformEntry` (name, cron env var,
parse_target_ref). parse_target_ref).
- `check_requirements` / `validate_config` / `is_connected` truth table. - `check_requirements` / `validate_config` / `is_connected` truth table.
- `_parse_target_ref`: `android:<chat>`, `android:<chat>:<thread>`, non-android - `_parse_target_ref`: `iris:<chat>`, `iris:<chat>:<thread>`, non-iris
→ None. → None.
- **Reasoning split:** given a `show_reasoning`-style final text, `send()` - **Reasoning split:** given a `show_reasoning`-style final text, `send()`
emits `message {reasoning, text}` correctly; no-prefix → no reasoning field. emits `message {reasoning, text}` correctly; no-prefix → no reasoning field.
@@ -53,12 +55,13 @@ commentary classification and the reasoning prefix) before/while building the
Kotlin client. Kotlin client.
```bash ```bash
hermes gateway & # with the android plugin hermes gateway & # with the iris plugin
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \ python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> \
--send "list the files and summarize" --send "list the files and summarize"
# prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end, # prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end,
# commentary, message.stop {reasoning,…}, … # commentary, message.stop {reasoning,…}, …
``` ```
Use it to lock `04-wire-protocol.md` against reality and to debug the adapter Use it to lock `04-wire-protocol.md` against reality and to debug the adapter
without waiting for the app. without waiting for the app.
@@ -102,6 +105,7 @@ adb logcat -d > /tmp/logcat.txt
``` ```
**E2E scenarios (script where possible):** **E2E scenarios (script where possible):**
1. **Pair:** connect screen → enter URL+token → `hello.ack` → main. (Verify auth 1. **Pair:** connect screen → enter URL+token → `hello.ack` → main. (Verify auth
leg, not just TCP.) leg, not just TCP.)
2. **Text round-trip:** send "hello" → streamed reply appears (message.start → 2. **Text round-trip:** send "hello" → streamed reply appears (message.start →
@@ -112,7 +116,7 @@ adb logcat -d > /tmp/logcat.txt
verbosity in Settings → rendering changes. verbosity in Settings → rendering changes.
5. **Intermediate:** a multi-step prompt → commentary bubble appears dimmed. 5. **Intermediate:** a multi-step prompt → commentary bubble appears dimmed.
6. **Channels:** create "Cron Reports" → appears in list; set as cron target. 6. **Channels:** create "Cron Reports" → appears in list; set as cron target.
7. **Cron delivery:** create a cron job `deliver=android:android:chan_<n>` → it 7. **Cron delivery:** create a cron job `deliver=iris:chan_<n>` → it
fires → lands in that channel (not default). fires → lands in that channel (not default).
8. **Search:** "search everywhere" vs "this chat" → correct scoping; tap → jump. 8. **Search:** "search everywhere" vs "this chat" → correct scoping; tap → jump.
9. **Media (in):** attach a photo + a video → agent receives (vision) → reply. 9. **Media (in):** attach a photo + a video → agent receives (vision) → reply.
@@ -144,11 +148,11 @@ adb logcat -d > /tmp/logcat.txt
## 13.5 Debugging tips ## 13.5 Debugging tips
- **Gateway side:** `~/.hermes/logs/gateway.log` (and `hermes logs --follow`). - **Gateway side:** `~/.hermes/logs/gateway.log` (and `hermes logs --follow`).
Our plugin logs under the `android` adapter name; secrets redacted. Our plugin logs under the `iris` adapter name; secrets redacted.
- **WS framing bugs:** use the `ws_probe.py` harness — it isolates the protocol - **WS framing bugs:** use the `ws_probe.py` harness — it isolates the protocol
from the app. from the app.
- **Streaming jitter:** the consumer edits at intervals; if updates look chunky, - **Streaming jitter:** the consumer edits at intervals; if updates look chunky,
check `display.platforms.android.streaming` and the consumer's edit interval. check `display.platforms.iris.streaming` and the consumer's edit interval.
- **Media pull stalls:** check chunk size + backpressure; confirm the file is - **Media pull stalls:** check chunk size + backpressure; confirm the file is
within hermes delivery roots (`validate_media_delivery_path`). within hermes delivery roots (`validate_media_delivery_path`).
- **FCM not arriving:** confirm the token registered (`devices.db`), the service - **FCM not arriving:** confirm the token registered (`devices.db`), the service
+59 -12
View File
@@ -1,4 +1,4 @@
# 14 — Milestones (M0–M7) # 14 — Milestones (M0–M8)
Phased delivery. Each milestone ends with a **demo** (on-device where noted) and Phased delivery. Each milestone ends with a **demo** (on-device where noted) and
has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
@@ -6,7 +6,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
--- ---
## M0 — Toolchain & scaffolding ## M0 — Toolchain & scaffolding
**Goal:** everything builds; the plugin is discoverable; the repo is safe. **Goal:** everything builds; the plugin is discoverable; the repo is safe.
- [X] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`). - [X] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`).
- [X] `cd hermes-agent && uv sync` (hermes venv works). - [X] `cd hermes-agent && uv sync` (hermes venv works).
- [X] Create monorepo scaffold (`02-monorepo.md`): `gateway-plugin/`, `app/` - [X] Create monorepo scaffold (`02-monorepo.md`): `gateway-plugin/`, `app/`
@@ -16,20 +18,22 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
- [X] CMP project builds empty: `./gradlew :androidApp:assembleDebug`, - [X] CMP project builds empty: `./gradlew :androidApp:assembleDebug`,
`./gradlew :desktopApp:run` (blank window). `./gradlew :desktopApp:run` (blank window).
- [X] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a - [X] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a
no-op `AndroidAdapter` → `hermes gateway status` lists **android**. no-op `IrisAdapter` → `hermes gateway status` lists **iris**.
- **Demo:** `hermes gateway status` shows `android`; `./gradlew - **Demo:** `hermes gateway status` shows `iris`; `./gradlew
:androidApp:installDebug` installs a blank app on the MIX 2S. :androidApp:installDebug` installs a blank app on the MIX 2S.
- **Accept:** blank app installs + launches on-device; plugin visible in - **Accept:** blank app installs + launches on-device; plugin visible in
`hermes gateway status`; `hermes-agent/` is git-ignored (verify with `hermes gateway status`; `hermes-agent/` is git-ignored (verify with
`git status --ignored`). `git status --ignored`).
## M1 — Gateway core loop (text round-trip) ## M1 — Gateway core loop (text round-trip)
**Goal:** pair + send a text message + get a (non-streaming) reply. **Goal:** pair + send a text message + get a (non-streaming) reply.
- [X] WS server (`ws_server.py`): bind, `hello` auth (constant-time), - [X] WS server (`ws_server.py`): bind, `hello` auth (constant-time),
`hello.ack`, heartbeat, connection registry. `hello.ack`, heartbeat, connection registry.
- [X] `AndroidAdapter.send()` → `message` frame; inbound `message.send` → - [X] `IrisAdapter.send()` → `message` frame; inbound `message.send` →
`MessageEvent` → `handle_message`. `MessageEvent` → `handle_message`.
- [X] Pairing store + `ANDROID_TOKEN`; QR payload in `interactive_setup`. - [X] Pairing store + `IRIS_TOKEN`; QR payload in `interactive_setup`.
- [X] App: Connect screen (URL+token, real `hello` test), `GatewayClient` - [X] App: Connect screen (URL+token, real `hello` test), `GatewayClient`
(connect + reconnect), ChatScreen sends + renders `message`. (connect + reconnect), ChatScreen sends + renders `message`.
- [X] `ws_probe.py` harness drives a real turn. - [X] `ws_probe.py` harness drives a real turn.
@@ -38,9 +42,11 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
reconnect after gateway restart re-pairs. reconnect after gateway restart re-pairs.
## M2 — Streaming + reasoning + tools + commentary ## M2 — Streaming + reasoning + tools + commentary
**Goal:** the "agent transparency" features. **Goal:** the "agent transparency" features.
- [X] Map consumer `send`/`edit_message` → `message.start/update/stop`. - [X] Map consumer `send`/`edit_message` → `message.start/update/stop`.
- [X] Reasoning: set `show_reasoning` for android; adapter splits prefix → - [X] Reasoning: set `show_reasoning` for iris; adapter splits prefix →
`reasoning` field. **Verify format with `ws_probe.py`.** (The model `reasoning` field. **Verify format with `ws_probe.py`.** (The model
returns a separate `reasoning_content` field. In the *streaming* case the returns a separate `reasoning_content` field. In the *streaming* case the
gateway drops it — the stream consumer only forwards `content` and the gateway drops it — the stream consumer only forwards `content` and the
@@ -64,12 +70,14 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
rendering; reasoning copy button works; frame shapes match `04-wire-protocol`. rendering; reasoning copy button works; frame shapes match `04-wire-protocol`.
## M3 — Channels/threads + cron + search ## M3 — Channels/threads + cron + search
**Goal:** organization + cron delegation + search. **Goal:** organization + cron delegation + search.
- [X] Channel directory (SQLite): default channel ensured; `channel.create/ - [X] Channel directory (SQLite): default channel ensured; `channel.create/
rename/set_default/delete` + `channel.*` frames. rename/set_default/delete` + `channel.*` frames.
- [X] Threads: toggle in default chat; `thread_id` lanes; `create_handoff_thread`. - [X] Threads: toggle in default chat; `thread_id` lanes; `create_handoff_thread`.
- [X] `parse_target_ref_fn` + `cron_deliver_env_var` → cron - [X] `parse_target_ref_fn` + `cron_deliver_env_var` → cron
`deliver=android:<chat>[:<thread>]` works. `deliver=iris:<chat>[:<thread>]` works.
- [X] `search.py` FTS5 bridge; `search` frame (all / this-chat) → results. - [X] `search.py` FTS5 bridge; `search` frame (all / this-chat) → results.
- [X] App: channel list (drawer/rail), thread toggle + topic switcher, "new - [X] App: channel list (drawer/rail), thread toggle + topic switcher, "new
channel" + "set as cron target", SearchScreen with scope toggle + jump. channel" + "set as cron target", SearchScreen with scope toggle + jump.
@@ -79,7 +87,7 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
isolate context; search scoping correct; channel list reconciles on events. isolate context; search scoping correct; channel list reconciles on events.
- **Status (complete):** channel directory + threads + search + outbox sync - **Status (complete):** channel directory + threads + search + outbox sync
verified end-to-end via `ws_probe.py` (create/rename/set_default/delete, verified end-to-end via `ws_probe.py` (create/rename/set_default/delete,
thread lanes, FTS5 search, sync); cron `deliver=android:<chat>[:<thread>]` thread lanes, FTS5 search, sync); cron `deliver=iris:<chat>[:<thread>]`
target resolution verified via `resolve_send_target`. App on-device: channel target resolution verified via `resolve_send_target`. App on-device: channel
drawer, thread toggle + topic switcher, new channel, search overlay with drawer, thread toggle + topic switcher, new channel, search overlay with
jump. Minor UI gaps deferred to M7 polish: "set as cron target" is set via jump. Minor UI gaps deferred to M7 polish: "set as cron target" is set via
@@ -89,7 +97,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
not e2e-tested (it shares the verified resolution path). not e2e-tested (it shares the verified resolution path).
## M4 — Media ## M4 — Media
**Goal:** attach + receive + play media. **Goal:** attach + receive + play media.
- [X] Inbound: `media.upload` chunked → `cache_*_from_bytes` → `media_urls`; - [X] Inbound: `media.upload` chunked → `cache_*_from_bytes` → `media_urls`;
size limit + sha256 + MIME re-sniff. size limit + sha256 + MIME re-sniff.
- [X] Outbound: `send_*` → `media.offer`; `media.pull` chunked; delivery-path - [X] Outbound: `send_*` → `media.offer`; `media.pull` chunked; delivery-path
@@ -117,12 +127,14 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
`04-wire-protocol.md` + `frames.schema.json`. `04-wire-protocol.md` + `frames.schema.json`.
## M5 — Push + offline (FCM + ntfy) ## M5 — Push + offline (FCM + ntfy)
**Goal:** reach the phone when backgrounded; catch up on reconnect. **Goal:** reach the phone when backgrounded; catch up on reconnect.
- [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune - [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune
(row cap 5000 + prune banner, throttled 1/h). (row cap 5000 + prune banner, throttled 1/h).
- [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via - [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via
PyJWT+cryptography) + `NtfyBackend` (X-Data header); selected by PyJWT+cryptography) + `NtfyBackend` (X-Data header); selected by
`ANDROID_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`. `IRIS_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`.
- [x] Fire push on no-live-subscriber; data payload for silent sync. - [x] Fire push on no-live-subscriber; data payload for silent sync.
High-priority kinds (approval/clarify/cron) push even when live. High-priority kinds (approval/clarify/cron) push even when live.
- [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a - [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a
@@ -143,15 +155,17 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
loss/dup (verified); banners show for foreground events (implemented). loss/dup (verified); banners show for foreground events (implemented).
## M6 — Desktop app ## M6 — Desktop app
**Goal:** the same app on a big screen. **Goal:** the same app on a big screen.
- [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView); - [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView);
`MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt. `MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt.
- [x] Two-pane default layout; keyboard shortcuts; optional inspector pane. - [x] Two-pane default layout; keyboard shortcuts; optional inspector pane.
- [ ] Parity pass vs Android feature checklist (`11-desktop-app.md`). - [ ] Parity pass vs Iris app feature checklist (`11-desktop-app.md`).
- [x] jpackage builds (Linux first; macOS/Windows as available). - [x] jpackage builds (Linux first; macOS/Windows as available).
- **Demo:** desktop app pairs to the same gateway; full feature parity; tray - **Demo:** desktop app pairs to the same gateway; full feature parity; tray
notifications; media plays. notifications; media plays.
- **Accept:** all Android features work on desktop; tray + shortcuts work; - **Accept:** all Iris app features work on desktop; tray + shortcuts work;
native binary launches. native binary launches.
- **Status (complete, 2026-08-19):** All `desktopMain` actuals implemented and - **Status (complete, 2026-08-19):** All `desktopMain` actuals implemented and
verified on Linux (X11). `DesktopSecureStore`: non-secrets in verified on Linux (X11). `DesktopSecureStore`: non-secrets in
@@ -178,7 +192,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
macOS/Windows packaging are deferred to M7. macOS/Windows packaging are deferred to M7.
## M7 — Polish + E2E + docs ## M7 — Polish + E2E + docs
**Goal:** ship-quality. **Goal:** ship-quality.
- [x] Telegram-style layout pass (per reference image): header, bubbles, date - [x] Telegram-style layout pass (per reference image): header, bubbles, date
separators, ✓✓, model/token footer, banner, bottom bar. separators, ✓✓, model/token footer, banner, bottom bar.
- [x] Theming (dark default, accent), onboarding/pairing UX, empty/loading/ - [x] Theming (dark default, accent), onboarding/pairing UX, empty/loading/
@@ -222,12 +238,43 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
--- ---
## M8 — QR pairing
**Goal:** QR-based pairing — a scannable QR at `hermes gateway setup` plus an
in-app scanner and `iris://pair` deep link (closes gap #12, `docs/20`).
- [x] Pure-stdlib QR encoder + terminal renderer (`gateway-plugin/qr.py`):
byte mode, EC M with L fallback, versions 1–10, ISO penalty masking,
**zero new Python deps**.
- [x] `interactive_setup` renders the QR after the pairing URL (text lines
stay as the primary path).
- [x] `PairLink` parser (`iris/util/PairLink.kt`) + jvmTest (valid/missing
token/bad port/wrong scheme/wrong host/percent-encoded/secure/default
port).
- [x] CameraX + ML Kit scanner (`QrScanActivity`, on-device, no Play
services) + Connect-screen **Scan QR** button (Android only; hidden on
desktop) + `CAMERA` permission.
- [x] `iris://pair` deep link (system-scanner / other-phone fallback) reusing
the same parser.
- **Accept:** `docs/20` §20.6; gap #12 in `09-pairing-security.md` closed.
- **Status (2026-08-22):** Encoder cross-checked byte-for-byte against an
independent reference and decoded by an independent decoder (zbarimg); fixed
v1-M and v7-M matrix vectors lock the algorithm. `hermes gateway setup`
prints a scannable QR (v7-M, 45 modules) for the 64-hex-token payload. App:
Connect screen shows **Scan QR** (Android), which opens `QrScanActivity`
(CameraX camera2 + ML Kit barcode), requests `CAMERA`, and pre-fills URL +
token via `PairLink.parse` without auto-connecting; `iris://pair` deep link
pre-fills the same way. Docs updated per `docs/20` Part C.
---
## Sequencing notes ## Sequencing notes
- **M1/M2 depend on the `ws_probe.py` harness** to lock frame shapes early — - **M1/M2 depend on the `ws_probe.py` harness** to lock frame shapes early —
build it in M1. build it in M1.
- **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3, - **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3,
extend for push in M5. extend for push in M5.
- **M6 (desktop) reuses M1–M5 shared code** — do it after the Android features - **M6 (desktop) reuses M1–M5 shared code** — do it after the Iris app features
are stable so the shared module is settled. are stable so the shared module is settled.
- Parallelizable: plugin (Python) and app (Kotlin) can be worked on - Parallelizable: plugin (Python) and app (Kotlin) can be worked on
concurrently once the protocol (`04-wire-protocol.md`) is agreed; the probe concurrently once the protocol (`04-wire-protocol.md`) is agreed; the probe
+1 -1
View File
@@ -5,7 +5,7 @@ integration point. Paths are relative to `hermes-agent/` (the read-only
reference). This lets a coder jump straight to the right code instead of reference). This lets a coder jump straight to the right code instead of
re-deriving the architecture. re-deriving the architecture.
> ⚠️ Read-only. We **install** our plugin into `~/.hermes/plugins/android`; we > ⚠️ Read-only. We **install** our plugin into `~/.hermes/plugins/iris`; we
> never edit these files. > never edit these files.
## Plugin / platform registration ## Plugin / platform registration
+11 -9
View File
@@ -3,24 +3,24 @@
## Locked decisions (from planning, 2026-08-19) ## Locked decisions (from planning, 2026-08-19)
| # | Decision | Choice | Rationale | | # | Decision | Choice | Rationale |
|---|---|---|---| | --- | --- | --- | --- |
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Android app, tweaked"; share protocol/state/UI. | | 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Iris app, tweaked"; share protocol/state/UI. |
| 2 | Push backend | **Both — FCM primary, ntfy fallback** | FCM is standard/reliable; ntfy for self-hosters with no Firebase. `ANDROID_PUSH_BACKEND`. | | 2 | Push backend | **Both — ntfy default, FCM optional** (issue #10: FCM metadata — title, device token — is routed via Google's servers, contradicting the privacy claim; ntfy keeps it on your own infrastructure) | `IRIS_PUSH_BACKEND`. |
| 3 | Media transport | **Over the WebSocket** | One transport, zero new Python deps; chunked binary frames. | | 3 | Media transport | **Over the WebSocket** | One transport, zero new Python deps; chunked binary frames. |
| 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. | | 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. |
## Additional decisions made during planning ## Additional decisions made during planning
| Decision | Choice | Note | | Decision | Choice | Note |
|---|---|---| | --- | --- | --- |
| Connection point | **Messaging gateway** (platform plugin `android`), not `tui_gateway` | Makes cron/`send_message`/slash/coexistence native. | | Connection point | **Messaging gateway** (platform plugin `iris`), not `tui_gateway` | Makes cron/`send_message`/slash/coexistence native. |
| Plugin style | **Community plugin** (`register(ctx)`) | Zero hermes-core changes. | | Plugin style | **Community plugin** (`register(ctx)`) | Zero hermes-core changes. |
| Python deps | **None new** (`websockets` + `httpx` are core) | Respects hermes pinning policy. | | Python deps | **None new** (`websockets` + `httpx` are core) | Respects hermes pinning policy. |
| Tool events | **Structured frames; app controls verbosity** | Gateway sends full data; app = everything/truncated/nothing. | | Tool events | **Structured frames; app controls verbosity** | Gateway sends full data; app = everything/truncated/nothing. |
| Reasoning | **Adapter splits the `show_reasoning` prefix** | Clean `reasoning` field → collapsible block above message. | | Reasoning | **Adapter splits the `show_reasoning` prefix** | Clean `reasoning` field → collapsible block above message. |
| Channels/threads | **Map onto `chat_id`/`thread_id`** | Existing gateway primitives; cron targets them. | | Channels/threads | **Map onto `chat_id`/`thread_id`** | Existing gateway primitives; cron targets them. |
| Offline | **SQLite outbox + sync cursor** | Catch-up on reconnect; push on disconnect. | | Offline | **SQLite outbox + sync cursor** | Catch-up on reconnect; push on disconnect. |
| Default chat id | `android:default` | Home channel + cron default. | | Default chat id | `default` | Home channel + cron default. |
| WS port | `8790` (default) | Configurable. | | WS port | `8790` (default) | Configurable. |
| minSdk | 26 (test device API 29) | Broad coverage. | | minSdk | 26 (test device API 29) | Broad coverage. |
| Frame routing | **Broadcast to all connected devices** (no per-chat subscribe) | Single-user model; simpler. | | Frame routing | **Broadcast to all connected devices** (no per-chat subscribe) | Single-user model; simpler. |
@@ -29,6 +29,8 @@
| Agent lifecycle | **`agent.busy`/`agent.idle`** events + **`agent.stop`/`agent.steer`** requests | App shows thinking indicator; user can abort or steer mid-turn. | | Agent lifecycle | **`agent.busy`/`agent.idle`** events + **`agent.stop`/`agent.steer`** requests | App shows thinking indicator; user can abort or steer mid-turn. |
| Local DB (KMP) | **SQLDelight** (not Room) | Room is Android-only; SQLDelight works in commonMain for both platforms. | | Local DB (KMP) | **SQLDelight** (not Room) | Room is Android-only; SQLDelight works in commonMain for both platforms. |
| Voice input | **Record → upload as audio media** (no client-side STT) | Agent's STT (if configured) handles transcription. | | Voice input | **Record → upload as audio media** (no client-side STT) | Agent's STT (if configured) handles transcription. |
| QR encoder | **Pure-stdlib** (`gateway-plugin/qr.py`) | No `qrcode`/`segno`/Pillow; byte mode, EC M→L, v1–10, ISO penalty masking. Keeps the plugin's zero-new-dep rule. |
| QR scanner | **ML Kit barcode** (not zxing-android-embedded) | On-device, no Google Play services; better accuracy/latency, Google-maintained. Android-only (desktop has no camera). |
## Open questions (resolve during implementation) ## Open questions (resolve during implementation)
@@ -42,10 +44,10 @@ will proceed with unless you say otherwise.
live gateway. If a clean metadata marker exists, prefer it. live gateway. If a clean metadata marker exists, prefer it.
2. **Reasoning prefix format stability (M2).** We split on the `code`-style 2. **Reasoning prefix format stability (M2).** We split on the `code`-style
`💭 **Reasoning:**\n```\n…\n```\n\n` prefix. *Default:* set `💭 **Reasoning:**\n```\n…\n```\n\n` prefix. *Default:* set
`reasoning_style: code` for android and split on that; fallback = no `reasoning_style: code` for iris and split on that; fallback = no
reasoning field (full text) if the prefix isn't found. Verify in M2. reasoning field (full text) if the prefix isn't found. Verify in M2.
3. **Per-device tokens vs shared token (M1/M5).** *Default (v1):* shared 3. **Per-device tokens vs shared token (M1/M5).** *Default (v1):* shared
`ANDROID_TOKEN` + optional `ANDROID_ALLOWED_USERS` device allowlist. `IRIS_TOKEN` + optional `IRIS_ALLOWED_USERS` device allowlist.
Per-device revocable tokens are a stretch. Per-device revocable tokens are a stretch.
4. **Desktop video backend (M6).** *Default:* `libmpv`/`mpv`-backed Compose 4. **Desktop video backend (M6).** *Default:* `libmpv`/`mpv`-backed Compose
surface, WebView fallback. Confirm `mpv` availability on target OSes during surface, WebView fallback. Confirm `mpv` availability on target OSes during
@@ -54,7 +56,7 @@ will proceed with unless you say otherwise.
recommended remote path; WSS + reverse proxy as alternatives. No public bind recommended remote path; WSS + reverse proxy as alternatives. No public bind
by default. by default.
6. **Streaming cadence (M2).** If live updates look chunky, tune 6. **Streaming cadence (M2).** If live updates look chunky, tune
`display.platforms.android.streaming` / consumer edit interval. *Default:* `display.platforms.iris.streaming` / consumer edit interval. *Default:*
follow global streaming config. follow global streaming config.
7. **App package name / branding.** *Default:* applicationId `dev.iris.app`, 7. **App package name / branding.** *Default:* applicationId `dev.iris.app`,
app name "Iris". Confirm final product name + package + icon. app name "Iris". Confirm final product name + package + icon.
+1 -1
View File
@@ -153,7 +153,7 @@ Config + setup per provider — `web_server.py` `/api/memory/providers/*`.
`app/shared/.../protocol/Protocol.kt` → `docs/protocol/frames.schema.json`). `app/shared/.../protocol/Protocol.kt` → `docs/protocol/frames.schema.json`).
2. **Security is the real gate.** The single-user model in 2. **Security is the real gate.** The single-user model in
`09-pairing-security.md` still holds, but control frames widen the blast `09-pairing-security.md` still holds, but control frames widen the blast
radius of a leaked `ANDROID_TOKEN`. Sensitive operations (env/secrets, radius of a leaked `IRIS_TOKEN`. Sensitive operations (env/secrets,
config writes, gateway restart, profile deletion) should get either an config writes, gateway restart, profile deletion) should get either an
in-app confirmation step or a capability flag negotiated at pairing. in-app confirmation step or a capability flag negotiated at pairing.
3. **Read-heavy first.** Most of the value is in list/view frames (cheap, 3. **Read-heavy first.** Most of the value is in list/view frames (cheap,
+5 -5
View File
@@ -1,7 +1,7 @@
# 18 — Code Review & Lint/LSP Cleanup (alpha → stable) # 18 — Code Review & Lint/LSP Cleanup (alpha → stable)
Comprehensive review of all three components — **gateway plugin**, **Android Comprehensive review of all three components — **gateway plugin**, **Iris app
app**, and **Desktop app** — performed to take the project from alpha to a (Android)**, and **Desktop app** — performed to take the project from alpha to a
clean, stable baseline. Each section records what was found, what was fixed, clean, stable baseline. Each section records what was found, what was fixed,
how it was verified, and what was deliberately left (with rationale). how it was verified, and what was deliberately left (with rationale).
@@ -50,7 +50,7 @@ findings. Categories:
`hermes_cli.config`, but those live in `hermes_cli.cli_output`; it also imported `hermes_cli.config`, but those live in `hermes_cli.cli_output`; it also imported
a `print_code` that does not exist in hermes at all. The whole import block a `print_code` that does not exist in hermes at all. The whole import block
raised `ImportError`, which the surrounding `try/except` swallowed, so raised `ImportError`, which the surrounding `try/except` swallowed, so
`hermes gateway setup` for the android platform **always bailed out early** with `hermes gateway setup` for the iris platform **always bailed out early** with
"setup helpers unavailable" and never generated a token or prompted for "setup helpers unavailable" and never generated a token or prompted for
host/port. Fixed by importing the print helpers from `hermes_cli.cli_output`, host/port. Fixed by importing the print helpers from `hermes_cli.cli_output`,
the env helpers from `hermes_cli.config`, and dropping the non-existent the env helpers from `hermes_cli.config`, and dropping the non-existent
@@ -127,9 +127,9 @@ rule set (`E W F I UP B SIM PL RET C4`) and `line-length = 100`. Changes:
--- ---
## 18.2 Android app (`app/androidApp` + `app/shared`) ## 18.2 Iris app — Android (`app/androidApp` + `app/shared`)
The Android and Desktop apps share the `:shared` KMP module (`commonMain` + The Iris Android and Desktop apps share the `:shared` KMP module (`commonMain` +
`jvmMain`), so most Kotlin code is covered here and in 18.3. `jvmMain`), so most Kotlin code is covered here and in 18.3.
### 18.2.1 Findings (before) ### 18.2.1 Findings (before)
+10 -10
View File
@@ -71,7 +71,7 @@ acceptable alternative if preferred).
``` ```
┌──────────────────────── hermes gateway process ───────────────────────┐ ┌──────────────────────── hermes gateway process ───────────────────────┐
│ AndroidAdapter │ │ IrisAdapter │
│ │ frames (same protocol.Frame objects) │ │ │ frames (same protocol.Frame objects) │
│ ▼ │ │ ▼ │
│ _broadcast_or_log ──► outbox.append(cursor) ──► push (if no live) │ │ _broadcast_or_log ──► outbox.append(cursor) ──► push (if no live) │
@@ -105,7 +105,7 @@ over HTTP whenever the WS is down.
## 19.4 Gateway: `gateway-plugin/http_server.py` ## 19.4 Gateway: `gateway-plugin/http_server.py`
New module, started/stopped by `AndroidAdapter.connect()`/`disconnect()` next New module, started/stopped by `IrisAdapter.connect()`/`disconnect()` next
to the WS server. to the WS server.
- **Server:** `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`, - **Server:** `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`,
@@ -113,13 +113,13 @@ to the WS server.
scale). The handler thread never touches adapter state directly; it bridges scale). The handler thread never touches adapter state directly; it bridges
into the gateway's asyncio loop with into the gateway's asyncio loop with
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at `asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
start, same loop the WS server runs on). start).
- **Config:** `ANDROID_HTTP_PORT` (default **8791**), same bind host as the WS - **Config:** `IRIS_HTTP_PORT` (default **8791**), bind host `IRIS_HTTP_HOST`.
(`ANDROID_WS_HOST`). Optional TLS via `ANDROID_HTTP_CERT`/`ANDROID_HTTP_KEY` Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
(`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a (`ssl.SSLContext` on the server): plaintext on a trusted LAN by default,
trusted LAN by default, TLS for remote/Tailscale setups. TLS for remote/Tailscale setups.
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the - **Bind failure is NON-fatal:** log a warning, disable the HTTP leg, show it
HTTP leg, show it in the inspector. The plugin must keep working WS-only. in the inspector.
- Port-conflict lock: same flock pattern the WS uses (`host:port` key). - Port-conflict lock: same flock pattern the WS uses (`host:port` key).
### Endpoints ### Endpoints
@@ -151,7 +151,7 @@ Wire format (standard SSE, three fields):
``` ```
id: 1043 id: 1043
event: frame event: frame
data: {"v":1,"type":"message","chat_id":"android:default",...} data: {"v":1,"type":"message","chat_id":"default",...}
: hb ← comment heartbeat every 15 s (keeps proxies alive) : hb ← comment heartbeat every 15 s (keeps proxies alive)
``` ```
+322
View File
@@ -0,0 +1,322 @@
# 20 — QR Pairing (terminal QR + in-app scanner)
**Status: implemented (M8, 2026-08-22).**
Closes gap #12 in `09-pairing-security.md` ("in-app QR scanner") and implements
the QR branch of the §9.2 pairing flow, which the docs already promise but the
code never delivered: today `interactive_setup` prints the pairing URL as
plain text only, and the app has no `iris://pair` parser at all.
Two halves, independent and shippable separately:
- **A — Gateway:** `hermes gateway setup` renders a scannable QR in the
terminal encoding `iris://pair?host=…&port=…&token=…`. **Zero new Python
dependencies** (pure stdlib encoder).
- **B — App:** a "Scan QR" button on the Android Connect screen (CameraX +
ML Kit, on-device, no Play services) that pre-fills URL + token. Not added
to desktop. Plus an `iris://pair` deep link so *any* scanner (system camera
app, other phones) can route the QR into the app.
---
## 20.1 Current state (what exists today)
| Piece | State | Location |
| ------- | ------- | ---------- |
| QR payload format | ✅ implemented | `gateway-plugin/pairing.py` → `qr_payload(host, port, token, secure)` → `iris://pair?host=<lan-ip>&port=8791&secure=0&token=<64-hex>` |
| Terminal QR rendering | ❌ missing | `gateway-plugin/adapter.py` → `interactive_setup()` prints the URL text only |
| App `iris://pair` parser | ❌ missing | app has manual URL + token entry only (`ConnectScreen`) |
| In-app camera scan | ❌ missing | no camera deps anywhere in `app/` |
| `iris://` deep link | ⚠️ partial | manifest handles `iris://chat/<id>` only (`androidApp/.../AndroidManifest.xml`, `MainActivity.handleDeepLink`) |
| QR libs in hermes venv | ❌ absent | `qrcode`/`segno` not installed; `Pillow` is a hermes core dep but only renders images — the QR *matrix* algorithm is still needed either way |
Payload size: `iris://pair?host=192.168.x.x&port=8791&secure=0&token=<64 hex>`
≈ **118 bytes** → QR version **7 at EC level M** (capacity 122 bytes) or v6 at
L (134). The encoder must therefore support at least versions 1–8; we target
1–10.
---
## 20.2 Part A — terminal QR in `interactive_setup`
### A1. Pure-stdlib QR encoder — `gateway-plugin/qr.py` (new file)
A self-contained ISO/IEC 18004 encoder, **stdlib only** (no `qrcode`, no
`segno`, no Pillow). Scope is deliberately minimal — we only ever encode
ASCII pairing URLs:
- **Mode:** byte mode only (no alphanumeric/numeric/kanji paths).
- **Error correction:** level **M** (15 %); auto-fallback to **L** if the
payload doesn't fit at M within the version cap.
- **Versions:** 1–10, auto-selected (smallest version whose capacity fits).
Payloads that don't fit v10-L raise `QrTooLongError` (caller falls back to
text-only output — see A3).
- **Components** (all well-known, spec-stable algorithms):
1. Data encoding: mode indicator `0100`, 8-bit char count (8 bits for
v1–9, 16 bits for v10), payload bytes, terminator, padding
(`0xEC`/`0x11` alternation).
2. Reed–Solomon error correction over GF(256), generator polynomial
`0x11D`, per (version, EC level) block structure from the spec tables.
3. Matrix placement: finder patterns + separators, timing patterns,
alignment patterns (v2+), dark module, format info (BCH(15,5)),
version info (v7+, BCH(18,6)), zig-zag data placement.
4. Masking: all 8 masks, ISO penalty scoring (N1–N4), pick lowest.
- **Public API:**
```python
def qr_matrix(data: str) -> list[list[bool]]:
"""Encode *data* (ASCII) into a module matrix (True = dark).
Includes the 4-module quiet zone. Raises QrTooLongError."""
```
~250–350 lines including the spec tables. No I/O, no globals, fully
unit-testable.
### A2. Terminal renderer — `qr.py`
```python
def render_qr(data: str) -> str:
"""Render *data* as a terminal QR using Unicode half-blocks (▀).
Returns '' (not an exception) when the payload is too long."""
```
- Pair consecutive module rows into one character row: both dark → `█`,
top dark → `▀`, bottom dark → `▄`, both light → space. (Matrix height
including quiet zone is always even: `2·(17+4v)+8`.)
- Output is a single string of `\n`-joined lines; the caller prints it.
- No ANSI colors, no cursor tricks — must survive `less`, log files, and
copy-paste.
### A3. Integration — `adapter.py:interactive_setup()`
After the existing "Pairing URL / Server URL" lines:
```python
qr = render_qr(qr_payload(host, port, token))
if qr:
print_info("Scan with the Iris app (Connect → Scan QR) or any camera app:")
print(qr)
else:
print_warning("QR too large to render; use the pairing URL above.")
```
- The **URL text lines stay** — the QR is a convenience, not a replacement
(terminals without UTF-8 still work, and the text is copy-pasteable).
- Printed on every setup run (new *and* existing token), consistent with the
URL lines which already print the token in cleartext.
- **Security note:** no new exposure — the token is already printed in the
pairing URL line today; the QR is the same bytes in a different encoding,
on the same operator-only stdout. (Gap #5 in the §9.7 table already
documents the stdout token print.)
### A4. Tests — `hermes-agent/tests/gateway/test_android.py`
The test file is a thin mirror importing the **live `gateway-plugin/`
package**, so new tests land there:
1. **Fixed test vectors** (guard against silent algorithm drift): at least
two known-good (data → matrix) pairs from public QR test vectors
(e.g. the ISO 18004 annex examples / the classic `KARAT` v2-L vector).
Assert the full matrix, not just dimensions.
2. **Round-trip via payload:** `qr_matrix(qr_payload(h, p, t))` has the
expected version/size for a 64-hex token (`17 + 4·7 = 45` modules at
v7-M, +8 quiet zone).
3. **Renderer shape:** every line equal length, height = half of matrix
height, quiet zone renders as blank border, only the 4 block chars +
space appear.
4. **`QrTooLongError` / `render_qr` → `""`** for a payload beyond v10-L.
5. **`interactive_setup` smoke:** with sandboxed HERMES_HOME (conftest
already does this), capture stdout and assert the QR block appears after
the pairing URL line.
Run: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`.
---
## 20.3 Part B — in-app scanner (Android only)
### B1. Dependencies — `app/shared/build.gradle.kts`, `androidMain.dependencies` only
| Dependency | Why |
| ------------ | ----- |
| `androidx.camera:camera-camera2` | Camera access (CameraX) |
| `androidx.camera:camera-lifecycle` | Lifecycle-aware binding |
| `androidx.camera:camera-view` | `PreviewView` for the scan surface |
| `com.google.mlkit:barcode-scanning` | On-device QR decode; **no Google Play services required** (self-contained model) |
- Chosen over `zxing-android-embedded` (decided 2026-07-20): ML Kit has
better accuracy/latency, is maintained by Google, and works fully
on-device without GMS.
- These go in **`androidMain`** only — the no-new-dep rule covers
`gateway-plugin/`, and the app already carries OkHttp/SQLDelight/KCEF/etc.
Desktop is untouched.
- `minSdk 29` is fine for all four (ML Kit barcode needs 21+).
- Versions go in the existing version catalog / `composeVersion`-style
constants at the top of the build file (follow the current pattern).
### B2. Scanner activity — `shared/src/androidMain/kotlin/iris/platform/QrScanActivity.kt` (new)
A minimal `ComponentActivity` (not a Fragment, no nav graph):
- Layout: full-screen `PreviewView` + overlay hint text ("Point at the QR
code") + close button.
- `ImageAnalysis` (STRATEGY_LATEST, YUV_420_888) →
`BarcodeScannerOptions(FORMAT_QR_CODE)` → first result →
`setResult(RESULT_OK, Intent().putExtra("iris.qr.text", raw))` → finish.
- **Runtime permission:** request `CAMERA` on launch; on denial show a
message + close (the Connect screen still has manual entry).
- Registered in `shared/src/androidMain/AndroidManifest.xml` (or the
androidApp manifest — follow where `NtfyListenerService` is declared)
with `android:exported="false"`, `android:theme` reusing the app theme.
- Manifest additions (androidApp manifest):
```xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
```
`required="false"` so the app stays installable on camera-less devices
(the button then just reports "no camera").
### B3. Platform hook — `expect`/`actual`
`shared/src/commonMain/kotlin/iris/platform/PlatformQr.kt` (new):
```kotlin
/** Launch the QR scanner. [onResult] gets the decoded text, or null when
* the user cancelled / no camera / permission denied. Desktop: no-op. */
expect fun scanQrCode(onResult: (String?) -> Unit)
```
- **androidMain actual:** `ActivityResultLauncher` (from the Compose
`LocalContext`) starting `QrScanActivity`; maps `RESULT_OK` → text,
everything else → `null`.
- **desktopMain actual:** `onResult(null)` immediately (the button is
hidden on desktop anyway — see B4; the no-op keeps the `expect` total).
### B4. Connect screen button — `ConnectScreen.kt`
- New **"Scan QR"** `Button` below the token field, rendered only when
`!isDesktop` (`iris.platform.isDesktop` already exists).
- On tap: `scanQrCode { raw -> … }`; on non-null `raw`:
- `PairLink.parse(raw)` (B5) → pre-fill `url` and `token` state, clear
error, and **do not auto-connect** — the user still taps
"Test & Connect" (pairing stays an explicit act, per §10.8).
- Parse failure → set `error` to "Not a pairing QR code" (don't echo the
raw payload — it may contain someone else's token).
- On `null` (cancel/denied): no-op, no error.
### B5. Pair-link parser — `shared/src/commonMain/kotlin/iris/util/PairLink.kt` (new)
```kotlin
data class PairLink(val url: String, val token: String)
object PairLink {
/** Parse `iris://pair?host=…&port=…&secure=…&token=…` → PairLink.
* Returns null on any malformation. */
fun parse(raw: String): PairLink?
}
```
- Accepts exactly scheme `iris`, host `pair` (case-insensitive scheme).
- Required: `host` (non-empty), `token` (non-empty). `port` defaults to
`8791` (the HTTP default, `docs/19`); `secure` defaults to `0`.
- Builds `url` as `http(s)://<host>:<port>`; validates port 1–65535.
- URL-decodes `host`/`token` (the Python side `quote()`s them).
- Pure function, no platform imports → **unit-tested in `jvmTest`**
(`:shared:testAndroidHostTest` / `:shared:desktopTest` both run it):
valid link, missing token, bad port, wrong scheme, wrong host,
percent-encoded host, secure=1 → https, default port.
### B6. `iris://pair` deep link (system-scanner fallback)
So a QR scanned by *any* app (phone's built-in scanner, a friend's phone)
lands in Iris:
- Manifest: extend the existing `VIEW` intent-filter block (or add a
sibling) with `<data android:scheme="iris" android:host="pair" />`.
- `MainActivity.handleDeepLink`: on `iris://pair` → `PairLink.parse(uri)` →
stash into a `mutableStateOf<PairLink?>` passed into `IrisApp` →
`ConnectScreen` receives it as `prefillUrl`/`prefillToken` (the params
already exist). If the app is already connected, ignore (or surface in
Settings later — out of scope).
- This reuses B5's parser; add one test for the URI shape Android delivers.
---
## 20.4 Part C — doc updates (with the implementation)
| Doc | Change |
| ----- | -------- |
| `09-pairing-security.md` | Gap #12 → **implemented** (fix the stale `adapter.py:648-654` reference while at it); §9.2 QR branch no longer aspirational |
| `10-android-app.md` §10.8 | "or scan QR" becomes real: scanner button + deep link, camera permission |
| `14-milestones.md` | New **M8 — QR pairing** section (acceptance criteria below) |
| `16-open-questions.md` | Record decision: ML Kit over zxing; pure-stdlib encoder over vendoring `segno` |
| `README.md` | Reading-order table: add row 20 |
---
## 20.5 Work breakdown & sequencing
Ordered so each step is independently verifiable; A and B can be
interleaved (different languages, no shared surface).
| # | Task | Verify |
| --- | ------ | -------- |
| 1 | `qr.py` encoder + renderer (A1/A2) | new unit tests green (A4.1–4.4) |
| 2 | `interactive_setup` integration (A3) | A4.5 + manual: `hermes gateway setup` in a real terminal shows a scannable QR (scan with the phone's *system* camera app as the decoder oracle) |
| 3 | `PairLink` parser + jvmTest (B5) | `./gradlew :shared:testAndroidHostTest` |
| 4 | Deps + manifest + `QrScanActivity` (B1/B2) | `:androidApp:assembleDebug` |
| 5 | `PlatformQr` expect/actual + Connect button (B3/B4) | `:androidApp:assembleDebug` + `:desktopApp:run` (button absent, no crash) |
| 6 | `iris://pair` deep link (B6) | ADB: `adb shell am start -a android.intent.action.VIEW -d "iris://pair?host=…&port=…&token=…"` → Connect screen pre-filled |
| 7 | Doc updates (Part C) | — |
**On-device E2E (final gate, per `13-testing.md` ADB workflow):**
1. `hermes gateway setup` on the gateway host → QR in terminal.
2. Phone: `adb shell am start -n dev.iris.app/.MainActivity` → Connect →
**Scan QR** → grant camera → point at the terminal (screenshot the QR
onto a second screen if needed; the reference device is API 29 —
verify CameraX works on the MIX 2S in step 4 before building the rest).
3. Fields pre-filled → **Test & Connect** → chat screen.
4. Repeat via deep link (step 6 command) with a *different* token.
5. Negative: scan a non-pairing QR (e.g. a website) → "Not a pairing QR
code", fields untouched.
---
## 20.6 Acceptance criteria (M8)
- [ ] `hermes gateway setup` prints a QR that a stock Android camera app
decodes to exactly `qr_payload(host, port, token)`.
- [ ] QR encoder: fixed test vectors + size/round-trip tests green;
**zero** new entries in the plugin's import surface (stdlib only —
verifiable by `ruff`/import scan).
- [ ] Android: Connect screen shows **Scan QR** (hidden on desktop);
scanning the setup QR pre-fills URL + token; "Test & Connect" pairs.
- [ ] Camera permission denied → graceful message, manual entry still works.
- [ ] `iris://pair` deep link pre-fills the Connect screen (ADB-verified).
- [ ] `PairLink.parse` unit tests cover the matrix in B5.
- [ ] Full Python suite green: `scripts/run_tests.sh` (no args).
- [ ] Docs updated per Part C; gap #12 closed.
## 20.7 Risks & mitigations
| Risk | Mitigation |
| ------ | ------------ |
| Hand-rolled QR encoder has a subtle bug | Fixed spec test vectors (A4.1) + the system-camera-app oracle in the E2E gate; scope locked to byte mode / v1–10 so the surface stays small |
| Terminal without UTF-8 mangles the QR | URL text lines remain the primary path; QR is additive |
| CameraX quirks on API 29 (MIX 2S) | Build the scanner activity first (task 4) and verify on-device before wiring the UI |
| ML Kit model size (~4 MB) | Bundled in the APK, on-device, no runtime download — acceptable for this app's footprint |
| Token in QR scanned by a bystander's phone | Same trust domain as the token already printed in the terminal; LAN pairing is operator-supervised by design (§9.2). Deep link only pre-fills — it never auto-connects |
| `secure=1` (WSS) URLs | Parser already handles `secure` → `https://`; QR payload unchanged |
## 20.8 Explicit non-goals
- **QR display in the app** (showing a QR for other devices to scan) —
single-device pairing today; revisit if multi-device lands.
- **`hermes iris pair` stretch CLI** (re-issue token + new QR,
`09-pairing-security.md` §9.2 line 48) — separate backlog item.
- **WSS cert pinning** (gap #6) — orthogonal; QR carries `secure=1`
already, pinning is app-side.
- **iOS scanner** — no iOS target (per `00-overview.md`).
+12 -5
View File
@@ -18,6 +18,11 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
--- ---
## User-facing guides
- [`install.md`](install.md) — **install the gateway + connect the app** (non-technical walkthrough, all options, TLS, push).
- [`setup.md`](setup.md) — moved; pointer to `install.md`.
## Reading order ## Reading order
| # | File | When to read | | # | File | When to read |
@@ -30,9 +35,9 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
| 5 | [`05-streaming.md`](05-streaming.md) | Streaming / reasoning / tools / intermediate. | | 5 | [`05-streaming.md`](05-streaming.md) | Streaming / reasoning / tools / intermediate. |
| 6 | [`06-channels-cron-search.md`](06-channels-cron-search.md) | Channels, threads, cron delivery, search. | | 6 | [`06-channels-cron-search.md`](06-channels-cron-search.md) | Channels, threads, cron delivery, search. |
| 7 | [`07-media.md`](07-media.md) | Media upload/download + playback. | | 7 | [`07-media.md`](07-media.md) | Media upload/download + playback. |
| 8 | [`08-push.md`](08-push.md) | Push (FCM + ntfy), outbox, sync. | | 8 | [`08-push.md`](08-push.md) | Push (ntfy default + FCM optional), outbox, sync. |
| 9 | [`09-pairing-security.md`](09-pairing-security.md) | Pairing, auth, security model. | | 9 | [`09-pairing-security.md`](09-pairing-security.md) | Pairing, auth, security model. |
| 10 | [`10-android-app.md`](10-android-app.md) | When building the Android app. | | 10 | [`10-android-app.md`](10-android-app.md) | When building the Iris app (Android). |
| 11 | [`11-desktop-app.md`](11-desktop-app.md) | When building the Desktop app. | | 11 | [`11-desktop-app.md`](11-desktop-app.md) | When building the Desktop app. |
| 12 | [`12-toolchain.md`](12-toolchain.md) | First time on a machine (JDK/SDK/uv/Firebase). | | 12 | [`12-toolchain.md`](12-toolchain.md) | First time on a machine (JDK/SDK/uv/Firebase). |
| 13 | [`13-testing.md`](13-testing.md) | Writing tests + on-device ADB workflow. | | 13 | [`13-testing.md`](13-testing.md) | Writing tests + on-device ADB workflow. |
@@ -41,25 +46,27 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
| 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. | | 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. |
| 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). | | 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). |
| 19 | [`19-http-fallback-transport.md`](19-http-fallback-transport.md) | **Design** — HTTP fallback leg (POST + SSE/long-poll) so the app can send/receive when the WS is down. | | 19 | [`19-http-fallback-transport.md`](19-http-fallback-transport.md) | **Design** — HTTP fallback leg (POST + SSE/long-poll) so the app can send/receive when the WS is down. |
| 20 | [`20-qr-pairing.md`](20-qr-pairing.md) | Terminal QR at `gateway setup` + in-app QR scanner (Android) + `iris://pair` deep link. |
Machine-readable / diagrams: Machine-readable / diagrams:
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema. - [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
- [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture. - [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture.
- [`playstore-listing.md`](playstore-listing.md) — Play Store listing text (incl. the FCM/ntfy privacy note).
--- ---
## The three deliverables (one monorepo) ## The three deliverables (one monorepo)
1. **`gateway-plugin/`** — a Python hermes **platform plugin** named `android`. 1. **`gateway-plugin/`** — a Python hermes **platform plugin** named `iris`.
Runs inside the `hermes gateway` process. Opens a WebSocket server the apps Runs inside the `hermes gateway` process. Opens a WebSocket server the apps
connect to. Implements the full `BasePlatformAdapter` contract. **Zero new connect to. Implements the full `BasePlatformAdapter` contract. **Zero new
Python dependencies, zero hermes-core changes.** Python dependencies, zero hermes-core changes.**
2. **`app/androidApp`** — native Kotlin + Jetpack Compose client. 2. **`app/androidApp`** — native Kotlin + Jetpack Compose client.
3. **`app/desktopApp`** — Kotlin + Compose Multiplatform client that *shares* 3. **`app/desktopApp`** — Kotlin + Compose Multiplatform client that *shares*
the Android app's code and is "tweaked" for a big screen. the Iris app's code and is "tweaked" for a big screen.
The Android and Desktop clients live in **one Compose Multiplatform Gradle The Iris Android and Desktop clients live in **one Compose Multiplatform Gradle
project** (`app/`) with a shared KMP module (`app/shared`). project** (`app/`) with a shared KMP module (`app/shared`).
--- ---
+4 -4
View File
@@ -6,8 +6,8 @@ flowchart TB
AGENT["Agent core<br/>(run_agent.py)"] AGENT["Agent core<br/>(run_agent.py)"]
SESS["Sessions<br/>(SQLite + FTS5)"] SESS["Sessions<br/>(SQLite + FTS5)"]
CRON["Cron scheduler"] CRON["Cron scheduler"]
subgraph PLUGIN["android PLATFORM PLUGIN"] subgraph PLUGIN["IRIS PLATFORM PLUGIN"]
ADAPTER["AndroidAdapter<br/>(BasePlatformAdapter)"] ADAPTER["IrisAdapter<br/>(BasePlatformAdapter)"]
OUTBOX["Outbox (SQLite)<br/>+ sync cursor"] OUTBOX["Outbox (SQLite)<br/>+ sync cursor"]
PUSH["push.py<br/>FcmBackend / NtfyBackend"] PUSH["push.py<br/>FcmBackend / NtfyBackend"]
MEDIA["media.py<br/>cache + chunk stream"] MEDIA["media.py<br/>cache + chunk stream"]
@@ -17,7 +17,7 @@ flowchart TB
WSS["WebSocket SERVER<br/>(websockets) ws://host:8790/ws"] WSS["WebSocket SERVER<br/>(websockets) ws://host:8790/ws"]
end end
AGENT -->|legacy stream callbacks| ADAPTER AGENT -->|legacy stream callbacks| ADAPTER
CRON -->|deliver=android:chat:thread| ADAPTER CRON -->|deliver=iris:chat:thread| ADAPTER
ADAPTER <--> WSS ADAPTER <--> WSS
ADAPTER <--> OUTBOX ADAPTER <--> OUTBOX
ADAPTER <--> PUSH ADAPTER <--> PUSH
@@ -33,7 +33,7 @@ flowchart TB
end end
subgraph DEVICES["Clients"] subgraph DEVICES["Clients"]
PHONE["ANDROID APP<br/>(Kotlin / Compose)<br/>WS client + ExoPlayer + FCM"] PHONE["IRIS APP (ANDROID)<br/>(Kotlin / Compose)<br/>WS client + ExoPlayer + FCM"]
DESKTOP["DESKTOP APP<br/>(Compose Multiplatform)<br/>WS client + tray + desktop player"] DESKTOP["DESKTOP APP<br/>(Compose Multiplatform)<br/>WS client + tray + desktop player"]
end end
+252
View File
@@ -0,0 +1,252 @@
# Install — Gateway & App
A step-by-step guide for getting **Iris** (the Android / Desktop app) talking
to your **hermes gateway**. Written for people who just want to *use* it, not
build it. If you only want the short version, the [README](../README.md) has
the three commands that matter.
The whole setup has two halves:
1. **The gateway** — a small plugin that runs *inside* your existing hermes
install and opens a door for the app to connect through.
2. **The app** — on your phone or desktop, where you enter the gateway's
address and a pairing token.
> **Old guides?** Earlier versions of Iris used a WebSocket on port `8790`
> (`ws://…/ws`). The transport is now plain HTTP on port **`8791`**
> (see [`19-http-fallback-transport.md`](19-http-fallback-transport.md)).
> The app still accepts old `ws://` URLs and converts them automatically, but
> new setups should use the `http://` URL printed by `hermes gateway setup`.
---
## What you need
| Where | What |
| --- | --- |
| Gateway host (any always-on computer: home server, Raspberry Pi, laptop) | [hermes-agent](https://github.com/NousResearch/hermes-agent) installed with its venv (`cd hermes-agent && uv sync`) |
| Phone / desktop | Android 8+ or Linux / macOS / Windows |
| Only if you build the app yourself | JDK 17 (+ Android SDK for Android) — see [`12-toolchain.md`](12-toolchain.md) |
---
## Part 1 — Install the gateway plugin (one-time)
Iris is a regular hermes **platform plugin**, so it installs with the normal
plugin command. This repo is a *monorepo* (the plugin lives in the
`gateway-plugin/` subfolder, next to the app), so you point the installer at
that subfolder with a `#subfolder` suffix:
```bash
hermes plugins install git@gitea.zephyre.one:ARIA/iris_x_hermes.git#gateway-plugin
```
That's it. The installer clones the repo, copies just the `gateway-plugin/`
folder into `~/.hermes/plugins/`, and asks whether to enable it now (say
**yes**).
Notes:
- Any git URL works with the `#gateway-plugin` suffix — e.g.
`https://gitea.zephyre.one/ARIA/iris_x_hermes.git#gateway-plugin` if you
prefer HTTPS.
- **Developing from a checkout?** Skip the install and symlink instead — the
plugin then always tracks your working tree:
```bash
mkdir -p ~/.hermes/plugins
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
```
- Check it was picked up:
```bash
hermes gateway status # the Iris platform should be listed
```
## Part 2 — Gateway setup (one-time)
Run the interactive setup:
```bash
hermes gateway setup
```
It walks you through five things:
| Prompt | What it means | Default |
| --- | --- | --- |
| **Iris pairing token** | A long random secret the app must present to connect. Generated for you; stored in `~/.hermes/.env` as `IRIS_TOKEN`. **It is printed only once** — write it down. | auto-generated |
| **HTTP host** | Which network address the gateway listens on. `127.0.0.1` = only this machine. For a phone on your home network, use the machine's **LAN IP** (e.g. `192.168.1.10`). | `127.0.0.1` |
| **Port** | The port the app connects to. | `8791` |
| **Push backend** | How offline notifications are delivered: `ntfy` (default, stays on your own infrastructure) or `fcm` (Google). See [Part 5](#part-5--push-notifications-optional). | `ntfy` |
| **TLS** | Only asked when no certificate is configured yet: generates a **self-signed** certificate + key under `~/.hermes/iris/` and stores the paths in `.env` (`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`), so the gateway serves `https://`. The app asks you to confirm the printed SHA-256 fingerprint once (like an SSH host key). | No (Yes if you bound `0.0.0.0`) |
When it finishes it prints two things you need for the app:
- **Server URL** — e.g. `http://192.168.1.10:8791`
- **Pairing QR + URL** — an `iris://pair?…` string with a scannable QR code
Then start the gateway:
```bash
hermes gateway # (or: hermes gateway restart after changes)
```
## Part 3 — Install the app
### Android
Build a debug APK on any machine with JDK 17 + the Android SDK:
```bash
cd app
./gradlew :androidApp:assembleDebug
# → app/androidApp/build/outputs/apk/debug/androidApp-debug.apk
```
Copy the APK to the phone (cable, LAN share, or any file transfer) and open
it — Android will ask to allow installs from unknown sources.
*Shortcut for developers with a USB-connected phone:*
`./gradlew :androidApp:installDebug` installs it directly.
### Desktop
```bash
cd app
./gradlew :desktopApp:jpackage # → app/desktopApp/build/…/ (native app, JRE bundled)
```
On Linux the launcher may print a `pure virtual method called` warning —
it's a known, harmless jpackage bug (JDK-8348560); the app works fine.
## Part 4 — Connect the app
Open the app. The first screen is **Connect**. You need the **Server URL**
and the **pairing token** from Part 2.
### Option A — Same home network (no encryption, simplest)
Works out of the box on a trusted home network:
1. **Server URL:** the one printed by `hermes gateway setup`,
e.g. `http://192.168.1.10:8791`.
- On a phone, use the gateway's **LAN IP** — not `127.0.0.1` (that only
means "this device" and won't reach your server).
- If you set the host to `127.0.0.1` during setup, re-run
`hermes gateway setup` and enter the LAN IP instead.
2. **Pairing token:** the long token from the setup output (or
`grep IRIS_TOKEN ~/.hermes/.env` on the gateway host).
3. **Test & Connect.**
> **Android shortcut:** the Connect screen has a **Scan QR** button — point
> the camera at the QR printed by `hermes gateway setup` and the URL + token
> fill themselves in. (Desktop has no camera, so it's manual entry.)
### Option B — Encrypted (TLS) — recommended for anything beyond your LAN
Plain `http://` is fine on a home network you trust, but for remote access
you want the traffic encrypted. The gateway can serve `https://` itself:
1. Create a certificate + key. Three flavors:
- **Generated by setup (easiest)**: `hermes gateway setup` offers to
generate a **self-signed** certificate for you (see the TLS prompt in
[Part 2](#part-2--gateway-setup-one-time)). It writes
`~/.hermes/iris/iris.crt` + `iris.key`, stores the paths in `.env`, and
prints the SHA-256 fingerprint the app will ask you to confirm.
- **CA-signed** (Let's Encrypt, or your own CA): works out of the box.
- **Self-signed manually** (e.g. `openssl req -x509 -newkey rsa:2048
-nodes -keyout iris.key -out iris.crt -days 3650 -subj "/CN=iris"
-addext "subjectAltName=DNS:iris.example.com,IP:192.168.1.10"`):
the certificate **must** carry a SAN entry matching the host you'll
type in the app.
2. Put the paths in `~/.hermes/.env` on the gateway host:
```ini
IRIS_HTTP_CERT=/path/to/iris.crt
IRIS_HTTP_KEY=/path/to/iris.key
```
3. `hermes gateway restart`.
4. In the app, use the **`https://`** URL, e.g. `https://iris.example.com:8791`.
**Self-signed certificates:** the app won't trust them automatically (by
design). On first connect it shows the certificate's SHA-256 fingerprint and
asks you to confirm it — exactly like an SSH host key. Compare the
fingerprint with the one on the gateway host
(`openssl x509 -fingerprint -sha256 -noout -in iris.crt`), confirm, and it's
pinned in the app's secure storage from then on. If the certificate ever
changes, you'll be asked to confirm again. No system trust-store installs
needed.
### Reaching the gateway from outside your home network
Pick one (in order of preference):
- **Tailscale / WireGuard (recommended).** Install Tailscale on the gateway
host; the app connects to the stable tailnet IP, e.g.
`http://100.x.y.z:8791`. No public exposure at all — and since the traffic
travels inside the encrypted mesh, plain `http://` is acceptable here.
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok). Terminate TLS
at the edge and forward to `127.0.0.1:8791` on the gateway host.
- **Public bind + TLS + strong token** (`IRIS_HTTP_HOST=0.0.0.0` + Option B).
Last resort — the port is then reachable from the internet; the token and
TLS are what protect it.
## Part 5 — Push notifications (optional)
Push wakes a backgrounded or offline phone so you see replies even when the
app is closed. Nothing is lost either way — on reconnect the app syncs its
outbox.
- **ntfy (default)** — the phone generates its own topic automatically; the
gateway publishes to it. Set `NTFY_SERVER_URL` to a **self-hosted ntfy**
for reliability (the public `ntfy.sh` SSE endpoint is flaky). Push metadata
stays on your own infrastructure — this is the private option.
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard and reliable, but push
metadata (notification title, device token) is routed through **Google's
servers**. Needs a Firebase project + `google-services.json` in the app
build. Without it, FCM is inert and ntfy is the path.
Details: [`08-push.md`](08-push.md).
---
## Gateway options (reference)
Everything is configured in `~/.hermes/.env` on the gateway host (or via the
prompts of `hermes gateway setup`). After changes: `hermes gateway restart`.
| Variable | What it does | Default |
| --- | --- | --- |
| `IRIS_TOKEN` | Pairing token the app must present. | — (required) |
| `IRIS_HTTP_HOST` | Bind address. `127.0.0.1` = local only; LAN IP = same network; `0.0.0.0` = all interfaces. | `127.0.0.1` |
| `IRIS_HTTP_PORT` | Port the app connects to. | `8791` |
| `IRIS_HOME_CHANNEL` | Default chat for cron/notification delivery. | `default` |
| `IRIS_ALLOWED_USERS` | Comma-separated device ids allowed to connect (empty = token-only auth). | empty |
| `IRIS_ALLOW_ALL_USERS` | Allow any paired device (**dev only**). | `false` |
| `IRIS_PUSH_BACKEND` | `ntfy` or `fcm`. | `ntfy` |
| `IRIS_FCM_SERVICE_ACCOUNT` | Path to Firebase service-account JSON (FCM). | — |
| `IRIS_FCM_SERVER_KEY` | Legacy FCM server key (fallback). | — |
| `NTFY_SERVER_URL` | ntfy server. Self-hosting recommended. | `https://ntfy.sh` |
| `NTFY_AUTH_TOKEN` | Auth token for a private ntfy topic (real trust boundary). | — |
| `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY` | TLS cert/key paths → serves `https://` (see Part 4, Option B). | — |
Security model (tokens, device allowlist, transport):
[`09-pairing-security.md`](09-pairing-security.md).
---
## Troubleshooting
| Symptom | Likely cause / fix |
| --- | --- |
| `auth failed` on connect | Wrong token. Check `IRIS_TOKEN` in `~/.hermes/.env` (setup prints it only when it generates it). |
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8791`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
| Phone can't reach the gateway | Gateway bound to `127.0.0.1` — re-run `hermes gateway setup` and set the LAN IP; or the phone is on a different network/VLAN. |
| TLS handshake fails | Cert has no SAN matching the URL host; or the pinned fingerprint is stale after a cert change (re-confirm in the app). |
| Push not arriving | Backend not configured (check `~/.hermes/logs/gateway.log`); ntfy.sh flakiness — self-host ntfy. |
| Start over on a phone | `adb shell pm clear dev.iris.app` wipes the app's pairing state. |
Logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
+41
View File
@@ -0,0 +1,41 @@
# Play Store Listing
Copy-paste text for the Play Console (and the F-Droid / sideload pages).
Keep this file in sync with the README whenever the privacy story changes.
## Short description (≤ 80 chars)
Private chat for your personal AI agent — Android & desktop.
## Full description
Iris is a native chat app for your personal AI agent (hermes-agent):
streaming replies, visible reasoning, structured tool activity, channels,
threads, media, search — Telegram-quality, on your own infrastructure.
**Absolute Privacy!** — everything stays on your own infrastructure:
- Your gateway, your machine, your data. No cloud middleman for chat.
- **Push notifications:** ntfy by default — push metadata stays on your own
(self-hosted) ntfy server.
- **FCM is opt-in** (`IRIS_PUSH_BACKEND=fcm`): standard and reliable, but FCM
push metadata (notification title, device token) is routed through
**Google's servers**. If you want truly private communication, use ntfy
(self-hosted) instead — it is the default.
100 MB file uploads by default (configurable on your gateway). No
4,096-character message limit like Telegram. Full markdown + HTML artifact
preview. All settings live in the app.
Runs on Android and desktop (Linux, macOS, Windows) from one shared codebase.
## Privacy note (for the "Data safety" section / FAQ)
Iris talks directly to your own hermes gateway over a private, token-authenticated
connection. By default, push notifications use ntfy, which you can self-host so
that push metadata never leaves your infrastructure. If you explicitly enable
FCM, push metadata (notification title, device token) is sent via Google's FCM
servers; chat content itself is not sent to Google — FCM only carries a short
preview, and full content is fetched from your gateway over the authenticated
connection. For truly private communication, use the default ntfy backend
(self-hosted).
+7 -5
View File
@@ -10,7 +10,7 @@
"v": { "type": "integer", "const": 1, "description": "Protocol version." }, "v": { "type": "integer", "const": 1, "description": "Protocol version." },
"id": { "type": "integer", "description": "Request id; present on requests and their responses/acks. Absent on pure events." }, "id": { "type": "integer", "description": "Request id; present on requests and their responses/acks. Absent on pure events." },
"type": { "type": "string", "description": "Frame type (see frame_types)." }, "type": { "type": "string", "description": "Frame type (see frame_types)." },
"chat_id": { "type": "string", "description": "Optional chat scope (e.g. android:default, android:chan_7)." }, "chat_id": { "type": "string", "description": "Optional chat scope (e.g. default, chan_7)." },
"thread_id": { "type": "string", "description": "Optional thread scope within a chat_id." }, "thread_id": { "type": "string", "description": "Optional thread scope within a chat_id." },
"cursor": { "type": "integer", "description": "Outbox cursor the frame was parked under. Present ONLY on frames replayed by sync (live frames carry none). The app skips re-notifying replayed frames with cursor <= last_pushed_cursor (docs/08 §8.7)." }, "cursor": { "type": "integer", "description": "Outbox cursor the frame was parked under. Present ONLY on frames replayed by sync (live frames carry none). The app skips re-notifying replayed frames with cursor <= last_pushed_cursor (docs/08 §8.7)." },
"payload": { "type": "object", "description": "Type-specific payload." } "payload": { "type": "object", "description": "Type-specific payload." }
@@ -24,6 +24,7 @@
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"} } }, "server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"} } },
"sync_cursor": { "type": "integer" }, "sync_cursor": { "type": "integer" },
"last_pushed_cursor": { "type": "integer", "description": "Highest outbox cursor already delivered to THIS device via the push backend (0 = never). The app skips system notifications for sync-replayed frames at/below it (dedupe, docs/08 §8.7)." }, "last_pushed_cursor": { "type": "integer", "description": "Highest outbox cursor already delivered to THIS device via the push backend (0 = never). The app skips system notifications for sync-replayed frames at/below it (dedupe, docs/08 §8.7)." },
"device_token": { "type": "string", "description": "Per-device token minted at pairing (docs/09 §9.3). The app stores it and presents it INSTEAD of the shared IRIS_TOKEN from then on; the gateway can revoke it per device. Empty when the gateway didn't issue one (legacy)." },
"channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } }
} }
}, },
@@ -47,9 +48,10 @@
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "runtime": { "$ref": "#/definitions/runtime" }, "ts": { "type": "integer" } } }, "message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "runtime": { "$ref": "#/definitions/runtime" }, "ts": { "type": "integer" } } },
"message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } }, "message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } },
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } }, "commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } }, "tool.start": { "description": "Cosmetic per-tool emoji (resolved server-side via hermes' get_tool_emoji: active-skin overrides, then the tool registry's per-tool emoji); omitted when the tool is unknown so the app falls back to its own default glyph.", "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" }, "emoji": { "type": "string" } } },
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } }, "tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
"tool.end": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "ok": { "type": "boolean" }, "duration": { "type": "number" }, "output_preview": { "type": "string" } } }, "tool.end": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "ok": { "type": "boolean" }, "duration": { "type": "number" }, "output_preview": { "type": "string" } } },
"todo.update": { "description": "The agent's FULL current todo list for a chat/thread lane (last-write-wins). Emitted whenever the `todo` tool completes (the tool result is authoritative even for merge writes, whose args carry only the changed items) and, as a snapshot, right after hello when a device opens its event stream. Ephemeral: never outboxed, so a reconnecting device learns the list from the snapshot, not a replay. The app renders it as a compact scrollable strip above the composer.", "payload": { "todos": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "content": { "type": "string" }, "status": { "type": "string", "enum": ["pending", "in_progress", "completed", "cancelled"] } } } } } },
"typing": { "payload": { "on": { "type": "boolean" } } }, "typing": { "payload": { "on": { "type": "boolean" } } },
"notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } }, "notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } },
"channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } }, "channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } },
@@ -66,7 +68,8 @@
"history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "runtime": {"$ref":"#/definitions/runtime"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } }, "history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "runtime": {"$ref":"#/definitions/runtime"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } },
"media.pull.end": { "payload": { "ok": { "type": "boolean" } } }, "media.pull.end": { "payload": { "ok": { "type": "boolean" } } },
"media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } }, "media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } },
"commands.catalog": { "description": "Response to a commands.catalog request: the gateway's slash-command catalog for the app's '/' drawer. Derived from hermes' COMMAND_REGISTRY (gateway-available subset) plus plugin-registered commands. The app fuzzy-matches the typed prefix client-side.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Canonical command with leading slash, e.g. \"/new\"." }, "description": { "type": "string" }, "args_hint": { "type": "string", "description": "Argument placeholder, e.g. \"[name]\"; empty when none." }, "category": { "type": "string", "description": "Registry category (Session, Configuration, Tools & Skills, Info, Exit, Plugin)." }, "aliases": { "type": "array", "items": { "type": "string" }, "description": "Alternative names with leading slash, e.g. [\"/reset\"] for /new." } } } } } } "commands.catalog": { "description": "Response to a commands.catalog request: the gateway's slash-command catalog for the app's '/' drawer. Derived from hermes' COMMAND_REGISTRY (gateway-available subset) plus plugin-registered commands. The app fuzzy-matches the typed prefix client-side.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Canonical command with leading slash, e.g. \"/new\"." }, "description": { "type": "string" }, "args_hint": { "type": "string", "description": "Argument placeholder, e.g. \"[name]\"; empty when none." }, "category": { "type": "string", "description": "Registry category (Session, Configuration, Tools & Skills, Info, Exit, Plugin)." }, "aliases": { "type": "array", "items": { "type": "string" }, "description": "Alternative names with leading slash, e.g. [\"/reset\"] for /new." } } } } } },
"picker.choice": { "description": "Interactive choice picker (one tap -> one value) for finite-choice slash commands (/reasoning, /fast, ...). The app renders the title + choice buttons and answers with picker.select carrying the same picker_id. Outboxed, so a reconnecting device re-renders a still-pending picker; pending state is in-memory only (a gateway restart expires it).", "payload": { "picker_id": { "type": "string" }, "title": { "type": "string" }, "choices": { "type": "array", "items": { "type": "object", "properties": { "value": { "type": "string" }, "label": { "type": "string" }, "is_current": { "type": "boolean" } } } } } }
}, },
"app_to_server": { "app_to_server": {
"hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } }, "hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
@@ -88,6 +91,7 @@
"history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } }, "history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } },
"message.delete": { "description": "Completely delete the given message(s) from a chat/thread. The server removes them from the outbox (so history/sync no longer return them) and from the hermes session store (so no search trace survives and they are not recoverable), then broadcasts message.deleted to every device. Idempotent: a message already gone (pruned) still yields a message.deleted broadcast.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" }, "description": "One or more message_id values to delete." } } }, "message.delete": { "description": "Completely delete the given message(s) from a chat/thread. The server removes them from the outbox (so history/sync no longer return them) and from the hermes session store (so no search trace survives and they are not recoverable), then broadcasts message.deleted to every device. Idempotent: a message already gone (pruned) still yields a message.deleted broadcast.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" }, "description": "One or more message_id values to delete." } } },
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } }, "fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
"picker.select": { "description": "Answer an interactive picker (picker.choice). The server runs the command's selection callback and delivers its reply as a normal message in the picker's chat. Unknown/expired picker ids are a no-op.", "payload": { "picker_id": { "type": "string" }, "value": { "type": "string" } } },
"ping": { "payload": { "ts": { "type": "integer" } } } "ping": { "payload": { "ts": { "type": "integer" } } }
} }
}, },
@@ -99,11 +103,9 @@
}, },
"x-planned-frames": [ "x-planned-frames": [
{ "name": "picker.model", "direction": "server_to_app", "note": "Model/provider picker prompt. Planned, not implemented." }, { "name": "picker.model", "direction": "server_to_app", "note": "Model/provider picker prompt. Planned, not implemented." },
{ "name": "picker.choice", "direction": "server_to_app", "note": "Generic choice picker prompt. Planned, not implemented." },
{ "name": "picker.clarify", "direction": "server_to_app", "note": "Clarify picker prompt. Planned, not implemented (clarifies arrive as notification + message)." }, { "name": "picker.clarify", "direction": "server_to_app", "note": "Clarify picker prompt. Planned, not implemented (clarifies arrive as notification + message)." },
{ "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." }, { "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." },
{ "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." }, { "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." },
{ "name": "picker.select", "direction": "app_to_server", "note": "Picker answer. Planned, not implemented." },
{ "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented (the app fuzzy-matches the commands.catalog list client-side)." }, { "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented (the app fuzzy-matches the commands.catalog list client-side)." },
{ "name": "commands.complete", "direction": "app_to_server", "note": "Slash-command autocomplete request. Planned, not implemented." }, { "name": "commands.complete", "direction": "app_to_server", "note": "Slash-command autocomplete request. Planned, not implemented." },
{ "name": "agent.busy", "direction": "server_to_app", "note": "Agent-busy indicator. Planned, not implemented (typing frames cover it)." }, { "name": "agent.busy", "direction": "server_to_app", "note": "Agent-busy indicator. Planned, not implemented (typing frames cover it)." },
+8 -168
View File
@@ -1,170 +1,10 @@
# Setup — Pairing a Device # Setup — Pairing a Device
User-facing guide: get a phone or desktop talking to your hermes gateway in > **Moved.** The user-facing setup guide now lives in
under 10 minutes. Design rationale lives in the numbered docs > [`install.md`](install.md) — gateway install, all options, app install,
([`09-pairing-security.md`](09-pairing-security.md), > and connecting (LAN / TLS / remote). This file is kept so old links keep
[`08-push.md`](08-push.md), [`12-toolchain.md`](12-toolchain.md)); this page is > working.
just the steps. >
> - Push details: [`08-push.md`](08-push.md)
## Prerequisites > - Security model: [`09-pairing-security.md`](09-pairing-security.md)
> - Toolchain (first-time machine setup): [`12-toolchain.md`](12-toolchain.md)
| Where | You need |
|---|---|
| Gateway host | hermes installed with its venv (`cd hermes-agent && uv sync`, see [`12-toolchain.md` §12.4](12-toolchain.md)) |
| Android build machine | JDK 17, Android SDK with `ANDROID_HOME` set (or `app/local.properties`), ADB with a connected device |
| Desktop build machine | JDK 17 only |
Gradle needs no system install — both apps use the project wrapper
(`./gradlew`). First-time machine setup: [`12-toolchain.md`](12-toolchain.md).
## 1. Gateway setup (on the gateway host)
Install the plugin into the live hermes home (dev: a symlink from the monorepo
root):
```bash
mkdir -p ~/.hermes/plugins
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android
hermes gateway status # should list "android"
```
Run the interactive setup:
```bash
hermes gateway setup
```
What it does:
- Generates `ANDROID_TOKEN` (64 hex chars) if none exists and stores it in
`~/.hermes/.env` (it prints the token once, at generation).
- Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`),
and push backend (`fcm` or `ntfy`, default `fcm`).
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…`
string) and the server URL (`ws://<host>:8790/ws`).
Then start the gateway:
```bash
hermes gateway # or: hermes gateway restart after config changes
```
> **Note:** the default bind host `127.0.0.1` only accepts connections from the
> gateway host itself (e.g. a desktop app on the same machine). For a phone on
> the LAN, re-run `hermes gateway setup` (or edit `~/.hermes/.env`) and set
> `ANDROID_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`).
## 2. Android app
Build and install (ADB device connected):
```bash
cd app
./gradlew :androidApp:installDebug
```
First run opens the **Connect** screen:
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (the URL printed by
`hermes gateway setup`; use the LAN IP, not `127.0.0.1`, from a phone).
2. **Pairing token** — from the `hermes gateway setup` output, or
`grep ANDROID_TOKEN ~/.hermes/.env` on the gateway host.
3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the
pairing and connects.
> **Honest limitation:** QR scanning is **not** supported in the app yet. The
> server prints a QR payload, but pairing is manual URL + token entry only.
## 3. Desktop app
```bash
cd app
./gradlew :desktopApp:run # dev run
./gradlew :desktopApp:jpackage # native app-image (bundles the JRE)
```
Pairing is the same Connect screen (URL + token); the token is stored in the OS
keyring (with an encrypted-file fallback). Desktop push is tray icon + OS
notifications (no FCM).
> **Known issue:** on Linux with JDK 17 the jpackage launcher prints a
> non-fatal `pure virtual method called` warning (JDK-8348560, a
> jpackage/Linux launcher bug). The app runs and connects regardless.
## 4. Push notifications
Push wakes a backgrounded/offline device; on reconnect the app syncs the
outbox, so nothing is lost. Push fires when the device is offline, plus for
high-priority events (approvals, clarifies, cron) even when a device is live.
### FCM (default; needs a Firebase project)
1. Create a Firebase project (console.firebase.google.com) and add an Android
app with the app's applicationId; download `google-services.json` into
`app/androidApp/`.
2. Create a service account (Project settings → Service accounts → Generate new
private key) and store the JSON path in `~/.hermes/.env`:
`ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
3. Keep `ANDROID_PUSH_BACKEND=fcm` (the default).
Without a Firebase project the FCM path is **inert** (the app's FCM service
does nothing) — use ntfy below, or add Firebase later.
**What you see:** system notifications for new messages when the app is
backgrounded; tapping one deep-links to the chat.
### ntfy (zero-config fallback)
```
ANDROID_PUSH_BACKEND=ntfy
```
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed);
the server publishes to it.
- `NTFY_SERVER_URL` defaults to `https://ntfy.sh`. **Self-hosted ntfy is
recommended** — the public ntfy.sh SSE endpoint is flaky (it has served its
web UI instead of the stream), while a self-hosted instance gives reliable
SSE. For a real trust boundary use a private topic + `NTFY_AUTH_TOKEN`.
**What you see:** a low-priority foreground "ntfy listener" notification while
the app is off; incoming pushes trigger a silent sync.
## 5. Remote access
- **Tailscale / WireGuard (recommended):** the gateway gets a stable tailnet IP;
the app connects to `ws://<tailnet-ip>:8790/ws`. No public exposure.
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS at
the edge, forward the WebSocket to `127.0.0.1:8790`.
- **WSS:** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY` (paths, in
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
> **Honest limitation:** the app has **no certificate pinning** yet, so
> self-signed certs won't work — remote access requires **CA-signed** WSS for
> now. Plain `ws://` on a trusted LAN (or inside Tailscale) stays the default.
## 6. Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| `auth failed` / `error {code:"auth"}` on connect | Wrong token. Check `ANDROID_TOKEN` in `~/.hermes/.env` on the gateway host (setup prints it only when it generates it). |
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8790`, path `/ws`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
| Push not arriving | Backend not configured (gateway log: `push backend … not configured`); app backgrounded with no working backend; ntfy.sh SSE flakiness — use a self-hosted ntfy. |
| Desktop jpackage launcher warning | Non-fatal (JDK-8348560 on Linux JDK 17); the app runs and connects regardless. |
Smoke test without the app (from the gateway host):
```bash
python - <<'PY'
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
"token":"<ANDROID_TOKEN>","device_id":"test","device_name":"probe",
"caps":{"min_protocol":1}}}))
print("recv:", await ws.recv())
asyncio.run(main())
PY
```
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
wrong.
+238 -2332
View File
File diff suppressed because it is too large. Load diff
+358
View File
@@ -0,0 +1,358 @@
"""M3: channel-directory frame handlers (``channel.*`` + directory queries).
Mixin for ``adapter.IrisAdapter``. Each request is answered by broadcasting
the matching ``channel.*`` event carrying the request ``id``: the requester's
pending request completes on the id, and every other device reconciles its
local copy from the same frame (single broadcast serves as event + response).
"""
import logging
from typing import Any
from hermes_constants import get_hermes_home
from . import protocol
from . import purge as purge_bridge
from .defaults import DEFAULT_HOME_CHANNEL
from .mixin_base import IrisAdapterBase
logger = logging.getLogger(__name__)
class ChannelFrameHandlers(IrisAdapterBase):
"""Channel directory management (see module docstring)."""
async def on_channel_create(self, frame: protocol.Frame, device_id: str) -> None:
payload = frame.payload
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, "channel.create requires a name", id=frame.id
),
)
return
kind = payload.get("kind")
kind = kind if kind in ("channel", "thread") else "channel"
parent_chat_id = payload.get("parent_chat_id")
if not isinstance(parent_chat_id, str) or not parent_chat_id.strip():
parent_chat_id = None
if kind == "thread" and not parent_chat_id:
parent_chat_id = frame.chat_id or self.home_channel
try:
entry = self._channels.create(name=name, kind=kind, parent_chat_id=parent_chat_id)
except ValueError as e:
await self._reply(
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
)
return
resp = protocol.channel_created(entry)
resp.id = frame.id
await self._http_server.fanout(resp)
# M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log(
entry["chat_id"],
protocol.notification(
entry["chat_id"],
protocol.NOTIF_CHANNEL_CREATED,
"Channels",
f"New channel: {name}",
),
)
async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.rename requires chat_id", id=frame.id
),
)
return
name = frame.payload.get("name")
if not isinstance(name, str) or not name.strip():
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, "channel.rename requires a name", id=frame.id
),
)
return
try:
entry = self._channels.rename(chat_id, name)
except ValueError as e:
await self._reply(
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
)
return
if entry is None:
await self._reply(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
return
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._http_server.fanout(resp)
# M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log(
chat_id,
protocol.notification(
chat_id,
protocol.NOTIF_CHANNEL_RENAMED,
"Channels",
f"Renamed to {name}",
),
)
async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.set_default requires chat_id", id=frame.id
),
)
return
entry = self._channels.set_default(chat_id)
if entry is None:
await self._reply(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
return
# Reuse the renamed event shape: it carries the full entry (incl. the
# new is_default flag) so every device reconciles the default change.
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._http_server.fanout(resp)
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.favorite requires chat_id", id=frame.id
),
)
return
on = bool(frame.payload.get("on"))
entry = self._channels.set_favorite(chat_id, on)
if entry is None:
await self._reply(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
return
# Reuse the renamed event shape: it carries the full entry (incl. the
# new favorite flag) so every device reconciles the change.
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._http_server.fanout(resp)
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.icon requires chat_id", id=frame.id
),
)
return
payload = frame.payload
icon = payload.get("icon")
icon = icon if isinstance(icon, str) and icon else None
color = payload.get("color")
color = color if isinstance(color, str) and color else None
# Guard against a runaway base64 blob (a channel icon is small).
if icon is not None and len(icon) > 512 * 1024:
await self._reply(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "channel icon too large", id=frame.id),
)
return
entry = self._channels.set_icon(chat_id, icon, color)
if entry is None:
await self._reply(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
return
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._http_server.fanout(resp)
async def on_channel_set_automation(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.set_automation requires chat_id", id=frame.id
),
)
return
on = bool(frame.payload.get("on"))
entry = self._channels.set_automation(chat_id, on)
if entry is None:
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND,
f"cannot set automation on {chat_id} (unknown or default)",
id=frame.id,
),
)
return
# Reuse the renamed event shape: it carries the full entry (incl. the
# new automation flag) so every device reconciles the change.
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._http_server.fanout(resp)
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND, "channel.delete requires chat_id", id=frame.id
),
)
return
entry = self._channels.delete(chat_id)
if entry is None:
await self._reply(
device_id,
protocol.error(
protocol.ERR_NOT_FOUND,
f"cannot delete {chat_id} (unknown or default)",
id=frame.id,
),
)
return
# Complete deletion: wipe the lane's history from the outbox (so
# ``history`` / ``sync`` can't resurrect it) and from the hermes
# session store (so no search trace survives). A channel delete takes
# its threads with it (thread_id=None); a thread delete is scoped to
# its parent channel + thread_id.
if entry.get("kind") == "thread":
lane_chat_id = entry.get("parent_chat_id") or chat_id
thread_id = chat_id
else:
lane_chat_id = chat_id
thread_id = None
removed_frames = self._outbox.delete_lane(lane_chat_id, thread_id=thread_id)
removed_msgs = purge_bridge.delete_lane(
get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id
)
logger.info(
"iris: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
chat_id,
entry.get("kind"),
removed_frames,
removed_msgs,
)
resp = protocol.channel_deleted(chat_id)
resp.id = frame.id
await self._http_server.fanout(resp)
# M5: banner + push mirror (parked in the outbox when offline).
await self._broadcast_or_log(
chat_id,
protocol.notification(
chat_id,
protocol.NOTIF_CHANNEL_DELETED,
"Channels",
f"{entry.get('name') or chat_id} deleted",
),
)
async def on_channel_list(self, frame: protocol.Frame, device_id: str) -> None:
channels = self._channels.list(include_archived=False)
resp = protocol.channel_list(channels)
resp.id = frame.id
await self._reply(device_id, resp)
# ── Chat info ─────────────────────────────────────────────────────────
def _channel_name(self, chat_id: str) -> str:
"""Channel display name (M3: from the channel directory)."""
if not chat_id:
return "chat"
entry = self._channels.get(chat_id)
if entry is not None:
return entry["name"]
if chat_id in (self.home_channel, DEFAULT_HOME_CHANNEL):
return self.home_channel_name
return chat_id
async def get_chat_info(self, chat_id: str) -> dict[str, Any]:
"""Return ``{name, type, chat_id}`` for a chat (M3: directory-backed)."""
entry = self._channels.get(chat_id)
kind = entry["kind"] if entry else "channel"
return {
"name": self._channel_name(chat_id),
"type": "dm" if kind == "default" else "channel",
"chat_id": chat_id,
}
def channel_list(self) -> list[dict[str, Any]]:
"""Channel directory for ``hello.ack`` (M3: full non-archived list)."""
return self._channels.list(include_archived=False)
# ── M3: core channel-directory hook (cron / send_message name resolution)
async def list_channels(self) -> list[dict[str, Any]]:
"""Expose the directory to the gateway's core channel directory.
``gateway/channel_directory.build_channel_directory`` calls this to
populate ``channel_directory.json``, which ``resolve_channel_name``
reads for friendly-name -> chat_id resolution (cron + send_message).
Threads are addressed via the explicit ``iris:<chat>:<thread>``
syntax (see ``_parse_target_ref``), so only channels are listed here.
"""
out: list[dict[str, Any]] = []
for entry in self._channels.list(include_archived=False):
if entry["kind"] == "thread":
continue
out.append(
{
"id": entry["chat_id"],
"name": entry["name"],
"type": "dm" if entry["kind"] == "default" else "channel",
}
)
return out
# ── M3: thread handoff (gateway create_handoff_thread) ────────────────
async def create_handoff_thread(self, parent_chat_id: str, name: str) -> str | None:
"""Mint a named thread under *parent_chat_id* (gateway handoff path).
Returns the new ``thread_id`` (``t_<n>``) so the handed-off session is
isolated in its own lane, or ``None`` when the parent is unknown.
"""
parent = self._channels.get(parent_chat_id)
if parent is None:
# Unknown parent: still mint a thread under it so the handoff has a
# lane (the directory row is created lazily on first use).
parent_chat_id = parent_chat_id or self.home_channel
try:
entry = self._channels.create(
name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id
)
except Exception:
logger.warning("iris: create_handoff_thread failed", exc_info=True)
return None
await self._broadcast_both(protocol.channel_created(entry))
return entry["chat_id"]
# ---------------------------------------------------------------------------
# Plugin entry point
# ---------------------------------------------------------------------------
+8 -8
View File
@@ -3,9 +3,9 @@
Maps app concepts onto hermes' existing ``chat_id`` / ``thread_id`` primitives Maps app concepts onto hermes' existing ``chat_id`` / ``thread_id`` primitives
(docs/06-channels-cron-search.md §6.1): (docs/06-channels-cron-search.md §6.1):
* **default chat** -> the home channel (``ANDROID_HOME_CHANNEL``, default * **default chat** -> the home channel (``IRIS_HOME_CHANNEL``, default
``android:default``), ``kind="default"``, ``is_default=1``. ``default``), ``kind="default"``, ``is_default=1``.
* **user channel** -> a minted ``chat_id = android:chan_<n>``, ``kind="channel"``. * **user channel** -> a minted ``chat_id = chan_<n>``, ``kind="channel"``.
* **thread** -> a minted ``thread_id = t_<n>`` under a ``chat_id``, * **thread** -> a minted ``thread_id = t_<n>`` under a ``chat_id``,
``kind="thread"`` (stored with its ``parent_chat_id``). ``kind="thread"`` (stored with its ``parent_chat_id``).
@@ -15,7 +15,7 @@ directory (``gateway/channel_directory.py``) via the adapter's
``list_channels()`` hook, so ``send_message`` / cron can resolve a friendly ``list_channels()`` hook, so ``send_message`` / cron can resolve a friendly
name (e.g. "Cron Reports") to a chat_id. name (e.g. "Cron Reports") to a chat_id.
Storage: ``get_hermes_home()/"android"/channels.db``. Storage: ``get_hermes_home()/"iris"/channels.db``.
Milestone M3. Milestone M3.
""" """
@@ -37,12 +37,12 @@ KIND_CHANNEL = "channel"
KIND_THREAD = "thread" KIND_THREAD = "thread"
# chat_id / thread_id minting prefixes. # chat_id / thread_id minting prefixes.
CHANNEL_PREFIX = "android:chan_" CHANNEL_PREFIX = "chan_"
THREAD_PREFIX = "t_" THREAD_PREFIX = "t_"
class ChannelDirectory: class ChannelDirectory:
"""Persistent channel directory under ``get_hermes_home()/"android"``. """Persistent channel directory under ``get_hermes_home()/"iris"``.
Thread-safe (single connection + lock); all operations are small and fast Thread-safe (single connection + lock); all operations are small and fast
enough to run inline on the gateway's asyncio loop. Mirrors the enough to run inline on the gateway's asyncio loop. Mirrors the
@@ -127,7 +127,7 @@ class ChannelDirectory:
when it was still the auto default); if another row is marked default when it was still the auto default); if another row is marked default
it is cleared so exactly one default exists. it is cleared so exactly one default exists.
""" """
chat_id = (chat_id or "android:default").strip() or "android:default" chat_id = (chat_id or "default").strip() or "default"
name = (name or "Default").strip() or "Default" name = (name or "Default").strip() or "Default"
with self._lock: with self._lock:
existing = self._conn.execute( existing = self._conn.execute(
@@ -443,6 +443,6 @@ def get_directory() -> ChannelDirectory:
# failure is not actionable. # failure is not actionable.
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
_directory.close() _directory.close()
_directory = ChannelDirectory(home / "android" / "channels.db") _directory = ChannelDirectory(home / "iris" / "channels.db")
_directory_home = home _directory_home = home
return _directory return _directory
+335
View File
@@ -0,0 +1,335 @@
"""Outbound frame classification (M2): turn state + content heuristics.
The main gateway delivers through the legacy callback path: the stream
consumer calls ``send()`` (first bubble of a segment) and ``edit_message()``
(updates), tool progress flows through ``send()``/``edit_message()`` of an
accumulated line buffer, and interim commentary arrives as a plain
``send()``. The adapter classifies each outbound call into a structured frame
using a per-chat turn state machine + the content markers below:
* ``metadata["expect_edits"] is True`` -> streaming segment start
* ``metadata["notify"] is True`` -> final message (or fallback final)
* tool-progress line format -> tool.start / tool.end
* anything else -> commentary
Verified empirically against the live gateway with ``tests/ws_probe.py``.
"""
import json
import logging
import re
import uuid
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
_STREAMING_CURSOR = " ▉"
# Code-style reasoning prefix (gateway/run.py, reasoning_style="code"):
# "💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>"
_REASONING_PREFIX = "💭 **Reasoning:**\n```\n"
_REASONING_CLOSE = "\n```\n\n"
# A gateway tool-progress line begins with a (non-ASCII) tool emoji.
_TOOL_LINE_RE = re.compile(r"^(\S+)\s+(.+)$")
_TOOL_NAME_PREVIEW_RE = re.compile(r'^(\S+):\s*"(.*)"\s*$')
_TOOL_NAME_BARE_RE = re.compile(r"^(\S+)\.\.\.\s*$")
_TOOL_NAME_ARGS_RE = re.compile(r"^(\S+)\(([^)]*)\)\s*$")
# Terminal code block: "💻 terminal\n```\n<cmd>\n```"
_TOOL_CODEBLOCK_HEAD_RE = re.compile(r"^(\S+)\s+(\S+)\s*$")
# Reverse map of the gateway's friendly tool verbs (agent/display.py
# _TOOL_VERBS) so a verb-form line ("🔍 Searching the web for …") can be
# recovered to a structured (tool_name, preview). Longest-first matching is
# done at parse time. Verbs shared by several tools map to the most common.
_VERB_TO_TOOL: dict[str, str] = {
"Searching the web": "web_search",
"Searching files": "search_files",
"Searching past sessions": "session_search",
"Running code": "execute_code",
"Running": "terminal",
"Reading skill": "skill_view",
"Reading": "read_file",
"Writing": "write_file",
"Editing": "patch",
"Browsing": "browser_navigate",
"Clicking": "browser_click",
"Typing": "browser_type",
"Generating image": "image_generate",
"Generating video": "video_generate",
"Generating speech": "text_to_speech",
"Looking at the image": "vision_analyze",
"Listing skills": "skills_list",
"Updating skill": "skill_manage",
"Updating memory": "memory",
"Updating tasks": "todo",
"Delegating": "delegate_task",
"Scheduling": "cronjob",
"Asking": "clarify",
}
# Verbs that take a " for " connector before the preview.
_VERB_FOR_CONNECTOR = {"web_search", "search_files"}
def _mint_message_id() -> str:
return f"m_{uuid.uuid4().hex[:16]}"
def _mint_picker_id() -> str:
return f"pc_{uuid.uuid4().hex[:16]}"
def _thread_id_from_metadata(metadata: dict[str, Any] | None) -> str | None:
if not metadata:
return None
tid = metadata.get("thread_id")
if isinstance(tid, str) and tid:
return tid
return None
def _derive_thread_name(text: str) -> str:
"""Instant auto-thread name from the user's opening message (no model).
Reuses hermes' session-title derivation (``agent/title_generator.py``):
a deterministic slice of the user's own words, so the thread is named the
moment it is created. The LLM upgrade (``_schedule_thread_title_upgrade``)
replaces it moments later — the same two-stage titling hermes uses for
sessions (derived < llm < user).
"""
try:
from agent.title_generator import derive_title
title = derive_title(text)
except Exception:
logger.debug("Thread name derivation failed", exc_info=True)
title = None
return (title or "").strip() or "New thread"
def _strip_streaming_cursor(text: str) -> str:
if text and text.endswith(_STREAMING_CURSOR):
return text[: -len(_STREAMING_CURSOR)]
return text
# M5: coalesce back-to-back pushes for the same chat (a cron delivery parks
# a notification frame AND a message frame; only the first should push).
_PUSH_COALESCE_S = 5.0
def _push_preview(text: Any, limit: int = 120) -> str:
"""Short single-line preview for push bodies (lock-screen privacy: no
secrets, no full bodies -- full content arrives via ``sync``)."""
s = " ".join(str(text or "").split())
if len(s) > limit:
s = s[: limit - 1] + "…"
return s
# Cron delivery wrap (cron/scheduler.py ``_deliver_result``,
# cron.wrap_response: true):
# "Cronjob Response: <name>\n(job_id: <id>)\n-------------\n\n<content>\n\n
# To stop or manage this job, send me a new message (e.g. ...)."
_CRON_WRAP_RE = re.compile(r"^Cronjob Response: (.+?)\n\(job_id: [^)]*\)\n-+\n\n")
_CRON_FOOTER = "\n\nTo stop or manage this job"
def _cron_brief(content: str, job_id: str) -> tuple[str, str]:
"""Parse a cron delivery into ``(job_name, inner_text)``.
Falls back to ``(job_id, content)`` when the wrap is disabled
(``cron.wrap_response: false``) or unrecognised.
"""
m = _CRON_WRAP_RE.match(content or "")
if not m:
return str(job_id or "cron"), (content or "").strip()
name = m.group(1).strip()
body = content[m.end() :]
idx = body.rfind(_CRON_FOOTER)
if idx != -1:
body = body[:idx]
return name, body.strip()
def _split_reasoning(text: str) -> tuple[str | None, str]:
"""Split a code-style reasoning prefix off the front of *text*.
Returns ``(reasoning, body)``; ``reasoning`` is ``None`` when no prefix is
present (reasoning off / no reasoning / non-code style). Best-effort parse
of a stable, gateway-owned format: on any mismatch the fallback is
``(None, full text)`` so the answer still renders.
"""
if not text or not text.startswith(_REASONING_PREFIX):
return None, text
close_idx = text.find(_REASONING_CLOSE, len(_REASONING_PREFIX))
if close_idx == -1:
return None, text
reasoning = text[len(_REASONING_PREFIX) : close_idx]
body = text[close_idx + len(_REASONING_CLOSE) :]
return reasoning, body
def _parse_tool_line(line: str) -> tuple[str, str | None] | None: # noqa: PLR0911
"""Parse a single gateway tool-progress line into ``(name, preview)``.
Returns ``None`` when the line is not a tool line. The gateway formats
tool lines as ``<emoji> <name>: "<preview>"``, ``<emoji> <name>...``,
``<emoji> <name>(keys)``, or a friendly verb phrase (``<emoji> <verb> …``).
The verb form is lossy (no tool name), so we surface the verb as the name.
"""
line = line.strip()
if not line:
return None
m = _TOOL_LINE_RE.match(line)
if not m:
return None
emoji, rest = m.group(1), m.group(2)
if emoji.isascii():
return None # a tool line always leads with a non-ASCII emoji
mp = _TOOL_NAME_PREVIEW_RE.match(rest)
if mp:
return mp.group(1), mp.group(2)
mb = _TOOL_NAME_BARE_RE.match(rest)
if mb:
return mb.group(1), None
ma = _TOOL_NAME_ARGS_RE.match(rest)
if ma:
return ma.group(1), None
# Friendly verb phrase: reverse-map to (tool_name, preview).
verb_parsed = _parse_verb_phrase(rest)
if verb_parsed is not None:
return verb_parsed
# Unrecognised: use the phrase as the label.
return rest, None
def _parse_verb_phrase(phrase: str) -> tuple[str, str | None] | None:
"""Reverse-map a friendly verb phrase to ``(tool_name, preview)``.
Matches the longest verb first so "Running code" wins over "Running".
Returns ``None`` when no known verb leads the phrase.
"""
for verb in sorted(_VERB_TO_TOOL, key=len, reverse=True):
tool = _VERB_TO_TOOL[verb]
if phrase == verb:
return tool, None
if tool in _VERB_FOR_CONNECTOR and phrase.startswith(verb + " for "):
return tool, phrase[len(verb) + len(" for ") :].strip() or None
if phrase.startswith(verb + " "):
return tool, phrase[len(verb) + 1 :].strip() or None
return None
def _extract_code_block(content: str) -> str | None:
"""Return the first fenced code block's body in *content*, else ``None``.
Used to recover the terminal command from a tool-progress code block
(``<emoji> terminal`` head line + fenced command).
"""
m = re.search(r"```[^\n]*\n(.*?)\n```", content, re.DOTALL)
if m:
return m.group(1).strip() or None
return None
def _extract_verbose_args(line: str, content: str) -> dict[str, Any] | None:
"""Recover the full args dict from a verbose tool line, else ``None``.
In verbose mode the gateway renders ``<emoji> <name>(keys)`` on one line
and the full args JSON on the line that follows. When *line* is such a
header, return the parsed JSON object from the following line.
"""
parts = line.strip().split(None, 1)
# 2 == "tool name" + "args JSON" on the header line.
if len(parts) < 2 or not _TOOL_NAME_ARGS_RE.match(parts[1]): # noqa: PLR2004
return None
lines = content.splitlines()
for i, ln in enumerate(lines):
if ln.strip() != line.strip():
continue
for follow_line in lines[i + 1 :]:
follow = follow_line.strip()
if not follow:
continue
if follow.startswith("{"):
try:
obj = json.loads(follow)
return obj if isinstance(obj, dict) else None
except Exception:
return None
return None # next non-empty line is not the args JSON
return None
def _short_preview_from_args(args: dict[str, Any], cap: int = 60) -> str | None:
"""Derive a short one-line preview from a verbose args dict.
The verbose line carries no explicit preview, so the Truncated display
would otherwise lose its one-liner. Use the first non-empty string value
(whitespace-collapsed, capped) as a stand-in.
"""
if not isinstance(args, dict):
return None
for value in args.values():
if isinstance(value, str) and value.strip():
s = " ".join(value.split())
return s[: cap - 1] + "…" if len(s) > cap else s
return None
def _is_tool_progress(content: str) -> bool:
"""Heuristic: does *content* look like gateway tool-progress line(s)?
Tool progress is delivered as one or more lines, each led by a tool emoji
(or a terminal code block). Commentary is free-form prose. We classify on
the first non-empty line; subsequent lines of the same bubble are tracked
by message id, not re-classified.
"""
if not content:
return False
lines = [ln for ln in content.splitlines() if ln.strip()]
if not lines:
return False
first = lines[0].strip()
# Terminal code block: "<emoji> terminal" then a fenced command.
if len(lines) > 1 and lines[1].strip().startswith("```"):
return _TOOL_CODEBLOCK_HEAD_RE.match(first) is not None
return _parse_tool_line(first) is not None
def _is_gateway_lifecycle_notice(content: str) -> bool:
"""True for hermes gateway lifecycle notices (restart / shutdown / online).
These are system notices, not tool progress. Their leading ⚠️/♻️ emoji
would otherwise trip the tool-line heuristic and render them as a
never-completing tool card (an endless spinner, since no ``tool.end``
ever arrives for a notice that is not a real tool).
"""
if not content:
return False
c = content.strip()
return any(
marker in c for marker in ("Gateway restarting", "Gateway shutting down", "Gateway online")
)
@dataclass
class _TurnState:
"""Per-chat turn state for outbound frame classification (M2)."""
active: bool = False
# message_id of the currently streaming segment (message.start open).
stream_id: str | None = None
# message_id of the current tool-progress bubble (editable line buffer).
tool_msg_id: str | None = None
# Monotonic per-turn tool counter (start -> end correlation).
tool_index: int = 0
# Index of the most recently started tool (awaiting tool.end).
open_tool_index: int | None = None
# Name of the most recently started tool (matches the post_tool_call
# record when the tool completes, so tool.end can carry its output).
open_tool_name: str | None = None
# Tool lines already emitted as tool.start (dedup across edits).
seen_tool_lines: set = field(default_factory=set)
+62
View File
@@ -0,0 +1,62 @@
"""Slash-command catalog for the app's "/" drawer.
Derived from hermes' central ``COMMAND_REGISTRY`` (``hermes_cli/commands.py``)
— the same source the gateway help text and the Telegram command menu use —
restricted to commands available on gateway surfaces, plus plugin-registered
commands. Never raises: any import/attribute problem (code skew between the
plugin and the hermes checkout) degrades to an empty catalog, so the app's
drawer simply stays closed.
"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
def _slash_command_catalog() -> list[dict[str, Any]]:
try:
from hermes_cli import commands as hermes_commands
except Exception:
logger.warning(
"iris: slash catalog unavailable (hermes_cli.commands import failed)",
exc_info=True,
)
return []
def _entry(
name: str, description: str, args_hint: str, category: str, aliases: list[str]
) -> dict[str, Any]:
return {
"name": f"/{name}",
"description": description,
"args_hint": args_hint or "",
"category": category,
"aliases": [f"/{a}" for a in aliases],
}
entries: list[dict[str, Any]] = []
try:
overrides = hermes_commands._resolve_config_gates()
for cmd in hermes_commands.COMMAND_REGISTRY:
if not hermes_commands._is_gateway_available(cmd, overrides):
continue
entries.append(
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
)
except Exception:
# Code skew: the private helpers moved. Fall back to the plain
# cli_only filter (config-gated commands are dropped, acceptable).
logger.warning("iris: slash catalog fell back to cli_only filter", exc_info=True)
entries = [
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
for cmd in hermes_commands.COMMAND_REGISTRY
if not cmd.cli_only
]
try:
for name, description, args_hint in hermes_commands._iter_plugin_command_entries():
entries.append(_entry(name, description, args_hint, "Plugin", []))
except Exception:
# Best-effort: a broken plugin-command registry should not break the
# built-in catalog, so the failure is intentionally swallowed.
logger.debug("iris: plugin command enumeration failed", exc_info=True)
return entries
+13
View File
@@ -0,0 +1,13 @@
"""Platform defaults (config.yaml ``extra`` / env fallbacks)."""
DEFAULT_HOST = "127.0.0.1"
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP is the only transport
DEFAULT_HOME_CHANNEL = "default"
DEFAULT_HOME_CHANNEL_NAME = "Default"
DEFAULT_PUSH_BACKEND = "ntfy"
DEFAULT_OUTBOX_RETENTION_HOURS = 72
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
def _truthy(value: str | None) -> bool:
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
+3 -1
View File
@@ -24,7 +24,7 @@ INBOUND_BURST = 40
MAX_DEVICE_ID_LEN = 128 MAX_DEVICE_ID_LEN = 128
async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) -> None: async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) -> None: # noqa: PLR0912
"""Shared inbound frame dispatch (docs/19 §19.4). Unknown types are """Shared inbound frame dispatch (docs/19 §19.4). Unknown types are
ignored (forward-compat).""" ignored (forward-compat)."""
if frame.type == protocol.TYPE_MESSAGE_SEND: if frame.type == protocol.TYPE_MESSAGE_SEND:
@@ -57,6 +57,8 @@ async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) ->
await adapter.on_message_delete(frame, device_id) await adapter.on_message_delete(frame, device_id)
elif frame.type == protocol.TYPE_FCM_REGISTER: elif frame.type == protocol.TYPE_FCM_REGISTER:
await adapter.on_fcm_register(frame, device_id) await adapter.on_fcm_register(frame, device_id)
elif frame.type == protocol.TYPE_PICKER_SELECT:
await adapter.on_picker_select(frame, device_id)
# Unknown types are ignored (forward-compat). # Unknown types are ignored (forward-compat).
+352
View File
@@ -0,0 +1,352 @@
"""Plugin hook capture: reasoning, tool results, runtime metadata.
The gateway's plugin hooks (``on_stream_delta``, ``post_tool_call``,
``post_api_request``) fire on gateway worker threads; these module-level
buffers accumulate the per-turn data the adapter attaches to outbound frames
(reasoning on ``message.stop``, tool output on ``tool.end``, the ``runtime``
footer on final messages). A personal iris gateway serves one active turn at
a time, so global buffers suffice; each is reset at the turn boundary.
"""
import asyncio
import contextlib
import json
import logging
import os
import threading
import time
from collections import deque
from typing import TYPE_CHECKING, Any
from . import protocol
if TYPE_CHECKING: # pragma: no cover - typing only
from .adapter import IrisAdapter
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# M2 — reasoning capture (streaming)
#
# The gateway streams only ``content`` to the platform and suppresses the
# final send (which would carry the prepended reasoning), so the model's
# separate ``reasoning_content`` is otherwise lost in the streaming case.
# hermes exposes a plugin ``on_stream_delta`` hook that fires reasoning
# deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``).
# We accumulate those deltas here and attach the result to the turn's
# ``message.stop`` frame. Single-chat for now (the default home channel), so a
# module-level buffer suffices; it is reset at each turn start.
# ---------------------------------------------------------------------------
_reasoning_parts: list[str] = []
_reasoning_lock = threading.Lock()
# Barrier: set by the hook worker once it has processed the first content
# delta (kind="text"). The worker drains a FIFO queue and reasoning deltas are
# enqueued before content deltas, so at that point every reasoning delta has
# already been appended -- a reliable "reasoning flushed" signal that avoids
# racing message.stop against the async hook thread.
_reasoning_flushed = threading.Event()
def _on_stream_delta(**kwargs: Any) -> None:
"""Plugin hook: capture reasoning deltas (kind="reasoning")."""
kind = kwargs.get("kind")
if kind == "reasoning":
delta = kwargs.get("delta") or ""
if delta:
with _reasoning_lock:
_reasoning_parts.append(delta)
elif kind == "text":
_reasoning_flushed.set()
async def _wait_for_reasoning_flushed(timeout: float = 0.3) -> None:
"""Wait (without blocking the event loop) until the hook worker has
processed all reasoning deltas, or *timeout* seconds elapse."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
if _reasoning_flushed.is_set():
return
await asyncio.sleep(0.01)
def _take_reasoning() -> str:
"""Drain and return the accumulated reasoning (empty string if none)."""
with _reasoning_lock:
parts = _reasoning_parts[:]
_reasoning_parts.clear()
_reasoning_flushed.clear()
return "".join(parts).strip()
def _reset_reasoning() -> None:
with _reasoning_lock:
_reasoning_parts.clear()
_reasoning_flushed.clear()
# ---------------------------------------------------------------------------
# M2 — tool-result capture (post_tool_call hook)
#
# The gateway renders tool *progress* lines to the platform but never streams
# the tool *output* (it is the agent's concern, persisted to history, not
# presentation). To let the app show the full call + result on demand
# (Settings → Tool detail), we capture each completed tool call via the
# ``post_tool_call`` hook and attach it to the ``tool.end`` frame.
#
# Global FIFO (like the reasoning buffer): a personal iris gateway serves
# one active turn at a time, and records are matched to the open tool by name
# in completion order. Bounded so a runaway turn can't grow it without limit.
# ---------------------------------------------------------------------------
_tool_results: "deque[dict[str, Any]]" = deque()
_tool_results_lock = threading.Lock()
_MAX_TOOL_RESULTS = 200
_MAX_OUTPUT_PREVIEW = 8000
def _on_post_tool_call(**kwargs: Any) -> None:
"""Plugin hook: capture a completed tool call's result + timing."""
result = kwargs.get("result")
record = {
"tool_name": kwargs.get("tool_name") or "",
"result": (str(result) if result is not None else "")[:_MAX_OUTPUT_PREVIEW],
"duration_ms": kwargs.get("duration_ms") or 0,
"status": kwargs.get("status") or "ok",
}
with _tool_results_lock:
_tool_results.append(record)
while len(_tool_results) > _MAX_TOOL_RESULTS:
_tool_results.popleft()
# Live todo list: the todo tool's result is the authoritative full list,
# so emit it the moment the call completes — the tool.end frame only
# arrives when the NEXT tool starts or the turn ends, which would lag the
# app's strip behind the agent's actual progress. Best-effort: a parse
# failure (truncated preview) or a missing live adapter just skips it.
if kwargs.get("tool_name") == "todo" and record["status"] == "ok":
todos = _parse_todo_result(record["result"])
adapter = _live_adapter
if todos is not None and adapter is not None and adapter._loop is not None:
with contextlib.suppress(Exception):
asyncio.run_coroutine_threadsafe(adapter._emit_todo_update(todos), adapter._loop)
def _take_tool_result(tool_name: str) -> dict[str, Any] | None:
"""Pop the first completed record matching *tool_name* (FIFO), else None."""
if not tool_name:
return None
with _tool_results_lock:
for i, rec in enumerate(_tool_results):
if rec["tool_name"] == tool_name:
del _tool_results[i]
return rec
return None
def _reset_tool_results() -> None:
"""Clear captured records (turn boundary — drop anything unconsumed)."""
with _tool_results_lock:
_tool_results.clear()
def _tool_end_fields(tool_name: str) -> dict[str, Any]:
"""Build the ``tool.end`` enrichment (ok/duration/output_preview) from the
captured hook record for *tool_name*; empty dict when none is available
(e.g. tool_progress off, or the call came from another session)."""
rec = _take_tool_result(tool_name)
if rec is None:
return {}
fields: dict[str, Any] = {
"ok": rec["status"] == "ok",
"output_preview": rec["result"] or None,
}
if rec["duration_ms"]:
fields["duration"] = round(rec["duration_ms"] / 1000.0, 3)
return fields
# The live adapter instance (module-level so the synchronous plugin hooks
# below can reach it). A personal iris gateway runs exactly one adapter;
# set on connect, cleared on disconnect.
_live_adapter: "IrisAdapter | None" = None
# Valid todo item statuses (hermes tools/todo_tool.py VALID_STATUSES).
_TODO_STATUSES = ("pending", "in_progress", "completed", "cancelled")
def _parse_todo_result(result: Any) -> list[dict[str, str]] | None:
"""Parse the ``todo`` tool's result into a clean item list.
The tool returns ``{"todos": [...], "summary": {...}}`` — the FULL current
list, which is authoritative even for ``merge`` writes (whose args carry
only the changed items) and read-only calls. Returns ``None`` when the
result is not a parseable todo list (error string, truncated preview, …)
so the caller skips the emission instead of broadcasting garbage.
"""
try:
data = json.loads(result)
except (TypeError, ValueError):
return None
items = data.get("todos") if isinstance(data, dict) else None
if not isinstance(items, list):
return None
todos: list[dict[str, str]] = []
for it in items:
if not isinstance(it, dict):
continue
content = str(it.get("content") or "").strip()
status = str(it.get("status") or "")
if not content or status not in _TODO_STATUSES:
continue
todos.append({"id": str(it.get("id") or ""), "content": content, "status": status})
return todos
def _tool_emoji(tool_name: str) -> str | None:
"""Cosmetic per-tool emoji for the ``tool.start`` frame.
Resolved via hermes' own display layer (``agent.display.get_tool_emoji``):
active-skin ``tool_emojis`` overrides first, then the tool registry's
per-tool ``emoji`` field — so icons track the user's hermes theme and
new/plugin tools get their registered glyph for free. Returns ``None``
when the tool is unknown (or the import fails) so the frame omits the
field and the app falls back to its own default glyph.
"""
try:
from agent.display import get_tool_emoji
return get_tool_emoji(tool_name, default="") or None
except Exception:
return None
# ---------------------------------------------------------------------------
# Runtime-metadata footer (post_api_request hook)
#
# The app renders a Telegram-style footer under final assistant messages
# (model, context %, cwd, latency, cost). Display is controlled by the APP
# (Settings → Runtime footer), not hermes config — so the gateway ALWAYS
# sends the data. hermes core only appends its own *text* footer when
# ``display.runtime_footer.enabled`` is set, and the adapter has no access to
# the gateway's ``agent_result``, so we capture the same facts ourselves via
# the ``post_api_request`` plugin hook (fires after every provider call with
# model + usage):
#
# * model — the turn's latest model (failover-aware)
# * prompt_tokens — the latest call's prompt size (context occupancy)
# * turn start — the first API call of the turn (latency baseline)
#
# Global buffer (same pattern as the reasoning/tool buffers): a personal
# iris gateway serves one active turn at a time. The hook fires for every
# platform, so we only record when the turn's platform is iris.
# ---------------------------------------------------------------------------
_runtime_meta: dict[str, Any] = {}
_runtime_meta_lock = threading.Lock()
# Per-model context-window cache. Resolution may probe endpoints on first use
# (slow); the cache is process-lifetime so each model resolves at most once.
_context_length_cache: dict[str, int] = {}
# Upper bound (seconds) on context-window resolution during a final send, so a
# slow first-use probe never delays the reply. The worker thread keeps running
# and populates the cache, so the next turn is fast.
_CTX_RESOLVE_TIMEOUT_S = 3.0
def _on_post_api_request(**kwargs: Any) -> None:
"""Plugin hook: capture per-turn runtime metadata (model, prompt tokens)."""
platform = kwargs.get("platform")
if platform and platform != "iris":
return
model = kwargs.get("model") or ""
usage = kwargs.get("usage") or {}
prompt_tokens = usage.get("prompt_tokens") or 0
with _runtime_meta_lock:
if model:
_runtime_meta["model"] = model
if prompt_tokens:
_runtime_meta["prompt_tokens"] = prompt_tokens
if "turn_start" not in _runtime_meta:
_runtime_meta["turn_start"] = time.monotonic()
def _take_runtime_meta() -> dict[str, Any]:
"""Drain the captured turn metadata (turn boundary)."""
with _runtime_meta_lock:
meta = dict(_runtime_meta)
_runtime_meta.clear()
return meta
def _resolve_context_length(model: str) -> int | None:
"""Best-effort context window for *model* (cached; None on failure).
Runs in a worker thread (may probe endpoints on first use). The cache is
populated even if the caller's asyncio task times out, so subsequent
turns resolve instantly.
"""
if not model:
return None
cached = _context_length_cache.get(model)
if cached:
return cached
try:
from agent.model_metadata import get_model_context_length
ctx = get_model_context_length(model)
if ctx and ctx > 0:
_context_length_cache[model] = int(ctx)
return int(ctx)
except Exception:
logger.debug("iris: context-length resolution failed for %s", model, exc_info=True)
return None
def _home_relative_cwd(cwd: str) -> str:
"""Collapse ``$HOME`` to ``~`` (matches hermes' runtime footer)."""
if not cwd:
return ""
try:
home = os.path.expanduser("~")
p = os.path.abspath(cwd)
if home and (p == home or p.startswith(home + os.sep)):
return "~" + p[len(home) :]
return p
except Exception:
return cwd
async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]:
"""Build the ``runtime`` footer object from captured turn metadata.
Called on every final send (the app decides what to show). Fields without
data are omitted. ``meta`` is the drained turn buffer (model,
prompt_tokens, turn_start).
"""
# Lazy import: the tests monkeypatch ``adapter._resolve_context_length``,
# so resolve it through the adapter module at call time (avoids a
# top-level circular import adapter -> hooks -> adapter).
from .adapter import _resolve_context_length
model = (meta.get("model") or "").rsplit("/", 1)[-1]
prompt_tokens = meta.get("prompt_tokens") or 0
context_pct = None
if prompt_tokens and model:
try:
ctx_len = await asyncio.wait_for(
asyncio.to_thread(_resolve_context_length, model),
timeout=_CTX_RESOLVE_TIMEOUT_S,
)
except (asyncio.TimeoutError, Exception):
ctx_len = None
if ctx_len:
context_pct = round(prompt_tokens / ctx_len * 100)
turn_start = meta.get("turn_start")
latency = (time.monotonic() - turn_start) if turn_start else None
cwd = _home_relative_cwd(os.environ.get("TERMINAL_CWD", ""))
return protocol.runtime_footer(
model=model or None,
context_pct=context_pct,
cwd=cwd or None,
latency=latency,
)
+77 -22
View File
@@ -199,9 +199,9 @@ class HttpServer:
from gateway.status import acquire_scoped_lock from gateway.status import acquire_scoped_lock
lock_key = f"http:{host}:{port}" lock_key = f"http:{host}:{port}"
if not acquire_scoped_lock("android", lock_key): if not acquire_scoped_lock("iris", lock_key):
logger.warning( logger.warning(
"android: HTTP port %s:%s in use by another profile; server disabled", "iris: HTTP port %s:%s in use by another profile; server disabled",
host, host,
port, port,
) )
@@ -218,18 +218,16 @@ class HttpServer:
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key) ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
except Exception as e: except Exception as e:
logger.warning("android: HTTP server disabled (bind %s:%s failed: %s)", host, port, e) logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
self._release_lock() self._release_lock()
return return
self._httpd = httpd self._httpd = httpd
self._thread = threading.Thread( self._thread = threading.Thread(target=httpd.serve_forever, name="iris-http", daemon=True)
target=httpd.serve_forever, name="android-http", daemon=True
)
self._thread.start() self._thread.start()
self.enabled = True self.enabled = True
scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http" scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http"
logger.info("android: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port) logger.info("iris: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port)
async def stop(self) -> None: async def stop(self) -> None:
"""Stop serving and unblock all subscribers.""" """Stop serving and unblock all subscribers."""
@@ -261,7 +259,7 @@ class HttpServer:
from gateway.status import release_scoped_lock from gateway.status import release_scoped_lock
if self._lock_key: if self._lock_key:
release_scoped_lock("android", self._lock_key) release_scoped_lock("iris", self._lock_key)
self._lock_key = None self._lock_key = None
# ── Subscriber registry ─────────────────────────────────────────────── # ── Subscriber registry ───────────────────────────────────────────────
@@ -277,6 +275,13 @@ class HttpServer:
def _add_sub(self, sub: _Subscriber) -> None: def _add_sub(self, sub: _Subscriber) -> None:
with self._subs_lock: with self._subs_lock:
self._subs.setdefault(sub.device_id, []).append(sub) self._subs.setdefault(sub.device_id, []).append(sub)
# M5: a live subscriber will sync the outbox -- tell the adapter to
# drop any held-back (deferred) pushes so the turn-end flush doesn't
# duplicate what the app already shows. getattr-guard: test doubles
# may use a bare adapter stub.
on_online = getattr(self._adapter, "on_device_online", None)
if on_online is not None:
on_online()
def _remove_sub(self, sub: _Subscriber) -> None: def _remove_sub(self, sub: _Subscriber) -> None:
with self._subs_lock: with self._subs_lock:
@@ -307,7 +312,7 @@ class HttpServer:
except queue.Full: except queue.Full:
# Slow subscriber: drop it. The client reconnects with # Slow subscriber: drop it. The client reconnects with
# Last-Event-ID and catches up from the outbox. # Last-Event-ID and catches up from the outbox.
logger.info("android: dropping slow HTTP subscriber %s", s.device_id) logger.info("iris: dropping slow HTTP subscriber %s", s.device_id)
s.closed.set() s.closed.set()
self._remove_sub(s) self._remove_sub(s)
return sent return sent
@@ -316,22 +321,38 @@ class HttpServer:
def _authenticate(self, handler: BaseHTTPRequestHandler) -> str | None: def _authenticate(self, handler: BaseHTTPRequestHandler) -> str | None:
"""Verify Bearer token + device identity. Returns the device_id, or """Verify Bearer token + device identity. Returns the device_id, or
None after sending a 401.""" None after sending a 401.
Token model (docs/09 §9.3): a REVOKED device_id is rejected no matter
which token it presents (per-device isolation). Otherwise the shared
``IRIS_TOKEN`` (bootstrap / legacy) or the device's own per-device
token (minted at pairing, returned in ``hello.ack.device_token``)
both authenticate — each compared in constant time."""
auth = handler.headers.get("Authorization") or "" auth = handler.headers.get("Authorization") or ""
token = auth[len("Bearer ") :] if auth.startswith("Bearer ") else None token = auth[len("Bearer ") :] if auth.startswith("Bearer ") else None
if not verify_token(token, self._adapter.token):
_send_json(handler, 401, {"error": "unauthorized"})
return None
device_id = (handler.headers.get("X-Iris-Device") or "").strip() device_id = (handler.headers.get("X-Iris-Device") or "").strip()
if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN: if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN:
_send_json(handler, 401, {"error": "X-Iris-Device header required"}) _send_json(handler, 401, {"error": "X-Iris-Device header required"})
return None return None
with contextlib.suppress(Exception):
if self._devices.is_revoked(device_id):
logger.warning("iris: http rejected: device %s is revoked", device_id)
_send_json(handler, 401, {"error": "device revoked"})
return None
if not verify_token(token, self._adapter.token):
# Not the shared token: try the device's own per-device token.
device_token = None
with contextlib.suppress(Exception):
device_token = self._devices.token_for(device_id)
if not (device_token and verify_token(token, device_token)):
_send_json(handler, 401, {"error": "unauthorized"})
return None
if ( if (
not self._adapter.allow_all not self._adapter.allow_all
and self._adapter.allowed_users and self._adapter.allowed_users
and device_id not in self._adapter.allowed_users and device_id not in self._adapter.allowed_users
): ):
logger.warning("android: http rejected: device %s not allowlisted", device_id) logger.warning("iris: http rejected: device %s not allowlisted", device_id)
_send_json(handler, 401, {"error": "device not allowed"}) _send_json(handler, 401, {"error": "device not allowed"})
return None return None
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
@@ -436,7 +457,7 @@ class HttpServer:
try: try:
await dispatch.dispatch_frame(self._adapter, frame, device_id) await dispatch.dispatch_frame(self._adapter, frame, device_id)
except Exception: except Exception:
logger.warning("android: HTTP dispatch failed for %s", frame.type, exc_info=True) logger.warning("iris: HTTP dispatch failed for %s", frame.type, exc_info=True)
finally: finally:
# Pop our sink entry (a newer request from the same device may # Pop our sink entry (a newer request from the same device may
# have replaced it). If the HTTP response was already sent # have replaced it). If the HTTP response was already sent
@@ -459,7 +480,7 @@ class HttpServer:
# ── GET /v1/events (SSE) ────────────────────────────────────────────── # ── GET /v1/events (SSE) ──────────────────────────────────────────────
def _handle_sse(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None: def _handle_sse(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None: # noqa: PLR0912,PLR0915
qs = parse_qs(parsed.query) qs = parse_qs(parsed.query)
cursor = _parse_cursor(qs.get("cursor", [None])[0], handler.headers.get("Last-Event-ID")) cursor = _parse_cursor(qs.get("cursor", [None])[0], handler.headers.get("Last-Event-ID"))
# Device registration (the HTTP equivalent of the WS hello upsert): # Device registration (the HTTP equivalent of the WS hello upsert):
@@ -478,17 +499,31 @@ class HttpServer:
ntfy_topic, ntfy_topic,
) )
except Exception: except Exception:
logger.warning("android: device registry upsert failed", exc_info=True) logger.warning("iris: device registry upsert failed", exc_info=True)
# Per-device token (docs/09 §9.3): minted once at pairing (idempotent
# across (re)connects) and returned in the hello below; the app
# stores it and presents it instead of the shared token from then on.
device_token = ""
try:
device_token = self._devices.issue_token(device_id)
except Exception:
logger.warning("iris: device token issuance failed", exc_info=True)
sub = _Subscriber(device_id=device_id, kind="sse") sub = _Subscriber(device_id=device_id, kind="sse")
# Register BEFORE the replay so a frame appended in between is # Register BEFORE the replay so a frame appended in between is
# fanned out to us (and de-duped by cursor below) instead of lost. # fanned out to us (and de-duped by cursor below) instead of lost.
self._add_sub(sub) self._add_sub(sub)
reason = "eof"
try: try:
handler.send_response(200) handler.send_response(200)
handler.send_header("Content-Type", "text/event-stream") handler.send_header("Content-Type", "text/event-stream")
handler.send_header("Cache-Control", "no-cache") handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no") handler.send_header("X-Accel-Buffering", "no")
handler.end_headers() handler.end_headers()
# INFO (not DEBUG like the per-request log): the stream
# lifecycle is the primary "is the device connected?" signal
# for debugging flaky links — a gap here is invisible at the
# gateway's default log level.
logger.info("iris: SSE stream opened: %s (cursor=%d)", device_id, cursor)
# 1. Catch-up from the outbox (id = cursor; the envelope also # 1. Catch-up from the outbox (id = cursor; the envelope also
# carries the cursor for the app's push dedupe). # carries the cursor for the app's push dedupe).
max_cursor = cursor max_cursor = cursor
@@ -502,19 +537,38 @@ class HttpServer:
sync_cursor=self._adapter._outbox.latest_cursor(), sync_cursor=self._adapter._outbox.latest_cursor(),
channels=self._adapter.channel_list(), channels=self._adapter.channel_list(),
last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id), last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id),
device_token=device_token,
) )
self._write_sse(handler, "hello", None, hello.to_json()) self._write_sse(handler, "hello", None, hello.to_json())
self._write_sse( self._write_sse(
handler, "frame", None, protocol.status(self._adapter.gateway_status()).to_json() handler, "frame", None, protocol.status(self._adapter.gateway_status()).to_json()
) )
# 2b. Live todo-list snapshot (ephemeral state, never outboxed):
# a (re)connecting device re-learns the agent's current plan here.
for snap in self._adapter.todo_snapshot_frames():
self._write_sse(handler, "frame", None, snap.to_json())
# 3. Live frames (cursor=None frames have no id). # 3. Live frames (cursor=None frames have no id).
while not sub.closed.is_set(): while True:
if sub.closed.is_set():
# stop() can land between the initial writes above and
# this loop (the handler thread is descheduled under
# load): drain the frames queued before the close — e.g.
# the status{restarting} teardown broadcast — so the
# client sees them before EOF instead of losing them to
# the closed check.
try:
item = sub.q.get_nowait()
except queue.Empty:
reason = "stopped"
break
else:
try: try:
item = sub.q.get(timeout=SSE_HEARTBEAT_S) item = sub.q.get(timeout=SSE_HEARTBEAT_S)
except queue.Empty: except queue.Empty:
self._write_raw(handler, ": hb\n\n") self._write_raw(handler, ": hb\n\n")
continue continue
if item is _STOP: if item is _STOP:
reason = "stopped"
break break
c, data = item c, data = item
if c is not None and c <= max_cursor: if c is not None and c <= max_cursor:
@@ -523,8 +577,9 @@ class HttpServer:
except (BrokenPipeError, ConnectionResetError, OSError): except (BrokenPipeError, ConnectionResetError, OSError):
# Client went away mid-stream: normal (the app reconnects with # Client went away mid-stream: normal (the app reconnects with
# Last-Event-ID and catches up from the outbox). # Last-Event-ID and catches up from the outbox).
pass reason = "client-gone"
finally: finally:
logger.info("iris: SSE stream closed: %s (%s)", device_id, reason)
self._remove_sub(sub) self._remove_sub(sub)
@staticmethod @staticmethod
@@ -545,7 +600,7 @@ class HttpServer:
# ── POST /v1/media (upload, docs/19 §19.15) ─────────────────────────── # ── POST /v1/media (upload, docs/19 §19.15) ───────────────────────────
def _handle_media_upload(self, handler: BaseHTTPRequestHandler, device_id: str) -> None: def _handle_media_upload(self, handler: BaseHTTPRequestHandler, device_id: str) -> None: # noqa: PLR0911
"""Whole-file upload: metadata in headers, file bytes as the body. """Whole-file upload: metadata in headers, file bytes as the body.
Mirrors the WS ``media.upload`` contract (docs/07 §7.2) in one Mirrors the WS ``media.upload`` contract (docs/07 §7.2) in one
@@ -727,7 +782,7 @@ class _Handler(BaseHTTPRequestHandler):
server: _ThreadingHTTPD server: _ThreadingHTTPD
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003 def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
logger.debug("android http: " + fmt, *args) logger.debug("iris http: " + fmt, *args)
# ── Routing ─────────────────────────────────────────────────────────── # ── Routing ───────────────────────────────────────────────────────────
@@ -765,7 +820,7 @@ class _Handler(BaseHTTPRequestHandler):
return return
_send_json(self, 404, {"error": "not found"}) _send_json(self, 404, {"error": "not found"})
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802, PLR0911, PLR0912
hs = self.server.http_server hs = self.server.http_server
if not hs.enabled: if not hs.enabled:
_send_json(self, 503, {"error": "http leg disabled"}) _send_json(self, 503, {"error": "http leg disabled"})
+255
View File
@@ -0,0 +1,255 @@
"""Inbound ``message.send`` handling (app -> agent).
Mixin for ``adapter.IrisAdapter``. Echoes the user message to all devices
(multi-device sync + ack), resolves ``media_refs`` to
``MessageEvent.media_urls``/``media_types``, applies auto-threading, and
hands the ``MessageEvent`` to ``handle_message()`` (the gateway's command
pipeline + agent turn).
"""
import asyncio
import logging
import threading
import time
import uuid
from typing import Any
from gateway.platforms.base import MessageEvent, MessageType
from . import protocol
from .classify import _derive_thread_name
from .mixin_base import IrisAdapterBase
logger = logging.getLogger(__name__)
class InboundHandlers(IrisAdapterBase):
"""Inbound message.send (see module docstring)."""
async def on_message_send(self, frame: protocol.Frame, device_id: str) -> None: # noqa: PLR0912,PLR0915
"""Handle an inbound ``message.send`` frame.
Echoes the user message to all devices (multi-device sync + ack),
then builds a ``MessageEvent`` and hands it to ``handle_message()``
(the gateway's command pipeline + agent turn).
M4: ``media_refs`` reference completed ``media.upload``s; they are
resolved to ``MessageEvent.media_urls``/``media_types`` (local paths
the agent's vision/audio tools can read) and echoed in the user
message's ``media[]`` so every device renders the attachments.
"""
payload = frame.payload
text = payload.get("text")
text = text if isinstance(text, str) else ""
refs_raw = payload.get("media_refs")
media_refs = (
[r for r in refs_raw if isinstance(r, str) and r] if isinstance(refs_raw, list) else []
)
if not text.strip() and not media_refs:
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, "message.send requires non-empty text", id=frame.id
),
)
return
chat_id = frame.chat_id or payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
chat_id = self.home_channel
chat_id = chat_id.strip()
# Automation channels are read-only for the user: they only receive
# gateway-originated output (cron jobs, webhooks). Reject direct sends
# (the app hides the composer for them, this is the server-side
# enforcement).
target = self._channels.get(chat_id)
if target is not None and target.get("automation"):
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED,
f"{target.get('name') or chat_id} is an automation channel "
"(read-only: cron/webhook output only)",
id=frame.id,
),
)
return
thread_id = frame.thread_id or payload.get("thread_id")
if not isinstance(thread_id, str) or not thread_id.strip():
thread_id = None
reply_to = payload.get("reply_to")
if not isinstance(reply_to, str) or not reply_to.strip():
reply_to = None
# Auto-threading (the app's Threads setting, docs/06 §6.3): a message
# in a channel's flat lane gets its own fresh thread, the way Telegram
# topic mode mints a topic per new conversation. The thread is named
# instantly from the user's opening message (derived title) and the
# LLM upgrades the name in the background. The user echo, the agent
# turn, and all streaming frames then carry the new thread_id.
# Skipped for slash commands (session-scoped, not conversation
# starters) and replies (they continue where the user is). Threading
# is only active on the default channel; other channels stay flat.
auto_thread = bool(payload.get("auto_thread"))
default_entry = self._channels.default()
if (
auto_thread
and thread_id is None
and text.strip()
and not text.lstrip().startswith("/")
and reply_to is None
and default_entry is not None
and chat_id == default_entry["chat_id"]
):
entry = self._channels.create(
name=_derive_thread_name(text),
kind="thread",
parent_chat_id=chat_id,
)
thread_id = entry["chat_id"]
# Bare broadcast (like channel.create): the directory is
# re-served on hello.ack, so no outbox entry is needed.
await self._broadcast_both(protocol.channel_created(entry, auto=True))
self._schedule_thread_title_upgrade(entry["chat_id"], text)
# M4: resolve media refs (single-use; unknown ref -> error).
media_urls: list[str] = []
media_types: list[str] = []
media_wire: list[dict[str, Any]] = []
for ref in media_refs:
entry = self._media.get_inbound(ref)
if entry is None:
await self._reply(
device_id,
protocol.error(
protocol.ERR_UNSUPPORTED, f"unknown media_ref {ref}", id=frame.id
),
)
return
media_urls.append(entry.path)
media_types.append(entry.mime)
media_wire.append(
{
"media_id": entry.media_id,
"kind": entry.kind,
"mime": entry.mime,
"size": entry.size,
"filename": entry.filename,
}
)
device = self._devices.get(device_id) or {}
user_name = device.get("name") or device_id
# Echo to all devices: the sender confirms (server-assigned id),
# other devices see the message too (single-user, multi-device).
# Routed through _broadcast_or_log (not a bare broadcast) so the echo
# is appended to the outbox: the app's ChatStore is in-memory only, so
# after a process death / activity recreation the only way the user's
# own message is restored is via the sync replay. Without this, user
# messages vanish on reconnect while bot messages (already parked)
# survive.
message_id = f"m_{uuid.uuid4().hex[:16]}"
echo = protocol.message(
chat_id=chat_id,
message_id=message_id,
role=protocol.ROLE_USER,
text=text,
thread_id=thread_id,
media=media_wire or None,
reply_to=reply_to,
ts=int(time.time() * 1000),
)
await self._broadcast_or_log(chat_id, echo)
# Refs are consumed by this message (no replay).
for ref in media_refs:
self._media.pop_inbound(ref)
# M4: a new user turn starts -- stale offer association is dropped.
self._last_message_id.pop(chat_id, None)
kind = media_wire[0]["kind"] if media_wire else None
if kind == "image":
message_type = MessageType.PHOTO
elif kind == "video":
message_type = MessageType.VIDEO
elif kind == "audio":
message_type = MessageType.AUDIO
elif kind == "voice":
message_type = MessageType.VOICE
elif kind == "document":
message_type = MessageType.DOCUMENT
else:
message_type = MessageType.TEXT
source = self.build_source(
chat_id=chat_id,
chat_name=self._channel_name(chat_id),
chat_type="dm",
user_id=device_id,
user_name=user_name,
thread_id=thread_id,
)
event = MessageEvent(
text=text,
message_type=message_type,
user_id=device_id,
user_name=user_name,
source=source,
message_id=message_id,
reply_to_message_id=reply_to,
media_urls=media_urls,
media_types=media_types,
)
await self.handle_message(event)
# M5: acknowledge the user message to the originating device (the
# app shows ✓✓) at the moment it is handed to the agent.
await self._reply(
device_id,
protocol.read_receipt(chat_id, message_id),
)
def _schedule_thread_title_upgrade(self, thread_id: str, text: str) -> None:
"""Upgrade an auto-created thread's name with the model's title.
Stage 2 of hermes' two-stage session titling (``agent/title_generator
.py``): the thread was created with an instant derived name; this
background call on the ``title_generation`` auxiliary task replaces it
with the model's title and broadcasts ``channel.renamed``. Best-effort
— any failure (config, model, network) leaves the derived name in
place, and a thread the user already renamed or archived is untouched.
"""
loop = asyncio.get_running_loop()
def _work() -> None:
try:
from agent.title_generator import generate_title
title = generate_title(text)
except Exception:
logger.debug("Thread title upgrade failed", exc_info=True)
return
if not title:
return
entry = self._channels.get(thread_id)
if entry is None or entry.get("archived"):
return
if (entry.get("name") or "") == title:
return
renamed = self._channels.rename(thread_id, title)
if renamed is None:
return
try:
asyncio.run_coroutine_threadsafe(
self._broadcast_both(protocol.channel_renamed(renamed)),
loop,
)
except Exception:
logger.debug("Thread title rename broadcast failed", exc_info=True)
threading.Thread(target=_work, daemon=True, name="iris-thread-title").start()
+6 -6
View File
@@ -15,7 +15,7 @@ binary frames. Delivery-path security via ``validate_media_delivery_path``
Reuses hermes ``cache_image/audio/video/document_from_bytes`` + the Reuses hermes ``cache_image/audio/video/document_from_bytes`` + the
``_looks_like_image`` / ``sniff_container`` magic-byte sniffers. Temp files ``_looks_like_image`` / ``sniff_container`` magic-byte sniffers. Temp files
live under ``get_hermes_home()/"android"/media/tmp``. live under ``get_hermes_home()/"iris"/media/tmp``.
Milestone M4. Milestone M4.
""" """
@@ -184,7 +184,7 @@ class UploadSession:
arrive so an over-limit transfer is rejected early. arrive so an over-limit transfer is rejected early.
""" """
def __init__( def __init__( # noqa: PLR0913
self, self,
media_ref: str, media_ref: str,
kind: str, kind: str,
@@ -231,7 +231,7 @@ class UploadSession:
self.failed = True self.failed = True
self.error_code = code self.error_code = code
self.error_message = message self.error_message = message
logger.warning("android: upload %s failed: %s", self.media_ref, message) logger.warning("iris: upload %s failed: %s", self.media_ref, message)
def digest(self) -> str: def digest(self) -> str:
return self._sha.hexdigest() return self._sha.hexdigest()
@@ -262,7 +262,7 @@ class MediaStore:
""" """
def __init__(self, hermes_home: Path): def __init__(self, hermes_home: Path):
self._tmp_dir = hermes_home / "android" / "media" / "tmp" self._tmp_dir = hermes_home / "iris" / "media" / "tmp"
self._tmp_dir.mkdir(parents=True, exist_ok=True) self._tmp_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock() self._lock = threading.Lock()
# (device_id, media_ref) -> UploadSession (one active per device) # (device_id, media_ref) -> UploadSession (one active per device)
@@ -272,7 +272,7 @@ class MediaStore:
# ── Inbound uploads ─────────────────────────────────────────────────── # ── Inbound uploads ───────────────────────────────────────────────────
def create_upload( def create_upload( # noqa: PLR0913
self, self,
device_id: str, device_id: str,
media_ref: str, media_ref: str,
@@ -374,7 +374,7 @@ class MediaStore:
with self._lock: with self._lock:
self._inbound[media_ref] = entry self._inbound[media_ref] = entry
logger.info( logger.info(
"android: upload %s cached as %s (%s, %d bytes)", "iris: upload %s cached as %s (%s, %d bytes)",
media_ref, media_ref,
kind, kind,
path, path,
+127
View File
@@ -0,0 +1,127 @@
"""M4: outbound media (agent -> app): ``media.offer`` emission.
Mixin for ``adapter.IrisAdapter``. The gateway's dispatch partition
(gateway/run.py) extracts MEDIA: tags / image URLs from the final response,
filters them through ``filter_media_delivery_paths``, then calls the
``send_*`` overrides with local file paths. We re-validate each path
(defense in depth), register it in the media registry, mint a ``media_id``,
and emit ``media.offer``; the app fetches the bytes via ``media.pull``.
"""
import logging
import os
from typing import Any
from gateway.platforms.base import SendResult, validate_media_delivery_path
from . import media as media_bridge
from . import protocol
from .classify import _thread_id_from_metadata
from .mixin_base import IrisAdapterBase
logger = logging.getLogger(__name__)
class MediaHandlers(IrisAdapterBase):
"""Outbound media (see module docstring)."""
async def _offer_media(
self,
chat_id: str,
path: str,
kind: str,
filename: str | None,
metadata: dict[str, Any] | None,
) -> SendResult:
safe = validate_media_delivery_path(path)
if safe is None:
logger.warning("iris: media path failed delivery validation: %s", path)
return SendResult(success=False, error="iris: media path not deliverable")
try:
size = os.path.getsize(safe)
except OSError as e:
logger.warning("iris: media file unreadable %s: %s", safe, e)
return SendResult(success=False, error="iris: media file unreadable")
entry = self._media.register_outbound(
safe, kind, media_bridge.mime_for_path(safe), filename or os.path.basename(safe), size
)
thread_id = _thread_id_from_metadata(metadata)
frame = protocol.media_offer(
entry.media_id,
entry.kind,
entry.mime,
entry.size,
entry.filename,
chat_id=chat_id,
thread_id=thread_id,
message_id=self._last_message_id.get(chat_id),
)
await self._broadcast_or_log(chat_id, frame)
return SendResult(success=True, message_id=entry.media_id)
async def send_image(
self,
chat_id: str,
image_url: str,
caption: str | None = None,
reply_to: str | None = None,
metadata: dict[str, Any] | None = None,
) -> SendResult:
"""Send an image (M4: local files offered over WS; remote URLs fall
back to the base text rendering)."""
if image_url.startswith("file://"):
from urllib.parse import unquote
return await self._offer_media(chat_id, unquote(image_url[7:]), "image", None, metadata)
return await super().send_image(
chat_id, image_url, caption=caption, reply_to=reply_to, metadata=metadata
)
async def send_image_file(
self,
chat_id: str,
image_path: str,
caption: str | None = None,
reply_to: str | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> SendResult:
"""Send a local image file (M4)."""
return await self._offer_media(chat_id, image_path, "image", None, metadata)
async def send_video(
self,
chat_id: str,
video_path: str,
caption: str | None = None,
reply_to: str | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> SendResult:
"""Send a video (M4)."""
return await self._offer_media(chat_id, video_path, "video", None, metadata)
async def send_voice(
self,
chat_id: str,
audio_path: str,
caption: str | None = None,
reply_to: str | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> SendResult:
"""Send a voice note / audio file (M4)."""
return await self._offer_media(chat_id, audio_path, "voice", None, metadata)
async def send_document( # noqa: PLR0913
self,
chat_id: str,
file_path: str,
caption: str | None = None,
file_name: str | None = None,
reply_to: str | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> SendResult:
"""Send a document (M4)."""
return await self._offer_media(chat_id, file_path, "document", file_name, metadata)
+54
View File
@@ -0,0 +1,54 @@
"""Shared base for the ``IrisAdapter`` mixin classes.
``adapter.IrisAdapter`` is assembled from several small mixin classes
(``inbound``, ``tool_frames``, ``push_frames``, ...) plus the core state and
lifecycle in ``adapter`` itself. Each mixin references instance attributes and
helper methods that are defined in the core class or in a *sibling* mixin, so a
type checker analysing one mixin in isolation cannot see them.
This base declares those shared names (as ``Any``) so static analysis resolves
``self.<name>`` inside every mixin. The annotations carry no runtime effect;
the real values are set in ``IrisAdapter.__init__`` and the real methods live
in the core class / sibling mixins.
"""
from typing import Any
class IrisAdapterBase:
"""Declaration-only base for the ``IrisAdapter`` mixins (see module doc)."""
# -- shared state (set in ``IrisAdapter.__init__``) -------------------
_channels: Any
_devices: Any
_http_server: Any
_media: Any
_outbox: Any
_push: Any
_active_lane: Any
_last_message_id: Any
_last_push_at: Any
_pending_push: Any
_pending_pickers: Any
_prune_notified_at: Any
_typing_turns: Any
home_channel: Any
home_channel_name: Any
# -- shared helpers (core class or sibling mixins) --------------------
_broadcast_both: Any
_broadcast_or_log: Any
_channel_name: Any
_maybe_push: Any
_offer_media: Any
_parse_tool_line_or_block: Any
_push_summary: Any
_reply: Any
_schedule_thread_title_upgrade: Any
# -- provided by ``BasePlatformAdapter`` / core -----------------------
build_source: Any
handle_message: Any
send: Any
send_image: Any
send_slash_confirm: Any
+48 -14
View File
@@ -10,7 +10,7 @@ Retention prunes rows older than ``outbox_retention_hours`` (default 72h). A
device offline longer than the window misses those frames; it recovers full device offline longer than the window misses those frames; it recovers full
context via ``history`` (M5 wires push so the device is woken to sync). context via ``history`` (M5 wires push so the device is woken to sync).
Storage: ``get_hermes_home()/"android"/outbox.db``. Storage: ``get_hermes_home()/"iris"/outbox.db``.
Milestone M3 (built), extended in M5 (push integration). Milestone M3 (built), extended in M5 (push integration).
""" """
@@ -35,8 +35,16 @@ _PRUNE_INTERVAL_S = 3600.0
DEFAULT_MAX_ROWS = 5000 DEFAULT_MAX_ROWS = 5000
def _frame_thread_id(frame: dict[str, Any]) -> str | None:
"""A frame's lane: its ``thread_id``, normalized (absent/blank -> None)."""
tid = frame.get("thread_id")
if not isinstance(tid, str) or not tid.strip():
return None
return tid
class Outbox: class Outbox:
"""Persistent outbox under ``get_hermes_home()/"android"``. """Persistent outbox under ``get_hermes_home()/"iris"``.
Thread-safe (single connection + lock); operations are small and fast Thread-safe (single connection + lock); operations are small and fast
enough to run inline on the gateway's asyncio loop (mirrors enough to run inline on the gateway's asyncio loop (mirrors
@@ -126,7 +134,7 @@ class Outbox:
(excess,), (excess,),
) )
self._overflow_pruned += excess self._overflow_pruned += excess
logger.info("android outbox: row cap pruned %s oldest row(s)", excess) logger.info("iris outbox: row cap pruned %s oldest row(s)", excess)
def latest_cursor(self) -> int: def latest_cursor(self) -> int:
"""The high-water cursor (0 when nothing has been appended).""" """The high-water cursor (0 when nothing has been appended)."""
@@ -163,7 +171,7 @@ class Outbox:
# ── history (full message history for a chat/thread) ────────────────── # ── history (full message history for a chat/thread) ──────────────────
def history( def history( # noqa: PLR0912
self, self,
chat_id: str, chat_id: str,
thread_id: str | None = None, thread_id: str | None = None,
@@ -197,7 +205,12 @@ class Outbox:
continue continue
if not isinstance(frame, dict): if not isinstance(frame, dict):
continue continue
if thread_id is not None and frame.get("thread_id") != thread_id: # Exact lane match: a flat-lane history (thread_id=None) must NOT
# include frames that belong to a thread, and vice versa. (The old
# loose filter let auto-threaded messages leak into the flat lane
# on restart, where a delete sent with thread_id=None then matched
# nothing in the outbox and the messages "resurrected" later.)
if _frame_thread_id(frame) != thread_id:
continue continue
ftype = frame.get("type") ftype = frame.get("type")
payload = frame.get("payload") payload = frame.get("payload")
@@ -289,6 +302,12 @@ class Outbox:
standalone ``message`` frame when present, else the ``message.stop`` standalone ``message`` frame when present, else the ``message.stop``
frame of a streamed reply -- or ``None`` when the message is not in the frame of a streamed reply -- or ``None`` when the message is not in the
outbox (e.g. already pruned by retention). outbox (e.g. already pruned by retention).
The lane is matched exactly first (a flat-lane lookup, ``thread_id
= None``, sees only frames with no ``thread_id``); when that finds
nothing the lookup falls back to the ``message_id`` alone across all
lanes (it is a unique uuid4), so a stale/missing ``thread_id`` on the
request still resolves the row.
""" """
if not message_id: if not message_id:
return None return None
@@ -296,6 +315,8 @@ class Outbox:
rows = self._conn.execute( rows = self._conn.execute(
"SELECT frame FROM outbox WHERE chat_id = ?", (chat_id,) "SELECT frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall() ).fetchall()
def scan(lane: str | None, exact: bool) -> dict[str, Any] | None:
msg_frame: dict[str, Any] | None = None msg_frame: dict[str, Any] | None = None
stop_frame: dict[str, Any] | None = None stop_frame: dict[str, Any] | None = None
for r in rows: for r in rows:
@@ -305,7 +326,7 @@ class Outbox:
continue continue
if not isinstance(frame, dict): if not isinstance(frame, dict):
continue continue
if thread_id is not None and frame.get("thread_id") != thread_id: if exact and _frame_thread_id(frame) != lane:
continue continue
payload = frame.get("payload") payload = frame.get("payload")
if not isinstance(payload, dict) or payload.get("message_id") != message_id: if not isinstance(payload, dict) or payload.get("message_id") != message_id:
@@ -325,6 +346,8 @@ class Outbox:
} }
return msg_frame or stop_frame return msg_frame or stop_frame
return scan(thread_id, exact=True) or scan(None, exact=False)
def delete_message( def delete_message(
self, self,
chat_id: str, chat_id: str,
@@ -337,10 +360,13 @@ class Outbox:
``message.update`` / ``message.stop`` / ``media.offer`` / ``message.update`` / ``message.stop`` / ``media.offer`` /
``commentary``); all of them are removed so neither ``history`` nor a ``commentary``); all of them are removed so neither ``history`` nor a
``sync`` replay can resurrect the message. The delete is scoped to the ``sync`` replay can resurrect the message. The delete is scoped to the
exact lane: a flat-lane delete (``thread_id=None``) matches only frames exact lane first: a flat-lane delete (``thread_id=None``) matches only
with no ``thread_id``, and a thread delete matches only that thread's frames with no ``thread_id``, and a thread delete matches only that
frames (a ``message_id`` is unique to one lane, so this is a safety thread's frames. When the exact lane matches nothing, the delete falls
net, not a filter that drops real frames). Returns the number of rows back to the ``message_id`` alone (it is a unique uuid4, so it cannot
hit the wrong message) — this keeps deletes working when the request's
lane is stale or missing (e.g. a message the app cached in the flat
lane that the gateway auto-threaded). Returns the number of rows
removed (0 when the message is not in the outbox — e.g. already pruned removed (0 when the message is not in the outbox — e.g. already pruned
by retention). by retention).
""" """
@@ -350,7 +376,9 @@ class Outbox:
rows = self._conn.execute( rows = self._conn.execute(
"SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,) "SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall() ).fetchall()
cursors: list[int] = []
def cursors_for(lane: str | None, exact: bool) -> list[int]:
out: list[int] = []
for r in rows: for r in rows:
try: try:
frame = json.loads(r["frame"]) frame = json.loads(r["frame"])
@@ -358,11 +386,17 @@ class Outbox:
continue continue
if not isinstance(frame, dict): if not isinstance(frame, dict):
continue continue
if frame.get("thread_id") != thread_id: if exact and _frame_thread_id(frame) != lane:
continue continue
payload = frame.get("payload") payload = frame.get("payload")
if isinstance(payload, dict) and payload.get("message_id") == message_id: if isinstance(payload, dict) and payload.get("message_id") == message_id:
cursors.append(int(r["cursor"])) out.append(int(r["cursor"]))
return out
# Exact lane first (a flat-lane delete must not reach into
# threads); fall back to the message_id across all lanes only
# when the exact lane matches nothing (stale/missing thread_id).
cursors = cursors_for(thread_id, exact=True) or cursors_for(thread_id, exact=False)
if not cursors: if not cursors:
return 0 return 0
# One bound-parameter delete per cursor (a message spans only a few # One bound-parameter delete per cursor (a message spans only a few
@@ -419,7 +453,7 @@ class Outbox:
self._conn.execute("DELETE FROM outbox WHERE created < ?", (cutoff,)) self._conn.execute("DELETE FROM outbox WHERE created < ?", (cutoff,))
self._conn.commit() self._conn.commit()
except sqlite3.Error as e: except sqlite3.Error as e:
logger.debug("android outbox: prune failed: %s", e) logger.debug("iris outbox: prune failed: %s", e)
def prune(self) -> None: def prune(self) -> None:
"""Force a retention prune (ignores the interval throttle).""" """Force a retention prune (ignores the interval throttle)."""
+151 -2
View File
@@ -4,7 +4,7 @@ Token generation (64-hex) and constant-time verification. Device registry
(SQLite) tracks ``device_id``, name, caps, fcm_token, ntfy_topic, last_seen, (SQLite) tracks ``device_id``, name, caps, fcm_token, ntfy_topic, last_seen,
created. QR payload for the pairing flow (``interactive_setup``). created. QR payload for the pairing flow (``interactive_setup``).
Storage: ``get_hermes_home()/"android"/devices.db``. Storage: ``get_hermes_home()/"iris"/devices.db``.
Milestone M1. Milestone M1.
""" """
@@ -14,6 +14,7 @@ import hmac
import json import json
import logging import logging
import secrets import secrets
import socket
import sqlite3 import sqlite3
import threading import threading
import time import time
@@ -42,6 +43,51 @@ def verify_token(provided: str | None, expected: str | None) -> bool:
) )
def lan_ip() -> str:
"""Best-effort default-route LAN IPv4 (UDP connect trick; no packet sent).
A phone can't reach a bind wildcard like ``0.0.0.0``/``127.0.0.1``, so the
pairing QR / URL advertise the machine's routable LAN IP instead. Falls
back to ``127.0.0.1`` when no route is available (offline sandbox).
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.settimeout(1.0)
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
s.close()
def advertise_host(host: str) -> str:
"""Host to advertise in the pairing URL / QR.
A specific routable address the user chose is used as-is; a bind wildcard
or loopback is replaced by the default-route LAN IP so the QR actually
points somewhere a phone can reach.
"""
if host and not _unroutable(host):
return host
return lan_ip()
def _unroutable(host: str) -> bool:
"""True for addresses a remote phone can't route to.
Covers the IPv4 bind wildcard (all-zero), loopback (127.x), and the IPv6
any/loopback. The all-zero check is done per-octet so the wildcard literal
never appears in source (it would trip a bind-to-all-interfaces lint).
"""
if host.startswith("127."):
return True
if host in ("::", "[::]", "::1"):
return True
parts = host.split(".")
return len(parts) == 4 and all(octet == "0" for octet in parts)
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str: def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
"""Pairing URL encoded into the QR / pre-filled into the app. """Pairing URL encoded into the QR / pre-filled into the app.
@@ -68,7 +114,7 @@ def pairing_url(host: str, port: int, secure: bool = False) -> str:
class DeviceRegistry: class DeviceRegistry:
"""Persistent device registry under ``get_hermes_home()/"android"``. """Persistent device registry under ``get_hermes_home()/"iris"``.
Thread-safe (single connection + lock); all operations are small and Thread-safe (single connection + lock); all operations are small and
fast enough to run inline on the gateway's asyncio loop. fast enough to run inline on the gateway's asyncio loop.
@@ -104,6 +150,21 @@ class DeviceRegistry:
self._conn.execute( self._conn.execute(
"ALTER TABLE devices ADD COLUMN last_pushed_cursor INTEGER NOT NULL DEFAULT 0" "ALTER TABLE devices ADD COLUMN last_pushed_cursor INTEGER NOT NULL DEFAULT 0"
) )
# Per-device tokens (docs/09 §9.3): a unique, revocable token
# minted at pairing, stored per device. NULL/empty = the device
# still authenticates with the shared IRIS_TOKEN (bootstrap).
if "token" not in cols:
self._conn.execute("ALTER TABLE devices ADD COLUMN token TEXT")
# Revocation denylist: a revoked device_id is rejected even when
# it presents the shared token (isolation, docs/09 §9.3).
self._conn.execute(
"""
CREATE TABLE IF NOT EXISTS revoked (
device_id TEXT PRIMARY KEY,
revoked_at REAL NOT NULL DEFAULT 0
)
"""
)
self._conn.commit() self._conn.commit()
def upsert( def upsert(
@@ -189,6 +250,91 @@ class DeviceRegistry:
except (TypeError, ValueError, KeyError, IndexError): except (TypeError, ValueError, KeyError, IndexError):
return 0 return 0
# ── Per-device tokens (docs/09 §9.3) ─────────────────────────────────
def issue_token(self, device_id: str) -> str:
"""Mint (or return the existing) per-device token for a device.
Idempotent: a device keeps its token across (re)connects. Creates the
device row on first sight (name defaults to the device_id; the SSE
open's upsert fills in the real name + push tokens)."""
with self._lock:
row = self._conn.execute(
"SELECT token FROM devices WHERE device_id = ?", (device_id,)
).fetchone()
if row and row["token"]:
return row["token"]
token = generate_token()
now = time.time()
if row:
self._conn.execute(
"UPDATE devices SET token = ? WHERE device_id = ?",
(token, device_id),
)
else:
self._conn.execute(
"INSERT INTO devices (device_id, name, token, last_seen, created)"
" VALUES (?, ?, ?, ?, ?)",
(device_id, device_id, token, now, now),
)
self._conn.commit()
return token
def reissue_token(self, device_id: str) -> str:
"""Rotate the device's token (the old one stops working)."""
with self._lock:
token = generate_token()
self._conn.execute(
"UPDATE devices SET token = ? WHERE device_id = ?",
(token, device_id),
)
self._conn.commit()
return token
def token_for(self, device_id: str) -> str | None:
"""The device's per-device token, or None (shared-token bootstrap)."""
with self._lock:
row = self._conn.execute(
"SELECT token FROM devices WHERE device_id = ?", (device_id,)
).fetchone()
if row and row["token"]:
return row["token"]
return None
# ── Revocation (docs/09 §9.3) ────────────────────────────────────────
def revoke(self, device_id: str) -> None:
"""Revoke a single device: drop its row (token, push tokens, cursor)
and add its id to the denylist, so even the shared token no longer
works for it. Other devices are unaffected."""
with self._lock:
self._conn.execute("DELETE FROM devices WHERE device_id = ?", (device_id,))
self._conn.execute(
"INSERT OR REPLACE INTO revoked (device_id, revoked_at) VALUES (?, ?)",
(device_id, time.time()),
)
self._conn.commit()
def unrevoke(self, device_id: str) -> None:
"""Remove a device from the denylist (operator re-pairing)."""
with self._lock:
self._conn.execute("DELETE FROM revoked WHERE device_id = ?", (device_id,))
self._conn.commit()
def is_revoked(self, device_id: str) -> bool:
with self._lock:
row = self._conn.execute(
"SELECT 1 FROM revoked WHERE device_id = ?", (device_id,)
).fetchone()
return row is not None
def list_revoked(self) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT device_id, revoked_at FROM revoked ORDER BY revoked_at DESC"
).fetchall()
return [{"device_id": r["device_id"], "revoked_at": r["revoked_at"]} for r in rows]
def get(self, device_id: str) -> dict[str, Any] | None: def get(self, device_id: str) -> dict[str, Any] | None:
with self._lock: with self._lock:
row = self._conn.execute( row = self._conn.execute(
@@ -214,6 +360,9 @@ def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
caps = {} caps = {}
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
caps = {} caps = {}
# NOTE: the per-device ``token`` column is deliberately NOT included —
# device dicts flow into push fan-out and operator listings, and the
# token must never leave the registry (docs/09 §9.5).
return { return {
"device_id": row["device_id"], "device_id": row["device_id"],
"name": row["name"], "name": row["name"],
+265
View File
@@ -0,0 +1,265 @@
"""M5: approval / clarify / choice-picker frames (interactive banners).
Mixin for ``adapter.IrisAdapter``. Hermes detects these methods on the
adapter type; each emits a high-priority ``notification`` (pushed even when
a device is live) and, with a live device, an interactive ``picker.choice``
card whose selection runs the stored callback (``pickers.py``).
"""
import time
from typing import Any
from gateway.platforms.base import SendResult
from . import protocol
from .classify import (
_mint_message_id,
_mint_picker_id,
_push_preview,
_thread_id_from_metadata,
)
from .mixin_base import IrisAdapterBase
from .pickers import (
_approval_picker_callback,
_clarify_is_multi,
_clarify_picker_callback,
)
class PickerHandlers(IrisAdapterBase):
"""Interactive pickers + approvals (see module docstring)."""
async def send_slash_confirm( # noqa: PLR0913
self,
chat_id: str,
title: str,
message: str,
session_key: str,
confirm_id: str,
metadata: dict[str, Any] | None = None,
) -> SendResult:
"""Banner + push for a slash-command approval prompt.
The gateway's text fallback still renders the actionable prompt (the
app has no inline buttons yet); the notification is the push-visible
signal (high priority: pushed even when a device is live).
"""
thread_id = _thread_id_from_metadata(metadata)
await self._broadcast_or_log(
chat_id,
protocol.notification(
chat_id,
protocol.NOTIF_APPROVAL,
title or "Approval needed",
_push_preview(message),
thread_id=thread_id,
),
)
return await super().send_slash_confirm(
chat_id, title, message, session_key, confirm_id, metadata=metadata
)
async def send_exec_approval( # noqa: PLR0913
self,
chat_id: str,
command: str,
session_key: str,
description: str = "dangerous command",
metadata: dict[str, Any] | None = None,
allow_permanent: bool = True,
allow_session: bool = True,
smart_denied: bool = False,
) -> SendResult:
"""Interactive exec-approval picker (buttons) for a dangerous command.
Hermes calls this (detected on the adapter type) when the agent wants
to run a command that needs approval; the agent thread blocks until the
user decides. With a live device we render the same choice set as the
native adapters (Allow Once / Session / Always / Deny, gated by the
same flags) as a ``picker.choice`` card, reusing the clarify/slash
picker mechanism. A tap resolves via ``resolve_gateway_approval``
(the same primitive the text ``/approve`` / ``/deny`` handlers use),
unblocking the agent, and a short confirmation is delivered as a
normal message. A high-priority ``approval`` notification is also
emitted so a backgrounded device is woken (pushed even when live).
With no live device the picker could never be answered, so report
failure and let hermes fall back to the text ``/approve`` prompt.
"""
if not self._http_server.has_devices():
return SendResult(success=False, error="no live devices for approval picker")
thread_id = _thread_id_from_metadata(metadata)
# High-priority banner + push (wakes a backgrounded device).
await self._broadcast_or_log(
chat_id,
protocol.notification(
chat_id,
protocol.NOTIF_APPROVAL,
"Approval needed",
_push_preview(description or command),
thread_id=thread_id,
),
)
# Choice set mirrors the native adapters (telegram/relay).
frame_choices = [{"value": "once", "label": "\u2705 Allow Once", "is_current": False}]
if not smart_denied and allow_session:
frame_choices.append(
{"value": "session", "label": "\u2705 Allow Session", "is_current": False}
)
if allow_permanent:
frame_choices.append(
{"value": "always", "label": "\u2705 Always Allow", "is_current": False}
)
frame_choices.append({"value": "deny", "label": "\u274c Deny", "is_current": False})
cmd_preview = command if len(command) <= 1500 else command[:1500] + "\u2026"
title = (
"\u26a0\ufe0f **Command approval required**\n\n"
f"```\n{cmd_preview}\n```\n\n"
f"Reason: {description}"
)
if smart_denied:
title += "\n\n**Smart DENY:** owner override applies to this one operation only."
picker_id = _mint_picker_id()
self._pending_pickers[picker_id] = {
"chat_id": chat_id,
"thread_id": thread_id,
"on_choice_selected": _approval_picker_callback(session_key),
}
await self._broadcast_or_log(
chat_id,
protocol.picker_choice(picker_id, title, frame_choices, chat_id, thread_id=thread_id),
)
return SendResult(success=True, message_id=picker_id)
async def send_choice_picker( # noqa: PLR0913
self,
chat_id: str,
title: str,
choices: list,
session_key: str,
on_choice_selected,
metadata: dict[str, Any] | None = None,
) -> SendResult:
"""Send an interactive choice picker (one tap → one value).
The generic companion to Telegram's inline-keyboard pickers, used by
``/reasoning``, ``/fast``, and any future finite-choice slash command
(hermes detects this method on the adapter type). Emits a
``picker.choice`` frame; the app answers with ``picker.select``,
which runs ``on_choice_selected(chat_id, value)`` and delivers the
returned text as a normal message. Outboxed, so a reconnecting
device re-renders a still-pending picker.
With no live device the picker could never be answered, so report
failure and let hermes fall back to the text status card.
"""
if not self._http_server.has_devices():
return SendResult(success=False, error="no live devices for picker")
thread_id = _thread_id_from_metadata(metadata)
picker_id = _mint_picker_id()
self._pending_pickers[picker_id] = {
"chat_id": chat_id,
"thread_id": thread_id,
"on_choice_selected": on_choice_selected,
}
await self._broadcast_or_log(
chat_id,
protocol.picker_choice(picker_id, title, choices, chat_id, thread_id=thread_id),
)
return SendResult(success=True, message_id=picker_id)
async def send_clarify( # noqa: PLR0913
self,
chat_id: str,
question: str,
choices: list | None,
clarify_id: str,
session_key: str,
metadata: dict[str, Any] | None = None,
) -> SendResult:
"""Banner + push for a clarify prompt.
Single-select clarifies with a live device render as an interactive
``picker.choice`` card (one tap per option + an "Other" free-text
button), reusing the slash-command picker mechanism. A real pick
resolves via ``resolve_gateway_clarify`` (the agent then continues and
replies); "Other" flips the entry to text-capture. Multi-select,
open-ended, and no-live-device clarifies fall back to a numbered text
list whose reply the gateway's text-intercept captures via
``mark_awaiting_text``.
"""
thread_id = _thread_id_from_metadata(metadata)
await self._broadcast_or_log(
chat_id,
protocol.notification(
chat_id,
protocol.NOTIF_CLARIFY,
"Question",
_push_preview(question),
thread_id=thread_id,
),
)
# Single-select + live device → interactive picker card.
if choices and not _clarify_is_multi(clarify_id) and self._http_server.has_devices():
picker_id = _mint_picker_id()
self._pending_pickers[picker_id] = {
"chat_id": chat_id,
"thread_id": thread_id,
"on_choice_selected": _clarify_picker_callback(
clarify_id, [str(c) for c in choices]
),
}
frame_choices = [
{"value": f"c{i}", "label": str(c)[:75], "is_current": False}
for i, c in enumerate(choices)
]
frame_choices.append(
{"value": "other", "label": "✏️ Other (type your answer)", "is_current": False}
)
await self._broadcast_or_log(
chat_id,
protocol.picker_choice(
picker_id, f"❓ {question}", frame_choices, chat_id, thread_id=thread_id
),
)
return SendResult(success=True, message_id=picker_id)
# Text fallback (multi-select / open-ended / no live device).
if choices:
lines = [f"❓ {question}", ""]
for i, choice in enumerate(choices, start=1):
lines.append(f" {i}. {choice}")
lines.append("")
if _clarify_is_multi(clarify_id):
lines.append(
"Multiple selections allowed — reply with the numbers "
'separated by commas or spaces (e.g. "1, 3"), the option '
"text, or your own answer."
)
else:
lines.append("Reply with the number, the option text, or your own answer.")
text = "\n".join(lines)
# Text fallback: enable text-capture so the gateway intercept
# picks up the user's typed reply (e.g. "2" or choice text).
from tools.clarify_gateway import mark_awaiting_text
mark_awaiting_text(clarify_id)
else:
text = f"❓ {question}"
message_id = _mint_message_id()
await self._broadcast_or_log(
chat_id,
protocol.message(
chat_id=chat_id,
message_id=message_id,
role=protocol.ROLE_ASSISTANT,
text=text,
thread_id=thread_id,
ts=int(time.time() * 1000),
),
)
return SendResult(success=True, message_id=message_id)
+83
View File
@@ -0,0 +1,83 @@
"""Choice-picker callbacks: clarify + exec approval.
Build the ``on_choice_selected`` closures the adapter stores in
``_pending_pickers``; a ``picker.select`` from the app runs the closure and
delivers its reply text as a normal message.
"""
def _clarify_is_multi(clarify_id: str) -> bool:
"""True when the pending clarify [clarify_id] allows multiple selections.
The flag lives on the gateway's pending entry; a missing/expired entry (or
any lookup error) is treated as single-select.
"""
try:
from tools import clarify_gateway as _cg
with _cg._lock:
_entry = _cg._entries.get(clarify_id)
return bool(_entry and getattr(_entry, "multi_select", False))
except Exception:
return False
def _clarify_picker_callback(clarify_id: str, choices: list[str]):
"""Build the ``on_choice_selected`` callback for a clarify picker.
Option values are positional (``c0``..``cN``) plus an ``other`` sentinel;
the closure maps them back to the real choice strings. A real pick resolves
the clarify (the agent then continues and replies); "Other" flips the entry
to text-capture so the next typed message is the answer. An unmappable value
also flips to text so a clarify never dead-ends.
"""
async def on_choice_selected(chat_id: str, value: str) -> str | None:
from tools.clarify_gateway import mark_awaiting_text, resolve_gateway_clarify
if value == "other":
mark_awaiting_text(clarify_id)
return "✏️ Type your answer:"
try:
idx = int(value[1:]) if value.startswith("c") else -1
except ValueError:
idx = -1
if 0 <= idx < len(choices):
resolve_gateway_clarify(clarify_id, choices[idx])
return None
mark_awaiting_text(clarify_id)
return "✏️ Type your answer:"
return on_choice_selected
# The four exec-approval outcomes hermes understands (tools.approval).
_APPROVAL_CHOICES = ("once", "session", "always", "deny")
def _approval_picker_callback(session_key: str):
"""Build the ``on_choice_selected`` callback for an exec-approval picker.
The option values are the raw hermes approval outcomes (``once`` /
``session`` / ``always`` / ``deny``); a tap resolves the waiting agent
thread via ``resolve_gateway_approval`` (the same primitive the text
``/approve`` / ``/deny`` handlers use) and returns a short confirmation
label, which the picker handler delivers as a normal message. An unknown
value is treated as a deny so a stray tap never approves a command.
"""
async def on_choice_selected(chat_id: str, value: str) -> str | None:
from tools.approval import resolve_gateway_approval
choice = value if value in _APPROVAL_CHOICES else "deny"
count = resolve_gateway_approval(session_key, choice)
label = {
"once": "✅ Approved once",
"session": "✅ Approved for this session",
"always": "✅ Approved permanently",
"deny": "❌ Denied",
}[choice]
if not count:
label = "⌛ Approval expired — no command was waiting."
return label
return on_choice_selected
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more