From 60df8e5ca1da43a3588ef8b138e9523bffbacceb Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:27:08 +0000 Subject: [PATCH 1/6] feat: live in-flight run progress notification (A1) (#111) * docs: spec for live in-flight run progress (A1) * docs: implementation plan for live in-flight run progress * feat: pure run-progress reducer over gateway run events * feat: map run progress to a platform-independent notification spec * feat: persist and expose the live run-progress preference * feat: render the live run-progress notification with API 36 progress style * feat: drive the live run-progress notification from the gateway event stream * Fix stranded run-progress notification race and split profile read onDestroy() cancelled the collector scope then immediately called notifier.cancelRunProgress(), but scope.cancel() is cooperative and does not preempt a collector iteration already executing synchronously. An in-flight iteration could still call notifier.postRunProgress(...) after the cancel, with no ordering guarantee, permanently stranding an ongoing notification. Hang the final cancel off the Job's actual completion (invokeOnCompletion) instead of off the cancel() call, so it fires only after every child coroutine has genuinely finished. Also collapse the two separate profiles.active.value reads in updateRunProgress into one, so a profile switch landing between them can no longer split a notification's title/route from its accent colour across two different tenants. * Latch tenant into run progress to fix accent/route drift A run's tenant is now latched into RunProgress at message.start / session.info time instead of being re-read from ProfileManager.active at notification-post time. Opening a cross-profile session (SessionsViewModel.prepareOpen) switches the active profile without resetting run state, so the two could drift and post a notification with one tenant's accent/title while its route still pointed at another tenant's session. Also: a differing sessionId on a running session.info now starts a fresh run instead of silently keeping the stale one, and the second reserved notification id (1003, run-progress) is now guarded against id collisions the same way the first (1001) already was. --- .../hermes/client/data/network/ServerEvent.kt | 20 + .../client/data/progress/RunProgress.kt | 69 ++ .../data/repository/NotificationSettings.kt | 8 +- .../notifications/GatewayConnectionService.kt | 42 +- .../client/notifications/HermesNotifier.kt | 69 ++ .../notifications/NotificationMapper.kt | 7 +- .../notifications/NotificationModels.kt | 26 + .../client/notifications/RunProgressMapper.kt | 23 + .../client/ui/settings/NotificationsScreen.kt | 8 + .../client/data/progress/RunProgressTest.kt | 186 ++++ .../notifications/RunProgressMapperTest.kt | 72 ++ .../plans/2026-07-20-live-run-progress.md | 914 ++++++++++++++++++ .../2026-07-20-live-run-progress-design.md | 213 ++++ 13 files changed, 1652 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/hermes/client/data/progress/RunProgress.kt create mode 100644 app/src/main/java/com/hermes/client/notifications/RunProgressMapper.kt create mode 100644 app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt create mode 100644 app/src/test/java/com/hermes/client/notifications/RunProgressMapperTest.kt create mode 100644 docs/superpowers/plans/2026-07-20-live-run-progress.md create mode 100644 docs/superpowers/specs/2026-07-20-live-run-progress-design.md diff --git a/app/src/main/java/com/hermes/client/data/network/ServerEvent.kt b/app/src/main/java/com/hermes/client/data/network/ServerEvent.kt index 5a1e9d6..b159489 100644 --- a/app/src/main/java/com/hermes/client/data/network/ServerEvent.kt +++ b/app/src/main/java/com/hermes/client/data/network/ServerEvent.kt @@ -43,3 +43,23 @@ internal fun ServerEvent.bool(key: String): Boolean? = internal fun ServerEvent.strList(key: String): List = (payload[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList() + +/** + * Counts todo items from a `tool.complete` payload's `todos` array (gateway sends the full list + * as `{id, content, status}` objects). `done` counts `completed`; `total` counts every item that + * is NOT `cancelled` — a cancelled task never completes, so including it would stall the progress + * bar below 100% forever. Defensive like [str]: a malformed or absent payload yields 0 to 0 + * rather than throwing, because a throw here would escape the event collector. + */ +internal fun ServerEvent.todoCounts(): Pair { + val arr = payload["todos"] as? JsonArray ?: return 0 to 0 + var done = 0 + var total = 0 + for (el in arr) { + val status = ((el as? JsonObject)?.get("status") as? JsonPrimitive)?.content?.lowercase() + if (status == "cancelled") continue + total++ + if (status == "completed") done++ + } + return done to total +} diff --git a/app/src/main/java/com/hermes/client/data/progress/RunProgress.kt b/app/src/main/java/com/hermes/client/data/progress/RunProgress.kt new file mode 100644 index 0000000..7b2fd6a --- /dev/null +++ b/app/src/main/java/com/hermes/client/data/progress/RunProgress.kt @@ -0,0 +1,69 @@ +package com.hermes.client.data.progress + +import com.hermes.client.data.network.ServerEvent +import com.hermes.client.data.network.bool +import com.hermes.client.data.network.str +import com.hermes.client.data.network.todoCounts + +/** + * State of the agent run currently in flight, derived purely from gateway WebSocket events. + * + * Deliberately NOT part of ChatUiState: that is scoped to an open chat screen and dies when the + * app is backgrounded, which is exactly when this state must survive to drive a notification. + */ +data class RunProgress( + val running: Boolean = false, + val sessionId: String? = null, + val profile: String? = null, + val tool: String? = null, + val done: Int = 0, + val total: Int = 0, +) { + /** A determinate bar is only possible once the `todo` tool has reported a non-empty list. */ + val determinate: Boolean get() = total > 0 +} + +/** + * Folds one gateway event into run state. Pure — no Android, no IO. + * + * `activeProfile` is latched into the run at the moment it starts (or restarts) so the + * tenant travels with the run itself rather than being re-read from a mutable source at + * notification-post time — a profile switch mid-run must never re-tag an in-flight run's + * notification with a different tenant while its route still points at the original session. + * + * `session.info.running` is the authoritative backstop: `message.complete` alone misses + * interrupted and compacted turns, which would otherwise strand a permanent "running" state. + * Tool events are ignored while idle so a late/stray `tool.*` cannot resurrect a finished run. + */ +fun RunProgress.reduce(event: ServerEvent, activeProfile: String?): RunProgress = when (event.type) { + "message.start" -> RunProgress(running = true, sessionId = event.sessionId, profile = activeProfile) + + "tool.start" -> if (!running) this else copy(tool = event.str("name")?.ifBlank { null }) + + "tool.complete" -> when { + !running -> this + event.str("name") == "todo" -> { + val (d, t) = event.todoCounts() + copy(tool = null, done = d, total = t) + } + else -> copy(tool = null) + } + + "message.complete", "error" -> RunProgress() + + // Authoritative busy/idle signal. A missing `running` field leaves state untouched. + "session.info" -> when (event.bool("running")) { + false -> RunProgress() + true -> when { + !running -> RunProgress(running = true, sessionId = event.sessionId, profile = activeProfile) + // A differing (non-null) sessionId means a different run started — start fresh + // rather than silently keeping the stale run's counts/sessionId. + event.sessionId != null && event.sessionId != sessionId -> + RunProgress(running = true, sessionId = event.sessionId, profile = activeProfile) + else -> this + } + null -> this + } + + else -> this +} diff --git a/app/src/main/java/com/hermes/client/data/repository/NotificationSettings.kt b/app/src/main/java/com/hermes/client/data/repository/NotificationSettings.kt index 68a963b..24809e0 100644 --- a/app/src/main/java/com/hermes/client/data/repository/NotificationSettings.kt +++ b/app/src/main/java/com/hermes/client/data/repository/NotificationSettings.kt @@ -10,21 +10,27 @@ import kotlinx.coroutines.flow.map private val Context.notificationDataStore by preferencesDataStore(name = "notifications") -/** Device-local notification preferences (master toggle + approvals + run-finished). Off by default. */ +/** + * Device-local notification preferences (master toggle + approvals + run-finished + + * run-progress). Off by default. + */ class NotificationSettings(private val context: Context) { private val kEnabled = booleanPreferencesKey("enabled") private val kApprovals = booleanPreferencesKey("approvals") private val kRunFinished = booleanPreferencesKey("runFinished") + private val kRunProgress = booleanPreferencesKey("runProgress") val prefs: Flow = context.notificationDataStore.data.map { p -> NotificationPrefs( enabled = p[kEnabled] ?: false, approvals = p[kApprovals] ?: true, runFinished = p[kRunFinished] ?: true, + runProgress = p[kRunProgress] ?: true, ) } suspend fun setEnabled(v: Boolean) = context.notificationDataStore.edit { it[kEnabled] = v } suspend fun setApprovals(v: Boolean) = context.notificationDataStore.edit { it[kApprovals] = v } suspend fun setRunFinished(v: Boolean) = context.notificationDataStore.edit { it[kRunFinished] = v } + suspend fun setRunProgress(v: Boolean) = context.notificationDataStore.edit { it[kRunProgress] = v } } diff --git a/app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt b/app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt index adc0a68..4660bf5 100644 --- a/app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt +++ b/app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.os.IBinder import com.hermes.client.data.network.HermesGatewayClient +import com.hermes.client.data.progress.reduce import com.hermes.client.data.repository.NotificationSettings import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope @@ -22,8 +23,12 @@ class GatewayConnectionService : Service() { @Inject lateinit var client: HermesGatewayClient @Inject lateinit var settings: NotificationSettings @Inject lateinit var notifier: HermesNotifier + @Inject lateinit var profiles: com.hermes.client.data.repository.ProfileManager - private val scope = CoroutineScope(SupervisorJob()) + // Held separately (not just scope.coroutineContext[Job]) so onDestroy can register an + // invokeOnCompletion callback on it directly — see onDestroy for why. + private val job = SupervisorJob() + private val scope = CoroutineScope(job) // Latest notification prefs, kept current by a collector so the hot event loop never blocks on // DataStore. @Volatile for cross-thread visibility (scope has no single-thread dispatcher). @@ -35,6 +40,15 @@ class GatewayConnectionService : Service() { // dispatcher). @Volatile private var appInForeground = false + // Live run state, folded from the same event stream. @Volatile for the same reason as the + // fields above: the collector scope has no single-thread dispatcher. + @Volatile private var runProgress = com.hermes.client.data.progress.RunProgress() + + // Last spec actually posted. message.delta fires many times per second and does not change + // the spec, so re-posting on every event would burn cycles and visibly flicker the + // notification. Only act when the derived spec actually changes. + @Volatile private var lastRunSpec: RunProgressSpec? = null + // ProcessLifecycleOwner is a process-lifetime singleton; hold the observer so onDestroy can // remove it — otherwise each stop/start of this service (e.g. toggling notifications) would // leak the retired Service instance (and its injected WS client) forever. @@ -66,6 +80,7 @@ class GatewayConnectionService : Service() { // ChatViewModel's reduce() uses around event handling. runCatching { toNotificationSpec(event, latestPrefs, appInForeground)?.let { notifier.post(it) } + updateRunProgress(event) } } } @@ -74,6 +89,22 @@ class GatewayConnectionService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY override fun onBind(intent: Intent?): IBinder? = null + /** Folds the event into run state and posts/cancels the progress notification on change. */ + private fun updateRunProgress(event: com.hermes.client.data.network.ServerEvent) { + // Single read: profiles.active.value feeds the reducer, which latches it into the run + // itself (RunProgress.profile). The spec and the post call below both derive their tenant + // from that latched value rather than re-reading profiles.active.value, so a profile + // switch landing mid-run can never split a notification's title/route from its accent + // colour across two different tenants — see RunProgress.reduce's kdoc. + val activeProfile = profiles.active.value + runProgress = runProgress.reduce(event, activeProfile) + val spec = runProgress.toSpec(latestPrefs) + if (spec == lastRunSpec) return + lastRunSpec = spec + if (spec != null) notifier.postRunProgress(spec, runProgress.profile) + else notifier.cancelRunProgress() + } + // Android 15+ (API 35) caps a dataSync foreground service at ~6h and calls this instead of // just killing the process; Android 16 (API 36) added a fgsType-aware overload. Implement // both so whichever the OS invokes stops the service cleanly rather than crashing/ANR-ing. @@ -87,6 +118,15 @@ class GatewayConnectionService : Service() { } override fun onDestroy() { + // A stopped service must never strand an ongoing "running" notification. scope.cancel() + // is cooperative: it does not preempt a collector iteration already executing + // synchronously, so if the collector is mid-lambda when we get here it can still call + // notifier.postRunProgress(...) after this function returns, with no ordering guarantee + // against a cancelRunProgress() called from here directly. Hanging the final cancel off + // the Job's actual completion — rather than off the cancel() call itself — guarantees it + // runs strictly after every child coroutine (including any such in-flight iteration) has + // finished, so no post can ever win the race. + job.invokeOnCompletion { notifier.cancelRunProgress() } scope.cancel() // onDestroy() runs on the main thread — remove the observer synchronously so this Service // instance isn't retained (and no event can hit a defunct instance in a deferred window). diff --git a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt index bf57658..2e7fe5b 100644 --- a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt +++ b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt @@ -6,11 +6,14 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.res.Configuration +import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.RemoteInput import com.hermes.client.MainActivity import com.hermes.client.R +import com.hermes.client.ui.theme.accentArgb /** Owns notification channels and turns a [NotificationSpec] into a posted Android notification. */ class HermesNotifier(private val context: Context) { @@ -29,6 +32,9 @@ class HermesNotifier(private val context: Context) { sys.createNotificationChannel( NotificationChannel(Notif.CHANNEL_ACTIVITY, "Activity", NotificationManager.IMPORTANCE_DEFAULT), ) + sys.createNotificationChannel( + NotificationChannel(Notif.CHANNEL_RUN_PROGRESS, "Run progress", NotificationManager.IMPORTANCE_LOW), + ) } fun serviceNotification(): Notification = @@ -68,6 +74,65 @@ class HermesNotifier(private val context: Context) { fun cancel(id: Int) = mgr.cancel(id) + /** + * Posts (or updates) the single ongoing run-progress notification. On API 36+ this uses the + * platform ProgressStyle so the system can promote it to a status-bar Live Update; below that + * it falls back to an ordinary ongoing progress notification. + * + * androidx.core 1.16.0 has no NotificationCompat.ProgressStyle, so the API 36+ branch builds + * with the platform Notification.Builder rather than upgrading the dependency. + */ + fun postRunProgress(spec: RunProgressSpec, profile: String?) { + if (!mgr.areNotificationsEnabled()) return + val accent = accentFor(profile) + val n = if (Build.VERSION.SDK_INT >= 36) buildPromoted(spec, accent) else buildCompat(spec, accent) + mgr.notify(RUN_PROGRESS_NOTIFICATION_ID, n) + } + + fun cancelRunProgress() = mgr.cancel(RUN_PROGRESS_NOTIFICATION_ID) + + /** Tenant accent, resolved against the system's current night mode. Chrome only. */ + private fun accentFor(profile: String?): Int { + val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + return accentArgb(profile, dark) + } + + @androidx.annotation.RequiresApi(36) + private fun buildPromoted(spec: RunProgressSpec, accent: Int): Notification { + val style = Notification.ProgressStyle().setProgressIndeterminate(spec.indeterminate) + if (!spec.indeterminate) { + // ProgressStyle has no setProgressMax(): the bar's maximum is the SUM of its segment + // lengths, so one segment of `total` gives a bar of exactly that length. + style.addProgressSegment(Notification.ProgressStyle.Segment(spec.total).setColor(accent)) + style.setProgress(spec.done) + } + val b = Notification.Builder(context, Notif.CHANNEL_RUN_PROGRESS) + .setSmallIcon(R.drawable.ic_stat_hermes) + .setContentTitle(spec.title) + .setContentText(spec.body) + .setStyle(style) + .setOngoing(true) + .setColor(accent) + .setContentIntent(openIntent(spec.route, RUN_PROGRESS_NOTIFICATION_ID)) + // Status-bar chip text on a promoted notification. The system decides promotion itself + // (Notification.FLAG_PROMOTED_ONGOING); there is no request API to call. + spec.shortText?.let { b.setShortCriticalText(it) } + return b.build() + } + + private fun buildCompat(spec: RunProgressSpec, accent: Int): Notification = + NotificationCompat.Builder(context, Notif.CHANNEL_RUN_PROGRESS) + .setSmallIcon(R.drawable.ic_stat_hermes) + .setContentTitle(spec.title) + .setContentText(spec.body) + .setProgress(spec.total, spec.done, spec.indeterminate) + .setOngoing(true) + .setColor(accent) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setContentIntent(openIntent(spec.route, RUN_PROGRESS_NOTIFICATION_ID)) + .build() + private fun openIntent(route: String?, id: Int): PendingIntent { val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -108,5 +173,9 @@ class HermesNotifier(private val context: Context) { companion object { const val SERVICE_NOTIFICATION_ID = 1001 + + // Distinct from SERVICE_NOTIFICATION_ID (1001) and from toNotificationSpec's 1002 + // collision fallback, so the ongoing progress notification can never clobber either. + const val RUN_PROGRESS_NOTIFICATION_ID = 1003 } } diff --git a/app/src/main/java/com/hermes/client/notifications/NotificationMapper.kt b/app/src/main/java/com/hermes/client/notifications/NotificationMapper.kt index db9208b..0038e8c 100644 --- a/app/src/main/java/com/hermes/client/notifications/NotificationMapper.kt +++ b/app/src/main/java/com/hermes/client/notifications/NotificationMapper.kt @@ -18,9 +18,10 @@ fun toNotificationSpec(event: ServerEvent, prefs: NotificationPrefs, appInForegr if (!prefs.enabled) return null val sid = event.sessionId ?: return null var id = (event.type + sid).hashCode() - // Never collide with HermesNotifier.SERVICE_NOTIFICATION_ID (1001) — that id belongs to the - // ongoing foreground-service notification, and notify()-ing over it would clobber it. - if (id == 1001) id = 1002 + // Never collide with HermesNotifier.SERVICE_NOTIFICATION_ID (1001) or the run-progress + // notification id (1003) — those ids belong to the ongoing foreground-service notification + // and the live run-progress notification, and notify()-ing over either would clobber it. + if (id == 1001 || id == 1003) id = 1002 return when (event.type) { Notif.EVENT_APPROVAL -> if (!prefs.approvals) null else { val elevated = tierFor(event.bool("allow_permanent") ?: false) == ApprovalTier.ELEVATED diff --git a/app/src/main/java/com/hermes/client/notifications/NotificationModels.kt b/app/src/main/java/com/hermes/client/notifications/NotificationModels.kt index 5435197..89429f8 100644 --- a/app/src/main/java/com/hermes/client/notifications/NotificationModels.kt +++ b/app/src/main/java/com/hermes/client/notifications/NotificationModels.kt @@ -5,6 +5,7 @@ data class NotificationPrefs( val enabled: Boolean = false, val approvals: Boolean = true, val runFinished: Boolean = true, + val runProgress: Boolean = true, ) /** @@ -30,11 +31,30 @@ data class NotificationSpec( val groupKey: String, ) +/** + * A platform-independent description of the live run-progress notification, so mapping stays + * unit-testable. [indeterminate] means no todo counts are available yet; [shortText] is the + * status-bar chip text used on API 36+ promoted notifications (null when indeterminate). + */ +data class RunProgressSpec( + val title: String, + val body: String, + val done: Int, + val total: Int, + val indeterminate: Boolean, + val route: String?, + val shortText: String?, +) + /** Channel ids, gateway event-type strings, and action names in one place. */ object Notif { const val CHANNEL_APPROVALS = "approvals" const val CHANNEL_SERVICE = "service" const val CHANNEL_ACTIVITY = "activity" + // Live in-flight run progress. IMPORTANCE_LOW (not MIN like CHANNEL_SERVICE) so the ongoing + // progress notification is actually glanceable in the shade and eligible for promotion to a + // status-bar Live Update on API 36+, while still making no sound. + const val CHANNEL_RUN_PROGRESS = "run_progress" // Notifiable events on the app's WebSocket (/api/ws), verified against the gateway source: // - approval.request / clarify.request -> the agent needs the user (always notify) @@ -48,6 +68,12 @@ object Notif { const val EVENT_CLARIFY = "clarify.request" const val EVENT_MESSAGE_COMPLETE = "message.complete" const val EVENT_ERROR = "error" + // Run-lifecycle events consumed by the run-progress reducer (not by toNotificationSpec). + // `session.info` carries "running": bool and is the authoritative busy/idle backstop. + const val EVENT_MESSAGE_START = "message.start" + const val EVENT_TOOL_START = "tool.start" + const val EVENT_TOOL_COMPLETE = "tool.complete" + const val EVENT_SESSION_INFO = "session.info" const val ACTION_ALLOW_ONCE = "allow_once" const val ACTION_ALLOW_SESSION = "allow_session" diff --git a/app/src/main/java/com/hermes/client/notifications/RunProgressMapper.kt b/app/src/main/java/com/hermes/client/notifications/RunProgressMapper.kt new file mode 100644 index 0000000..371da12 --- /dev/null +++ b/app/src/main/java/com/hermes/client/notifications/RunProgressMapper.kt @@ -0,0 +1,23 @@ +package com.hermes.client.notifications + +import com.hermes.client.data.progress.RunProgress + +/** + * Pure mapping from run state to a notification description, or null when nothing should be + * shown. Mirrors [toNotificationSpec]: all decisions live here so they are testable without + * Android, and [HermesNotifier] only renders. + */ +fun RunProgress.toSpec(prefs: NotificationPrefs): RunProgressSpec? { + if (!prefs.enabled || !prefs.runProgress) return null + if (!running) return null + val tenant = profile?.takeIf { it.isNotBlank() } + return RunProgressSpec( + title = if (tenant != null) "$tenant · agent running" else "Agent running", + body = tool?.let { "Calling tool: $it" } ?: "Working…", + done = done, + total = total, + indeterminate = !determinate, + route = sessionId?.let { "chat/$it" }, + shortText = if (determinate) "$done/$total" else null, + ) +} diff --git a/app/src/main/java/com/hermes/client/ui/settings/NotificationsScreen.kt b/app/src/main/java/com/hermes/client/ui/settings/NotificationsScreen.kt index e1c2786..2229c09 100644 --- a/app/src/main/java/com/hermes/client/ui/settings/NotificationsScreen.kt +++ b/app/src/main/java/com/hermes/client/ui/settings/NotificationsScreen.kt @@ -47,6 +47,7 @@ class NotificationsViewModel @Inject constructor( fun setEnabled(v: Boolean) = viewModelScope.launch { settings.setEnabled(v) } fun setApprovals(v: Boolean) = viewModelScope.launch { settings.setApprovals(v) } fun setRunFinished(v: Boolean) = viewModelScope.launch { settings.setRunFinished(v) } + fun setRunProgress(v: Boolean) = viewModelScope.launch { settings.setRunProgress(v) } } @OptIn(ExperimentalMaterial3Api::class) @@ -100,6 +101,13 @@ fun NotificationsScreen(onBack: () -> Unit, vm: NotificationsViewModel = hiltVie prefs.runFinished, enabled = prefs.enabled, ) { vm.setRunFinished(it) } + HorizontalDivider() + ToggleRow( + "Live run progress", + "Show an ongoing notification with live progress while an agent run is in flight", + prefs.runProgress, + enabled = prefs.enabled, + ) { vm.setRunProgress(it) } } } } diff --git a/app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt b/app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt new file mode 100644 index 0000000..6bb25bc --- /dev/null +++ b/app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt @@ -0,0 +1,186 @@ +package com.hermes.client.data.progress + +import com.hermes.client.data.network.ServerEvent +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RunProgressTest { + private fun ev(type: String, session: String = "s1", build: (JsonObjectBuilder.() -> Unit) = {}) = + ServerEvent(type, session, buildJsonObject { put("session_id", session); build() }) + + /** A todo tool.complete carrying an explicit list of {id, content, status} items. */ + private fun todoEvent(vararg statuses: String) = ev("tool.complete") { + put("name", "todo") + putJsonArray("todos") { + statuses.forEachIndexed { i, s -> + addJsonObject { put("id", "$i"); put("content", "task $i"); put("status", s) } + } + } + } + + // Case 1 + @Test fun message_start_marks_running_and_indeterminate() { + val s = RunProgress().reduce(ev("message.start"), "acme") + assertTrue(s.running) + assertFalse(s.determinate) + assertEquals("s1", s.sessionId) + } + + // Case 2 + @Test fun tool_start_records_tool_name() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce(ev("tool.start") { put("name", "web_search") }, "acme") + assertEquals("web_search", s.tool) + } + + // Case 3 + @Test fun todo_tool_complete_yields_determinate_counts() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce(todoEvent("completed", "completed", "in_progress", "pending", "pending"), "acme") + assertTrue(s.determinate) + assertEquals(2, s.done) + assertEquals(5, s.total) + } + + // Case 4 + @Test fun cancelled_todos_are_excluded_from_total() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce(todoEvent("completed", "completed", "completed", "cancelled"), "acme") + assertEquals(3, s.done) + assertEquals(3, s.total) + } + + // Case 5 + @Test fun a_new_run_resets_counts_from_the_previous_run() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce(todoEvent("completed", "pending"), "acme") + s = s.reduce(ev("message.complete"), "acme") + s = s.reduce(ev("message.start"), "acme") + assertTrue(s.running) + assertEquals(0, s.done) + assertEquals(0, s.total) + assertNull(s.tool) + } + + // Case 6 + @Test fun message_complete_returns_to_idle() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce(ev("message.complete"), "acme") + assertFalse(s.running) + } + + // Case 7 + @Test fun error_returns_to_idle() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce(ev("error") { put("message", "boom") }, "acme") + assertFalse(s.running) + } + + // Case 8 + @Test fun session_info_running_false_is_the_interrupt_backstop() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce(todoEvent("completed", "pending"), "acme") + s = s.reduce(ev("session.info") { put("running", false) }, "acme") + assertFalse(s.running) + assertEquals(0, s.total) + } + + @Test fun session_info_running_true_marks_running_when_connecting_mid_run() { + val s = RunProgress().reduce(ev("session.info") { put("running", true) }, "acme") + assertTrue(s.running) + assertEquals("s1", s.sessionId) + } + + // Case 9 — tool_progress_mode "off" means no tool.* events ever arrive. + @Test fun run_without_tool_events_stays_indeterminate() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce(ev("message.delta") { put("text", "thinking") }, "acme") + assertTrue(s.running) + assertFalse(s.determinate) + } + + // Case 10 + @Test fun malformed_todos_payload_does_not_crash_and_stays_indeterminate() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce(ev("tool.complete") { put("name", "todo"); put("todos", "not-an-array") }, "acme") + assertFalse(s.determinate) + assertEquals(0, s.total) + } + + @Test fun todo_items_with_missing_status_count_toward_total_but_not_done() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce( + ev("tool.complete") { + put("name", "todo") + putJsonArray("todos") { addJsonObject { put("id", "1"); put("content", "x") } } + }, + "acme", + ) + assertEquals(0, s.done) + assertEquals(1, s.total) + } + + @Test fun non_todo_tool_complete_clears_the_tool_without_touching_counts() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce(todoEvent("completed", "pending"), "acme") + s = s.reduce(ev("tool.start") { put("name", "web_search") }, "acme") + s = s.reduce(ev("tool.complete") { put("name", "web_search") }, "acme") + assertNull(s.tool) + assertEquals(1, s.done) + assertEquals(2, s.total) + } + + @Test fun tool_events_while_idle_do_not_start_a_phantom_run() { + val s = RunProgress().reduce(ev("tool.start") { put("name", "web_search") }, "acme") + assertFalse(s.running) + assertNull(s.tool) + } + + @Test fun unrelated_events_leave_state_untouched() { + val running = RunProgress().reduce(ev("message.start"), "acme") + assertEquals(running, running.reduce(ev("approval.request") { put("command", "ls") }, "acme")) + } + + // --- Tenant-latching tests (FIX 1) --- + + @Test fun message_start_latches_the_active_profile() { + val s = RunProgress().reduce(ev("message.start"), "acme") + assertEquals("acme", s.profile) + } + + @Test fun a_later_profile_does_not_retroactively_change_an_in_flight_runs_latched_profile() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce(ev("tool.start") { put("name", "web_search") }, "globex") + assertEquals("acme", s.profile) + } + + @Test fun session_info_running_true_with_a_differing_session_id_starts_a_fresh_run() { + var s = RunProgress().reduce(ev("message.start", "s1"), "acme") + s = s.reduce(todoEvent("completed", "pending"), "acme") + s = s.reduce(ev("session.info", "s2") { put("running", true) }, "globex") + assertTrue(s.running) + assertEquals("s2", s.sessionId) + assertEquals("globex", s.profile) + assertEquals(0, s.done) + assertEquals(0, s.total) + } + + @Test fun session_info_running_true_with_the_same_session_id_preserves_existing_counts() { + var s = RunProgress().reduce(ev("message.start", "s1"), "acme") + s = s.reduce(todoEvent("completed", "pending"), "acme") + s = s.reduce(ev("session.info", "s1") { put("running", true) }, "globex") + assertTrue(s.running) + assertEquals("s1", s.sessionId) + assertEquals("acme", s.profile) + assertEquals(1, s.done) + assertEquals(2, s.total) + } +} diff --git a/app/src/test/java/com/hermes/client/notifications/RunProgressMapperTest.kt b/app/src/test/java/com/hermes/client/notifications/RunProgressMapperTest.kt new file mode 100644 index 0000000..fb8522a --- /dev/null +++ b/app/src/test/java/com/hermes/client/notifications/RunProgressMapperTest.kt @@ -0,0 +1,72 @@ +package com.hermes.client.notifications + +import com.hermes.client.data.progress.RunProgress +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RunProgressMapperTest { + private val on = NotificationPrefs(enabled = true, runProgress = true) + + @Test fun idle_state_maps_to_null() { + assertNull(RunProgress(profile = "acme").toSpec(on)) + } + + @Test fun running_maps_to_an_indeterminate_spec_titled_with_the_tenant() { + val spec = RunProgress(running = true, sessionId = "s1", profile = "acme").toSpec(on)!! + assertEquals("acme · agent running", spec.title) + assertEquals("Working…", spec.body) + assertTrue(spec.indeterminate) + assertEquals("chat/s1", spec.route) + assertNull(spec.shortText) + } + + @Test fun active_tool_appears_in_the_body() { + val spec = RunProgress(running = true, tool = "web_search", profile = "acme").toSpec(on)!! + assertEquals("Calling tool: web_search", spec.body) + } + + @Test fun todo_counts_make_the_spec_determinate_with_a_short_chip() { + val spec = RunProgress(running = true, done = 3, total = 5, profile = "globex").toSpec(on)!! + assertFalse(spec.indeterminate) + assertEquals(3, spec.done) + assertEquals(5, spec.total) + assertEquals("3/5", spec.shortText) + } + + @Test fun a_missing_profile_falls_back_to_a_generic_title() { + val spec = RunProgress(running = true, profile = null).toSpec(on)!! + assertEquals("Agent running", spec.title) + } + + @Test fun a_blank_profile_falls_back_to_a_generic_title() { + val spec = RunProgress(running = true, profile = " ").toSpec(on)!! + assertEquals("Agent running", spec.title) + } + + @Test fun master_notification_toggle_off_suppresses_progress() { + val prefs = NotificationPrefs(enabled = false, runProgress = true) + assertNull(RunProgress(running = true, profile = "acme").toSpec(prefs)) + } + + @Test fun run_progress_toggle_off_suppresses_progress() { + val prefs = NotificationPrefs(enabled = true, runProgress = false) + assertNull(RunProgress(running = true, profile = "acme").toSpec(prefs)) + } + + @Test fun run_progress_defaults_to_on() { + assertTrue(NotificationPrefs().runProgress) + } + + @Test fun a_run_with_no_session_id_has_no_route() { + val spec = RunProgress(running = true, sessionId = null, profile = "acme").toSpec(on)!! + assertNull(spec.route) + } + + @Test fun the_title_comes_from_the_runs_latched_profile_not_a_passed_in_name() { + val spec = RunProgress(running = true, profile = "globex").toSpec(on)!! + assertEquals("globex · agent running", spec.title) + } +} diff --git a/docs/superpowers/plans/2026-07-20-live-run-progress.md b/docs/superpowers/plans/2026-07-20-live-run-progress.md new file mode 100644 index 0000000..4b0e2b1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-live-run-progress.md @@ -0,0 +1,914 @@ +# Live In-Flight Run Progress Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a live, glanceable ongoing notification while an agent run is in flight — indeterminate by default, determinate when the model drives the `todo` tool. + +**Architecture:** A pure reducer (`RunProgress.reduce`) folds gateway WebSocket events into a small run-state value. `GatewayConnectionService` — which already owns the single `client.events` collector — holds that value, maps it through a pure `toSpec()` to a platform-independent `RunProgressSpec`, and drives `HermesNotifier` to post/update/cancel one ongoing notification. No new singleton, no second collector, and all logic outside the notifier is pure and unit-testable without Android. + +**Tech Stack:** Kotlin, kotlinx.serialization (`JsonObject` payloads), Hilt, Compose/Material3 (settings toggle only), JUnit4 + `kotlinx-coroutines-test`. + +**Spec:** `docs/superpowers/specs/2026-07-20-live-run-progress-design.md` (committed `1fe69c7`). + +## Global Constraints + +- `RUN_PROGRESS_NOTIFICATION_ID = 1003` — must not collide with `SERVICE_NOTIFICATION_ID` (1001) or the mapper's 1002 fallback. +- New channel id `"run_progress"`, `IMPORTANCE_LOW`. The existing MIN `"service"` channel and its static "Connected" notification are unchanged. +- `cancelled` todos are EXCLUDED from `total`; determinate iff `total > 0`. +- `session.info.running` is the authoritative idle/busy backstop; `false` ⇒ reset. +- The progress notification must be cancelled on `message.complete`, `error`, `session.info running=false`, AND `Service.onDestroy()`. +- Tenant accent via `accentArgb` for chrome only; generic `acme`/`globex` naming in tests. +- No AI attribution in commits/files/PRs. Run `gitleaks git --no-banner --redact` before every push. PR into `dev`, never `main`. +- Reducer and spec-mapping logic must be PURE functions, unit-testable without Android. +- Every API-36 call site runtime-gated on `Build.VERSION.SDK_INT >= 36`, with the `NotificationCompat.setProgress` fallback for API 26–35 (`minSdk = 26`). + +### Resolved implementation fork (do not re-investigate) + +`NotificationCompat.ProgressStyle` **does not exist** in `androidx.core 1.16.0` — verified by extracting `core-1.16.0.aar` and finding no `ProgressStyle` class. **Do NOT upgrade the dependency.** The API-36 branch uses the platform `android.app.Notification.Builder` with `android.app.Notification.ProgressStyle`, confirmed present in the android-36 platform jar. + +Verified platform API surface (`javap` on `android.jar`): + +- `Notification.ProgressStyle()` — no-arg constructor. +- `setProgress(int)`, `setProgressIndeterminate(boolean)`, `addProgressSegment(Segment)`. +- **There is NO `setProgressMax(int)`.** The bar's maximum is the SUM of its segment lengths, so a determinate `done/total` bar is built by adding ONE segment of length `total` and calling `setProgress(done)`. +- `Notification.ProgressStyle.Segment(int length)`, with `setColor(int)`. +- `Notification.Builder.setShortCriticalText(String)` — the status-bar chip text on promoted notifications. +- **There is NO `requestPromotedOngoing()`.** The system grants `Notification.FLAG_PROMOTED_ONGOING` itself when the notification has promotable characteristics. Do not attempt to set that flag. + +### Build commands + +```bash +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +./gradlew :app:compileDebugKotlin +./gradlew :app:testDebugUnitTest +./gradlew :app:assembleBeta +``` + +### File structure + +| File | Responsibility | +|---|---| +| `app/src/main/java/com/hermes/client/data/progress/RunProgress.kt` | **Create.** Run-state model + pure `reduce(ServerEvent)`. Knows nothing about notifications. | +| `app/src/main/java/com/hermes/client/data/network/ServerEvent.kt` | Modify. Add the defensive `todoCounts()` payload helper. | +| `app/src/main/java/com/hermes/client/notifications/RunProgressMapper.kt` | **Create.** Pure `RunProgress.toSpec(profileName, prefs)` → `RunProgressSpec?`. | +| `app/src/main/java/com/hermes/client/notifications/NotificationModels.kt` | Modify. `RunProgressSpec`, `runProgress` pref, channel + event constants. | +| `app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt` | Modify. `run_progress` channel, `postRunProgress()`, `cancelRunProgress()`, API-36 split. | +| `app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt` | Modify. Hold reduced state, drive the notifier, cancel in `onDestroy()`. | +| `app/src/main/java/com/hermes/client/data/repository/NotificationSettings.kt` | Modify. Persist `runProgress`. | +| `app/src/main/java/com/hermes/client/ui/settings/NotificationsScreen.kt` | Modify. Toggle row. | +| `app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt` | **Create.** Reducer tests (spec cases 1–10). | +| `app/src/test/java/com/hermes/client/notifications/RunProgressMapperTest.kt` | **Create.** Spec-mapping tests. | + +**Note — a deliberate refinement of the spec's Files table:** the spec placed `toSpec()` inside `RunProgress.kt`. It is instead placed in `notifications/RunProgressMapper.kt` so the `data` layer never imports notification types, mirroring the existing `NotificationMapper.kt` pattern. Behaviour is identical. + +--- + +### Task 1: RunProgress model + pure reducer + +**Files:** +- Create: `app/src/main/java/com/hermes/client/data/progress/RunProgress.kt` +- Modify: `app/src/main/java/com/hermes/client/data/network/ServerEvent.kt` (append helper after line 45) +- Test: `app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt` + +**Interfaces:** +- Consumes: `ServerEvent(type, sessionId, payload)` and the existing `internal fun ServerEvent.str(key)` / `bool(key)` helpers from `com.hermes.client.data.network`. +- Produces: `data class RunProgress(running, sessionId, tool, done, total)` with `val determinate: Boolean`; `fun RunProgress.reduce(event: ServerEvent): RunProgress`; `internal fun ServerEvent.todoCounts(): Pair`. + +- [ ] **Step 1: Write the failing tests** + +Create `app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt`: + +```kotlin +package com.hermes.client.data.progress + +import com.hermes.client.data.network.ServerEvent +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RunProgressTest { + private fun ev(type: String, session: String = "s1", build: (JsonObjectBuilder.() -> Unit) = {}) = + ServerEvent(type, session, buildJsonObject { put("session_id", session); build() }) + + /** A todo tool.complete carrying an explicit list of {id, content, status} items. */ + private fun todoEvent(vararg statuses: String) = ev("tool.complete") { + put("name", "todo") + putJsonArray("todos") { + statuses.forEachIndexed { i, s -> + addJsonObject { put("id", "$i"); put("content", "task $i"); put("status", s) } + } + } + } + + // Case 1 + @Test fun message_start_marks_running_and_indeterminate() { + val s = RunProgress().reduce(ev("message.start")) + assertTrue(s.running) + assertFalse(s.determinate) + assertEquals("s1", s.sessionId) + } + + // Case 2 + @Test fun tool_start_records_tool_name() { + var s = RunProgress().reduce(ev("message.start")) + s = s.reduce(ev("tool.start") { put("name", "web_search") }) + assertEquals("web_search", s.tool) + } + + // Case 3 + @Test fun todo_tool_complete_yields_determinate_counts() { + var s = RunProgress().reduce(ev("message.start")) + s = s.reduce(todoEvent("completed", "completed", "in_progress", "pending", "pending")) + assertTrue(s.determinate) + assertEquals(2, s.done) + assertEquals(5, s.total) + } + + // Case 4 + @Test fun cancelled_todos_are_excluded_from_total() { + var s = RunProgress().reduce(ev("message.start")) + s = s.reduce(todoEvent("completed", "completed", "completed", "cancelled")) + assertEquals(3, s.done) + assertEquals(3, s.total) + } + + // Case 5 + @Test fun a_new_run_resets_counts_from_the_previous_run() { + var s = RunProgress().reduce(ev("message.start")) + s = s.reduce(todoEvent("completed", "pending")) + s = s.reduce(ev("message.complete")) + s = s.reduce(ev("message.start")) + assertTrue(s.running) + assertEquals(0, s.done) + assertEquals(0, s.total) + assertNull(s.tool) + } + + // Case 6 + @Test fun message_complete_returns_to_idle() { + var s = RunProgress().reduce(ev("message.start")) + s = s.reduce(ev("message.complete")) + assertFalse(s.running) + } + + // Case 7 + @Test fun error_returns_to_idle() { + var s = RunProgress().reduce(ev("message.start")) + s = s.reduce(ev("error") { put("message", "boom") }) + assertFalse(s.running) + } + + // Case 8 + @Test fun session_info_running_false_is_the_interrupt_backstop() { + var s = RunProgress().reduce(ev("message.start")) + s = s.reduce(todoEvent("completed", "pending")) + s = s.reduce(ev("session.info") { put("running", false) }) + assertFalse(s.running) + assertEquals(0, s.total) + } + + @Test fun session_info_running_true_marks_running_when_connecting_mid_run() { + val s = RunProgress().reduce(ev("session.info") { put("running", true) }) + assertTrue(s.running) + assertEquals("s1", s.sessionId) + } + + // Case 9 — tool_progress_mode "off" means no tool.* events ever arrive. + @Test fun run_without_tool_events_stays_indeterminate() { + var s = RunProgress().reduce(ev("message.start")) + s = s.reduce(ev("message.delta") { put("text", "thinking") }) + assertTrue(s.running) + assertFalse(s.determinate) + } + + // Case 10 + @Test fun malformed_todos_payload_does_not_crash_and_stays_indeterminate() { + var s = RunProgress().reduce(ev("message.start")) + s = s.reduce(ev("tool.complete") { put("name", "todo"); put("todos", "not-an-array") }) + assertFalse(s.determinate) + assertEquals(0, s.total) + } + + @Test fun todo_items_with_missing_status_count_toward_total_but_not_done() { + var s = RunProgress().reduce(ev("message.start")) + s = s.reduce(ev("tool.complete") { + put("name", "todo") + putJsonArray("todos") { addJsonObject { put("id", "1"); put("content", "x") } } + }) + assertEquals(0, s.done) + assertEquals(1, s.total) + } + + @Test fun non_todo_tool_complete_clears_the_tool_without_touching_counts() { + var s = RunProgress().reduce(ev("message.start")) + s = s.reduce(todoEvent("completed", "pending")) + s = s.reduce(ev("tool.start") { put("name", "web_search") }) + s = s.reduce(ev("tool.complete") { put("name", "web_search") }) + assertNull(s.tool) + assertEquals(1, s.done) + assertEquals(2, s.total) + } + + @Test fun tool_events_while_idle_do_not_start_a_phantom_run() { + val s = RunProgress().reduce(ev("tool.start") { put("name", "web_search") }) + assertFalse(s.running) + assertNull(s.tool) + } + + @Test fun unrelated_events_leave_state_untouched() { + val running = RunProgress().reduce(ev("message.start")) + assertEquals(running, running.reduce(ev("approval.request") { put("command", "ls") })) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +```bash +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +./gradlew :app:testDebugUnitTest --tests "com.hermes.client.data.progress.RunProgressTest" +``` +Expected: FAIL — compilation error, `Unresolved reference: RunProgress`. + +- [ ] **Step 3: Add the `todoCounts()` helper to ServerEvent.kt** + +Append to `app/src/main/java/com/hermes/client/data/network/ServerEvent.kt` (after the existing `strList` helper on line 45). Add `import kotlinx.serialization.json.JsonArray` only if it is not already imported — it is (line 3). + +```kotlin +/** + * Counts todo items from a `tool.complete` payload's `todos` array (gateway sends the full list + * as `{id, content, status}` objects). `done` counts `completed`; `total` counts every item that + * is NOT `cancelled` — a cancelled task never completes, so including it would stall the progress + * bar below 100% forever. Defensive like [str]: a malformed or absent payload yields 0 to 0 + * rather than throwing, because a throw here would escape the event collector. + */ +internal fun ServerEvent.todoCounts(): Pair { + val arr = payload["todos"] as? JsonArray ?: return 0 to 0 + var done = 0 + var total = 0 + for (el in arr) { + val status = ((el as? JsonObject)?.get("status") as? JsonPrimitive)?.content?.lowercase() + if (status == "cancelled") continue + total++ + if (status == "completed") done++ + } + return done to total +} +``` + +- [ ] **Step 4: Create the model and reducer** + +Create `app/src/main/java/com/hermes/client/data/progress/RunProgress.kt`: + +```kotlin +package com.hermes.client.data.progress + +import com.hermes.client.data.network.ServerEvent +import com.hermes.client.data.network.bool +import com.hermes.client.data.network.str +import com.hermes.client.data.network.todoCounts + +/** + * State of the agent run currently in flight, derived purely from gateway WebSocket events. + * + * Deliberately NOT part of ChatUiState: that is scoped to an open chat screen and dies when the + * app is backgrounded, which is exactly when this state must survive to drive a notification. + */ +data class RunProgress( + val running: Boolean = false, + val sessionId: String? = null, + val tool: String? = null, + val done: Int = 0, + val total: Int = 0, +) { + /** A determinate bar is only possible once the `todo` tool has reported a non-empty list. */ + val determinate: Boolean get() = total > 0 +} + +/** + * Folds one gateway event into run state. Pure — no Android, no IO. + * + * `session.info.running` is the authoritative backstop: `message.complete` alone misses + * interrupted and compacted turns, which would otherwise strand a permanent "running" state. + * Tool events are ignored while idle so a late/stray `tool.*` cannot resurrect a finished run. + */ +fun RunProgress.reduce(event: ServerEvent): RunProgress = when (event.type) { + "message.start" -> RunProgress(running = true, sessionId = event.sessionId) + + "tool.start" -> if (!running) this else copy(tool = event.str("name")?.ifBlank { null }) + + "tool.complete" -> when { + !running -> this + event.str("name") == "todo" -> { + val (d, t) = event.todoCounts() + copy(tool = null, done = d, total = t) + } + else -> copy(tool = null) + } + + "message.complete", "error" -> RunProgress() + + // Authoritative busy/idle signal. A missing `running` field leaves state untouched. + "session.info" -> when (event.bool("running")) { + false -> RunProgress() + true -> if (running) this else RunProgress(running = true, sessionId = event.sessionId) + null -> this + } + + else -> this +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: +```bash +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +./gradlew :app:testDebugUnitTest --tests "com.hermes.client.data.progress.RunProgressTest" +``` +Expected: PASS — 14 tests. + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/com/hermes/client/data/progress/RunProgress.kt \ + app/src/main/java/com/hermes/client/data/network/ServerEvent.kt \ + app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt +git commit -m "feat: pure run-progress reducer over gateway run events" +``` + +--- + +### Task 2: RunProgressSpec + pure mapping + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/notifications/NotificationModels.kt` (add `runProgress` to `NotificationPrefs` at lines 4-8; add `RunProgressSpec`; add constants to `Notif` at lines 34-59) +- Create: `app/src/main/java/com/hermes/client/notifications/RunProgressMapper.kt` +- Test: `app/src/test/java/com/hermes/client/notifications/RunProgressMapperTest.kt` + +**Interfaces:** +- Consumes: `RunProgress(running, sessionId, tool, done, total)` and `RunProgress.determinate` from Task 1. +- Produces: `data class RunProgressSpec(title, body, done, total, indeterminate, route, shortText)`; `fun RunProgress.toSpec(profileName: String?, prefs: NotificationPrefs): RunProgressSpec?`; `NotificationPrefs.runProgress: Boolean`; `Notif.CHANNEL_RUN_PROGRESS`. + +- [ ] **Step 1: Write the failing tests** + +Create `app/src/test/java/com/hermes/client/notifications/RunProgressMapperTest.kt`: + +```kotlin +package com.hermes.client.notifications + +import com.hermes.client.data.progress.RunProgress +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RunProgressMapperTest { + private val on = NotificationPrefs(enabled = true, runProgress = true) + + @Test fun idle_state_maps_to_null() { + assertNull(RunProgress().toSpec("acme", on)) + } + + @Test fun running_maps_to_an_indeterminate_spec_titled_with_the_tenant() { + val spec = RunProgress(running = true, sessionId = "s1").toSpec("acme", on)!! + assertEquals("acme · agent running", spec.title) + assertEquals("Working…", spec.body) + assertTrue(spec.indeterminate) + assertEquals("chat/s1", spec.route) + assertNull(spec.shortText) + } + + @Test fun active_tool_appears_in_the_body() { + val spec = RunProgress(running = true, tool = "web_search").toSpec("acme", on)!! + assertEquals("Calling tool: web_search", spec.body) + } + + @Test fun todo_counts_make_the_spec_determinate_with_a_short_chip() { + val spec = RunProgress(running = true, done = 3, total = 5).toSpec("globex", on)!! + assertFalse(spec.indeterminate) + assertEquals(3, spec.done) + assertEquals(5, spec.total) + assertEquals("3/5", spec.shortText) + } + + @Test fun a_missing_profile_falls_back_to_a_generic_title() { + val spec = RunProgress(running = true).toSpec(null, on)!! + assertEquals("Agent running", spec.title) + } + + @Test fun a_blank_profile_falls_back_to_a_generic_title() { + val spec = RunProgress(running = true).toSpec(" ", on)!! + assertEquals("Agent running", spec.title) + } + + @Test fun master_notification_toggle_off_suppresses_progress() { + val prefs = NotificationPrefs(enabled = false, runProgress = true) + assertNull(RunProgress(running = true).toSpec("acme", prefs)) + } + + @Test fun run_progress_toggle_off_suppresses_progress() { + val prefs = NotificationPrefs(enabled = true, runProgress = false) + assertNull(RunProgress(running = true).toSpec("acme", prefs)) + } + + @Test fun run_progress_defaults_to_on() { + assertTrue(NotificationPrefs().runProgress) + } + + @Test fun a_run_with_no_session_id_has_no_route() { + val spec = RunProgress(running = true, sessionId = null).toSpec("acme", on)!! + assertNull(spec.route) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: +```bash +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +./gradlew :app:testDebugUnitTest --tests "com.hermes.client.notifications.RunProgressMapperTest" +``` +Expected: FAIL — `Unresolved reference: toSpec`, and `No value passed for parameter 'runProgress'`. + +- [ ] **Step 3: Extend NotificationModels.kt** + +Replace the `NotificationPrefs` declaration (currently lines 4-8) with: + +```kotlin +/** User's notification preferences (persisted); off by default. */ +data class NotificationPrefs( + val enabled: Boolean = false, + val approvals: Boolean = true, + val runFinished: Boolean = true, + val runProgress: Boolean = true, +) +``` + +Add this data class immediately after the existing `NotificationSpec` declaration (currently ends line 31): + +```kotlin +/** + * A platform-independent description of the live run-progress notification, so mapping stays + * unit-testable. [indeterminate] means no todo counts are available yet; [shortText] is the + * status-bar chip text used on API 36+ promoted notifications (null when indeterminate). + */ +data class RunProgressSpec( + val title: String, + val body: String, + val done: Int, + val total: Int, + val indeterminate: Boolean, + val route: String?, + val shortText: String?, +) +``` + +Inside the `Notif` object, add the channel constant after `CHANNEL_ACTIVITY` (line 37): + +```kotlin + // Live in-flight run progress. IMPORTANCE_LOW (not MIN like CHANNEL_SERVICE) so the ongoing + // progress notification is actually glanceable in the shade and eligible for promotion to a + // status-bar Live Update on API 36+, while still making no sound. + const val CHANNEL_RUN_PROGRESS = "run_progress" +``` + +And add the run-lifecycle event constants after `EVENT_ERROR` (line 50): + +```kotlin + // Run-lifecycle events consumed by the run-progress reducer (not by toNotificationSpec). + // `session.info` carries "running": bool and is the authoritative busy/idle backstop. + const val EVENT_MESSAGE_START = "message.start" + const val EVENT_TOOL_START = "tool.start" + const val EVENT_TOOL_COMPLETE = "tool.complete" + const val EVENT_SESSION_INFO = "session.info" +``` + +- [ ] **Step 4: Create the mapper** + +Create `app/src/main/java/com/hermes/client/notifications/RunProgressMapper.kt`: + +```kotlin +package com.hermes.client.notifications + +import com.hermes.client.data.progress.RunProgress + +/** + * Pure mapping from run state to a notification description, or null when nothing should be + * shown. Mirrors [toNotificationSpec]: all decisions live here so they are testable without + * Android, and [HermesNotifier] only renders. + */ +fun RunProgress.toSpec(profileName: String?, prefs: NotificationPrefs): RunProgressSpec? { + if (!prefs.enabled || !prefs.runProgress) return null + if (!running) return null + val tenant = profileName?.takeIf { it.isNotBlank() } + return RunProgressSpec( + title = if (tenant != null) "$tenant · agent running" else "Agent running", + body = tool?.let { "Calling tool: $it" } ?: "Working…", + done = done, + total = total, + indeterminate = !determinate, + route = sessionId?.let { "chat/$it" }, + shortText = if (determinate) "$done/$total" else null, + ) +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: +```bash +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +./gradlew :app:testDebugUnitTest --tests "com.hermes.client.notifications.RunProgressMapperTest" +``` +Expected: PASS — 10 tests. + +- [ ] **Step 6: Run the full suite to confirm the new pref field broke nothing** + +Run: +```bash +./gradlew :app:testDebugUnitTest +``` +Expected: BUILD SUCCESSFUL. `NotificationPrefs` gained a defaulted field, so existing constructor calls still compile. + +- [ ] **Step 7: Commit** + +```bash +git add app/src/main/java/com/hermes/client/notifications/NotificationModels.kt \ + app/src/main/java/com/hermes/client/notifications/RunProgressMapper.kt \ + app/src/test/java/com/hermes/client/notifications/RunProgressMapperTest.kt +git commit -m "feat: map run progress to a platform-independent notification spec" +``` + +--- + +### Task 3: Persist the runProgress preference and expose a toggle + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/data/repository/NotificationSettings.kt` +- Modify: `app/src/main/java/com/hermes/client/ui/settings/NotificationsScreen.kt` + +**Interfaces:** +- Consumes: `NotificationPrefs.runProgress` from Task 2. +- Produces: `NotificationSettings.setRunProgress(v: Boolean)`; `NotificationsViewModel.setRunProgress(v: Boolean)`. + +- [ ] **Step 1: Persist the preference** + +In `app/src/main/java/com/hermes/client/data/repository/NotificationSettings.kt`, update the KDoc (line 13), add the key after `kRunFinished` (line 17), read it in the `map` block (after line 23), and add the setter after `setRunFinished` (line 29). The full file becomes: + +```kotlin +package com.hermes.client.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.hermes.client.notifications.NotificationPrefs +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.notificationDataStore by preferencesDataStore(name = "notifications") + +/** + * Device-local notification preferences (master toggle + approvals + run-finished + + * run-progress). Off by default. + */ +class NotificationSettings(private val context: Context) { + private val kEnabled = booleanPreferencesKey("enabled") + private val kApprovals = booleanPreferencesKey("approvals") + private val kRunFinished = booleanPreferencesKey("runFinished") + private val kRunProgress = booleanPreferencesKey("runProgress") + + val prefs: Flow = context.notificationDataStore.data.map { p -> + NotificationPrefs( + enabled = p[kEnabled] ?: false, + approvals = p[kApprovals] ?: true, + runFinished = p[kRunFinished] ?: true, + runProgress = p[kRunProgress] ?: true, + ) + } + + suspend fun setEnabled(v: Boolean) = context.notificationDataStore.edit { it[kEnabled] = v } + suspend fun setApprovals(v: Boolean) = context.notificationDataStore.edit { it[kApprovals] = v } + suspend fun setRunFinished(v: Boolean) = context.notificationDataStore.edit { it[kRunFinished] = v } + suspend fun setRunProgress(v: Boolean) = context.notificationDataStore.edit { it[kRunProgress] = v } +} +``` + +- [ ] **Step 2: Add the ViewModel setter** + +In `app/src/main/java/com/hermes/client/ui/settings/NotificationsScreen.kt`, add after `setRunFinished` (line 49): + +```kotlin + fun setRunProgress(v: Boolean) = viewModelScope.launch { settings.setRunProgress(v) } +``` + +- [ ] **Step 3: Add the toggle row** + +In the same file, add after the "Run finished" `ToggleRow` block (which currently ends at line 102, before the closing `}` of the `Column`): + +```kotlin + HorizontalDivider() + ToggleRow( + "Live run progress", + "Show an ongoing notification with live progress while an agent run is in flight", + prefs.runProgress, + enabled = prefs.enabled, + ) { vm.setRunProgress(it) } +``` + +- [ ] **Step 4: Compile and run the suite** + +Run: +```bash +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +./gradlew :app:compileDebugKotlin && ./gradlew :app:testDebugUnitTest +``` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/com/hermes/client/data/repository/NotificationSettings.kt \ + app/src/main/java/com/hermes/client/ui/settings/NotificationsScreen.kt +git commit -m "feat: persist and expose the live run-progress preference" +``` + +--- + +### Task 4: Render the progress notification + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt` + +**Interfaces:** +- Consumes: `RunProgressSpec(title, body, done, total, indeterminate, route, shortText)` and `Notif.CHANNEL_RUN_PROGRESS` from Task 2; the existing private `openIntent(route, id)` (lines 71-77) and `accentArgb(profile, dark)` from `com.hermes.client.ui.theme`. +- Produces: `HermesNotifier.postRunProgress(spec: RunProgressSpec, profile: String?)`; `HermesNotifier.cancelRunProgress()`; `HermesNotifier.Companion.RUN_PROGRESS_NOTIFICATION_ID = 1003`. + +This task has no unit test: it is pure Android framework rendering with no branching logic worth faking, and the repo does not use Robolectric. All decision logic was tested in Tasks 1-2. Verification is compilation plus the on-device check in Task 5. + +- [ ] **Step 1: Add the channel** + +In `ensureChannels()` (lines 19-32), add after the `CHANNEL_ACTIVITY` block: + +```kotlin + sys.createNotificationChannel( + NotificationChannel(Notif.CHANNEL_RUN_PROGRESS, "Run progress", NotificationManager.IMPORTANCE_LOW), + ) +``` + +- [ ] **Step 2: Add the imports** + +Add to the import block at the top of `HermesNotifier.kt`: + +```kotlin +import android.content.res.Configuration +import android.os.Build +import com.hermes.client.ui.theme.accentArgb +``` + +- [ ] **Step 3: Add the post/cancel methods** + +Add after the existing `cancel(id: Int)` (line 69): + +```kotlin + /** + * Posts (or updates) the single ongoing run-progress notification. On API 36+ this uses the + * platform ProgressStyle so the system can promote it to a status-bar Live Update; below that + * it falls back to an ordinary ongoing progress notification. + * + * androidx.core 1.16.0 has no NotificationCompat.ProgressStyle, so the API 36+ branch builds + * with the platform Notification.Builder rather than upgrading the dependency. + */ + fun postRunProgress(spec: RunProgressSpec, profile: String?) { + if (!mgr.areNotificationsEnabled()) return + val accent = accentFor(profile) + val n = if (Build.VERSION.SDK_INT >= 36) buildPromoted(spec, accent) else buildCompat(spec, accent) + mgr.notify(RUN_PROGRESS_NOTIFICATION_ID, n) + } + + fun cancelRunProgress() = mgr.cancel(RUN_PROGRESS_NOTIFICATION_ID) + + /** Tenant accent, resolved against the system's current night mode. Chrome only. */ + private fun accentFor(profile: String?): Int { + val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + return accentArgb(profile, dark) + } + + @androidx.annotation.RequiresApi(36) + private fun buildPromoted(spec: RunProgressSpec, accent: Int): Notification { + val style = Notification.ProgressStyle().setProgressIndeterminate(spec.indeterminate) + if (!spec.indeterminate) { + // ProgressStyle has no setProgressMax(): the bar's maximum is the SUM of its segment + // lengths, so one segment of `total` gives a bar of exactly that length. + style.addProgressSegment(Notification.ProgressStyle.Segment(spec.total).setColor(accent)) + style.setProgress(spec.done) + } + val b = Notification.Builder(context, Notif.CHANNEL_RUN_PROGRESS) + .setSmallIcon(R.drawable.ic_stat_hermes) + .setContentTitle(spec.title) + .setContentText(spec.body) + .setStyle(style) + .setOngoing(true) + .setColor(accent) + .setContentIntent(openIntent(spec.route, RUN_PROGRESS_NOTIFICATION_ID)) + // Status-bar chip text on a promoted notification. The system decides promotion itself + // (Notification.FLAG_PROMOTED_ONGOING); there is no request API to call. + spec.shortText?.let { b.setShortCriticalText(it) } + return b.build() + } + + private fun buildCompat(spec: RunProgressSpec, accent: Int): Notification = + NotificationCompat.Builder(context, Notif.CHANNEL_RUN_PROGRESS) + .setSmallIcon(R.drawable.ic_stat_hermes) + .setContentTitle(spec.title) + .setContentText(spec.body) + .setProgress(spec.total, spec.done, spec.indeterminate) + .setOngoing(true) + .setColor(accent) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setContentIntent(openIntent(spec.route, RUN_PROGRESS_NOTIFICATION_ID)) + .build() +``` + +- [ ] **Step 4: Add the id constant** + +In the `companion object` (lines 109-111), add after `SERVICE_NOTIFICATION_ID`: + +```kotlin + // Distinct from SERVICE_NOTIFICATION_ID (1001) and from toNotificationSpec's 1002 + // collision fallback, so the ongoing progress notification can never clobber either. + const val RUN_PROGRESS_NOTIFICATION_ID = 1003 +``` + +- [ ] **Step 5: Compile** + +Run: +```bash +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +./gradlew :app:compileDebugKotlin +``` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt +git commit -m "feat: render the live run-progress notification with API 36 progress style" +``` + +--- + +### Task 5: Wire the service and verify end to end + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt` + +**Interfaces:** +- Consumes: `RunProgress()` / `reduce(event)` (Task 1), `toSpec(profileName, prefs)` (Task 2), `postRunProgress(spec, profile)` / `cancelRunProgress()` (Task 4), and the existing `ProfileManager.active: StateFlow`. +- Produces: nothing consumed downstream — this is the integration task. + +- [ ] **Step 1: Inject ProfileManager and add the state fields** + +In `app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt`, add the injection after `notifier` (line 24): + +```kotlin + @Inject lateinit var profiles: com.hermes.client.data.repository.ProfileManager +``` + +Add after the `appInForeground` field (line 36): + +```kotlin + // Live run state, folded from the same event stream. @Volatile for the same reason as the + // fields above: the collector scope has no single-thread dispatcher. + @Volatile private var runProgress = com.hermes.client.data.progress.RunProgress() + + // Last spec actually posted. message.delta fires many times per second and does not change + // the spec, so re-posting on every event would burn cycles and visibly flicker the + // notification. Only act when the derived spec actually changes. + @Volatile private var lastRunSpec: RunProgressSpec? = null +``` + +- [ ] **Step 2: Drive the notification from the collector** + +Replace the event collector block (currently lines 63-71) with: + +```kotlin + scope.launch { + client.events.collect { event -> + // One malformed/unexpected event must not crash the process — mirror the guard + // ChatViewModel's reduce() uses around event handling. + runCatching { + toNotificationSpec(event, latestPrefs, appInForeground)?.let { notifier.post(it) } + updateRunProgress(event) + } + } + } +``` + +Add this private method after `onStartCommand`/`onBind` (after line 75): + +```kotlin + /** Folds the event into run state and posts/cancels the progress notification on change. */ + private fun updateRunProgress(event: com.hermes.client.data.network.ServerEvent) { + runProgress = runProgress.reduce(event) + val spec = runProgress.toSpec(profiles.active.value, latestPrefs) + if (spec == lastRunSpec) return + lastRunSpec = spec + if (spec != null) notifier.postRunProgress(spec, profiles.active.value) + else notifier.cancelRunProgress() + } +``` + +Add the required imports at the top of the file: + +```kotlin +import com.hermes.client.data.progress.reduce +``` + +- [ ] **Step 3: Cancel on destroy** + +In `onDestroy()` (lines 89-96), add immediately after `scope.cancel()`: + +```kotlin + // A stopped service must never strand an ongoing "running" notification. + notifier.cancelRunProgress() +``` + +- [ ] **Step 4: Compile and run the full suite** + +Run: +```bash +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +./gradlew :app:compileDebugKotlin && ./gradlew :app:testDebugUnitTest +``` +Expected: BUILD SUCCESSFUL, all tests pass. + +- [ ] **Step 5: Build the beta variant** + +Run: +```bash +./gradlew :app:assembleBeta +``` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt +git commit -m "feat: drive the live run-progress notification from the gateway event stream" +``` + +- [ ] **Step 7: On-device verification (best-effort)** + +Target the emulator explicitly — `emulator-5554`. **Do NOT touch the physical device (serial `57150DLCH00385`).** + +```bash +adb -s emulator-5554 install -r app/build/outputs/apk/beta/app-beta.apk +``` + +Then, in the app: enable Settings → Notifications → "Enable notifications", confirm "Live run progress" is on, background the app, and send a prompt that makes the agent use tools. Expect an ongoing "acme · agent running" notification whose body tracks the active tool, becoming a determinate bar if the model uses the `todo` tool, and disappearing when the run ends. + +If the emulator is unavailable or no gateway is reachable, record that verification was skipped and why — do not fake the result. + +--- + +## Self-Review + +**Spec coverage:** + +| Spec requirement | Task | +|---|---| +| `RunProgress` model + pure reducer | 1 | +| Event→state table (all 7 rows) | 1 | +| `session.info` backstop + `ServerEvent` parsing | 1 | +| Cancelled excluded from total; determinate iff `total > 0` | 1 | +| `RunProgressSpec` + pure `toSpec` | 2 | +| Tenant title / tool body / accent | 2 (title, body), 4 (accent) | +| `runProgress` pref + persistence + toggle | 2 (model), 3 (store + UI) | +| `run_progress` channel at IMPORTANCE_LOW | 4 | +| `RUN_PROGRESS_NOTIFICATION_ID = 1003` | 4 | +| API-36 ProgressStyle / API 26-35 fallback | 4 | +| Cancel on complete/error/session.info-false | 1 (state) + 5 (cancel call) | +| Cancel in `onDestroy()` | 5 | +| All 10 spec test cases | 1 (cases 1-10), 2 (mapping) | + +No gaps. + +**Placeholder scan:** none — every code step carries complete code, and the one open fork from the spec (`NotificationCompat.ProgressStyle` availability) is resolved above with verified `javap` evidence. + +**Type consistency:** `RunProgress(running, sessionId, tool, done, total)` and `determinate` are used identically in Tasks 1, 2 and 5. `RunProgressSpec(title, body, done, total, indeterminate, route, shortText)` is declared in Task 2 and consumed unchanged in Tasks 4 and 5. `postRunProgress(spec, profile)` / `cancelRunProgress()` are declared in Task 4 and called with matching arity in Task 5. `toSpec(profileName, prefs)` is declared in Task 2 and called with matching arity in Task 5. + +**One risk carried into execution:** an indeterminate `ProgressStyle` with no segments is assumed valid on API 36. If the device check in Task 5 shows no bar rendering, add a single full-length segment and rely on `setProgressIndeterminate(true)` for the animation. diff --git a/docs/superpowers/specs/2026-07-20-live-run-progress-design.md b/docs/superpowers/specs/2026-07-20-live-run-progress-design.md new file mode 100644 index 0000000..7f13fad --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-live-run-progress-design.md @@ -0,0 +1,213 @@ +# Live in-flight run progress — design + +**Status:** approved +**Branch:** `feature/live-run-progress` (off `dev`) +**Roadmap item:** A1 in `docs/ideas/2026-07-16-competitive-refresh.md` (P0) + +## Problem + +> How might we make an agent run that is *already in flight* glanceable from outside the app? + +Hermes notifies on **completion** and renders nothing during the minutes a run is actually +executing. The only in-flight signal is a spinner inside an open chat screen. A user who +backgrounds the app has no way to tell whether their agent is working, stuck, or finished. +Cursor iOS streams this to the lock screen via Live Activities; Android 16 provides the +equivalent through `Notification.ProgressStyle` and promoted "Live Updates". + +## Scope + +Client-only. **No gateway changes.** Progress is *opportunistic*: indeterminate +("acme · agent running · Calling tool: web_search") by default, upgrading to a determinate +"3/5" bar whenever the model drives the `todo` tool. + +### Why not a true "step N/M" + +The roadmap proposed "step 3/5". That is not achievable on this wiring. The only genuine +iteration counter in the agent codebase (`api_call_count` vs `max_iterations`, +`agent/conversation_loop.py:715-766`) is wired into `gateway/run.py` and `acp_adapter/server.py` +only — never into `tui_gateway/server.py`, which is the path this app's WebSocket uses +(`grep -n step_callback tui_gateway/server.py` returns nothing). A generic determinate bar +therefore requires a gateway change, which is explicitly out of scope for this wave. + +The `todo` tool's list is the one real, client-reachable proxy, and it is the same signal the +Electron desktop client already uses for its "Tasks N/M" chip +(`apps/desktop/src/app/chat/composer/status-stack/index.tsx:49`). Using it is desktop parity. + +## Verified gateway facts + +All confirmed in source; do not re-derive during implementation. + +| Fact | Evidence | +|---|---| +| Events emitted via one `_emit(event, sid, payload)` dispatcher | `tui_gateway/server.py:1187-1191` | +| `message.start` | `server.py:9894` | +| `tool.start` — payload has `name` | `server.py:4048` | +| `tool.complete` — payload has `name` | `server.py:4095` | +| `message.complete` | `server.py:10157` | +| `session.info` carries `"running": bool` | `server.py:3805`, re-emitted `:10335-10339` | +| On `tool.complete` where `name == "todo"`, payload gains `todos` = full list | `server.py:4076-4082` | +| A todo item is `{id, content, status}`; `status ∈ {pending, in_progress, completed, cancelled}` | `tools/todo_tool.py:21,59` | +| `tool.start`/`tool.complete` are gated on `_tool_progress_enabled(sid)` | `server.py:4048,4095` | +| That gate defaults to `"all"`, i.e. enabled; only `tool_progress_mode: "off"` suppresses | `server.py:3096-3105` | + +**`tool.progress` is NOT emitted on this path** — it exists only in the unrelated +`gateway/platforms/api_server.py`. Do not use it. + +## Architecture + +### State location + +Run state must survive backgrounding and drive a notification, so it cannot live in +`ChatUiState` (per-open-session, owned by `ChatViewModel`, gone when the app is backgrounded). + +It is held as a **pure reducer's** value inside `GatewayConnectionService`, mirroring exactly how +that service already holds `latestPrefs` and `appInForeground` and calls the pure +`toNotificationSpec`. This adds no second collector on `client.events` and no new singleton, and +leaves all logic unit-testable as pure functions. + +A singleton `StateFlow` would only be required to feed an in-app chip, which is out of scope +(see Not Doing). Promote it then, not now. + +### Data flow + +``` +HermesGatewayClient.events + └─> GatewayConnectionService (existing single collector) + ├─> toNotificationSpec(...) // existing: approvals / clarify / finished / error + └─> runProgress = runProgress.reduce(event) // new: pure + └─> runProgress.toSpec(profileName) // new: pure, null when idle + ├─ non-null -> notifier.postRunProgress(spec) + └─ null -> notifier.cancelRunProgress() +``` + +### Event → state + +| Event | Effect on `RunProgress` | +|---|---| +| `message.start` | `running = true`; reset `tool`, `done`, `total`; capture `sessionId` | +| `tool.start` | `tool = payload["name"]` | +| `tool.complete`, `name == "todo"` | `done` = count(`status == "completed"`), `total` = count(`status != "cancelled"`) | +| `tool.complete`, other tool | clear `tool` | +| `message.complete` | `running = false`; reset | +| `error` | `running = false`; reset | +| `session.info` | `running = payload["running"]`; when false, reset | + +`session.info` is the **authoritative backstop**. `message.complete` alone misses interrupted and +compacted turns, which would otherwise strand a permanent "running…" notification. Android does +not parse `session.info` today (`ServerEvent.kt:11-25` reads only `type`/`sessionId`/`payload`); +wiring it closes a real correctness gap. + +### Determinate rule + +`total > 0` means determinate. `cancelled` todos are **excluded from `total`**: a cancelled task +never completes, so counting it would stall the bar permanently below 100%. This is a deliberate +divergence from the desktop client, which uses raw `items.length`. + +When a session runs with `tool_progress_mode: "off"`, no `tool.*` events arrive, so `total` +stays 0 and the surface degrades to indeterminate. That is correct and expected behaviour, not +an error state. + +## Notification + +A **new `run_progress` channel at `IMPORTANCE_LOW`** — present and glanceable in the shade, +silent, and eligible for promotion on API 36. + +Reusing the existing `service` channel is rejected: it is `IMPORTANCE_MIN`, which suppresses the +status-bar presence this feature exists to provide. The static MIN "Connected" foreground-service +notification therefore stays exactly as it is, and the progress notification is a **separate +ongoing notification** at `RUN_PROGRESS_NOTIFICATION_ID = 1003` (clear of the existing `1001` and +the mapper's `1002` fallback). + +Lifecycle: posted on run start, updated on `tool.start` and on todo counts, cancelled on run end +(`message.complete` / `error` / `session.info running=false`) **and** in `Service.onDestroy()` so +a stopped service never strands it. + +### Rendering + +- **API ≥ 36:** `ProgressStyle` + promoted ongoing (Live Update). +- **API 26–35:** `NotificationCompat.setProgress(total, done, indeterminate)` + `setOngoing(true)`. + +Every API-36 call site must be runtime-gated on `Build.VERSION.SDK_INT >= 36` +(`minSdk = 26`, `compileSdk`/`targetSdk = 37` — `app/build.gradle.kts:21,25,26`). + +**Implementation risk to resolve in the plan:** confirm whether `NotificationCompat.ProgressStyle` +exists in `androidx.core 1.16.0` (`gradle/libs.versions.toml:12`). If it does not, the API-36 +branch uses the platform `Notification.Builder` directly rather than upgrading the dependency. + +### Content + +- **Title:** `"acme · agent running"` — tenant name from `ProfileManager.active` + (`ProfileManager.kt:22-23`). +- **Body:** `"Calling tool: web_search"` when a tool is active, else `"Working…"`. +- **Colour:** tenant accent via `setColor(accentArgb(profile, dark))` + (`ui/theme/ProfileAccent.kt:45`). Accent is correct here — this is chrome, not a + down/error state. +- **Tap:** routes to `chat/$sessionId`, reusing the notifier's existing `openIntent` route + mechanism. + +### Preference + +Add `runProgress: Boolean = true` to `NotificationPrefs` (`NotificationModels.kt:4-8`), persisted +in `NotificationSettings` (`NotificationSettings.kt`) alongside `enabled`/`approvals`/ +`runFinished`, and exposed as a toggle in `ui/settings/NotificationsScreen.kt` beside the +existing switches. A persistent ongoing notification warrants an in-app switch rather than +forcing the user into system channel settings. + +Progress is gated by the master `enabled` pref like everything else, which means it only appears +while the notifications toggle is on — that is what runs `GatewayConnectionService` at all. This +is a documented limitation, not something to work around in this wave. + +## Multi-tenancy + +The WebSocket is a single Hilt `@Singleton` bound to one active profile at a time +(`AppModule.kt:69,125`; `ProfileManager.kt:18-41`), and `ServerEvent` carries no profile field. +There is structurally no concurrent multi-tenant run, so the notification always represents "the +active profile's run" and attribution is a direct read of `ProfileManager.active`. Tenant names +in tests and docs use the generic `acme`/`globex` convention. + +## Files + +| File | Change | +|---|---| +| `data/progress/RunProgress.kt` | **Create** — `RunProgress` model, pure `reduce(ServerEvent)`, pure `toSpec(profileName)` | +| `notifications/NotificationModels.kt` | Modify — `RunProgressSpec`, `CHANNEL_RUN_PROGRESS`, `EVENT_*` constants, `runProgress` pref | +| `notifications/HermesNotifier.kt` | Modify — `run_progress` channel in `ensureChannels()`, `postRunProgress()`, `cancelRunProgress()`, API-36 split | +| `notifications/GatewayConnectionService.kt` | Modify — hold reduced state, drive notifier, cancel in `onDestroy()` | +| `data/network/ServerEvent.kt` | Modify — helpers to read `session.info.running` and the `todos` array | +| `data/repository/NotificationSettings.kt` | Modify — persist `runProgress` | +| `ui/settings/NotificationsScreen.kt` | Modify — toggle | +| `test/.../RunProgressTest.kt` | **Create** — reducer + spec-mapping tests | + +## Testing + +Pure-logic and reducer-style only, per repo convention — fakes plus `runTest`, no Compose UI +tests. Events are constructed with the `ev()` helper pattern from `ChatReducerTest`. + +Cases: +1. `message.start` → running, indeterminate. +2. `tool.start` → tool name surfaces in the spec body. +3. todo `tool.complete` → determinate `done`/`total`. +4. `cancelled` todos excluded from `total`. +5. Second run resets counts from the previous run (no bleed-through). +6. `message.complete` → idle, `toSpec` returns null. +7. `error` → idle. +8. `session.info running=false` mid-run → idle (interrupt backstop). +9. No `tool.*` events (`tool_progress_mode: "off"`) → stays indeterminate while running. +10. Malformed/absent `todos` payload → no crash, stays indeterminate. + +## Not doing (and why) + +- **In-app progress chip.** The chat screen already shows a generating spinner; this feature's + value is the *backgrounded* surface. Deferred rather than bundled, and the pure-reducer design + makes promoting it to a singleton `StateFlow` cheap later. +- **Determinate `step N/M` for non-todo runs.** Requires the declined gateway change. +- **Cross-tenant concurrent progress.** Structurally impossible today (single active-profile WS); + tracked separately as a gateway-side wave. +- **Auto-restarting the service** so progress works with notifications off. Out of scope; the + opt-in limitation is documented instead. + +## Open questions + +None. Scope and rendering decisions are settled; the single implementation fork +(`NotificationCompat.ProgressStyle` availability in core 1.16.0) is resolved by inspection during +Task 1 of the plan and has a defined fallback. From 85818f01532a6e9260c47aa515bde409509463b3 Mon Sep 17 00:00:00 2001 From: Andrew Debnar Date: Mon, 20 Jul 2026 12:27:27 -0500 Subject: [PATCH 2/6] chore: bump version to 0.1.52 (live in-flight run progress) --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ca4bb6..da99405 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "com.hermes.client" minSdk = 26 targetSdk = 37 - versionCode = 55 - versionName = "0.1.51" + versionCode = 56 + versionName = "0.1.52" testInstrumentationRunner = "com.hermes.client.HiltTestRunner" // App name; the beta build type overrides this so both can be installed at once. manifestPlaceholders["appLabel"] = "Hermes" From b98e014da4b29776b407affa1411ad5635a20b5a Mon Sep 17 00:00:00 2001 From: Andrew Debnar Date: Mon, 20 Jul 2026 14:00:02 -0500 Subject: [PATCH 3/6] Fix review findings on run-progress notification - todoCounts(): skip non-object todos array elements entirely instead of letting a bare string/number/null inflate the total denominator - HermesNotifier: isolate the API-36 ProgressStyle construction in its own class so no API-36 type is named in HermesNotifier's own method bodies (defensive class-verification hardening, no behaviour change) - GatewayConnectionService.onCreate(): seed ProfileManager's active profile on a headless START_STICKY restart so the run-progress notification title still names the tenant --- .../hermes/client/data/network/ServerEvent.kt | 3 +- .../notifications/GatewayConnectionService.kt | 10 ++++ .../client/notifications/HermesNotifier.kt | 60 ++++++++++++------- .../client/data/progress/RunProgressTest.kt | 18 ++++++ 4 files changed, 69 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/hermes/client/data/network/ServerEvent.kt b/app/src/main/java/com/hermes/client/data/network/ServerEvent.kt index b159489..094cbbd 100644 --- a/app/src/main/java/com/hermes/client/data/network/ServerEvent.kt +++ b/app/src/main/java/com/hermes/client/data/network/ServerEvent.kt @@ -56,7 +56,8 @@ internal fun ServerEvent.todoCounts(): Pair { var done = 0 var total = 0 for (el in arr) { - val status = ((el as? JsonObject)?.get("status") as? JsonPrimitive)?.content?.lowercase() + val obj = el as? JsonObject ?: continue + val status = (obj["status"] as? JsonPrimitive)?.content?.lowercase() if (status == "cancelled") continue total++ if (status == "completed") done++ diff --git a/app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt b/app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt index 4660bf5..55b9d03 100644 --- a/app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt +++ b/app/src/main/java/com/hermes/client/notifications/GatewayConnectionService.kt @@ -70,6 +70,16 @@ class GatewayConnectionService : Service() { } lifecycleObserver = obs androidx.lifecycle.ProcessLifecycleOwner.get().lifecycle.addObserver(obs) + // START_STICKY means Android can recreate this service headlessly (no UI ever launched), + // in which case ProfileManager._active is still the initial null — nothing but UI code + // ever calls refresh(). Seed it here so a bare-restart run's progress notification still + // gets the tenant name instead of falling back to a generic title. Non-blocking (launched, + // not awaited) and runCatching-wrapped: a gateway unreachable at boot must not crash the + // service. Skipped when a profile is already known so we don't redundantly refresh on + // every ordinary (UI-driven) start. + if (profiles.active.value == null) { + scope.launch { runCatching { profiles.refresh() } } + } // Track the latest prefs reactively so the event loop reads a cached value instead of // collecting DataStore per event. Started first so its replayed value is in place before // events arrive; also picks up mid-run toggles (e.g. approvals turned off). diff --git a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt index 2e7fe5b..4e46014 100644 --- a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt +++ b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt @@ -99,27 +99,13 @@ class HermesNotifier(private val context: Context) { } @androidx.annotation.RequiresApi(36) - private fun buildPromoted(spec: RunProgressSpec, accent: Int): Notification { - val style = Notification.ProgressStyle().setProgressIndeterminate(spec.indeterminate) - if (!spec.indeterminate) { - // ProgressStyle has no setProgressMax(): the bar's maximum is the SUM of its segment - // lengths, so one segment of `total` gives a bar of exactly that length. - style.addProgressSegment(Notification.ProgressStyle.Segment(spec.total).setColor(accent)) - style.setProgress(spec.done) - } - val b = Notification.Builder(context, Notif.CHANNEL_RUN_PROGRESS) - .setSmallIcon(R.drawable.ic_stat_hermes) - .setContentTitle(spec.title) - .setContentText(spec.body) - .setStyle(style) - .setOngoing(true) - .setColor(accent) - .setContentIntent(openIntent(spec.route, RUN_PROGRESS_NOTIFICATION_ID)) - // Status-bar chip text on a promoted notification. The system decides promotion itself - // (Notification.FLAG_PROMOTED_ONGOING); there is no request API to call. - spec.shortText?.let { b.setShortCriticalText(it) } - return b.build() - } + private fun buildPromoted(spec: RunProgressSpec, accent: Int): Notification = + Api36RunProgressBuilder.build( + context = context, + spec = spec, + accent = accent, + contentIntent = openIntent(spec.route, RUN_PROGRESS_NOTIFICATION_ID), + ) private fun buildCompat(spec: RunProgressSpec, accent: Int): Notification = NotificationCompat.Builder(context, Notif.CHANNEL_RUN_PROGRESS) @@ -179,3 +165,35 @@ class HermesNotifier(private val context: Context) { const val RUN_PROGRESS_NOTIFICATION_ID = 1003 } } + +/** + * Isolates the API-36-only `Notification.ProgressStyle` construction in its own class so that + * [HermesNotifier]'s own method bodies never name an API-36 type. ART verifies dex classes + * lazily and per-class, so this is defensive hardening against class-verification issues rather + * than a behaviour change — [HermesNotifier.buildPromoted] only reaches this class from behind + * the existing `Build.VERSION.SDK_INT >= 36` guard. + */ +@androidx.annotation.RequiresApi(36) +private object Api36RunProgressBuilder { + fun build(context: Context, spec: RunProgressSpec, accent: Int, contentIntent: PendingIntent): Notification { + val style = Notification.ProgressStyle().setProgressIndeterminate(spec.indeterminate) + if (!spec.indeterminate) { + // ProgressStyle has no setProgressMax(): the bar's maximum is the SUM of its segment + // lengths, so one segment of `total` gives a bar of exactly that length. + style.addProgressSegment(Notification.ProgressStyle.Segment(spec.total).setColor(accent)) + style.setProgress(spec.done) + } + val b = Notification.Builder(context, Notif.CHANNEL_RUN_PROGRESS) + .setSmallIcon(R.drawable.ic_stat_hermes) + .setContentTitle(spec.title) + .setContentText(spec.body) + .setStyle(style) + .setOngoing(true) + .setColor(accent) + .setContentIntent(contentIntent) + // Status-bar chip text on a promoted notification. The system decides promotion itself + // (Notification.FLAG_PROMOTED_ONGOING); there is no request API to call. + spec.shortText?.let { b.setShortCriticalText(it) } + return b.build() + } +} diff --git a/app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt b/app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt index 6bb25bc..4c17996 100644 --- a/app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt +++ b/app/src/test/java/com/hermes/client/data/progress/RunProgressTest.kt @@ -128,6 +128,24 @@ class RunProgressTest { assertEquals(1, s.total) } + @Test fun junk_array_elements_are_excluded_from_both_counters() { + var s = RunProgress().reduce(ev("message.start"), "acme") + s = s.reduce( + ev("tool.complete") { + put("name", "todo") + putJsonArray("todos") { + addJsonObject { put("id", "1"); put("content", "task 1"); put("status", "completed") } + add(kotlinx.serialization.json.JsonPrimitive("not-an-object")) + add(kotlinx.serialization.json.JsonNull) + addJsonObject { put("id", "2"); put("content", "task 2"); put("status", "pending") } + } + }, + "acme", + ) + assertEquals(1, s.done) + assertEquals(2, s.total) + } + @Test fun non_todo_tool_complete_clears_the_tool_without_touching_counts() { var s = RunProgress().reduce(ev("message.start"), "acme") s = s.reduce(todoEvent("completed", "pending"), "acme") From 55480bf6c301ae9adfccb6624ac7e0e331bb5aeb Mon Sep 17 00:00:00 2001 From: Andrew Debnar Date: Mon, 20 Jul 2026 14:15:03 -0500 Subject: [PATCH 4/6] fix: name notification intent components explicitly for static analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kotlin's ::class.java compiles to a reflection call rather than a type literal, so Intent(Context, Class) is not recognised as setting a component and the resulting PendingIntents read as implicit. Use setClassName() with the class's own name instead: identical at runtime, rename-safe, and unambiguously explicit. Matters most for the direct-reply intent, which must be FLAG_MUTABLE — mutable plus implicit is the genuinely exploitable combination. --- .../client/notifications/HermesNotifier.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt index 4e46014..1d7069b 100644 --- a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt +++ b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt @@ -120,7 +120,14 @@ class HermesNotifier(private val context: Context) { .build() private fun openIntent(route: String?, id: Int): PendingIntent { - val intent = Intent(context, MainActivity::class.java).apply { + val intent = Intent().apply { + // Target our own Activity by name rather than via Intent(Context, Class). Both are + // equally explicit at runtime, but static analysis models setClassName() as "component + // set", whereas Kotlin's `::class.java` compiles to a reflection call rather than a + // type literal and reads as an *implicit* intent — which would be a real finding if + // it were true, since an implicit PendingIntent handed to the shade is redirectable. + // `.name` keeps this rename-safe. + setClassName(context, MainActivity::class.java.name) flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP route?.let { putExtra("extra_route", it) } } @@ -128,7 +135,9 @@ class HermesNotifier(private val context: Context) { } private fun actionIntent(a: NotifAction, notifId: Int): PendingIntent { - val intent = Intent(context, NotificationActionReceiver::class.java).apply { + val intent = Intent().apply { + // Explicit component by name — see the note in openIntent(). + setClassName(context, NotificationActionReceiver::class.java.name) action = a.action putExtra("session_id", a.sessionId) putExtra("notif_id", notifId) @@ -137,7 +146,11 @@ class HermesNotifier(private val context: Context) { } private fun replyIntent(a: NotifAction, notifId: Int): PendingIntent { - val intent = Intent(context, NotificationActionReceiver::class.java).apply { + val intent = Intent().apply { + // Explicit component by name — see the note in openIntent(). This matters most here: + // direct reply requires FLAG_MUTABLE below, and mutable + implicit is the actual + // vulnerable combination. Naming the component keeps it explicit and unredirectable. + setClassName(context, NotificationActionReceiver::class.java.name) action = a.action putExtra("session_id", a.sessionId) putExtra("notif_id", notifId) From 72d56aedf1ebadb967c9d0b71aeea148cb31b2ea Mon Sep 17 00:00:00 2001 From: Andrew Debnar Date: Mon, 20 Jul 2026 14:24:17 -0500 Subject: [PATCH 5/6] fix: spell PendingIntent flags out at each creation site Static analysis constant-folds a flag literal at the call site but not a value returned from a helper, so FLAG_IMMUTABLE was unprovable and the PendingIntents read as mutable. Drops the pendingFlags() helper in favour of the literal; no runtime change. --- .../client/notifications/HermesNotifier.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt index 1d7069b..388f73b 100644 --- a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt +++ b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt @@ -131,7 +131,15 @@ class HermesNotifier(private val context: Context) { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP route?.let { putExtra("extra_route", it) } } - return PendingIntent.getActivity(context, id, intent, pendingFlags()) + // Flags spelled out at the creation site rather than via a helper: static analysis + // constant-folds a literal here, but not a value returned from a function, and an + // unprovable FLAG_IMMUTABLE reads as a mutable PendingIntent. + return PendingIntent.getActivity( + context, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) } private fun actionIntent(a: NotifAction, notifId: Int): PendingIntent { @@ -142,7 +150,13 @@ class HermesNotifier(private val context: Context) { putExtra("session_id", a.sessionId) putExtra("notif_id", notifId) } - return PendingIntent.getBroadcast(context, (a.action + a.sessionId).hashCode(), intent, pendingFlags()) + // Flags inline — see the note in openIntent(). + return PendingIntent.getBroadcast( + context, + (a.action + a.sessionId).hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) } private fun replyIntent(a: NotifAction, notifId: Int): PendingIntent { @@ -168,7 +182,6 @@ class HermesNotifier(private val context: Context) { ) } - private fun pendingFlags() = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE companion object { const val SERVICE_NOTIFICATION_ID = 1001 From 0158632058cefb09bb69e9522f866081d5ac82ac Mon Sep 17 00:00:00 2001 From: Andrew Debnar Date: Mon, 20 Jul 2026 14:33:00 -0500 Subject: [PATCH 6/6] fix: build notification intents with direct calls, not apply blocks Mutations inside a Kotlin apply { } block are made on the lambda receiver, which static analysis does not reliably attribute back to the variable, so the component set there was invisible and the intents read as implicit. Assign to the variable directly instead. No runtime change. --- .../client/notifications/HermesNotifier.kt | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt index 388f73b..4d6e38a 100644 --- a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt +++ b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt @@ -120,17 +120,15 @@ class HermesNotifier(private val context: Context) { .build() private fun openIntent(route: String?, id: Int): PendingIntent { - val intent = Intent().apply { - // Target our own Activity by name rather than via Intent(Context, Class). Both are - // equally explicit at runtime, but static analysis models setClassName() as "component - // set", whereas Kotlin's `::class.java` compiles to a reflection call rather than a - // type literal and reads as an *implicit* intent — which would be a real finding if - // it were true, since an implicit PendingIntent handed to the shade is redirectable. - // `.name` keeps this rename-safe. - setClassName(context, MainActivity::class.java.name) - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - route?.let { putExtra("extra_route", it) } - } + // Built with direct calls on the variable rather than inside an `apply { }` block, and + // targeted by class name rather than via Intent(Context, Class). All forms are equally + // explicit at runtime, but this is the one static analysis reliably attributes to the + // intent — an implicit PendingIntent handed to the notification shade IS redirectable, + // so it is worth making the component unmistakable. `.name` keeps it rename-safe. + val intent = Intent() + intent.setClassName(context, MainActivity::class.java.name) + intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + route?.let { intent.putExtra("extra_route", it) } // Flags spelled out at the creation site rather than via a helper: static analysis // constant-folds a literal here, but not a value returned from a function, and an // unprovable FLAG_IMMUTABLE reads as a mutable PendingIntent. @@ -143,13 +141,12 @@ class HermesNotifier(private val context: Context) { } private fun actionIntent(a: NotifAction, notifId: Int): PendingIntent { - val intent = Intent().apply { - // Explicit component by name — see the note in openIntent(). - setClassName(context, NotificationActionReceiver::class.java.name) - action = a.action - putExtra("session_id", a.sessionId) - putExtra("notif_id", notifId) - } + // Direct calls, explicit component — see the note in openIntent(). + val intent = Intent() + intent.setClassName(context, NotificationActionReceiver::class.java.name) + intent.action = a.action + intent.putExtra("session_id", a.sessionId) + intent.putExtra("notif_id", notifId) // Flags inline — see the note in openIntent(). return PendingIntent.getBroadcast( context, @@ -160,16 +157,15 @@ class HermesNotifier(private val context: Context) { } private fun replyIntent(a: NotifAction, notifId: Int): PendingIntent { - val intent = Intent().apply { - // Explicit component by name — see the note in openIntent(). This matters most here: - // direct reply requires FLAG_MUTABLE below, and mutable + implicit is the actual - // vulnerable combination. Naming the component keeps it explicit and unredirectable. - setClassName(context, NotificationActionReceiver::class.java.name) - action = a.action - putExtra("session_id", a.sessionId) - putExtra("notif_id", notifId) - putExtra("request_id", a.requestId.orEmpty()) - } + // Direct calls, explicit component — see the note in openIntent(). This matters most + // here: direct reply requires FLAG_MUTABLE below, and mutable + implicit is the actually + // vulnerable combination. Naming the component keeps it explicit and unredirectable. + val intent = Intent() + intent.setClassName(context, NotificationActionReceiver::class.java.name) + intent.action = a.action + intent.putExtra("session_id", a.sessionId) + intent.putExtra("notif_id", notifId) + intent.putExtra("request_id", a.requestId.orEmpty()) // Direct-reply requires FLAG_MUTABLE so the system can attach the RemoteInput results. // The intent is explicit (our own receiver), so it can't be redirected — mutability is safe. return PendingIntent.getBroadcast(