From 8262ceb78661311495e18a9fbd6309aa3e4c08fb Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Tue, 15 Sep 2026 13:44:32 -0400 Subject: [PATCH] Show live background task counts --- AGENTS.md | 5 +- PLAN.md | 9 +++- SUBAGENTS.md | 8 +-- TODO.md | 4 ++ .../src/main/kotlin/com/example/aiapp/Api.kt | 3 ++ .../main/kotlin/com/example/aiapp/Events.kt | 4 ++ .../com/example/aiapp/SessionListScreen.kt | 8 +++ .../kotlin/com/example/aiapp/SessionScreen.kt | 37 +++++++++++++- .../com/example/aiapp/SessionStatusWords.kt | 2 + .../com/example/aiapp/TranscriptItems.kt | 1 + server/src/session/claude.rs | 4 ++ server/src/session/claude/translate.rs | 51 ++++++++++++------- server/src/session/driver.rs | 14 +++++ server/src/session/echo.rs | 32 ++++++++++-- server/src/session/mod.rs | 6 +++ server/src/session/transcript.rs | 1 + 16 files changed, 160 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9c2dd83..8c34f33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,7 +264,10 @@ written, and the fold uses that same predicate to decide a reply is settled. - **Claude's background-task level is authority; two edge sources are the fallback.** Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` replaces the live set and repairs - a missed ending edge. An adopted CLI is sent a repeated `initialize` to ask + a missed ending edge. Its array size is also the measured `backgroundTasks` + count exposed on the session row and event stream; the phone draws a nonzero + count beside the status rather than deriving one from `waiting` or from the + subagent directory. An adopted CLI is sent a repeated `initialize` to ask for the current set. Reconcile only between turns or at a result boundary: a foreground agent is legitimately absent from a background-only snapshot. Older CLIs still need both edge sources: `open_tasks` knows about a diff --git a/PLAN.md b/PLAN.md index 52b9f53..9c10176 100644 --- a/PLAN.md +++ b/PLAN.md @@ -858,7 +858,9 @@ the reader was not asking after, and one of them turned out to be a whole shell command drawn as centred prose, because its words came from somewhere with no reason to keep them short. The parent's transcript gets a row for a message a subagent genuinely *sends* it, which arrives by the peer path and already has -one. +one. The live provider-reported count beside the parent session's status is +deliberately the only background-task UI until there is a design for inspecting +them. **The report goes to whichever record is the only one of it**, and the two cases are different places. A subagent has a transcript of its own, and its closing @@ -874,7 +876,10 @@ the ones a stale "running in background" reads worst on. (2026-09-15). Claude Code 2.1.261 added `background_tasks_changed { tasks: [...] }` with replace semantics expressly so a missed bookend cannot wedge a running indicator. Its ids are not correlated -with the edge stream; this side uses the authoritative empty/nonempty level. +with the edge stream; this side uses the authoritative empty/nonempty level and +its measured size. That size is exposed as `backgroundTasks` in the session row +and event stream, and is drawn beside the status; background tasks do not become +subagent cards. The driver sends a repeated `initialize` when it adopts a CLI, which prompts a full snapshot without restarting the conversation. A parent already recorded as idle or waiting can apply it immediately; one adopted mid-turn waits for the result boundary, diff --git a/SUBAGENTS.md b/SUBAGENTS.md index 855dfaf..b8eb110 100644 --- a/SUBAGENTS.md +++ b/SUBAGENTS.md @@ -89,9 +89,11 @@ transcript is still being written to and its process is the session's to stop. Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is the authoritative level beside those edges: its set replaces the previous set, so a missed terminal edge cannot leave a subagent running forever. Its - ids are deliberately not correlated with the edge stream; the useful claim - here is whether the set is empty. The edges still carry mapping, outcome - and closing summary. On adoption the driver sends a repeated `initialize`, + ids are deliberately not correlated with the edge stream; the useful claims + here are whether the set is empty and its measured size. The session API and + stream expose that size as `backgroundTasks`, which the phone draws beside + the status without pretending those tasks are subagents. The edges still + carry mapping, outcome and closing summary. On adoption the driver sends a repeated `initialize`, which makes a current CLI send the full set; an older CLI accepts it and sends no level, leaving the edge-based path unchanged. A snapshot is reconciled immediately when the persisted parent status proves it is between turns, and otherwise diff --git a/TODO.md b/TODO.md index 41d0504..ddb6aa9 100644 --- a/TODO.md +++ b/TODO.md @@ -5,6 +5,10 @@ one in place when it turns out to need a decision. ## App — transcript +- [ ] Decide how running background tasks can be inspected. For now the session + status shows only the provider-reported count; command details stay in + their existing tool cards and must not become subagent cards. + - [ ] Messages received from other agents are inconsistent — sometimes they appear, sometimes they don't. **Needs a rig.** Read the code rather than measured: a live Claude session only learns of a peer message from the diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index fe9d0ae..6ef9a6a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -225,6 +225,8 @@ data class SessionSummary( val usageProvider: String?, val status: String, val lastActivity: Double, + /** Latest measured number of live background tasks; zero also covers older servers. */ + val backgroundTasks: Int, /** * How many subagents this session has, however their own status now reads. * @@ -263,6 +265,7 @@ private fun parseSession(session: JSONObject) = usageProvider = session.optString("usageProvider").ifEmpty { null }, status = session.getString("status"), lastActivity = session.getDouble("lastActivity"), + backgroundTasks = session.optInt("backgroundTasks", 0), subagents = session.optInt("subagents", 0), ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt index edad1e8..05bc4bd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt @@ -140,6 +140,9 @@ sealed class SessionEvent { /** The same command, handed to the session. */ data class CommandSent(val id: String, val text: String) : SessionEvent() + /** Provider-reported number of background tasks alive now. */ + data class BackgroundTasks(val count: Int) : SessionEvent() + data class Status(val state: String) : SessionEvent() /** @@ -283,6 +286,7 @@ fun parseSeqEvent(json: String): SeqEvent { "commandQueued" -> SessionEvent.CommandQueued(body.getString("id"), body.getString("text")) "commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text")) + "backgroundTasks" -> SessionEvent.BackgroundTasks(body.getInt("count")) "status" -> SessionEvent.Status(body.getString("state")) "settings" -> SessionEvent.Settings( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index c7f0a7d..7f1095b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -554,6 +554,14 @@ private fun SessionCard( modifier = Modifier.weight(1f), ) StatusText(session.status) + if (session.backgroundTasks > 0) { + Text( + backgroundTaskLabel(session.backgroundTasks), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + } } Spacer(Modifier.height(4.dp)) Row(modifier = Modifier.fillMaxWidth()) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 741e6d2..50327c8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -256,6 +256,8 @@ fun SessionScreen( val topEdgeHeld = remember { TopEdgeHold() } var items by remember { mutableStateOf(listOf()) } var status by remember { mutableStateOf(subagent?.status ?: summary.status) } + var backgroundTasks by + remember(address) { mutableIntStateOf(if (isSubagent) 0 else summary.backgroundTasks) } // Seeded from the row this screen was opened from, so a conversation already under way says how // much it is holding before any turn happens here. Null is "nobody has measured it", which is a // different answer from an empty context and is drawn differently. @@ -538,6 +540,9 @@ fun SessionScreen( } status = event.state } + if (event is SessionEvent.BackgroundTasks && !isSubagent) { + backgroundTasks = event.count + } if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event) // In order, always: one late event recorded ahead of the backlog would fold a // streamed delta into whatever row happened to be last by then. @@ -1842,6 +1847,7 @@ fun SessionScreen( status = status, compactingFor = compactingFor, contextTokens = contextTokens, + backgroundTasks = backgroundTasks, subagent = isSubagent, ) @@ -2362,6 +2368,8 @@ private fun SessionStatusRow( compactingFor: Long?, /** Context the session is holding, or null where nothing has measured it. */ contextTokens: Long?, + /** Provider-reported live background work; zero is deliberately not drawn. */ + backgroundTasks: Int, modifier: Modifier = Modifier, /** * Whether this row is for a subagent rather than a session, which changes only one word: @@ -2395,6 +2403,14 @@ private fun SessionStatusRow( // its own contrast, since the surface under it will not change to rescue it. color = commandColor, ) + if (backgroundTasks > 0) { + Text( + backgroundTaskLabel(backgroundTasks), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + } LinearProgressIndicator( color = commandColor, trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, @@ -2415,6 +2431,14 @@ private fun SessionStatusRow( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp), ) + if (backgroundTasks > 0) { + Text( + backgroundTaskLabel(backgroundTasks), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + } Spacer(Modifier.weight(1f)) } // Every remaining state says which one it is, including the quiet one. The row used to @@ -2423,13 +2447,22 @@ private fun SessionStatusRow( // showed nothing at all. The words and the colour are `sessionStatusWord`'s and // `sessionStatusColour`'s, shared with the session list so one state is not called two // things -- or drawn two colours -- depending which screen you are on. - else -> + else -> { Text( sessionStatusWord(status, subagent), style = MaterialTheme.typography.labelSmall, color = sessionStatusColour(status), - modifier = Modifier.weight(1f), ) + if (backgroundTasks > 0) { + Text( + backgroundTaskLabel(backgroundTasks), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + } + Spacer(Modifier.weight(1f)) + } } // How full the session is, which is the number a reader is asking about -- how much room is // left before the next compaction -- rather than what has been spent getting here. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionStatusWords.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionStatusWords.kt index aa9b2e3..35802d9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionStatusWords.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionStatusWords.kt @@ -38,6 +38,8 @@ fun sessionStatusWord(status: String, subagent: Boolean = false): String = else -> status } +fun backgroundTaskLabel(count: Int): String = "$count bg ${if (count == 1) "task" else "tasks"}" + /** * The colour that goes with [sessionStatusWord]: the accent is spent on the states that are about * to do something or want something, and every quiet one shares the muted colour. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt index 657b547..47b196c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt @@ -517,6 +517,7 @@ fun foldEvent(items: List, entry: SeqEvent): List items is SessionEvent.Settings -> items + is SessionEvent.BackgroundTasks -> items is SessionEvent.Status -> settleReply(items, event.state) is SessionEvent.AuthenticationRequired -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index b985496..a25ca42 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -510,6 +510,10 @@ impl ClaudeDriver { } impl Driver for ClaudeDriver { + fn background_tasks(&self) -> Option { + self.state.lock().unwrap().background_task_count() + } + fn send_user_message(&self, text: String, attachments: Vec) { let mut content = Vec::new(); // An image goes into the message itself; the model looks at it. Any diff --git a/server/src/session/claude/translate.rs b/server/src/session/claude/translate.rs index df16393..4402e51 100644 --- a/server/src/session/claude/translate.rs +++ b/server/src/session/claude/translate.rs @@ -136,8 +136,9 @@ pub(super) struct Translator { /// Claude's level signal for background work, when this CLI is new enough /// to send one. Unlike `open_tasks`, this is a snapshot: each new value /// replaces the old one, so a missed ending edge cannot leave work open - /// forever. See [`Translator::translate_background_tasks`]. - background_tasks: Option, + /// forever. The count is also shown beside the session's status. See + /// [`Translator::translate_background_tasks`]. + background_tasks: Option, /// Tasks the level signal closed before their ordinary notification /// arrived. That notification still owns the useful summary, so it gets /// one chance to update the transcript or tool card after the status was @@ -649,17 +650,18 @@ impl Translator { }; let was_outstanding = self.work_outstanding(); // The CLI explicitly says not to correlate these ids with its edge - // stream. Their useful claim here is the level: empty or not. - self.background_tasks = Some(!tasks.is_empty()); + // stream. Their useful claims here are the level and its exact size. + self.background_tasks = Some(tasks.len()); + let mut events = vec![Event::BackgroundTasks { count: tasks.len() }]; // During a turn the snapshot omits a foreground task, so wait for the // result boundary before using it to close anything. Between turns, // every task that can still be alive is background work and the level // can repair a missed notification immediately. if !self.settled || self.in_turn { - return Vec::new(); + return events; } - let mut events = self.reconcile_background_tasks(); + events.extend(self.reconcile_background_tasks()); let is_outstanding = self.work_outstanding(); if was_outstanding != is_outstanding { events.push(Event::Status { @@ -677,7 +679,7 @@ impl Translator { /// foreground task can remain. Returns updates for background commands; /// subagents carry the same correction in their own status transcript. fn reconcile_background_tasks(&mut self) -> Vec { - let Some(false) = self.background_tasks else { + let Some(0) = self.background_tasks else { return Vec::new(); }; let mut events = Vec::new(); @@ -717,11 +719,17 @@ impl Translator { /// `session_running` is true by construction: this is only ever asked /// while translating a line the session's process just wrote. fn work_outstanding(&self) -> bool { - self.background_tasks == Some(true) + self.background_tasks.is_some_and(|count| count > 0) || !self.open_tasks.is_empty() || self.subagents.any_open(true) } + /// The latest count Claude supplied, and `None` until this process has + /// supplied its first authoritative snapshot. + pub(super) fn background_task_count(&self) -> Option { + self.background_tasks + } + /// A task reporting back, from whichever of the two lines got here first. /// /// Handled once. The two shapes can both arrive for one task, and what @@ -1853,23 +1861,30 @@ mod tests { let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents)); translator.mark_settled(); - assert!( + assert_eq!( translate_lines( &mut translator, - &[r#"{"type":"system","subtype":"background_tasks_changed","tasks":["toolu_stale"]}"#], - ) - .is_empty() + &[ + r#"{"type":"system","subtype":"background_tasks_changed","tasks":["toolu_stale","command-2","command-3","command-4","command-5"]}"# + ], + ), + vec![Event::BackgroundTasks { count: 5 }] ); + assert_eq!(translator.background_task_count(), Some(5)); assert_eq!(subagents.list(true)[0].status, SessionStatus::Running); assert_eq!( translate_lines( &mut translator, &[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#], ), - vec![Event::Status { - state: SessionStatus::Idle - }] + vec![ + Event::BackgroundTasks { count: 0 }, + Event::Status { + state: SessionStatus::Idle + } + ] ); + assert_eq!(translator.background_task_count(), Some(0)); assert_eq!(subagents.list(true)[0].status, SessionStatus::Exited); } @@ -1884,12 +1899,12 @@ mod tests { let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents)); translator.mark_running(); - assert!( + assert_eq!( translate_lines( &mut translator, &[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#], - ) - .is_empty() + ), + vec![Event::BackgroundTasks { count: 0 }] ); assert_eq!(subagents.list(true)[0].status, SessionStatus::Running); diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs index b15d041..bd3dd7a 100644 --- a/server/src/session/driver.rs +++ b/server/src/session/driver.rs @@ -337,6 +337,14 @@ pub enum Event { id: String, answers: Vec, }, + /// How many background tasks the provider says are alive now. + /// + /// State rather than a transcript row: the phone draws it beside the + /// session status. A distinct event keeps the count current while a + /// session is open; `GET /sessions` supplies the opening snapshot. + BackgroundTasks { + count: usize, + }, Status { state: SessionStatus, }, @@ -596,6 +604,12 @@ pub type EventSink = mpsc::UnboundedSender; /// with live input injects it at the next tool boundary, while a turn-at-a-time /// dialect queues it for the next child process. pub trait Driver: Send + Sync { + /// The provider's latest measured number of live background tasks. + /// `None` means it has not reported one, not that the count is zero. + fn background_tasks(&self) -> Option { + None + } + /// Takes a message, now or once the session is free for it. /// /// Every driver owes exactly one `MessageTaken` per message, at the moment diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index b6b9baf..c168043 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -124,6 +124,9 @@ pub struct EchoDriver { /// says it recovered, and a clear leaves it unmeasured. What is real is /// which way the numbers move. context: Arc, + /// Live background commands, for the same count a real provider reports. + /// This is the deterministic UI/session-lifecycle rig for that state. + background_tasks: Arc>, /// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the /// test rig for the same registry the claude driver routes real Task /// calls into. @@ -500,6 +503,12 @@ impl EchoDriver { id: id.clone(), output: format!("Command running in background with ID: {id}"), }); + let background_tasks = Arc::clone(&self.background_tasks); + { + let mut count = background_tasks.lock().unwrap(); + *count += 1; + self.emit(Event::BackgroundTasks { count: *count }); + } for word in "Started it; I'll pick this up when it lands.".split_inclusive(' ') { self.emit(Event::AssistantText { delta: word.to_string(), @@ -513,6 +522,11 @@ impl EchoDriver { let sink = self.sink.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(seconds)).await; + { + let mut count = background_tasks.lock().unwrap(); + *count -= 1; + let _ = sink.send(Event::BackgroundTasks { count: *count }); + } let _ = sink.send(Event::ToolUpdate { id, output: format!(r#"Background command "{command}" completed (exit code 0)"#), @@ -526,9 +540,16 @@ impl EchoDriver { }); tokio::time::sleep(DELTA_DELAY).await; } - let _ = sink.send(Event::Status { - state: SessionStatus::Idle, - }); + { + let count = background_tasks.lock().unwrap(); + let _ = sink.send(Event::Status { + state: if *count == 0 { + SessionStatus::Idle + } else { + SessionStatus::Waiting + }, + }); + } }); return; } @@ -855,6 +876,7 @@ impl EchoDriver { sink, pending_questions: Mutex::new(Vec::new()), context: Arc::new(AtomicU64::new(0)), + background_tasks: Arc::new(Mutex::new(0)), busy: Arc::new(AtomicBool::new(false)), queued: Arc::new(Mutex::new(Vec::new())), session_dir, @@ -1165,6 +1187,10 @@ fn finish_turn(sink: &EventSink, queued: &Mutex>, busy: &AtomicBool) { } impl Driver for EchoDriver { + fn background_tasks(&self) -> Option { + Some(*self.background_tasks.lock().unwrap()) + } + fn between_turns(&self) -> bool { !self.busy.load(Ordering::SeqCst) } diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index d729b52..1f9e21f 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -272,6 +272,10 @@ pub struct SessionInfo { pub status: SessionStatus, pub last_activity: f64, pub created: f64, + /// The provider's latest measured number of live background tasks. + /// Absent until it reports one; absence is not a measured zero. + #[serde(skip_serializing_if = "Option::is_none")] + pub background_tasks: Option, /// How many subagents this session has started, from a directory /// listing rather than reading each one's status -- see /// `GET /sessions/{id}/subagents` for that. 0 when it has none, not @@ -608,6 +612,7 @@ impl LiveSession { status: *self.shared.status.lock().unwrap(), last_activity: *self.shared.last_activity.lock().unwrap(), created: self.meta.created, + background_tasks: self.driver().and_then(|driver| driver.background_tasks()), subagents: subagent::count(self.dir()), } } @@ -1140,6 +1145,7 @@ impl SessionManager { status: status_of_unlaunched(&self.data_dir.join(&meta.id)), last_activity: meta.created, created: meta.created, + background_tasks: None, subagents: subagent::count(&self.data_dir.join(&meta.id)), }, }) diff --git a/server/src/session/transcript.rs b/server/src/session/transcript.rs index 4ca8884..e6d698e 100644 --- a/server/src/session/transcript.rs +++ b/server/src/session/transcript.rs @@ -866,6 +866,7 @@ mod tests { id: "q1".into(), answers: vec!["Yes".into()], }, + Event::BackgroundTasks { count: 5 }, Event::Status { state: SessionStatus::Idle, },