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

This commit is contained in:
ARIA committed 2026-08-22 22:43:13 +02:00
1 parent 27dc7917f2
commit 7a6d922d12
63 files changed
+2073 -630

No files matched your search

+158 -124
View File
@@ -1,5 +1,5 @@
"""
Android Platform Adapter for Hermes Agent (Iris x Hermes).
Iris Platform Adapter for Hermes Agent (Iris x Hermes).
A plugin-based gateway adapter that runs an HTTP server *inside* the
``hermes gateway`` process. The native Android / Desktop app connects to it
@@ -34,7 +34,7 @@ register the (delivery-validated) file in the media registry and emit
Milestone M5: push + offline. Frames with no live subscriber are parked in
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
HTTP v1 primary, ntfy fallback, selected by ``ANDROID_PUSH_BACKEND``).
HTTP v1 primary, ntfy fallback, selected by ``IRIS_PUSH_BACKEND``).
``notification`` frames render in-app banners and mirror to push (channel
events, cron deliveries, approvals, clarifies); high-priority kinds push even
when a device is live. ``fcm.register`` rotates push tokens (registry + live
@@ -44,19 +44,19 @@ Configuration in config.yaml::
gateway:
platforms:
android:
iris:
enabled: true
extra:
host: 127.0.0.1
port: 8790
home_channel: android:default
home_channel: default
push_backend: fcm
outbox_retention_hours: 72
max_upload_bytes: 104857600
Or via environment variables (overrides config.yaml; secrets live in .env):
ANDROID_TOKEN, ANDROID_WS_HOST, ANDROID_WS_PORT, ANDROID_HOME_CHANNEL,
ANDROID_PUSH_BACKEND, ANDROID_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
IRIS_TOKEN, IRIS_WS_HOST, IRIS_WS_PORT, IRIS_HOME_CHANNEL,
IRIS_PUSH_BACKEND, IRIS_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
"""
import asyncio
@@ -116,7 +116,10 @@ from gateway.platforms.base import ( # noqa: E402
from hermes_constants import get_hermes_home # noqa: E402
from . import media as media_bridge # noqa: E402
from . import protocol # noqa: E402
from . import ( # noqa: E402
protocol,
qr,
)
from . import purge as purge_bridge # noqa: E402
from . import search as search_bridge # noqa: E402
from .channels import get_directory # noqa: E402
@@ -124,6 +127,7 @@ from .http_server import HttpServer # noqa: E402
from .outbox import Outbox # noqa: E402
from .pairing import ( # noqa: E402
DeviceRegistry,
advertise_host,
generate_token,
pairing_url,
qr_payload,
@@ -147,7 +151,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
from hermes_cli import commands as hermes_commands
except Exception:
logger.warning(
"android: slash catalog unavailable (hermes_cli.commands import failed)",
"iris: slash catalog unavailable (hermes_cli.commands import failed)",
exc_info=True,
)
return []
@@ -175,7 +179,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
except Exception:
# Code skew: the private helpers moved. Fall back to the plain
# cli_only filter (config-gated commands are dropped, acceptable).
logger.warning("android: slash catalog fell back to cli_only filter", exc_info=True)
logger.warning("iris: slash catalog fell back to cli_only filter", exc_info=True)
entries = [
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
for cmd in hermes_commands.COMMAND_REGISTRY
@@ -187,7 +191,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
except Exception:
# Best-effort: a broken plugin-command registry should not break the
# built-in catalog, so the failure is intentionally swallowed.
logger.debug("android: plugin command enumeration failed", exc_info=True)
logger.debug("iris: plugin command enumeration failed", exc_info=True)
return entries
@@ -200,7 +204,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
# hermes exposes a plugin ``on_stream_delta`` hook that fires reasoning
# deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``).
# We accumulate those deltas here and attach the result to the turn's
# ``message.stop`` frame. Single-chat for now (android:default), so a
# ``message.stop`` frame. Single-chat for now (the default home channel), so a
# module-level buffer suffices; it is reset at each turn start.
# ---------------------------------------------------------------------------
@@ -261,7 +265,7 @@ def _reset_reasoning() -> None:
# (Settings → Tool detail), we capture each completed tool call via the
# ``post_tool_call`` hook and attach it to the ``tool.end`` frame.
#
# Global FIFO (like the reasoning buffer): a personal android gateway serves
# Global FIFO (like the reasoning buffer): a personal iris gateway serves
# one active turn at a time, and records are matched to the open tool by name
# in completion order. Bounded so a runaway turn can't grow it without limit.
# ---------------------------------------------------------------------------
@@ -356,8 +360,8 @@ def _tool_emoji(tool_name: str) -> str | None:
# * turn start — the first API call of the turn (latency baseline)
#
# Global buffer (same pattern as the reasoning/tool buffers): a personal
# android gateway serves one active turn at a time. The hook fires for every
# platform, so we only record when the turn's platform is android.
# iris gateway serves one active turn at a time. The hook fires for every
# platform, so we only record when the turn's platform is iris.
# ---------------------------------------------------------------------------
_runtime_meta: dict[str, Any] = {}
@@ -374,7 +378,7 @@ _CTX_RESOLVE_TIMEOUT_S = 3.0
def _on_post_api_request(**kwargs: Any) -> None:
"""Plugin hook: capture per-turn runtime metadata (model, prompt tokens)."""
platform = kwargs.get("platform")
if platform and platform != "android":
if platform and platform != "iris":
return
model = kwargs.get("model") or ""
usage = kwargs.get("usage") or {}
@@ -416,7 +420,7 @@ def _resolve_context_length(model: str) -> int | None:
_context_length_cache[model] = int(ctx)
return int(ctx)
except Exception:
logger.debug("android: context-length resolution failed for %s", model, exc_info=True)
logger.debug("iris: context-length resolution failed for %s", model, exc_info=True)
return None
@@ -472,7 +476,7 @@ async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]:
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8790
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
DEFAULT_HOME_CHANNEL = "android:default"
DEFAULT_HOME_CHANNEL = "default"
DEFAULT_HOME_CHANNEL_NAME = "Default"
DEFAULT_PUSH_BACKEND = "fcm"
DEFAULT_OUTBOX_RETENTION_HOURS = 72
@@ -825,13 +829,13 @@ def check_requirements() -> bool:
dashboard readiness). Never installs. The HTTP transport is stdlib-only,
so there is no extra dependency to probe.
"""
return bool(_get_scoped_secret("ANDROID_TOKEN"))
return bool(_get_scoped_secret("IRIS_TOKEN"))
def validate_config(config) -> bool:
"""Given a PlatformConfig, is the platform properly configured?"""
extra = getattr(config, "extra", {}) or {}
token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "")
return bool(token)
@@ -858,7 +862,7 @@ def _env_enablement() -> dict | None:
core hook -- it becomes a proper ``HomeChannel`` dataclass on the
``PlatformConfig`` rather than being merged into ``extra``.
"""
token = _get_scoped_secret("ANDROID_TOKEN", "")
token = _get_scoped_secret("IRIS_TOKEN", "")
if not token:
return None
@@ -867,20 +871,20 @@ def _env_enablement() -> dict | None:
# clobber user YAML. Unset keys fall through to config.yaml / adapter
# defaults.
seed: dict[str, Any] = {}
host = os.getenv("ANDROID_WS_HOST", "").strip()
host = os.getenv("IRIS_WS_HOST", "").strip()
if host:
seed["host"] = host
http_port_raw = os.getenv("ANDROID_HTTP_PORT", "").strip()
http_port_raw = os.getenv("IRIS_HTTP_PORT", "").strip()
if http_port_raw:
seed["http_port"] = _parse_port(http_port_raw)
push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
push = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower()
if push:
seed["push_backend"] = push
home = os.getenv("ANDROID_HOME_CHANNEL", "").strip()
home = os.getenv("IRIS_HOME_CHANNEL", "").strip()
if home:
seed["home_channel"] = {
"chat_id": home,
"name": os.getenv("ANDROID_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME,
"name": os.getenv("IRIS_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME,
}
return seed
@@ -893,21 +897,22 @@ def _parse_port(raw: str) -> int:
# ---------------------------------------------------------------------------
# Target parsing: "android:<chat>[:<thread>]"
# Target parsing: "<chat_id>[:<thread>]" (platform prefix stripped by core)
# ---------------------------------------------------------------------------
def _parse_target_ref(target_ref: str) -> tuple | None:
"""Parse a raw target string into ``(chat_id, thread_id)`` or ``None``.
Recognises the native syntax ``android:<chat>[:<thread>]`` where the
chat_id itself carries the ``android:`` prefix (e.g. ``android:chan_7``)
and an optional thread is a trailing ``:t_<n>``. A bare friendly name
(e.g. ``Cron Reports``) is resolved against the channel directory so cron
/ ``send_message`` can target a channel by name immediately, without
waiting for the core directory's refresh timer. Returns ``None`` for
anything unrecognised so the target proceeds to the core channel-directory
resolution.
The core strips the platform prefix before calling us, so the native
syntax is simply ``<chat_id>[:<thread>]`` (e.g. ``chan_7`` or
``chan_7:t_31``); the home channel is ``default``. Chat ids are direct
(no embedded platform prefix), so a cron delivery reads
``iris:chan_7`` end to end. A bare friendly name (e.g. ``Cron Reports``)
is resolved against the channel directory so cron / ``send_message`` can
target a channel by name immediately, without waiting for the core
directory's refresh timer. Returns ``None`` for anything unrecognised so
the target proceeds to the core channel-directory resolution.
"""
if not target_ref:
return None
@@ -915,17 +920,26 @@ def _parse_target_ref(target_ref: str) -> tuple | None:
if not t:
return None
if t.startswith("android:"):
body = t[len("android:") :].strip()
if not body:
return None
thread_id: str | None = None
if ":" in body:
head, tail = body.rsplit(":", 1)
if tail and tail.startswith("t_"):
thread_id = tail
body = head
return (f"android:{body}", thread_id)
thread_id: str | None = None
if ":" in t:
head, tail = t.rsplit(":", 1)
if head and tail.startswith("t_"):
thread_id = tail
t = head
else:
# Not a <chat>:<thread> pair -- treat the whole string as a name.
t = target_ref.strip()
if not t:
return None
# Native chat id (default / chan_<n>) or any id known to the directory
# (covers custom IRIS_HOME_CHANNEL values).
try:
known = get_directory().get(t) is not None
except Exception:
known = False
if t == "default" or re.fullmatch(r"chan_\d+", t) or known:
return (t, thread_id)
# Bare friendly name -> resolve via the channel directory. A thread resolves
# to its session lane (parent_chat_id + thread_id); a channel/default to
@@ -966,7 +980,7 @@ async def _standalone_send(
"""
return {
"error": (
"android standalone send: the running gateway is required to serve "
"iris standalone send: the running gateway is required to serve "
"the outbox (standalone delivery is best-effort only)"
)
}
@@ -978,7 +992,7 @@ async def _standalone_send(
def _ensure_verbose_tool_progress() -> None:
"""Ensure the android platform renders tool progress in ``verbose`` mode.
"""Ensure the iris platform renders tool progress in ``verbose`` mode.
Verbose mode makes the gateway's tool-progress line carry the FULL
argument JSON (not just a ~40-char preview), which the adapter parses
@@ -988,7 +1002,7 @@ def _ensure_verbose_tool_progress() -> None:
it).
Best-effort and idempotent: writes
``display.platforms.android.tool_progress: verbose`` to config.yaml only
``display.platforms.iris.tool_progress: verbose`` to config.yaml only
when it isn't already set. The gateway's config cache is mtime-keyed, so
the write takes effect on the next turn without a restart. Never raises.
"""
@@ -998,19 +1012,19 @@ def _ensure_verbose_tool_progress() -> None:
cfg = load_config_readonly() or {}
display = cfg.get("display") or {}
platforms = display.get("platforms") or {}
android = platforms.get("android") or {}
if android.get("tool_progress") == "verbose":
iris_cfg = platforms.get("iris") or {}
if iris_cfg.get("tool_progress") == "verbose":
return # already set
from utils import atomic_roundtrip_yaml_update
atomic_roundtrip_yaml_update(
get_hermes_home() / "config.yaml",
"display.platforms.android.tool_progress",
"display.platforms.iris.tool_progress",
"verbose",
)
logger.info("android: set display.platforms.android.tool_progress=verbose")
logger.info("iris: set display.platforms.iris.tool_progress=verbose")
except Exception:
logger.debug("android: could not ensure verbose tool_progress", exc_info=True)
logger.debug("iris: could not ensure verbose tool_progress", exc_info=True)
# ---------------------------------------------------------------------------
@@ -1033,57 +1047,77 @@ def interactive_setup() -> None:
)
from hermes_cli.config import get_env_value, save_env_value
except Exception:
print("android: setup helpers unavailable; set ANDROID_TOKEN in ~/.hermes/.env")
print("iris: setup helpers unavailable; set IRIS_TOKEN in ~/.hermes/.env")
return
print_info("📱 Android / Desktop (Iris x Hermes)")
token = get_env_value("ANDROID_TOKEN") or ""
token = get_env_value("IRIS_TOKEN") or ""
if not token:
generated = generate_token()
save_env_value("ANDROID_TOKEN", generated)
save_env_value("IRIS_TOKEN", generated)
print_success(f"Generated pairing token: {generated}")
print_warning("Keep this secret -- the app presents it on connect.")
else:
print_info("Existing ANDROID_TOKEN found (not shown).")
print_info("Existing IRIS_TOKEN found (not shown).")
host = prompt("Bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST)
save_env_value("ANDROID_WS_HOST", host or DEFAULT_HOST)
host = prompt("Bind host", default=get_env_value("IRIS_WS_HOST") or DEFAULT_HOST)
save_env_value("IRIS_WS_HOST", host or DEFAULT_HOST)
# _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
# HTTP default must be applied explicitly (docs/19: 8791).
http_port_raw = (get_env_value("ANDROID_HTTP_PORT") or "").strip()
http_port_raw = (get_env_value("IRIS_HTTP_PORT") or "").strip()
port = prompt(
"HTTP port",
default=str(int(http_port_raw) if http_port_raw.isdigit() else DEFAULT_HTTP_PORT),
)
save_env_value("ANDROID_HTTP_PORT", str(_parse_port(port)))
save_env_value("IRIS_HTTP_PORT", str(_parse_port(port)))
backend = prompt(
"Push backend (fcm/ntfy)",
default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
default=get_env_value("IRIS_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
)
save_env_value("ANDROID_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
save_env_value("IRIS_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
# Pairing payload for the app's Connect screen (manual entry; the app has
# no QR scanner).
url = pairing_url(host or DEFAULT_HOST, _parse_port(port))
# Pairing payload for the app's Connect screen (manual entry + QR scan).
# Advertise a routable host: a bind wildcard (0.0.0.0/127.0.0.1) is
# replaced by the default-route LAN IP so the QR points somewhere a phone
# can actually reach (the user can still override the Server URL in-app).
advertised = advertise_host(host or DEFAULT_HOST)
url = pairing_url(advertised, _parse_port(port))
pairing = qr_payload(advertised, _parse_port(port), token)
print_info("Pair your device (enter this on the app's Connect screen):")
print_info(f"Pairing URL: {qr_payload(host or DEFAULT_HOST, _parse_port(port), token)}")
print_info(f"Pairing URL: {pairing}")
print_info(f"Server URL: {url}")
if advertised != (host or DEFAULT_HOST):
print_info(
f"QR points to {advertised} (your default LAN address). If your "
"phone is on a different network, change the Server URL in the app."
)
# Scannable QR (docs/20): the same payload as a terminal QR. The URL text
# lines stay — the QR is a convenience, not a replacement (non-UTF-8
# terminals still work, and the text is copy-pasteable). render_qr returns
# '' (not an exception) when the payload is too long to encode.
qr_block = qr.render_qr(pairing)
if qr_block:
print_info("Scan with the Iris app (Connect → Scan QR) or any camera app:")
print(qr_block)
else:
print_warning("QR too large to render; use the pairing URL above.")
# Always render tool progress verbosely so the app receives the full tool
# call args (it decides how much to show via Settings → Tool detail).
_ensure_verbose_tool_progress()
print_success("Android configuration saved to ~/.hermes/.env")
print_success("Iris configuration saved to ~/.hermes/.env")
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
# ---------------------------------------------------------------------------
# Android Adapter
# Iris Adapter
# ---------------------------------------------------------------------------
class AndroidAdapter(BasePlatformAdapter):
"""HTTP-backed adapter for the native Iris Android / Desktop app.
class IrisAdapter(BasePlatformAdapter):
"""HTTP-backed adapter for the native Iris app (Android / Desktop).
The HTTP server (``http_server.HttpServer``) authenticates devices with
the pairing token, the device registry tracks live subscribers, ``send()``
@@ -1100,7 +1134,7 @@ class AndroidAdapter(BasePlatformAdapter):
MAX_MESSAGE_LENGTH = 1_000_000
def __init__(self, config, **kwargs):
platform = Platform("android")
platform = Platform("iris")
super().__init__(config=config, platform=platform)
# Ensure verbose tool progress (full args on the progress line) so the
@@ -1111,13 +1145,13 @@ class AndroidAdapter(BasePlatformAdapter):
# Connection settings (env vars override config.yaml). The bind host
# is shared with the (legacy) WS-era env var name for compatibility.
self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
self.host = os.getenv("IRIS_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
self.http_port = _parse_port(
os.getenv("ANDROID_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
os.getenv("IRIS_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
)
self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
self.push_backend = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or extra.get(
self.token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "")
self.push_backend = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower() or extra.get(
"push_backend", DEFAULT_PUSH_BACKEND
)
self.outbox_retention_hours = int(
@@ -1146,18 +1180,18 @@ class AndroidAdapter(BasePlatformAdapter):
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
# TLS (optional)
self.http_cert = _get_scoped_secret("ANDROID_HTTP_CERT") or extra.get("http_cert", "")
self.http_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "")
self.http_cert = _get_scoped_secret("IRIS_HTTP_CERT") or extra.get("http_cert", "")
self.http_key = _get_scoped_secret("IRIS_HTTP_KEY") or extra.get("http_key", "")
# Auth
allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip()
allowed = os.getenv("IRIS_ALLOWED_USERS", "").strip()
self.allowed_users: list[str] = (
[u.strip() for u in allowed.split(",") if u.strip()] if allowed else []
)
self.allow_all = _truthy(os.getenv("ANDROID_ALLOW_ALL_USERS"))
self.allow_all = _truthy(os.getenv("IRIS_ALLOW_ALL_USERS"))
# Runtime state
self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db")
self._devices = DeviceRegistry(get_hermes_home() / "iris" / "devices.db")
# docs/19: HTTP transport (the only device-facing transport).
self._http_server = HttpServer(self, self._devices)
# docs/19 §19.7: reply sinks for in-flight HTTP requests — while a
@@ -1171,7 +1205,7 @@ class AndroidAdapter(BasePlatformAdapter):
# M3: channel directory (shared singleton) + offline outbox.
self._channels = get_directory()
self._outbox = Outbox(
get_hermes_home() / "android" / "outbox.db",
get_hermes_home() / "iris" / "outbox.db",
retention_hours=self.outbox_retention_hours,
)
# M4: media registry (inbound upload refs + outbound offers) and the
@@ -1182,8 +1216,8 @@ class AndroidAdapter(BasePlatformAdapter):
# the outbox-prune banner.
self._push: PushBackend = build_push_backend(
self.push_backend,
fcm_service_account=_get_scoped_secret("ANDROID_FCM_SERVICE_ACCOUNT"),
fcm_server_key=_get_scoped_secret("ANDROID_FCM_SERVER_KEY"),
fcm_service_account=_get_scoped_secret("IRIS_FCM_SERVICE_ACCOUNT"),
fcm_server_key=_get_scoped_secret("IRIS_FCM_SERVER_KEY"),
ntfy_topic=_get_scoped_secret("NTFY_TOPIC"),
ntfy_server_url=os.getenv("NTFY_SERVER_URL", "").strip() or None,
ntfy_auth_token=_get_scoped_secret("NTFY_AUTH_TOKEN"),
@@ -1202,17 +1236,17 @@ class AndroidAdapter(BasePlatformAdapter):
@property
def name(self) -> str:
return "Android"
return "Iris"
# ── Connection lifecycle ──────────────────────────────────────────────
async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Bring the platform up: bind the HTTP server on host:http_port."""
if not self.token:
logger.error("android: ANDROID_TOKEN must be set")
logger.error("iris: IRIS_TOKEN must be set")
self._set_fatal_error(
"config_missing",
"ANDROID_TOKEN must be set",
"IRIS_TOKEN must be set",
retryable=False,
)
return False
@@ -1222,7 +1256,7 @@ class AndroidAdapter(BasePlatformAdapter):
# start() never raises; it disables the leg and logs on failure.
await self._http_server.start()
if not self._http_server.enabled:
logger.error("android: HTTP server failed to bind %s:%s", self.host, self.http_port)
logger.error("iris: HTTP server failed to bind %s:%s", self.host, self.http_port)
self._set_fatal_error(
"bind_failed",
f"HTTP port {self.http_port} unavailable",
@@ -1242,21 +1276,21 @@ class AndroidAdapter(BasePlatformAdapter):
try:
self._channels.ensure_default(self.home_channel, self.home_channel_name)
except Exception:
logger.warning("android: ensure_default failed", exc_info=True)
logger.warning("iris: ensure_default failed", exc_info=True)
# M5: push backend status (degrade gracefully when unconfigured).
if not self._push.configured():
logger.warning(
"android: push backend %r not configured (no credentials) -- "
"iris: push backend %r not configured (no credentials) -- "
"offline devices will not be woken; outbox + sync still apply",
self.push_backend,
)
else:
logger.info("android: push backend: %s", self._push.name)
logger.info("iris: push backend: %s", self._push.name)
self._connected = True
self._mark_connected()
logger.info("android: connected; HTTP server on %s:%s", self.host, self.http_port)
logger.info("iris: connected; HTTP server on %s:%s", self.host, self.http_port)
return True
async def disconnect(self) -> None:
@@ -1271,7 +1305,7 @@ class AndroidAdapter(BasePlatformAdapter):
try:
await self._http_server.stop()
except Exception:
logger.warning("android: HTTP server stop failed", exc_info=True)
logger.warning("iris: HTTP server stop failed", exc_info=True)
# Best-effort shutdown: a close failure on an already-closed store is
# not actionable at disconnect time.
with contextlib.suppress(Exception):
@@ -1280,7 +1314,7 @@ class AndroidAdapter(BasePlatformAdapter):
self._outbox.close()
self._connected = False
self._mark_disconnected()
logger.info("android: disconnected")
logger.info("iris: disconnected")
# ── Outbound (agent -> app) ───────────────────────────────────────────
@@ -1706,7 +1740,7 @@ class AndroidAdapter(BasePlatformAdapter):
try:
cursor = self._outbox.append(chat_id, frame.to_json())
except Exception:
logger.warning("android: outbox append failed", exc_info=True)
logger.warning("iris: outbox append failed", exc_info=True)
return
# docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber
# — count it in the delivery total or every message would push AND
@@ -1714,7 +1748,7 @@ class AndroidAdapter(BasePlatformAdapter):
delivered = await self._http_server.fanout(frame, cursor)
if delivered == 0:
logger.info(
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
"iris: no live devices for %s; %s frame parked in outbox (cursor=%s)",
chat_id,
frame.type,
cursor,
@@ -1786,7 +1820,7 @@ class AndroidAdapter(BasePlatformAdapter):
now = time.time()
if now - self._last_push_at.get(chat_id, 0.0) < _PUSH_COALESCE_S:
logger.info(
"android: push coalesced for %s (%s frame within %.0fs of last push)",
"iris: push coalesced for %s (%s frame within %.0fs of last push)",
chat_id,
frame.type,
_PUSH_COALESCE_S,
@@ -1823,7 +1857,7 @@ class AndroidAdapter(BasePlatformAdapter):
priority=priority,
)
except Exception:
logger.warning("android: push via %s failed", backend.name, exc_info=True)
logger.warning("iris: push via %s failed", backend.name, exc_info=True)
continue
if ok:
# M5: remember that this cursor reached the device via push,
@@ -1832,11 +1866,11 @@ class AndroidAdapter(BasePlatformAdapter):
self._devices.update_push_cursor(device_id, cursor)
except Exception:
logger.warning(
"android: push cursor update failed for %s", device_id, exc_info=True
"iris: push cursor update failed for %s", device_id, exc_info=True
)
self._last_push_at[chat_id] = time.time()
logger.info(
"android: push via %s -> %s (%s, chat=%s)",
"iris: push via %s -> %s (%s, chat=%s)",
backend.name,
device_id,
frame.type,
@@ -1897,13 +1931,13 @@ class AndroidAdapter(BasePlatformAdapter):
) -> SendResult:
safe = validate_media_delivery_path(path)
if safe is None:
logger.warning("android: media path failed delivery validation: %s", path)
return SendResult(success=False, error="android: media path not deliverable")
logger.warning("iris: media path failed delivery validation: %s", path)
return SendResult(success=False, error="iris: media path not deliverable")
try:
size = os.path.getsize(safe)
except OSError as e:
logger.warning("android: media file unreadable %s: %s", safe, e)
return SendResult(success=False, error="android: media file unreadable")
logger.warning("iris: media file unreadable %s: %s", safe, e)
return SendResult(success=False, error="iris: media file unreadable")
entry = self._media.register_outbound(
safe, kind, media_bridge.mime_for_path(safe), filename or os.path.basename(safe), size
)
@@ -2216,7 +2250,7 @@ class AndroidAdapter(BasePlatformAdapter):
except Exception:
logger.debug("Thread title rename broadcast failed", exc_info=True)
threading.Thread(target=_work, daemon=True, name="android-thread-title").start()
threading.Thread(target=_work, daemon=True, name="iris-thread-title").start()
# ── M3: channel directory management (app -> agent) ───────────────────
#
@@ -2455,7 +2489,7 @@ class AndroidAdapter(BasePlatformAdapter):
get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id
)
logger.info(
"android: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
"iris: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
chat_id,
entry.get("kind"),
removed_frames,
@@ -2565,7 +2599,7 @@ class AndroidAdapter(BasePlatformAdapter):
"""
payload = frame.payload
chat_id = frame.chat_id or payload.get("chat_id")
logger.info("android: history request from %s chat_id=%r", device_id, chat_id)
logger.info("iris: history request from %s chat_id=%r", device_id, chat_id)
if not isinstance(chat_id, str) or not chat_id.strip():
await self._reply(
device_id,
@@ -2658,7 +2692,7 @@ class AndroidAdapter(BasePlatformAdapter):
info.get("ts"),
)
logger.info(
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s",
"iris: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s",
device_id,
chat_id,
thread_id,
@@ -2687,9 +2721,9 @@ class AndroidAdapter(BasePlatformAdapter):
try:
self._devices.update_push_tokens(device_id, fcm_token=fcm_token, ntfy_topic=ntfy_topic)
except Exception:
logger.warning("android: fcm.register update failed", exc_info=True)
logger.warning("iris: fcm.register update failed", exc_info=True)
return
logger.info("android: push tokens updated for %s", device_id)
logger.info("iris: push tokens updated for %s", device_id)
# ── M5: approval / clarify banners ────────────────────────────────────
@@ -2854,7 +2888,7 @@ class AndroidAdapter(BasePlatformAdapter):
``gateway/channel_directory.build_channel_directory`` calls this to
populate ``channel_directory.json``, which ``resolve_channel_name``
reads for friendly-name -> chat_id resolution (cron + send_message).
Threads are addressed via the explicit ``android:<chat>:<thread>``
Threads are addressed via the explicit ``iris:<chat>:<thread>``
syntax (see ``_parse_target_ref``), so only channels are listed here.
"""
out: list[dict[str, Any]] = []
@@ -2888,7 +2922,7 @@ class AndroidAdapter(BasePlatformAdapter):
name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id
)
except Exception:
logger.warning("android: create_handoff_thread failed", exc_info=True)
logger.warning("iris: create_handoff_thread failed", exc_info=True)
return None
await self._broadcast_both(protocol.channel_created(entry))
return entry["chat_id"]
@@ -2907,14 +2941,14 @@ def register(ctx):
try:
ctx.register_hook("on_stream_delta", _on_stream_delta)
except Exception:
logger.debug("android: on_stream_delta hook registration failed", exc_info=True)
logger.debug("iris: on_stream_delta hook registration failed", exc_info=True)
# M2: capture each completed tool call's result + timing so the tool.end
# frame can carry the output (the gateway never streams tool output to
# platforms). The app shows it on demand (Settings → Tool detail).
try:
ctx.register_hook("post_tool_call", _on_post_tool_call)
except Exception:
logger.debug("android: post_tool_call hook registration failed", exc_info=True)
logger.debug("iris: post_tool_call hook registration failed", exc_info=True)
# Runtime-metadata footer: capture the turn's model + prompt tokens (per
# provider call) so the final message can carry a structured ``runtime``
# object. The app decides whether/what to show (Settings → Runtime
@@ -2923,30 +2957,30 @@ def register(ctx):
try:
ctx.register_hook("post_api_request", _on_post_api_request)
except Exception:
logger.debug("android: post_api_request hook registration failed", exc_info=True)
logger.debug("iris: post_api_request hook registration failed", exc_info=True)
ctx.register_platform(
name="android",
label="Android",
adapter_factory=AndroidAdapter,
name="iris",
label="Iris",
adapter_factory=IrisAdapter,
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=["ANDROID_TOKEN"],
required_env=["IRIS_TOKEN"],
install_hint="No extra packages needed (httpx is a core dep)",
setup_fn=interactive_setup,
# Env-driven auto-configuration: seeds PlatformConfig.extra with
# host/port/push_backend + home_channel so env-only setups show up in
# gateway status without instantiating the adapter.
env_enablement_fn=_env_enablement,
# Cron home-channel delivery support (deliver=android:<chat>[:<thread>]).
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
# Cron home-channel delivery support (deliver=iris:<chat_id>[:<thread>]).
cron_deliver_env_var="IRIS_HOME_CHANNEL",
# Out-of-process cron delivery (best-effort; outbox is gateway-served).
standalone_sender_fn=_standalone_send,
# Native target syntax: "android:<chat>[:<thread>]".
# Native target syntax: "iris:<chat_id>[:<thread>]" (chat ids are direct, e.g. iris:chan_7).
parse_target_ref_fn=_parse_target_ref,
# Auth env vars for _is_user_authorized() integration.
allowed_users_env="ANDROID_ALLOWED_USERS",
allow_all_env="ANDROID_ALLOW_ALL_USERS",
allowed_users_env="IRIS_ALLOWED_USERS",
allow_all_env="IRIS_ALLOW_ALL_USERS",
# WS has no message-size limit.
max_message_length=0,
# Display.