23 Commits
Author SHA1 Message Date
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
91 changed files with 5540 additions and 1520 deletions

No files matched your search

+65 -65
View File
@@ -1,79 +1,79 @@
name: CI
on:
push:
branches: [master]
pull_request:
push:
branches: [master]
pull_request:
jobs:
gateway:
name: Gateway plugin tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
gateway:
name: Gateway plugin tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
# The gateway tests run inside the hermes-agent test harness, which is
# git-ignored in this repo (read-only research reference). CI clones the
# upstream repo at a pinned commit and drops in the vendored test copy.
# Bump the pinned SHA when you update the local hermes-agent checkout.
- name: Clone hermes-agent (pinned)
run: |
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
# The gateway tests run inside the hermes-agent test harness, which is
# git-ignored in this repo (read-only research reference). CI clones the
# upstream repo at a pinned commit and drops in the vendored test copy.
# Bump the pinned SHA when you update the local hermes-agent checkout.
- name: Clone hermes-agent (pinned)
run: |
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
- name: Sync venv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
cd hermes-agent
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
# venv without it and run_tests.sh refuses to run.
uv sync --extra dev
- name: Sync venv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
cd hermes-agent
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
# venv without it and run_tests.sh refuses to run.
uv sync --extra dev
- name: Run android gateway tests
run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py
- name: Run android gateway tests
run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py
kotlin:
name: Kotlin tests (android host + desktop)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
kotlin:
name: Kotlin tests (android host + desktop)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# gradle.properties pins org.gradle.java.home to a local JDK path;
# strip it so CI uses the JDK installed by setup-java.
- name: Strip local JDK pin
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
# gradle.properties pins org.gradle.java.home to a local JDK path;
# strip it so CI uses the JDK installed by setup-java.
- name: Strip local JDK pin
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
- name: Install Android SDK
run: |
export ANDROID_HOME="$HOME/android-sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools"
curl -fsSL -o /tmp/ct.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
# exits before `yes` is done writing.
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
- name: Install Android SDK
run: |
export ANDROID_HOME="$HOME/android-sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools"
curl -fsSL -o /tmp/ct.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
# exits before `yes` is done writing.
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
# Host-side tests only (no device needed). AGP auto-downloads the
# missing SDK platforms (licenses accepted above).
- name: Run host tests
working-directory: app
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
# Host-side tests only (no device needed). AGP auto-downloads the
# missing SDK platforms (licenses accepted above).
- name: Run host tests
working-directory: app
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
+29 -9
View File
@@ -8,7 +8,7 @@ on:
required: true
type: string
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
type: string
@@ -40,7 +40,7 @@ jobs:
run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
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
kotlin:
@@ -178,19 +178,38 @@ jobs:
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
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"
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# Re-run safety: drop a previous release (and its tag) for this version.
OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty')
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
# message) before failing — plain `curl -f` hides it (exit 22).
api() {
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
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
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
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.
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" \
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
'{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
[ -f "$f" ] || continue
echo "Uploading $(basename "$f")"
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/attachments" > /dev/null
# Forgejo-style API: release assets live under /assets, not /attachments.
api -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/assets" > /dev/null
done
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
+8 -7
View File
@@ -3,6 +3,7 @@
## 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.
- **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/android` → `<repo>/gateway-plugin` (already set up on this machine).
## Layout
@@ -14,26 +15,26 @@
## Commands
- `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).
- 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).
- 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`.
## Environment / pairing quirks
- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry.
- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP.
- 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 `IRIS_WS_HOST` to the gateway's LAN IP.
- `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.
- JDK 17; no system Gradle — always the wrapper (`./gradlew`).
- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
- **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 21 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
## 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.
- 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.
+2 -2
View File
@@ -58,7 +58,7 @@ jobs:
run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
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
kotlin:
@@ -153,7 +153,7 @@ jobs:
run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
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
kotlin:
@@ -8,7 +8,16 @@
<!-- 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_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
android:label="Iris"
android:icon="@mipmap/ic_launcher"
@@ -38,8 +47,22 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="iris" android:host="chat" />
</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>
<!-- 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). -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -16,11 +16,16 @@ import iris.platform.AndroidEnv
import iris.platform.AndroidSecureStore
import iris.platform.AppBridge
import iris.platform.syncNtfyListener
import iris.util.PairLink
class MainActivity : ComponentActivity() {
private val deepLinkChatId = 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 =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
@@ -37,7 +42,13 @@ class MainActivity : ComponentActivity() {
setContent {
val chatId by deepLinkChatId
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). */
private fun handleDeepLink(intent: Intent?) {
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 =
intent?.getStringExtra("chat_id")
?: data?.pathSegments?.firstOrNull()
+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
# Java-21 bytecode). AGP is JDK-21-compatible, so the Android build is
# 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.configuration-cache=true
+10
View File
@@ -29,6 +29,9 @@ val kcefVersion = "2025.03.23"
val markdownVersion = "0.44.0"
// Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16).
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 {
android {
@@ -111,6 +114,13 @@ kotlin {
implementation("androidx.media3:media3-ui:1.11.0")
// SAF picker (rememberLauncherForActivityResult).
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;
// the ntfy listener is the fallback). The google-services plugin is
// applied conditionally in the app module.
@@ -1,6 +1,7 @@
package iris.platform
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
@@ -15,7 +16,9 @@ actual fun setActiveController(controller: Any?) {
actual fun syncNtfyListener(backend: String) {
val context = AndroidEnv.context
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()) {
ContextCompat.startForegroundService(context, intent)
} 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(
chatId: String?,
chatName: String?,
@@ -33,7 +48,7 @@ actual fun postSystemNotification(
threadId: String?,
) {
val context = AndroidEnv.context
val id = chatId ?: "android:default"
val id = chatId ?: "default"
// POST_NOTIFICATIONS is a runtime permission on API 33+.
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
@@ -179,10 +179,20 @@ class AndroidSecureStore(
}
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
.edit()
.remove(KEY_URL)
.remove(KEY_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)
.apply()
}
@@ -22,7 +22,10 @@ import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/"
@Composable
actual fun PlatformWebView(html: String, modifier: Modifier) {
actual fun PlatformWebView(
html: String,
modifier: Modifier,
) {
val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL)
state.webSettings.androidWebSettings.domStorageEnabled = true
WebView(state, modifier = modifier)
@@ -33,16 +36,18 @@ actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val currentOnBack = rememberUpdatedState(onBack)
DisposableEffect(dispatcher) {
if (dispatcher != null) {
val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
currentOnBack.value()
}
val callback =
dispatcher?.let { d ->
val c =
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
currentOnBack.value()
}
}
d.addCallback(c)
c
}
dispatcher.addCallback(callback)
onDispose { callback.remove() }
}
onDispose { }
onDispose { callback?.remove() }
}
return this
}
}
@@ -14,7 +14,14 @@ object AppBridge {
@Volatile
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
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
* `fcm.register` (so the next push targets the current token).
* - [onMessageReceived]: the data payload drives a silent sync. When the app
* is foregrounded the SSE path already delivered the frame (in-app banner),
* so we only post a system notification when backgrounded.
* - [onMessageReceived]: posts a system notification from the data payload.
* When the app is foregrounded the SSE path already delivered the frame
* (in-app banner), so we only post a notification when backgrounded.
*
* Inert without a Firebase project (no google-services.json): the service is
* 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.
if (message.notification != null) return
val data = message.data
val chatId = data["chat_id"] ?: "android:default"
val chatId = data["chat_id"] ?: "default"
val threadId = data["thread_id"]
val title = data["title"] ?: "Iris"
val body = data["body"] ?: data["title"].orEmpty()
@@ -5,7 +5,6 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
/**
@@ -21,15 +20,22 @@ object IrisNotifications {
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
private const val NOTIF_ID_BASE = 1_000_000
fun ensureChannel(context: Context, chatId: String, chatName: String? = null) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
// L-31: notification channel ids are capped at 64 chars (Android limit) and
// 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 id = CHANNEL_PREFIX + chatId
val id = channelIdFor(chatId)
val name = chatName ?: chatId
if (nm.getNotificationChannel(id) == null) {
nm.createNotificationChannel(
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = "Iris messages for $name" }
.apply { description = "Iris messages for $name" },
)
}
}
@@ -43,23 +49,28 @@ object IrisNotifications {
threadId: String?,
) {
ensureChannel(context, chatId, chatName)
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff)
val intent = Intent(ACTION_OPEN_CHAT).apply {
setPackage(context.packageName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("chat_id", chatId)
if (threadId != null) putExtra("thread_id", threadId)
}
val pi = PendingIntent.getActivity(
context,
id,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
// L-32: 24-bit hash (was 16-bit) to reduce the chance two chatIds map
// 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)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("chat_id", chatId)
if (threadId != null) putExtra("thread_id", threadId)
}
val pi =
PendingIntent.getActivity(
context,
id,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(
id,
NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId)
NotificationCompat
.Builder(context, channelIdFor(chatId))
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
@@ -69,4 +80,4 @@ object IrisNotifications {
.build(),
)
}
}
}
@@ -11,11 +11,13 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import iris.protocol.IrisJson
import iris.util.IrisLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
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 system notification for each push. The structured payload rides in the
* `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body
* is the short preview. When the app is foregrounded the WS path already
* delivered the frame, so the service skips posting to avoid a duplicate.
* `X-Data` SSE field (JSON: chat_id, kind, cursor, thread_id); the message
* body is the short preview. When the app is foregrounded the SSE path
* 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() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var streamJob: Job? = null
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream
.build()
private val client =
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()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
startForeground(NOTIF_ID, foregroundNotification())
streamJob?.cancel()
streamJob = scope.launch { stream() }
@@ -60,47 +75,79 @@ class NtfyListenerService : Service() {
if (topic.isBlank()) return
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
val url = "$server/$topic"
val request = Request.Builder()
.url(url)
.header("Accept", "text/event-stream")
.build()
try {
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return
val body = resp.body ?: return
val source = body.source()
var data: String? = null
var title: String? = null
var msgBody: String? = null
while (!source.exhausted()) {
val line = source.readUtf8Line() ?: break
when {
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim()
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim()
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim()
line.isEmpty() -> {
// Event boundary: process the accumulated message.
data?.let { handleData(it, title, msgBody) }
data = null
title = null
msgBody = null
}
val request =
Request
.Builder()
.url(url)
.header("Accept", "text/event-stream")
.build()
var backoff = 1_000L
while (true) {
try {
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
IrisLog.w("ntfy stream HTTP ${resp.code}")
} else {
val body = resp.body ?: return
readEvents(body.source())
}
}
} catch (e: Exception) {
IrisLog.w("ntfy stream dropped: ${e.message}")
}
} catch (_: Exception) {
// Stream dropped; the service is START_STICKY so the system
// restarts it. If it keeps failing, the WS path still works.
// Stream ended (EOF, error, or non-2xx): back off and reconnect.
delay(backoff)
backoff = (backoff * 2).coerceAtMost(30_000L)
}
}
private fun handleData(dataJson: String, title: String?, msgBody: String?) {
val data = try {
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
} catch (_: Exception) {
null
/**
* 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 title: String? = null
var msgBody: String? = null
while (true) {
val line = source.readUtf8Line() ?: break
when {
line.startsWith("X-Data:") -> {
data = line.removePrefix("X-Data:").trim()
}
line.startsWith("X-Title:") -> {
title = line.removePrefix("X-Title:").trim()
}
line.startsWith("data:") -> {
msgBody = line.removePrefix("data:").trim()
}
line.isEmpty() -> {
// Event boundary: process the accumulated message.
data?.let { handleData(it, title, msgBody) }
data = null
title = null
msgBody = null
}
}
}
val chatId = data?.str("chat_id") ?: "android:default"
}
private fun handleData(
dataJson: String,
title: String?,
msgBody: String?,
) {
val data =
try {
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
} catch (_: Exception) {
null
}
val chatId = data?.str("chat_id") ?: "default"
val threadId = data?.str("thread_id")
// The short preview rides in the SSE `data:` field; fall back to the
// X-Title, then a generic label.
@@ -126,11 +173,12 @@ class NtfyListenerService : Service() {
LISTENER_CHANNEL,
"Iris push listener",
NotificationManager.IMPORTANCE_MIN,
)
),
)
}
}
return NotificationCompat.Builder(context, LISTENER_CHANNEL)
return NotificationCompat
.Builder(context, LISTENER_CHANNEL)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Iris")
.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). */
private fun JsonObject?.str(key: String): String? =
(this?.get(key) as? JsonPrimitive)?.content
private fun JsonObject?.str(key: String): String? = (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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@@ -26,19 +27,21 @@ import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme
import iris.ui.theme.LocalUserTheme
import iris.ui.theme.rememberBackgroundImage
import iris.util.PairLink
/**
* Root composable shared by the Android and Desktop shells.
*
* M1: routes between the Connect screen (unpaired / auth failed) and the
* Chat screen (paired). Later milestones add the channel list, search,
* settings, and media (docs/10-android-app.md).
* Routes between the Connect screen (unpaired / auth failed) and the main
* app (paired), which hosts the channel list, chat, search, settings, and
* media (docs/10-android-app.md).
*/
@Composable
fun IrisApp(
store: SecureStore,
deepLinkChatId: String? = null,
deepLinkThreadId: String? = null,
deepLinkPair: PairLink? = null,
) {
val controller = remember(store) { IrisController(store) }
DisposableEffect(controller) {
@@ -66,10 +69,11 @@ fun IrisApp(
// untouched, so the UI keeps its proportions at any size.
CompositionLocalProvider(
LocalUserTheme provides theme,
LocalDensity provides Density(
density = baseDensity.density,
fontScale = baseDensity.fontScale * fontScale,
),
LocalDensity provides
Density(
density = baseDensity.density,
fontScale = baseDensity.fontScale * fontScale,
),
) {
// The color goes through Surface's `color` parameter: a
// Modifier.background on the Surface would be painted *under* the
@@ -95,20 +99,34 @@ fun IrisApp(
)
}
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) {
GatewayClient.State.Disconnected ->
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
is GatewayClient.State.AuthFailed ->
ConnectScreen(
controller,
prefillUrl = store.serverUrl,
prefillToken = store.token,
initialError = "Pairing rejected: ${s.message}",
)
GatewayClient.State.Disconnected -> {
key(deepLinkPair) {
ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
}
}
is GatewayClient.State.AuthFailed -> {
key(deepLinkPair) {
ConnectScreen(
controller,
prefillUrl = pairUrl,
prefillToken = pairToken,
initialError = "Pairing rejected: ${s.message}",
)
}
}
// Connecting / Reconnecting / Connected all render the chat; the header
// status bubble + connection banner show the link state
// 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
// artifact card in the chat); covers everything while open.
@@ -117,4 +135,4 @@ fun IrisApp(
}
}
}
}
}
@@ -71,6 +71,54 @@ class ChannelStore {
_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> =
list.sortedWith(
compareByDescending<ChannelInfo> { it.isDefault }
@@ -36,7 +36,7 @@ class ChatDb(
* tool cards interleaved at their anchored position). */
fun loadLanes(): Map<String, List<ChatItem>> =
synchronized(lock) {
val messages = linkedMapOf<String, MutableList<MessageItem>>()
val messages = linkedMapOf<String, MutableList<ChatItem>>()
for (row in db.cacheQueries.allMessages().executeAsList()) {
val item = decodeMessage(row.payload) ?: continue
messages.getOrPut(row.lane) { mutableListOf() }.add(item)
@@ -96,6 +96,12 @@ class ChatDb(
is ToolItem -> {
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,11 +165,17 @@ class ChatDb(
}
}
private fun decodeMessage(payload: String): MessageItem? =
private fun decodeMessage(payload: String): ChatItem? =
try {
json.decodeFromString<MessageItem>(payload).sanitizeForRestore()
} catch (_: Exception) {
null
// 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) {
null
}
}
private fun decodeTool(payload: String): ToolItem? =
@@ -8,6 +8,8 @@ import iris.protocol.MessagePayload
import iris.protocol.MessageStartPayload
import iris.protocol.MessageStopPayload
import iris.protocol.MessageUpdatePayload
import iris.protocol.PickerChoice
import iris.protocol.PickerChoicePayload
import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ROLE_USER
import iris.protocol.RuntimeMeta
@@ -17,9 +19,13 @@ import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP
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_PROGRESS
import iris.protocol.TYPE_TOOL_START
import iris.protocol.TodoItem
import iris.protocol.TodoUpdatePayload
import iris.protocol.ToolEndPayload
import iris.protocol.ToolProgressPayload
import iris.protocol.ToolStartPayload
@@ -103,6 +109,9 @@ data class ToolItem(
val name: String,
val preview: String? = 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 done: Boolean = false,
val ok: Boolean = true,
@@ -111,6 +120,20 @@ data class ToolItem(
val anchorId: String? = null,
) : 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 {
/** lane key -> chronological items. */
private val _lanes = MutableStateFlow<Map<String, List<ChatItem>>>(emptyMap())
@@ -120,7 +143,25 @@ class ChatStore {
private val _currentLane = MutableStateFlow(DEFAULT_LANE)
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
* reply materializes as a single final message on `message.stop`
@@ -129,15 +170,16 @@ class ChatStore {
var streamingEnabled: Boolean = true
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)}"
}
// ── Lane helpers ──────────────────────────────────────────────────────
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because
* chat ids already contain a single `:` (e.g. `android:chan_1`). */
/** Lane key for a (chat, thread) pair. Uses `::` as the separator so a
* thread lane can never collide with a chat id (chat ids are direct,
* e.g. `chan_1`, and never contain `:`). */
fun laneKey(
chatId: String,
threadId: String?,
@@ -162,9 +204,26 @@ class ChatStore {
lane: String,
transform: (List<ChatItem>) -> List<ChatItem>,
) {
synchronized(lock) {
val map = _lanes.value.toMutableMap()
map[lane] = transform(map[lane].orEmpty())
_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()
map[lane] = transform(map[lane].orEmpty())
_lanes.value = map
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 ───────────────────────────────────────────────────
@@ -193,8 +252,10 @@ class ChatStore {
lane: String,
text: String,
) {
localSeq++
val id = "sys_$localSeq"
// randomId (not a process-local seq): system messages are persisted,
// 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) {
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_PROGRESS -> onToolProgress(lane, frame)
TYPE_TOOL_END -> onToolEnd(lane, frame)
TYPE_TODO_UPDATE -> onTodoUpdate(lane, frame)
TYPE_COMMENTARY -> onCommentary(lane, frame)
TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame)
TYPE_PICKER_CHOICE -> onPickerChoice(lane, frame)
else -> Unit
}
}
@@ -315,16 +378,18 @@ class ChatStore {
val (chatId, threadId) = parseLane(lane)
if (threadId == null) return
val flatLane = chatId
val map = _lanes.value.toMutableMap()
val flatList = map[flatLane].orEmpty()
val idx =
flatList.indexOfLast {
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
(it.pending || it.status == MsgStatus.Failed)
}
if (idx < 0) return
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
_lanes.value = map
synchronized(lock) {
val map = _lanes.value.toMutableMap()
val flatList = map[flatLane].orEmpty()
val idx =
flatList.indexOfLast {
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
(it.pending || it.status == MsgStatus.Failed)
}
if (idx < 0) return@synchronized
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
_lanes.value = map
}
}
/** Merge server media refs into existing items, keeping local paths. */
@@ -450,6 +515,7 @@ class ChatStore {
name = p.name,
preview = p.preview,
args = p.args,
emoji = p.emoji,
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) ──────────────────────────────────
private fun onCommentary(
@@ -571,10 +651,8 @@ class ChatStore {
mediaId: String,
localPath: String,
) {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated =
synchronized(lock) {
mapLanes { list ->
list.map { item ->
if (item is MessageItem) {
item.copy(
@@ -587,20 +665,84 @@ class ChatStore {
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). */
fun markRead(messageId: String) {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated =
synchronized(lock) {
mapLanes { list ->
list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Read
@@ -610,22 +752,16 @@ class ChatStore {
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
* gateway — network drop, or the gateway rejected it); tap the bubble
* to retry. */
fun failMessage(messageId: String) {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated =
synchronized(lock) {
mapLanes { list ->
list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Failed
@@ -635,12 +771,8 @@ class ChatStore {
item
}
}
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
/**
@@ -651,16 +783,9 @@ class ChatStore {
*/
fun removeMessages(messageIds: Set<String>) {
if (messageIds.isEmpty()) return
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.filterNot { it.id in messageIds }
if (updated != list) {
map[lane] = updated
changed = true
}
synchronized(lock) {
mapLanes { list -> list.filterNot { it.id in messageIds } }
}
if (changed) _lanes.value = map
}
/**
@@ -671,30 +796,23 @@ class ChatStore {
* message.stop) will never arrive to close them.
*/
fun finalizeInterrupted() {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated =
synchronized(lock) {
mapLanes { list ->
list.map { item ->
when (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 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). */
fun failPending() {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated =
synchronized(lock) {
mapLanes { list ->
list.map { item ->
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
item.copy(pending = false, status = MsgStatus.Failed)
@@ -702,12 +820,8 @@ class ChatStore {
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. */
@@ -783,6 +897,8 @@ class ChatStore {
// A resolved ts of 0 means the anchor itself is ts-less
// (lane start) — sort with it (end) instead of to the top.
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>>) {
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() {
_lanes.value = emptyMap()
synchronized(lock) {
_lanes.value = emptyMap()
_unread.value = emptyMap()
_todos.value = emptyMap()
}
}
}
@@ -2,14 +2,14 @@ package iris.data
/**
* Pairing settings storage. The token is a secret: platform actuals keep it
* in secure storage (EncryptedSharedPreferences on Android — M5; plain
* SharedPreferences for M1 dev, file on desktop).
* in secure storage (EncryptedSharedPreferences on Android, OS keyring or an
* encrypted file on desktop).
*/
interface SecureStore {
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
var serverUrl: String
/** ANDROID_TOKEN presented in the auth header. */
/** IRIS_TOKEN presented in the auth header. */
var token: String
/** Stable app-generated device id (persisted). */
@@ -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,32 +6,43 @@ import iris.protocol.KIND_IMAGE
import iris.protocol.KIND_VIDEO
/** Map a MIME type to a media kind (docs/07 §7.1). */
fun kindFromMime(mime: String): String = when {
mime.startsWith("image/") -> KIND_IMAGE
mime.startsWith("video/") -> KIND_VIDEO
mime.startsWith("audio/") -> KIND_AUDIO
else -> KIND_DOCUMENT
}
fun kindFromMime(mime: String): String =
when {
mime.startsWith("image/") -> KIND_IMAGE
mime.startsWith("video/") -> KIND_VIDEO
mime.startsWith("audio/") -> KIND_AUDIO
else -> KIND_DOCUMENT
}
/** Best-effort file extension for a MIME type (cache file naming). */
fun extForMime(mime: String): String = when {
mime == "image/jpeg" -> ".jpg"
mime == "image/png" -> ".png"
mime == "image/webp" -> ".webp"
mime == "image/gif" -> ".gif"
mime == "image/heic" -> ".heic"
mime == "image/heif" -> ".heif"
mime == "video/mp4" -> ".mp4"
mime == "video/webm" -> ".webm"
mime == "video/quicktime" -> ".mov"
mime == "audio/mpeg" -> ".mp3"
mime == "audio/mp4" || mime == "audio/x-m4a" -> ".m4a"
mime == "audio/ogg" -> ".ogg"
mime == "audio/wav" -> ".wav"
mime == "audio/flac" -> ".flac"
mime == "audio/aac" -> ".aac"
mime == "application/pdf" -> ".pdf"
mime == "application/zip" -> ".zip"
mime == "text/plain" -> ".txt"
else -> ".bin"
}
fun extForMime(mime: String): String =
when {
mime == "image/jpeg" -> ".jpg"
mime == "image/png" -> ".png"
mime == "image/webp" -> ".webp"
mime == "image/gif" -> ".gif"
mime == "image/heic" -> ".heic"
mime == "image/heif" -> ".heif"
mime == "video/mp4" -> ".mp4"
mime == "video/webm" -> ".webm"
mime == "video/quicktime" -> ".mov"
mime == "audio/mpeg" -> ".mp3"
mime == "audio/mp4" || mime == "audio/x-m4a" -> ".m4a"
mime == "audio/ogg" -> ".ogg"
mime == "audio/wav" -> ".wav"
mime == "audio/flac" -> ".flac"
mime == "audio/aac" -> ".aac"
mime == "application/pdf" -> ".pdf"
mime == "application/zip" -> ".zip"
mime == "text/plain" -> ".txt"
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.syncFrame
import iris.util.IrisLog
import iris.util.nowMillis
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
@@ -26,14 +26,13 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
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
* `/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)
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect
* - 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(
private val scope: CoroutineScope,
@@ -86,6 +87,14 @@ class GatewayClient(
private var connectJob: Job? = null
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
// Set by poke() (app returned to the foreground): the connect loop's
@@ -97,19 +106,33 @@ class GatewayClient(
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
// drop) so the UI can show the right status without a blocking screen.
private var hasConnected = false
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
// 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
@Volatile
private var httpCursor: Long = 0
private var sseFailures = 0
private var usingLongPoll = false
// 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
// 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
* every (re)connect. Used for time-critical work that must not wait for
@@ -133,11 +156,22 @@ class GatewayClient(
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() {
connectJob?.cancel()
connectJob = null
_state.value = State.Disconnected
http?.close()
http = null
httpCursor = 0
sseFailures = 0
usingLongPoll = false
lastAck = null
}
/**
@@ -186,6 +220,7 @@ class GatewayClient(
sseFailures = 0
usingLongPoll = false
httpCursor = store.syncCursor
lastFrameMs = nowMs()
// Provisional Connected state (previous caps/channels) until the
// SSE hello arrives with the real ones.
val prev = _state.value
@@ -217,6 +252,16 @@ class GatewayClient(
receiveJob.cancel()
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()
}
@@ -227,22 +272,25 @@ class GatewayClient(
// ── HTTP receive leg ──────────────────────────────────────────────────
/** Lazily build the HTTP client from the stored URL. */
private fun httpGateway(): HttpGateway? {
val url = store.serverUrl.trim()
val token = store.token
if (url.isBlank() || token.isBlank()) return null
return http
?: HttpGateway(
client,
HttpGateway.deriveHttpUrl(url),
token,
store.deviceId,
deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } },
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
).also { http = it }
}
/** Lazily build the HTTP client from the stored URL. Synchronized: two
* 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 token = store.token
if (url.isBlank() || token.isBlank()) return@synchronized null
http
?: HttpGateway(
client,
HttpGateway.deriveHttpUrl(url),
token,
store.deviceId,
deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } },
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
).also { http = it }
}
/**
* The HTTP receive loop: SSE by default; after two consecutive SSE open
@@ -276,9 +324,16 @@ class GatewayClient(
onHello = { onHttpHello(it) },
onFrame = { emitHttpFrame(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
delay(backoff)
} catch (e: HttpGateway.HttpAuthException) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
return
@@ -306,7 +361,7 @@ class GatewayClient(
// M5: reconnect catch-up — replay frames parked while offline.
val local = store.syncCursor
if (local < ack.syncCursor) {
val id = nextRequestId++
val id = nextId()
scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) }
}
// Prompt fast path (before the possibly-starved state collector).
@@ -321,19 +376,38 @@ class GatewayClient(
/** 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
* 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() {
if (_state.value !is State.Reconnecting) return
_state.value =
val connected =
lastAck?.let { State.Connected(it.serverCaps, it.channels, it.lastPushedCursor) }
?: State.Connected(ServerCaps(), emptyList())
_state.value = connected
onHelloAck?.invoke(connected)
}
/** Deliver an HTTP-leg frame to the same sinks as any other frame. */
private fun emitHttpFrame(frame: Frame) {
_events.tryEmit(frame)
frame.id?.let { id ->
pending[id]?.complete(frame)
lastFrameMs = nowMs()
if (!_events.tryEmit(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)")
}
}
@@ -360,7 +434,7 @@ class GatewayClient(
onResult?.invoke(0)
return
}
val id = nextRequestId++
val id = nextId()
scope.launch {
val res =
httpGateway()?.postFrame(
@@ -369,10 +443,7 @@ class GatewayClient(
// 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 —
// deliver it or it is lost (docs/19 §19.7).
res?.frame?.let { emitHttpFrame(it) }
if (res?.status == 401) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
}
handlePostResult(res)
onResult?.invoke(res?.status ?: 0)
}
}
@@ -408,8 +479,12 @@ class GatewayClient(
}
companion object {
const val UPLOAD_TIMEOUT_MS = 120_000L
const val PULL_TIMEOUT_MS = 300_000L
/** No frames (or keep-alive comments) from the receive stream for
* 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 +494,13 @@ class GatewayClient(
*/
fun sendFrame(frame: Frame): Int {
if (_state.value !is State.Connected) return -1
val id = nextRequestId++
val id = nextId()
scope.launch {
val res = httpGateway()?.postFrame(frame.copy(id = id))
// Single-frame responses (commands.catalog, channel.list, search,
// history, sync, errors) come back in the POST body, not on the
// event stream — deliver it or it is lost (docs/19 §19.7).
res?.frame?.let { emitHttpFrame(it) }
if (res?.status == 401) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
}
handlePostResult(res)
}
return id
}
@@ -518,4 +590,6 @@ class GatewayClient(
val capped = minOf(base, 30_000L)
return capped + Random.nextLong(0, 500)
}
private fun nowMs(): Long = nowMillis()
}
@@ -1,6 +1,7 @@
package iris.net
import iris.media.Sha256
import iris.media.isValidMediaId
import iris.protocol.ErrorPayload
import iris.protocol.Frame
import iris.protocol.HelloAckPayload
@@ -60,6 +61,19 @@ class HttpGateway(
private val pollClient: OkHttpClient = client.pollClient()
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
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
* 202 accept-and-ack. */
@@ -195,8 +209,10 @@ class HttpGateway(
* leg is proven; the server may still replay a large outbox before the
* hello); [onHello] fires for `event: hello` (the HTTP hello.ack);
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
* cursor) when present. Returns on clean EOF; throws [IOException] on
* open/read failure. Callbacks run on the IO thread.
* cursor) when present; [onKeepAlive] for SSE comment lines (the
* 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(
cursor: Long,
@@ -204,6 +220,7 @@ class HttpGateway(
onHello: (HelloAckPayload) -> Unit,
onFrame: (Frame) -> Unit,
onCursor: (Long) -> Unit,
onKeepAlive: (() -> Unit)? = null,
) {
withContext(Dispatchers.IO) {
val request =
@@ -257,10 +274,10 @@ class HttpGateway(
}
line.startsWith(":") -> {
Unit
// heartbeat comment
onKeepAlive?.invoke()
}
// heartbeat comment
line.startsWith("id:") -> {
line
.removePrefix("id:")
@@ -399,6 +416,11 @@ class HttpGateway(
onChunk: suspend (ByteArray) -> Unit,
): Result<Unit> =
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 =
Request
.Builder()
@@ -6,7 +6,7 @@ package iris.platform
* The controller (commonMain) needs to know whether the app is in the
* foreground (to decide between an in-app banner and a system notification)
* 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). */
@@ -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).
* See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send,
* error, ping/pong, typing. M2: message.start/update/stop, tool.start/
* progress/end, commentary, reasoning (on message / message.stop).
* See docs/04-wire-protocol.md. Defines all frame types and payloads:
* hello/hello.ack, message (send + streaming), tool cards, commentary,
* reasoning, channels, media, notifications, sync, and error frames.
*/
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_PROGRESS = "tool.progress"
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"
// 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)
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_DONE = "sync.done"
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)
const val KIND_IMAGE = "image"
const val KIND_AUDIO = "audio"
const val KIND_VIDEO = "video"
const val KIND_DOCUMENT = "document"
const val KIND_VOICE = "voice"
// ── Roles ───────────────────────────────────────────────────────────────
@@ -271,6 +271,9 @@ data class ToolStartPayload(
val name: String,
val preview: String? = 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
@@ -289,6 +292,30 @@ data class ToolEndPayload(
@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) ────────────────────────────────
@Serializable
@@ -410,6 +437,27 @@ data class CommandsCatalogPayload(
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) ───────────────────────────────────────
@Serializable
@@ -459,12 +507,9 @@ data class MessageDeletedPayload(
// ── M5: push / notifications ────────────────────────────────────────────
/** Notification kinds (mirror of protocol.NOTIF_*). */
const val NOTIF_MESSAGE = "message"
const val NOTIF_APPROVAL = "approval"
const val NOTIF_CLARIFY = "clarify"
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). */
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
@@ -637,6 +682,24 @@ fun searchFrame(
* Answered by a `commands.catalog` frame carrying the same id. */
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(
id: Int,
cursor: Long,
@@ -8,6 +8,7 @@ import iris.data.MessageItem
import iris.data.MsgStatus
import iris.data.SecureStore
import iris.media.MediaCache
import iris.media.isValidMediaId
import iris.media.kindFromMime
import iris.net.GatewayClient
import iris.platform.PickedFile
@@ -45,16 +46,17 @@ import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HISTORY
import iris.protocol.TYPE_MEDIA_OFFER
import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_DELETE
import iris.protocol.TYPE_MESSAGE_DELETED
import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE
import iris.protocol.TYPE_NOTIFICATION
import iris.protocol.TYPE_PICKER_CHOICE
import iris.protocol.TYPE_READ_RECEIPT
import iris.protocol.TYPE_SEARCH_RESULTS
import iris.protocol.TYPE_STATUS
import iris.protocol.TYPE_SYNC_DONE
import iris.protocol.TYPE_TODO_UPDATE
import iris.protocol.TYPE_TOOL_END
import iris.protocol.TYPE_TOOL_PROGRESS
import iris.protocol.TYPE_TOOL_START
@@ -71,6 +73,7 @@ import iris.protocol.channelSetDefaultFrame
import iris.protocol.commandsCatalogFrame
import iris.protocol.historyFrame
import iris.protocol.messageDeleteFrame
import iris.protocol.pickerSelectFrame
import iris.protocol.searchFrame
import iris.protocol.syncFrame
import iris.ui.theme.Backdrop
@@ -79,6 +82,7 @@ import iris.ui.theme.UserTheme
import iris.util.IrisLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -86,6 +90,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import java.util.Collections
import java.util.concurrent.atomic.AtomicLong
import kotlin.random.Random
/**
@@ -134,14 +140,15 @@ class IrisController(
}
/** 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()
/** 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
* `sync` delta can seed a lane with recent frames without it being opened,
* so "lane is empty" is not a reliable first-open signal. */
private val historyLoaded = mutableSetOf<String>()
* so "lane is empty" is not a reliable first-open signal. Synchronized:
* 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).
* Note: "restarting" is deliberately NOT stored here — it posts the
@@ -149,6 +156,42 @@ class IrisController(
private val _gatewayStatus = MutableStateFlow<String?>(null)
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
// (on the gateway's status{restarting} frame), consumed by the matching
// "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). */
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) ─────
// Persisted (Settings → "Threads").
private val _threadsEnabled = MutableStateFlow(store.threadsEnabled)
@@ -229,6 +303,9 @@ class IrisController(
}
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_MAX = 1.5f
@@ -340,8 +417,12 @@ class IrisController(
// ── M4: media ─────────────────────────────────────────────────────────
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(
val id: String,
val filename: String,
val mime: String,
val size: Long,
@@ -370,7 +451,10 @@ class IrisController(
private val _banners = MutableStateFlow<List<Banner>>(emptyList())
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) {
_banners.value = _banners.value.filterNot { it.id == id }
@@ -384,8 +468,13 @@ class IrisController(
threadId: String?,
) {
val persistent = kind in HIGH_PRIORITY_NOTIF_KINDS
val banner = Banner(bannerSeq++, kind, title, body, chatId, threadId, persistent)
_banners.value = (_banners.value + banner).takeLast(5)
val banner = Banner(bannerSeq.getAndIncrement(), kind, title, body, chatId, threadId, persistent)
// 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) {
scope.launch {
delay(5_000)
@@ -408,7 +497,7 @@ class IrisController(
) {
if (isAppForeground()) return
if (text.isBlank()) return
val id = chatId ?: "android:default"
val id = chatId ?: "default"
val chatName = channels.byId(id)?.name
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
}
@@ -462,9 +551,29 @@ class IrisController(
scope.launch {
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 {
client.events.collect { frame ->
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) {
TYPE_TOOL_START,
TYPE_TOOL_PROGRESS,
@@ -480,6 +589,13 @@ class IrisController(
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_START,
TYPE_MESSAGE_UPDATE,
@@ -501,7 +617,13 @@ class IrisController(
when (frame.type) {
TYPE_MESSAGE_STOP -> {
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)
}
}
@@ -509,8 +631,14 @@ class IrisController(
TYPE_MESSAGE -> {
frame.payloadAs<MessagePayload>()?.let {
if (it.role == ROLE_ASSISTANT && !isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
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)
}
}
}
}
@@ -570,14 +698,25 @@ class IrisController(
_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 -> {
frame.payloadAs<CommandsCatalogPayload>()?.let { _slashCommands.value = it.commands }
}
TYPE_SYNC_DONE -> {
// Replayed frames already flowed through [events]; the
// cursor is authoritative server-side (outbox).
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor }
// cursor is authoritative server-side (outbox). Keep the
// 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 -> {
@@ -602,7 +741,7 @@ class IrisController(
)
// Mark the lane loaded only when the response
// 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
// retries it.
historyLoaded.add(lane)
@@ -616,15 +755,21 @@ class IrisController(
TYPE_NOTIFICATION -> {
frame.payloadAs<NotificationPayload>()?.let { p ->
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
// M5: WS is live but the app is backgrounded — the
// in-app banner is invisible, so mirror to a system
// notification (the push backend only fires when
// there is no live subscriber). Suppressed for
// sync replays that already woke the device via
// push (docs/08 §8.7).
if (!isAppForeground() && !isPushedReplay(frame)) {
postSystemNotification(p.chatId, null, p.title, p.body, p.threadId)
// 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)
// M5: the connection is live but the app is backgrounded — the
// in-app banner is invisible, so mirror to a system
// notification (the push backend only fires when
// there is no live subscriber). Suppressed for
// sync replays that already woke the device via
// push (docs/08 §8.7).
if (!isAppForeground() && !isPushedReplay(frame)) {
postSystemNotification(p.chatId, null, p.title, p.body, p.threadId)
}
}
}
}
@@ -650,7 +795,7 @@ class IrisController(
if (st.state == "restarting") {
// Gateway is going down (restart/stop):
// 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
// for that transition would delay 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
* 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.
@@ -765,7 +910,10 @@ class IrisController(
* refreshes (skipped on a plain reconnect via historyLoaded).
*/
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
_homeChannel.value = home ?: ChatStore.DEFAULT_LANE
if (home == null) return
@@ -803,6 +951,18 @@ class IrisController(
// ── 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) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
@@ -828,6 +988,7 @@ class IrisController(
}
fun setDefaultChannel(chatId: String) {
channels.setDefault(chatId)
client.sendFrame(channelSetDefaultFrame(0, chatId))
}
@@ -836,6 +997,7 @@ class IrisController(
chatId: String,
on: Boolean,
) {
channels.setFavorite(chatId, on)
client.sendFrame(channelFavoriteFrame(0, chatId, on))
}
@@ -845,6 +1007,7 @@ class IrisController(
chatId: String,
on: Boolean,
) {
channels.setAutomation(chatId, on)
client.sendFrame(channelSetAutomationFrame(0, chatId, on))
}
@@ -855,6 +1018,7 @@ class IrisController(
icon: String?,
color: String?,
) {
channels.setIcon(chatId, icon, color)
client.sendFrame(channelIconFrame(0, chatId, icon, color))
}
@@ -913,9 +1077,10 @@ class IrisController(
val lane = chat.laneKey(chatId, threadId)
if (lane in historyLoaded) return
// 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 —
// 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))
}
@@ -1008,8 +1173,9 @@ class IrisController(
* gateway error frame): queued (Pending) or failed bubbles that go out
* automatically on the next (re)connect. In-memory only — a process
* death leaves them as tap-to-retry (the local cache restore already
* marks pending sends failed). */
private val networkFailed = mutableSetOf<String>()
* marks pending sends failed). Synchronized: written by the send-result
* 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
* 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. */
fun attachFile(picked: PickedFile) {
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 =
PendingAttachment(
id = id,
filename = picked.name,
mime = picked.mime,
size = picked.size,
@@ -1084,7 +1255,7 @@ class IrisController(
)
_attachments.value =
_attachments.value.map {
if (it.filename == picked.name && it.uploading) {
if (it.id == id && it.uploading) {
result.fold(
{ ref -> it.copy(uploading = false, mediaRef = ref) },
{ 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. */
private fun pullMedia(offer: MediaOfferPayload) {
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.
mediaCache.path(offer.mediaId, offer.mime)?.let {
chat.setMediaLocalPath(offer.mediaId, it)
@@ -1141,11 +1318,13 @@ class IrisController(
fun dispose() {
client.stop()
// Final synchronous flush so the newest frames survive the process
// death (the debounce window may still hold unsaved changes).
// M-17: cancel the debounce job FIRST so a pending save can't fire
// 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.saveChannels(channels.channels.value)
job.cancel()
}
}
File diff suppressed because it is too large. Load diff
@@ -27,8 +27,11 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import iris.platform.QrScanButton
import iris.platform.isDesktop
import iris.state.IrisController
import iris.ui.theme.IrisColors
import iris.util.PairLink
import kotlinx.coroutines.launch
/**
@@ -91,11 +94,25 @@ fun ConnectScreen(
value = token,
onValueChange = { token = it },
label = { Text("Pairing token") },
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
placeholder = { Text("IRIS_TOKEN (64 hex)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
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))
Button(
@@ -129,7 +146,7 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(24.dp))
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.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -78,6 +78,7 @@ fun SettingsScreen(
val fontSizeScale by controller.fontSizeScale.collectAsState()
val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showForgetConfirm by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
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) {
@@ -437,6 +456,32 @@ fun SettingsScreen(
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
/**
* A bundled backdrop image shipped in the app (Android `assets/backdrops/`,
* desktop classpath `backdrops/`), loaded via [iris.platform.readBackdropBytes].
* A bundled backdrop image shipped in the app (Android: `androidApp` module
* `assets/backdrops/`; desktop: classpath `backdrops/`), loaded via
* [iris.platform.readBackdropBytes].
*
* Bundled backdrops are referenced in [UserTheme.backgroundImagePath] by the
* sentinel path `backdrop://<id>` (see [path]); user-picked images use a real
@@ -18,9 +19,12 @@ data class Backdrop(
companion object {
const val PATH_PREFIX = "backdrop://"
/** Default background for new chats / fresh installs. */
val DEFAULT = Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli")
val ALL: List<Backdrop> =
listOf(
Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli"),
DEFAULT,
Backdrop("pexels-bogdankrupin-12049700", "Bogdan Krupin"),
Backdrop("pexels-bosichong-27940302", "Bosi Chong"),
Backdrop("pexels-farhan-najeer-644774196-32490483", "Farhan Najeer"),
@@ -28,9 +32,6 @@ data class Backdrop(
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. */
fun isBackdropPath(path: String?): Boolean = !path.isNullOrBlank() && path.startsWith(PATH_PREFIX)
@@ -35,8 +35,6 @@ object IrisColors {
val textDim = Color(0xFF8A93A6)
// Bubbles
val bubbleUser = primary
val bubbleAssistant = Color(0xFF2A2E3B)
val bubbleCommentary = Color(0xFF23262F)
// Panels, chips, rows
@@ -47,7 +45,6 @@ object IrisColors {
val divider = Color(0xFF2A2E3B)
// Status
val statusGrey = Color(0xFF9E9E9E)
val statusAmber = Color(0xFFFFC107)
val statusGreen = Color(0xFF4CAF50)
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). */
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 (
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)
payload TEXT NOT NULL, -- serialized MessageItem
PRIMARY KEY (lane, id)
@@ -13,7 +13,7 @@ CREATE TABLE message (
CREATE TABLE tool (
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)
payload TEXT NOT NULL, -- serialized ToolItem (carries its anchor_id)
PRIMARY KEY (lane, id)
@@ -2,10 +2,14 @@ package iris.data
import iris.protocol.Frame
import iris.protocol.IrisJson
import iris.protocol.TYPE_TODO_UPDATE
import iris.protocol.TYPE_TOOL_START
import iris.protocol.TodoItem
import iris.protocol.TodoUpdatePayload
import iris.protocol.ToolStartPayload
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ChatStoreCacheTest {
@Test
@@ -13,7 +17,7 @@ class ChatStoreCacheTest {
val store = ChatStore()
store.loadFromCache(
mapOf(
"android:default" to
"default" to
listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
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
fun loadFromCacheEmptyIsNoOp() {
val store = ChatStore()
store.addPending("hello", "android:default")
store.addPending("hello", "default")
store.loadFromCache(emptyMap())
assertEquals(1, store.lanes.value["android:default"]!!.size)
assertEquals(1, store.lanes.value["default"]!!.size)
}
@Test
@@ -39,7 +43,7 @@ class ChatStoreCacheTest {
// to it, and the final answer.
store.loadFromCache(
mapOf(
"android:default" to
"default" to
listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
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
// it to the end.
store.loadHistory(
"android:default",
"default",
listOf(
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
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
@@ -65,7 +69,7 @@ class ChatStoreCacheTest {
val store = ChatStore()
store.loadFromCache(
mapOf(
"android:default" to
"default" to
listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", done = true),
@@ -73,11 +77,11 @@ class ChatStoreCacheTest {
),
)
store.loadHistory(
"android:default",
"default",
listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)),
)
// 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
@@ -87,7 +91,7 @@ class ChatStoreCacheTest {
// card minted by the PREVIOUS process.
store.loadFromCache(
mapOf(
"android:default" to
"default" to
listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
@@ -99,7 +103,7 @@ class ChatStoreCacheTest {
store.onFrame(
Frame(
type = TYPE_TOOL_START,
chatId = "android",
chatId = "iris:other",
payload =
IrisJson.instance.encodeToJsonElement(
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)
}
@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)
}
}
@@ -1,6 +1,7 @@
package iris.platform
import iris.state.IrisController
import java.util.concurrent.CopyOnWriteArrayList
/**
* 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
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
var foreground: Boolean = true
set(value) {
if (field != value) {
field = value
controller?.setForeground(value)
}
}
@Volatile
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) {
notificationListeners.add(listener)
}
fun notifyListeners(chatId: String?, title: String, body: String, threadId: String?) {
notificationListeners.toList().forEach { it(chatId, title, body, threadId) }
fun notifyListeners(
chatId: String?,
title: String,
body: String,
threadId: String?,
) {
notificationListeners.forEach { it(chatId, title, body, threadId) }
}
}
}
@@ -1,7 +1,6 @@
package iris.platform
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -35,7 +34,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
@@ -62,7 +60,6 @@ import kotlin.random.Random
*/
@Composable
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
var busy by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
@@ -71,16 +68,10 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
) {
Text("Attach a file:", fontSize = 13.sp)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
busy = true
val picked = pickFile()
busy = false
onPicked(picked)
},
enabled = !busy,
) {
Text(if (busy) "Choosing…" else "Choose file…")
// pickFile() blocks the UI thread (JFileChooser is modal), so there's
// no in-flight state to show while the dialog is open.
Button(onClick = { onPicked(pickFile()) }) {
Text("Choose file…")
}
}
}
@@ -138,7 +129,8 @@ private fun guessMime(name: String): String {
"mov" -> "video/quicktime"
"mkv" -> "video/x-matroska"
"mp3" -> "audio/mpeg"
"m4a", "aac" -> "audio/mp4"
"m4a" -> "audio/mp4"
"aac" -> "audio/aac"
"ogg", "opus" -> "audio/ogg"
"wav" -> "audio/wav"
"flac" -> "audio/flac"
@@ -59,7 +59,7 @@ object DesktopNotifier {
"\$n = New-Object System.Windows.Forms.NotifyIcon; " +
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
"\$n.Visible = \$true; " +
"\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " +
"\$n.ShowBalloonTip(4000, \"${psEsc(title)}\", \"${psEsc(body)}\", " +
"[System.Windows.Forms.ToolTipIcon]::Info); " +
"Start-Sleep -Milliseconds 4500; \$n.Dispose()",
)
@@ -71,4 +71,16 @@ object DesktopNotifier {
}
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", " ")
}
@@ -29,6 +29,12 @@ class DesktopSecureStore : SecureStore {
private val legacyFile = File(baseDir, "pairing.json")
private val secret = SecretBackend(baseDir)
// 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
private data class Settings(
val serverUrl: String = "",
@@ -77,24 +83,32 @@ class DesktopSecureStore : SecureStore {
),
)
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 =
if (settingsFile.exists()) {
try {
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
} catch (_: Exception) {
private fun load(): Settings {
cached?.let { return it }
val s =
if (settingsFile.exists()) {
try {
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
} catch (_: Exception) {
Settings()
}
} else {
Settings()
}
} else {
Settings()
}
cached = s
return s
}
private fun save(data: Settings) {
baseDir.mkdirs()
settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data))
cached = data
}
override var serverUrl: String
@@ -206,7 +220,7 @@ class DesktopSecureStore : SecureStore {
}
override var backgroundMode: String
get() = load().backgroundMode.ifBlank { "color" }
get() = load().backgroundMode.ifBlank { BackgroundMode.Image.name.lowercase() }
set(value) {
val d = load()
save(d.copy(backgroundMode = value))
@@ -257,8 +271,22 @@ class DesktopSecureStore : SecureStore {
}
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()
save(d.copy(serverUrl = ""))
save(
d.copy(
serverUrl = "",
deviceId = "",
syncCursor = 0L,
fcmToken = "",
ntfyTopic = "",
ntfyServer = "",
pushBackend = "",
),
)
secret.clear()
}
}
@@ -291,7 +319,12 @@ private class SecretBackend(
fun write(value: String) {
if (keyring != null) {
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
// without a live daemon). Drop the stale entry and fall back to
// the encrypted file so the token survives a restart.
@@ -384,28 +417,28 @@ private class KeyringBackend {
fun write(value: String) {
try {
if (isMac) {
ProcessBuilder(
"security",
"add-generic-password",
"-U",
"-a",
"iris",
"-s",
"iris-gateway-token",
"-w",
value,
).inheritIO().start().waitFor()
} else {
ProcessBuilder(
"secret-tool",
"store",
"--label=Iris gateway token",
"app",
"iris",
"token",
value,
).inheritIO().start().waitFor()
// 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) {
listOf(
"security",
"add-generic-password",
"-U",
"-a",
"iris",
"-s",
"iris-gateway-token",
"-w",
)
} else {
listOf("secret-tool", "store", "--label=Iris gateway token", "app", "iris")
}
ProcessBuilder(cmd).start().apply {
outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
waitFor()
}
} 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 val path: String
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 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
}
actual fun openWriter(mediaId: String, mime: String): MediaWriter =
JvmMediaWriter(File(mediaDir, "$mediaId${extForMime(mime)}"), this)
actual fun openWriter(
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() }
}
@@ -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"))
override val path: String get() = target.absolutePath
@@ -56,4 +81,4 @@ private class JvmMediaWriter(private val target: File, private val cache: MediaC
}
cache.evict()
}
}
}
@@ -43,24 +43,24 @@ class ChatDbTest {
val db = newDb()
db.saveLanes(
mapOf(
"android:default" to
"default" to
listOf<ChatItem>(
msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"),
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()
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
// (after the message they follow, before the answer).
assertEquals(listOf("m1", "tool_1", "m2"), loaded["android:default"]!!.map { it.id })
assertEquals(listOf("m3"), loaded["android:default::thr_1"]!!.map { it.id })
assertEquals(listOf("m1", "tool_1", "m2"), loaded["default"]!!.map { it.id })
assertEquals(listOf("m3"), loaded["default::thr_1"]!!.map { it.id })
// Messages ordered by ts.
assertEquals(100L, (loaded["android:default"]!![0] as MessageItem).ts)
assertEquals(200L, (loaded["android:default"]!![2] as MessageItem).ts)
assertEquals(100L, (loaded["default"]!![0] as MessageItem).ts)
assertEquals(200L, (loaded["default"]!![2] as MessageItem).ts)
}
@Test
@@ -68,7 +68,7 @@ class ChatDbTest {
val db = newDb()
db.saveLanes(
mapOf(
"android:default" to
"default" to
listOf<ChatItem>(
msg("m1", ts = 100),
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
@@ -85,7 +85,7 @@ class ChatDbTest {
val db = newDb()
db.saveLanes(
mapOf(
"android:default" to
"default" to
listOf<ChatItem>(
msg("m1", ts = 100),
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
@@ -101,7 +101,7 @@ class ChatDbTest {
val db = newDb()
db.saveLanes(
mapOf(
"android:default" to
"default" to
listOf<ChatItem>(
msg("m1", ts = 100),
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
// interrupted, not left spinning.
val open = lane.first { it.id == "tool_1" } as ToolItem
@@ -123,9 +123,9 @@ class ChatDbTest {
@Test
fun saveLanesReplacesPreviousSnapshot() {
val db = newDb()
db.saveLanes(mapOf("android:default" to listOf(msg("m1"), msg("m2"))))
db.saveLanes(mapOf("android:default" to listOf(msg("m2"))))
assertEquals(listOf("m2"), db.loadLanes()["android:default"]!!.map { it.id })
db.saveLanes(mapOf("default" to listOf(msg("m1"), msg("m2"))))
db.saveLanes(mapOf("default" to listOf(msg("m2"))))
assertEquals(listOf("m2"), db.loadLanes()["default"]!!.map { it.id })
}
@Test
@@ -133,7 +133,7 @@ class ChatDbTest {
val db = newDb()
db.saveLanes(
mapOf(
"android:default" to
"default" to
listOf(
msg("p1", status = MsgStatus.Pending, pending = true, ts = 0),
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 }
// A pending send becomes failed (tap to retry); the gateway never
// acknowledged it before the process died.
@@ -156,7 +156,7 @@ class ChatDbTest {
@Test
fun systemMessagesAreNotPersisted() {
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())
}
@@ -182,8 +182,8 @@ class ChatDbTest {
),
),
)
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(item)))
val loaded = db.loadLanes()["android:default"]!!.first() as MessageItem
db.saveLanes(mapOf("default" to listOf<ChatItem>(item)))
val loaded = db.loadLanes()["default"]!!.first() as MessageItem
assertEquals("gpt", loaded.runtime?.model)
assertEquals("/tmp/a.png", loaded.media.first().localPath)
}
@@ -193,26 +193,26 @@ class ChatDbTest {
val db = newDb()
db.saveChannels(
listOf(
ChannelInfo(chatId = "android:default", name = "General", isDefault = true),
ChannelInfo(chatId = "android:chan_1", name = "Work"),
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "android:chan_1"),
ChannelInfo(chatId = "default", name = "General", isDefault = true),
ChannelInfo(chatId = "chan_1", name = "Work"),
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "chan_1"),
),
)
val loaded = db.loadChannels()
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(true, loaded.first { it.chatId == "android:default" }.isDefault)
assertEquals(true, loaded.first { it.chatId == "default" }.isDefault)
}
@Test
fun metaRoundTripAndClearAll() {
val db = newDb()
assertNull(db.metaGet("last_lane"))
db.metaPut("last_lane", "android:chan_1")
assertEquals("android:chan_1", db.metaGet("last_lane"))
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(msg("m1"))))
db.saveChannels(listOf(ChannelInfo(chatId = "android:default", name = "General")))
db.metaPut("last_lane", "chan_1")
assertEquals("chan_1", db.metaGet("last_lane"))
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("m1"))))
db.saveChannels(listOf(ChannelInfo(chatId = "default", name = "General")))
db.clearAll()
assertTrue(db.loadLanes().isEmpty())
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)
}
}
+5 -5
View File
@@ -46,7 +46,7 @@ Everything in the feature checklist below.
| 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 |
| 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 |
| 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 |
@@ -57,7 +57,7 @@ Everything in the feature checklist below.
| Decision | Choice |
|---|---|
| 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** — FCM primary, ntfy fallback (`IRIS_PUSH_BACKEND`) |
| 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) |
@@ -66,7 +66,7 @@ Everything in the feature checklist below.
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,
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,
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.
@@ -88,6 +88,6 @@ Everything in the feature checklist below.
## Naming
- 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).
- Default chat id: **`android:default`** (the home channel).
- Default chat id: **`default`** (the home channel).
+5 -5
View File
@@ -12,7 +12,7 @@
│ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │
│ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │
│ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
│ │ │ AndroidAdapter │ JSON │ ws://host:8790/ws │ │ │
│ │ │ IrisAdapter │ JSON │ ws://host:8790/ws │ │ │
│ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │
│ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │
│ │ │ • media cache │ │ WSS │ │
@@ -40,8 +40,8 @@
## Process model
- **One `hermes gateway` process** hosts the agent core, the session store, the
cron scheduler, *and* our `android` platform plugin. The plugin's WebSocket
server runs on the gateway's asyncio loop (started in `AndroidAdapter.connect()`).
cron scheduler, *and* our `iris` platform plugin. The plugin's WebSocket
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
(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
@@ -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
**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.
2. **`send_message` tool routing** works out of the box (plugin
`parse_target_ref_fn`).
@@ -86,7 +86,7 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
1. App sends `message.send {text}` (or `/cmd`).
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.
4. Agent streams: `stream_delta_callback` → `GatewayStreamConsumer` →
`adapter.send()` (first) / `adapter.edit_message()` (updates) →
+7 -7
View File
@@ -13,10 +13,10 @@ iris_x_hermes/
│
├── 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)
│ ├── __init__.py
│ ├── adapter.py # AndroidAdapter(BasePlatformAdapter) + register(ctx)
│ ├── adapter.py # IrisAdapter(BasePlatformAdapter) + register(ctx)
│ ├── ws_server.py # websockets server, connection registry, framing
│ ├── protocol.py # frame schemas (source of truth, mirrored in Kotlin)
│ ├── media.py # inbound cache + outbound chunked streaming
@@ -50,9 +50,9 @@ iris_x_hermes/
## Module responsibilities
### `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).
- **`adapter.py`** — `AndroidAdapter(BasePlatformAdapter)` + `register(ctx)`.
- **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`.
The heart of the plugin. See `03-gateway-plugin.md`.
- **`ws_server.py`** — `websockets` server, per-device connection registry,
frame encode/decode, heartbeat, broadcast routing to all connected devices.
@@ -62,7 +62,7 @@ iris_x_hermes/
`media.offer`/`media.pull` chunked streaming.
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
- **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and
`NtfyBackend` (reuses hermes ntfy publish). Selected by `ANDROID_PUSH_BACKEND`.
`NtfyBackend` (reuses hermes ntfy publish). Selected by `IRIS_PUSH_BACKEND`.
- **`pairing.py`** — token generation/verification (constant-time), device
registry (SQLite), QR payload.
- **`search.py`** — FTS5 query bridge over the hermes session store.
@@ -83,7 +83,7 @@ Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
## Build systems
- **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`.
- **Kotlin/CMP:** Gradle (Kotlin DSL) with the Compose Multiplatform plugin.
`./gradlew :androidApp:installDebug`, `./gradlew :desktopApp:run`,
@@ -124,7 +124,7 @@ keystore.jks
## 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`.
- **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`.
- **App (desktop, dev):** `./gradlew :desktopApp:run`.
+42 -35
View File
@@ -1,6 +1,6 @@
# 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`
and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
**Zero hermes-core changes. Zero new Python dependencies** (`websockets` and
@@ -11,7 +11,7 @@ and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
## 3.1 `plugin.yaml` (manifest)
```yaml
name: android-platform
name: iris-platform
label: Android
kind: platform
version: 0.1.0
@@ -22,63 +22,63 @@ description: >
channels/threads, media, FTS5 search, and FCM/ntfy push.
author: <you>
requires_env:
- name: ANDROID_TOKEN
- name: IRIS_TOKEN
description: "Shared pairing token the app presents on connect"
prompt: "Android pairing token"
password: true
optional_env:
- name: ANDROID_WS_HOST
- name: IRIS_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host"
password: false
- name: ANDROID_WS_PORT
- name: IRIS_WS_PORT
description: "WS port (default 8790)"
prompt: "WS port"
password: false
- name: ANDROID_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"
password: false
- name: ANDROID_ALLOWED_USERS
- name: IRIS_ALLOWED_USERS
description: "Comma-separated allowed device_ids (empty = token-only auth)"
prompt: "Allowed device ids"
password: false
- name: ANDROID_ALLOW_ALL_USERS
- name: IRIS_ALLOW_ALL_USERS
description: "Allow any paired device (dev only)"
prompt: "Allow all devices? (true/false)"
password: false
- name: ANDROID_PUSH_BACKEND
- name: IRIS_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy"
prompt: "Push backend"
password: false
- name: ANDROID_FCM_SERVICE_ACCOUNT
- name: IRIS_FCM_SERVICE_ACCOUNT
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
prompt: "FCM service account path"
password: true
- name: ANDROID_FCM_SERVER_KEY
- name: IRIS_FCM_SERVER_KEY
description: "Legacy FCM server key (fallback if no service account)"
prompt: "FCM server key"
password: true
- 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"
password: false
- name: NTFY_SERVER_URL
description: "ntfy server URL (default https://ntfy.sh)"
prompt: "ntfy server URL"
password: false
- name: ANDROID_WS_CERT
- name: IRIS_WS_CERT
description: "TLS cert path for WSS (optional)"
prompt: "WSS cert"
password: false
- name: ANDROID_WS_KEY
- name: IRIS_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS key"
password: false
```
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
only.)
@@ -87,21 +87,21 @@ only.)
```python
def register(ctx):
ctx.register_platform(
name="android",
label="Android",
adapter_factory=lambda cfg: AndroidAdapter(cfg),
name="iris",
label="Iris",
adapter_factory=lambda cfg: IrisAdapter(cfg),
check_fn=check_requirements, # passive: websockets importable + token set
validate_config=validate_config, # host/port/token present
is_connected=is_connected,
required_env=["ANDROID_TOKEN"],
required_env=["IRIS_TOKEN"],
install_hint="No extra packages needed (websockets + httpx are core deps)",
setup_fn=interactive_setup, # hermes gateway setup flow
env_enablement_fn=_env_enablement, # seed extra + home_channel from env
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
parse_target_ref_fn=_parse_target_ref, # "android:<chat>[:<thread>]"
allowed_users_env="ANDROID_ALLOWED_USERS",
allow_all_env="ANDROID_ALLOW_ALL_USERS",
parse_target_ref_fn=_parse_target_ref, # "iris:<chat>[:<thread>]"
allowed_users_env="IRIS_ALLOWED_USERS",
allow_all_env="IRIS_ALLOW_ALL_USERS",
max_message_length=0, # 0 = no limit (WS has none)
emoji="📱",
pii_safe=False,
@@ -123,26 +123,29 @@ Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`):
`ensure_deps_fn`.
- **`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`
(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.
- **`_parse_target_ref(ref)`** — if `ref` starts with `android:`, return
`(chat_id, thread_id)` parsed from `android:<chat>[:<thread>]`; else `None`.
- **`_parse_target_ref(ref)`** — the core strips the platform prefix first, so
`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,
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
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
- **`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.
- Start the `websockets` server on `host:port` (TLS if cert/key set).
- `_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()`.
### Inbound (app → agent)
- WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via
`self.build_source(chat_id, chat_name, chat_type, user_id, user_name,
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.
### Outbound (agent → app)
- **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`**
- Split reasoning prefix (see `05-streaming.md`) → `reasoning` field.
- 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).
### Streaming hooks
The main gateway drives delivery through the **legacy callback path**:
- `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) +
`edit_message()` (updates) → `message.start` / `message.update`.
- `tool_progress_callback` → progress queue → `send_progress_messages` →
@@ -230,7 +237,7 @@ verified empirically in M2 (see `13-testing.md`).
`message.update` (coalesce to latest) under pressure, never drop
`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).
> Never hardcode `~/.hermes`.
@@ -245,9 +252,9 @@ verified empirically in M2 (see `13-testing.md`).
## 3.6 Config resolution
- **Secrets (`.env`):** `ANDROID_TOKEN`, `ANDROID_FCM_SERVICE_ACCOUNT`,
`ANDROID_FCM_SERVER_KEY`, `ANDROID_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.android.extra`):** `host`,
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
`IRIS_FCM_SERVER_KEY`, `IRIS_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
`max_upload_bytes`, `tls`.
- Env vars override `config.yaml` (hermes convention). Read secrets with the
@@ -260,4 +267,4 @@ verified empirically in M2 (see `13-testing.md`).
- All outbound sends are best-effort; a dead socket latches and the frame falls
to the outbox.
- `disconnect()` cancels the server task and closes sockets cleanly.
- Token/PII redaction in all logs (hermes PII policy).
- Token/PII redaction in all logs (hermes PII policy).
+61 -21
View File
@@ -12,7 +12,7 @@ Every frame:
"v": 1,
"id": 42, // optional; present on requests + their responses
"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
"payload": { } // type-specific object
}
@@ -43,7 +43,7 @@ Pairing succeeded.
"search":true,"push":"fcm","pickers":true},
"sync_cursor":1042,
"last_pushed_cursor":1040,
"channels":[{"chat_id":"android:default","name":"Default","kind":"default","is_default":true}]
"channels":[{"chat_id":"default","name":"Default","kind":"default","is_default":true}]
}}
```
@@ -57,7 +57,7 @@ woke the device via push (dedupe, `08-push.md` §8.7).
A final / standalone message.
```json
{"type":"message","chat_id":"android:default","thread_id":null,
{"type":"message","chat_id":"default","thread_id":null,
"payload":{
"message_id":"m_9001","role":"assistant",
"text":"Here is the answer…",
@@ -107,7 +107,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`.
```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"]}}
```
@@ -126,7 +126,7 @@ truncated / nothing).
```json
{"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.end","chat_id":"…","payload":{"index":3,"name":"terminal","ok":true,"duration":12.4,
"output_preview":"12 passed"}}
@@ -135,6 +135,36 @@ truncated / nothing).
`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).
`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`
```json
@@ -156,6 +186,16 @@ In-app banner (foreground) and/or push mirror (background).
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
{"type":"picker.model","chat_id":"…","payload":{
"picker_id":"pm_1","current_model":"qwen3-27b","current_provider":"local",
@@ -171,7 +211,7 @@ Channel directory updates. **Broadcast to all connected devices** (no explicit
subscribe; the server pushes to every open WS).
```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}}
```
@@ -185,7 +225,7 @@ title.
Response to a `history` request. Returns a page of messages for a chat/thread.
```json
{"type":"history","id":20,"chat_id":"android:default","thread_id":null,
{"type":"history","id":20,"chat_id":"default","thread_id":null,
"payload":{
"messages":[
{"message_id":"m_8990","role":"user","text":"Hi","ts":1723990000000},
@@ -239,9 +279,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`.
```json
{"type":"agent.busy","chat_id":"android:default","thread_id":null,
{"type":"agent.busy","chat_id":"default","thread_id":null,
"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`.
@@ -251,7 +291,7 @@ Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`
```json
{"type":"search.results","id":7,"payload":{
"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}]}}
```
@@ -270,7 +310,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.
```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
@@ -314,7 +354,7 @@ First frame; auth + caps.
```json
{"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"},
"fcm_token":"<FCM token>","ntfy_topic":"<topic, if ntfy>"}}
```
@@ -324,7 +364,7 @@ First frame; auth + caps.
Send text (or a `/slash-command`).
```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"],
"auto_thread":false}}
```
@@ -378,8 +418,8 @@ Answer an interactive picker.
```json
{"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.set_default","id":16,"chat_id":"android:chan_7","payload":{}}
{"type":"channel.rename","id":15,"chat_id":"chan_7","payload":{"name":"Reports"}}
{"type":"channel.set_default","id":16,"chat_id":"chan_7","payload":{}}
```
`channel.delete` is a **hard delete**: the channel/thread row is removed from
@@ -391,7 +431,7 @@ removes its threads. The default channel cannot be deleted.
```json
{"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`.
@@ -403,7 +443,7 @@ broadcasts to other devices (for multi-device ✓✓ sync). The app uses it to
mark messages as read locally (✓✓ on user bubbles).
```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`
@@ -411,7 +451,7 @@ mark messages as read locally (✓✓ on user bubbles).
Load a page of messages for a chat/thread (initial open, scroll-up pagination).
```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}}
```
@@ -428,7 +468,7 @@ message already gone (pruned by retention) still yields a `message.deleted`
broadcast so live caches drop it.
```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"]}}
```
@@ -453,7 +493,7 @@ Autocomplete for a typed `/prefix`.
Stop the current agent turn (abort generation / tool execution).
```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`
@@ -461,7 +501,7 @@ Stop the current agent turn (abort generation / tool execution).
Inject a steering message mid-turn (redirects the agent without a new turn).
```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."}}
```
+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.
**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.
- **App side (per device):** Settings → "Streaming" toggle (default on). When
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>`
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
**Plugin config.** Set for the `android` platform:
**Plugin config.** Set for the `iris` platform:
```yaml
display:
platforms:
android:
iris:
show_reasoning: true
reasoning_style: code # we split on the code-fence form
```
+11 -11
View File
@@ -9,10 +9,10 @@ gateway identity concepts**.
| App concept | hermes primitive | Example |
| --- | --- | --- |
| Default chat | home channel `chat_id` | `android:default` |
| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=android:default, thread_id=t_12` |
| A user-created channel | a new `chat_id` | `android:chan_7` |
| A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=android:chan_7, thread_id=t_31` |
| Default chat | home channel `chat_id` | `default` |
| 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` | `chan_7` |
| 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).
- **`thread_id`** = an optional sub-lane within a `chat_id` (topic-like).
@@ -23,7 +23,7 @@ gateway identity concepts**.
## 6.2 Default chat
- On first connect, the plugin ensures a **default channel** exists:
`chat_id = ANDROID_HOME_CHANNEL` (default `android:default`), `kind=default`,
`chat_id = ANDROID_HOME_CHANNEL` (default `default`), `kind=default`,
`is_default=true`, name "Default".
- It is also the **cron home channel** (`cron_deliver_env_var=
ANDROID_HOME_CHANNEL`), so `deliver=android` (bare) routes here.
@@ -37,7 +37,7 @@ gateway identity concepts**.
- **Threads OFF** — flat conversation; all messages use `thread_id=null`.
- **Threads ON** — the app groups the conversation into topic-like lanes.
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.
- 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
@@ -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
delegated to them** instead of the default chat.
- **`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.rename` / `channel.set_default` / `channel.delete`** manage the
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
`parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the
`send_message` tool can target any channel/thread:
- `deliver="android"` → home (default) channel.
- `deliver="android:android:chan_7"` → that channel.
- `deliver="android:android:chan_7:t_31"` → that channel's thread.
- `deliver="iris"` → home (default) channel.
- `deliver="iris:chan_7"` → that channel.
- `deliver="iris:chan_7:t_31"` → that channel's thread.
- 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.
- **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`
(`_resolve_single_delivery_target`). For `platform:chat_id[:thread_id]` it
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
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;
+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
read the file.
**Limits:** `get_inbound_media_max_bytes()` / `validate_inbound_media_size`
(`base.py:758/779`) enforce the cap; over-limit → 413 +
`error {code:"media_too_large"}`. (The 1 MiB `MAX_BODY_BYTES` cap applies to
JSON *frame* bodies only, not media uploads.)
**Limits:** two caps apply — the plugin's `max_upload_bytes` (default
100 MiB) and hermes's `gateway.max_inbound_media_bytes` (default 128 MiB,
enforced by `get_inbound_media_max_bytes()` / `validate_inbound_media_size`,
`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
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
- Cache dir: app-specific external cache (`getExternalCacheDir()/media`).
- LRU eviction by size (configurable, default 500 MB) so old media doesn't fill
the device.
- Cache dir: app-specific external cache (`getExternalCacheDir()/media`,
internal `cacheDir` fallback; desktop: `~/.iris/cache/media`).
- 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
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).
+41 -8
View File
@@ -1,17 +1,48 @@
# 08 — Push Notifications, Outbox & Sync
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: FCM primary, ntfy fallback** (`IRIS_PUSH_BACKEND`).
## 8.1 When push fires
- A frame targets a `chat_id` whose device is **disconnected** (WS closed) →
drop to **outbox** + fire **push**.
- A frame targets a `chat_id` whose device is **disconnected** (no live
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
the app is backgrounded (approvals, clarifies, cron completions) — the app
decides whether to also show an in-app banner.
- 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`)
```python
@@ -23,13 +54,14 @@ class PushBackend(Protocol):
def configured(self) -> bool: ...
```
Selected at adapter init by `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`).
Selected at adapter init by `IRIS_PUSH_BACKEND` (`fcm` default, `ntfy`).
### 8.2.1 `FcmBackend` (primary)
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
account** (`ANDROID_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
OAuth2 access token (cached, refreshed before expiry).
- Fallback: legacy **server key** (`ANDROID_FCM_SERVER_KEY`) if no service
account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
OAuth2 access token (cached, refreshed before expiry).
- Fallback: legacy **server key** (`IRIS_FCM_SERVER_KEY`) if no service
account (simpler, but legacy).
- Target = the device's **FCM token** (registered via `hello` /
`fcm.register`, stored in `devices.db`).
@@ -43,6 +75,7 @@ Selected at adapter init by `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`).
devices).
### 8.2.2 `NtfyBackend` (fallback, self-host friendly)
- 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
`httpx` POST, with an `X-Title` / `X-Message` / `X-Tag` / `X-Priority` and a
@@ -132,4 +165,4 @@ device push watermark:
Residual edge: FCM is at-least-once, so a lost device ack can still produce a
duplicate *system-displayed* notification (two `FCM-Notification:*` ids). The
designed evolution is the data-only push option (§8.2.1), which moves display
into the app and lets it use a stable per-message notification id.
into the app and lets it use a stable per-message notification id.
+20 -19
View File
@@ -13,19 +13,20 @@
## 9.2 Pairing flow
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`.
2. **Present to the app.** Two options:
- **QR code:** the setup prints a QR encoding
`iris://pair?host=<lan-ip>&port=8790&token=<token>` (or a WSS URL). The
phone scans it with the app's camera (or a system scanner) → pre-fills
`iris://pair?host=<lan-ip>&port=8791&secure=0&token=<token>` (or a WSS
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.
- **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,
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
`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`.
**On failure:** send `error {code:"auth"}` and close.
@@ -36,24 +37,24 @@ security principal (the token is).
## 9.3 Auth model
- **Token = the security principal.** Any connection presenting the valid
`ANDROID_TOKEN` is authorized (it's the user's own token).
- **Allowlist (optional):** `ANDROID_ALLOWED_USERS` (comma-separated
`IRIS_TOKEN` is authorized (it's the user's own token).
- **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated
`device_id`s) restricts which *devices* may connect even with the token —
useful if the token is shared. `ANDROID_ALLOW_ALL_USERS=true` disables the
useful if the token is shared. `IRIS_ALLOW_ALL_USERS=true` disables the
allowlist (dev only).
- **Per-device tokens (stretch):** mint a unique token per device at pairing
(revocable) instead of one shared token. v1 uses the shared token + optional
device allowlist.
- **Re-pairing:** rotating `ANDROID_TOKEN` invalidates all devices; they must
- **Re-pairing:** rotating `IRIS_TOKEN` invalidates all devices; they must
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR.
## 9.4 Transport security
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
network.
- **WSS (recommended for remote):** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY`
- **WSS (recommended for remote):** set `IRIS_WS_CERT` / `IRIS_WS_KEY`
(self-signed or CA-signed). The app pins/accepts the cert (self-signed → user
confirms fingerprint on first pair, like a SSH host key).
confirms fingerprint on first pair, like a SSH host key).
- **Remote reachability options** (documented, user's choice):
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
app connects over the private mesh. No public exposure.
@@ -61,11 +62,11 @@ security principal (the token is).
at the edge, forward WS to `127.0.0.1:8790`.
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames
over plain HTTP (`ANDROID_HTTP_PORT`, default 8791) for the app's
over plain HTTP (`IRIS_HTTP_PORT`, default 8791) for the app's
fallback transport. It is a *second door with the same lock*: the same
Bearer token (constant-time `verify_token`) + the same device allowlist
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as
the WS. Optional TLS via `ANDROID_HTTP_CERT` / `ANDROID_HTTP_KEY`.
the WS. Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
`GET /v1/health` is unauthenticated by design (liveness only — it must
not reflect tokens, device ids, or versions).
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
@@ -73,7 +74,7 @@ security principal (the token is).
## 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).
- **`device_id`** is a random UUID (not PII). `device_name` is user-chosen.
- **Media pull** is gated by hermes `validate_media_delivery_path` + delivery
@@ -84,7 +85,7 @@ security principal (the token is).
## 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
`plugins/platforms/irc/adapter.py:42`) so multiplexed profiles don't leak
each other's tokens (fail-closed under `gateway.multiplex_profiles`).
@@ -110,16 +111,16 @@ M7 research pass. "verified" = implemented and covered by
"gap" = known limitation with the planned mitigation.
| # | Item | Status | Evidence / mitigation |
|---|------|--------|-----------------------|
| --- | ------ | -------- | ----------------------- |
| 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) |
| 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` |
| 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 |
| 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 | WSS + cert pinning for remote | gap (partial) | WSS supported server-side (`IRIS_WS_CERT`/`IRIS_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 |
| 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_WS_CERT`/`IRIS_WS_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 |
| 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` |
| 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` |
+9 -1
View File
@@ -115,7 +115,7 @@ app/shared/src/
- `ToolCard` renders `tool.start/progress/end` frames.
- 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
call via the `post_tool_call` hook (→ `tool.end` `output_preview` /
`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"
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.
- **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
with honest copy and a way out.
+14 -14
View File
@@ -57,8 +57,8 @@ hermes --version # sanity
```bash
# install the plugin (dev: symlink)
mkdir -p ~/.hermes/plugins
ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/android
hermes gateway status # should list "android"
ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/iris
hermes gateway status # should list "iris"
hermes gateway # run
```
- Tests use hermes's hermetic runner (never bare `pytest`):
@@ -73,43 +73,43 @@ hermes --version # sanity
`dev.iris.app`). Download `google-services.json` → `app/androidApp/`.
3. Create a **service account** (Project settings → Service accounts → Generate
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
registers it via `hello` / `fcm.register`.
> Skip Firebase → set `ANDROID_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` /
> Skip Firebase → set `IRIS_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` /
> `NTFY_SERVER_URL` (self-host ntfy or use ntfy.sh). See `08-push.md`.
## 12.6 Environment variables (summary)
**Secrets (`~/.hermes/.env`):**
```
ANDROID_TOKEN=<64-hex>
ANDROID_PUSH_BACKEND=fcm # or ntfy
ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
# ANDROID_FCM_SERVER_KEY=<legacy key> # fallback if no service account
IRIS_TOKEN=<64-hex>
IRIS_PUSH_BACKEND=fcm # or ntfy
IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
# IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
# NTFY_TOPIC=iris-push # when ntfy
# NTFY_SERVER_URL=https://ntfy.sh
# ANDROID_WS_CERT=/path/cert.pem # WSS
# ANDROID_WS_KEY=/path/key.pem
# IRIS_WS_CERT=/path/cert.pem # WSS
# IRIS_WS_KEY=/path/key.pem
```
**Behavioral (`~/.hermes/config.yaml`):**
```yaml
gateway:
platforms:
android:
iris:
enabled: true
extra:
host: 127.0.0.1 # 0.0.0.0 for LAN
port: 8790
home_channel: android:default
home_channel: default
push_backend: fcm
outbox_retention_hours: 72
max_upload_bytes: 104857600 # 100 MB
display:
platforms:
android:
iris:
show_reasoning: true
reasoning_style: code
streaming: true
@@ -128,7 +128,7 @@ 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",
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
"caps":{"min_protocol":1}}}))
print("recv:", await ws.recv())
asyncio.run(main())
+5 -5
View File
@@ -19,7 +19,7 @@ without the app (critical for verifying frame shapes early).
- `register(ctx)` produces a valid `PlatformEntry` (name, cron env var,
parse_target_ref).
- `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-android
→ None.
- **Reasoning split:** given a `show_reasoning`-style final text, `send()`
emits `message {reasoning, text}` correctly; no-prefix → no reasoning field.
@@ -54,7 +54,7 @@ Kotlin client.
```bash
hermes gateway & # with the android 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"
# prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end,
# commentary, message.stop {reasoning,…}, …
@@ -112,7 +112,7 @@ adb logcat -d > /tmp/logcat.txt
verbosity in Settings → rendering changes.
5. **Intermediate:** a multi-step prompt → commentary bubble appears dimmed.
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).
8. **Search:** "search everywhere" vs "this chat" → correct scoping; tap → jump.
9. **Media (in):** attach a photo + a video → agent receives (vision) → reply.
@@ -144,11 +144,11 @@ adb logcat -d > /tmp/logcat.txt
## 13.5 Debugging tips
- **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
from the app.
- **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
within hermes delivery roots (`validate_media_delivery_path`).
- **FCM not arriving:** confirm the token registered (`devices.db`), the service
+56 -9
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
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
**Goal:** everything builds; the plugin is discoverable; the repo is safe.
- [X] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`).
- [X] `cd hermes-agent && uv sync` (hermes venv works).
- [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`,
`./gradlew :desktopApp:run` (blank window).
- [X] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a
no-op `AndroidAdapter` → `hermes gateway status` lists **android**.
- **Demo:** `hermes gateway status` shows `android`; `./gradlew
no-op `IrisAdapter` → `hermes gateway status` lists **iris**.
- **Demo:** `hermes gateway status` shows `iris`; `./gradlew
:androidApp:installDebug` installs a blank app on the MIX 2S.
- **Accept:** blank app installs + launches on-device; plugin visible in
`hermes gateway status`; `hermes-agent/` is git-ignored (verify with
`git status --ignored`).
## M1 — Gateway core loop (text round-trip)
**Goal:** pair + send a text message + get a (non-streaming) reply.
- [X] WS server (`ws_server.py`): bind, `hello` auth (constant-time),
`hello.ack`, heartbeat, connection registry.
- [X] `AndroidAdapter.send()` → `message` frame; inbound `message.send` →
- [X] `IrisAdapter.send()` → `message` frame; inbound `message.send` →
`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`
(connect + reconnect), ChatScreen sends + renders `message`.
- [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.
## M2 — Streaming + reasoning + tools + commentary
**Goal:** the "agent transparency" features.
- [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
returns a separate `reasoning_content` field. In the *streaming* case 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`.
## M3 — Channels/threads + cron + search
**Goal:** organization + cron delegation + search.
- [X] Channel directory (SQLite): default channel ensured; `channel.create/
rename/set_default/delete` + `channel.*` frames.
- [X] Threads: toggle in default chat; `thread_id` lanes; `create_handoff_thread`.
- [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] App: channel list (drawer/rail), thread toggle + topic switcher, "new
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.
- **Status (complete):** channel directory + threads + search + outbox sync
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
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
@@ -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).
## M4 — Media
**Goal:** attach + receive + play media.
- [X] Inbound: `media.upload` chunked → `cache_*_from_bytes` → `media_urls`;
size limit + sha256 + MIME re-sniff.
- [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`.
## M5 — Push + offline (FCM + ntfy)
**Goal:** reach the phone when backgrounded; catch up on reconnect.
- [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune
(row cap 5000 + prune banner, throttled 1/h).
- [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via
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.
High-priority kinds (approval/clarify/cron) push even when live.
- [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a
@@ -143,7 +155,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
loss/dup (verified); banners show for foreground events (implemented).
## M6 — Desktop app
**Goal:** the same app on a big screen.
- [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView);
`MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt.
- [x] Two-pane default layout; keyboard shortcuts; optional inspector pane.
@@ -178,7 +192,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
macOS/Windows packaging are deferred to M7.
## M7 — Polish + E2E + docs
**Goal:** ship-quality.
- [x] Telegram-style layout pass (per reference image): header, bubbles, date
separators, ✓✓, model/token footer, banner, bottom bar.
- [x] Theming (dark default, accent), onboarding/pairing UX, empty/loading/
@@ -222,7 +238,38 @@ 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
- **M1/M2 depend on the `ws_probe.py` harness** to lock frame shapes early —
build it in M1.
- **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3,
+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
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.
## Plugin / platform registration
+10 -8
View File
@@ -3,24 +3,24 @@
## Locked decisions (from planning, 2026-08-19)
| # | Decision | Choice | Rationale |
|---|---|---|---|
| --- | --- | --- | --- |
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Android 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 — FCM primary, ntfy fallback** | FCM is standard/reliable; ntfy for self-hosters with no Firebase. `IRIS_PUSH_BACKEND`. |
| 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. |
## Additional decisions made during planning
| 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. |
| 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. |
| 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. |
| 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. |
| minSdk | 26 (test device API 29) | Broad coverage. |
| 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. |
| 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. |
| 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)
@@ -45,7 +47,7 @@ will proceed with unless you say otherwise.
`reasoning_style: code` for android and split on that; fallback = no
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
`ANDROID_TOKEN` + optional `ANDROID_ALLOWED_USERS` device allowlist.
`IRIS_TOKEN` + optional `IRIS_ALLOWED_USERS` device allowlist.
Per-device revocable tokens are a stretch.
4. **Desktop video backend (M6).** *Default:* `libmpv`/`mpv`-backed Compose
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
by default.
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.
7. **App package name / branding.** *Default:* applicationId `dev.iris.app`,
app name "Iris". Confirm final product name + package + icon.
@@ -72,4 +74,4 @@ will proceed with unless you say otherwise.
- End-to-end encryption (transport WSS only).
- Standalone-cron delivery while the gateway process is fully down (best-effort
push only).
- iOS.
- iOS.
+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`).
2. **Security is the real gate.** The single-user model in
`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
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,
+5 -5
View File
@@ -71,7 +71,7 @@ acceptable alternative if preferred).
```
┌──────────────────────── hermes gateway process ───────────────────────┐
│ AndroidAdapter │
│ IrisAdapter │
│ │ frames (same protocol.Frame objects) │
│ ▼ │
│ _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`
New module, started/stopped by `AndroidAdapter.connect()`/`disconnect()` next
New module, started/stopped by `IrisAdapter.connect()`/`disconnect()` next
to the WS server.
- **Server:** `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`,
@@ -114,8 +114,8 @@ to the WS server.
into the gateway's asyncio loop with
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
start, same loop the WS server runs on).
- **Config:** `ANDROID_HTTP_PORT` (default **8791**), same bind host as the WS
(`ANDROID_WS_HOST`). Optional TLS via `ANDROID_HTTP_CERT`/`ANDROID_HTTP_KEY`
- **Config:** `IRIS_HTTP_PORT` (default **8791**), same bind host as the WS
(`IRIS_WS_HOST`). Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
(`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a
trusted LAN by default, TLS for remote/Tailscale setups.
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the
@@ -151,7 +151,7 @@ Wire format (standard SSE, three fields):
```
id: 1043
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)
```
+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 android 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`).
+2 -1
View File
@@ -41,6 +41,7 @@ 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. |
| 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. |
| 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:
@@ -51,7 +52,7 @@ Machine-readable / diagrams:
## 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
connect to. Implements the full `BasePlatformAdapter` contract. **Zero new
Python dependencies, zero hermes-core changes.**
+1 -1
View File
@@ -17,7 +17,7 @@ flowchart TB
WSS["WebSocket SERVER<br/>(websockets) ws://host:8790/ws"]
end
AGENT -->|legacy stream callbacks| ADAPTER
CRON -->|deliver=android:chat:thread| ADAPTER
CRON -->|deliver=iris:chat:thread| ADAPTER
ADAPTER <--> WSS
ADAPTER <--> OUTBOX
ADAPTER <--> PUSH
+6 -5
View File
@@ -10,7 +10,7 @@
"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." },
"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." },
"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." }
@@ -47,9 +47,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.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" } } },
"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.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" } } },
"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" } } } },
@@ -66,7 +67,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." } } },
"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" } } },
"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": {
"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 +90,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)." } } },
"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" } } },
"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" } } }
}
},
@@ -99,11 +102,9 @@
},
"x-planned-frames": [
{ "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.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.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": "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)." },
+20 -17
View File
@@ -9,7 +9,7 @@ just the steps.
## Prerequisites
| 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 |
@@ -24,8 +24,8 @@ root):
```bash
mkdir -p ~/.hermes/plugins
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android
hermes gateway status # should list "android"
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
hermes gateway status # should list "iris"
```
Run the interactive setup:
@@ -36,12 +36,13 @@ hermes gateway setup
What it does:
- Generates `ANDROID_TOKEN` (64 hex chars) if none exists and stores it in
- Generates `IRIS_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`).
string), a scannable QR of that payload, and the server URL
(`ws://<host>:8790/ws`).
Then start the gateway:
@@ -52,7 +53,7 @@ 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`).
> `IRIS_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`).
## 2. Android app
@@ -68,12 +69,14 @@ 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.
`grep IRIS_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.
> **Scan QR (Android):** the Connect screen has a **Scan QR** button (CameraX +
> ML Kit) that reads the QR printed by `hermes gateway setup` and pre-fills the
> URL + token. Desktop has no camera, so it uses manual entry. An `iris://pair`
> deep link (from any scanner) pre-fills the same way.
## 3. Desktop app
@@ -104,8 +107,8 @@ high-priority events (approvals, clarifies, cron) even when a device is live.
`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).
`IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
3. Keep `IRIS_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.
@@ -116,7 +119,7 @@ backgrounded; tapping one deep-links to the chat.
### ntfy (zero-config fallback)
```
ANDROID_PUSH_BACKEND=ntfy
IRIS_PUSH_BACKEND=ntfy
```
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed);
@@ -135,7 +138,7 @@ the app is off; incoming pushes trigger a silent sync.
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
- **WSS:** set `IRIS_WS_CERT` / `IRIS_WS_KEY` (paths, in
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
> **Honest limitation:** the app has **no certificate pinning** yet, so
@@ -145,8 +148,8 @@ the app is off; incoming pushes trigger a silent sync.
## 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). |
| --- | --- |
| `auth failed` / `error {code:"auth"}` on connect | Wrong token. Check `IRIS_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. |
@@ -159,7 +162,7 @@ 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",
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
"caps":{"min_protocol":1}}}))
print("recv:", await ws.recv())
asyncio.run(main())
@@ -167,4 +170,4 @@ PY
```
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
wrong.
wrong.
+615 -143
View File
File diff suppressed because it is too large. Load diff
+8 -8
View File
@@ -3,9 +3,9 @@
Maps app concepts onto hermes' existing ``chat_id`` / ``thread_id`` primitives
(docs/06-channels-cron-search.md §6.1):
* **default chat** -> the home channel (``ANDROID_HOME_CHANNEL``, default
``android:default``), ``kind="default"``, ``is_default=1``.
* **user channel** -> a minted ``chat_id = android:chan_<n>``, ``kind="channel"``.
* **default chat** -> the home channel (``IRIS_HOME_CHANNEL``, default
``default``), ``kind="default"``, ``is_default=1``.
* **user channel** -> a minted ``chat_id = chan_<n>``, ``kind="channel"``.
* **thread** -> a minted ``thread_id = t_<n>`` under a ``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
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.
"""
@@ -37,12 +37,12 @@ KIND_CHANNEL = "channel"
KIND_THREAD = "thread"
# chat_id / thread_id minting prefixes.
CHANNEL_PREFIX = "android:chan_"
CHANNEL_PREFIX = "chan_"
THREAD_PREFIX = "t_"
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
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
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"
with self._lock:
existing = self._conn.execute(
@@ -443,6 +443,6 @@ def get_directory() -> ChannelDirectory:
# failure is not actionable.
with contextlib.suppress(Exception):
_directory.close()
_directory = ChannelDirectory(home / "android" / "channels.db")
_directory = ChannelDirectory(home / "iris" / "channels.db")
_directory_home = home
return _directory
+2
View File
@@ -57,6 +57,8 @@ async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) ->
await adapter.on_message_delete(frame, device_id)
elif frame.type == protocol.TYPE_FCM_REGISTER:
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).
+50 -20
View File
@@ -199,9 +199,9 @@ class HttpServer:
from gateway.status import acquire_scoped_lock
lock_key = f"http:{host}:{port}"
if not acquire_scoped_lock("android", lock_key):
if not acquire_scoped_lock("iris", lock_key):
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,
port,
)
@@ -218,18 +218,16 @@ class HttpServer:
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
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()
return
self._httpd = httpd
self._thread = threading.Thread(
target=httpd.serve_forever, name="android-http", daemon=True
)
self._thread = threading.Thread(target=httpd.serve_forever, name="iris-http", daemon=True)
self._thread.start()
self.enabled = True
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:
"""Stop serving and unblock all subscribers."""
@@ -261,7 +259,7 @@ class HttpServer:
from gateway.status import release_scoped_lock
if self._lock_key:
release_scoped_lock("android", self._lock_key)
release_scoped_lock("iris", self._lock_key)
self._lock_key = None
# ── Subscriber registry ───────────────────────────────────────────────
@@ -277,6 +275,13 @@ class HttpServer:
def _add_sub(self, sub: _Subscriber) -> None:
with self._subs_lock:
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:
with self._subs_lock:
@@ -307,7 +312,7 @@ class HttpServer:
except queue.Full:
# Slow subscriber: drop it. The client reconnects with
# 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()
self._remove_sub(s)
return sent
@@ -331,7 +336,7 @@ class HttpServer:
and 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"})
return None
with contextlib.suppress(Exception):
@@ -436,7 +441,7 @@ class HttpServer:
try:
await dispatch.dispatch_frame(self._adapter, frame, device_id)
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:
# Pop our sink entry (a newer request from the same device may
# have replaced it). If the HTTP response was already sent
@@ -478,17 +483,23 @@ class HttpServer:
ntfy_topic,
)
except Exception:
logger.warning("android: device registry upsert failed", exc_info=True)
logger.warning("iris: device registry upsert failed", exc_info=True)
sub = _Subscriber(device_id=device_id, kind="sse")
# Register BEFORE the replay so a frame appended in between is
# fanned out to us (and de-duped by cursor below) instead of lost.
self._add_sub(sub)
reason = "eof"
try:
handler.send_response(200)
handler.send_header("Content-Type", "text/event-stream")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
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
# carries the cursor for the app's push dedupe).
max_cursor = cursor
@@ -507,14 +518,32 @@ class HttpServer:
self._write_sse(
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).
while not sub.closed.is_set():
try:
item = sub.q.get(timeout=SSE_HEARTBEAT_S)
except queue.Empty:
self._write_raw(handler, ": hb\n\n")
continue
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:
item = sub.q.get(timeout=SSE_HEARTBEAT_S)
except queue.Empty:
self._write_raw(handler, ": hb\n\n")
continue
if item is _STOP:
reason = "stopped"
break
c, data = item
if c is not None and c <= max_cursor:
@@ -523,8 +552,9 @@ class HttpServer:
except (BrokenPipeError, ConnectionResetError, OSError):
# Client went away mid-stream: normal (the app reconnects with
# Last-Event-ID and catches up from the outbox).
pass
reason = "client-gone"
finally:
logger.info("iris: SSE stream closed: %s (%s)", device_id, reason)
self._remove_sub(sub)
@staticmethod
@@ -727,7 +757,7 @@ class _Handler(BaseHTTPRequestHandler):
server: _ThreadingHTTPD
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
logger.debug("android http: " + fmt, *args)
logger.debug("iris http: " + fmt, *args)
# ── Routing ───────────────────────────────────────────────────────────
+4 -4
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
``_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.
"""
@@ -231,7 +231,7 @@ class UploadSession:
self.failed = True
self.error_code = code
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:
return self._sha.hexdigest()
@@ -262,7 +262,7 @@ class MediaStore:
"""
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._lock = threading.Lock()
# (device_id, media_ref) -> UploadSession (one active per device)
@@ -374,7 +374,7 @@ class MediaStore:
with self._lock:
self._inbound[media_ref] = entry
logger.info(
"android: upload %s cached as %s (%s, %d bytes)",
"iris: upload %s cached as %s (%s, %d bytes)",
media_ref,
kind,
path,
+84 -50
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
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).
"""
@@ -35,8 +35,16 @@ _PRUNE_INTERVAL_S = 3600.0
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:
"""Persistent outbox under ``get_hermes_home()/"android"``.
"""Persistent outbox under ``get_hermes_home()/"iris"``.
Thread-safe (single connection + lock); operations are small and fast
enough to run inline on the gateway's asyncio loop (mirrors
@@ -126,7 +134,7 @@ class Outbox:
(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:
"""The high-water cursor (0 when nothing has been appended)."""
@@ -197,7 +205,12 @@ class Outbox:
continue
if not isinstance(frame, dict):
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
ftype = frame.get("type")
payload = frame.get("payload")
@@ -289,6 +302,12 @@ class Outbox:
standalone ``message`` frame when present, else the ``message.stop``
frame of a streamed reply -- or ``None`` when the message is not in the
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:
return None
@@ -296,34 +315,38 @@ class Outbox:
rows = self._conn.execute(
"SELECT frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall()
msg_frame: dict[str, Any] | None = None
stop_frame: dict[str, Any] | None = None
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if thread_id is not None and frame.get("thread_id") != thread_id:
continue
payload = frame.get("payload")
if not isinstance(payload, dict) or payload.get("message_id") != message_id:
continue
ftype = frame.get("type")
if ftype == "message":
msg_frame = {
"role": payload.get("role"),
"text": payload.get("text", ""),
"ts": payload.get("ts"),
}
elif ftype == "message.stop":
stop_frame = {
"role": "assistant",
"text": payload.get("final_text", ""),
"ts": payload.get("ts"),
}
return msg_frame or stop_frame
def scan(lane: str | None, exact: bool) -> dict[str, Any] | None:
msg_frame: dict[str, Any] | None = None
stop_frame: dict[str, Any] | None = None
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if exact and _frame_thread_id(frame) != lane:
continue
payload = frame.get("payload")
if not isinstance(payload, dict) or payload.get("message_id") != message_id:
continue
ftype = frame.get("type")
if ftype == "message":
msg_frame = {
"role": payload.get("role"),
"text": payload.get("text", ""),
"ts": payload.get("ts"),
}
elif ftype == "message.stop":
stop_frame = {
"role": "assistant",
"text": payload.get("final_text", ""),
"ts": payload.get("ts"),
}
return msg_frame or stop_frame
return scan(thread_id, exact=True) or scan(None, exact=False)
def delete_message(
self,
@@ -337,10 +360,13 @@ class Outbox:
``message.update`` / ``message.stop`` / ``media.offer`` /
``commentary``); all of them are removed so neither ``history`` nor a
``sync`` replay can resurrect the message. The delete is scoped to the
exact lane: a flat-lane delete (``thread_id=None``) matches only frames
with no ``thread_id``, and a thread delete matches only that thread's
frames (a ``message_id`` is unique to one lane, so this is a safety
net, not a filter that drops real frames). Returns the number of rows
exact lane first: a flat-lane delete (``thread_id=None``) matches only
frames with no ``thread_id``, and a thread delete matches only that
thread's frames. When the exact lane matches nothing, the delete falls
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
by retention).
"""
@@ -350,19 +376,27 @@ class Outbox:
rows = self._conn.execute(
"SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall()
cursors: list[int] = []
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if frame.get("thread_id") != thread_id:
continue
payload = frame.get("payload")
if isinstance(payload, dict) and payload.get("message_id") == message_id:
cursors.append(int(r["cursor"]))
def cursors_for(lane: str | None, exact: bool) -> list[int]:
out: list[int] = []
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if exact and _frame_thread_id(frame) != lane:
continue
payload = frame.get("payload")
if isinstance(payload, dict) and payload.get("message_id") == message_id:
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:
return 0
# 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.commit()
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:
"""Force a retention prune (ignores the interval throttle)."""
+48 -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,
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.
"""
@@ -14,6 +14,7 @@ import hmac
import json
import logging
import secrets
import socket
import sqlite3
import threading
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:
"""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:
"""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
fast enough to run inline on the gateway's asyncio loop.
+16 -16
View File
@@ -1,5 +1,5 @@
name: android-platform
label: Android
name: iris-platform
label: Iris
kind: platform
version: 0.1.0
description: >
@@ -12,45 +12,45 @@ author: Iris x Hermes
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
# env var injector in ``hermes_cli/config.py``.
requires_env:
- name: ANDROID_TOKEN
- name: IRIS_TOKEN
description: "Shared pairing token the app presents on connect"
prompt: "Android pairing token"
prompt: "Iris pairing token"
password: true
optional_env:
- name: ANDROID_WS_HOST
- name: IRIS_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host"
password: false
- name: ANDROID_WS_PORT
- name: IRIS_WS_PORT
description: "WS port (default 8790)"
prompt: "WS port"
password: false
- name: ANDROID_HOME_CHANNEL
description: "Default chat id for cron/notification delivery (default android:default)"
- name: IRIS_HOME_CHANNEL
description: "Default chat id for cron/notification delivery (default: default)"
prompt: "Home channel"
password: false
- name: ANDROID_ALLOWED_USERS
- name: IRIS_ALLOWED_USERS
description: "Comma-separated allowed device_ids (empty = token-only auth)"
prompt: "Allowed device ids"
password: false
- name: ANDROID_ALLOW_ALL_USERS
- name: IRIS_ALLOW_ALL_USERS
description: "Allow any paired device (dev only)"
prompt: "Allow all devices? (true/false)"
password: false
- name: ANDROID_PUSH_BACKEND
- name: IRIS_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy"
prompt: "Push backend"
password: false
- name: ANDROID_FCM_SERVICE_ACCOUNT
- name: IRIS_FCM_SERVICE_ACCOUNT
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
prompt: "FCM service account path"
password: true
- name: ANDROID_FCM_SERVER_KEY
- name: IRIS_FCM_SERVER_KEY
description: "Legacy FCM server key (fallback if no service account)"
prompt: "FCM server key"
password: true
- 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"
password: false
- name: NTFY_SERVER_URL
@@ -61,11 +61,11 @@ optional_env:
description: "ntfy auth token for a private topic (trust boundary)"
prompt: "ntfy auth token"
password: true
- name: ANDROID_WS_CERT
- name: IRIS_WS_CERT
description: "TLS cert path for WSS (optional)"
prompt: "WSS cert"
password: false
- name: ANDROID_WS_KEY
- name: IRIS_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS key"
password: false
+70
View File
@@ -48,6 +48,9 @@ TYPE_TOOL_START = "tool.start"
TYPE_TOOL_PROGRESS = "tool.progress"
TYPE_TOOL_END = "tool.end"
# Agent todo list (live planning state; ephemeral, never outboxed)
TYPE_TODO_UPDATE = "todo.update"
# Intermediate assistant beat (M2)
TYPE_COMMENTARY = "commentary"
@@ -71,6 +74,10 @@ TYPE_SEARCH_RESULTS = "search.results"
# Slash-command catalog (app's "/" drawer)
TYPE_COMMANDS_CATALOG = "commands.catalog"
# Interactive pickers (slash-command choice menus, e.g. /reasoning, /fast)
TYPE_PICKER_CHOICE = "picker.choice"
TYPE_PICKER_SELECT = "picker.select"
# Reconnect catch-up (M3 outbox; extended by M5 push)
TYPE_SYNC = "sync"
TYPE_SYNC_DONE = "sync.done"
@@ -423,12 +430,15 @@ def tool_start(
thread_id: str | None = None,
preview: str | None = None,
args: dict[str, Any] | None = None,
emoji: str | None = None,
) -> Frame:
payload: dict[str, Any] = {"index": index, "name": name}
if preview:
payload["preview"] = preview
if args:
payload["args"] = args
if emoji:
payload["emoji"] = emoji
return Frame(
type=TYPE_TOOL_START,
chat_id=chat_id,
@@ -479,6 +489,35 @@ def tool_end(
)
# ---------------------------------------------------------------------------
# Todo-update frame (agent planning state)
# ---------------------------------------------------------------------------
def todo_update(
chat_id: str,
todos: list[dict[str, str]],
*,
thread_id: str | None = None,
) -> Frame:
"""The agent's current todo list for a chat/thread lane.
Emitted whenever the ``todo`` tool completes (the tool result is the
authoritative full list — it also covers ``merge`` writes, whose args
carry only the changed items) and, as a snapshot, when a device opens
its event stream. Ephemeral state: never outboxed, so a reconnecting
device learns the current list from the snapshot instead of a replay.
Each item is ``{id, content, status}`` with status one of
``pending | in_progress | completed | cancelled``.
"""
return Frame(
type=TYPE_TODO_UPDATE,
chat_id=chat_id,
thread_id=thread_id,
payload={"todos": todos},
)
# ---------------------------------------------------------------------------
# Commentary frame (M2)
# ---------------------------------------------------------------------------
@@ -603,6 +642,37 @@ def commands_catalog(commands: list[dict[str, Any]], *, id: int | None = None) -
)
# ---------------------------------------------------------------------------
# Picker frames (interactive slash-command choice menus)
# ---------------------------------------------------------------------------
def picker_choice(
picker_id: str,
title: str,
choices: list[dict[str, Any]],
chat_id: str,
*,
thread_id: str | None = None,
) -> Frame:
"""Event: an interactive choice picker (one tap → one value).
Used by slash commands with a finite option set (``/reasoning``,
``/fast``, …) on platforms that support pickers. The app renders the
title + choice buttons and answers with a ``picker.select`` frame
carrying the same ``picker_id``. Outboxed, so a reconnecting device
re-renders a still-pending picker.
Each choice: ``{"value": str, "label": str, "is_current": bool}``.
"""
return Frame(
type=TYPE_PICKER_CHOICE,
chat_id=chat_id,
thread_id=thread_id,
payload={"picker_id": picker_id, "title": title, "choices": choices},
)
# ---------------------------------------------------------------------------
# Sync frames (M3 outbox)
# ---------------------------------------------------------------------------
+4 -4
View File
@@ -1,6 +1,6 @@
"""Complete (hard) deletion of android messages from the hermes session store.
"""Complete (hard) deletion of iris messages from the hermes session store.
The android plugin mints its own message ids (``m_<hex>``) that are **not**
The iris plugin mints its own message ids (``m_<hex>``) that are **not**
persisted in the hermes session DB (``state.db``), so a delete request cannot
join on an id. Instead a message is matched to its ``messages`` row by
(session, role, content, timestamp proximity) and that row is deleted.
@@ -89,7 +89,7 @@ def delete_lane(db_path: Path, chat_id: str, thread_id: str | None = None) -> in
conn.commit()
return n_msgs
except sqlite3.Error as e:
logger.warning("android purge: delete_lane failed: %s", e)
logger.warning("iris purge: delete_lane failed: %s", e)
return 0
finally:
with contextlib.suppress(Exception):
@@ -148,7 +148,7 @@ def delete_message(
conn.commit()
return 1
except sqlite3.Error as e:
logger.warning("android purge: delete_message failed: %s", e)
logger.warning("iris purge: delete_message failed: %s", e)
return 0
finally:
with contextlib.suppress(Exception):
+12 -12
View File
@@ -2,13 +2,13 @@
``PushBackend`` interface with two implementations:
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
(``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key
(``ANDROID_FCM_SERVER_KEY``).
(``IRIS_FCM_SERVICE_ACCOUNT``), or a legacy server key
(``IRIS_FCM_SERVER_KEY``).
- ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``NTFY_SERVER_URL``
(default ``https://ntfy.sh``) via ``httpx``; the app's listener
subscribes to the topic.
Selected by ``ANDROID_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
Selected by ``IRIS_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
Fired when a frame has no live subscriber; the data payload drives a silent
sync on the device (docs/08-push.md).
@@ -120,7 +120,7 @@ class FcmBackend(PushBackend):
self._sa = sa
return sa
except Exception:
logger.warning("android: FCM service account unreadable: %s", self._sa_path)
logger.warning("iris: FCM service account unreadable: %s", self._sa_path)
self._sa_failed = True
return None
@@ -151,7 +151,7 @@ class FcmBackend(PushBackend):
claims, sa["private_key"], algorithm="RS256", headers=headers
)
except Exception:
logger.warning("android: FCM JWT mint failed", exc_info=True)
logger.warning("iris: FCM JWT mint failed", exc_info=True)
return None
try:
resp = await client.post(
@@ -163,11 +163,11 @@ class FcmBackend(PushBackend):
timeout=_HTTP_TIMEOUT_S,
)
except Exception:
logger.warning("android: FCM token exchange failed", exc_info=True)
logger.warning("iris: FCM token exchange failed", exc_info=True)
return None
if resp.status_code != _HTTP_OK:
logger.warning(
"android: FCM token exchange HTTP %s: %s",
"iris: FCM token exchange HTTP %s: %s",
resp.status_code, resp.text[:200],
)
return None
@@ -236,12 +236,12 @@ class FcmBackend(PushBackend):
headers={"Authorization": f"Bearer {auth}"},
)
except Exception:
logger.warning("android: FCM send failed (network)", exc_info=True)
logger.warning("iris: FCM send failed (network)", exc_info=True)
return False
if resp.status_code >= _HTTP_ERROR_MIN:
# 404 NOT_FOUND = stale/invalid registration token.
logger.warning(
"android: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
"iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
)
return False
return True
@@ -313,11 +313,11 @@ class NtfyBackend(PushBackend):
url, content=text.encode("utf-8"), headers=headers
)
except Exception:
logger.warning("android: ntfy publish failed (network)", exc_info=True)
logger.warning("iris: ntfy publish failed (network)", exc_info=True)
return False
if resp.status_code >= _HTTP_ERROR_MIN:
logger.warning(
"android: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
"iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
)
return False
return True
@@ -332,7 +332,7 @@ def build_push_backend(
ntfy_server_url: str | None = None,
ntfy_auth_token: str | None = None,
) -> PushBackend:
"""Select the backend by name (``ANDROID_PUSH_BACKEND``; fcm default)."""
"""Select the backend by name (``IRIS_PUSH_BACKEND``; fcm default)."""
if (name or "").strip().lower() == "ntfy":
return NtfyBackend(
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
+494
View File
@@ -0,0 +1,494 @@
"""Pure-stdlib QR encoder (ISO/IEC 18004) + terminal renderer.
Scope is deliberately minimal — we only ever encode ASCII pairing URLs
(``iris://pair?...``):
- **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 :class:`QrTooLongError`.
No third-party imports (no ``qrcode``/``segno``/``Pillow``) — the plugin's
zero-new-dep rule. No I/O, no module-level mutable state, fully unit-testable.
Public API:
- :func:`qr_matrix` — encode *data* (ASCII) into a module matrix
(``True`` = dark) including the 4-module quiet zone.
- :func:`render_qr` — render *data* as a terminal QR using Unicode
half-blocks; returns ``""`` (not an exception) when the payload is too long.
"""
from __future__ import annotations
__all__ = ["QrTooLongError", "qr_matrix", "render_qr"]
class QrTooLongError(ValueError):
"""Raised when *data* doesn't fit in any supported version (1–10)."""
# ---------------------------------------------------------------------------
# GF(256) arithmetic (polynomial 0x11D)
# ---------------------------------------------------------------------------
_GF_EXP = [0] * 512
_GF_LOG = [0] * 256
_x = 1
for _i in range(255):
_GF_EXP[_i] = _x
_GF_LOG[_x] = _i
_x <<= 1
if _x & 0x100:
_x ^= 0x11D
for _i in range(255, 512):
_GF_EXP[_i] = _GF_EXP[_i - 255]
def _gf_mul(a: int, b: int) -> int:
if a == 0 or b == 0:
return 0
return _GF_EXP[_GF_LOG[a] + _GF_LOG[b]]
def _rs_generator_poly(degree: int) -> list[int]:
"""Generator polynomial of *degree* (big-endian, leading coeff first)."""
poly = [1]
for i in range(degree):
new = [0] * (len(poly) + 1)
for k, coef in enumerate(poly):
new[k] ^= coef # x * coef
new[k + 1] ^= _gf_mul(coef, _GF_EXP[i])
poly = new
return poly
def _rs_encode(data: list[int], ec_len: int) -> list[int]:
"""Reed–Solomon error-correction codewords for *data*."""
gen = _rs_generator_poly(ec_len)
buf = list(data) + [0] * ec_len
for i in range(len(data)):
coef = buf[i]
if coef:
for j in range(1, len(gen)):
buf[i + j] ^= _gf_mul(gen[j], coef)
return buf[len(data) :]
# ---------------------------------------------------------------------------
# Block structure (version, EC level) -> (ec_per_block, [(count, data_cw), ...])
#
# Source: ISO/IEC 18004 Table 9 (cross-checked against the reference encoder).
# Only levels L and M are needed (M primary, L fallback).
# ---------------------------------------------------------------------------
_BLOCK_TABLE: dict[tuple[int, str], tuple[int, list[tuple[int, int]]]] = {
(1, "L"): (7, [(1, 19)]),
(1, "M"): (10, [(1, 16)]),
(2, "L"): (10, [(1, 34)]),
(2, "M"): (16, [(1, 28)]),
(3, "L"): (15, [(1, 55)]),
(3, "M"): (26, [(1, 44)]),
(4, "L"): (20, [(1, 80)]),
(4, "M"): (18, [(2, 32)]),
(5, "L"): (26, [(1, 108)]),
(5, "M"): (24, [(2, 43)]),
(6, "L"): (18, [(2, 68)]),
(6, "M"): (16, [(4, 27)]),
(7, "L"): (20, [(2, 78)]),
(7, "M"): (18, [(4, 31)]),
(8, "L"): (24, [(2, 97)]),
(8, "M"): (22, [(2, 38), (2, 39)]),
(9, "L"): (30, [(2, 116)]),
(9, "M"): (22, [(3, 36), (2, 37)]),
(10, "L"): (18, [(2, 68), (2, 69)]),
(10, "M"): (26, [(4, 43), (1, 44)]),
}
# Alignment-pattern centre coordinates per version (v1 has none).
_ALIGNMENT: dict[int, list[int]] = {
1: [],
2: [6, 18],
3: [6, 22],
4: [6, 26],
5: [6, 30],
6: [6, 34],
7: [6, 22, 38],
8: [6, 24, 42],
9: [6, 26, 46],
10: [6, 28, 50],
}
# EC level -> 2-bit format-info code (ISO/IEC 18004 Table 17).
_EC_FORMAT_BITS = {"L": 0b01, "M": 0b00}
_MIN_VERSION, _MAX_VERSION = 1, 10
_QUIET = 4
def _data_capacity(version: int, level: str) -> int:
"""Max payload bytes in byte mode for (version, level)."""
_, groups = _BLOCK_TABLE[(version, level)]
data_bits = sum(count * data_cw for count, data_cw in groups) * 8
# mode indicator (4) + char count (8 for v1-9, 16 for v10) + terminator (4)
count_bits = 16 if version >= 10 else 8
return (data_bits - 4 - count_bits - 4) // 8
def _select_version(data: bytes) -> tuple[int, str]:
for level in ("M", "L"):
for version in range(_MIN_VERSION, _MAX_VERSION + 1):
if len(data) <= _data_capacity(version, level):
return version, level
raise QrTooLongError(f"payload of {len(data)} bytes exceeds v{_MAX_VERSION}-L capacity")
# ---------------------------------------------------------------------------
# Data encoding (byte mode)
# ---------------------------------------------------------------------------
def _encode_data(data: bytes, version: int, level: str) -> list[int]:
"""Return the full codeword stream (data + EC), interleaved per spec."""
_, groups = _BLOCK_TABLE[(version, level)]
ec_per_block = _BLOCK_TABLE[(version, level)][0]
total_data_cw = sum(count * data_cw for count, data_cw in groups)
bits: list[int] = []
def put(value: int, width: int) -> None:
for i in range(width - 1, -1, -1):
bits.append((value >> i) & 1)
put(0b0100, 4) # byte mode
put(len(data), 16 if version >= 10 else 8) # char count
for byte in data:
put(byte, 8)
# terminator (up to 4 zero bits)
capacity_bits = total_data_cw * 8
put(0, min(4, capacity_bits - len(bits)))
# pad to byte boundary
if len(bits) % 8:
put(0, 8 - len(bits) % 8)
# pad bytes 0xEC / 0x11
pad_bytes = [0xEC, 0x11]
pi = 0
while len(bits) < capacity_bits:
put(pad_bytes[pi % 2], 8)
pi += 1
data_cw = [int("".join(map(str, bits[i : i + 8])), 2) for i in range(0, len(bits), 8)]
# Split into blocks, compute EC per block.
blocks: list[list[int]] = []
ec_blocks: list[list[int]] = []
idx = 0
for count, data_cw_len in groups:
for _ in range(count):
block = data_cw[idx : idx + data_cw_len]
idx += data_cw_len
blocks.append(block)
ec_blocks.append(_rs_encode(block, ec_per_block))
# Interleave data codewords, then EC codewords (ISO/IEC 18004 §8.6.3).
out: list[int] = []
max_data = max(len(b) for b in blocks)
for i in range(max_data):
for b in blocks:
if i < len(b):
out.append(b[i])
max_ec = max(len(b) for b in ec_blocks)
for i in range(max_ec):
for b in ec_blocks:
if i < len(b):
out.append(b[i])
return out
# ---------------------------------------------------------------------------
# Matrix construction
# ---------------------------------------------------------------------------
def _bch(data: int, shift: int, generator: int) -> int:
"""BCH codeword: *data* shifted left by *shift*, the low *shift* bits
filled with the remainder of the division by *generator*."""
d = data << shift
g_len = generator.bit_length()
while d.bit_length() >= g_len:
d ^= generator << (d.bit_length() - g_len)
return (data << shift) | d
def _format_info(level: str, mask: int) -> int:
"""15-bit format info (BCH(15,5)) XORed with 0x5412."""
data = (_EC_FORMAT_BITS[level] << 3) | mask
return _bch(data, 10, 0x537) ^ 0x5412
def _version_info(version: int) -> int:
"""18-bit version info (BCH(18,6)); only for v7+."""
return _bch(version, 12, 0x1F25)
def _build_matrix(version: int, level: str, codewords: list[int], mask: int) -> list[list[bool]]:
size = 17 + 4 * version
# matrix[r][c] = dark; reserved[r][c] = function module (not data)
matrix = [[False] * size for _ in range(size)]
reserved = [[False] * size for _ in range(size)]
def set_module(r: int, c: int, dark: bool) -> None:
matrix[r][c] = dark
reserved[r][c] = True
# Finder patterns + separators (three corners).
for fr, fc in ((0, 0), (0, size - 7), (size - 7, 0)):
for r in range(-1, 8):
for c in range(-1, 8):
rr, cc = fr + r, fc + c
if not (0 <= rr < size and 0 <= cc < size):
continue
if 0 <= r <= 6 and 0 <= c <= 6:
# Canonical finder: 7x7 border dark, 5x5 white, 3x3 dark centre.
ring = max(abs(r - 3), abs(c - 3))
set_module(rr, cc, ring in (0, 1, 3))
else:
set_module(rr, cc, False) # separator
# Timing patterns.
for i in range(8, size - 8):
dark = i % 2 == 0
if not reserved[6][i]:
set_module(6, i, dark)
if not reserved[i][6]:
set_module(i, 6, dark)
# Alignment patterns (v2+), skipping those overlapping finders.
positions = _ALIGNMENT[version]
if len(positions) > 1:
for r in positions:
for c in positions:
# Skip the three corners that share a finder pattern.
if (
(r == positions[0] and c == positions[0])
or (r == positions[0] and c == positions[-1])
or (r == positions[-1] and c == positions[0])
):
continue
for dr in range(-2, 3):
for dc in range(-2, 3):
ring = max(abs(dr), abs(dc))
dark = ring != 1
set_module(r + dr, c + dc, dark)
# Dark module (always dark) at (4*version + 9, 8).
set_module(4 * version + 9, 8, True)
# Reserve format-info regions (filled after masking).
for i in range(9):
if not reserved[8][i]:
reserved[8][i] = True
if not reserved[i][8]:
reserved[i][8] = True
for i in range(8):
reserved[8][size - 1 - i] = True
reserved[size - 1 - i][8] = True
# (8,8) handled above; mark the remaining format cells.
reserved[8][8] = True
# Reserve version-info regions (v7+).
if version >= 7:
vinfo = _version_info(version)
for i in range(18):
bit = (vinfo >> i) & 1
# Two 3x6 blocks: top-left and bottom-right corners.
r, c = size - 11 + (i % 3), i // 3
set_module(r, c, bool(bit))
r, c = i // 3, size - 11 + (i % 3)
set_module(r, c, bool(bit))
# Place data codewords in the zig-zag, applying the mask. Start at the
# bottom-right and traverse column pairs bottom-to-top, then top-to-bottom.
bit_index = 0
total_bits = len(codewords) * 8
inc = -1
row = size - 1
for col in range(size - 1, 0, -2):
if col <= 6:
col -= 1 # skip the vertical timing column
while True:
for c in (col, col - 1):
if not reserved[row][c]:
bit = 0
if bit_index < total_bits:
bit = (codewords[bit_index // 8] >> (7 - bit_index % 8)) & 1
bit_index += 1
if _mask_bit(mask, row, c):
bit ^= 1
matrix[row][c] = bool(bit)
row += inc
if row < 0 or row >= size:
row -= inc
inc = -inc
break
# Write format info (after masking, unmasked).
fmt = _format_info(level, mask)
for i in range(15):
bit = bool((fmt >> i) & 1)
# Vertical copy (column 8).
if i < 6:
set_module(i, 8, bit)
elif i < 8:
set_module(i + 1, 8, bit)
else:
set_module(size - 15 + i, 8, bit)
# Horizontal copy (row 8).
if i < 8:
set_module(8, size - i - 1, bit)
elif i < 9:
set_module(8, 15 - i, bit)
else:
set_module(8, 15 - i - 1, bit)
return matrix
def _mask_bit(mask: int, r: int, c: int) -> bool:
if mask == 0:
return (r + c) % 2 == 0
if mask == 1:
return r % 2 == 0
if mask == 2:
return c % 3 == 0
if mask == 3:
return (r + c) % 3 == 0
if mask == 4:
return (r // 2 + c // 3) % 2 == 0
if mask == 5:
return (r * c) % 2 + (r * c) % 3 == 0
if mask == 6:
return ((r * c) % 2 + (r * c) % 3) % 2 == 0
if mask == 7:
return ((r + c) % 2 + (r * c) % 3) % 2 == 0
raise ValueError(f"invalid mask {mask}")
# ---------------------------------------------------------------------------
# Penalty scoring (ISO/IEC 18004 §8.8.2)
# ---------------------------------------------------------------------------
def _penalty(matrix: list[list[bool]]) -> int:
size = len(matrix)
total = 0
# N1: runs of >= 5 same-colour in rows and columns.
for line in _all_lines(matrix):
run = 1
for i in range(1, len(line)):
if line[i] == line[i - 1]:
run += 1
else:
if run >= 5:
total += 3 + (run - 5)
run = 1
if run >= 5:
total += 3 + (run - 5)
# N2: 2x2 blocks of same colour.
for r in range(size - 1):
for c in range(size - 1):
v = matrix[r][c]
if v == matrix[r][c + 1] == matrix[r + 1][c] == matrix[r + 1][c + 1]:
total += 3
# N3: 10111010000 / 00001011101 patterns (with 4 light on one side).
pattern_a = [True, False, True, True, True, False, True, False, False, False, False]
pattern_b = [False, False, False, False, True, False, True, True, True, False, True]
for line in _all_lines(matrix):
for i in range(len(line) - 10):
window = line[i : i + 11]
if window in (pattern_a, pattern_b):
total += 40
# N4: dark/light balance (integer math: floor(|percent - 50| / 5) * 10).
dark = sum(cell for line in matrix for cell in line)
total += 10 * (abs(20 * dark - 10 * size * size) // (5 * size * size))
return total
def _all_lines(matrix: list[list[bool]]):
size = len(matrix)
for r in range(size):
yield matrix[r]
for c in range(size):
yield [matrix[r][c] for r in range(size)]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def qr_matrix(data: str) -> list[list[bool]]:
"""Encode *data* (ASCII) into a module matrix (``True`` = dark).
Includes the 4-module quiet zone. Raises :class:`QrTooLongError` when the
payload doesn't fit in versions 1–10.
"""
payload = data.encode("ascii")
version, level = _select_version(payload)
codewords = _encode_data(payload, version, level)
best = _build_matrix(version, level, codewords, 0)
best_penalty = _penalty(best)
for mask in range(1, 8):
m = _build_matrix(version, level, codewords, mask)
p = _penalty(m)
if p < best_penalty:
best, best_penalty = m, p
size = len(best)
return (
[[False] * (size + 2 * _QUIET) for _ in range(_QUIET)]
+ [[False] * _QUIET + row + [False] * _QUIET for row in best]
+ [[False] * (size + 2 * _QUIET) for _ in range(_QUIET)]
)
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. No ANSI colours or
cursor tricks — survives ``less``, log files, and copy-paste.
"""
try:
matrix = qr_matrix(data)
except QrTooLongError:
return ""
height = len(matrix)
width = len(matrix[0])
if height % 2:
matrix = matrix + [[False] * width]
lines: list[str] = []
for r in range(0, len(matrix), 2):
chars: list[str] = []
for c in range(width):
top, bottom = matrix[r][c], matrix[r + 1][c]
if top and bottom:
chars.append("█")
elif top:
chars.append("▀")
elif bottom:
chars.append("▄")
else:
chars.append(" ")
lines.append("".join(chars))
return "\n".join(lines)
+3 -3
View File
@@ -224,7 +224,7 @@ def search(
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
except sqlite3.Error as e:
logger.warning("android search: open failed: %s", e)
logger.warning("iris search: open failed: %s", e)
return []
conn.row_factory = sqlite3.Row
try:
@@ -232,10 +232,10 @@ def search(
try:
return _fts_query(conn, sanitized, scope, chat_id, thread_id, limit)
except sqlite3.Error as e:
logger.debug("android search: FTS5 failed, using LIKE: %s", e)
logger.debug("iris search: FTS5 failed, using LIKE: %s", e)
return _like_query(conn, sanitized, scope, chat_id, thread_id, limit)
except sqlite3.Error as e:
logger.warning("android search: query failed: %s", e)
logger.warning("iris search: query failed: %s", e)
return []
finally:
# Best-effort: a close failure on a read-only connection is not
+4 -4
View File
@@ -1,4 +1,4 @@
# Tests for the android gateway plugin.
# Tests for the iris gateway plugin.
Run via hermes's hermetic runner (never bare pytest)::
@@ -13,7 +13,7 @@ drives a turn, printing every frame. Run with the hermes venv python
(needs `websockets`); the gateway must already be up::
hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py \
--token <ANDROID_TOKEN> --send "hello"
--token <IRIS_TOKEN> --send "hello"
Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
`--fcm-token`/`--fcm-reg`, `--authfail`, `--url`, `--token`, `--device`,
@@ -48,7 +48,7 @@ Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
`POST /v1/frame` (the `message.send`), receive over SSE `GET
/v1/events`. The same assertion flags apply. The base URL defaults to
the `--url` host with scheme `ws(s)` → `http(s)` and port 8791
(`ANDROID_HTTP_PORT`).
(`IRIS_HTTP_PORT`).
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
@@ -72,7 +72,7 @@ FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise::
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
The token is read from `$ANDROID_TOKEN`, else `hermes-agent/.env`, else
The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else
`~/.hermes/.env`. The gateway must already be running (the driver never
starts or stops it). It is idempotent: channels/jobs it creates are
cleaned up even on failure, and leftover `e2e-*` channels/jobs from
+8 -8
View File
@@ -11,7 +11,7 @@ Usage::
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
The token is read from $ANDROID_TOKEN, else hermes-agent/.env, else
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
~/.hermes/.env. The gateway must already be running (this driver never
starts or stops it). Idempotent: channels/jobs it creates are cleaned up
even on failure, and leftover "e2e-*" channels/jobs from earlier runs are
@@ -52,14 +52,14 @@ PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
def find_token(cli_token: str) -> str:
if cli_token:
return cli_token
env = os.getenv("ANDROID_TOKEN")
env = os.getenv("IRIS_TOKEN")
if env:
return env
for p in (REPO / "hermes-agent" / ".env", Path.home() / ".hermes" / ".env"):
try:
for raw_line in p.read_text().splitlines():
line = raw_line.strip()
if line.startswith("ANDROID_TOKEN="):
if line.startswith("IRIS_TOKEN="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
@@ -204,7 +204,7 @@ def s7_cron(env, url, token):
if not chat_id:
return FAIL, "channel.created received but chat_id not parseable"
job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}"
deliver = f"android:{chat_id}"
deliver = f"iris:{chat_id}"
rc, out, err = run_hermes(
env, "cron", "create", "1m",
"Reply with exactly: e2e cron delivery OK",
@@ -310,7 +310,7 @@ def s13_http_fallback(env, url, token):
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
u = urlparse(url)
scheme = "https" if u.scheme == "wss" else "http"
http_port = os.getenv("ANDROID_HTTP_PORT", "8791")
http_port = os.getenv("IRIS_HTTP_PORT", "8791")
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
"--send", "Reply with exactly: e2e http fallback OK",
@@ -352,7 +352,7 @@ def main() -> int:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", DEFAULT_URL))
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", DEFAULT_URL))
p.add_argument("--token", default="")
p.add_argument("--skip", default="",
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
@@ -360,12 +360,12 @@ def main() -> int:
token = find_token(args.token)
if not token:
print("!! ANDROID_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)")
print("!! IRIS_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)")
return 1
skip = {int(x) for x in args.skip.split(",") if x.strip()}
env = dict(os.environ)
env["ANDROID_TOKEN"] = token
env["IRIS_TOKEN"] = token
print(f"== e2e: url={args.url} token={token[:6]}…")
sweep_leftovers(env, args.url, token)
+406 -87
View File
@@ -1,7 +1,7 @@
"""Tests for the Iris x Hermes android gateway plugin (M4: media).
The plugin lives in the sibling ``iris_x_hermes`` checkout (installed into
``~/.hermes/plugins/android`` as a symlink in production); tests load it
``~/.hermes/plugins/iris`` as a symlink in production); tests load it
from the source tree directly so they never depend on that install.
Coverage (docs/13-testing.md §13.1, media bullets):
@@ -42,12 +42,12 @@ PNG_1X1 = base64.b64decode(
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
TOKEN = "test-android-token-0123456789"
TOKEN = "test-iris-token-0123456789"
DEVICE_ID = "test-device"
def _plugin_dir() -> Path:
env = os.environ.get("ANDROID_PLUGIN_DIR")
env = os.environ.get("IRIS_PLUGIN_DIR")
if env:
return Path(env)
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
@@ -66,13 +66,13 @@ def _load_plugin():
The plugin uses relative imports (``from . import protocol``), so it
must be imported as a package (``submodule_search_locations``).
"""
name = "android_plugin_under_test"
name = "iris_plugin_under_test"
cached = sys.modules.get(name)
if cached is not None:
return cached
pkg_dir = _plugin_dir()
if not (pkg_dir / "__init__.py").is_file():
pytest.fail(f"android plugin not found at {pkg_dir}")
pytest.fail(f"iris plugin not found at {pkg_dir}")
spec = importlib.util.spec_from_file_location(
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
)
@@ -95,16 +95,23 @@ def plugin():
@pytest.fixture
def adapter(plugin, monkeypatch):
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
# Clear any IRIS transport overrides leaked into the process env by earlier
# tests (e.g. test_interactive_setup_prints_qr runs the real
# interactive_setup(), which save_env_value()s IRIS_HTTP_PORT/IRIS_WS_HOST).
# Without this, a later adapter would bind the leaked port (8791) instead of
# the ephemeral 0 below, colliding with a live gateway on that port.
monkeypatch.delenv("IRIS_HTTP_PORT", raising=False)
monkeypatch.delenv("IRIS_WS_HOST", raising=False)
from gateway.platform_registry import PlatformEntry, platform_registry
# Platform("android") resolves only once the platform is registered
# Platform("iris") resolves only once the platform is registered
# (the plugin's register(ctx) does this in production).
if not platform_registry.is_registered("android"):
if not platform_registry.is_registered("iris"):
platform_registry.register(
PlatformEntry(
name="android",
name="iris",
label="Android",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
@@ -119,7 +126,7 @@ def adapter(plugin, monkeypatch):
},
home_channel=None,
)
a = plugin.adapter.AndroidAdapter(config)
a = plugin.adapter.IrisAdapter(config)
yield a
try:
a._devices.close()
@@ -459,14 +466,14 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
ws, _ = ws_client
# Simulate the post_api_request hook capturing the turn's model + tokens.
plugin.adapter._on_post_api_request(
platform="android",
platform="iris",
model="openai/gpt-5.4",
usage={"prompt_tokens": 12345},
)
# Stub context-length resolution (avoid network probing in tests).
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
res = await adapter.send("android:default", "hello", metadata={"notify": True})
res = await adapter.send("default", "hello", metadata={"notify": True})
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message")
msg = frames[-1]
@@ -477,7 +484,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
assert runtime["cwd"] == "~"
assert "latency" in runtime and runtime["latency"] >= 0
# The turn buffer is drained: a second final send carries no stale model.
res2 = await adapter.send("android:default", "again", metadata={"notify": True})
res2 = await adapter.send("default", "again", metadata={"notify": True})
assert res2.success
frames2 = await recv_until(
ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "again"
@@ -492,7 +499,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
@pytest.mark.asyncio
async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client, monkeypatch):
ws, _ = ws_client
# A non-android turn must not pollute the android runtime buffer.
# A non-iris turn must not pollute the iris runtime buffer.
plugin.adapter._on_post_api_request(
platform="telegram",
model="openai/gpt-5.4",
@@ -500,7 +507,7 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client
)
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
res = await adapter.send("android:default", "hi", metadata={"notify": True})
res = await adapter.send("default", "hi", metadata={"notify": True})
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message")
runtime = frames[-1]["payload"].get("runtime")
@@ -512,15 +519,15 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client
async def test_history_preserves_runtime_footer(plugin, adapter, ws_client, monkeypatch):
ws, _ = ws_client
plugin.adapter._on_post_api_request(
platform="android",
platform="iris",
model="openai/gpt-5.4",
usage={"prompt_tokens": 12345},
)
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
await adapter.send("android:default", "hello", metadata={"notify": True})
await adapter.send("default", "hello", metadata={"notify": True})
# The outbox history reconstruction must carry the runtime object.
page = adapter._outbox.history("android:default", limit=50)
page = adapter._outbox.history("default", limit=50)
assert len(page["messages"]) == 1
m = page["messages"][0]
assert m.get("runtime", {}).get("model") == "gpt-5.4"
@@ -722,7 +729,7 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client,
"v": 1,
"id": 40,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": "fix the login bug please", "auto_thread": True},
}
)
@@ -733,16 +740,16 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client,
created = next(f for f in frames if f.get("type") == "channel.created")
assert created["payload"]["kind"] == "thread"
assert created["payload"]["auto"] is True
assert created["payload"]["parent_chat_id"] == "android:default"
assert created["payload"]["parent_chat_id"] == "default"
assert created["payload"]["name"] == "fix the login bug please"
thread_id = created["payload"]["chat_id"]
echo = frames[-1]
assert echo["chat_id"] == "android:default"
assert echo["chat_id"] == "default"
assert echo["thread_id"] == thread_id
assert len(captured) == 1
assert captured[0].source.chat_id == "android:default"
assert captured[0].source.chat_id == "default"
assert captured[0].source.thread_id == thread_id
@@ -762,7 +769,7 @@ async def test_message_send_auto_thread_llm_upgrade_renames(adapter, ws_client,
"v": 1,
"id": 41,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": "fix the login bug please", "auto_thread": True},
}
)
@@ -789,7 +796,7 @@ async def test_message_send_auto_thread_ignored_with_existing_thread(adapter, ws
"v": 1,
"id": 42,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"thread_id": "t_9",
"payload": {"text": "follow up", "auto_thread": True},
}
@@ -818,7 +825,7 @@ async def test_message_send_auto_thread_ignored_for_slash_command(adapter, ws_cl
"v": 1,
"id": 43,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": "/new", "auto_thread": True},
}
)
@@ -848,7 +855,7 @@ async def test_message_send_auto_thread_media_only_stays_flat(adapter, ws_client
"v": 1,
"id": 44,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": "", "media_refs": ["mu_flat"], "auto_thread": True},
}
)
@@ -877,7 +884,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client):
"v": 1,
"id": 20,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": "persist me"},
}
)
@@ -888,7 +895,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client):
echo = frames[-1]
message_id = echo["payload"]["message_id"]
# The echo is parked in the outbox under the home channel.
page = adapter._outbox.history("android:default", limit=50)
page = adapter._outbox.history("default", limit=50)
ids = [m["message_id"] for m in page["messages"]]
assert message_id in ids
parked = next(m for m in page["messages"] if m["message_id"] == message_id)
@@ -904,7 +911,7 @@ async def test_history_returns_final_messages_oldest_first(plugin, adapter, ws_c
ws, _ = ws_client
protocol = plugin.protocol
adapter.handle_message = AsyncMock()
chat_id = "android:default"
chat_id = "default"
# Two user turns, each with a (non-streaming) assistant final.
for i, text in enumerate(["one", "two"]):
await ws.send(
@@ -952,7 +959,7 @@ async def test_history_paginates_older_pages(plugin, adapter, ws_client):
ws, _ = ws_client
protocol = plugin.protocol
adapter.handle_message = AsyncMock()
chat_id = "android:default"
chat_id = "default"
for i in range(5):
await adapter._broadcast_or_log(
chat_id,
@@ -982,7 +989,7 @@ async def test_history_frame_roundtrip(plugin, adapter, ws_client):
ws, _ = ws_client
protocol = plugin.protocol
adapter.handle_message = AsyncMock()
chat_id = "android:default"
chat_id = "default"
await adapter._broadcast_or_log(
chat_id,
protocol.message(
@@ -1023,7 +1030,7 @@ async def test_message_delete_removes_from_outbox_and_broadcasts(plugin, adapter
ws, _ = ws_client
protocol = plugin.protocol
adapter.handle_message = AsyncMock()
chat_id = "android:default"
chat_id = "default"
await adapter._broadcast_or_log(
chat_id,
protocol.message(
@@ -1062,7 +1069,7 @@ async def test_message_delete_idempotent(plugin, adapter, ws_client):
on the outbox but still emits ``message.deleted`` so live caches drop it."""
ws, _ = ws_client
adapter.handle_message = AsyncMock()
chat_id = "android:default"
chat_id = "default"
await ws.send(
json.dumps(
{
@@ -1088,7 +1095,7 @@ async def test_message_delete_requires_message_ids(adapter, ws_client):
"v": 1,
"id": 52,
"type": "message.delete",
"chat_id": "android:default",
"chat_id": "default",
"payload": {},
}
)
@@ -1125,7 +1132,7 @@ async def test_message_delete_purges_session_store(plugin, adapter, ws_client):
adapter.handle_message = AsyncMock()
from hermes_constants import get_hermes_home
chat_id = "android:default"
chat_id = "default"
db = get_hermes_home() / "state.db"
_make_state_db(db)
import sqlite3
@@ -1244,12 +1251,12 @@ async def test_send_video_emits_offer_with_message_association(adapter, ws_clien
video.write_bytes(b"fake-video-bytes")
# A final message first, so the offer can associate with it.
res = await adapter.send("android:default", "here you go", metadata={"notify": True})
res = await adapter.send("default", "here you go", metadata={"notify": True})
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message")
msg_id = frames[-1]["payload"]["message_id"]
res2 = await adapter.send_video("android:default", str(video))
res2 = await adapter.send_video("default", str(video))
assert res2.success
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
offer = frames[-1]["payload"]
@@ -1270,7 +1277,7 @@ async def test_send_document_uses_caller_filename(adapter, ws_client):
doc = get_document_cache_dir() / "report.pdf"
doc.write_bytes(b"%PDF-1.4 fake")
res = await adapter.send_document(
"android:default", str(doc), file_name="My Report.pdf"
"default", str(doc), file_name="My Report.pdf"
)
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
@@ -1287,7 +1294,7 @@ async def test_send_image_file_offers_image(adapter, ws_client):
img = get_image_cache_dir() / "shot.png"
img.write_bytes(PNG_1X1)
res = await adapter.send_image_file("android:default", str(img))
res = await adapter.send_image_file("default", str(img))
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
offer = frames[-1]["payload"]
@@ -1299,7 +1306,7 @@ async def test_send_image_file_offers_image(adapter, ws_client):
async def test_send_media_rejects_denied_path(adapter, ws_client):
ws, _ = ws_client
# /etc/passwd exists but is on hermes' delivery denylist.
res = await adapter.send_document("android:default", "/etc/passwd")
res = await adapter.send_document("default", "/etc/passwd")
assert not res.success
# Nothing was offered (a failed offer is silent, like other platforms).
try:
@@ -1439,10 +1446,10 @@ async def test_ntfy_backend_publishes_with_data_header(plugin, monkeypatch):
)
ok = await backend.send(
device_id="d1",
chat_id="android:default",
chat_id="default",
title="Iris",
body="hello",
data={"chat_id": "android:default", "kind": "message", "cursor": "7"},
data={"chat_id": "default", "kind": "message", "cursor": "7"},
token="iris-topic",
)
assert ok is True
@@ -1479,10 +1486,10 @@ async def test_fcm_backend_legacy_server_key(plugin, monkeypatch):
)
ok = await backend.send(
device_id="d1",
chat_id="android:default",
chat_id="default",
title="Iris",
body="hi",
data={"chat_id": "android:default", "kind": "message", "cursor": "3"},
data={"chat_id": "default", "kind": "message", "cursor": "3"},
token="fcm-token-1",
)
assert ok is True
@@ -1528,10 +1535,10 @@ async def test_fcm_backend_service_account_v1(plugin, monkeypatch, tmp_path):
fake = _patch_httpx(plugin, monkeypatch, responder)
ok = await backend.send(
device_id="d1",
chat_id="android:default",
chat_id="default",
title="Iris",
body="hi",
data={"chat_id": "android:default", "kind": "cron", "cursor": "4"},
data={"chat_id": "default", "kind": "cron", "cursor": "4"},
token="fcm-token-2",
priority="high",
)
@@ -1584,15 +1591,15 @@ async def test_push_fires_when_no_live_subscriber(adapter):
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
res = await adapter.send(
"android:default", "hello while offline", metadata={"notify": True}
"default", "hello while offline", metadata={"notify": True}
)
assert res.success
assert len(fake.calls) == 1
call = fake.calls[0]
assert call["token"] == "tok-1"
assert call["chat_id"] == "android:default"
assert call["chat_id"] == "default"
assert call["data"]["kind"] == "message"
assert call["data"]["chat_id"] == "android:default"
assert call["data"]["chat_id"] == "default"
assert call["data"]["cursor"] == "1"
assert call["priority"] == "normal"
# The frame is parked in the outbox for sync.
@@ -1606,7 +1613,7 @@ async def test_push_not_fired_when_live(adapter, ws_client):
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter.send("android:default", "live reply", metadata={"notify": True})
await adapter.send("default", "live reply", metadata={"notify": True})
frames = await recv_until(ws, lambda f: f.get("type") == "message")
assert frames[-1]["payload"]["text"] == "live reply"
assert fake.calls == []
@@ -1619,7 +1626,7 @@ async def test_live_delivered_frame_still_parked_for_sync(adapter, ws_client):
tapping a push notification) can catch up via sync. Regression: chat empty
after tapping a message notification."""
ws, _ = ws_client
await adapter.send("android:default", "live reply", metadata={"notify": True})
await adapter.send("default", "live reply", metadata={"notify": True})
frames = await recv_until(ws, lambda f: f.get("type") == "message")
assert frames[-1]["payload"]["text"] == "live reply"
# The live-delivered frame is still parked in the outbox for sync.
@@ -1674,9 +1681,9 @@ async def test_intermediate_frames_park_without_push(adapter):
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter.send("android:default", "seg", metadata={"expect_edits": True})
stream_id = adapter._turns["android:default"].stream_id
await adapter.edit_message("android:default", stream_id, "seg more")
await adapter.send("default", "seg", metadata={"expect_edits": True})
stream_id = adapter._turns["default"].stream_id
await adapter.edit_message("default", stream_id, "seg more")
assert adapter._outbox.latest_cursor() == 2
assert fake.calls == []
@@ -1689,9 +1696,9 @@ async def test_high_priority_notification_pushes_even_when_live(plugin, adapter,
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter._broadcast_or_log(
"android:default",
"default",
plugin.protocol.notification(
"android:default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body"
"default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body"
),
)
frames = await recv_until(ws, lambda f: f.get("type") == "notification")
@@ -1707,7 +1714,7 @@ async def test_push_skipped_when_device_has_no_token(adapter):
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}) # no push token
await adapter.send("android:default", "no token", metadata={"notify": True})
await adapter.send("default", "no token", metadata={"notify": True})
assert fake.calls == []
assert adapter._outbox.latest_cursor() == 1 # still parked for sync
@@ -1718,10 +1725,83 @@ async def test_push_skipped_when_backend_unconfigured(adapter):
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {})
await adapter.send("android:default", "unconfigured", metadata={"notify": True})
await adapter.send("default", "unconfigured", metadata={"notify": True})
assert fake.calls == []
@pytest.mark.asyncio
async def test_push_deferred_while_turn_active_flushed_on_stop_typing(adapter):
"""Turn-aware push: while the agent's turn is in flight (typing on) and
the device is offline, intermediate status messages park without a push;
the turn-end (stop_typing) flushes ONE push with the latest (final)
message. Regression: a long research turn pushed every status report."""
fake = _FakePush()
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter.send_typing("default") # turn starts
await adapter.send("default", "researching XXXX", metadata={"notify": True})
assert fake.calls == [] # intermediate: held back
await adapter.send(
"default", "found XXX, cross-referencing", metadata={"notify": True}
)
assert fake.calls == [] # still held back; latest replaces the pending
await adapter.send("default", "done, findings ready", metadata={"notify": True})
assert fake.calls == []
await adapter.stop_typing("default") # turn ends
assert len(fake.calls) == 1
call = fake.calls[0]
assert call["chat_id"] == "default"
assert call["data"]["kind"] == "message"
assert "done, findings ready" in call["body"]
# All frames are parked in the outbox for sync catch-up.
assert adapter._outbox.latest_cursor() == 3
@pytest.mark.asyncio
async def test_deferred_push_dropped_when_device_comes_online(adapter, ws_client):
"""If the device reconnects mid-turn it syncs the parked frames, so the
turn-end flush must not push a duplicate."""
ws, _ = ws_client
fake = _FakePush()
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter.send_typing("default")
await adapter.send("default", "intermediate", metadata={"notify": True})
assert fake.calls == []
# Device reconnects mid-turn (SSE open -> on_device_online clears the
# pending push); the parked frame arrives via the event stream.
frames = await recv_until(ws, lambda f: f.get("type") == "message")
assert frames[-1]["payload"]["text"] == "intermediate"
await adapter.stop_typing("default")
assert fake.calls == []
@pytest.mark.asyncio
async def test_high_priority_notification_pushes_immediately_during_turn(plugin, adapter):
"""Approval/clarify/cron notifications need user action: they push
immediately even while a turn is in flight and the device is offline."""
fake = _FakePush()
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter.send_typing("default")
await adapter._broadcast_or_log(
"default",
plugin.protocol.notification(
"default", plugin.protocol.NOTIF_APPROVAL, "Approve", "run rm -rf?"
),
)
assert len(fake.calls) == 1
assert fake.calls[0]["priority"] == "high"
assert fake.calls[0]["data"]["kind"] == "approval"
# ── M5: fcm.register ───────────────────────────────────────────────────────
@@ -1755,7 +1835,7 @@ async def test_fcm_register_updates_registry(adapter, ws_client):
adapter._http_server._subs.clear()
fake = _FakePush()
adapter._push = fake
await adapter.send("android:default", "after rotation", metadata={"notify": True})
await adapter.send("default", "after rotation", metadata={"notify": True})
assert len(fake.calls) == 1
assert fake.calls[0]["token"] == "rotated-token"
@@ -1854,7 +1934,7 @@ async def test_channel_favorite_toggle(adapter, ws_client):
@pytest.mark.asyncio
async def test_channel_favorite_unknown_id_rejected(adapter, ws_client):
ws, _ = ws_client
await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.favorite", "chat_id": "android:chan_999", "payload": {"on": True}}))
await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.favorite", "chat_id": "chan_999", "payload": {"on": True}}))
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1)
assert frames[-1]["payload"]["code"] == "not_found"
@@ -1909,7 +1989,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client):
"hello from cron\n\n"
"To stop or manage this job, send me a new message (e.g. \"stop reminder My Job\")."
)
res = await adapter.send("android:default", wrapped, metadata={"job_id": "abc123"})
res = await adapter.send("default", wrapped, metadata={"job_id": "abc123"})
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message")
msg = frames[-1]
@@ -1922,7 +2002,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client):
assert notif[0]["payload"]["body"] == "hello from cron"
# wrap_response: false -- raw content, job id as the name.
res2 = await adapter.send("android:default", "raw cron output", metadata={"job_id": "j2"})
res2 = await adapter.send("default", "raw cron output", metadata={"job_id": "j2"})
assert res2.success
frames = await recv_until(
ws,
@@ -1938,10 +2018,12 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client):
ws, _ = ws_client
from tools import clarify_gateway as cg
cg.register("cl_1", "sk", "Which one?", ["A", "B"])
# Multi-select forces the numbered-text fallback (single-select with a live
# device now renders a picker -- see test_clarify_single_select_emits_picker).
cg.register("cl_1", "sk", "Which one?", ["A", "B"], multi_select=True)
try:
res = await adapter.send_clarify(
"android:default", "Which one?", ["A", "B"], "cl_1", "sk"
"default", "Which one?", ["A", "B"], "cl_1", "sk"
)
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message")
@@ -1958,14 +2040,106 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client):
cg._session_index.pop("sk", None)
@pytest.mark.asyncio
async def test_clarify_single_select_emits_picker(plugin, adapter, ws_client):
"""Single-select clarify with a live device renders as a picker.choice
card (options + Other). Tapping an option resolves the clarify with the
choice text; the picker is consumed."""
ws, _ = ws_client
from tools import clarify_gateway as cg
cg.register("cl_pk1", "sk", "Which one?", ["A", "B"])
try:
res = await adapter.send_clarify("default", "Which one?", ["A", "B"], "cl_pk1", "sk")
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "picker.choice")
pc = frames[-1]
pid = pc["payload"]["picker_id"]
assert pid == res.message_id
assert pc["payload"]["title"] == "❓ Which one?"
assert [c["value"] for c in pc["payload"]["choices"]] == ["c0", "c1", "other"]
assert [c["label"] for c in pc["payload"]["choices"]] == [
"A",
"B",
"✏️ Other (type your answer)",
]
# Tap "A" (c0) -> resolves the clarify with the choice text.
await ws.send(
json.dumps(
{"type": "picker.select", "id": 1, "payload": {"picker_id": pid, "value": "c0"}}
)
)
entry = cg._entries.get("cl_pk1")
for _ in range(50):
if entry.event.is_set():
break
await asyncio.sleep(0.05)
assert entry.event.is_set()
assert entry.response == "A"
assert pid not in adapter._pending_pickers
finally:
cg._entries.pop("cl_pk1", None)
cg._session_index.pop("sk", None)
@pytest.mark.asyncio
async def test_clarify_picker_other_flips_to_text(plugin, adapter, ws_client):
"""Tapping 'Other' on a clarify picker flips the entry to text-capture
(awaiting_text) and prompts the user to type; the clarify is NOT resolved."""
ws, _ = ws_client
from tools import clarify_gateway as cg
cg.register("cl_pk2", "sk", "Which one?", ["A", "B"])
try:
res = await adapter.send_clarify("default", "Which one?", ["A", "B"], "cl_pk2", "sk")
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "picker.choice")
pid = frames[-1]["payload"]["picker_id"]
await ws.send(
json.dumps(
{"type": "picker.select", "id": 1, "payload": {"picker_id": pid, "value": "other"}}
)
)
frames2 = await recv_until(
ws,
lambda f: f.get("type") == "message"
and "Type your answer" in f["payload"].get("text", ""),
)
assert frames2[-1]["payload"]["role"] == "assistant"
entry = cg._entries.get("cl_pk2")
assert entry.awaiting_text is True
assert not entry.event.is_set()
finally:
cg._entries.pop("cl_pk2", None)
cg._session_index.pop("sk", None)
@pytest.mark.asyncio
async def test_clarify_single_select_no_device_falls_back_to_text(adapter):
"""Single-select clarify with NO live device falls back to the numbered
text list (no picker) and enables text-capture."""
from tools import clarify_gateway as cg
cg.register("cl_pk3", "sk", "Which one?", ["A", "B"])
try:
res = await adapter.send_clarify("default", "Which one?", ["A", "B"], "cl_pk3", "sk")
assert res.success
assert not adapter._pending_pickers
entry = cg._entries.get("cl_pk3")
assert entry.awaiting_text is True
finally:
cg._entries.pop("cl_pk3", None)
cg._session_index.pop("sk", None)
# ── M5: sync catch-up + outbox row cap ─────────────────────────────────────
@pytest.mark.asyncio
async def test_sync_replays_parked_frames_and_done_cursor(adapter):
# Park two frames while offline.
await adapter.send("android:default", "one", metadata={"notify": True})
await adapter.send("android:default", "two", metadata={"notify": True})
await adapter.send("default", "one", metadata={"notify": True})
await adapter.send("default", "two", metadata={"notify": True})
assert adapter._outbox.latest_cursor() == 2
await adapter.connect()
@@ -2021,26 +2195,26 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter.send("android:default", "one", metadata={"notify": True})
await adapter.send("default", "one", metadata={"notify": True})
assert len(fake.calls) == 1
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
# Immediate second frame (cron's message frame) coalesces — no second
# push, cursor unchanged.
await adapter.send("android:default", "two", metadata={"notify": True})
await adapter.send("default", "two", metadata={"notify": True})
assert len(fake.calls) == 1
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
# Simulate the coalesce window elapsing, then push again.
adapter._last_push_at["android:default"] = 0.0
await adapter.send("android:default", "three", metadata={"notify": True})
adapter._last_push_at["default"] = 0.0
await adapter.send("default", "three", metadata={"notify": True})
assert len(fake.calls) == 2
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
# A failed push must NOT advance the cursor (the device never woke).
fake.fail_next = True
adapter._last_push_at["android:default"] = 0.0
await adapter.send("android:default", "four", metadata={"notify": True})
adapter._last_push_at["default"] = 0.0
await adapter.send("default", "four", metadata={"notify": True})
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
await adapter.connect()
@@ -2058,8 +2232,8 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter):
"""Frames replayed by sync carry their outbox cursor in the envelope so
the app can compare it against last_pushed_cursor (docs/08 §8.7). Live
frames carry no cursor."""
await adapter.send("android:default", "one", metadata={"notify": True})
await adapter.send("android:default", "two", metadata={"notify": True})
await adapter.send("default", "one", metadata={"notify": True})
await adapter.send("default", "two", metadata={"notify": True})
await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
@@ -2083,7 +2257,7 @@ async def test_live_frames_carry_no_cursor(adapter, ws_client):
"""Live (non-replay) frames must not carry a cursor — the app only
suppresses notifications for replayed frames (docs/08 §8.7)."""
ws, _ = ws_client
await adapter.send("android:default", "live", metadata={"notify": True})
await adapter.send("default", "live", metadata={"notify": True})
frames = await recv_until(ws, lambda f: f.get("type") == "message")
assert "cursor" not in frames[-1]
@@ -2093,7 +2267,7 @@ def test_outbox_row_cap_prunes_oldest(plugin, tmp_path):
try:
for i in range(7):
outbox.append(
"android:default",
"default",
json.dumps({"v": 1, "type": "message", "payload": {"n": i}}),
)
assert outbox.latest_cursor() == 7 # cursor stays monotonic
@@ -2110,7 +2284,7 @@ def test_outbox_delete_message_removes_all_frames_for_id(plugin, tmp_path):
in the chat, leaving other messages intact; a thread_id scopes the delete."""
outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
try:
chat = "android:default"
chat = "default"
# A streaming message spans start/update/stop; a standalone message is
# one frame. Plus an unrelated message that must survive.
outbox.append(chat, json.dumps({"v": 1, "type": "message.start", "payload": {"message_id": "m1", "role": "assistant"}}))
@@ -2139,12 +2313,12 @@ def test_outbox_delete_lane_removes_channel_and_thread_frames(plugin, tmp_path):
scoped to that thread's frames only."""
outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
try:
chan = "android:chan_9"
chan = "chan_9"
# Flat-lane frames + two threads' frames, plus an unrelated channel.
outbox.append(chan, json.dumps({"v": 1, "type": "message", "payload": {"message_id": "a", "role": "user", "text": "flat"}}))
outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_1", "payload": {"message_id": "b", "role": "user", "text": "t1"}}))
outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_2", "payload": {"message_id": "c", "role": "user", "text": "t2"}}))
outbox.append("android:chan_8", json.dumps({"v": 1, "type": "message", "payload": {"message_id": "z", "role": "user", "text": "other"}}))
outbox.append("chan_8", json.dumps({"v": 1, "type": "message", "payload": {"message_id": "z", "role": "user", "text": "other"}}))
# Thread delete: only t_1's frame goes.
assert outbox.delete_lane(chan, thread_id="t_1") == 1
rows = outbox.replay(0)
@@ -2164,7 +2338,7 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
a channel, its threads; the default channel cannot be deleted."""
d = plugin.channels.ChannelDirectory(tmp_path / "ch.db")
try:
d.ensure_default("android:default", "Default")
d.ensure_default("default", "Default")
chan = d.create("Work", kind="channel")
t1 = d.create("Topic", kind="thread", parent_chat_id=chan["chat_id"])
# Deleting the channel removes it AND its thread from the directory.
@@ -2180,10 +2354,10 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
assert d.get(t2["chat_id"]) is None
assert d.get(chan2["chat_id"]) is not None # parent channel survives
# The default channel cannot be deleted.
assert d.delete("android:default") is None
assert d.get("android:default") is not None
assert d.delete("default") is None
assert d.get("default") is not None
# Unknown id -> None.
assert d.delete("android:chan_nope") is None
assert d.delete("chan_nope") is None
finally:
d.close()
@@ -2272,19 +2446,80 @@ def test_tool_end_fields_from_hook(plugin):
def test_parse_tool_line_or_block_verbose(plugin):
a = plugin.adapter
content = '🔍 web_search(["query"])\n{"query": "hermes agent"}'
name, preview, args = a.AndroidAdapter._parse_tool_line_or_block(
name, preview, args = a.IrisAdapter._parse_tool_line_or_block(
'🔍 web_search(["query"])', content
)
assert name == "web_search"
assert args == {"query": "hermes agent"}
assert preview == "hermes agent" # derived short preview
# Non-verbose line -> args None, preview from the line.
name2, preview2, args2 = a.AndroidAdapter._parse_tool_line_or_block(
name2, preview2, args2 = a.IrisAdapter._parse_tool_line_or_block(
'🔍 web_search: "x"', '🔍 web_search: "x"'
)
assert name2 == "web_search" and preview2 == "x" and args2 is None
def test_tool_start_frame_emoji_field(plugin):
"""``tool.start`` carries the cosmetic emoji when given, omits it when
None (the app then falls back to its own default glyph)."""
pf = plugin.protocol.tool_start
f = pf("default", 3, "terminal", emoji="💻")
assert f.payload["emoji"] == "💻"
f2 = pf("default", 3, "terminal")
assert "emoji" not in f2.payload
f3 = pf("default", 3, "terminal", emoji=None)
assert "emoji" not in f3.payload
def test_tool_emoji_resolves_via_hermes_display(plugin, monkeypatch):
"""``_tool_emoji`` delegates to hermes' ``get_tool_emoji`` (skin overrides
-> registry) and maps unknown tools / failures to None (field omitted)."""
a = plugin.adapter
seen = {}
def fake_get_tool_emoji(name, default="⚡"):
seen[name] = default
return {"terminal": "💻", "read_file": "📖"}.get(name, default)
monkeypatch.setattr("agent.display.get_tool_emoji", fake_get_tool_emoji)
assert a._tool_emoji("terminal") == "💻"
assert a._tool_emoji("read_file") == "📖"
# Unknown tool: the adapter asks for an empty default and drops it.
assert a._tool_emoji("no_such_tool") is None
assert seen["no_such_tool"] == ""
# A display-layer failure degrades to None, never a crash.
def boom(name, default="⚡"):
raise RuntimeError("no display")
monkeypatch.setattr("agent.display.get_tool_emoji", boom)
assert a._tool_emoji("terminal") is None
@pytest.mark.asyncio
async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkeypatch):
"""A tool-progress line emits ``tool.start`` with the per-tool emoji
resolved from hermes' display layer."""
ws, _ = ws_client
monkeypatch.setattr(
"agent.display.get_tool_emoji",
lambda name, default="⚡": "💻" if name == "terminal" else default,
)
res = await adapter.send('default', '💻 terminal: "ls -la"')
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "tool.start")
payload = frames[-1]["payload"]
assert payload["name"] == "terminal"
assert payload["emoji"] == "💻"
# Unknown tool -> field omitted (app falls back to its default glyph).
monkeypatch.setattr("agent.display.get_tool_emoji", lambda name, default="⚡": default)
res2 = await adapter.send('default', '🔧 patch: "x"')
assert res2.success
frames2 = await recv_until(
ws, lambda f: f.get("type") == "tool.start" and f["payload"]["name"] == "patch"
)
assert "emoji" not in frames2[-1]["payload"]
# ── Slash-command catalog (the app's "/" drawer) ──────────────────────────
@@ -2336,4 +2571,88 @@ async def test_commands_catalog_frame_roundtrip(plugin, adapter, ws_client):
for c in commands:
assert c["name"].startswith("/")
assert "description" in c and "args_hint" in c and "category" in c
assert isinstance(c["aliases"], list)
assert isinstance(c["aliases"], list)
# ── Interactive pickers (picker.choice / picker.select) ───────────────────
@pytest.mark.asyncio
async def test_choice_picker_roundtrip(plugin, adapter, ws_client):
"""send_choice_picker emits a picker.choice frame; a picker.select from
the app runs the selection callback and delivers its reply as a normal
final message. The picker is consumed (a second select is a no-op)."""
ws, _ = ws_client
picked: list = []
async def on_choice(chat_id, value):
picked.append((chat_id, value))
return f"picked {value}"
res = await adapter.send_choice_picker(
"default",
"Pick one",
[
{"value": "a", "label": "A", "is_current": False},
{"value": "b", "label": "B", "is_current": True},
],
"sess",
on_choice,
)
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "picker.choice")
pc = frames[-1]
assert pc["chat_id"] == "default"
pid = pc["payload"]["picker_id"]
assert pid == res.message_id
assert pc["payload"]["title"] == "Pick one"
assert pc["payload"]["choices"][1]["is_current"] is True
# The app taps a button -> picker.select.
await ws.send(
json.dumps(
{"type": "picker.select", "id": 1, "payload": {"picker_id": pid, "value": "b"}}
)
)
assert picked == [("default", "b")]
# The callback's reply arrives as a normal final assistant message.
frames2 = await recv_until(
ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "picked b"
)
assert frames2[-1]["payload"]["role"] == "assistant"
# The picker is consumed: a second select for the same id is a no-op.
await ws.send(
json.dumps(
{"type": "picker.select", "id": 2, "payload": {"picker_id": pid, "value": "a"}}
)
)
await asyncio.sleep(0.2)
assert picked == [("default", "b")]
@pytest.mark.asyncio
async def test_choice_picker_requires_live_device(adapter):
"""With no live device the picker could never be answered, so the adapter
reports failure and hermes falls back to the text status card."""
res = await adapter.send_choice_picker(
"default",
"t",
[{"value": "a", "label": "A"}],
"s",
lambda c, v: "x",
)
assert not res.success
assert not adapter._pending_pickers
@pytest.mark.asyncio
async def test_picker_select_unknown_id_is_noop(plugin, adapter, ws_client):
"""A stale picker.select (gateway restart expired the pending state) is
ignored without error."""
ws, _ = ws_client
await ws.send(
json.dumps(
{"type": "picker.select", "id": 1, "payload": {"picker_id": "pc_nope", "value": "a"}}
)
)
await asyncio.sleep(0.2)
assert not adapter._pending_pickers
+11 -11
View File
@@ -1,4 +1,4 @@
"""Tests for the android plugin's HTTP fallback transport (docs/19).
"""Tests for the iris plugin's HTTP fallback transport (docs/19).
The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it
from the source tree directly (same pattern as ``test_android.py``).
@@ -44,9 +44,9 @@ import pytest_asyncio
# Test-only token (not a credential; the adapter is built with it via
# monkeypatch in the fixture below).
# pi-lens-ignore: S105
TOKEN = "test-android-http-token-0123456789"
TOKEN = "test-iris-http-token-0123456789"
DEVICE_ID = "test-http-device"
CHAT_ID = "android:default"
CHAT_ID = "default"
# 1x1 PNG (same fixture as test_android.py).
PNG_1X1 = base64.b64decode(
@@ -56,7 +56,7 @@ PNG_1X1 = base64.b64decode(
def _plugin_dir() -> Path:
env = os.environ.get("ANDROID_PLUGIN_DIR")
env = os.environ.get("IRIS_PLUGIN_DIR")
if env:
return Path(env)
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
@@ -72,13 +72,13 @@ def _plugin_dir() -> Path:
def _load_plugin():
"""Load the gateway-plugin package under a unique module name (same
pattern as test_android.py)."""
name = "android_plugin_http_under_test"
name = "iris_plugin_http_under_test"
cached = sys.modules.get(name)
if cached is not None:
return cached
pkg_dir = _plugin_dir()
if not (pkg_dir / "__init__.py").is_file():
pytest.fail(f"android plugin not found at {pkg_dir}")
pytest.fail(f"iris plugin not found at {pkg_dir}")
spec = importlib.util.spec_from_file_location(
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
)
@@ -101,14 +101,14 @@ def plugin():
@pytest.fixture
def adapter(plugin, monkeypatch):
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
from gateway.platform_registry import PlatformEntry, platform_registry
if not platform_registry.is_registered("android"):
if not platform_registry.is_registered("iris"):
platform_registry.register(
PlatformEntry(
name="android",
name="iris",
label="Android",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
@@ -124,7 +124,7 @@ def adapter(plugin, monkeypatch):
},
home_channel=None,
)
a = plugin.adapter.AndroidAdapter(config)
a = plugin.adapter.IrisAdapter(config)
yield a
with contextlib.suppress(Exception):
a._devices.close()
+9 -9
View File
@@ -7,13 +7,13 @@ while building the Kotlin client.
Usage::
hermes gateway & # with the android plugin
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \
hermes gateway & # with the iris plugin
python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> \
--send "hello"
Options:
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws)
--token ANDROID_TOKEN (default: $ANDROID_TOKEN)
--token IRIS_TOKEN (default: $IRIS_TOKEN)
--device device_id (default: probe-<rand>)
--send TEXT send this message after pairing (default: "hello")
--upload F M4: upload F (chunked media.upload) and attach it to the
@@ -573,7 +573,7 @@ def run_http(args, base: str) -> int:
"v": 1,
"id": 1,
"type": "message.send",
"chat_id": "android:default",
"chat_id": "default",
"payload": {"text": args.send},
}
conn = HTTPConnection(host, port, timeout=30)
@@ -666,8 +666,8 @@ def run_http(args, base: str) -> int:
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", "ws://127.0.0.1:8790/ws"))
p.add_argument("--token", default=os.getenv("ANDROID_TOKEN", ""))
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", "ws://127.0.0.1:8790/ws"))
p.add_argument("--token", default=os.getenv("IRIS_TOKEN", ""))
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
p.add_argument("--send", default="hello")
p.add_argument(
@@ -724,8 +724,8 @@ def main() -> int:
)
p.add_argument(
"--chat-id",
default="android:default",
help="chat_id for --scope chat (default android:default)",
default="default",
help="chat_id for --scope chat (default default)",
)
p.add_argument(
"--channel-create", default="", help="M3: create a channel, print its chat_id, exit"
@@ -762,7 +762,7 @@ def main() -> int:
)
args = p.parse_args()
if not args.token and not args.authfail:
p.error("--token (or $ANDROID_TOKEN) is required")
p.error("--token (or $IRIS_TOKEN) is required")
if args.assert_read_receipt and not args.send:
p.error("--assert-read-receipt requires --send (the receipt must follow the sent message)")
# HTTP is the only transport (docs/19): derive the http(s) base from the