45 Commits
Author SHA1 Message Date
ARIA 2c20b1c8a5 fix(http): don't let a half-open TLS connection wedge the accept loop
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 8m18s
CI / Gateway plugin tests (pull_request) Failing after 15m7s
A client that completes TCP but vanishes mid-TLS-handshake (e.g. a
phone losing its network/VPN while traveling) blocked
ssl.SSLSocket.accept() inside serve_forever forever: the gateway
stopped accepting any new device connections (the app could not
reconnect), and on the next restart httpd.shutdown() froze the whole
event loop until the shutdown watchdog killed the process (ARIA
journal 2026-09-11 / 2026-09-23).

- Move the TLS handshake out of the accept loop: it now runs in the
  per-connection thread under a hard timeout (HANDSHAKE_TIMEOUT_S,
  10 s); a failed/timed-out handshake just closes the socket.
- stop() no longer blocks the event loop: shutdown()/server_close()/
  join run in an executor under asyncio.wait_for(10 s); if the bound
  expires the daemon threads are abandoned.
- Regression test: a silent half-open TCP connection must not stop
  fresh TLS connections from being served, and stop() must stay
  bounded.
2026-09-23 15:10:16 +02:00
ARIA 8657e6afc6 fix(http): test acquire_scoped_lock's bool, not the always-truthy tuple
CI / Kotlin tests (android host + desktop) (push) Successful in 5m48s
CI / Gateway plugin tests (push) Successful in 7m40s
acquire_scoped_lock returns (acquired, existing_record); the old
'if not acquire_scoped_lock(...)' tested the tuple, which is always
truthy, so the 'port in use by another profile' pre-check never fired
and a conflict surfaced as a generic bind failure. Unpack and test the
first element, matching gateway/platforms/base.py's canonical usage.

Bump VERSION / plugin.yaml to 0.1.3.
2026-08-31 23:17:30 +02:00
ARIA 0f5b5a16ab Fix gateway advertising 'unknown' version in production installs
CI / Gateway plugin tests (push) Successful in 5m56s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m29s
hermes plugins install <repo>#gateway-plugin ships ONLY the
gateway-plugin/ subdirectory into ~/.hermes/plugins/iris, so the
repo-root VERSION file is not present there and plugin_version()
fell back to 'unknown' — the app then showed a false
'App and gateway versions differ' warning.

- version.py: resolution chain repo-root VERSION (dev/symlink
  install) -> plugin.yaml version field (production install) ->
  'unknown'; stdlib-only parse, base= param for tests
- plugin.yaml: bump stale version 0.1.0 -> 0.1.2 (matches VERSION)
- scripts/check_version_sync.sh + pre-commit hook: fail commits
  where plugin.yaml drifts from the repo-root VERSION
- tests: regression test simulating the production layout
2026-08-27 09:33:31 +02:00
ARIA fb980d12b4 Release version management: single VERSION file as source of truth
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m0s
- VERSION at repo root (0.1.2); bump it to cut a release
- App: generated AppVersion.kt (config-cache-safe Gradle task with
  VERSION as declared input) shown in Settings; sent to the gateway
  via X-Iris-App-Version header on the SSE open
- Gateway: reports its own version in hello.ack server_caps.app_version
  (read from the repo-root VERSION via the plugin symlink); stores the
  app's version in the device registry caps (merge, not overwrite, so
  an old app reconnecting without the header doesn't wipe it)
- Settings: app + gateway version rows, mismatch hint, and a best-effort
  Gitea latest-release check (ReleaseCheck) with an 'update available' hint
- Release workflow: reads VERSION from the repo (no manual input), with
  a guard against an empty file
- Docs: frames.schema.json + 04-wire-protocol.md updated for app_version
2026-08-25 14:42:39 +02:00
ARIA f3f1b37221 Fix iris setup: embed generated token in the pairing URL/QR
CI / Gateway plugin tests (push) Successful in 5m23s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m53s
interactive_setup() generated a fresh IRIS_TOKEN and saved it to .env,
but the local `token` variable was never updated, so the pairing URL and
QR payload were built with an empty token (token=). The gateway accepted
the saved token, but the app never received it, so pairing was impossible.

Assign the generated value back to `token` so the pairing URL/QR carry it.
Add a regression test (test_interactive_setup_generates_token_in_pairing_url).
2026-08-25 13:42:46 +02:00
ARIA 6f339330c5 Move gateway-plugin tests out of the installable tree; clean plugin scan
CI / Gateway plugin tests (push) Successful in 5m13s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m43s
The install-time security scanner scans the whole plugin directory and
flagged the test/dev fixtures (hardcoded tokens, /tmp paths, and the
~/.hermes/.env literal in setup.py) as DANGEROUS, blocking installs with
"19 findings".

- Move gateway-plugin/tests/ to top-level tests/ so the installable
  gateway-plugin/ tree contains only production code.
- Update _plugin_dir() in the tests and REPO in e2e.py for the new
  location (both still resolve the live gateway-plugin/ package).
- Update all references: docs, CI-SETUP.md, Gitea workflows, .pi-lens.json.
- Build the hermes .env path at runtime in setup.py via get_hermes_home()
  so the scanner no longer matches the literal ~/.hermes/.env.

Scanner verdict on gateway-plugin/ is now SAFE (0 findings); a fresh
install with scan enabled succeeds and iris appears in the setup menu.
2026-08-25 13:26:12 +02:00
ARIA 573291fc1e threads: order topic chips newest-first beneath General
CI / Gateway plugin tests (push) Successful in 5m17s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
The gateway already stored a created timestamp per channel/thread but
never sent it over the wire. Now:

- protocol.py: _channel_payload() includes created (unix seconds)
- Protocol.kt: ChannelInfo.created (default 0.0 for legacy gateways)
- ChatScreen: topic switcher sorts threads created-desc (newest right
  after General, swipe new -> old), name as tie-break
- frames.schema.json: document the created field
- ChannelCreatedWireTest: wire deserialization + ordering tests
2026-08-25 11:22:00 +02:00
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 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 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
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 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 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 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 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
ARIA e6015033b6 HTTP transport: drop WS server, offline send queue + dead-stream watchdog
CI / Gateway plugin tests (push) Successful in 5m9s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m55s
Gateway (docs/19):
- Remove ws_server.py; frame dispatch factored into dispatch.py
- http_server: media upload/pull, pairing over HTTP
- protocol: media frames mirrored; tests + ws_probe updated for HTTP

App:
- HttpGateway: postFrame/uploadMedia/pullMedia no longer throw on
  network failure (PostResult ok=false / Result.failure) — uncaught
  SocketTimeoutException on Dispatchers.Default crashed the app
- GatewayClient: dead-stream watchdog (health probe every 10s, 2
  failures -> redial in ~20s instead of the 45s SSE read timeout);
  state flips to Reconnecting when the stream dies, restored from the
  last hello.ack on long-poll success; poke() + backoff reset on app
  resume (MainActivity.onResume)
- Offline sends: composer enabled while disconnected; a send with no
  response (status 0) stays queued (Pending) and is auto-resent on the
  next (re)connect after a 2s outbox-replay grace; gateway 4xx
  rejections fail the bubble (tap to retry, no auto-loop)
- ChatStore: echo-replace and thread-relocate also match Failed
  bubbles (POST response lost in a network drop); loadHistory dedupes
  local failed bubbles the server already has; failMessage()
- MainActivity: poke() on resume so a backgrounded app reconnects
  promptly instead of waiting out the backoff
2026-08-22 20:10:05 +02:00
ARIA 2349a95dd4 HTTP fallback leg (docs/19): e2e scenario 13 + docs
- e2e.py: scenario 13 (http fallback) — drives a full turn over the
  HTTP leg (health + POST /v1/frame + SSE /v1/events, no WS) and
  asserts the user echo lands on the SSE stream in < 1.5 s.
- ws_probe.py --http: prints '== user echo in X.XXs' (the docs/19
  sendable-in-fallback timing assertion) alongside the existing
  health/POST/SSE output; same assertion flags as the WS leg.
- docs: 19 status flipped to implemented; 09-pairing-security §9.4
  cross-reference (second door, same lock: token + device allowlist,
  64 KiB cap, rate limit, optional TLS, unauthenticated /v1/health);
  13-testing manual scenario 15 + automated pointers.
2026-08-22 14:34:14 +02:00
ARIA e5c7d690b8 HTTP fallback leg (docs/19): POST /v1/frame + SSE /v1/events + long-poll
Second, short-lived-connection transport next to the WS: same frames,
same outbox/cursor, same token, served over plain HTTP (stdlib
ThreadingHTTPServer bridged into the asyncio loop; zero new deps).

- http_server.py: /v1/health (unauthenticated), POST /v1/frame
  (accept-and-ack; validation rejections as 4xx error frames), SSE
  /v1/events (outbox catch-up with id=cursor, event: hello, 15s
  heartbeat, bounded-queue backpressure), long-poll /v1/poll (25s hold).
  Bearer token + X-Iris-Device (same allowlist as WS hello), 64 KiB body
  cap, per-device rate limit, optional TLS, non-fatal bind failure.
- ws_server.py: inbound dispatch chain extracted to shared
  dispatch_frame() used by both transports.
- adapter.py: ANDROID_HTTP_PORT/CERT/KEY config; start/stop next to the
  WS; delivery counting in _broadcast_or_log (an SSE subscriber is a
  live subscriber -> no push, docs/19 19.8); _reply() routes
  point-to-point replies into the in-flight HTTP response (reply sink)
  or broadcasts when the device has no live WS (19.7); status/typing/
  channel events fan out to both transports.
- ws_probe.py: --http mode (health + POST + SSE turn drive, same
  assertion flags); tests/README updated.
- Tests: hermes-agent/tests/gateway/test_android_http.py (23 tests,
  incl. the 19.8 delivery-counting regression); test_android.py (74)
  still green.
2026-08-22 14:10:14 +02:00
ARIA 6591d7cec0 Gateway restart notices: explicit status{restarting} signal, correct timing
CI / Gateway plugin tests (push) Successful in 4m22s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m41s
The app previously showed 'Gateway restarting' on every connection loss.
Now the gateway broadcasts status{state=restarting} on its shutdown path
(before closing the sockets), and the app:

- posts 'Gateway restarting' immediately on that frame (not on the
  socket-drop transition, which lags by the ~20s WS ping timeout)
- posts 'Gateway online' on the next reconnect only when the restart
  notice was posted (latch) - a plain network drop shows neither, just
  the reconnect banner
- drops the 'Gateway is restarting...' banner (replaced by the chat notice)

Docs (04-wire-protocol, frames.schema.json) updated: restarting is no
longer reserved. Test for the disconnect broadcast added to the local
hermes-agent test mirror (git-ignored, not committed).
2026-08-22 11:31:10 +02:00
ARIA 7309158e12 Add Gitea CI + release workflows (CI-SETUP.md)
CI / Gateway plugin tests (push) Failing after 2m6s
CI / Kotlin tests (android host + desktop) (push) Failing after 4m51s
- .gitea/workflows/ci.yml: gateway plugin tests + Kotlin host tests on
  push/PR (hermes-agent cloned at pinned SHA, vendored test mirror)
- .gitea/workflows/release.yml: manual dispatch; runs tests, builds signed
  Android APK (debug fallback) + Linux desktop app-image/deb via jpackage,
  creates Gitea release v<version> with artifacts (re-run safe)
- androidApp: versionName from -PappVersion, CI release signing from
  ANDROID_KEYSTORE_* env vars
- desktopApp: jpackage --app-version from -PappVersion
- scripts/make_release_keystore.sh: one-time keystore + Gitea secret setup
- vendor gateway-plugin/tests/test_android.py (byte-identical mirror) and
  ignore it in .pi-lens.json
2026-08-22 03:32:07 +02:00
ARIA 9a519e3c5a Omit null media in history frames (schema-conformant)
history() wrote "media": null for streaming finals, but the frame
schema says array. The app's HistoryMessage.media was non-nullable, so
deserialization threw and every history page was silently dropped.
Omit the key when media is absent instead.
2026-08-21 20:56:16 +02:00
ARIA 17bf41a0b9 Make thread/channel/message deletion complete (hard delete)
Deleting a thread, channel, or message was a no-op/soft-delete: messages
were only dropped from the plugin outbox (still in hermes' session store,
hence searchable/recoverable) and channels/threads were merely archived.

Now deletion is complete and non-recoverable, with no search trace:

- purge.py (new): hard-delete from hermes' session store (state.db).
  delete_lane wipes a channel's/thread's sessions + messages; deleting a
  messages row also drops it from the FTS5 index via the delete triggers.
  delete_message removes one message, matched by (session, role, exact
  content, closest timestamp) since plugin m_<hex> ids aren't persisted.
- channels.py: delete() hard-deletes the row (and a channel's child
  threads) instead of archiving.
- outbox.py: add delete_lane() (wipe all frames for a lane) and
  message_info() (read a message's final role/text/ts for the match).
- adapter.py: on_channel_delete wipes outbox + session store;
  on_message_delete purges the session-store row per message.
- App: delete confirmations no longer claim history stays for search;
  ChannelStore removes a channel's threads on channel delete.
- Docs updated to describe hard deletion.
2026-08-21 19:47:55 +02:00
ARIA 9286937e2d Runtime footer: app-controlled model/context/cwd/latency/cost under replies
Hermes can append a text "runtime footer" (model, context %, workdir,
latency, cost) to final replies, but only when display.runtime_footer is
enabled in the hermes config. We want the same info but controlled by the
APP, not the gateway config. So the gateway now ALWAYS sends the data as a
structured `runtime` object on final assistant messages, and the app decides
whether/what to show.

Gateway (gateway-plugin/):
- protocol.py: new runtime_footer() helper + `runtime` field on the
  message / message.stop frames. Keys (all optional, absent when the data
  is unavailable — e.g. no cost for local models): model (vendor prefix
  dropped), context_pct (0-100), cwd (home-relative), latency (seconds),
  cost (USD).
- adapter.py: a post_api_request plugin hook captures the turn's model +
  prompt tokens + start time (platform-filtered to android so other
  platforms don't pollute the buffer). _build_runtime_footer() resolves the
  model's context window (cached, best-effort, off the event loop via
  asyncio.to_thread with a timeout) and computes context_pct. The runtime
  object is attached on every final send (streaming message.stop and
  non-streaming message, plus the fallback paths).
- outbox.py: `runtime` preserved in history reconstruction so the footer
  survives a restart / first open.

App (app/shared/):
- Protocol.kt: RuntimeMeta data class + `runtime` on MessagePayload /
  MessageStopPayload / HistoryMessage.
- ChatStore.kt: `runtime` on MessageItem, wired through live + history
  reconciliation.
- SecureStore.kt (+ Android/Desktop actuals): runtimeFooterEnabled +
  runtimeFooterFields (persisted per device).
- IrisController.kt: StateFlows + toggleRuntimeFooter() /
  toggleRuntimeField(); RUNTIME_FIELD_KEYS / default set / parser.
- SettingsScreen.kt: "Runtime footer" switch; when on, an expandable chip
  menu (Model · Context % · Workdir · Latency · Cost) to pick fields.
- ChatScreen.kt: footer rendered on the SAME line as the timestamp (footer
  left, time right, Telegram-style), only for final non-streaming assistant
  answers; Inspector pane now shows the runtime fields too.

Docs: 04-wire-protocol.md + frames.schema.json document the `runtime`
object.

Verified end-to-end on device: final replies carry
`qwen3.8-27B-exl3-4.5bpw · 53% · ~ · 38s` with the time right-aligned on
the same line; 69/69 gateway tests pass, Kotlin builds + tests pass.
2026-08-21 19:32:05 +02:00
ARIA 678c0344c8 Clean up lint/LSP across gateway, Android, and desktop (alpha -> stable)
Gateway (gateway-plugin/):
- Fix interactive_setup broken imports: print helpers were imported from the
  wrong hermes module (hermes_cli.config instead of hermes_cli.cli_output) plus
  a non-existent print_code; the try/except swallowed the ImportError so
  `hermes gateway setup` for android always bailed out early.
- Fix release_scoped_lock type error (str | None passed where str required).
- Rewrite empty `except: pass` blocks as contextlib.suppress with rationale.
- Restructure two ambiguous ws_server try blocks (hello-auth, frame loop).
- Ruff cleanup: type annotations, import sorting, line wrapping, magic values
  -> named constants, `raise ... from e`, complexity. Add gateway-plugin/ruff.toml.
- Add pyrightconfig.json so the Python LSP resolves hermes-runtime imports.
- Suppress verified false positives inline (parameterized SQL, column-name
  "secrets", hermes-generated media path).

Android (app/androidApp + app/shared):
- Consolidate launcher icons into a single mipmap-anydpi (minSdk 29 >= 26) with
  the monochrome layer; clears ObsoleteSdkInt + MonochromeLauncherIcon.
- Bump core-splashscreen 1.0.1 -> 1.2.0; pin targetSdk 34 (deliberate).
- Suppress verified findings inline (LAN ws:// default, correct GCM IV usage).

Desktop (app/desktopApp):
- Move the desktop to a Java 21 runtime (org.gradle.java.home) and set the
  desktop jvmTarget to 21 (Android stays JVM 17 / minSdk 29). Fixes the startup
  UnsupportedClassVersionError and restores Markdown renderer 0.44.0.

Tooling/config:
- .pi-lens.json: disable verified-noisy heuristics (documented in docs).
- .gitleaks.toml: allowlist git-ignored false-positive paths.
- docs/18-code-review.md: full findings + verification.

Verified: ruff clean, pyright 0 errors, 64/64 gateway tests, all Kotlin tests,
Android lint 0 issues, Android installed+launched on device, desktop launches
on JDK 21.
2026-08-21 18:47:03 +02:00
ARIA 9f3f9842c8 Push notification dedupe: one message = one notification — an offline message was notified twice (FCM push, then again when the app synced the outbox and mirrored the replayed frames). Fix: the gateway records the highest outbox cursor delivered per device via push (devices.last_pushed_cursor, advanced only on successful send) and returns it in hello.ack; sync-replayed frames carry their outbox cursor in the envelope; the app skips system notifications for replayed frames at/below the watermark (live frames never suppressed — that is the case where no push fired). Also: 5s per-chat push coalescing so a cron delivery (notification frame + message frame) pushes once, and the FCM handler no longer posts a redundant notification (skips when WS is connected or FCM already displayed the notification payload; data-only messages are the exception). Docs: frames.schema.json, 04-wire-protocol.md, 08-push.md §8.8 2026-08-21 17:21:54 +02:00
ARIA 1bcadcf950 Added slash command handling 2026-08-21 10:18:48 +02:00
ARIA 10e9565e2e Channels: default-only threading + automation (read-only) channels — threading (topic switcher, Ctrl+T, auto-threading) is now only active on the default channel, and the gateway ignores auto_thread for other channels; new channel.set_automation frame marks a channel read-only for cron/webhook output (app hides the composer behind a notice, gateway rejects message.send, default channel cannot be marked, flag syncs to all devices via the full-entry channel.renamed response, gear badge in the list); header pill drops the 'Bot' subtitle and shows the chat_id on automation channels (tap to copy for cron delivery targeting); also fixes a pre-existing stray brace that made frames.schema.json invalid JSON 2026-08-21 09:39:32 +02:00
ARIA ac4097e9a1 android gateway: raise MAX_MESSAGE_LENGTH to 1M so long replies (and complete HTML artifacts) aren't chunked across fence-reopened messages — the stream consumer defaults to 4096 when the adapter sets no cap, and WS has no message-size limit 2026-08-21 00:43:42 +02:00
ARIA 49b7a3f577 Channel context menu: long-press/right-click a row → rename, favorite/unfavorite, add/change/remove icon (color or image), delete; removed star/delete row buttons; favorite + icon/color synced across devices 2026-08-20 20:55:24 +02:00
ARIA 86a4c8ee70 Added official hermes-gateway status messages (new comment category instead of tool calls and messages) 2026-08-20 18:11:36 +02:00
ARIA e9e1aed0f2 Long-press message selection + delete (message.delete frame, outbox removal, all-device sync) 2026-08-20 16:51:19 +02:00
ARIA efecf2732e Auto-threading, history pagination, streaming toggle + tool/reasoning display settings
- Auto-threading (Telegram topic-mode workflow): message.send {auto_thread}
  mints a fresh AI-named thread (instant derived title, LLM upgrade via
  channel.renamed); channel.created {auto:true}; the app jumps into the new
  thread and relocates the optimistic pending bubble.
- history frame: paged full message history for initial channel open /
  scroll-up pagination (reconstructed from the outbox log).
- Streaming on/off: gateway side (display.platforms.android.streaming) plus a
  per-device app toggle (Settings → Streaming); reasoning/model/tokens carried
  on message frames.
- Context menu: long-press (touch) / right-click (desktop) thread affordances
  via a KMP rightClick expect/actual.
- Settings → Reasoning: auto-collapse long reasoning blocks (default on).
- Tool detail: the gateway now always supplies full tool data — it forces
  verbose tool progress (full args → tool.start.args) and captures each
  completed call via the post_tool_call hook (output/duration/ok → tool.end).
  The app reveals the full call + output on expand (Truncated) and
  auto-expands cards in Everything mode.
2026-08-20 16:26:52 +02:00
ARIA bf6bf7e8bd M7: polish + E2E + docs (layout pass, theming, states, e2e driver, schema, setup.md, security) 2026-08-20 12:00:13 +02:00
ARIA 2ecfe1c05c M5: push (FCM + ntfy) + offline catch-up + background notifications
Server (gateway-plugin):
- push.py: FCM (HTTP v1 service-account / legacy key) + ntfy backends; NtfyBackend.server_url for app discovery
- adapter.py: push on offline broadcast + high-priority push when live; fcm.register; server_caps.push_ntfy_server; outbox now ALWAYS appends so a reconnecting app catches up on live-delivered frames (fixes empty chat after notification tap / activity recreation)
- protocol.py / ws_server.py / outbox.py / plugin.yaml: M5 frames + env vars

App (Kotlin CMP):
- Protocol.kt: notification / fcm.register / sync frames + push caps
- GatewayClient.kt: hello carries push creds; auto-sync on reconnect
- IrisController.kt: banners, deep-link, notifyMessageIfBackgrounded (system notification on a regular reply when backgrounded)
- Android: PlatformPush, AppBridge, IrisNotifications, NtfyListenerService, IrisFirebaseMessagingService, AndroidPush, AndroidSecureStore
- Desktop: DesktopPush, DesktopSecureStore
- build files + manifest (permissions, services, deep-links)

Tests: 35-test tests/gateway/test_android.py suite passes (incl. new regression test_live_delivered_frame_still_parked_for_sync). E2E verified on device: push fire, reconnect sync, background notification, and message replay after ChatStore reset.
2026-08-19 19:20:55 +02:00
ARIA 913ee91024 M4: media upload/download/playback (both directions)
Gateway plugin:
- media.upload (chunked binary) -> size/sha256 verify + MIME re-sniff ->
  cache_*_from_bytes -> media.upload.ack
- media.offer / media.pull (chunked) for agent-sent media, delivery-path
  security re-checked at pull time
- send_* overrides mint media_id and emit media.offer
- message.send media_refs resolve to cached inbound media
- per-send + per-chunk timeouts so a stalled peer can't starve the rest

App (Kotlin CMP):
- Protocol: media frame types/payloads/builders
- GatewayClient: binary session, uploadMedia (chunked + streaming sha256),
  pullMedia serialized via Mutex so concurrent offers don't interleave
- ChatStore/IrisController: MediaItem, attachments, auto-pull on offer
- Platform media: SAF picker, ExoPlayer (audio mini-player + video), image
  loader, FileProvider document open (Android); AWT-free desktop actuals
- ChatScreen: attach button + chips, media rendering, keyboard dismiss on send

UI polish:
- preserve image aspect ratio (no stretching), cap dominant dimension
- adjustResize so only chat content squeezes for the keyboard
- clear focus (hide keyboard) on send

Docs: media.upload.ack in 04-wire-protocol.md + frames.schema.json +
07-media.md; M4 marked complete in 14-milestones.md.

Tests: 17-test tests/gateway/test_android.py suite passes.
2026-08-19 17:29:39 +02:00
ARIA 9b511fdd28 M3: channels/threads + cron delivery + search + outbox sync
Gateway plugin:
- ChannelDirectory (SQLite): channels + threads, friendly-name resolution
- FTS5 search bridge over state.db (scope all/chat, LIKE fallback)
- Outbox (monotonic cursor, 72h retention) for reconnect catch-up
- adapter: channel.* handlers, search, sync, cron target parsing
- ws_server: M3 frame routing + hello.ack sync cursor

App (KMP):
- ChannelStore (directory cache) + lane-aware ChatStore (per chat/thread)
- GatewayClient.sendFrame; IrisController channel/search/nav actions
- ChatScreen: channel drawer, thread toggle, topic switcher, search overlay
2026-08-19 14:53:48 +02:00
ARIA 218c50d688 M1+M2: gateway core loop + agent transparency
M1 (gateway core loop / text round-trip):
- WS server (ws_server.py): bind, hello auth (constant-time), hello.ack, heartbeat, connection registry
- pairing.py: token generation, pairing store, QR payload
- adapter.py: send() -> message frame; inbound message.send -> MessageEvent -> handle_message
- app: Connect screen, GatewayClient (connect + reconnect), ChatScreen send/render, SecureStore (Android/Desktop)
- tests/ws_probe.py: probe harness driving a real turn

M2 (streaming + reasoning + tools + commentary):
- protocol.py: M2 frame types (message.start/update/stop, tool.start/progress/end, commentary)
- adapter.py: per-chat turn-state machine; classify outbound into frames; _split_reasoning; tool-line parsing
- reasoning in streaming: capture via on_stream_delta hook (kind=reasoning, gated by plugins.stream_reasoning_deltas) with a FIFO barrier, attach to message.stop
- app: live streaming bubble, ReasoningBlock (collapse + copy), ToolCard (Everything/Truncated/Nothing), dimmed commentary, typing
- docs/14-milestones.md: M1/M2 marked done; reasoning note corrected
2026-08-19 13:56:19 +02:00
ARIA 59acf66c89 M0: toolchain, monorepo scaffold, gateway plugin skeleton, CMP app
- gateway-plugin/: android platform plugin (plugin.yaml + adapter.py
  register(ctx) + no-op AndroidAdapter) + stub modules for M1-M5
- app/: Compose Multiplatform project (shared KMP + androidApp +
  desktopApp) with Gradle wrapper; builds :androidApp:assembleDebug
  and :desktopApp:compileKotlin
- scripts/guard_hermes_agent.sh + pre-commit hook: fail if hermes-agent/
  is staged (read-only reference, never committed)
- .gitignore excludes hermes-agent/; docs/ reference library
2026-08-19 11:27:02 +02:00