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
+2068 -625

No files matched your search

+65 -65
View File
@@ -1,79 +1,79 @@
name: CI name: CI
on: on:
push: push:
branches: [master] branches: [master]
pull_request: pull_request:
jobs: jobs:
gateway: gateway:
name: Gateway plugin tests name: Gateway plugin tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install uv - name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh run: curl -LsSf https://astral.sh/uv/install.sh | sh
# The gateway tests run inside the hermes-agent test harness, which is # The gateway tests run inside the hermes-agent test harness, which is
# git-ignored in this repo (read-only research reference). CI clones the # 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. # 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. # Bump the pinned SHA when you update the local hermes-agent checkout.
- name: Clone hermes-agent (pinned) - name: Clone hermes-agent (pinned)
run: | run: |
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
- name: Sync venv - name: Sync venv
run: | run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
cd hermes-agent cd hermes-agent
# pytest lives in the `dev` extra — a plain `uv sync` leaves the # pytest lives in the `dev` extra — a plain `uv sync` leaves the
# venv without it and run_tests.sh refuses to run. # venv without it and run_tests.sh refuses to run.
uv sync --extra dev uv sync --extra dev
- name: Run android gateway tests - name: Run android gateway tests
run: | run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent cd hermes-agent
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
kotlin: kotlin:
name: Kotlin tests (android host + desktop) name: Kotlin tests (android host + desktop)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
# gradle.properties pins org.gradle.java.home to a local JDK path; # gradle.properties pins org.gradle.java.home to a local JDK path;
# strip it so CI uses the JDK installed by setup-java. # strip it so CI uses the JDK installed by setup-java.
- name: Strip local JDK pin - name: Strip local JDK pin
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
- name: Install Android SDK - name: Install Android SDK
run: | run: |
export ANDROID_HOME="$HOME/android-sdk" export ANDROID_HOME="$HOME/android-sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools" mkdir -p "$ANDROID_HOME/cmdline-tools"
curl -fsSL -o /tmp/ct.zip \ curl -fsSL -o /tmp/ct.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools" unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE # Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager # (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
# exits before `yes` is done writing. # exits before `yes` is done writing.
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt 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 "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "sdk.dir=$ANDROID_HOME" > app/local.properties echo "sdk.dir=$ANDROID_HOME" > app/local.properties
# Host-side tests only (no device needed). AGP auto-downloads the # Host-side tests only (no device needed). AGP auto-downloads the
# missing SDK platforms (licenses accepted above). # missing SDK platforms (licenses accepted above).
- name: Run host tests - name: Run host tests
working-directory: app working-directory: app
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
run: | run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent cd hermes-agent
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
kotlin: kotlin:
+5 -5
View File
@@ -14,18 +14,18 @@
## Commands ## Commands
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`). - `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
- Gateway: `hermes gateway setup` (one-time; generates `ANDROID_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`. - Gateway: `hermes gateway setup` (one-time; generates `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below). - Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb). - Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite). - Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
- Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set). - Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`. - WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`. - E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
## Environment / pairing quirks ## Environment / pairing quirks
- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry. - Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. 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. - 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. - `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy. - `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
- JDK 17; no system Gradle — always the wrapper (`./gradlew`). - JDK 17; no system Gradle — always the wrapper (`./gradlew`).
@@ -33,7 +33,7 @@
## Testing quirks ## Testing quirks
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `ANDROID_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`. - `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `IRIS_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design. - e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`. - ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
- ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates. - ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates.
+2 -2
View File
@@ -58,7 +58,7 @@ jobs:
run: | run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent cd hermes-agent
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
kotlin: kotlin:
@@ -153,7 +153,7 @@ jobs:
run: | run: |
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent cd hermes-agent
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
kotlin: kotlin:
@@ -8,6 +8,9 @@
<!-- M5: ntfy listener foreground service (dataSync type on API 34). --> <!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- QR pairing (docs/20): the in-app scanner reads the camera. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application <application
android:label="Iris" android:label="Iris"
@@ -38,8 +41,22 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="iris" android:host="chat" /> <data android:scheme="iris" android:host="chat" />
</intent-filter> </intent-filter>
<!-- QR pairing (docs/20): iris://pair deep link (scanned QR / link). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="iris" android:host="pair" />
</intent-filter>
</activity> </activity>
<!-- QR pairing (docs/20): full-screen scanner launched from the
Connect screen. Not exported; started only by our own app. -->
<activity
android:name="iris.platform.QrScanActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). --> <!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -16,11 +16,16 @@ import iris.platform.AndroidEnv
import iris.platform.AndroidSecureStore import iris.platform.AndroidSecureStore
import iris.platform.AppBridge import iris.platform.AppBridge
import iris.platform.syncNtfyListener import iris.platform.syncNtfyListener
import iris.util.PairLink
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val deepLinkChatId = mutableStateOf<String?>(null) private val deepLinkChatId = mutableStateOf<String?>(null)
private val deepLinkThreadId = mutableStateOf<String?>(null) private val deepLinkThreadId = mutableStateOf<String?>(null)
// QR pairing (docs/20): a parsed iris://pair link to prefill the Connect
// screen with.
private val pendingPair = mutableStateOf<PairLink?>(null)
private val notificationPermission = private val notificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ } registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
@@ -37,7 +42,13 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
val chatId by deepLinkChatId val chatId by deepLinkChatId
val threadId by deepLinkThreadId val threadId by deepLinkThreadId
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId) val pair by pendingPair
IrisApp(
store = store,
deepLinkChatId = chatId,
deepLinkThreadId = threadId,
deepLinkPair = pair,
)
} }
} }
@@ -64,6 +75,14 @@ class MainActivity : ComponentActivity() {
* extras or an iris://chat/<id>?thread=<tid> URI). */ * extras or an iris://chat/<id>?thread=<tid> URI). */
private fun handleDeepLink(intent: Intent?) { private fun handleDeepLink(intent: Intent?) {
val data = intent?.data val data = intent?.data
// QR pairing (docs/20): iris://pair?host=…&port=…&secure=…&token=… —
// the whole payload is in the query string; parse it with the shared
// PairLink parser.
if (data?.scheme == "iris" && data.host == "pair") {
val link = PairLink.parse(data.toString())
if (link != null) pendingPair.value = link
return
}
val chatId = val chatId =
intent?.getStringExtra("chat_id") intent?.getStringExtra("chat_id")
?: data?.pathSegments?.firstOrNull() ?: data?.pathSegments?.firstOrNull()
+10
View File
@@ -29,6 +29,9 @@ val kcefVersion = "2025.03.23"
val markdownVersion = "0.44.0" val markdownVersion = "0.44.0"
// Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16). // Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16).
val sqldelightVersion = "2.3.2" val sqldelightVersion = "2.3.2"
// CameraX + ML Kit for in-app QR pairing (docs/20). Android-only.
val cameraxVersion = "1.5.1"
val mlKitVersion = "16.1.1"
kotlin { kotlin {
android { android {
@@ -111,6 +114,13 @@ kotlin {
implementation("androidx.media3:media3-ui:1.11.0") implementation("androidx.media3:media3-ui:1.11.0")
// SAF picker (rememberLauncherForActivityResult). // SAF picker (rememberLauncherForActivityResult).
implementation("androidx.activity:activity-compose:1.13.0") implementation("androidx.activity:activity-compose:1.13.0")
// CameraX + ML Kit for QR pairing (docs/20): the scanner activity
// uses the camera2 CameraX backend and ML Kit's barcode model.
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-view:$cameraxVersion")
implementation("com.google.mlkit:barcode-scanning:$mlKitVersion")
// M5: FCM push (inert without a Firebase project / google-services.json; // M5: FCM push (inert without a Firebase project / google-services.json;
// the ntfy listener is the fallback). The google-services plugin is // the ntfy listener is the fallback). The google-services plugin is
// applied conditionally in the app module. // applied conditionally in the app module.
@@ -33,7 +33,7 @@ actual fun postSystemNotification(
threadId: String?, threadId: String?,
) { ) {
val context = AndroidEnv.context val context = AndroidEnv.context
val id = chatId ?: "android:default" val id = chatId ?: "default"
// POST_NOTIFICATIONS is a runtime permission on API 33+. // POST_NOTIFICATIONS is a runtime permission on API 33+.
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED != PackageManager.PERMISSION_GRANTED
@@ -53,7 +53,7 @@ class IrisFirebaseMessagingService : FirebaseMessagingService() {
// exception: the app must display them itself. // exception: the app must display them itself.
if (message.notification != null) return if (message.notification != null) return
val data = message.data val data = message.data
val chatId = data["chat_id"] ?: "android:default" val chatId = data["chat_id"] ?: "default"
val threadId = data["thread_id"] val threadId = data["thread_id"]
val title = data["title"] ?: "Iris" val title = data["title"] ?: "Iris"
val body = data["body"] ?: data["title"].orEmpty() val body = data["body"] ?: data["title"].orEmpty()
@@ -100,7 +100,7 @@ class NtfyListenerService : Service() {
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
val chatId = data?.str("chat_id") ?: "android:default" val chatId = data?.str("chat_id") ?: "default"
val threadId = data?.str("thread_id") val threadId = data?.str("thread_id")
// The short preview rides in the SSE `data:` field; fall back to the // The short preview rides in the SSE `data:` field; fall back to the
// X-Title, then a generic label. // X-Title, then a generic label.
@@ -0,0 +1,49 @@
package iris.platform
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/**
* Android QR scanner (docs/20): launches [QrScanActivity] and returns the
* scanned text (or null on cancel) to [onResult].
*/
@Composable
actual fun QrScanButton(onResult: (String?) -> Unit) {
val context = LocalContext.current
val launcher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
val text =
if (result.resultCode == Activity.RESULT_OK) {
result.data?.getStringExtra(QrScanActivity.EXTRA_QR)
} else {
null
}
onResult(text)
}
Button(onClick = {
val activity = context.resolveActivity()
if (activity != null) {
launcher.launch(Intent(activity, QrScanActivity::class.java))
}
}) {
Text("Scan QR")
}
}
/** Walk a (possibly wrapped) context to the hosting [Activity], if any. */
private fun Context.resolveActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.resolveActivity()
else -> null
}
@@ -0,0 +1,136 @@
package iris.platform
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.ViewGroup
import androidx.activity.ComponentActivity
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
/**
* Full-screen QR scanner (docs/20). Launched from the Connect screen's
* "Scan QR" button; returns the raw QR text via [EXTRA_QR] on
* [Activity.RESULT_OK], or [Activity.RESULT_CANCELED] on back/cancel.
*
* Uses the camera2 CameraX backend + ML Kit's barcode model. The camera is
* stopped in [onDestroy].
*/
class QrScanActivity : ComponentActivity() {
companion object {
/** Intent extra carrying the scanned QR text. */
const val EXTRA_QR = "qr"
}
private lateinit var previewView: PreviewView
private var cameraProvider: ProcessCameraProvider? = null
private var settled = false
private val permissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) startCamera() else finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
previewView =
PreviewView(this).apply {
layoutParams =
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
setContentView(previewView)
onBackPressedDispatcher.addCallback(this) {
if (!settled) setResult(Activity.RESULT_CANCELED)
finish()
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
) {
startCamera()
} else {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
private fun startCamera() {
val future = ProcessCameraProvider.getInstance(this)
future.addListener(
{
val provider = future.get()
cameraProvider = provider
val preview =
Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val analyzer =
ImageAnalysis
.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
try {
provider.unbindAll()
provider.bindToLifecycle(
this,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analyzer,
)
} catch (e: Exception) {
// No usable camera (e.g. headless emulator): bail out.
finish()
return@addListener
}
analyzer.setAnalyzer(ContextCompat.getMainExecutor(this)) { proxy ->
analyzeImage(proxy)
}
},
ContextCompat.getMainExecutor(this),
)
}
private fun analyzeImage(proxy: ImageProxy) {
val mediaImage = proxy.image
if (mediaImage == null) {
proxy.close()
return
}
val inputImage = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees)
BarcodeScanning
.getClient()
.process(inputImage)
.addOnSuccessListener { barcodes ->
val text = barcodes.firstOrNull { it.rawValue != null }?.rawValue
if (text != null) finishWithResult(text)
}.addOnCompleteListener { proxy.close() }
}
private fun finishWithResult(text: String) {
if (settled) return
settled = true
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_QR, text))
finish()
}
override fun onDestroy() {
cameraProvider?.unbindAll()
super.onDestroy()
}
}
@@ -10,6 +10,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -26,6 +27,7 @@ import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme import iris.ui.theme.IrisTheme
import iris.ui.theme.LocalUserTheme import iris.ui.theme.LocalUserTheme
import iris.ui.theme.rememberBackgroundImage import iris.ui.theme.rememberBackgroundImage
import iris.util.PairLink
/** /**
* Root composable shared by the Android and Desktop shells. * Root composable shared by the Android and Desktop shells.
@@ -39,6 +41,7 @@ fun IrisApp(
store: SecureStore, store: SecureStore,
deepLinkChatId: String? = null, deepLinkChatId: String? = null,
deepLinkThreadId: String? = null, deepLinkThreadId: String? = null,
deepLinkPair: PairLink? = null,
) { ) {
val controller = remember(store) { IrisController(store) } val controller = remember(store) { IrisController(store) }
DisposableEffect(controller) { DisposableEffect(controller) {
@@ -66,10 +69,11 @@ fun IrisApp(
// untouched, so the UI keeps its proportions at any size. // untouched, so the UI keeps its proportions at any size.
CompositionLocalProvider( CompositionLocalProvider(
LocalUserTheme provides theme, LocalUserTheme provides theme,
LocalDensity provides Density( LocalDensity provides
density = baseDensity.density, Density(
fontScale = baseDensity.fontScale * fontScale, density = baseDensity.density,
), fontScale = baseDensity.fontScale * fontScale,
),
) { ) {
// The color goes through Surface's `color` parameter: a // The color goes through Surface's `color` parameter: a
// Modifier.background on the Surface would be painted *under* the // Modifier.background on the Surface would be painted *under* the
@@ -95,20 +99,34 @@ fun IrisApp(
) )
} }
val s = state val s = state
// QR pairing (docs/20): an iris://pair deep link prefills
// the Connect screen, taking priority over stored creds.
val pairUrl = deepLinkPair?.url ?: store.serverUrl
val pairToken = deepLinkPair?.token ?: store.token
when (s) { when (s) {
GatewayClient.State.Disconnected -> GatewayClient.State.Disconnected -> {
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token) key(deepLinkPair) {
is GatewayClient.State.AuthFailed -> ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
ConnectScreen( }
controller, }
prefillUrl = store.serverUrl,
prefillToken = store.token, is GatewayClient.State.AuthFailed -> {
initialError = "Pairing rejected: ${s.message}", key(deepLinkPair) {
) ConnectScreen(
controller,
prefillUrl = pairUrl,
prefillToken = pairToken,
initialError = "Pairing rejected: ${s.message}",
)
}
}
// Connecting / Reconnecting / Connected all render the chat; the header // Connecting / Reconnecting / Connected all render the chat; the header
// status bubble + connection banner show the link state // status bubble + connection banner show the link state
// without blocking the view (M7's full-screen spinner is gone). // without blocking the view (M7's full-screen spinner is gone).
else -> ChatScreen(controller) else -> {
ChatScreen(controller)
}
} }
// M9: full-screen HTML artifact preview (opened from an // M9: full-screen HTML artifact preview (opened from an
// artifact card in the chat); covers everything while open. // artifact card in the chat); covers everything while open.
@@ -132,15 +132,16 @@ class ChatStore {
var streamingEnabled: Boolean = true var streamingEnabled: Boolean = true
companion object { companion object {
const val DEFAULT_LANE = "android:default" const val DEFAULT_LANE = "default"
fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}" fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
} }
// ── Lane helpers ────────────────────────────────────────────────────── // ── Lane helpers ──────────────────────────────────────────────────────
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because /** Lane key for a (chat, thread) pair. Uses `::` as the separator so a
* chat ids already contain a single `:` (e.g. `android:chan_1`). */ * thread lane can never collide with a chat id (chat ids are direct,
* e.g. `chan_1`, and never contain `:`). */
fun laneKey( fun laneKey(
chatId: String, chatId: String,
threadId: String?, threadId: String?,
@@ -9,7 +9,7 @@ interface SecureStore {
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */ /** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
var serverUrl: String var serverUrl: String
/** ANDROID_TOKEN presented in the auth header. */ /** IRIS_TOKEN presented in the auth header. */
var token: String var token: String
/** Stable app-generated device id (persisted). */ /** Stable app-generated device id (persisted). */
@@ -33,7 +33,7 @@ import java.util.concurrent.TimeUnit
import kotlin.random.Random import kotlin.random.Random
/** /**
* HTTP client for the hermes android gateway (docs/19). * HTTP client for the hermes iris gateway (docs/19).
* *
* HTTP is the only transport: send via `POST /v1/frame`, receive over SSE * HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`. * `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
@@ -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)
@@ -134,7 +134,7 @@ class IrisController(
} }
/** Home channel id (from hello.ack; default until then). */ /** Home channel id (from hello.ack; default until then). */
private val _homeChannel = MutableStateFlow("android:default") private val _homeChannel = MutableStateFlow("default")
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow() val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
/** Lanes whose full history has been loaded this session (in-memory; reset /** Lanes whose full history has been loaded this session (in-memory; reset
@@ -408,7 +408,7 @@ class IrisController(
) { ) {
if (isAppForeground()) return if (isAppForeground()) return
if (text.isBlank()) return if (text.isBlank()) return
val id = chatId ?: "android:default" val id = chatId ?: "default"
val chatName = channels.byId(id)?.name val chatName = channels.byId(id)?.name
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId) postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
} }
@@ -1314,7 +1314,7 @@ private fun SystemMessage(msg: MessageItem) {
/** M7: header title pill — channel avatar + channel name. Automation /** M7: header title pill — channel avatar + channel name. Automation
* channels show their chat_id as a subtitle (tap the pill to copy it) so * channels show their chat_id as a subtitle (tap the pill to copy it) so
* the user can target them for cron delivery (`deliver="android:<chat_id>"`) * the user can target them for cron delivery (`deliver="iris:<chat_id>"`)
* without asking the agent which channel is in use. */ * without asking the agent which channel is in use. */
@Composable @Composable
private fun TitlePill( private fun TitlePill(
@@ -27,8 +27,11 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import iris.platform.QrScanButton
import iris.platform.isDesktop
import iris.state.IrisController import iris.state.IrisController
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.util.PairLink
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -91,11 +94,25 @@ fun ConnectScreen(
value = token, value = token,
onValueChange = { token = it }, onValueChange = { token = it },
label = { Text("Pairing token") }, label = { Text("Pairing token") },
placeholder = { Text("ANDROID_TOKEN (64 hex)") }, placeholder = { Text("IRIS_TOKEN (64 hex)") },
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (!isDesktop) {
Spacer(modifier = Modifier.height(12.dp))
QrScanButton { raw ->
if (raw != null) {
val link = PairLink.parse(raw)
if (link != null) {
url = link.url
token = link.token
} else {
error = "Couldn't read that QR code."
}
}
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
@@ -129,7 +146,7 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" + "Find the token in ~/.hermes/.env (IRIS_TOKEN) or run\n" +
"hermes gateway setup on the gateway host.", "hermes gateway setup on the gateway host.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -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()
}
}
}
@@ -13,7 +13,7 @@ class ChatStoreCacheTest {
val store = ChatStore() val store = ChatStore()
store.loadFromCache( store.loadFromCache(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "hi", ts = 1), MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
@@ -21,15 +21,15 @@ class ChatStoreCacheTest {
), ),
), ),
) )
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
} }
@Test @Test
fun loadFromCacheEmptyIsNoOp() { fun loadFromCacheEmptyIsNoOp() {
val store = ChatStore() val store = ChatStore()
store.addPending("hello", "android:default") store.addPending("hello", "default")
store.loadFromCache(emptyMap()) store.loadFromCache(emptyMap())
assertEquals(1, store.lanes.value["android:default"]!!.size) assertEquals(1, store.lanes.value["default"]!!.size)
} }
@Test @Test
@@ -39,7 +39,7 @@ class ChatStoreCacheTest {
// to it, and the final answer. // to it, and the final answer.
store.loadFromCache( store.loadFromCache(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "count", ts = 100), MessageItem(id = "m1", role = "user", text = "count", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"),
@@ -51,13 +51,13 @@ class ChatStoreCacheTest {
// the tool card between the user message and the answer — not push // the tool card between the user message and the answer — not push
// it to the end. // it to the end.
store.loadHistory( store.loadHistory(
"android:default", "default",
listOf( listOf(
MessageItem(id = "m1", role = "user", text = "count", ts = 100), MessageItem(id = "m1", role = "user", text = "count", ts = 100),
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200), MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
), ),
) )
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
} }
@Test @Test
@@ -65,7 +65,7 @@ class ChatStoreCacheTest {
val store = ChatStore() val store = ChatStore()
store.loadFromCache( store.loadFromCache(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "count", ts = 100), MessageItem(id = "m1", role = "user", text = "count", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", done = true), ToolItem(id = "tool_1", index = 0, name = "bash", done = true),
@@ -73,11 +73,11 @@ class ChatStoreCacheTest {
), ),
) )
store.loadHistory( store.loadHistory(
"android:default", "default",
listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)), listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)),
) )
// A card whose anchor is unknown falls to the end (degenerate case). // A card whose anchor is unknown falls to the end (degenerate case).
assertEquals(listOf("m1", "tool_1"), store.lanes.value["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "tool_1"), store.lanes.value["default"]!!.map { it.id })
} }
@Test @Test
@@ -87,7 +87,7 @@ class ChatStoreCacheTest {
// card minted by the PREVIOUS process. // card minted by the PREVIOUS process.
store.loadFromCache( store.loadFromCache(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "hi", ts = 1), MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
@@ -99,7 +99,7 @@ class ChatStoreCacheTest {
store.onFrame( store.onFrame(
Frame( Frame(
type = TYPE_TOOL_START, type = TYPE_TOOL_START,
chatId = "android", chatId = "iris:other",
payload = payload =
IrisJson.instance.encodeToJsonElement( IrisJson.instance.encodeToJsonElement(
ToolStartPayload.serializer(), ToolStartPayload.serializer(),
@@ -107,7 +107,7 @@ class ChatStoreCacheTest {
), ),
), ),
) )
val ids = store.lanes.value["android:default"]!!.map { it.id } val ids = store.lanes.value["default"]!!.map { it.id }
assertEquals(ids.size, ids.toSet().size) assertEquals(ids.size, ids.toSet().size)
} }
} }
@@ -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.
}
@@ -43,24 +43,24 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveLanes( db.saveLanes(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
msg("m1", ts = 100), msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"),
msg("m2", role = "assistant", text = "hi", ts = 200), msg("m2", role = "assistant", text = "hi", ts = 200),
), ),
"android:default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)), "default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)),
), ),
) )
val loaded = db.loadLanes() val loaded = db.loadLanes()
assertEquals(setOf("android:default", "android:default::thr_1"), loaded.keys) assertEquals(setOf("default", "default::thr_1"), loaded.keys)
// Tool cards are persisted and restored at their anchored position // Tool cards are persisted and restored at their anchored position
// (after the message they follow, before the answer). // (after the message they follow, before the answer).
assertEquals(listOf("m1", "tool_1", "m2"), loaded["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "tool_1", "m2"), loaded["default"]!!.map { it.id })
assertEquals(listOf("m3"), loaded["android:default::thr_1"]!!.map { it.id }) assertEquals(listOf("m3"), loaded["default::thr_1"]!!.map { it.id })
// Messages ordered by ts. // Messages ordered by ts.
assertEquals(100L, (loaded["android:default"]!![0] as MessageItem).ts) assertEquals(100L, (loaded["default"]!![0] as MessageItem).ts)
assertEquals(200L, (loaded["android:default"]!![2] as MessageItem).ts) assertEquals(200L, (loaded["default"]!![2] as MessageItem).ts)
} }
@Test @Test
@@ -68,7 +68,7 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveLanes( db.saveLanes(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
msg("m1", ts = 100), msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"),
@@ -77,7 +77,7 @@ class ChatDbTest {
), ),
), ),
) )
assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["default"]!!.map { it.id })
} }
@Test @Test
@@ -85,7 +85,7 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveLanes( db.saveLanes(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
msg("m1", ts = 100), msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"), ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"),
@@ -93,7 +93,7 @@ class ChatDbTest {
), ),
), ),
) )
assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["android:default"]!!.map { it.id }) assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["default"]!!.map { it.id })
} }
@Test @Test
@@ -101,7 +101,7 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveLanes( db.saveLanes(
mapOf( mapOf(
"android:default" to "default" to
listOf<ChatItem>( listOf<ChatItem>(
msg("m1", ts = 100), msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"), ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"),
@@ -109,7 +109,7 @@ class ChatDbTest {
), ),
), ),
) )
val lane = db.loadLanes()["android:default"]!! val lane = db.loadLanes()["default"]!!
// The process died before tool.end — the open card is closed as // The process died before tool.end — the open card is closed as
// interrupted, not left spinning. // interrupted, not left spinning.
val open = lane.first { it.id == "tool_1" } as ToolItem val open = lane.first { it.id == "tool_1" } as ToolItem
@@ -123,9 +123,9 @@ class ChatDbTest {
@Test @Test
fun saveLanesReplacesPreviousSnapshot() { fun saveLanesReplacesPreviousSnapshot() {
val db = newDb() val db = newDb()
db.saveLanes(mapOf("android:default" to listOf(msg("m1"), msg("m2")))) db.saveLanes(mapOf("default" to listOf(msg("m1"), msg("m2"))))
db.saveLanes(mapOf("android:default" to listOf(msg("m2")))) db.saveLanes(mapOf("default" to listOf(msg("m2"))))
assertEquals(listOf("m2"), db.loadLanes()["android:default"]!!.map { it.id }) assertEquals(listOf("m2"), db.loadLanes()["default"]!!.map { it.id })
} }
@Test @Test
@@ -133,7 +133,7 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveLanes( db.saveLanes(
mapOf( mapOf(
"android:default" to "default" to
listOf( listOf(
msg("p1", status = MsgStatus.Pending, pending = true, ts = 0), msg("p1", status = MsgStatus.Pending, pending = true, ts = 0),
msg("s1", role = "assistant", streaming = true, ts = 500), msg("s1", role = "assistant", streaming = true, ts = 500),
@@ -141,7 +141,7 @@ class ChatDbTest {
), ),
), ),
) )
val lane = db.loadLanes()["android:default"]!! val lane = db.loadLanes()["default"]!!
val msgItem = { id: String -> lane.first { it.id == id } as MessageItem } val msgItem = { id: String -> lane.first { it.id == id } as MessageItem }
// A pending send becomes failed (tap to retry); the gateway never // A pending send becomes failed (tap to retry); the gateway never
// acknowledged it before the process died. // acknowledged it before the process died.
@@ -156,7 +156,7 @@ class ChatDbTest {
@Test @Test
fun systemMessagesAreNotPersisted() { fun systemMessagesAreNotPersisted() {
val db = newDb() val db = newDb()
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true)))) db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true))))
assertTrue(db.loadLanes().isEmpty()) assertTrue(db.loadLanes().isEmpty())
} }
@@ -182,8 +182,8 @@ class ChatDbTest {
), ),
), ),
) )
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(item))) db.saveLanes(mapOf("default" to listOf<ChatItem>(item)))
val loaded = db.loadLanes()["android:default"]!!.first() as MessageItem val loaded = db.loadLanes()["default"]!!.first() as MessageItem
assertEquals("gpt", loaded.runtime?.model) assertEquals("gpt", loaded.runtime?.model)
assertEquals("/tmp/a.png", loaded.media.first().localPath) assertEquals("/tmp/a.png", loaded.media.first().localPath)
} }
@@ -193,26 +193,26 @@ class ChatDbTest {
val db = newDb() val db = newDb()
db.saveChannels( db.saveChannels(
listOf( listOf(
ChannelInfo(chatId = "android:default", name = "General", isDefault = true), ChannelInfo(chatId = "default", name = "General", isDefault = true),
ChannelInfo(chatId = "android:chan_1", name = "Work"), ChannelInfo(chatId = "chan_1", name = "Work"),
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "android:chan_1"), ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "chan_1"),
), ),
) )
val loaded = db.loadChannels() val loaded = db.loadChannels()
assertEquals(3, loaded.size) assertEquals(3, loaded.size)
assertEquals("Work", loaded.first { it.chatId == "android:chan_1" }.name) assertEquals("Work", loaded.first { it.chatId == "chan_1" }.name)
assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind) assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind)
assertEquals(true, loaded.first { it.chatId == "android:default" }.isDefault) assertEquals(true, loaded.first { it.chatId == "default" }.isDefault)
} }
@Test @Test
fun metaRoundTripAndClearAll() { fun metaRoundTripAndClearAll() {
val db = newDb() val db = newDb()
assertNull(db.metaGet("last_lane")) assertNull(db.metaGet("last_lane"))
db.metaPut("last_lane", "android:chan_1") db.metaPut("last_lane", "chan_1")
assertEquals("android:chan_1", db.metaGet("last_lane")) assertEquals("chan_1", db.metaGet("last_lane"))
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(msg("m1")))) db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("m1"))))
db.saveChannels(listOf(ChannelInfo(chatId = "android:default", name = "General"))) db.saveChannels(listOf(ChannelInfo(chatId = "default", name = "General")))
db.clearAll() db.clearAll()
assertTrue(db.loadLanes().isEmpty()) assertTrue(db.loadLanes().isEmpty())
assertEquals(emptyList<ChannelInfo>(), db.loadChannels()) assertEquals(emptyList<ChannelInfo>(), db.loadChannels())
@@ -0,0 +1,89 @@
package iris.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Parser tests for `iris://pair` links (docs/20). The payload shape mirrors
* `pairing.qr_payload` on the gateway side.
*/
class PairLinkTest {
private val token = "ab".repeat(32) // 64 hex chars
@Test
fun parsesValidLink() {
val link = PairLink.parse("iris://pair?host=192.168.1.50&port=8791&secure=0&token=$token")
assertEquals("http://192.168.1.50:8791", link?.url)
assertEquals(token, link?.token)
}
@Test
fun defaultsPortTo8791() {
val link = PairLink.parse("iris://pair?host=10.0.0.5&token=$token")
assertEquals("http://10.0.0.5:8791", link?.url)
}
@Test
fun secureOneSelectsHttps() {
val link = PairLink.parse("iris://pair?host=10.0.0.5&port=8443&secure=1&token=$token")
assertEquals("https://10.0.0.5:8443", link?.url)
}
@Test
fun schemeIsCaseInsensitive() {
val link = PairLink.parse("IRIS://pair?host=10.0.0.5&token=$token")
assertEquals("http://10.0.0.5:8791", link?.url)
}
@Test
fun percentDecodesHost() {
// A hostname with a space would be %20 on the wire.
val link = PairLink.parse("iris://pair?host=my%20host&token=$token")
assertEquals("http://my host:8791", link?.url)
}
@Test
fun rejectsMissingToken() {
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=8791"))
}
@Test
fun rejectsEmptyToken() {
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&token="))
}
@Test
fun rejectsMissingHost() {
assertNull(PairLink.parse("iris://pair?port=8791&token=$token"))
}
@Test
fun rejectsBadPort() {
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=0&token=$token"))
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=70000&token=$token"))
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=abc&token=$token"))
}
@Test
fun rejectsWrongScheme() {
assertNull(PairLink.parse("foo://pair?host=10.0.0.5&token=$token"))
}
@Test
fun rejectsWrongHost() {
assertNull(PairLink.parse("iris://chat?host=10.0.0.5&token=$token"))
}
@Test
fun rejectsNoAuthority() {
assertNull(PairLink.parse("pair?host=10.0.0.5&token=$token"))
}
@Test
fun acceptsBoundaryPorts() {
assertTrue(PairLink.parse("iris://pair?host=h&port=1&token=$token") != null)
assertTrue(PairLink.parse("iris://pair?host=h&port=65535&token=$token") != null)
}
}
+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 | | Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing |
| Reasoning shown before message | Captures + splits reasoning | Collapsible "Reasoning" block above message | | Reasoning shown before message | Captures + splits reasoning | Collapsible "Reasoning" block above message |
| Intermediate messages | Forwards `Commentary` events | Distinct dimmed bubble | | Intermediate messages | Forwards `Commentary` events | Distinct dimmed bubble |
| Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=android:<chat>[:<thread>]` | Channel list, thread toggle, "new channel" | | Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=iris:<chat>[:<thread>]` | Channel list, thread toggle, "new channel" |
| Search ("everywhere" / "this chat/channel") | FTS5 session search bridge | Search UI + scope toggle | | Search ("everywhere" / "this chat/channel") | FTS5 session search bridge | Search UI + scope toggle |
| Attach media (music/video/images/docs) | Inbound cache; outbound `send_*` | Pickers + chunked upload + preview | | Attach media (music/video/images/docs) | Inbound cache; outbound `send_*` | Pickers + chunked upload + preview |
| Push notifications | FCM (primary) / ntfy (fallback) | FCM token / ntfy topic + notification service | | Push notifications | FCM (primary) / ntfy (fallback) | FCM token / ntfy topic + notification service |
@@ -57,7 +57,7 @@ Everything in the feature checklist below.
| Decision | Choice | | Decision | Choice |
|---|---| |---|---|
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) | | Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
| Push backend | **Both** — FCM primary, ntfy fallback (`ANDROID_PUSH_BACKEND`) | | Push backend | **Both** — FCM primary, ntfy fallback (`IRIS_PUSH_BACKEND`) |
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) | | Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) |
| Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) | | Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) |
@@ -66,7 +66,7 @@ Everything in the feature checklist below.
1. **`hermes-agent/` is a read-only research reference.** It lives next to this 1. **`hermes-agent/` is a read-only research reference.** It lives next to this
folder for study only. It is **git-ignored** and must **never** be committed, folder for study only. It is **git-ignored** and must **never** be committed,
pushed, or included in any artifact. Our plugin is *installed* into a live pushed, or included in any artifact. Our plugin is *installed* into a live
hermes home (`~/.hermes/plugins/android`); we never edit hermes core files. hermes home (`~/.hermes/plugins/iris`); we never edit hermes core files.
2. **ADB is available and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S, 2. **ADB is available and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S,
Android 10 / API 29). Use `adb install` / `adb logcat` / `adb shell am start` Android 10 / API 29). Use `adb install` / `adb logcat` / `adb shell am start`
to install, launch, and debug the app on-device throughout the build. to install, launch, and debug the app on-device throughout the build.
@@ -88,6 +88,6 @@ Everything in the feature checklist below.
## Naming ## Naming
- Product/effort name: **Iris × Hermes** (folder `iris_x_hermes`). - Product/effort name: **Iris × Hermes** (folder `iris_x_hermes`).
- hermes platform name: **`android`** (the plugin registers `Platform("android")`). - hermes platform name: **`iris`** (the plugin registers `Platform("iris")`).
- WS default port: **8790** (configurable). - WS default port: **8790** (configurable).
- Default chat id: **`android:default`** (the home channel). - Default chat id: **`default`** (the home channel).
+5 -5
View File
@@ -12,7 +12,7 @@
│ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │ │ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │
│ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │ │ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │
│ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │ │ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
│ │ │ AndroidAdapter │ JSON │ ws://host:8790/ws │ │ │ │ │ │ IrisAdapter │ JSON │ ws://host:8790/ws │ │ │
│ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │ │ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │
│ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │ │ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │
│ │ │ • media cache │ │ WSS │ │ │ │ │ • media cache │ │ WSS │ │
@@ -40,8 +40,8 @@
## Process model ## Process model
- **One `hermes gateway` process** hosts the agent core, the session store, the - **One `hermes gateway` process** hosts the agent core, the session store, the
cron scheduler, *and* our `android` platform plugin. The plugin's WebSocket cron scheduler, *and* our `iris` platform plugin. The plugin's WebSocket
server runs on the gateway's asyncio loop (started in `AndroidAdapter.connect()`). server runs on the gateway's asyncio loop (started in `IrisAdapter.connect()`).
- **The app is a client.** It *initiates* the WS connection to the gateway - **The app is a client.** It *initiates* the WS connection to the gateway
(outbound), so no inbound port is needed on the phone. For LAN/remote access (outbound), so no inbound port is needed on the phone. For LAN/remote access
the user points the app at the gateway's LAN IP / Tailscale name / a WSS the user points the app at the gateway's LAN IP / Tailscale name / a WSS
@@ -56,7 +56,7 @@ serves Telegram/Discord/… and cron) and the **`tui_gateway`** JSON-RPC backend
(used by the TUI and the existing Electron desktop app). We deliberately use the (used by the TUI and the existing Electron desktop app). We deliberately use the
**messaging gateway** because: **messaging gateway** because:
1. **Cron delivery is native.** Cron jobs resolve `deliver=android:<chat>[:<thread>]` 1. **Cron delivery is native.** Cron jobs resolve `deliver=iris:<chat>[:<thread>]`
through the platform registry and call our adapter's `send()`. No bridging. through the platform registry and call our adapter's `send()`. No bridging.
2. **`send_message` tool routing** works out of the box (plugin 2. **`send_message` tool routing** works out of the box (plugin
`parse_target_ref_fn`). `parse_target_ref_fn`).
@@ -86,7 +86,7 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
1. App sends `message.send {text}` (or `/cmd`). 1. App sends `message.send {text}` (or `/cmd`).
2. Plugin builds a `MessageEvent` (+ `media_urls` if attachments) → 2. Plugin builds a `MessageEvent` (+ `media_urls` if attachments) →
`AndroidAdapter.handle_message(event)`. `IrisAdapter.handle_message(event)`.
3. Gateway resolves the session (`chat_id`/`thread_id`), runs the agent. 3. Gateway resolves the session (`chat_id`/`thread_id`), runs the agent.
4. Agent streams: `stream_delta_callback` → `GatewayStreamConsumer` → 4. Agent streams: `stream_delta_callback` → `GatewayStreamConsumer` →
`adapter.send()` (first) / `adapter.edit_message()` (updates) → `adapter.send()` (first) / `adapter.edit_message()` (updates) →
+7 -7
View File
@@ -13,10 +13,10 @@ iris_x_hermes/
│ │
├── hermes-agent/ # ⚠️ READ-ONLY REFERENCE — NEVER PUSHED (git-ignored) ├── hermes-agent/ # ⚠️ READ-ONLY REFERENCE — NEVER PUSHED (git-ignored)
│ │
├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/android ├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/iris
│ ├── plugin.yaml # manifest (kind: platform, env vars, home channel) │ ├── plugin.yaml # manifest (kind: platform, env vars, home channel)
│ ├── __init__.py │ ├── __init__.py
│ ├── adapter.py # AndroidAdapter(BasePlatformAdapter) + register(ctx) │ ├── adapter.py # IrisAdapter(BasePlatformAdapter) + register(ctx)
│ ├── ws_server.py # websockets server, connection registry, framing │ ├── ws_server.py # websockets server, connection registry, framing
│ ├── protocol.py # frame schemas (source of truth, mirrored in Kotlin) │ ├── protocol.py # frame schemas (source of truth, mirrored in Kotlin)
│ ├── media.py # inbound cache + outbound chunked streaming │ ├── media.py # inbound cache + outbound chunked streaming
@@ -50,9 +50,9 @@ iris_x_hermes/
## Module responsibilities ## Module responsibilities
### `gateway-plugin/` (Python) ### `gateway-plugin/` (Python)
- **`plugin.yaml`** — manifest: `name: android-platform`, `kind: platform`, - **`plugin.yaml`** — manifest: `name: iris-platform`, `kind: platform`,
`requires_env` / `optional_env` (surfaced in `hermes config`/setup). `requires_env` / `optional_env` (surfaced in `hermes config`/setup).
- **`adapter.py`** — `AndroidAdapter(BasePlatformAdapter)` + `register(ctx)`. - **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`.
The heart of the plugin. See `03-gateway-plugin.md`. The heart of the plugin. See `03-gateway-plugin.md`.
- **`ws_server.py`** — `websockets` server, per-device connection registry, - **`ws_server.py`** — `websockets` server, per-device connection registry,
frame encode/decode, heartbeat, broadcast routing to all connected devices. frame encode/decode, heartbeat, broadcast routing to all connected devices.
@@ -62,7 +62,7 @@ iris_x_hermes/
`media.offer`/`media.pull` chunked streaming. `media.offer`/`media.pull` chunked streaming.
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor. - **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
- **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and - **`push.py`** — `PushBackend` interface; `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 - **`pairing.py`** — token generation/verification (constant-time), device
registry (SQLite), QR payload. registry (SQLite), QR payload.
- **`search.py`** — FTS5 query bridge over the hermes session store. - **`search.py`** — FTS5 query bridge over the hermes session store.
@@ -83,7 +83,7 @@ Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
## Build systems ## Build systems
- **Python plugin:** no build step (pure Python, stdlib + hermes core deps). - **Python plugin:** no build step (pure Python, stdlib + hermes core deps).
Installed by copying/symlinking into `~/.hermes/plugins/android`. Tested with Installed by copying/symlinking into `~/.hermes/plugins/iris`. Tested with
hermes's `scripts/run_tests.sh`. hermes's `scripts/run_tests.sh`.
- **Kotlin/CMP:** Gradle (Kotlin DSL) with the Compose Multiplatform plugin. - **Kotlin/CMP:** Gradle (Kotlin DSL) with the Compose Multiplatform plugin.
`./gradlew :androidApp:installDebug`, `./gradlew :desktopApp:run`, `./gradlew :androidApp:installDebug`, `./gradlew :desktopApp:run`,
@@ -124,7 +124,7 @@ keystore.jks
## Install layout (runtime) ## Install layout (runtime)
- **Plugin:** `~/.hermes/plugins/android/` ← copy of `gateway-plugin/` - **Plugin:** `~/.hermes/plugins/iris/` ← copy of `gateway-plugin/`
(or a symlink for dev). Discovered by hermes's `PluginManager`. (or a symlink for dev). Discovered by hermes's `PluginManager`.
- **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`. - **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`.
- **App (desktop, dev):** `./gradlew :desktopApp:run`. - **App (desktop, dev):** `./gradlew :desktopApp:run`.
+41 -34
View File
@@ -1,6 +1,6 @@
# 03 — Gateway Plugin (Python) # 03 — Gateway Plugin (Python)
The plugin is a **community-style hermes platform plugin** named `android`. The plugin is a **community-style hermes platform plugin** named `iris`.
It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md` It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md`
and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`. and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
**Zero hermes-core changes. Zero new Python dependencies** (`websockets` and **Zero hermes-core changes. Zero new Python dependencies** (`websockets` and
@@ -11,7 +11,7 @@ and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
## 3.1 `plugin.yaml` (manifest) ## 3.1 `plugin.yaml` (manifest)
```yaml ```yaml
name: android-platform name: iris-platform
label: Android label: Android
kind: platform kind: platform
version: 0.1.0 version: 0.1.0
@@ -22,63 +22,63 @@ description: >
channels/threads, media, FTS5 search, and FCM/ntfy push. channels/threads, media, FTS5 search, and FCM/ntfy push.
author: <you> author: <you>
requires_env: requires_env:
- name: ANDROID_TOKEN - name: IRIS_TOKEN
description: "Shared pairing token the app presents on connect" description: "Shared pairing token the app presents on connect"
prompt: "Android pairing token" prompt: "Android pairing token"
password: true password: true
optional_env: 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)" description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host" prompt: "WS host"
password: false password: false
- name: ANDROID_WS_PORT - name: IRIS_WS_PORT
description: "WS port (default 8790)" description: "WS port (default 8790)"
prompt: "WS port" prompt: "WS port"
password: false password: false
- name: ANDROID_HOME_CHANNEL - 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" prompt: "Home channel"
password: false password: false
- name: ANDROID_ALLOWED_USERS - name: IRIS_ALLOWED_USERS
description: "Comma-separated allowed device_ids (empty = token-only auth)" description: "Comma-separated allowed device_ids (empty = token-only auth)"
prompt: "Allowed device ids" prompt: "Allowed device ids"
password: false password: false
- name: ANDROID_ALLOW_ALL_USERS - name: IRIS_ALLOW_ALL_USERS
description: "Allow any paired device (dev only)" description: "Allow any paired device (dev only)"
prompt: "Allow all devices? (true/false)" prompt: "Allow all devices? (true/false)"
password: false password: false
- name: ANDROID_PUSH_BACKEND - name: IRIS_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy" description: "Push backend: fcm (default) or ntfy"
prompt: "Push backend" prompt: "Push backend"
password: false password: false
- name: ANDROID_FCM_SERVICE_ACCOUNT - name: IRIS_FCM_SERVICE_ACCOUNT
description: "Path to Firebase service-account JSON (FCM HTTP v1)" description: "Path to Firebase service-account JSON (FCM HTTP v1)"
prompt: "FCM service account path" prompt: "FCM service account path"
password: true password: true
- name: ANDROID_FCM_SERVER_KEY - name: IRIS_FCM_SERVER_KEY
description: "Legacy FCM server key (fallback if no service account)" description: "Legacy FCM server key (fallback if no service account)"
prompt: "FCM server key" prompt: "FCM server key"
password: true password: true
- name: NTFY_TOPIC - name: NTFY_TOPIC
description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)" description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)"
prompt: "ntfy topic" prompt: "ntfy topic"
password: false password: false
- name: NTFY_SERVER_URL - name: NTFY_SERVER_URL
description: "ntfy server URL (default https://ntfy.sh)" description: "ntfy server URL (default https://ntfy.sh)"
prompt: "ntfy server URL" prompt: "ntfy server URL"
password: false password: false
- name: ANDROID_WS_CERT - name: IRIS_WS_CERT
description: "TLS cert path for WSS (optional)" description: "TLS cert path for WSS (optional)"
prompt: "WSS cert" prompt: "WSS cert"
password: false password: false
- name: ANDROID_WS_KEY - name: IRIS_WS_KEY
description: "TLS key path for WSS (optional)" description: "TLS key path for WSS (optional)"
prompt: "WSS key" prompt: "WSS key"
password: false password: false
``` ```
Behavioral (non-secret) settings live in `config.yaml` under Behavioral (non-secret) settings live in `config.yaml` under
`gateway.platforms.android.extra` (host, port, home_channel, outbox retention, `gateway.platforms.iris.extra` (host, port, home_channel, outbox retention,
max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets
only.) only.)
@@ -87,21 +87,21 @@ only.)
```python ```python
def register(ctx): def register(ctx):
ctx.register_platform( ctx.register_platform(
name="android", name="iris",
label="Android", label="Iris",
adapter_factory=lambda cfg: AndroidAdapter(cfg), adapter_factory=lambda cfg: IrisAdapter(cfg),
check_fn=check_requirements, # passive: websockets importable + token set check_fn=check_requirements, # passive: websockets importable + token set
validate_config=validate_config, # host/port/token present validate_config=validate_config, # host/port/token present
is_connected=is_connected, is_connected=is_connected,
required_env=["ANDROID_TOKEN"], required_env=["IRIS_TOKEN"],
install_hint="No extra packages needed (websockets + httpx are core deps)", install_hint="No extra packages needed (websockets + httpx are core deps)",
setup_fn=interactive_setup, # hermes gateway setup flow setup_fn=interactive_setup, # hermes gateway setup flow
env_enablement_fn=_env_enablement, # seed extra + home_channel from env env_enablement_fn=_env_enablement, # seed extra + home_channel from env
cron_deliver_env_var="ANDROID_HOME_CHANNEL", cron_deliver_env_var="ANDROID_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch) standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
parse_target_ref_fn=_parse_target_ref, # "android:<chat>[:<thread>]" parse_target_ref_fn=_parse_target_ref, # "iris:<chat>[:<thread>]"
allowed_users_env="ANDROID_ALLOWED_USERS", allowed_users_env="IRIS_ALLOWED_USERS",
allow_all_env="ANDROID_ALLOW_ALL_USERS", allow_all_env="IRIS_ALLOW_ALL_USERS",
max_message_length=0, # 0 = no limit (WS has none) max_message_length=0, # 0 = no limit (WS has none)
emoji="📱", emoji="📱",
pii_safe=False, pii_safe=False,
@@ -123,26 +123,29 @@ Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`):
`ensure_deps_fn`. `ensure_deps_fn`.
- **`check_requirements()`** — passive probe: `import websockets` succeeds and - **`check_requirements()`** — passive probe: `import websockets` succeeds and
`ANDROID_TOKEN` is set. Never installs. `IRIS_TOKEN` is set. Never installs.
- **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra` - **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra`
(host/port/home_channel/push_backend) + a `home_channel` key (host/port/home_channel/push_backend) + a `home_channel` key
`{"chat_id": "android:default", "name": "Default"}` so `hermes gateway status` `{"chat_id": "default", "name": "Default"}` so `hermes gateway status`
and cron home-channel resolution work without instantiating the adapter. and cron home-channel resolution work without instantiating the adapter.
- **`_parse_target_ref(ref)`** — if `ref` starts with `android:`, return - **`_parse_target_ref(ref)`** — the core strips the platform prefix first, so
`(chat_id, thread_id)` parsed from `android:<chat>[:<thread>]`; else `None`. `ref` is the direct chat id (e.g. `chan_7`, `default`) with an optional
`:t_<n>` thread suffix; friendly names resolve via the channel directory.
Returns `(chat_id, thread_id)` or `None`.
- **`interactive_setup()`** — prompts for token (or generates one), host/port, - **`interactive_setup()`** — prompts for token (or generates one), host/port,
push backend + credentials, prints a QR code (pairing) and the app URL. push backend + credentials, prints a QR code (pairing) and the app URL.
## 3.3 `AndroidAdapter(BasePlatformAdapter)` ## 3.3 `IrisAdapter(BasePlatformAdapter)`
Constructor: `super().__init__(config=config, platform=Platform("android"))`. Constructor: `super().__init__(config=config, platform=Platform("iris"))`.
Reads `config.extra` (env overrides win). Initializes: WS server (not started Reads `config.extra` (env overrides win). Initializes: WS server (not started
until `connect()`), connection registry, outbox (SQLite under until `connect()`), connection registry, outbox (SQLite under
`get_hermes_home()/"android"`), push backend, pairing store, channel directory. `get_hermes_home()/"iris"`), push backend, pairing store, channel directory.
### Lifecycle ### Lifecycle
- **`connect(*, is_reconnect=False) -> bool`** - **`connect(*, is_reconnect=False) -> bool`**
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("android", key)`) - Acquire scoped lock (`gateway.status.acquire_scoped_lock("iris", key)`)
so two profiles can't bind the same port/identity. so two profiles can't bind the same port/identity.
- Start the `websockets` server on `host:port` (TLS if cert/key set). - Start the `websockets` server on `host:port` (TLS if cert/key set).
- `_mark_connected()`; return True. - `_mark_connected()`; return True.
@@ -150,6 +153,7 @@ until `connect()`), connection registry, outbox (SQLite under
- Stop server, close all device sockets, release lock, `_mark_disconnected()`. - Stop server, close all device sockets, release lock, `_mark_disconnected()`.
### Inbound (app → agent) ### Inbound (app → agent)
- WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via - WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via
`self.build_source(chat_id, chat_name, chat_type, user_id, user_name, `self.build_source(chat_id, chat_name, chat_type, user_id, user_name,
thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…, thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…,
@@ -171,6 +175,7 @@ until `connect()`), connection registry, outbox (SQLite under
- `sync {cursor}` → `outbox.py` → replay frames since cursor. - `sync {cursor}` → `outbox.py` → replay frames since cursor.
### Outbound (agent → app) ### Outbound (agent → app)
- **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`** - **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`**
- Split reasoning prefix (see `05-streaming.md`) → `reasoning` field. - Split reasoning prefix (see `05-streaming.md`) → `reasoning` field.
- If **any** device is connected: broadcast `message` frame to all. - If **any** device is connected: broadcast `message` frame to all.
@@ -195,7 +200,9 @@ until `connect()`), connection registry, outbox (SQLite under
channel directory, return it (used by cron "continuable" threads). channel directory, return it (used by cron "continuable" threads).
### Streaming hooks ### Streaming hooks
The main gateway drives delivery through the **legacy callback path**: The main gateway drives delivery through the **legacy callback path**:
- `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) + - `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) +
`edit_message()` (updates) → `message.start` / `message.update`. `edit_message()` (updates) → `message.start` / `message.update`.
- `tool_progress_callback` → progress queue → `send_progress_messages` → - `tool_progress_callback` → progress queue → `send_progress_messages` →
@@ -230,7 +237,7 @@ verified empirically in M2 (see `13-testing.md`).
`message.update` (coalesce to latest) under pressure, never drop `message.update` (coalesce to latest) under pressure, never drop
`message`/`tool.end`/`notification`. `message`/`tool.end`/`notification`.
## 3.5 State & storage (all under `get_hermes_home()/"android"`) ## 3.5 State & storage (all under `get_hermes_home()/"iris"`)
> Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe). > Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe).
> Never hardcode `~/.hermes`. > Never hardcode `~/.hermes`.
@@ -245,9 +252,9 @@ verified empirically in M2 (see `13-testing.md`).
## 3.6 Config resolution ## 3.6 Config resolution
- **Secrets (`.env`):** `ANDROID_TOKEN`, `ANDROID_FCM_SERVICE_ACCOUNT`, - **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
`ANDROID_FCM_SERVER_KEY`, `ANDROID_WS_CERT/KEY`, `NTFY_TOPIC` (if secret). `IRIS_FCM_SERVER_KEY`, `IRIS_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.android.extra`):** `host`, - **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`, `port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
`max_upload_bytes`, `tls`. `max_upload_bytes`, `tls`.
- Env vars override `config.yaml` (hermes convention). Read secrets with the - Env vars override `config.yaml` (hermes convention). Read secrets with the
+20 -20
View File
@@ -12,7 +12,7 @@ Every frame:
"v": 1, "v": 1,
"id": 42, // optional; present on requests + their responses "id": 42, // optional; present on requests + their responses
"type": "message", // frame type (below) "type": "message", // frame type (below)
"chat_id": "android:default", // optional; scope for chat-scoped frames "chat_id": "default", // optional; scope for chat-scoped frames
"thread_id": "t_123", // optional "thread_id": "t_123", // optional
"payload": { } // type-specific object "payload": { } // type-specific object
} }
@@ -43,7 +43,7 @@ Pairing succeeded.
"search":true,"push":"fcm","pickers":true}, "search":true,"push":"fcm","pickers":true},
"sync_cursor":1042, "sync_cursor":1042,
"last_pushed_cursor":1040, "last_pushed_cursor":1040,
"channels":[{"chat_id":"android:default","name":"Default","kind":"default","is_default":true}] "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. A final / standalone message.
```json ```json
{"type":"message","chat_id":"android:default","thread_id":null, {"type":"message","chat_id":"default","thread_id":null,
"payload":{ "payload":{
"message_id":"m_9001","role":"assistant", "message_id":"m_9001","role":"assistant",
"text":"Here is the answer…", "text":"Here is the answer…",
@@ -107,7 +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`. offline learns of the deletion on its next `sync`.
```json ```json
{"type":"message.deleted","id":30,"chat_id":"android:default","thread_id":null, {"type":"message.deleted","id":30,"chat_id":"default","thread_id":null,
"payload":{"message_ids":["m_9001","m_9002"]}} "payload":{"message_ids":["m_9001","m_9002"]}}
``` ```
@@ -176,7 +176,7 @@ Channel directory updates. **Broadcast to all connected devices** (no explicit
subscribe; the server pushes to every open WS). subscribe; the server pushes to every open WS).
```json ```json
{"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports", {"type":"channel.created","payload":{"chat_id":"chan_7","name":"Cron Reports",
"kind":"channel","parent_chat_id":null}} "kind":"channel","parent_chat_id":null}}
``` ```
@@ -190,7 +190,7 @@ title.
Response to a `history` request. Returns a page of messages for a chat/thread. Response to a `history` request. Returns a page of messages for a chat/thread.
```json ```json
{"type":"history","id":20,"chat_id":"android:default","thread_id":null, {"type":"history","id":20,"chat_id":"default","thread_id":null,
"payload":{ "payload":{
"messages":[ "messages":[
{"message_id":"m_8990","role":"user","text":"Hi","ts":1723990000000}, {"message_id":"m_8990","role":"user","text":"Hi","ts":1723990000000},
@@ -244,9 +244,9 @@ Response to a `commands.complete` request. Autocomplete matches for a typed pref
Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`. Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`.
```json ```json
{"type":"agent.busy","chat_id":"android:default","thread_id":null, {"type":"agent.busy","chat_id":"default","thread_id":null,
"payload":{"reason":"processing"}} "payload":{"reason":"processing"}}
{"type":"agent.idle","chat_id":"android:default","thread_id":null,"payload":{}} {"type":"agent.idle","chat_id":"default","thread_id":null,"payload":{}}
``` ```
`reason` ∈ `processing | tool | waiting_input | cron`. `reason` ∈ `processing | tool | waiting_input | cron`.
@@ -256,7 +256,7 @@ Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`
```json ```json
{"type":"search.results","id":7,"payload":{ {"type":"search.results","id":7,"payload":{
"query":"deploy","scope":"all","hits":[ "query":"deploy","scope":"all","hits":[
{"message_id":"m_123","chat_id":"android:chan_7","thread_id":null, {"message_id":"m_123","chat_id":"chan_7","thread_id":null,
"role":"assistant","snippet":"…deploy the service…","ts":1723900000000}]}} "role":"assistant","snippet":"…deploy the service…","ts":1723900000000}]}}
``` ```
@@ -275,7 +275,7 @@ The gateway acknowledges that the agent has received and started processing
the user's message. The app uses it to show ✓✓ on user bubbles. the user's message. The app uses it to show ✓✓ on user bubbles.
```json ```json
{"type":"read.receipt","chat_id":"android:default","payload":{"message_id":"m_9001"}} {"type":"read.receipt","chat_id":"default","payload":{"message_id":"m_9001"}}
``` ```
Emitted to the originating connection when a `message.send` is accepted for Emitted to the originating connection when a `message.send` is accepted for
@@ -319,7 +319,7 @@ First frame; auth + caps.
```json ```json
{"type":"hello","payload":{ {"type":"hello","payload":{
"token":"<ANDROID_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S", "token":"<IRIS_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S",
"caps":{"min_protocol":1,"media":true,"push":"fcm"}, "caps":{"min_protocol":1,"media":true,"push":"fcm"},
"fcm_token":"<FCM token>","ntfy_topic":"<topic, if ntfy>"}} "fcm_token":"<FCM token>","ntfy_topic":"<topic, if ntfy>"}}
``` ```
@@ -329,7 +329,7 @@ First frame; auth + caps.
Send text (or a `/slash-command`). Send text (or a `/slash-command`).
```json ```json
{"type":"message.send","id":10,"chat_id":"android:default","thread_id":null, {"type":"message.send","id":10,"chat_id":"default","thread_id":null,
"payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"], "payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"],
"auto_thread":false}} "auto_thread":false}}
``` ```
@@ -383,8 +383,8 @@ Answer an interactive picker.
```json ```json
{"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}} {"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}}
{"type":"channel.rename","id":15,"chat_id":"android:chan_7","payload":{"name":"Reports"}} {"type":"channel.rename","id":15,"chat_id":"chan_7","payload":{"name":"Reports"}}
{"type":"channel.set_default","id":16,"chat_id":"android:chan_7","payload":{}} {"type":"channel.set_default","id":16,"chat_id":"chan_7","payload":{}}
``` ```
`channel.delete` is a **hard delete**: the channel/thread row is removed from `channel.delete` is a **hard delete**: the channel/thread row is removed from
@@ -396,7 +396,7 @@ removes its threads. The default channel cannot be deleted.
```json ```json
{"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}} {"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}}
{"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"android:chan_7","thread_id":null}} {"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"chan_7","thread_id":null}}
``` ```
`scope` ∈ `all | chat`. `scope` ∈ `all | chat`.
@@ -408,7 +408,7 @@ broadcasts to other devices (for multi-device ✓✓ sync). The app uses it to
mark messages as read locally (✓✓ on user bubbles). mark messages as read locally (✓✓ on user bubbles).
```json ```json
{"type":"read.receipt","payload":{"chat_id":"android:default","message_id":"m_9001"}} {"type":"read.receipt","payload":{"chat_id":"default","message_id":"m_9001"}}
``` ```
### `history` ### `history`
@@ -416,7 +416,7 @@ mark messages as read locally (✓✓ on user bubbles).
Load a page of messages for a chat/thread (initial open, scroll-up pagination). Load a page of messages for a chat/thread (initial open, scroll-up pagination).
```json ```json
{"type":"history","id":20,"chat_id":"android:default","thread_id":null, {"type":"history","id":20,"chat_id":"default","thread_id":null,
"payload":{"before_message_id":"m_8990","limit":50}} "payload":{"before_message_id":"m_8990","limit":50}}
``` ```
@@ -433,7 +433,7 @@ message already gone (pruned by retention) still yields a `message.deleted`
broadcast so live caches drop it. broadcast so live caches drop it.
```json ```json
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null, {"type":"message.delete","id":30,"chat_id":"default","thread_id":null,
"payload":{"message_ids":["m_9001","m_9002"]}} "payload":{"message_ids":["m_9001","m_9002"]}}
``` ```
@@ -458,7 +458,7 @@ Autocomplete for a typed `/prefix`.
Stop the current agent turn (abort generation / tool execution). Stop the current agent turn (abort generation / tool execution).
```json ```json
{"type":"agent.stop","id":23,"chat_id":"android:default","thread_id":null,"payload":{}} {"type":"agent.stop","id":23,"chat_id":"default","thread_id":null,"payload":{}}
``` ```
### `agent.steer` ### `agent.steer`
@@ -466,7 +466,7 @@ Stop the current agent turn (abort generation / tool execution).
Inject a steering message mid-turn (redirects the agent without a new turn). Inject a steering message mid-turn (redirects the agent without a new turn).
```json ```json
{"type":"agent.steer","id":24,"chat_id":"android:default","thread_id":null, {"type":"agent.steer","id":24,"chat_id":"default","thread_id":null,
"payload":{"text":"Actually, focus on the error case."}} "payload":{"text":"Actually, focus on the error case."}}
``` ```
+3 -3
View File
@@ -28,7 +28,7 @@ finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
while the user is at the bottom. while the user is at the bottom.
**Streaming on/off.** Two levels: **Streaming on/off.** Two levels:
- **Gateway side:** hermes `display.platforms.android.streaming` (default - **Gateway side:** hermes `display.platforms.iris.streaming` (default
follows global). When off, the app just gets one final `message` frame. follows global). When off, the app just gets one final `message` frame.
- **App side (per device):** Settings → "Streaming" toggle (default on). When - **App side (per device):** Settings → "Streaming" toggle (default on). When
off, the app ignores `message.start`/`message.update` frames and off, the app ignores `message.start`/`message.update` frames and
@@ -49,11 +49,11 @@ chosen by `reasoning_style` (`gateway/display_config.py:37`):
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>` - `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style) - `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
**Plugin config.** Set for the `android` platform: **Plugin config.** Set for the `iris` platform:
```yaml ```yaml
display: display:
platforms: platforms:
android: iris:
show_reasoning: true show_reasoning: true
reasoning_style: code # we split on the code-fence form reasoning_style: code # we split on the code-fence form
``` ```
+11 -11
View File
@@ -9,10 +9,10 @@ gateway identity concepts**.
| App concept | hermes primitive | Example | | App concept | hermes primitive | Example |
| --- | --- | --- | | --- | --- | --- |
| Default chat | home channel `chat_id` | `android:default` | | Default chat | home channel `chat_id` | `default` |
| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=android:default, thread_id=t_12` | | A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=default, thread_id=t_12` |
| A user-created channel | a new `chat_id` | `android:chan_7` | | A user-created channel | a new `chat_id` | `chan_7` |
| A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=android:chan_7, thread_id=t_31` | | A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=chan_7, thread_id=t_31` |
- **`chat_id`** = the conversation lane (a channel or the default chat). - **`chat_id`** = the conversation lane (a channel or the default chat).
- **`thread_id`** = an optional sub-lane within a `chat_id` (topic-like). - **`thread_id`** = an optional sub-lane within a `chat_id` (topic-like).
@@ -23,7 +23,7 @@ gateway identity concepts**.
## 6.2 Default chat ## 6.2 Default chat
- On first connect, the plugin ensures a **default channel** exists: - On first connect, the plugin ensures a **default channel** exists:
`chat_id = ANDROID_HOME_CHANNEL` (default `android:default`), `kind=default`, `chat_id = ANDROID_HOME_CHANNEL` (default `default`), `kind=default`,
`is_default=true`, name "Default". `is_default=true`, name "Default".
- It is also the **cron home channel** (`cron_deliver_env_var= - It is also the **cron home channel** (`cron_deliver_env_var=
ANDROID_HOME_CHANNEL`), so `deliver=android` (bare) routes here. 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 OFF** — flat conversation; all messages use `thread_id=null`.
- **Threads ON** — the app groups the conversation into topic-like lanes. - **Threads ON** — the app groups the conversation into topic-like lanes.
Each new "topic" mints a `thread_id` (via `channel.create {kind:thread, Each new "topic" mints a `thread_id` (via `channel.create {kind:thread,
parent_chat_id:android:default}` or an implicit thread). The UI shows a parent_chat_id:default}` or an implicit thread). The UI shows a
topic switcher (like Telegram topics) above the message list. topic switcher (like Telegram topics) above the message list.
- Threads are **app-organized** but **gateway-real**: each `thread_id` is a - Threads are **app-organized** but **gateway-real**: each `thread_id` is a
distinct hermes session lane, so context is isolated per thread and cron can distinct hermes session lane, so context is isolated per thread and cron can
@@ -82,7 +82,7 @@ lane (nothing to title / session-scoped, not conversation starters).
- **Requirement:** the user creates new channels so **cron job outputs can be - **Requirement:** the user creates new channels so **cron job outputs can be
delegated to them** instead of the default chat. delegated to them** instead of the default chat.
- **`channel.create {name, kind:"channel"}`** → plugin mints - **`channel.create {name, kind:"channel"}`** → plugin mints
`chat_id = android:chan_<n>`, stores in directory, broadcasts `chat_id = chan_<n>`, stores in directory, broadcasts
`channel.created` to all devices. The new channel appears in the channel list. `channel.created` to all devices. The new channel appears in the channel list.
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the - **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
directory (rename broadcasts `channel.renamed`; delete is a **hard delete** directory (rename broadcasts `channel.renamed`; delete is a **hard delete**
@@ -104,9 +104,9 @@ lane (nothing to title / session-scoped, not conversation starters).
- **Cron targeting** (the key payoff): because the plugin registers - **Cron targeting** (the key payoff): because the plugin registers
`parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the `parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the
`send_message` tool can target any channel/thread: `send_message` tool can target any channel/thread:
- `deliver="android"` → home (default) channel. - `deliver="iris"` → home (default) channel.
- `deliver="android:android:chan_7"` → that channel. - `deliver="iris:chan_7"` → that channel.
- `deliver="android:android:chan_7:t_31"` → that channel's thread. - `deliver="iris:chan_7:t_31"` → that channel's thread.
- In-chat: the agent's `cronjob` tool can be told "deliver to the *Cron - In-chat: the agent's `cronjob` tool can be told "deliver to the *Cron
Reports* channel"; the gateway resolves the name via the channel directory. Reports* channel"; the gateway resolves the name via the channel directory.
- **In-app affordance:** each channel's menu has "Set as cron target" / shows a - **In-app affordance:** each channel's menu has "Set as cron target" / shows a
@@ -118,7 +118,7 @@ lane (nothing to title / session-scoped, not conversation starters).
- Cron resolves delivery targets in `cron/scheduler.py:2148` - Cron resolves delivery targets in `cron/scheduler.py:2148`
(`_resolve_single_delivery_target`). For `platform:chat_id[:thread_id]` it (`_resolve_single_delivery_target`). For `platform:chat_id[:thread_id]` it
calls `tools.send_message_tool.resolve_send_target`, which uses our calls `tools.send_message_tool.resolve_send_target`, which uses our
`parse_target_ref_fn` to parse `android:<chat>[:<thread>]`. `parse_target_ref_fn` to parse `iris:<chat>[:<thread>]`.
- Delivery then calls the **live adapter's `send(chat_id, text, …)`** (gateway - Delivery then calls the **live adapter's `send(chat_id, text, …)`** (gateway
running) → our WS `message` frame (or outbox+push if the app is offline). running) → our WS `message` frame (or outbox+push if the app is offline).
- Cron deliveries are framed with a `[Cron delivery: <name>]` header by hermes; - Cron deliveries are framed with a `[Cron delivery: <name>]` header by hermes;
+4 -4
View File
@@ -1,7 +1,7 @@
# 08 — Push Notifications, Outbox & Sync # 08 — Push Notifications, Outbox & Sync
The gateway can't reach a sleeping phone directly. Push goes through a cloud The gateway can't reach a sleeping phone directly. Push goes through a cloud
relay. **Decision: FCM primary, ntfy fallback** (`ANDROID_PUSH_BACKEND`). relay. **Decision: FCM primary, ntfy fallback** (`IRIS_PUSH_BACKEND`).
## 8.1 When push fires ## 8.1 When push fires
@@ -23,13 +23,13 @@ class PushBackend(Protocol):
def configured(self) -> bool: ... def configured(self) -> bool: ...
``` ```
Selected at adapter init by `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`). Selected at adapter init by `IRIS_PUSH_BACKEND` (`fcm` default, `ntfy`).
### 8.2.1 `FcmBackend` (primary) ### 8.2.1 `FcmBackend` (primary)
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service - **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
account** (`ANDROID_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
OAuth2 access token (cached, refreshed before expiry). OAuth2 access token (cached, refreshed before expiry).
- Fallback: legacy **server key** (`ANDROID_FCM_SERVER_KEY`) if no service - Fallback: legacy **server key** (`IRIS_FCM_SERVER_KEY`) if no service
account (simpler, but legacy). account (simpler, but legacy).
- Target = the device's **FCM token** (registered via `hello` / - Target = the device's **FCM token** (registered via `hello` /
`fcm.register`, stored in `devices.db`). `fcm.register`, stored in `devices.db`).
+19 -18
View File
@@ -13,19 +13,20 @@
## 9.2 Pairing flow ## 9.2 Pairing flow
1. **Generate a token.** `hermes gateway setup` (our `interactive_setup`) either 1. **Generate a token.** `hermes gateway setup` (our `interactive_setup`) either
uses an existing `ANDROID_TOKEN` or generates a fresh high-entropy token uses an existing `IRIS_TOKEN` or generates a fresh high-entropy token
(e.g. 32 bytes → 64 hex chars) and stores it in `.env`. (e.g. 32 bytes → 64 hex chars) and stores it in `.env`.
2. **Present to the app.** Two options: 2. **Present to the app.** Two options:
- **QR code:** the setup prints a QR encoding - **QR code:** the setup prints a QR encoding
`iris://pair?host=<lan-ip>&port=8790&token=<token>` (or a WSS URL). The `iris://pair?host=<lan-ip>&port=8791&secure=0&token=<token>` (or a WSS
phone scans it with the app's camera (or a system scanner) → pre-fills URL when `secure=1`). The phone scans it with the app's **Scan QR**
button (or a system scanner → `iris://pair` deep link) → pre-fills
settings. settings.
- **Manual:** user types the server URL + token in the app's Connect screen. - **Manual:** user types the server URL + token in the app's Connect screen.
3. **App connects.** First WS frame is `hello {token, device_id, device_name, 3. **App connects.** First WS frame is `hello {token, device_id, device_name,
caps, fcm_token?}`. caps, fcm_token?}`.
4. **Server verifies.** Constant-time compare of `token` vs `ANDROID_TOKEN` 4. **Server verifies.** Constant-time compare of `token` vs `IRIS_TOKEN`
(`hmac.compare_digest`). Optionally check `device_id` against (`hmac.compare_digest`). Optionally check `device_id` against
`ANDROID_ALLOWED_USERS` (if set) or `ANDROID_ALLOW_ALL_USERS`. `IRIS_ALLOWED_USERS` (if set) or `IRIS_ALLOW_ALL_USERS`.
5. **On success:** register the device in `devices.db`, send `hello.ack`. 5. **On success:** register the device in `devices.db`, send `hello.ack`.
**On failure:** send `error {code:"auth"}` and close. **On failure:** send `error {code:"auth"}` and close.
@@ -36,22 +37,22 @@ security principal (the token is).
## 9.3 Auth model ## 9.3 Auth model
- **Token = the security principal.** Any connection presenting the valid - **Token = the security principal.** Any connection presenting the valid
`ANDROID_TOKEN` is authorized (it's the user's own token). `IRIS_TOKEN` is authorized (it's the user's own token).
- **Allowlist (optional):** `ANDROID_ALLOWED_USERS` (comma-separated - **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated
`device_id`s) restricts which *devices* may connect even with the token — `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). allowlist (dev only).
- **Per-device tokens (stretch):** mint a unique token per device at pairing - **Per-device tokens (stretch):** mint a unique token per device at pairing
(revocable) instead of one shared token. v1 uses the shared token + optional (revocable) instead of one shared token. v1 uses the shared token + optional
device allowlist. 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. re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR.
## 9.4 Transport security ## 9.4 Transport security
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home - **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
network. 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 (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): - **Remote reachability options** (documented, user's choice):
@@ -61,11 +62,11 @@ security principal (the token is).
at the edge, forward WS to `127.0.0.1:8790`. at the edge, forward WS to `127.0.0.1:8790`.
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort. - **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames - **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 fallback transport. It is a *second door with the same lock*: the same
Bearer token (constant-time `verify_token`) + the same device allowlist 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 (`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 `GET /v1/health` is unauthenticated by design (liveness only — it must
not reflect tokens, device ids, or versions). not reflect tokens, device ids, or versions).
- The app stores the server URL + (for self-signed) the pinned cert fingerprint - The app stores the server URL + (for self-signed) the pinned cert fingerprint
@@ -73,7 +74,7 @@ security principal (the token is).
## 9.5 Secret & PII handling ## 9.5 Secret & PII handling
- **Tokens/keys never logged.** Redact `ANDROID_TOKEN`, FCM tokens/keys, ntfy - **Tokens/keys never logged.** Redact `IRIS_TOKEN`, FCM tokens/keys, ntfy
tokens in all log output (hermes PII policy; `agent/redact.py` patterns). tokens in all log output (hermes PII policy; `agent/redact.py` patterns).
- **`device_id`** is a random UUID (not PII). `device_name` is user-chosen. - **`device_id`** is a random UUID (not PII). `device_name` is user-chosen.
- **Media pull** is gated by hermes `validate_media_delivery_path` + delivery - **Media pull** is gated by hermes `validate_media_delivery_path` + delivery
@@ -84,7 +85,7 @@ security principal (the token is).
## 9.6 Profile safety ## 9.6 Profile safety
- All plugin state lives under `get_hermes_home()/"android"` (profile-aware). - All plugin state lives under `get_hermes_home()/"iris"` (profile-aware).
- Secrets are read with the scope-aware `_get_scoped_secret` pattern (see - Secrets are read with the scope-aware `_get_scoped_secret` pattern (see
`plugins/platforms/irc/adapter.py:42`) so multiplexed profiles don't leak `plugins/platforms/irc/adapter.py:42`) so multiplexed profiles don't leak
each other's tokens (fail-closed under `gateway.multiplex_profiles`). each other's tokens (fail-closed under `gateway.multiplex_profiles`).
@@ -110,16 +111,16 @@ M7 research pass. "verified" = implemented and covered by
"gap" = known limitation with the planned mitigation. "gap" = known limitation with the planned mitigation.
| # | Item | Status | Evidence / mitigation | | # | Item | Status | Evidence / mitigation |
|---|------|--------|-----------------------| | --- | ------ | -------- | ----------------------- |
| 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` | | 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` |
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`ws_server.py:47`, `broadcast`/`send_to`). Inbound: per-connection token bucket on JSON frames (20/s, burst 40) → `error {code:"rate_limited"}` + close on exceed (`ws_server.py:55`, `_TokenBucket`, `_on_frame`); binary upload chunks exempt (see gap 1) | | 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`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` | | 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` | | 4 | Verify media sha256 + re-sniff MIME (don't trust client) | verified | `media.py:317` (`complete_upload` digest check), `media.py:147` (`reclassify_kind`); `test_upload_sha256_mismatch_rejected`, `test_reclassify_kind_does_not_trust_client` |
| 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned | | 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned |
| 6 | WSS + cert pinning for remote | gap (partial) | WSS supported server-side (`ANDROID_WS_CERT`/`ANDROID_WS_KEY`, `ws_server.py:122`); the app builds a default `OkHttpClient` with no `CertificatePinner` (`app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt:87`). Mitigation: remote access requires CA-signed WSS until pinning lands; LAN `ws://` stays the default | | 6 | 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` | | 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 | | 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`) | | 10 | Gap: Android token storage | implemented | `AndroidSecureStore` → `EncryptedSharedPreferences` (MasterKey AES256_GCM) with one-time migration of the plain `iris` prefs (read old key → write encrypted → delete old key); dep in `app/shared/build.gradle.kts` (`app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt`) |
| 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` | | 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` |
| 12 | Gap: in-app QR scanner | gap | Pairing is manual URL+token only; the server prints a QR (`gateway-plugin/adapter.py:648-654`) that any system scanner can read. Plan: in-app camera scan later | | 12 | Gap: in-app QR scanner | implemented | `hermes gateway setup` renders a terminal QR (`gateway-plugin/qr.py`, pure-stdlib encoder) and the app's Connect screen has a **Scan QR** button (CameraX + ML Kit, `QrScanActivity`) plus an `iris://pair` deep link (`PairLink.parse`); `docs/20` |
+9 -1
View File
@@ -115,7 +115,7 @@ app/shared/src/
- `ToolCard` renders `tool.start/progress/end` frames. - `ToolCard` renders `tool.start/progress/end` frames.
- The gateway always supplies the **full** tool data: it forces - The gateway always supplies the **full** tool data: it forces
`display.platforms.android.tool_progress: verbose` (so the progress line `display.platforms.iris.tool_progress: verbose` (so the progress line
carries the full args JSON → `tool.start.args`) and captures each completed carries the full args JSON → `tool.start.args`) and captures each completed
call via the `post_tool_call` hook (→ `tool.end` `output_preview` / call via the `post_tool_call` hook (→ `tool.end` `output_preview` /
`duration` / `ok`). The app decides how much to show. `duration` / `ok`). The app decides how much to show.
@@ -317,5 +317,13 @@ Storage: `AndroidSqliteDriver` (app database dir) on Android,
- First launch → **Connect**: server URL + token (or scan QR). "Test connection" - First launch → **Connect**: server URL + token (or scan QR). "Test connection"
does a real `hello` (not just a TCP probe — per hermes desktop guidance, the does a real `hello` (not just a TCP probe — per hermes desktop guidance, the
auth leg must be exercised). On success → save (secure storage) → main. auth leg must be exercised). On success → save (secure storage) → main.
- **Scan QR** (Android only, `docs/20`): a button below the token field opens
`QrScanActivity` (CameraX + ML Kit, on-device, no Play services), requests the
`CAMERA` permission, and pre-fills URL + token from the decoded
`iris://pair…` payload via `PairLink.parse`. It never auto-connects — the
user still taps "Test & Connect". A non-pairing QR sets an error and leaves
the fields untouched. The same payload also arrives as an `iris://pair` deep
link (system scanner / other phones) and pre-fills the screen the same way.
Hidden on desktop (no camera).
- States: connecting / connected / reconnecting / degraded / auth-failed — each - States: connecting / connected / reconnecting / degraded / auth-failed — each
with honest copy and a way out. with honest copy and a way out.
+14 -14
View File
@@ -57,8 +57,8 @@ hermes --version # sanity
```bash ```bash
# install the plugin (dev: symlink) # install the plugin (dev: symlink)
mkdir -p ~/.hermes/plugins mkdir -p ~/.hermes/plugins
ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/android ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/iris
hermes gateway status # should list "android" hermes gateway status # should list "iris"
hermes gateway # run hermes gateway # run
``` ```
- Tests use hermes's hermetic runner (never bare `pytest`): - Tests use hermes's hermetic runner (never bare `pytest`):
@@ -73,43 +73,43 @@ hermes --version # sanity
`dev.iris.app`). Download `google-services.json` → `app/androidApp/`. `dev.iris.app`). Download `google-services.json` → `app/androidApp/`.
3. Create a **service account** (Project settings → Service accounts → Generate 3. Create a **service account** (Project settings → Service accounts → Generate
new private key) → download the JSON. Store its path in new private key) → download the JSON. Store its path in
`ANDROID_FCM_SERVICE_ACCOUNT` (in `~/.hermes/.env`). `IRIS_FCM_SERVICE_ACCOUNT` (in `~/.hermes/.env`).
4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and 4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and
registers it via `hello` / `fcm.register`. registers it via `hello` / `fcm.register`.
> Skip Firebase → set `ANDROID_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` / > Skip Firebase → set `IRIS_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` /
> `NTFY_SERVER_URL` (self-host ntfy or use ntfy.sh). See `08-push.md`. > `NTFY_SERVER_URL` (self-host ntfy or use ntfy.sh). See `08-push.md`.
## 12.6 Environment variables (summary) ## 12.6 Environment variables (summary)
**Secrets (`~/.hermes/.env`):** **Secrets (`~/.hermes/.env`):**
``` ```
ANDROID_TOKEN=<64-hex> IRIS_TOKEN=<64-hex>
ANDROID_PUSH_BACKEND=fcm # or ntfy IRIS_PUSH_BACKEND=fcm # or ntfy
ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
# ANDROID_FCM_SERVER_KEY=<legacy key> # fallback if no service account # IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
# NTFY_TOPIC=iris-push # when ntfy # NTFY_TOPIC=iris-push # when ntfy
# NTFY_SERVER_URL=https://ntfy.sh # NTFY_SERVER_URL=https://ntfy.sh
# ANDROID_WS_CERT=/path/cert.pem # WSS # IRIS_WS_CERT=/path/cert.pem # WSS
# ANDROID_WS_KEY=/path/key.pem # IRIS_WS_KEY=/path/key.pem
``` ```
**Behavioral (`~/.hermes/config.yaml`):** **Behavioral (`~/.hermes/config.yaml`):**
```yaml ```yaml
gateway: gateway:
platforms: platforms:
android: iris:
enabled: true enabled: true
extra: extra:
host: 127.0.0.1 # 0.0.0.0 for LAN host: 127.0.0.1 # 0.0.0.0 for LAN
port: 8790 port: 8790
home_channel: android:default home_channel: default
push_backend: fcm push_backend: fcm
outbox_retention_hours: 72 outbox_retention_hours: 72
max_upload_bytes: 104857600 # 100 MB max_upload_bytes: 104857600 # 100 MB
display: display:
platforms: platforms:
android: iris:
show_reasoning: true show_reasoning: true
reasoning_style: code reasoning_style: code
streaming: true streaming: true
@@ -128,7 +128,7 @@ import asyncio, json, websockets
async def main(): async def main():
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws: async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
await ws.send(json.dumps({"v":1,"type":"hello","payload":{ 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}}})) "caps":{"min_protocol":1}}}))
print("recv:", await ws.recv()) print("recv:", await ws.recv())
asyncio.run(main()) 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, - `register(ctx)` produces a valid `PlatformEntry` (name, cron env var,
parse_target_ref). parse_target_ref).
- `check_requirements` / `validate_config` / `is_connected` truth table. - `check_requirements` / `validate_config` / `is_connected` truth table.
- `_parse_target_ref`: `android:<chat>`, `android:<chat>:<thread>`, non-android - `_parse_target_ref`: `iris:<chat>`, `iris:<chat>:<thread>`, non-android
→ None. → None.
- **Reasoning split:** given a `show_reasoning`-style final text, `send()` - **Reasoning split:** given a `show_reasoning`-style final text, `send()`
emits `message {reasoning, text}` correctly; no-prefix → no reasoning field. emits `message {reasoning, text}` correctly; no-prefix → no reasoning field.
@@ -54,7 +54,7 @@ Kotlin client.
```bash ```bash
hermes gateway & # with the android plugin 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" --send "list the files and summarize"
# prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end, # prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end,
# commentary, message.stop {reasoning,…}, … # commentary, message.stop {reasoning,…}, …
@@ -112,7 +112,7 @@ adb logcat -d > /tmp/logcat.txt
verbosity in Settings → rendering changes. verbosity in Settings → rendering changes.
5. **Intermediate:** a multi-step prompt → commentary bubble appears dimmed. 5. **Intermediate:** a multi-step prompt → commentary bubble appears dimmed.
6. **Channels:** create "Cron Reports" → appears in list; set as cron target. 6. **Channels:** create "Cron Reports" → appears in list; set as cron target.
7. **Cron delivery:** create a cron job `deliver=android:android:chan_<n>` → it 7. **Cron delivery:** create a cron job `deliver=iris:chan_<n>` → it
fires → lands in that channel (not default). fires → lands in that channel (not default).
8. **Search:** "search everywhere" vs "this chat" → correct scoping; tap → jump. 8. **Search:** "search everywhere" vs "this chat" → correct scoping; tap → jump.
9. **Media (in):** attach a photo + a video → agent receives (vision) → reply. 9. **Media (in):** attach a photo + a video → agent receives (vision) → reply.
@@ -144,11 +144,11 @@ adb logcat -d > /tmp/logcat.txt
## 13.5 Debugging tips ## 13.5 Debugging tips
- **Gateway side:** `~/.hermes/logs/gateway.log` (and `hermes logs --follow`). - **Gateway side:** `~/.hermes/logs/gateway.log` (and `hermes logs --follow`).
Our plugin logs under the `android` adapter name; secrets redacted. Our plugin logs under the `iris` adapter name; secrets redacted.
- **WS framing bugs:** use the `ws_probe.py` harness — it isolates the protocol - **WS framing bugs:** use the `ws_probe.py` harness — it isolates the protocol
from the app. from the app.
- **Streaming jitter:** the consumer edits at intervals; if updates look chunky, - **Streaming jitter:** the consumer edits at intervals; if updates look chunky,
check `display.platforms.android.streaming` and the consumer's edit interval. check `display.platforms.iris.streaming` and the consumer's edit interval.
- **Media pull stalls:** check chunk size + backpressure; confirm the file is - **Media pull stalls:** check chunk size + backpressure; confirm the file is
within hermes delivery roots (`validate_media_delivery_path`). within hermes delivery roots (`validate_media_delivery_path`).
- **FCM not arriving:** confirm the token registered (`devices.db`), the service - **FCM not arriving:** confirm the token registered (`devices.db`), the service
+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 Phased delivery. Each milestone ends with a **demo** (on-device where noted) and
has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1. has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
@@ -6,7 +6,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
--- ---
## M0 — Toolchain & scaffolding ## M0 — Toolchain & scaffolding
**Goal:** everything builds; the plugin is discoverable; the repo is safe. **Goal:** everything builds; the plugin is discoverable; the repo is safe.
- [X] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`). - [X] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`).
- [X] `cd hermes-agent && uv sync` (hermes venv works). - [X] `cd hermes-agent && uv sync` (hermes venv works).
- [X] Create monorepo scaffold (`02-monorepo.md`): `gateway-plugin/`, `app/` - [X] Create monorepo scaffold (`02-monorepo.md`): `gateway-plugin/`, `app/`
@@ -16,20 +18,22 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
- [X] CMP project builds empty: `./gradlew :androidApp:assembleDebug`, - [X] CMP project builds empty: `./gradlew :androidApp:assembleDebug`,
`./gradlew :desktopApp:run` (blank window). `./gradlew :desktopApp:run` (blank window).
- [X] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a - [X] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a
no-op `AndroidAdapter` → `hermes gateway status` lists **android**. no-op `IrisAdapter` → `hermes gateway status` lists **iris**.
- **Demo:** `hermes gateway status` shows `android`; `./gradlew - **Demo:** `hermes gateway status` shows `iris`; `./gradlew
:androidApp:installDebug` installs a blank app on the MIX 2S. :androidApp:installDebug` installs a blank app on the MIX 2S.
- **Accept:** blank app installs + launches on-device; plugin visible in - **Accept:** blank app installs + launches on-device; plugin visible in
`hermes gateway status`; `hermes-agent/` is git-ignored (verify with `hermes gateway status`; `hermes-agent/` is git-ignored (verify with
`git status --ignored`). `git status --ignored`).
## M1 — Gateway core loop (text round-trip) ## M1 — Gateway core loop (text round-trip)
**Goal:** pair + send a text message + get a (non-streaming) reply. **Goal:** pair + send a text message + get a (non-streaming) reply.
- [X] WS server (`ws_server.py`): bind, `hello` auth (constant-time), - [X] WS server (`ws_server.py`): bind, `hello` auth (constant-time),
`hello.ack`, heartbeat, connection registry. `hello.ack`, heartbeat, connection registry.
- [X] `AndroidAdapter.send()` → `message` frame; inbound `message.send` → - [X] `IrisAdapter.send()` → `message` frame; inbound `message.send` →
`MessageEvent` → `handle_message`. `MessageEvent` → `handle_message`.
- [X] Pairing store + `ANDROID_TOKEN`; QR payload in `interactive_setup`. - [X] Pairing store + `IRIS_TOKEN`; QR payload in `interactive_setup`.
- [X] App: Connect screen (URL+token, real `hello` test), `GatewayClient` - [X] App: Connect screen (URL+token, real `hello` test), `GatewayClient`
(connect + reconnect), ChatScreen sends + renders `message`. (connect + reconnect), ChatScreen sends + renders `message`.
- [X] `ws_probe.py` harness drives a real turn. - [X] `ws_probe.py` harness drives a real turn.
@@ -38,9 +42,11 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
reconnect after gateway restart re-pairs. reconnect after gateway restart re-pairs.
## M2 — Streaming + reasoning + tools + commentary ## M2 — Streaming + reasoning + tools + commentary
**Goal:** the "agent transparency" features. **Goal:** the "agent transparency" features.
- [X] Map consumer `send`/`edit_message` → `message.start/update/stop`. - [X] Map consumer `send`/`edit_message` → `message.start/update/stop`.
- [X] Reasoning: set `show_reasoning` for android; adapter splits prefix → - [X] Reasoning: set `show_reasoning` for iris; adapter splits prefix →
`reasoning` field. **Verify format with `ws_probe.py`.** (The model `reasoning` field. **Verify format with `ws_probe.py`.** (The model
returns a separate `reasoning_content` field. In the *streaming* case the returns a separate `reasoning_content` field. In the *streaming* case the
gateway drops it — the stream consumer only forwards `content` and the gateway drops it — the stream consumer only forwards `content` and the
@@ -64,12 +70,14 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
rendering; reasoning copy button works; frame shapes match `04-wire-protocol`. rendering; reasoning copy button works; frame shapes match `04-wire-protocol`.
## M3 — Channels/threads + cron + search ## M3 — Channels/threads + cron + search
**Goal:** organization + cron delegation + search. **Goal:** organization + cron delegation + search.
- [X] Channel directory (SQLite): default channel ensured; `channel.create/ - [X] Channel directory (SQLite): default channel ensured; `channel.create/
rename/set_default/delete` + `channel.*` frames. rename/set_default/delete` + `channel.*` frames.
- [X] Threads: toggle in default chat; `thread_id` lanes; `create_handoff_thread`. - [X] Threads: toggle in default chat; `thread_id` lanes; `create_handoff_thread`.
- [X] `parse_target_ref_fn` + `cron_deliver_env_var` → cron - [X] `parse_target_ref_fn` + `cron_deliver_env_var` → cron
`deliver=android:<chat>[:<thread>]` works. `deliver=iris:<chat>[:<thread>]` works.
- [X] `search.py` FTS5 bridge; `search` frame (all / this-chat) → results. - [X] `search.py` FTS5 bridge; `search` frame (all / this-chat) → results.
- [X] App: channel list (drawer/rail), thread toggle + topic switcher, "new - [X] App: channel list (drawer/rail), thread toggle + topic switcher, "new
channel" + "set as cron target", SearchScreen with scope toggle + jump. channel" + "set as cron target", SearchScreen with scope toggle + jump.
@@ -79,7 +87,7 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
isolate context; search scoping correct; channel list reconciles on events. isolate context; search scoping correct; channel list reconciles on events.
- **Status (complete):** channel directory + threads + search + outbox sync - **Status (complete):** channel directory + threads + search + outbox sync
verified end-to-end via `ws_probe.py` (create/rename/set_default/delete, verified end-to-end via `ws_probe.py` (create/rename/set_default/delete,
thread lanes, FTS5 search, sync); cron `deliver=android:<chat>[:<thread>]` thread lanes, FTS5 search, sync); cron `deliver=iris:<chat>[:<thread>]`
target resolution verified via `resolve_send_target`. App on-device: channel target resolution verified via `resolve_send_target`. App on-device: channel
drawer, thread toggle + topic switcher, new channel, search overlay with drawer, thread toggle + topic switcher, new channel, search overlay with
jump. Minor UI gaps deferred to M7 polish: "set as cron target" is set via jump. Minor UI gaps deferred to M7 polish: "set as cron target" is set via
@@ -89,7 +97,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
not e2e-tested (it shares the verified resolution path). not e2e-tested (it shares the verified resolution path).
## M4 — Media ## M4 — Media
**Goal:** attach + receive + play media. **Goal:** attach + receive + play media.
- [X] Inbound: `media.upload` chunked → `cache_*_from_bytes` → `media_urls`; - [X] Inbound: `media.upload` chunked → `cache_*_from_bytes` → `media_urls`;
size limit + sha256 + MIME re-sniff. size limit + sha256 + MIME re-sniff.
- [X] Outbound: `send_*` → `media.offer`; `media.pull` chunked; delivery-path - [X] Outbound: `send_*` → `media.offer`; `media.pull` chunked; delivery-path
@@ -117,12 +127,14 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
`04-wire-protocol.md` + `frames.schema.json`. `04-wire-protocol.md` + `frames.schema.json`.
## M5 — Push + offline (FCM + ntfy) ## M5 — Push + offline (FCM + ntfy)
**Goal:** reach the phone when backgrounded; catch up on reconnect. **Goal:** reach the phone when backgrounded; catch up on reconnect.
- [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune - [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune
(row cap 5000 + prune banner, throttled 1/h). (row cap 5000 + prune banner, throttled 1/h).
- [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via - [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via
PyJWT+cryptography) + `NtfyBackend` (X-Data header); selected by PyJWT+cryptography) + `NtfyBackend` (X-Data header); selected by
`ANDROID_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`. `IRIS_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`.
- [x] Fire push on no-live-subscriber; data payload for silent sync. - [x] Fire push on no-live-subscriber; data payload for silent sync.
High-priority kinds (approval/clarify/cron) push even when live. High-priority kinds (approval/clarify/cron) push even when live.
- [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a - [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a
@@ -143,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). loss/dup (verified); banners show for foreground events (implemented).
## M6 — Desktop app ## M6 — Desktop app
**Goal:** the same app on a big screen. **Goal:** the same app on a big screen.
- [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView); - [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView);
`MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt. `MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt.
- [x] Two-pane default layout; keyboard shortcuts; optional inspector pane. - [x] Two-pane default layout; keyboard shortcuts; optional inspector pane.
@@ -178,7 +192,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
macOS/Windows packaging are deferred to M7. macOS/Windows packaging are deferred to M7.
## M7 — Polish + E2E + docs ## M7 — Polish + E2E + docs
**Goal:** ship-quality. **Goal:** ship-quality.
- [x] Telegram-style layout pass (per reference image): header, bubbles, date - [x] Telegram-style layout pass (per reference image): header, bubbles, date
separators, ✓✓, model/token footer, banner, bottom bar. separators, ✓✓, model/token footer, banner, bottom bar.
- [x] Theming (dark default, accent), onboarding/pairing UX, empty/loading/ - [x] Theming (dark default, accent), onboarding/pairing UX, empty/loading/
@@ -222,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 ## Sequencing notes
- **M1/M2 depend on the `ws_probe.py` harness** to lock frame shapes early — - **M1/M2 depend on the `ws_probe.py` harness** to lock frame shapes early —
build it in M1. build it in M1.
- **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3, - **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3,
+1 -1
View File
@@ -5,7 +5,7 @@ integration point. Paths are relative to `hermes-agent/` (the read-only
reference). This lets a coder jump straight to the right code instead of reference). This lets a coder jump straight to the right code instead of
re-deriving the architecture. re-deriving the architecture.
> ⚠️ Read-only. We **install** our plugin into `~/.hermes/plugins/android`; we > ⚠️ Read-only. We **install** our plugin into `~/.hermes/plugins/iris`; we
> never edit these files. > never edit these files.
## Plugin / platform registration ## Plugin / platform registration
+9 -7
View File
@@ -3,24 +3,24 @@
## Locked decisions (from planning, 2026-08-19) ## Locked decisions (from planning, 2026-08-19)
| # | Decision | Choice | Rationale | | # | Decision | Choice | Rationale |
|---|---|---|---| | --- | --- | --- | --- |
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Android app, tweaked"; share protocol/state/UI. | | 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the 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. | | 3 | Media transport | **Over the WebSocket** | One transport, zero new Python deps; chunked binary frames. |
| 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. | | 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. |
## Additional decisions made during planning ## Additional decisions made during planning
| Decision | Choice | Note | | Decision | Choice | Note |
|---|---|---| | --- | --- | --- |
| Connection point | **Messaging gateway** (platform plugin `android`), not `tui_gateway` | Makes cron/`send_message`/slash/coexistence native. | | Connection point | **Messaging gateway** (platform plugin `iris`), not `tui_gateway` | Makes cron/`send_message`/slash/coexistence native. |
| Plugin style | **Community plugin** (`register(ctx)`) | Zero hermes-core changes. | | Plugin style | **Community plugin** (`register(ctx)`) | Zero hermes-core changes. |
| Python deps | **None new** (`websockets` + `httpx` are core) | Respects hermes pinning policy. | | Python deps | **None new** (`websockets` + `httpx` are core) | Respects hermes pinning policy. |
| Tool events | **Structured frames; app controls verbosity** | Gateway sends full data; app = everything/truncated/nothing. | | Tool events | **Structured frames; app controls verbosity** | Gateway sends full data; app = everything/truncated/nothing. |
| Reasoning | **Adapter splits the `show_reasoning` prefix** | Clean `reasoning` field → collapsible block above message. | | Reasoning | **Adapter splits the `show_reasoning` prefix** | Clean `reasoning` field → collapsible block above message. |
| Channels/threads | **Map onto `chat_id`/`thread_id`** | Existing gateway primitives; cron targets them. | | Channels/threads | **Map onto `chat_id`/`thread_id`** | Existing gateway primitives; cron targets them. |
| Offline | **SQLite outbox + sync cursor** | Catch-up on reconnect; push on disconnect. | | Offline | **SQLite outbox + sync cursor** | Catch-up on reconnect; push on disconnect. |
| Default chat id | `android:default` | Home channel + cron default. | | Default chat id | `default` | Home channel + cron default. |
| WS port | `8790` (default) | Configurable. | | WS port | `8790` (default) | Configurable. |
| minSdk | 26 (test device API 29) | Broad coverage. | | minSdk | 26 (test device API 29) | Broad coverage. |
| Frame routing | **Broadcast to all connected devices** (no per-chat subscribe) | Single-user model; simpler. | | Frame routing | **Broadcast to all connected devices** (no per-chat subscribe) | Single-user model; simpler. |
@@ -29,6 +29,8 @@
| Agent lifecycle | **`agent.busy`/`agent.idle`** events + **`agent.stop`/`agent.steer`** requests | App shows thinking indicator; user can abort or steer mid-turn. | | Agent lifecycle | **`agent.busy`/`agent.idle`** events + **`agent.stop`/`agent.steer`** requests | App shows thinking indicator; user can abort or steer mid-turn. |
| Local DB (KMP) | **SQLDelight** (not Room) | Room is Android-only; SQLDelight works in commonMain for both platforms. | | Local DB (KMP) | **SQLDelight** (not Room) | Room is Android-only; SQLDelight works in commonMain for both platforms. |
| Voice input | **Record → upload as audio media** (no client-side STT) | Agent's STT (if configured) handles transcription. | | Voice input | **Record → upload as audio media** (no client-side STT) | Agent's STT (if configured) handles transcription. |
| QR encoder | **Pure-stdlib** (`gateway-plugin/qr.py`) | No `qrcode`/`segno`/Pillow; byte mode, EC M→L, v1–10, ISO penalty masking. Keeps the plugin's zero-new-dep rule. |
| QR scanner | **ML Kit barcode** (not zxing-android-embedded) | On-device, no Google Play services; better accuracy/latency, Google-maintained. Android-only (desktop has no camera). |
## Open questions (resolve during implementation) ## Open questions (resolve during implementation)
@@ -45,7 +47,7 @@ will proceed with unless you say otherwise.
`reasoning_style: code` for android and split on that; fallback = no `reasoning_style: code` for android and split on that; fallback = no
reasoning field (full text) if the prefix isn't found. Verify in M2. reasoning field (full text) if the prefix isn't found. Verify in M2.
3. **Per-device tokens vs shared token (M1/M5).** *Default (v1):* shared 3. **Per-device tokens vs shared token (M1/M5).** *Default (v1):* shared
`ANDROID_TOKEN` + optional `ANDROID_ALLOWED_USERS` device allowlist. `IRIS_TOKEN` + optional `IRIS_ALLOWED_USERS` device allowlist.
Per-device revocable tokens are a stretch. Per-device revocable tokens are a stretch.
4. **Desktop video backend (M6).** *Default:* `libmpv`/`mpv`-backed Compose 4. **Desktop video backend (M6).** *Default:* `libmpv`/`mpv`-backed Compose
surface, WebView fallback. Confirm `mpv` availability on target OSes during surface, WebView fallback. Confirm `mpv` availability on target OSes during
@@ -54,7 +56,7 @@ will proceed with unless you say otherwise.
recommended remote path; WSS + reverse proxy as alternatives. No public bind recommended remote path; WSS + reverse proxy as alternatives. No public bind
by default. by default.
6. **Streaming cadence (M2).** If live updates look chunky, tune 6. **Streaming cadence (M2).** If live updates look chunky, tune
`display.platforms.android.streaming` / consumer edit interval. *Default:* `display.platforms.iris.streaming` / consumer edit interval. *Default:*
follow global streaming config. follow global streaming config.
7. **App package name / branding.** *Default:* applicationId `dev.iris.app`, 7. **App package name / branding.** *Default:* applicationId `dev.iris.app`,
app name "Iris". Confirm final product name + package + icon. app name "Iris". Confirm final product name + package + icon.
+1 -1
View File
@@ -153,7 +153,7 @@ Config + setup per provider — `web_server.py` `/api/memory/providers/*`.
`app/shared/.../protocol/Protocol.kt` → `docs/protocol/frames.schema.json`). `app/shared/.../protocol/Protocol.kt` → `docs/protocol/frames.schema.json`).
2. **Security is the real gate.** The single-user model in 2. **Security is the real gate.** The single-user model in
`09-pairing-security.md` still holds, but control frames widen the blast `09-pairing-security.md` still holds, but control frames widen the blast
radius of a leaked `ANDROID_TOKEN`. Sensitive operations (env/secrets, radius of a leaked `IRIS_TOKEN`. Sensitive operations (env/secrets,
config writes, gateway restart, profile deletion) should get either an config writes, gateway restart, profile deletion) should get either an
in-app confirmation step or a capability flag negotiated at pairing. in-app confirmation step or a capability flag negotiated at pairing.
3. **Read-heavy first.** Most of the value is in list/view frames (cheap, 3. **Read-heavy first.** Most of the value is in list/view frames (cheap,
+5 -5
View File
@@ -71,7 +71,7 @@ acceptable alternative if preferred).
``` ```
┌──────────────────────── hermes gateway process ───────────────────────┐ ┌──────────────────────── hermes gateway process ───────────────────────┐
│ AndroidAdapter │ │ IrisAdapter │
│ │ frames (same protocol.Frame objects) │ │ │ frames (same protocol.Frame objects) │
│ ▼ │ │ ▼ │
│ _broadcast_or_log ──► outbox.append(cursor) ──► push (if no live) │ │ _broadcast_or_log ──► outbox.append(cursor) ──► push (if no live) │
@@ -105,7 +105,7 @@ over HTTP whenever the WS is down.
## 19.4 Gateway: `gateway-plugin/http_server.py` ## 19.4 Gateway: `gateway-plugin/http_server.py`
New module, started/stopped by `AndroidAdapter.connect()`/`disconnect()` next New module, started/stopped by `IrisAdapter.connect()`/`disconnect()` next
to the WS server. to the WS server.
- **Server:** `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`, - **Server:** `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`,
@@ -114,8 +114,8 @@ to the WS server.
into the gateway's asyncio loop with into the gateway's asyncio loop with
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at `asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
start, same loop the WS server runs on). start, same loop the WS server runs on).
- **Config:** `ANDROID_HTTP_PORT` (default **8791**), same bind host as the WS - **Config:** `IRIS_HTTP_PORT` (default **8791**), same bind host as the WS
(`ANDROID_WS_HOST`). Optional TLS via `ANDROID_HTTP_CERT`/`ANDROID_HTTP_KEY` (`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 (`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a
trusted LAN by default, TLS for remote/Tailscale setups. trusted LAN by default, TLS for remote/Tailscale setups.
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the - **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 id: 1043
event: frame event: frame
data: {"v":1,"type":"message","chat_id":"android:default",...} data: {"v":1,"type":"message","chat_id":"default",...}
: hb ← comment heartbeat every 15 s (keeps proxies alive) : hb ← comment heartbeat every 15 s (keeps proxies alive)
``` ```
+322
View File
@@ -0,0 +1,322 @@
# 20 — QR Pairing (terminal QR + in-app scanner)
**Status: implemented (M8, 2026-08-22).**
Closes gap #12 in `09-pairing-security.md` ("in-app QR scanner") and implements
the QR branch of the §9.2 pairing flow, which the docs already promise but the
code never delivered: today `interactive_setup` prints the pairing URL as
plain text only, and the app has no `iris://pair` parser at all.
Two halves, independent and shippable separately:
- **A — Gateway:** `hermes gateway setup` renders a scannable QR in the
terminal encoding `iris://pair?host=…&port=…&token=…`. **Zero new Python
dependencies** (pure stdlib encoder).
- **B — App:** a "Scan QR" button on the Android Connect screen (CameraX +
ML Kit, on-device, no Play services) that pre-fills URL + token. Not added
to desktop. Plus an `iris://pair` deep link so *any* scanner (system camera
app, other phones) can route the QR into the app.
---
## 20.1 Current state (what exists today)
| Piece | State | Location |
| ------- | ------- | ---------- |
| QR payload format | ✅ implemented | `gateway-plugin/pairing.py` → `qr_payload(host, port, token, secure)` → `iris://pair?host=<lan-ip>&port=8791&secure=0&token=<64-hex>` |
| Terminal QR rendering | ❌ missing | `gateway-plugin/adapter.py` → `interactive_setup()` prints the URL text only |
| App `iris://pair` parser | ❌ missing | app has manual URL + token entry only (`ConnectScreen`) |
| In-app camera scan | ❌ missing | no camera deps anywhere in `app/` |
| `iris://` deep link | ⚠️ partial | manifest handles `iris://chat/<id>` only (`androidApp/.../AndroidManifest.xml`, `MainActivity.handleDeepLink`) |
| QR libs in hermes venv | ❌ absent | `qrcode`/`segno` not installed; `Pillow` is a hermes core dep but only renders images — the QR *matrix* algorithm is still needed either way |
Payload size: `iris://pair?host=192.168.x.x&port=8791&secure=0&token=<64 hex>`
≈ **118 bytes** → QR version **7 at EC level M** (capacity 122 bytes) or v6 at
L (134). The encoder must therefore support at least versions 1–8; we target
1–10.
---
## 20.2 Part A — terminal QR in `interactive_setup`
### A1. Pure-stdlib QR encoder — `gateway-plugin/qr.py` (new file)
A self-contained ISO/IEC 18004 encoder, **stdlib only** (no `qrcode`, no
`segno`, no Pillow). Scope is deliberately minimal — we only ever encode
ASCII pairing URLs:
- **Mode:** byte mode only (no alphanumeric/numeric/kanji paths).
- **Error correction:** level **M** (15 %); auto-fallback to **L** if the
payload doesn't fit at M within the version cap.
- **Versions:** 1–10, auto-selected (smallest version whose capacity fits).
Payloads that don't fit v10-L raise `QrTooLongError` (caller falls back to
text-only output — see A3).
- **Components** (all well-known, spec-stable algorithms):
1. Data encoding: mode indicator `0100`, 8-bit char count (8 bits for
v1–9, 16 bits for v10), payload bytes, terminator, padding
(`0xEC`/`0x11` alternation).
2. Reed–Solomon error correction over GF(256), generator polynomial
`0x11D`, per (version, EC level) block structure from the spec tables.
3. Matrix placement: finder patterns + separators, timing patterns,
alignment patterns (v2+), dark module, format info (BCH(15,5)),
version info (v7+, BCH(18,6)), zig-zag data placement.
4. Masking: all 8 masks, ISO penalty scoring (N1–N4), pick lowest.
- **Public API:**
```python
def qr_matrix(data: str) -> list[list[bool]]:
"""Encode *data* (ASCII) into a module matrix (True = dark).
Includes the 4-module quiet zone. Raises QrTooLongError."""
```
~250–350 lines including the spec tables. No I/O, no globals, fully
unit-testable.
### A2. Terminal renderer — `qr.py`
```python
def render_qr(data: str) -> str:
"""Render *data* as a terminal QR using Unicode half-blocks (▀).
Returns '' (not an exception) when the payload is too long."""
```
- Pair consecutive module rows into one character row: both dark → `█`,
top dark → `▀`, bottom dark → `▄`, both light → space. (Matrix height
including quiet zone is always even: `2·(17+4v)+8`.)
- Output is a single string of `\n`-joined lines; the caller prints it.
- No ANSI colors, no cursor tricks — must survive `less`, log files, and
copy-paste.
### A3. Integration — `adapter.py:interactive_setup()`
After the existing "Pairing URL / Server URL" lines:
```python
qr = render_qr(qr_payload(host, port, token))
if qr:
print_info("Scan with the Iris app (Connect → Scan QR) or any camera app:")
print(qr)
else:
print_warning("QR too large to render; use the pairing URL above.")
```
- The **URL text lines stay** — the QR is a convenience, not a replacement
(terminals without UTF-8 still work, and the text is copy-pasteable).
- Printed on every setup run (new *and* existing token), consistent with the
URL lines which already print the token in cleartext.
- **Security note:** no new exposure — the token is already printed in the
pairing URL line today; the QR is the same bytes in a different encoding,
on the same operator-only stdout. (Gap #5 in the §9.7 table already
documents the stdout token print.)
### A4. Tests — `hermes-agent/tests/gateway/test_android.py`
The test file is a thin mirror importing the **live `gateway-plugin/`
package**, so new tests land there:
1. **Fixed test vectors** (guard against silent algorithm drift): at least
two known-good (data → matrix) pairs from public QR test vectors
(e.g. the ISO 18004 annex examples / the classic `KARAT` v2-L vector).
Assert the full matrix, not just dimensions.
2. **Round-trip via payload:** `qr_matrix(qr_payload(h, p, t))` has the
expected version/size for a 64-hex token (`17 + 4·7 = 45` modules at
v7-M, +8 quiet zone).
3. **Renderer shape:** every line equal length, height = half of matrix
height, quiet zone renders as blank border, only the 4 block chars +
space appear.
4. **`QrTooLongError` / `render_qr` → `""`** for a payload beyond v10-L.
5. **`interactive_setup` smoke:** with sandboxed HERMES_HOME (conftest
already does this), capture stdout and assert the QR block appears after
the pairing URL line.
Run: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`.
---
## 20.3 Part B — in-app scanner (Android only)
### B1. Dependencies — `app/shared/build.gradle.kts`, `androidMain.dependencies` only
| Dependency | Why |
| ------------ | ----- |
| `androidx.camera:camera-camera2` | Camera access (CameraX) |
| `androidx.camera:camera-lifecycle` | Lifecycle-aware binding |
| `androidx.camera:camera-view` | `PreviewView` for the scan surface |
| `com.google.mlkit:barcode-scanning` | On-device QR decode; **no Google Play services required** (self-contained model) |
- Chosen over `zxing-android-embedded` (decided 2026-07-20): ML Kit has
better accuracy/latency, is maintained by Google, and works fully
on-device without GMS.
- These go in **`androidMain`** only — the no-new-dep rule covers
`gateway-plugin/`, and the app already carries OkHttp/SQLDelight/KCEF/etc.
Desktop is untouched.
- `minSdk 29` is fine for all four (ML Kit barcode needs 21+).
- Versions go in the existing version catalog / `composeVersion`-style
constants at the top of the build file (follow the current pattern).
### B2. Scanner activity — `shared/src/androidMain/kotlin/iris/platform/QrScanActivity.kt` (new)
A minimal `ComponentActivity` (not a Fragment, no nav graph):
- Layout: full-screen `PreviewView` + overlay hint text ("Point at the QR
code") + close button.
- `ImageAnalysis` (STRATEGY_LATEST, YUV_420_888) →
`BarcodeScannerOptions(FORMAT_QR_CODE)` → first result →
`setResult(RESULT_OK, Intent().putExtra("iris.qr.text", raw))` → finish.
- **Runtime permission:** request `CAMERA` on launch; on denial show a
message + close (the Connect screen still has manual entry).
- Registered in `shared/src/androidMain/AndroidManifest.xml` (or the
androidApp manifest — follow where `NtfyListenerService` is declared)
with `android:exported="false"`, `android:theme` reusing the app theme.
- Manifest additions (androidApp manifest):
```xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
```
`required="false"` so the app stays installable on camera-less devices
(the button then just reports "no camera").
### B3. Platform hook — `expect`/`actual`
`shared/src/commonMain/kotlin/iris/platform/PlatformQr.kt` (new):
```kotlin
/** Launch the QR scanner. [onResult] gets the decoded text, or null when
* the user cancelled / no camera / permission denied. Desktop: no-op. */
expect fun scanQrCode(onResult: (String?) -> Unit)
```
- **androidMain actual:** `ActivityResultLauncher` (from the Compose
`LocalContext`) starting `QrScanActivity`; maps `RESULT_OK` → text,
everything else → `null`.
- **desktopMain actual:** `onResult(null)` immediately (the button is
hidden on desktop anyway — see B4; the no-op keeps the `expect` total).
### B4. Connect screen button — `ConnectScreen.kt`
- New **"Scan QR"** `Button` below the token field, rendered only when
`!isDesktop` (`iris.platform.isDesktop` already exists).
- On tap: `scanQrCode { raw -> … }`; on non-null `raw`:
- `PairLink.parse(raw)` (B5) → pre-fill `url` and `token` state, clear
error, and **do not auto-connect** — the user still taps
"Test & Connect" (pairing stays an explicit act, per §10.8).
- Parse failure → set `error` to "Not a pairing QR code" (don't echo the
raw payload — it may contain someone else's token).
- On `null` (cancel/denied): no-op, no error.
### B5. Pair-link parser — `shared/src/commonMain/kotlin/iris/util/PairLink.kt` (new)
```kotlin
data class PairLink(val url: String, val token: String)
object PairLink {
/** Parse `iris://pair?host=…&port=…&secure=…&token=…` → PairLink.
* Returns null on any malformation. */
fun parse(raw: String): PairLink?
}
```
- Accepts exactly scheme `iris`, host `pair` (case-insensitive scheme).
- Required: `host` (non-empty), `token` (non-empty). `port` defaults to
`8791` (the HTTP default, `docs/19`); `secure` defaults to `0`.
- Builds `url` as `http(s)://<host>:<port>`; validates port 1–65535.
- URL-decodes `host`/`token` (the Python side `quote()`s them).
- Pure function, no platform imports → **unit-tested in `jvmTest`**
(`:shared:testAndroidHostTest` / `:shared:desktopTest` both run it):
valid link, missing token, bad port, wrong scheme, wrong host,
percent-encoded host, secure=1 → https, default port.
### B6. `iris://pair` deep link (system-scanner fallback)
So a QR scanned by *any* app (phone's built-in scanner, a friend's phone)
lands in Iris:
- Manifest: extend the existing `VIEW` intent-filter block (or add a
sibling) with `<data android:scheme="iris" android:host="pair" />`.
- `MainActivity.handleDeepLink`: on `iris://pair` → `PairLink.parse(uri)` →
stash into a `mutableStateOf<PairLink?>` passed into `IrisApp` →
`ConnectScreen` receives it as `prefillUrl`/`prefillToken` (the params
already exist). If the app is already connected, ignore (or surface in
Settings later — out of scope).
- This reuses B5's parser; add one test for the URI shape Android delivers.
---
## 20.4 Part C — doc updates (with the implementation)
| Doc | Change |
| ----- | -------- |
| `09-pairing-security.md` | Gap #12 → **implemented** (fix the stale `adapter.py:648-654` reference while at it); §9.2 QR branch no longer aspirational |
| `10-android-app.md` §10.8 | "or scan QR" becomes real: scanner button + deep link, camera permission |
| `14-milestones.md` | New **M8 — QR pairing** section (acceptance criteria below) |
| `16-open-questions.md` | Record decision: ML Kit over zxing; pure-stdlib encoder over vendoring `segno` |
| `README.md` | Reading-order table: add row 20 |
---
## 20.5 Work breakdown & sequencing
Ordered so each step is independently verifiable; A and B can be
interleaved (different languages, no shared surface).
| # | Task | Verify |
| --- | ------ | -------- |
| 1 | `qr.py` encoder + renderer (A1/A2) | new unit tests green (A4.1–4.4) |
| 2 | `interactive_setup` integration (A3) | A4.5 + manual: `hermes gateway setup` in a real terminal shows a scannable QR (scan with the phone's *system* camera app as the decoder oracle) |
| 3 | `PairLink` parser + jvmTest (B5) | `./gradlew :shared:testAndroidHostTest` |
| 4 | Deps + manifest + `QrScanActivity` (B1/B2) | `:androidApp:assembleDebug` |
| 5 | `PlatformQr` expect/actual + Connect button (B3/B4) | `:androidApp:assembleDebug` + `:desktopApp:run` (button absent, no crash) |
| 6 | `iris://pair` deep link (B6) | ADB: `adb shell am start -a android.intent.action.VIEW -d "iris://pair?host=…&port=…&token=…"` → Connect screen pre-filled |
| 7 | Doc updates (Part C) | — |
**On-device E2E (final gate, per `13-testing.md` ADB workflow):**
1. `hermes gateway setup` on the gateway host → QR in terminal.
2. Phone: `adb shell am start -n dev.iris.app/.MainActivity` → Connect →
**Scan QR** → grant camera → point at the terminal (screenshot the QR
onto a second screen if needed; the reference device is API 29 —
verify CameraX works on the MIX 2S in step 4 before building the rest).
3. Fields pre-filled → **Test & Connect** → chat screen.
4. Repeat via deep link (step 6 command) with a *different* token.
5. Negative: scan a non-pairing QR (e.g. a website) → "Not a pairing QR
code", fields untouched.
---
## 20.6 Acceptance criteria (M8)
- [ ] `hermes gateway setup` prints a QR that a stock Android camera app
decodes to exactly `qr_payload(host, port, token)`.
- [ ] QR encoder: fixed test vectors + size/round-trip tests green;
**zero** new entries in the plugin's import surface (stdlib only —
verifiable by `ruff`/import scan).
- [ ] Android: Connect screen shows **Scan QR** (hidden on desktop);
scanning the setup QR pre-fills URL + token; "Test & Connect" pairs.
- [ ] Camera permission denied → graceful message, manual entry still works.
- [ ] `iris://pair` deep link pre-fills the Connect screen (ADB-verified).
- [ ] `PairLink.parse` unit tests cover the matrix in B5.
- [ ] Full Python suite green: `scripts/run_tests.sh` (no args).
- [ ] Docs updated per Part C; gap #12 closed.
## 20.7 Risks & mitigations
| Risk | Mitigation |
| ------ | ------------ |
| Hand-rolled QR encoder has a subtle bug | Fixed spec test vectors (A4.1) + the system-camera-app oracle in the E2E gate; scope locked to byte mode / v1–10 so the surface stays small |
| Terminal without UTF-8 mangles the QR | URL text lines remain the primary path; QR is additive |
| CameraX quirks on API 29 (MIX 2S) | Build the scanner activity first (task 4) and verify on-device before wiring the UI |
| ML Kit model size (~4 MB) | Bundled in the APK, on-device, no runtime download — acceptable for this app's footprint |
| Token in QR scanned by a bystander's phone | Same trust domain as the token already printed in the terminal; LAN pairing is operator-supervised by design (§9.2). Deep link only pre-fills — it never auto-connects |
| `secure=1` (WSS) URLs | Parser already handles `secure` → `https://`; QR payload unchanged |
## 20.8 Explicit non-goals
- **QR display in the app** (showing a QR for other devices to scan) —
single-device pairing today; revisit if multi-device lands.
- **`hermes 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. | | 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. |
| 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). | | 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). |
| 19 | [`19-http-fallback-transport.md`](19-http-fallback-transport.md) | **Design** — HTTP fallback leg (POST + SSE/long-poll) so the app can send/receive when the WS is down. | | 19 | [`19-http-fallback-transport.md`](19-http-fallback-transport.md) | **Design** — HTTP fallback leg (POST + SSE/long-poll) so the app can send/receive when the WS is down. |
| 20 | [`20-qr-pairing.md`](20-qr-pairing.md) | Terminal QR at `gateway setup` + in-app QR scanner (Android) + `iris://pair` deep link. |
Machine-readable / diagrams: Machine-readable / diagrams:
@@ -51,7 +52,7 @@ Machine-readable / diagrams:
## The three deliverables (one monorepo) ## The three deliverables (one monorepo)
1. **`gateway-plugin/`** — a Python hermes **platform plugin** named `android`. 1. **`gateway-plugin/`** — a Python hermes **platform plugin** named `iris`.
Runs inside the `hermes gateway` process. Opens a WebSocket server the apps Runs inside the `hermes gateway` process. Opens a WebSocket server the apps
connect to. Implements the full `BasePlatformAdapter` contract. **Zero new connect to. Implements the full `BasePlatformAdapter` contract. **Zero new
Python dependencies, zero hermes-core changes.** Python dependencies, zero hermes-core changes.**
+1 -1
View File
@@ -17,7 +17,7 @@ flowchart TB
WSS["WebSocket SERVER<br/>(websockets) ws://host:8790/ws"] WSS["WebSocket SERVER<br/>(websockets) ws://host:8790/ws"]
end end
AGENT -->|legacy stream callbacks| ADAPTER AGENT -->|legacy stream callbacks| ADAPTER
CRON -->|deliver=android:chat:thread| ADAPTER CRON -->|deliver=iris:chat:thread| ADAPTER
ADAPTER <--> WSS ADAPTER <--> WSS
ADAPTER <--> OUTBOX ADAPTER <--> OUTBOX
ADAPTER <--> PUSH ADAPTER <--> PUSH
+1 -1
View File
@@ -10,7 +10,7 @@
"v": { "type": "integer", "const": 1, "description": "Protocol version." }, "v": { "type": "integer", "const": 1, "description": "Protocol version." },
"id": { "type": "integer", "description": "Request id; present on requests and their responses/acks. Absent on pure events." }, "id": { "type": "integer", "description": "Request id; present on requests and their responses/acks. Absent on pure events." },
"type": { "type": "string", "description": "Frame type (see frame_types)." }, "type": { "type": "string", "description": "Frame type (see frame_types)." },
"chat_id": { "type": "string", "description": "Optional chat scope (e.g. android:default, android:chan_7)." }, "chat_id": { "type": "string", "description": "Optional chat scope (e.g. default, chan_7)." },
"thread_id": { "type": "string", "description": "Optional thread scope within a chat_id." }, "thread_id": { "type": "string", "description": "Optional thread scope within a chat_id." },
"cursor": { "type": "integer", "description": "Outbox cursor the frame was parked under. Present ONLY on frames replayed by sync (live frames carry none). The app skips re-notifying replayed frames with cursor <= last_pushed_cursor (docs/08 §8.7)." }, "cursor": { "type": "integer", "description": "Outbox cursor the frame was parked under. Present ONLY on frames replayed by sync (live frames carry none). The app skips re-notifying replayed frames with cursor <= last_pushed_cursor (docs/08 §8.7)." },
"payload": { "type": "object", "description": "Type-specific payload." } "payload": { "type": "object", "description": "Type-specific payload." }
+19 -16
View File
@@ -9,7 +9,7 @@ just the steps.
## Prerequisites ## Prerequisites
| Where | You need | | Where | You need |
|---|---| | --- | --- |
| Gateway host | hermes installed with its venv (`cd hermes-agent && uv sync`, see [`12-toolchain.md` §12.4](12-toolchain.md)) | | 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 | | 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 | | Desktop build machine | JDK 17 only |
@@ -24,8 +24,8 @@ root):
```bash ```bash
mkdir -p ~/.hermes/plugins mkdir -p ~/.hermes/plugins
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
hermes gateway status # should list "android" hermes gateway status # should list "iris"
``` ```
Run the interactive setup: Run the interactive setup:
@@ -36,12 +36,13 @@ hermes gateway setup
What it does: 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). `~/.hermes/.env` (it prints the token once, at generation).
- Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`), - Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`),
and push backend (`fcm` or `ntfy`, default `fcm`). and push backend (`fcm` or `ntfy`, default `fcm`).
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…` - 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: 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 > **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 > 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 > 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 ## 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 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). `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 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 3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the
pairing and connects. pairing and connects.
> **Honest limitation:** QR scanning is **not** supported in the app yet. The > **Scan QR (Android):** the Connect screen has a **Scan QR** button (CameraX +
> server prints a QR payload, but pairing is manual URL + token entry only. > 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 ## 3. Desktop app
@@ -104,8 +107,8 @@ high-priority events (approvals, clarifies, cron) even when a device is live.
`app/androidApp/`. `app/androidApp/`.
2. Create a service account (Project settings → Service accounts → Generate new 2. Create a service account (Project settings → Service accounts → Generate new
private key) and store the JSON path in `~/.hermes/.env`: private key) and store the JSON path in `~/.hermes/.env`:
`ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`. `IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
3. Keep `ANDROID_PUSH_BACKEND=fcm` (the default). 3. Keep `IRIS_PUSH_BACKEND=fcm` (the default).
Without a Firebase project the FCM path is **inert** (the app's FCM service Without a Firebase project the FCM path is **inert** (the app's FCM service
does nothing) — use ntfy below, or add Firebase later. 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) ### ntfy (zero-config fallback)
``` ```
ANDROID_PUSH_BACKEND=ntfy IRIS_PUSH_BACKEND=ntfy
``` ```
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed); - 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. the app connects to `ws://<tailnet-ip>:8790/ws`. No public exposure.
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS at - **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS at
the edge, forward the WebSocket to `127.0.0.1:8790`. 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://`. `~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
> **Honest limitation:** the app has **no certificate pinning** yet, so > **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 ## 6. Troubleshooting
| Symptom | Likely cause / fix | | 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. | | 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. | | 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. | | 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 def main():
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws: async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
await ws.send(json.dumps({"v":1,"type":"hello","payload":{ 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}}})) "caps":{"min_protocol":1}}}))
print("recv:", await ws.recv()) print("recv:", await ws.recv())
asyncio.run(main()) asyncio.run(main())
+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 A plugin-based gateway adapter that runs an HTTP server *inside* the
``hermes gateway`` process. The native Android / Desktop app connects to it ``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 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 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 ``notification`` frames render in-app banners and mirror to push (channel
events, cron deliveries, approvals, clarifies); high-priority kinds push even events, cron deliveries, approvals, clarifies); high-priority kinds push even
when a device is live. ``fcm.register`` rotates push tokens (registry + live when a device is live. ``fcm.register`` rotates push tokens (registry + live
@@ -44,19 +44,19 @@ Configuration in config.yaml::
gateway: gateway:
platforms: platforms:
android: iris:
enabled: true enabled: true
extra: extra:
host: 127.0.0.1 host: 127.0.0.1
port: 8790 port: 8790
home_channel: android:default home_channel: default
push_backend: fcm push_backend: fcm
outbox_retention_hours: 72 outbox_retention_hours: 72
max_upload_bytes: 104857600 max_upload_bytes: 104857600
Or via environment variables (overrides config.yaml; secrets live in .env): Or via environment variables (overrides config.yaml; secrets live in .env):
ANDROID_TOKEN, ANDROID_WS_HOST, ANDROID_WS_PORT, ANDROID_HOME_CHANNEL, IRIS_TOKEN, IRIS_WS_HOST, IRIS_WS_PORT, IRIS_HOME_CHANNEL,
ANDROID_PUSH_BACKEND, ANDROID_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ... IRIS_PUSH_BACKEND, IRIS_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
""" """
import asyncio import asyncio
@@ -116,7 +116,10 @@ from gateway.platforms.base import ( # noqa: E402
from hermes_constants import get_hermes_home # noqa: E402 from hermes_constants import get_hermes_home # noqa: E402
from . import media as media_bridge # 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 purge as purge_bridge # noqa: E402
from . import search as search_bridge # noqa: E402 from . import search as search_bridge # noqa: E402
from .channels import get_directory # 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 .outbox import Outbox # noqa: E402
from .pairing import ( # noqa: E402 from .pairing import ( # noqa: E402
DeviceRegistry, DeviceRegistry,
advertise_host,
generate_token, generate_token,
pairing_url, pairing_url,
qr_payload, qr_payload,
@@ -147,7 +151,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
from hermes_cli import commands as hermes_commands from hermes_cli import commands as hermes_commands
except Exception: except Exception:
logger.warning( logger.warning(
"android: slash catalog unavailable (hermes_cli.commands import failed)", "iris: slash catalog unavailable (hermes_cli.commands import failed)",
exc_info=True, exc_info=True,
) )
return [] return []
@@ -175,7 +179,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
except Exception: except Exception:
# Code skew: the private helpers moved. Fall back to the plain # Code skew: the private helpers moved. Fall back to the plain
# cli_only filter (config-gated commands are dropped, acceptable). # 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 = [ entries = [
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases)) _entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
for cmd in hermes_commands.COMMAND_REGISTRY for cmd in hermes_commands.COMMAND_REGISTRY
@@ -187,7 +191,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
except Exception: except Exception:
# Best-effort: a broken plugin-command registry should not break the # Best-effort: a broken plugin-command registry should not break the
# built-in catalog, so the failure is intentionally swallowed. # 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 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 # hermes exposes a plugin ``on_stream_delta`` hook that fires reasoning
# deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``). # deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``).
# We accumulate those deltas here and attach the result to the turn's # 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. # 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 # (Settings → Tool detail), we capture each completed tool call via the
# ``post_tool_call`` hook and attach it to the ``tool.end`` frame. # ``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 # 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. # 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) # * turn start — the first API call of the turn (latency baseline)
# #
# Global buffer (same pattern as the reasoning/tool buffers): a personal # 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 # 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 android. # platform, so we only record when the turn's platform is iris.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_runtime_meta: dict[str, Any] = {} _runtime_meta: dict[str, Any] = {}
@@ -374,7 +378,7 @@ _CTX_RESOLVE_TIMEOUT_S = 3.0
def _on_post_api_request(**kwargs: Any) -> None: def _on_post_api_request(**kwargs: Any) -> None:
"""Plugin hook: capture per-turn runtime metadata (model, prompt tokens).""" """Plugin hook: capture per-turn runtime metadata (model, prompt tokens)."""
platform = kwargs.get("platform") platform = kwargs.get("platform")
if platform and platform != "android": if platform and platform != "iris":
return return
model = kwargs.get("model") or "" model = kwargs.get("model") or ""
usage = kwargs.get("usage") or {} usage = kwargs.get("usage") or {}
@@ -416,7 +420,7 @@ def _resolve_context_length(model: str) -> int | None:
_context_length_cache[model] = int(ctx) _context_length_cache[model] = int(ctx)
return int(ctx) return int(ctx)
except Exception: 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 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_HOST = "127.0.0.1"
DEFAULT_PORT = 8790 DEFAULT_PORT = 8790
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
DEFAULT_HOME_CHANNEL = "android:default" DEFAULT_HOME_CHANNEL = "default"
DEFAULT_HOME_CHANNEL_NAME = "Default" DEFAULT_HOME_CHANNEL_NAME = "Default"
DEFAULT_PUSH_BACKEND = "fcm" DEFAULT_PUSH_BACKEND = "fcm"
DEFAULT_OUTBOX_RETENTION_HOURS = 72 DEFAULT_OUTBOX_RETENTION_HOURS = 72
@@ -825,13 +829,13 @@ def check_requirements() -> bool:
dashboard readiness). Never installs. The HTTP transport is stdlib-only, dashboard readiness). Never installs. The HTTP transport is stdlib-only,
so there is no extra dependency to probe. 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: def validate_config(config) -> bool:
"""Given a PlatformConfig, is the platform properly configured?""" """Given a PlatformConfig, is the platform properly configured?"""
extra = getattr(config, "extra", {}) or {} 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) return bool(token)
@@ -858,7 +862,7 @@ def _env_enablement() -> dict | None:
core hook -- it becomes a proper ``HomeChannel`` dataclass on the core hook -- it becomes a proper ``HomeChannel`` dataclass on the
``PlatformConfig`` rather than being merged into ``extra``. ``PlatformConfig`` rather than being merged into ``extra``.
""" """
token = _get_scoped_secret("ANDROID_TOKEN", "") token = _get_scoped_secret("IRIS_TOKEN", "")
if not token: if not token:
return None return None
@@ -867,20 +871,20 @@ def _env_enablement() -> dict | None:
# clobber user YAML. Unset keys fall through to config.yaml / adapter # clobber user YAML. Unset keys fall through to config.yaml / adapter
# defaults. # defaults.
seed: dict[str, Any] = {} seed: dict[str, Any] = {}
host = os.getenv("ANDROID_WS_HOST", "").strip() host = os.getenv("IRIS_WS_HOST", "").strip()
if host: if host:
seed["host"] = 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: if http_port_raw:
seed["http_port"] = _parse_port(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: if push:
seed["push_backend"] = push seed["push_backend"] = push
home = os.getenv("ANDROID_HOME_CHANNEL", "").strip() home = os.getenv("IRIS_HOME_CHANNEL", "").strip()
if home: if home:
seed["home_channel"] = { seed["home_channel"] = {
"chat_id": home, "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 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: def _parse_target_ref(target_ref: str) -> tuple | None:
"""Parse a raw target string into ``(chat_id, thread_id)`` or ``None``. """Parse a raw target string into ``(chat_id, thread_id)`` or ``None``.
Recognises the native syntax ``android:<chat>[:<thread>]`` where the The core strips the platform prefix before calling us, so the native
chat_id itself carries the ``android:`` prefix (e.g. ``android:chan_7``) syntax is simply ``<chat_id>[:<thread>]`` (e.g. ``chan_7`` or
and an optional thread is a trailing ``:t_<n>``. A bare friendly name ``chan_7:t_31``); the home channel is ``default``. Chat ids are direct
(e.g. ``Cron Reports``) is resolved against the channel directory so cron (no embedded platform prefix), so a cron delivery reads
/ ``send_message`` can target a channel by name immediately, without ``iris:chan_7`` end to end. A bare friendly name (e.g. ``Cron Reports``)
waiting for the core directory's refresh timer. Returns ``None`` for is resolved against the channel directory so cron / ``send_message`` can
anything unrecognised so the target proceeds to the core channel-directory target a channel by name immediately, without waiting for the core
resolution. directory's refresh timer. Returns ``None`` for anything unrecognised so
the target proceeds to the core channel-directory resolution.
""" """
if not target_ref: if not target_ref:
return None return None
@@ -915,17 +920,26 @@ def _parse_target_ref(target_ref: str) -> tuple | None:
if not t: if not t:
return None return None
if t.startswith("android:"): thread_id: str | None = None
body = t[len("android:") :].strip() if ":" in t:
if not body: head, tail = t.rsplit(":", 1)
return None if head and tail.startswith("t_"):
thread_id: str | None = None thread_id = tail
if ":" in body: t = head
head, tail = body.rsplit(":", 1) else:
if tail and tail.startswith("t_"): # Not a <chat>:<thread> pair -- treat the whole string as a name.
thread_id = tail t = target_ref.strip()
body = head if not t:
return (f"android:{body}", thread_id) 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 # Bare friendly name -> resolve via the channel directory. A thread resolves
# to its session lane (parent_chat_id + thread_id); a channel/default to # to its session lane (parent_chat_id + thread_id); a channel/default to
@@ -966,7 +980,7 @@ async def _standalone_send(
""" """
return { return {
"error": ( "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)" "the outbox (standalone delivery is best-effort only)"
) )
} }
@@ -978,7 +992,7 @@ async def _standalone_send(
def _ensure_verbose_tool_progress() -> None: 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 Verbose mode makes the gateway's tool-progress line carry the FULL
argument JSON (not just a ~40-char preview), which the adapter parses argument JSON (not just a ~40-char preview), which the adapter parses
@@ -988,7 +1002,7 @@ def _ensure_verbose_tool_progress() -> None:
it). it).
Best-effort and idempotent: writes 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 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. 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 {} cfg = load_config_readonly() or {}
display = cfg.get("display") or {} display = cfg.get("display") or {}
platforms = display.get("platforms") or {} platforms = display.get("platforms") or {}
android = platforms.get("android") or {} iris_cfg = platforms.get("iris") or {}
if android.get("tool_progress") == "verbose": if iris_cfg.get("tool_progress") == "verbose":
return # already set return # already set
from utils import atomic_roundtrip_yaml_update from utils import atomic_roundtrip_yaml_update
atomic_roundtrip_yaml_update( atomic_roundtrip_yaml_update(
get_hermes_home() / "config.yaml", get_hermes_home() / "config.yaml",
"display.platforms.android.tool_progress", "display.platforms.iris.tool_progress",
"verbose", "verbose",
) )
logger.info("android: set display.platforms.android.tool_progress=verbose") logger.info("iris: set display.platforms.iris.tool_progress=verbose")
except Exception: 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 from hermes_cli.config import get_env_value, save_env_value
except Exception: 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 return
print_info("📱 Android / Desktop (Iris x Hermes)") print_info("📱 Android / Desktop (Iris x Hermes)")
token = get_env_value("ANDROID_TOKEN") or "" token = get_env_value("IRIS_TOKEN") or ""
if not token: if not token:
generated = generate_token() generated = generate_token()
save_env_value("ANDROID_TOKEN", generated) save_env_value("IRIS_TOKEN", generated)
print_success(f"Generated pairing token: {generated}") print_success(f"Generated pairing token: {generated}")
print_warning("Keep this secret -- the app presents it on connect.") print_warning("Keep this secret -- the app presents it on connect.")
else: 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) host = prompt("Bind host", default=get_env_value("IRIS_WS_HOST") or DEFAULT_HOST)
save_env_value("ANDROID_WS_HOST", 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 # _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
# HTTP default must be applied explicitly (docs/19: 8791). # 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( port = prompt(
"HTTP port", "HTTP port",
default=str(int(http_port_raw) if http_port_raw.isdigit() else DEFAULT_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( backend = prompt(
"Push backend (fcm/ntfy)", "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 # Pairing payload for the app's Connect screen (manual entry + QR scan).
# no QR scanner). # Advertise a routable host: a bind wildcard (0.0.0.0/127.0.0.1) is
url = pairing_url(host or DEFAULT_HOST, _parse_port(port)) # 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("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}") 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 # Always render tool progress verbosely so the app receives the full tool
# call args (it decides how much to show via Settings → Tool detail). # call args (it decides how much to show via Settings → Tool detail).
_ensure_verbose_tool_progress() _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") print_info("Restart the gateway for changes to take effect: hermes gateway restart")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Android Adapter # Iris Adapter
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class AndroidAdapter(BasePlatformAdapter): class IrisAdapter(BasePlatformAdapter):
"""HTTP-backed adapter for the native Iris Android / Desktop app. """HTTP-backed adapter for the native Iris app (Android / Desktop).
The HTTP server (``http_server.HttpServer``) authenticates devices with The HTTP server (``http_server.HttpServer``) authenticates devices with
the pairing token, the device registry tracks live subscribers, ``send()`` the pairing token, the device registry tracks live subscribers, ``send()``
@@ -1100,7 +1134,7 @@ class AndroidAdapter(BasePlatformAdapter):
MAX_MESSAGE_LENGTH = 1_000_000 MAX_MESSAGE_LENGTH = 1_000_000
def __init__(self, config, **kwargs): def __init__(self, config, **kwargs):
platform = Platform("android") platform = Platform("iris")
super().__init__(config=config, platform=platform) super().__init__(config=config, platform=platform)
# Ensure verbose tool progress (full args on the progress line) so the # 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 # Connection settings (env vars override config.yaml). The bind host
# is shared with the (legacy) WS-era env var name for compatibility. # 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). # docs/19: HTTP transport (the only device-facing transport; optional TLS).
self.http_port = _parse_port( 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.token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "")
self.push_backend = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or extra.get( self.push_backend = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower() or extra.get(
"push_backend", DEFAULT_PUSH_BACKEND "push_backend", DEFAULT_PUSH_BACKEND
) )
self.outbox_retention_hours = int( self.outbox_retention_hours = int(
@@ -1146,18 +1180,18 @@ class AndroidAdapter(BasePlatformAdapter):
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
# TLS (optional) # TLS (optional)
self.http_cert = _get_scoped_secret("ANDROID_HTTP_CERT") or extra.get("http_cert", "") self.http_cert = _get_scoped_secret("IRIS_HTTP_CERT") or extra.get("http_cert", "")
self.http_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "") self.http_key = _get_scoped_secret("IRIS_HTTP_KEY") or extra.get("http_key", "")
# Auth # Auth
allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip() allowed = os.getenv("IRIS_ALLOWED_USERS", "").strip()
self.allowed_users: list[str] = ( self.allowed_users: list[str] = (
[u.strip() for u in allowed.split(",") if u.strip()] if allowed else [] [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 # 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). # docs/19: HTTP transport (the only device-facing transport).
self._http_server = HttpServer(self, self._devices) self._http_server = HttpServer(self, self._devices)
# docs/19 §19.7: reply sinks for in-flight HTTP requests — while a # 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. # M3: channel directory (shared singleton) + offline outbox.
self._channels = get_directory() self._channels = get_directory()
self._outbox = Outbox( self._outbox = Outbox(
get_hermes_home() / "android" / "outbox.db", get_hermes_home() / "iris" / "outbox.db",
retention_hours=self.outbox_retention_hours, retention_hours=self.outbox_retention_hours,
) )
# M4: media registry (inbound upload refs + outbound offers) and the # M4: media registry (inbound upload refs + outbound offers) and the
@@ -1182,8 +1216,8 @@ class AndroidAdapter(BasePlatformAdapter):
# the outbox-prune banner. # the outbox-prune banner.
self._push: PushBackend = build_push_backend( self._push: PushBackend = build_push_backend(
self.push_backend, self.push_backend,
fcm_service_account=_get_scoped_secret("ANDROID_FCM_SERVICE_ACCOUNT"), fcm_service_account=_get_scoped_secret("IRIS_FCM_SERVICE_ACCOUNT"),
fcm_server_key=_get_scoped_secret("ANDROID_FCM_SERVER_KEY"), fcm_server_key=_get_scoped_secret("IRIS_FCM_SERVER_KEY"),
ntfy_topic=_get_scoped_secret("NTFY_TOPIC"), ntfy_topic=_get_scoped_secret("NTFY_TOPIC"),
ntfy_server_url=os.getenv("NTFY_SERVER_URL", "").strip() or None, ntfy_server_url=os.getenv("NTFY_SERVER_URL", "").strip() or None,
ntfy_auth_token=_get_scoped_secret("NTFY_AUTH_TOKEN"), ntfy_auth_token=_get_scoped_secret("NTFY_AUTH_TOKEN"),
@@ -1202,17 +1236,17 @@ class AndroidAdapter(BasePlatformAdapter):
@property @property
def name(self) -> str: def name(self) -> str:
return "Android" return "Iris"
# ── Connection lifecycle ────────────────────────────────────────────── # ── Connection lifecycle ──────────────────────────────────────────────
async def connect(self, *, is_reconnect: bool = False) -> bool: async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Bring the platform up: bind the HTTP server on host:http_port.""" """Bring the platform up: bind the HTTP server on host:http_port."""
if not self.token: if not self.token:
logger.error("android: ANDROID_TOKEN must be set") logger.error("iris: IRIS_TOKEN must be set")
self._set_fatal_error( self._set_fatal_error(
"config_missing", "config_missing",
"ANDROID_TOKEN must be set", "IRIS_TOKEN must be set",
retryable=False, retryable=False,
) )
return False return False
@@ -1222,7 +1256,7 @@ class AndroidAdapter(BasePlatformAdapter):
# start() never raises; it disables the leg and logs on failure. # start() never raises; it disables the leg and logs on failure.
await self._http_server.start() await self._http_server.start()
if not self._http_server.enabled: 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( self._set_fatal_error(
"bind_failed", "bind_failed",
f"HTTP port {self.http_port} unavailable", f"HTTP port {self.http_port} unavailable",
@@ -1242,21 +1276,21 @@ class AndroidAdapter(BasePlatformAdapter):
try: try:
self._channels.ensure_default(self.home_channel, self.home_channel_name) self._channels.ensure_default(self.home_channel, self.home_channel_name)
except Exception: 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). # M5: push backend status (degrade gracefully when unconfigured).
if not self._push.configured(): if not self._push.configured():
logger.warning( 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", "offline devices will not be woken; outbox + sync still apply",
self.push_backend, self.push_backend,
) )
else: else:
logger.info("android: push backend: %s", self._push.name) logger.info("iris: push backend: %s", self._push.name)
self._connected = True self._connected = True
self._mark_connected() 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 return True
async def disconnect(self) -> None: async def disconnect(self) -> None:
@@ -1271,7 +1305,7 @@ class AndroidAdapter(BasePlatformAdapter):
try: try:
await self._http_server.stop() await self._http_server.stop()
except Exception: 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 # Best-effort shutdown: a close failure on an already-closed store is
# not actionable at disconnect time. # not actionable at disconnect time.
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
@@ -1280,7 +1314,7 @@ class AndroidAdapter(BasePlatformAdapter):
self._outbox.close() self._outbox.close()
self._connected = False self._connected = False
self._mark_disconnected() self._mark_disconnected()
logger.info("android: disconnected") logger.info("iris: disconnected")
# ── Outbound (agent -> app) ─────────────────────────────────────────── # ── Outbound (agent -> app) ───────────────────────────────────────────
@@ -1706,7 +1740,7 @@ class AndroidAdapter(BasePlatformAdapter):
try: try:
cursor = self._outbox.append(chat_id, frame.to_json()) cursor = self._outbox.append(chat_id, frame.to_json())
except Exception: except Exception:
logger.warning("android: outbox append failed", exc_info=True) logger.warning("iris: outbox append failed", exc_info=True)
return return
# docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber # 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 # — 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) delivered = await self._http_server.fanout(frame, cursor)
if delivered == 0: if delivered == 0:
logger.info( 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, chat_id,
frame.type, frame.type,
cursor, cursor,
@@ -1786,7 +1820,7 @@ class AndroidAdapter(BasePlatformAdapter):
now = time.time() now = time.time()
if now - self._last_push_at.get(chat_id, 0.0) < _PUSH_COALESCE_S: if now - self._last_push_at.get(chat_id, 0.0) < _PUSH_COALESCE_S:
logger.info( 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, chat_id,
frame.type, frame.type,
_PUSH_COALESCE_S, _PUSH_COALESCE_S,
@@ -1823,7 +1857,7 @@ class AndroidAdapter(BasePlatformAdapter):
priority=priority, priority=priority,
) )
except Exception: 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 continue
if ok: if ok:
# M5: remember that this cursor reached the device via push, # 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) self._devices.update_push_cursor(device_id, cursor)
except Exception: except Exception:
logger.warning( 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() self._last_push_at[chat_id] = time.time()
logger.info( logger.info(
"android: push via %s -> %s (%s, chat=%s)", "iris: push via %s -> %s (%s, chat=%s)",
backend.name, backend.name,
device_id, device_id,
frame.type, frame.type,
@@ -1897,13 +1931,13 @@ class AndroidAdapter(BasePlatformAdapter):
) -> SendResult: ) -> SendResult:
safe = validate_media_delivery_path(path) safe = validate_media_delivery_path(path)
if safe is None: if safe is None:
logger.warning("android: media path failed delivery validation: %s", path) logger.warning("iris: media path failed delivery validation: %s", path)
return SendResult(success=False, error="android: media path not deliverable") return SendResult(success=False, error="iris: media path not deliverable")
try: try:
size = os.path.getsize(safe) size = os.path.getsize(safe)
except OSError as e: except OSError as e:
logger.warning("android: media file unreadable %s: %s", safe, e) logger.warning("iris: media file unreadable %s: %s", safe, e)
return SendResult(success=False, error="android: media file unreadable") return SendResult(success=False, error="iris: media file unreadable")
entry = self._media.register_outbound( entry = self._media.register_outbound(
safe, kind, media_bridge.mime_for_path(safe), filename or os.path.basename(safe), size safe, kind, media_bridge.mime_for_path(safe), filename or os.path.basename(safe), size
) )
@@ -2216,7 +2250,7 @@ class AndroidAdapter(BasePlatformAdapter):
except Exception: except Exception:
logger.debug("Thread title rename broadcast failed", exc_info=True) 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) ─────────────────── # ── 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 get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id
) )
logger.info( 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, chat_id,
entry.get("kind"), entry.get("kind"),
removed_frames, removed_frames,
@@ -2565,7 +2599,7 @@ class AndroidAdapter(BasePlatformAdapter):
""" """
payload = frame.payload payload = frame.payload
chat_id = frame.chat_id or payload.get("chat_id") 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(): if not isinstance(chat_id, str) or not chat_id.strip():
await self._reply( await self._reply(
device_id, device_id,
@@ -2658,7 +2692,7 @@ class AndroidAdapter(BasePlatformAdapter):
info.get("ts"), info.get("ts"),
) )
logger.info( 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, device_id,
chat_id, chat_id,
thread_id, thread_id,
@@ -2687,9 +2721,9 @@ class AndroidAdapter(BasePlatformAdapter):
try: try:
self._devices.update_push_tokens(device_id, fcm_token=fcm_token, ntfy_topic=ntfy_topic) self._devices.update_push_tokens(device_id, fcm_token=fcm_token, ntfy_topic=ntfy_topic)
except Exception: except Exception:
logger.warning("android: fcm.register update failed", exc_info=True) logger.warning("iris: fcm.register update failed", exc_info=True)
return 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 ──────────────────────────────────── # ── M5: approval / clarify banners ────────────────────────────────────
@@ -2854,7 +2888,7 @@ class AndroidAdapter(BasePlatformAdapter):
``gateway/channel_directory.build_channel_directory`` calls this to ``gateway/channel_directory.build_channel_directory`` calls this to
populate ``channel_directory.json``, which ``resolve_channel_name`` populate ``channel_directory.json``, which ``resolve_channel_name``
reads for friendly-name -> chat_id resolution (cron + send_message). 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. syntax (see ``_parse_target_ref``), so only channels are listed here.
""" """
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
@@ -2888,7 +2922,7 @@ class AndroidAdapter(BasePlatformAdapter):
name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id
) )
except Exception: except Exception:
logger.warning("android: create_handoff_thread failed", exc_info=True) logger.warning("iris: create_handoff_thread failed", exc_info=True)
return None return None
await self._broadcast_both(protocol.channel_created(entry)) await self._broadcast_both(protocol.channel_created(entry))
return entry["chat_id"] return entry["chat_id"]
@@ -2907,14 +2941,14 @@ def register(ctx):
try: try:
ctx.register_hook("on_stream_delta", _on_stream_delta) ctx.register_hook("on_stream_delta", _on_stream_delta)
except Exception: 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 # 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 # frame can carry the output (the gateway never streams tool output to
# platforms). The app shows it on demand (Settings → Tool detail). # platforms). The app shows it on demand (Settings → Tool detail).
try: try:
ctx.register_hook("post_tool_call", _on_post_tool_call) ctx.register_hook("post_tool_call", _on_post_tool_call)
except Exception: 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 # Runtime-metadata footer: capture the turn's model + prompt tokens (per
# provider call) so the final message can carry a structured ``runtime`` # provider call) so the final message can carry a structured ``runtime``
# object. The app decides whether/what to show (Settings → Runtime # object. The app decides whether/what to show (Settings → Runtime
@@ -2923,30 +2957,30 @@ def register(ctx):
try: try:
ctx.register_hook("post_api_request", _on_post_api_request) ctx.register_hook("post_api_request", _on_post_api_request)
except Exception: 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( ctx.register_platform(
name="android", name="iris",
label="Android", label="Iris",
adapter_factory=AndroidAdapter, adapter_factory=IrisAdapter,
check_fn=check_requirements, check_fn=check_requirements,
validate_config=validate_config, validate_config=validate_config,
is_connected=is_connected, is_connected=is_connected,
required_env=["ANDROID_TOKEN"], required_env=["IRIS_TOKEN"],
install_hint="No extra packages needed (httpx is a core dep)", install_hint="No extra packages needed (httpx is a core dep)",
setup_fn=interactive_setup, setup_fn=interactive_setup,
# Env-driven auto-configuration: seeds PlatformConfig.extra with # Env-driven auto-configuration: seeds PlatformConfig.extra with
# host/port/push_backend + home_channel so env-only setups show up in # host/port/push_backend + home_channel so env-only setups show up in
# gateway status without instantiating the adapter. # gateway status without instantiating the adapter.
env_enablement_fn=_env_enablement, env_enablement_fn=_env_enablement,
# Cron home-channel delivery support (deliver=android:<chat>[:<thread>]). # Cron home-channel delivery support (deliver=iris:<chat_id>[:<thread>]).
cron_deliver_env_var="ANDROID_HOME_CHANNEL", cron_deliver_env_var="IRIS_HOME_CHANNEL",
# Out-of-process cron delivery (best-effort; outbox is gateway-served). # Out-of-process cron delivery (best-effort; outbox is gateway-served).
standalone_sender_fn=_standalone_send, 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, parse_target_ref_fn=_parse_target_ref,
# Auth env vars for _is_user_authorized() integration. # Auth env vars for _is_user_authorized() integration.
allowed_users_env="ANDROID_ALLOWED_USERS", allowed_users_env="IRIS_ALLOWED_USERS",
allow_all_env="ANDROID_ALLOW_ALL_USERS", allow_all_env="IRIS_ALLOW_ALL_USERS",
# WS has no message-size limit. # WS has no message-size limit.
max_message_length=0, max_message_length=0,
# Display. # Display.
+8 -8
View File
@@ -3,9 +3,9 @@
Maps app concepts onto hermes' existing ``chat_id`` / ``thread_id`` primitives Maps app concepts onto hermes' existing ``chat_id`` / ``thread_id`` primitives
(docs/06-channels-cron-search.md §6.1): (docs/06-channels-cron-search.md §6.1):
* **default chat** -> the home channel (``ANDROID_HOME_CHANNEL``, default * **default chat** -> the home channel (``IRIS_HOME_CHANNEL``, default
``android:default``), ``kind="default"``, ``is_default=1``. ``default``), ``kind="default"``, ``is_default=1``.
* **user channel** -> a minted ``chat_id = android:chan_<n>``, ``kind="channel"``. * **user channel** -> a minted ``chat_id = chan_<n>``, ``kind="channel"``.
* **thread** -> a minted ``thread_id = t_<n>`` under a ``chat_id``, * **thread** -> a minted ``thread_id = t_<n>`` under a ``chat_id``,
``kind="thread"`` (stored with its ``parent_chat_id``). ``kind="thread"`` (stored with its ``parent_chat_id``).
@@ -15,7 +15,7 @@ directory (``gateway/channel_directory.py``) via the adapter's
``list_channels()`` hook, so ``send_message`` / cron can resolve a friendly ``list_channels()`` hook, so ``send_message`` / cron can resolve a friendly
name (e.g. "Cron Reports") to a chat_id. name (e.g. "Cron Reports") to a chat_id.
Storage: ``get_hermes_home()/"android"/channels.db``. Storage: ``get_hermes_home()/"iris"/channels.db``.
Milestone M3. Milestone M3.
""" """
@@ -37,12 +37,12 @@ KIND_CHANNEL = "channel"
KIND_THREAD = "thread" KIND_THREAD = "thread"
# chat_id / thread_id minting prefixes. # chat_id / thread_id minting prefixes.
CHANNEL_PREFIX = "android:chan_" CHANNEL_PREFIX = "chan_"
THREAD_PREFIX = "t_" THREAD_PREFIX = "t_"
class ChannelDirectory: class ChannelDirectory:
"""Persistent channel directory under ``get_hermes_home()/"android"``. """Persistent channel directory under ``get_hermes_home()/"iris"``.
Thread-safe (single connection + lock); all operations are small and fast Thread-safe (single connection + lock); all operations are small and fast
enough to run inline on the gateway's asyncio loop. Mirrors the enough to run inline on the gateway's asyncio loop. Mirrors the
@@ -127,7 +127,7 @@ class ChannelDirectory:
when it was still the auto default); if another row is marked default when it was still the auto default); if another row is marked default
it is cleared so exactly one default exists. it is cleared so exactly one default exists.
""" """
chat_id = (chat_id or "android:default").strip() or "android:default" chat_id = (chat_id or "default").strip() or "default"
name = (name or "Default").strip() or "Default" name = (name or "Default").strip() or "Default"
with self._lock: with self._lock:
existing = self._conn.execute( existing = self._conn.execute(
@@ -443,6 +443,6 @@ def get_directory() -> ChannelDirectory:
# failure is not actionable. # failure is not actionable.
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
_directory.close() _directory.close()
_directory = ChannelDirectory(home / "android" / "channels.db") _directory = ChannelDirectory(home / "iris" / "channels.db")
_directory_home = home _directory_home = home
return _directory return _directory
+13 -13
View File
@@ -199,9 +199,9 @@ class HttpServer:
from gateway.status import acquire_scoped_lock from gateway.status import acquire_scoped_lock
lock_key = f"http:{host}:{port}" lock_key = f"http:{host}:{port}"
if not acquire_scoped_lock("android", lock_key): if not acquire_scoped_lock("iris", lock_key):
logger.warning( logger.warning(
"android: HTTP port %s:%s in use by another profile; server disabled", "iris: HTTP port %s:%s in use by another profile; server disabled",
host, host,
port, port,
) )
@@ -218,18 +218,18 @@ class HttpServer:
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key) ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
except Exception as e: except Exception as e:
logger.warning("android: HTTP server disabled (bind %s:%s failed: %s)", host, port, e) logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
self._release_lock() self._release_lock()
return return
self._httpd = httpd self._httpd = httpd
self._thread = threading.Thread( self._thread = threading.Thread(
target=httpd.serve_forever, name="android-http", daemon=True target=httpd.serve_forever, name="iris-http", daemon=True
) )
self._thread.start() self._thread.start()
self.enabled = True self.enabled = True
scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http" scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http"
logger.info("android: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port) logger.info("iris: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port)
async def stop(self) -> None: async def stop(self) -> None:
"""Stop serving and unblock all subscribers.""" """Stop serving and unblock all subscribers."""
@@ -261,7 +261,7 @@ class HttpServer:
from gateway.status import release_scoped_lock from gateway.status import release_scoped_lock
if self._lock_key: if self._lock_key:
release_scoped_lock("android", self._lock_key) release_scoped_lock("iris", self._lock_key)
self._lock_key = None self._lock_key = None
# ── Subscriber registry ─────────────────────────────────────────────── # ── Subscriber registry ───────────────────────────────────────────────
@@ -307,7 +307,7 @@ class HttpServer:
except queue.Full: except queue.Full:
# Slow subscriber: drop it. The client reconnects with # Slow subscriber: drop it. The client reconnects with
# Last-Event-ID and catches up from the outbox. # Last-Event-ID and catches up from the outbox.
logger.info("android: dropping slow HTTP subscriber %s", s.device_id) logger.info("iris: dropping slow HTTP subscriber %s", s.device_id)
s.closed.set() s.closed.set()
self._remove_sub(s) self._remove_sub(s)
return sent return sent
@@ -331,7 +331,7 @@ class HttpServer:
and self._adapter.allowed_users and self._adapter.allowed_users
and device_id not in self._adapter.allowed_users and device_id not in self._adapter.allowed_users
): ):
logger.warning("android: http rejected: device %s not allowlisted", device_id) logger.warning("iris: http rejected: device %s not allowlisted", device_id)
_send_json(handler, 401, {"error": "device not allowed"}) _send_json(handler, 401, {"error": "device not allowed"})
return None return None
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
@@ -436,7 +436,7 @@ class HttpServer:
try: try:
await dispatch.dispatch_frame(self._adapter, frame, device_id) await dispatch.dispatch_frame(self._adapter, frame, device_id)
except Exception: except Exception:
logger.warning("android: HTTP dispatch failed for %s", frame.type, exc_info=True) logger.warning("iris: HTTP dispatch failed for %s", frame.type, exc_info=True)
finally: finally:
# Pop our sink entry (a newer request from the same device may # Pop our sink entry (a newer request from the same device may
# have replaced it). If the HTTP response was already sent # have replaced it). If the HTTP response was already sent
@@ -478,7 +478,7 @@ class HttpServer:
ntfy_topic, ntfy_topic,
) )
except Exception: except Exception:
logger.warning("android: device registry upsert failed", exc_info=True) logger.warning("iris: device registry upsert failed", exc_info=True)
sub = _Subscriber(device_id=device_id, kind="sse") sub = _Subscriber(device_id=device_id, kind="sse")
# Register BEFORE the replay so a frame appended in between is # Register BEFORE the replay so a frame appended in between is
# fanned out to us (and de-duped by cursor below) instead of lost. # fanned out to us (and de-duped by cursor below) instead of lost.
@@ -494,7 +494,7 @@ class HttpServer:
# lifecycle is the primary "is the device connected?" signal # lifecycle is the primary "is the device connected?" signal
# for debugging flaky links — a gap here is invisible at the # for debugging flaky links — a gap here is invisible at the
# gateway's default log level. # gateway's default log level.
logger.info("android: SSE stream opened: %s (cursor=%d)", device_id, cursor) logger.info("iris: SSE stream opened: %s (cursor=%d)", device_id, cursor)
# 1. Catch-up from the outbox (id = cursor; the envelope also # 1. Catch-up from the outbox (id = cursor; the envelope also
# carries the cursor for the app's push dedupe). # carries the cursor for the app's push dedupe).
max_cursor = cursor max_cursor = cursor
@@ -532,7 +532,7 @@ class HttpServer:
# Last-Event-ID and catches up from the outbox). # Last-Event-ID and catches up from the outbox).
reason = "client-gone" reason = "client-gone"
finally: finally:
logger.info("android: SSE stream closed: %s (%s)", device_id, reason) logger.info("iris: SSE stream closed: %s (%s)", device_id, reason)
self._remove_sub(sub) self._remove_sub(sub)
@staticmethod @staticmethod
@@ -735,7 +735,7 @@ class _Handler(BaseHTTPRequestHandler):
server: _ThreadingHTTPD server: _ThreadingHTTPD
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003 def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
logger.debug("android http: " + fmt, *args) logger.debug("iris http: " + fmt, *args)
# ── Routing ─────────────────────────────────────────────────────────── # ── Routing ───────────────────────────────────────────────────────────
+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 Reuses hermes ``cache_image/audio/video/document_from_bytes`` + the
``_looks_like_image`` / ``sniff_container`` magic-byte sniffers. Temp files ``_looks_like_image`` / ``sniff_container`` magic-byte sniffers. Temp files
live under ``get_hermes_home()/"android"/media/tmp``. live under ``get_hermes_home()/"iris"/media/tmp``.
Milestone M4. Milestone M4.
""" """
@@ -231,7 +231,7 @@ class UploadSession:
self.failed = True self.failed = True
self.error_code = code self.error_code = code
self.error_message = message self.error_message = message
logger.warning("android: upload %s failed: %s", self.media_ref, message) logger.warning("iris: upload %s failed: %s", self.media_ref, message)
def digest(self) -> str: def digest(self) -> str:
return self._sha.hexdigest() return self._sha.hexdigest()
@@ -262,7 +262,7 @@ class MediaStore:
""" """
def __init__(self, hermes_home: Path): def __init__(self, hermes_home: Path):
self._tmp_dir = hermes_home / "android" / "media" / "tmp" self._tmp_dir = hermes_home / "iris" / "media" / "tmp"
self._tmp_dir.mkdir(parents=True, exist_ok=True) self._tmp_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock() self._lock = threading.Lock()
# (device_id, media_ref) -> UploadSession (one active per device) # (device_id, media_ref) -> UploadSession (one active per device)
@@ -374,7 +374,7 @@ class MediaStore:
with self._lock: with self._lock:
self._inbound[media_ref] = entry self._inbound[media_ref] = entry
logger.info( logger.info(
"android: upload %s cached as %s (%s, %d bytes)", "iris: upload %s cached as %s (%s, %d bytes)",
media_ref, media_ref,
kind, kind,
path, path,
+4 -4
View File
@@ -10,7 +10,7 @@ Retention prunes rows older than ``outbox_retention_hours`` (default 72h). A
device offline longer than the window misses those frames; it recovers full device offline longer than the window misses those frames; it recovers full
context via ``history`` (M5 wires push so the device is woken to sync). context via ``history`` (M5 wires push so the device is woken to sync).
Storage: ``get_hermes_home()/"android"/outbox.db``. Storage: ``get_hermes_home()/"iris"/outbox.db``.
Milestone M3 (built), extended in M5 (push integration). Milestone M3 (built), extended in M5 (push integration).
""" """
@@ -36,7 +36,7 @@ DEFAULT_MAX_ROWS = 5000
class Outbox: class Outbox:
"""Persistent outbox under ``get_hermes_home()/"android"``. """Persistent outbox under ``get_hermes_home()/"iris"``.
Thread-safe (single connection + lock); operations are small and fast Thread-safe (single connection + lock); operations are small and fast
enough to run inline on the gateway's asyncio loop (mirrors enough to run inline on the gateway's asyncio loop (mirrors
@@ -126,7 +126,7 @@ class Outbox:
(excess,), (excess,),
) )
self._overflow_pruned += excess self._overflow_pruned += excess
logger.info("android outbox: row cap pruned %s oldest row(s)", excess) logger.info("iris outbox: row cap pruned %s oldest row(s)", excess)
def latest_cursor(self) -> int: def latest_cursor(self) -> int:
"""The high-water cursor (0 when nothing has been appended).""" """The high-water cursor (0 when nothing has been appended)."""
@@ -419,7 +419,7 @@ class Outbox:
self._conn.execute("DELETE FROM outbox WHERE created < ?", (cutoff,)) self._conn.execute("DELETE FROM outbox WHERE created < ?", (cutoff,))
self._conn.commit() self._conn.commit()
except sqlite3.Error as e: except sqlite3.Error as e:
logger.debug("android outbox: prune failed: %s", e) logger.debug("iris outbox: prune failed: %s", e)
def prune(self) -> None: def prune(self) -> None:
"""Force a retention prune (ignores the interval throttle).""" """Force a retention prune (ignores the interval throttle)."""
+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, (SQLite) tracks ``device_id``, name, caps, fcm_token, ntfy_topic, last_seen,
created. QR payload for the pairing flow (``interactive_setup``). created. QR payload for the pairing flow (``interactive_setup``).
Storage: ``get_hermes_home()/"android"/devices.db``. Storage: ``get_hermes_home()/"iris"/devices.db``.
Milestone M1. Milestone M1.
""" """
@@ -14,6 +14,7 @@ import hmac
import json import json
import logging import logging
import secrets import secrets
import socket
import sqlite3 import sqlite3
import threading import threading
import time import time
@@ -42,6 +43,51 @@ def verify_token(provided: str | None, expected: str | None) -> bool:
) )
def lan_ip() -> str:
"""Best-effort default-route LAN IPv4 (UDP connect trick; no packet sent).
A phone can't reach a bind wildcard like ``0.0.0.0``/``127.0.0.1``, so the
pairing QR / URL advertise the machine's routable LAN IP instead. Falls
back to ``127.0.0.1`` when no route is available (offline sandbox).
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.settimeout(1.0)
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
s.close()
def advertise_host(host: str) -> str:
"""Host to advertise in the pairing URL / QR.
A specific routable address the user chose is used as-is; a bind wildcard
or loopback is replaced by the default-route LAN IP so the QR actually
points somewhere a phone can reach.
"""
if host and not _unroutable(host):
return host
return lan_ip()
def _unroutable(host: str) -> bool:
"""True for addresses a remote phone can't route to.
Covers the IPv4 bind wildcard (all-zero), loopback (127.x), and the IPv6
any/loopback. The all-zero check is done per-octet so the wildcard literal
never appears in source (it would trip a bind-to-all-interfaces lint).
"""
if host.startswith("127."):
return True
if host in ("::", "[::]", "::1"):
return True
parts = host.split(".")
return len(parts) == 4 and all(octet == "0" for octet in parts)
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str: def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
"""Pairing URL encoded into the QR / pre-filled into the app. """Pairing URL encoded into the QR / pre-filled into the app.
@@ -68,7 +114,7 @@ def pairing_url(host: str, port: int, secure: bool = False) -> str:
class DeviceRegistry: class DeviceRegistry:
"""Persistent device registry under ``get_hermes_home()/"android"``. """Persistent device registry under ``get_hermes_home()/"iris"``.
Thread-safe (single connection + lock); all operations are small and Thread-safe (single connection + lock); all operations are small and
fast enough to run inline on the gateway's asyncio loop. fast enough to run inline on the gateway's asyncio loop.
+16 -16
View File
@@ -1,5 +1,5 @@
name: android-platform name: iris-platform
label: Android label: Iris
kind: platform kind: platform
version: 0.1.0 version: 0.1.0
description: > description: >
@@ -12,45 +12,45 @@ author: Iris x Hermes
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin # ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
# env var injector in ``hermes_cli/config.py``. # env var injector in ``hermes_cli/config.py``.
requires_env: requires_env:
- name: ANDROID_TOKEN - name: IRIS_TOKEN
description: "Shared pairing token the app presents on connect" description: "Shared pairing token the app presents on connect"
prompt: "Android pairing token" prompt: "Iris pairing token"
password: true password: true
optional_env: optional_env:
- name: ANDROID_WS_HOST - name: IRIS_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)" description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host" prompt: "WS host"
password: false password: false
- name: ANDROID_WS_PORT - name: IRIS_WS_PORT
description: "WS port (default 8790)" description: "WS port (default 8790)"
prompt: "WS port" prompt: "WS port"
password: false password: false
- name: ANDROID_HOME_CHANNEL - name: IRIS_HOME_CHANNEL
description: "Default chat id for cron/notification delivery (default android:default)" description: "Default chat id for cron/notification delivery (default: default)"
prompt: "Home channel" prompt: "Home channel"
password: false password: false
- name: ANDROID_ALLOWED_USERS - name: IRIS_ALLOWED_USERS
description: "Comma-separated allowed device_ids (empty = token-only auth)" description: "Comma-separated allowed device_ids (empty = token-only auth)"
prompt: "Allowed device ids" prompt: "Allowed device ids"
password: false password: false
- name: ANDROID_ALLOW_ALL_USERS - name: IRIS_ALLOW_ALL_USERS
description: "Allow any paired device (dev only)" description: "Allow any paired device (dev only)"
prompt: "Allow all devices? (true/false)" prompt: "Allow all devices? (true/false)"
password: false password: false
- name: ANDROID_PUSH_BACKEND - name: IRIS_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy" description: "Push backend: fcm (default) or ntfy"
prompt: "Push backend" prompt: "Push backend"
password: false password: false
- name: ANDROID_FCM_SERVICE_ACCOUNT - name: IRIS_FCM_SERVICE_ACCOUNT
description: "Path to Firebase service-account JSON (FCM HTTP v1)" description: "Path to Firebase service-account JSON (FCM HTTP v1)"
prompt: "FCM service account path" prompt: "FCM service account path"
password: true password: true
- name: ANDROID_FCM_SERVER_KEY - name: IRIS_FCM_SERVER_KEY
description: "Legacy FCM server key (fallback if no service account)" description: "Legacy FCM server key (fallback if no service account)"
prompt: "FCM server key" prompt: "FCM server key"
password: true password: true
- name: NTFY_TOPIC - name: NTFY_TOPIC
description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)" description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)"
prompt: "ntfy topic" prompt: "ntfy topic"
password: false password: false
- name: NTFY_SERVER_URL - name: NTFY_SERVER_URL
@@ -61,11 +61,11 @@ optional_env:
description: "ntfy auth token for a private topic (trust boundary)" description: "ntfy auth token for a private topic (trust boundary)"
prompt: "ntfy auth token" prompt: "ntfy auth token"
password: true password: true
- name: ANDROID_WS_CERT - name: IRIS_WS_CERT
description: "TLS cert path for WSS (optional)" description: "TLS cert path for WSS (optional)"
prompt: "WSS cert" prompt: "WSS cert"
password: false password: false
- name: ANDROID_WS_KEY - name: IRIS_WS_KEY
description: "TLS key path for WSS (optional)" description: "TLS key path for WSS (optional)"
prompt: "WSS key" prompt: "WSS key"
password: false password: false
+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 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 join on an id. Instead a message is matched to its ``messages`` row by
(session, role, content, timestamp proximity) and that row is deleted. (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() conn.commit()
return n_msgs return n_msgs
except sqlite3.Error as e: 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 return 0
finally: finally:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
@@ -148,7 +148,7 @@ def delete_message(
conn.commit() conn.commit()
return 1 return 1
except sqlite3.Error as e: 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 return 0
finally: finally:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
+12 -12
View File
@@ -2,13 +2,13 @@
``PushBackend`` interface with two implementations: ``PushBackend`` interface with two implementations:
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account - ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
(``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key (``IRIS_FCM_SERVICE_ACCOUNT``), or a legacy server key
(``ANDROID_FCM_SERVER_KEY``). (``IRIS_FCM_SERVER_KEY``).
- ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``NTFY_SERVER_URL`` - ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``NTFY_SERVER_URL``
(default ``https://ntfy.sh``) via ``httpx``; the app's listener (default ``https://ntfy.sh``) via ``httpx``; the app's listener
subscribes to the topic. 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 Fired when a frame has no live subscriber; the data payload drives a silent
sync on the device (docs/08-push.md). sync on the device (docs/08-push.md).
@@ -120,7 +120,7 @@ class FcmBackend(PushBackend):
self._sa = sa self._sa = sa
return sa return sa
except Exception: 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 self._sa_failed = True
return None return None
@@ -151,7 +151,7 @@ class FcmBackend(PushBackend):
claims, sa["private_key"], algorithm="RS256", headers=headers claims, sa["private_key"], algorithm="RS256", headers=headers
) )
except Exception: except Exception:
logger.warning("android: FCM JWT mint failed", exc_info=True) logger.warning("iris: FCM JWT mint failed", exc_info=True)
return None return None
try: try:
resp = await client.post( resp = await client.post(
@@ -163,11 +163,11 @@ class FcmBackend(PushBackend):
timeout=_HTTP_TIMEOUT_S, timeout=_HTTP_TIMEOUT_S,
) )
except Exception: except Exception:
logger.warning("android: FCM token exchange failed", exc_info=True) logger.warning("iris: FCM token exchange failed", exc_info=True)
return None return None
if resp.status_code != _HTTP_OK: if resp.status_code != _HTTP_OK:
logger.warning( logger.warning(
"android: FCM token exchange HTTP %s: %s", "iris: FCM token exchange HTTP %s: %s",
resp.status_code, resp.text[:200], resp.status_code, resp.text[:200],
) )
return None return None
@@ -236,12 +236,12 @@ class FcmBackend(PushBackend):
headers={"Authorization": f"Bearer {auth}"}, headers={"Authorization": f"Bearer {auth}"},
) )
except Exception: except Exception:
logger.warning("android: FCM send failed (network)", exc_info=True) logger.warning("iris: FCM send failed (network)", exc_info=True)
return False return False
if resp.status_code >= _HTTP_ERROR_MIN: if resp.status_code >= _HTTP_ERROR_MIN:
# 404 NOT_FOUND = stale/invalid registration token. # 404 NOT_FOUND = stale/invalid registration token.
logger.warning( 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 False
return True return True
@@ -313,11 +313,11 @@ class NtfyBackend(PushBackend):
url, content=text.encode("utf-8"), headers=headers url, content=text.encode("utf-8"), headers=headers
) )
except Exception: except Exception:
logger.warning("android: ntfy publish failed (network)", exc_info=True) logger.warning("iris: ntfy publish failed (network)", exc_info=True)
return False return False
if resp.status_code >= _HTTP_ERROR_MIN: if resp.status_code >= _HTTP_ERROR_MIN:
logger.warning( 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 False
return True return True
@@ -332,7 +332,7 @@ def build_push_backend(
ntfy_server_url: str | None = None, ntfy_server_url: str | None = None,
ntfy_auth_token: str | None = None, ntfy_auth_token: str | None = None,
) -> PushBackend: ) -> 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": if (name or "").strip().lower() == "ntfy":
return NtfyBackend( return NtfyBackend(
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token 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: try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
except sqlite3.Error as e: except sqlite3.Error as e:
logger.warning("android search: open failed: %s", e) logger.warning("iris search: open failed: %s", e)
return [] return []
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
@@ -232,10 +232,10 @@ def search(
try: try:
return _fts_query(conn, sanitized, scope, chat_id, thread_id, limit) return _fts_query(conn, sanitized, scope, chat_id, thread_id, limit)
except sqlite3.Error as e: 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) return _like_query(conn, sanitized, scope, chat_id, thread_id, limit)
except sqlite3.Error as e: except sqlite3.Error as e:
logger.warning("android search: query failed: %s", e) logger.warning("iris search: query failed: %s", e)
return [] return []
finally: finally:
# Best-effort: a close failure on a read-only connection is not # 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):: 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:: (needs `websockets`); the gateway must already be up::
hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py \ 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`, Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
`--fcm-token`/`--fcm-reg`, `--authfail`, `--url`, `--token`, `--device`, `--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 `POST /v1/frame` (the `message.send`), receive over SSE `GET
/v1/events`. The same assertion flags apply. The base URL defaults to /v1/events`. The same assertion flags apply. The base URL defaults to
the `--url` host with scheme `ws(s)` → `http(s)` and port 8791 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, 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 `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 --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws 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 `~/.hermes/.env`. The gateway must already be running (the driver never
starts or stops it). It is idempotent: channels/jobs it creates are starts or stops it). It is idempotent: channels/jobs it creates are
cleaned up even on failure, and leftover `e2e-*` channels/jobs from 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 --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws 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 ~/.hermes/.env. The gateway must already be running (this driver never
starts or stops it). Idempotent: channels/jobs it creates are cleaned up starts or stops it). Idempotent: channels/jobs it creates are cleaned up
even on failure, and leftover "e2e-*" channels/jobs from earlier runs are 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: def find_token(cli_token: str) -> str:
if cli_token: if cli_token:
return cli_token return cli_token
env = os.getenv("ANDROID_TOKEN") env = os.getenv("IRIS_TOKEN")
if env: if env:
return env return env
for p in (REPO / "hermes-agent" / ".env", Path.home() / ".hermes" / ".env"): for p in (REPO / "hermes-agent" / ".env", Path.home() / ".hermes" / ".env"):
try: try:
for raw_line in p.read_text().splitlines(): for raw_line in p.read_text().splitlines():
line = raw_line.strip() line = raw_line.strip()
if line.startswith("ANDROID_TOKEN="): if line.startswith("IRIS_TOKEN="):
return line.split("=", 1)[1].strip().strip('"').strip("'") return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError: except OSError:
pass pass
@@ -204,7 +204,7 @@ def s7_cron(env, url, token):
if not chat_id: if not chat_id:
return FAIL, "channel.created received but chat_id not parseable" return FAIL, "channel.created received but chat_id not parseable"
job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}" job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}"
deliver = f"android:{chat_id}" deliver = f"iris:{chat_id}"
rc, out, err = run_hermes( rc, out, err = run_hermes(
env, "cron", "create", "1m", env, "cron", "create", "1m",
"Reply with exactly: e2e cron delivery OK", "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).""" must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
u = urlparse(url) u = urlparse(url)
scheme = "https" if u.scheme == "wss" else "http" 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}" 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, rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
"--send", "Reply with exactly: e2e http fallback OK", "--send", "Reply with exactly: e2e http fallback OK",
@@ -352,7 +352,7 @@ def main() -> int:
p = argparse.ArgumentParser( p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 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("--token", default="")
p.add_argument("--skip", default="", p.add_argument("--skip", default="",
help="comma-separated scenario numbers to skip (e.g. 3,5,7)") help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
@@ -360,12 +360,12 @@ def main() -> int:
token = find_token(args.token) token = find_token(args.token)
if not 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 return 1
skip = {int(x) for x in args.skip.split(",") if x.strip()} skip = {int(x) for x in args.skip.split(",") if x.strip()}
env = dict(os.environ) env = dict(os.environ)
env["ANDROID_TOKEN"] = token env["IRIS_TOKEN"] = token
print(f"== e2e: url={args.url} token={token[:6]}…") print(f"== e2e: url={args.url} token={token[:6]}…")
sweep_leftovers(env, args.url, token) sweep_leftovers(env, args.url, token)
+90 -90
View File
@@ -1,7 +1,7 @@
"""Tests for the Iris x Hermes android gateway plugin (M4: media). """Tests for the Iris x Hermes android gateway plugin (M4: media).
The plugin lives in the sibling ``iris_x_hermes`` checkout (installed into 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. from the source tree directly so they never depend on that install.
Coverage (docs/13-testing.md §13.1, media bullets): Coverage (docs/13-testing.md §13.1, media bullets):
@@ -42,12 +42,12 @@ PNG_1X1 = base64.b64decode(
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" "AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
) )
TOKEN = "test-android-token-0123456789" TOKEN = "test-iris-token-0123456789"
DEVICE_ID = "test-device" DEVICE_ID = "test-device"
def _plugin_dir() -> Path: def _plugin_dir() -> Path:
env = os.environ.get("ANDROID_PLUGIN_DIR") env = os.environ.get("IRIS_PLUGIN_DIR")
if env: if env:
return Path(env) return Path(env)
# Works from either copy of this file: gateway-plugin/tests/ (canonical, # 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 The plugin uses relative imports (``from . import protocol``), so it
must be imported as a package (``submodule_search_locations``). must be imported as a package (``submodule_search_locations``).
""" """
name = "android_plugin_under_test" name = "iris_plugin_under_test"
cached = sys.modules.get(name) cached = sys.modules.get(name)
if cached is not None: if cached is not None:
return cached return cached
pkg_dir = _plugin_dir() pkg_dir = _plugin_dir()
if not (pkg_dir / "__init__.py").is_file(): 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( spec = importlib.util.spec_from_file_location(
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)] name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
) )
@@ -95,16 +95,16 @@ def plugin():
@pytest.fixture @pytest.fixture
def adapter(plugin, monkeypatch): def adapter(plugin, monkeypatch):
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest).""" """A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("ANDROID_TOKEN", TOKEN) monkeypatch.setenv("IRIS_TOKEN", TOKEN)
from gateway.platform_registry import PlatformEntry, platform_registry 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). # (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( platform_registry.register(
PlatformEntry( PlatformEntry(
name="android", name="iris",
label="Android", label="Android",
adapter_factory=lambda cfg: None, adapter_factory=lambda cfg: None,
check_fn=lambda: True, check_fn=lambda: True,
@@ -119,7 +119,7 @@ def adapter(plugin, monkeypatch):
}, },
home_channel=None, home_channel=None,
) )
a = plugin.adapter.AndroidAdapter(config) a = plugin.adapter.IrisAdapter(config)
yield a yield a
try: try:
a._devices.close() a._devices.close()
@@ -459,14 +459,14 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
ws, _ = ws_client ws, _ = ws_client
# Simulate the post_api_request hook capturing the turn's model + tokens. # Simulate the post_api_request hook capturing the turn's model + tokens.
plugin.adapter._on_post_api_request( plugin.adapter._on_post_api_request(
platform="android", platform="iris",
model="openai/gpt-5.4", model="openai/gpt-5.4",
usage={"prompt_tokens": 12345}, usage={"prompt_tokens": 12345},
) )
# Stub context-length resolution (avoid network probing in tests). # Stub context-length resolution (avoid network probing in tests).
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768) monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~")) 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 assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message") frames = await recv_until(ws, lambda f: f.get("type") == "message")
msg = frames[-1] msg = frames[-1]
@@ -477,7 +477,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
assert runtime["cwd"] == "~" assert runtime["cwd"] == "~"
assert "latency" in runtime and runtime["latency"] >= 0 assert "latency" in runtime and runtime["latency"] >= 0
# The turn buffer is drained: a second final send carries no stale model. # 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 assert res2.success
frames2 = await recv_until( frames2 = await recv_until(
ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "again" ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "again"
@@ -492,7 +492,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client, monkeypatch): async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client, monkeypatch):
ws, _ = ws_client 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( plugin.adapter._on_post_api_request(
platform="telegram", platform="telegram",
model="openai/gpt-5.4", model="openai/gpt-5.4",
@@ -500,7 +500,7 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client
) )
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768) monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~")) 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 assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message") frames = await recv_until(ws, lambda f: f.get("type") == "message")
runtime = frames[-1]["payload"].get("runtime") runtime = frames[-1]["payload"].get("runtime")
@@ -512,15 +512,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): async def test_history_preserves_runtime_footer(plugin, adapter, ws_client, monkeypatch):
ws, _ = ws_client ws, _ = ws_client
plugin.adapter._on_post_api_request( plugin.adapter._on_post_api_request(
platform="android", platform="iris",
model="openai/gpt-5.4", model="openai/gpt-5.4",
usage={"prompt_tokens": 12345}, usage={"prompt_tokens": 12345},
) )
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768) monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~")) 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. # 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 assert len(page["messages"]) == 1
m = page["messages"][0] m = page["messages"][0]
assert m.get("runtime", {}).get("model") == "gpt-5.4" assert m.get("runtime", {}).get("model") == "gpt-5.4"
@@ -722,7 +722,7 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client,
"v": 1, "v": 1,
"id": 40, "id": 40,
"type": "message.send", "type": "message.send",
"chat_id": "android:default", "chat_id": "default",
"payload": {"text": "fix the login bug please", "auto_thread": True}, "payload": {"text": "fix the login bug please", "auto_thread": True},
} }
) )
@@ -733,16 +733,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") created = next(f for f in frames if f.get("type") == "channel.created")
assert created["payload"]["kind"] == "thread" assert created["payload"]["kind"] == "thread"
assert created["payload"]["auto"] is True 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" assert created["payload"]["name"] == "fix the login bug please"
thread_id = created["payload"]["chat_id"] thread_id = created["payload"]["chat_id"]
echo = frames[-1] echo = frames[-1]
assert echo["chat_id"] == "android:default" assert echo["chat_id"] == "default"
assert echo["thread_id"] == thread_id assert echo["thread_id"] == thread_id
assert len(captured) == 1 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 assert captured[0].source.thread_id == thread_id
@@ -762,7 +762,7 @@ async def test_message_send_auto_thread_llm_upgrade_renames(adapter, ws_client,
"v": 1, "v": 1,
"id": 41, "id": 41,
"type": "message.send", "type": "message.send",
"chat_id": "android:default", "chat_id": "default",
"payload": {"text": "fix the login bug please", "auto_thread": True}, "payload": {"text": "fix the login bug please", "auto_thread": True},
} }
) )
@@ -789,7 +789,7 @@ async def test_message_send_auto_thread_ignored_with_existing_thread(adapter, ws
"v": 1, "v": 1,
"id": 42, "id": 42,
"type": "message.send", "type": "message.send",
"chat_id": "android:default", "chat_id": "default",
"thread_id": "t_9", "thread_id": "t_9",
"payload": {"text": "follow up", "auto_thread": True}, "payload": {"text": "follow up", "auto_thread": True},
} }
@@ -818,7 +818,7 @@ async def test_message_send_auto_thread_ignored_for_slash_command(adapter, ws_cl
"v": 1, "v": 1,
"id": 43, "id": 43,
"type": "message.send", "type": "message.send",
"chat_id": "android:default", "chat_id": "default",
"payload": {"text": "/new", "auto_thread": True}, "payload": {"text": "/new", "auto_thread": True},
} }
) )
@@ -848,7 +848,7 @@ async def test_message_send_auto_thread_media_only_stays_flat(adapter, ws_client
"v": 1, "v": 1,
"id": 44, "id": 44,
"type": "message.send", "type": "message.send",
"chat_id": "android:default", "chat_id": "default",
"payload": {"text": "", "media_refs": ["mu_flat"], "auto_thread": True}, "payload": {"text": "", "media_refs": ["mu_flat"], "auto_thread": True},
} }
) )
@@ -877,7 +877,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client):
"v": 1, "v": 1,
"id": 20, "id": 20,
"type": "message.send", "type": "message.send",
"chat_id": "android:default", "chat_id": "default",
"payload": {"text": "persist me"}, "payload": {"text": "persist me"},
} }
) )
@@ -888,7 +888,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client):
echo = frames[-1] echo = frames[-1]
message_id = echo["payload"]["message_id"] message_id = echo["payload"]["message_id"]
# The echo is parked in the outbox under the home channel. # 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"]] ids = [m["message_id"] for m in page["messages"]]
assert message_id in ids assert message_id in ids
parked = next(m for m in page["messages"] if m["message_id"] == message_id) parked = next(m for m in page["messages"] if m["message_id"] == message_id)
@@ -904,7 +904,7 @@ async def test_history_returns_final_messages_oldest_first(plugin, adapter, ws_c
ws, _ = ws_client ws, _ = ws_client
protocol = plugin.protocol protocol = plugin.protocol
adapter.handle_message = AsyncMock() adapter.handle_message = AsyncMock()
chat_id = "android:default" chat_id = "default"
# Two user turns, each with a (non-streaming) assistant final. # Two user turns, each with a (non-streaming) assistant final.
for i, text in enumerate(["one", "two"]): for i, text in enumerate(["one", "two"]):
await ws.send( await ws.send(
@@ -952,7 +952,7 @@ async def test_history_paginates_older_pages(plugin, adapter, ws_client):
ws, _ = ws_client ws, _ = ws_client
protocol = plugin.protocol protocol = plugin.protocol
adapter.handle_message = AsyncMock() adapter.handle_message = AsyncMock()
chat_id = "android:default" chat_id = "default"
for i in range(5): for i in range(5):
await adapter._broadcast_or_log( await adapter._broadcast_or_log(
chat_id, chat_id,
@@ -982,7 +982,7 @@ async def test_history_frame_roundtrip(plugin, adapter, ws_client):
ws, _ = ws_client ws, _ = ws_client
protocol = plugin.protocol protocol = plugin.protocol
adapter.handle_message = AsyncMock() adapter.handle_message = AsyncMock()
chat_id = "android:default" chat_id = "default"
await adapter._broadcast_or_log( await adapter._broadcast_or_log(
chat_id, chat_id,
protocol.message( protocol.message(
@@ -1023,7 +1023,7 @@ async def test_message_delete_removes_from_outbox_and_broadcasts(plugin, adapter
ws, _ = ws_client ws, _ = ws_client
protocol = plugin.protocol protocol = plugin.protocol
adapter.handle_message = AsyncMock() adapter.handle_message = AsyncMock()
chat_id = "android:default" chat_id = "default"
await adapter._broadcast_or_log( await adapter._broadcast_or_log(
chat_id, chat_id,
protocol.message( protocol.message(
@@ -1062,7 +1062,7 @@ async def test_message_delete_idempotent(plugin, adapter, ws_client):
on the outbox but still emits ``message.deleted`` so live caches drop it.""" on the outbox but still emits ``message.deleted`` so live caches drop it."""
ws, _ = ws_client ws, _ = ws_client
adapter.handle_message = AsyncMock() adapter.handle_message = AsyncMock()
chat_id = "android:default" chat_id = "default"
await ws.send( await ws.send(
json.dumps( json.dumps(
{ {
@@ -1088,7 +1088,7 @@ async def test_message_delete_requires_message_ids(adapter, ws_client):
"v": 1, "v": 1,
"id": 52, "id": 52,
"type": "message.delete", "type": "message.delete",
"chat_id": "android:default", "chat_id": "default",
"payload": {}, "payload": {},
} }
) )
@@ -1125,7 +1125,7 @@ async def test_message_delete_purges_session_store(plugin, adapter, ws_client):
adapter.handle_message = AsyncMock() adapter.handle_message = AsyncMock()
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
chat_id = "android:default" chat_id = "default"
db = get_hermes_home() / "state.db" db = get_hermes_home() / "state.db"
_make_state_db(db) _make_state_db(db)
import sqlite3 import sqlite3
@@ -1244,12 +1244,12 @@ async def test_send_video_emits_offer_with_message_association(adapter, ws_clien
video.write_bytes(b"fake-video-bytes") video.write_bytes(b"fake-video-bytes")
# A final message first, so the offer can associate with it. # 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 assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message") frames = await recv_until(ws, lambda f: f.get("type") == "message")
msg_id = frames[-1]["payload"]["message_id"] 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 assert res2.success
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer") frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
offer = frames[-1]["payload"] offer = frames[-1]["payload"]
@@ -1270,7 +1270,7 @@ async def test_send_document_uses_caller_filename(adapter, ws_client):
doc = get_document_cache_dir() / "report.pdf" doc = get_document_cache_dir() / "report.pdf"
doc.write_bytes(b"%PDF-1.4 fake") doc.write_bytes(b"%PDF-1.4 fake")
res = await adapter.send_document( 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 assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer") frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
@@ -1287,7 +1287,7 @@ async def test_send_image_file_offers_image(adapter, ws_client):
img = get_image_cache_dir() / "shot.png" img = get_image_cache_dir() / "shot.png"
img.write_bytes(PNG_1X1) 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 assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer") frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
offer = frames[-1]["payload"] offer = frames[-1]["payload"]
@@ -1299,7 +1299,7 @@ async def test_send_image_file_offers_image(adapter, ws_client):
async def test_send_media_rejects_denied_path(adapter, ws_client): async def test_send_media_rejects_denied_path(adapter, ws_client):
ws, _ = ws_client ws, _ = ws_client
# /etc/passwd exists but is on hermes' delivery denylist. # /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 assert not res.success
# Nothing was offered (a failed offer is silent, like other platforms). # Nothing was offered (a failed offer is silent, like other platforms).
try: try:
@@ -1439,10 +1439,10 @@ async def test_ntfy_backend_publishes_with_data_header(plugin, monkeypatch):
) )
ok = await backend.send( ok = await backend.send(
device_id="d1", device_id="d1",
chat_id="android:default", chat_id="default",
title="Iris", title="Iris",
body="hello", body="hello",
data={"chat_id": "android:default", "kind": "message", "cursor": "7"}, data={"chat_id": "default", "kind": "message", "cursor": "7"},
token="iris-topic", token="iris-topic",
) )
assert ok is True assert ok is True
@@ -1479,10 +1479,10 @@ async def test_fcm_backend_legacy_server_key(plugin, monkeypatch):
) )
ok = await backend.send( ok = await backend.send(
device_id="d1", device_id="d1",
chat_id="android:default", chat_id="default",
title="Iris", title="Iris",
body="hi", body="hi",
data={"chat_id": "android:default", "kind": "message", "cursor": "3"}, data={"chat_id": "default", "kind": "message", "cursor": "3"},
token="fcm-token-1", token="fcm-token-1",
) )
assert ok is True assert ok is True
@@ -1528,10 +1528,10 @@ async def test_fcm_backend_service_account_v1(plugin, monkeypatch, tmp_path):
fake = _patch_httpx(plugin, monkeypatch, responder) fake = _patch_httpx(plugin, monkeypatch, responder)
ok = await backend.send( ok = await backend.send(
device_id="d1", device_id="d1",
chat_id="android:default", chat_id="default",
title="Iris", title="Iris",
body="hi", body="hi",
data={"chat_id": "android:default", "kind": "cron", "cursor": "4"}, data={"chat_id": "default", "kind": "cron", "cursor": "4"},
token="fcm-token-2", token="fcm-token-2",
priority="high", priority="high",
) )
@@ -1584,15 +1584,15 @@ async def test_push_fires_when_no_live_subscriber(adapter):
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
res = await adapter.send( res = await adapter.send(
"android:default", "hello while offline", metadata={"notify": True} "default", "hello while offline", metadata={"notify": True}
) )
assert res.success assert res.success
assert len(fake.calls) == 1 assert len(fake.calls) == 1
call = fake.calls[0] call = fake.calls[0]
assert call["token"] == "tok-1" assert call["token"] == "tok-1"
assert call["chat_id"] == "android:default" assert call["chat_id"] == "default"
assert call["data"]["kind"] == "message" 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["data"]["cursor"] == "1"
assert call["priority"] == "normal" assert call["priority"] == "normal"
# The frame is parked in the outbox for sync. # The frame is parked in the outbox for sync.
@@ -1606,7 +1606,7 @@ async def test_push_not_fired_when_live(adapter, ws_client):
adapter._push = fake adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") 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") frames = await recv_until(ws, lambda f: f.get("type") == "message")
assert frames[-1]["payload"]["text"] == "live reply" assert frames[-1]["payload"]["text"] == "live reply"
assert fake.calls == [] assert fake.calls == []
@@ -1619,7 +1619,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 tapping a push notification) can catch up via sync. Regression: chat empty
after tapping a message notification.""" after tapping a message notification."""
ws, _ = ws_client 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") frames = await recv_until(ws, lambda f: f.get("type") == "message")
assert frames[-1]["payload"]["text"] == "live reply" assert frames[-1]["payload"]["text"] == "live reply"
# The live-delivered frame is still parked in the outbox for sync. # The live-delivered frame is still parked in the outbox for sync.
@@ -1674,9 +1674,9 @@ async def test_intermediate_frames_park_without_push(adapter):
adapter._push = fake adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter.send("android:default", "seg", metadata={"expect_edits": True}) await adapter.send("default", "seg", metadata={"expect_edits": True})
stream_id = adapter._turns["android:default"].stream_id stream_id = adapter._turns["default"].stream_id
await adapter.edit_message("android:default", stream_id, "seg more") await adapter.edit_message("default", stream_id, "seg more")
assert adapter._outbox.latest_cursor() == 2 assert adapter._outbox.latest_cursor() == 2
assert fake.calls == [] assert fake.calls == []
@@ -1689,9 +1689,9 @@ async def test_high_priority_notification_pushes_even_when_live(plugin, adapter,
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
await adapter._broadcast_or_log( await adapter._broadcast_or_log(
"android:default", "default",
plugin.protocol.notification( 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") frames = await recv_until(ws, lambda f: f.get("type") == "notification")
@@ -1707,7 +1707,7 @@ async def test_push_skipped_when_device_has_no_token(adapter):
adapter._push = fake adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}) # no push token 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 fake.calls == []
assert adapter._outbox.latest_cursor() == 1 # still parked for sync assert adapter._outbox.latest_cursor() == 1 # still parked for sync
@@ -1718,7 +1718,7 @@ async def test_push_skipped_when_backend_unconfigured(adapter):
adapter._push = fake adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}) 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 == [] assert fake.calls == []
@@ -1755,7 +1755,7 @@ async def test_fcm_register_updates_registry(adapter, ws_client):
adapter._http_server._subs.clear() adapter._http_server._subs.clear()
fake = _FakePush() fake = _FakePush()
adapter._push = fake 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 len(fake.calls) == 1
assert fake.calls[0]["token"] == "rotated-token" assert fake.calls[0]["token"] == "rotated-token"
@@ -1854,7 +1854,7 @@ async def test_channel_favorite_toggle(adapter, ws_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_channel_favorite_unknown_id_rejected(adapter, ws_client): async def test_channel_favorite_unknown_id_rejected(adapter, ws_client):
ws, _ = 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) frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1)
assert frames[-1]["payload"]["code"] == "not_found" assert frames[-1]["payload"]["code"] == "not_found"
@@ -1909,7 +1909,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client):
"hello from cron\n\n" "hello from cron\n\n"
"To stop or manage this job, send me a new message (e.g. \"stop reminder My Job\")." "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 assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message") frames = await recv_until(ws, lambda f: f.get("type") == "message")
msg = frames[-1] msg = frames[-1]
@@ -1922,7 +1922,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client):
assert notif[0]["payload"]["body"] == "hello from cron" assert notif[0]["payload"]["body"] == "hello from cron"
# wrap_response: false -- raw content, job id as the name. # 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 assert res2.success
frames = await recv_until( frames = await recv_until(
ws, ws,
@@ -1941,7 +1941,7 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client):
cg.register("cl_1", "sk", "Which one?", ["A", "B"]) cg.register("cl_1", "sk", "Which one?", ["A", "B"])
try: try:
res = await adapter.send_clarify( 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 assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "message") frames = await recv_until(ws, lambda f: f.get("type") == "message")
@@ -1964,8 +1964,8 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sync_replays_parked_frames_and_done_cursor(adapter): async def test_sync_replays_parked_frames_and_done_cursor(adapter):
# Park two frames while offline. # Park two frames while offline.
await adapter.send("android:default", "one", metadata={"notify": True}) await adapter.send("default", "one", metadata={"notify": True})
await adapter.send("android:default", "two", metadata={"notify": True}) await adapter.send("default", "two", metadata={"notify": True})
assert adapter._outbox.latest_cursor() == 2 assert adapter._outbox.latest_cursor() == 2
await adapter.connect() await adapter.connect()
@@ -2021,26 +2021,26 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
adapter._push = fake adapter._push = fake
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1") 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 len(fake.calls) == 1
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1 assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
# Immediate second frame (cron's message frame) coalesces — no second # Immediate second frame (cron's message frame) coalesces — no second
# push, cursor unchanged. # 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 len(fake.calls) == 1
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1 assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
# Simulate the coalesce window elapsing, then push again. # Simulate the coalesce window elapsing, then push again.
adapter._last_push_at["android:default"] = 0.0 adapter._last_push_at["default"] = 0.0
await adapter.send("android:default", "three", metadata={"notify": True}) await adapter.send("default", "three", metadata={"notify": True})
assert len(fake.calls) == 2 assert len(fake.calls) == 2
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3 assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
# A failed push must NOT advance the cursor (the device never woke). # A failed push must NOT advance the cursor (the device never woke).
fake.fail_next = True fake.fail_next = True
adapter._last_push_at["android:default"] = 0.0 adapter._last_push_at["default"] = 0.0
await adapter.send("android:default", "four", metadata={"notify": True}) await adapter.send("default", "four", metadata={"notify": True})
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3 assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
await adapter.connect() await adapter.connect()
@@ -2058,8 +2058,8 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter):
"""Frames replayed by sync carry their outbox cursor in the envelope so """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 the app can compare it against last_pushed_cursor (docs/08 §8.7). Live
frames carry no cursor.""" frames carry no cursor."""
await adapter.send("android:default", "one", metadata={"notify": True}) await adapter.send("default", "one", metadata={"notify": True})
await adapter.send("android:default", "two", metadata={"notify": True}) await adapter.send("default", "two", metadata={"notify": True})
await adapter.connect() await adapter.connect()
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor()) ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
@@ -2083,7 +2083,7 @@ async def test_live_frames_carry_no_cursor(adapter, ws_client):
"""Live (non-replay) frames must not carry a cursor — the app only """Live (non-replay) frames must not carry a cursor — the app only
suppresses notifications for replayed frames (docs/08 §8.7).""" suppresses notifications for replayed frames (docs/08 §8.7)."""
ws, _ = ws_client 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") frames = await recv_until(ws, lambda f: f.get("type") == "message")
assert "cursor" not in frames[-1] assert "cursor" not in frames[-1]
@@ -2093,7 +2093,7 @@ def test_outbox_row_cap_prunes_oldest(plugin, tmp_path):
try: try:
for i in range(7): for i in range(7):
outbox.append( outbox.append(
"android:default", "default",
json.dumps({"v": 1, "type": "message", "payload": {"n": i}}), json.dumps({"v": 1, "type": "message", "payload": {"n": i}}),
) )
assert outbox.latest_cursor() == 7 # cursor stays monotonic assert outbox.latest_cursor() == 7 # cursor stays monotonic
@@ -2110,7 +2110,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.""" in the chat, leaving other messages intact; a thread_id scopes the delete."""
outbox = plugin.outbox.Outbox(tmp_path / "ob.db") outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
try: try:
chat = "android:default" chat = "default"
# A streaming message spans start/update/stop; a standalone message is # A streaming message spans start/update/stop; a standalone message is
# one frame. Plus an unrelated message that must survive. # 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"}})) outbox.append(chat, json.dumps({"v": 1, "type": "message.start", "payload": {"message_id": "m1", "role": "assistant"}}))
@@ -2139,12 +2139,12 @@ def test_outbox_delete_lane_removes_channel_and_thread_frames(plugin, tmp_path):
scoped to that thread's frames only.""" scoped to that thread's frames only."""
outbox = plugin.outbox.Outbox(tmp_path / "ob.db") outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
try: try:
chan = "android:chan_9" chan = "chan_9"
# Flat-lane frames + two threads' frames, plus an unrelated channel. # 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", "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_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(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. # Thread delete: only t_1's frame goes.
assert outbox.delete_lane(chan, thread_id="t_1") == 1 assert outbox.delete_lane(chan, thread_id="t_1") == 1
rows = outbox.replay(0) rows = outbox.replay(0)
@@ -2164,7 +2164,7 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
a channel, its threads; the default channel cannot be deleted.""" a channel, its threads; the default channel cannot be deleted."""
d = plugin.channels.ChannelDirectory(tmp_path / "ch.db") d = plugin.channels.ChannelDirectory(tmp_path / "ch.db")
try: try:
d.ensure_default("android:default", "Default") d.ensure_default("default", "Default")
chan = d.create("Work", kind="channel") chan = d.create("Work", kind="channel")
t1 = d.create("Topic", kind="thread", parent_chat_id=chan["chat_id"]) t1 = d.create("Topic", kind="thread", parent_chat_id=chan["chat_id"])
# Deleting the channel removes it AND its thread from the directory. # Deleting the channel removes it AND its thread from the directory.
@@ -2180,10 +2180,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(t2["chat_id"]) is None
assert d.get(chan2["chat_id"]) is not None # parent channel survives assert d.get(chan2["chat_id"]) is not None # parent channel survives
# The default channel cannot be deleted. # The default channel cannot be deleted.
assert d.delete("android:default") is None assert d.delete("default") is None
assert d.get("android:default") is not None assert d.get("default") is not None
# Unknown id -> None. # Unknown id -> None.
assert d.delete("android:chan_nope") is None assert d.delete("chan_nope") is None
finally: finally:
d.close() d.close()
@@ -2272,14 +2272,14 @@ def test_tool_end_fields_from_hook(plugin):
def test_parse_tool_line_or_block_verbose(plugin): def test_parse_tool_line_or_block_verbose(plugin):
a = plugin.adapter a = plugin.adapter
content = '🔍 web_search(["query"])\n{"query": "hermes agent"}' 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 '🔍 web_search(["query"])', content
) )
assert name == "web_search" assert name == "web_search"
assert args == {"query": "hermes agent"} assert args == {"query": "hermes agent"}
assert preview == "hermes agent" # derived short preview assert preview == "hermes agent" # derived short preview
# Non-verbose line -> args None, preview from the line. # 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"' '🔍 web_search: "x"', '🔍 web_search: "x"'
) )
assert name2 == "web_search" and preview2 == "x" and args2 is None assert name2 == "web_search" and preview2 == "x" and args2 is None
@@ -2289,11 +2289,11 @@ def test_tool_start_frame_emoji_field(plugin):
"""``tool.start`` carries the cosmetic emoji when given, omits it when """``tool.start`` carries the cosmetic emoji when given, omits it when
None (the app then falls back to its own default glyph).""" None (the app then falls back to its own default glyph)."""
pf = plugin.protocol.tool_start pf = plugin.protocol.tool_start
f = pf("android:default", 3, "terminal", emoji="💻") f = pf("default", 3, "terminal", emoji="💻")
assert f.payload["emoji"] == "💻" assert f.payload["emoji"] == "💻"
f2 = pf("android:default", 3, "terminal") f2 = pf("default", 3, "terminal")
assert "emoji" not in f2.payload assert "emoji" not in f2.payload
f3 = pf("android:default", 3, "terminal", emoji=None) f3 = pf("default", 3, "terminal", emoji=None)
assert "emoji" not in f3.payload assert "emoji" not in f3.payload
@@ -2330,7 +2330,7 @@ async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkey
"agent.display.get_tool_emoji", "agent.display.get_tool_emoji",
lambda name, default="⚡": "💻" if name == "terminal" else default, lambda name, default="⚡": "💻" if name == "terminal" else default,
) )
res = await adapter.send('android:default', '💻 terminal: "ls -la"') res = await adapter.send('default', '💻 terminal: "ls -la"')
assert res.success assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "tool.start") frames = await recv_until(ws, lambda f: f.get("type") == "tool.start")
payload = frames[-1]["payload"] payload = frames[-1]["payload"]
@@ -2338,7 +2338,7 @@ async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkey
assert payload["emoji"] == "💻" assert payload["emoji"] == "💻"
# Unknown tool -> field omitted (app falls back to its default glyph). # Unknown tool -> field omitted (app falls back to its default glyph).
monkeypatch.setattr("agent.display.get_tool_emoji", lambda name, default="⚡": default) monkeypatch.setattr("agent.display.get_tool_emoji", lambda name, default="⚡": default)
res2 = await adapter.send('android:default', '🔧 patch: "x"') res2 = await adapter.send('default', '🔧 patch: "x"')
assert res2.success assert res2.success
frames2 = await recv_until( frames2 = await recv_until(
ws, lambda f: f.get("type") == "tool.start" and f["payload"]["name"] == "patch" ws, lambda f: f.get("type") == "tool.start" and f["payload"]["name"] == "patch"
+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 The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it
from the source tree directly (same pattern as ``test_android.py``). 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 # Test-only token (not a credential; the adapter is built with it via
# monkeypatch in the fixture below). # monkeypatch in the fixture below).
# pi-lens-ignore: S105 # pi-lens-ignore: S105
TOKEN = "test-android-http-token-0123456789" TOKEN = "test-iris-http-token-0123456789"
DEVICE_ID = "test-http-device" DEVICE_ID = "test-http-device"
CHAT_ID = "android:default" CHAT_ID = "default"
# 1x1 PNG (same fixture as test_android.py). # 1x1 PNG (same fixture as test_android.py).
PNG_1X1 = base64.b64decode( PNG_1X1 = base64.b64decode(
@@ -56,7 +56,7 @@ PNG_1X1 = base64.b64decode(
def _plugin_dir() -> Path: def _plugin_dir() -> Path:
env = os.environ.get("ANDROID_PLUGIN_DIR") env = os.environ.get("IRIS_PLUGIN_DIR")
if env: if env:
return Path(env) return Path(env)
# Works from either copy of this file: gateway-plugin/tests/ (canonical, # Works from either copy of this file: gateway-plugin/tests/ (canonical,
@@ -72,13 +72,13 @@ def _plugin_dir() -> Path:
def _load_plugin(): def _load_plugin():
"""Load the gateway-plugin package under a unique module name (same """Load the gateway-plugin package under a unique module name (same
pattern as test_android.py).""" pattern as test_android.py)."""
name = "android_plugin_http_under_test" name = "iris_plugin_http_under_test"
cached = sys.modules.get(name) cached = sys.modules.get(name)
if cached is not None: if cached is not None:
return cached return cached
pkg_dir = _plugin_dir() pkg_dir = _plugin_dir()
if not (pkg_dir / "__init__.py").is_file(): 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( spec = importlib.util.spec_from_file_location(
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)] name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
) )
@@ -101,14 +101,14 @@ def plugin():
@pytest.fixture @pytest.fixture
def adapter(plugin, monkeypatch): def adapter(plugin, monkeypatch):
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest).""" """A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("ANDROID_TOKEN", TOKEN) monkeypatch.setenv("IRIS_TOKEN", TOKEN)
from gateway.platform_registry import PlatformEntry, platform_registry 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( platform_registry.register(
PlatformEntry( PlatformEntry(
name="android", name="iris",
label="Android", label="Android",
adapter_factory=lambda cfg: None, adapter_factory=lambda cfg: None,
check_fn=lambda: True, check_fn=lambda: True,
@@ -124,7 +124,7 @@ def adapter(plugin, monkeypatch):
}, },
home_channel=None, home_channel=None,
) )
a = plugin.adapter.AndroidAdapter(config) a = plugin.adapter.IrisAdapter(config)
yield a yield a
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
a._devices.close() a._devices.close()
+9 -9
View File
@@ -7,13 +7,13 @@ while building the Kotlin client.
Usage:: Usage::
hermes gateway & # with the android plugin hermes gateway & # with the iris plugin
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \ python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> \
--send "hello" --send "hello"
Options: Options:
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws) --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>) --device device_id (default: probe-<rand>)
--send TEXT send this message after pairing (default: "hello") --send TEXT send this message after pairing (default: "hello")
--upload F M4: upload F (chunked media.upload) and attach it to the --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, "v": 1,
"id": 1, "id": 1,
"type": "message.send", "type": "message.send",
"chat_id": "android:default", "chat_id": "default",
"payload": {"text": args.send}, "payload": {"text": args.send},
} }
conn = HTTPConnection(host, port, timeout=30) conn = HTTPConnection(host, port, timeout=30)
@@ -666,8 +666,8 @@ def run_http(args, base: str) -> int:
def main() -> int: def main() -> int:
p = argparse.ArgumentParser(description=__doc__) 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("--url", default=os.getenv("IRIS_WS_URL", "ws://127.0.0.1:8790/ws"))
p.add_argument("--token", default=os.getenv("ANDROID_TOKEN", "")) p.add_argument("--token", default=os.getenv("IRIS_TOKEN", ""))
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}") p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
p.add_argument("--send", default="hello") p.add_argument("--send", default="hello")
p.add_argument( p.add_argument(
@@ -724,8 +724,8 @@ def main() -> int:
) )
p.add_argument( p.add_argument(
"--chat-id", "--chat-id",
default="android:default", default="default",
help="chat_id for --scope chat (default android:default)", help="chat_id for --scope chat (default default)",
) )
p.add_argument( p.add_argument(
"--channel-create", default="", help="M3: create a channel, print its chat_id, exit" "--channel-create", default="", help="M3: create a channel, print its chat_id, exit"
@@ -762,7 +762,7 @@ def main() -> int:
) )
args = p.parse_args() args = p.parse_args()
if not args.token and not args.authfail: 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: if args.assert_read_receipt and not args.send:
p.error("--assert-read-receipt requires --send (the receipt must follow the sent message)") 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 # HTTP is the only transport (docs/19): derive the http(s) base from the