Show live background task counts

This commit is contained in:
iris-ai committed 2026-09-15 13:44:32 -04:00
1 parent 9fd21af4e8
commit 8262ceb786
16 files changed
+157 -26

No files matched your search

+4 -1
View File
@@ -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
+7 -2
View File
@@ -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,
+5 -3
View File
@@ -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
+4
View File
@@ -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
@@ -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),
)
@@ -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(
@@ -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()) {
@@ -256,6 +256,8 @@ fun SessionScreen(
val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
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.
@@ -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.
@@ -517,6 +517,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// nothing it belongs above.
is SessionEvent.MessageDropped -> 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)
+4
View File
@@ -510,6 +510,10 @@ impl ClaudeDriver {
}
impl Driver for ClaudeDriver {
fn background_tasks(&self) -> Option<usize> {
self.state.lock().unwrap().background_task_count()
}
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
let mut content = Vec::new();
// An image goes into the message itself; the model looks at it. Any
+32 -17
View File
@@ -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<bool>,
/// forever. The count is also shown beside the session's status. See
/// [`Translator::translate_background_tasks`].
background_tasks: Option<usize>,
/// 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<Event> {
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<usize> {
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 {
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);
+14
View File
@@ -337,6 +337,14 @@ pub enum Event {
id: String,
answers: Vec<String>,
},
/// 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<Event>;
/// 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<usize> {
None
}
/// Takes a message, now or once the session is free for it.
///
/// Every driver owes exactly one `MessageTaken` per message, at the moment
+27 -1
View File
@@ -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<AtomicU64>,
/// 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<Mutex<usize>>,
/// 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 count = background_tasks.lock().unwrap();
let _ = sink.send(Event::Status {
state: SessionStatus::Idle,
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<Vec<Held>>, busy: &AtomicBool) {
}
impl Driver for EchoDriver {
fn background_tasks(&self) -> Option<usize> {
Some(*self.background_tasks.lock().unwrap())
}
fn between_turns(&self) -> bool {
!self.busy.load(Ordering::SeqCst)
}
+6
View File
@@ -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<usize>,
/// 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)),
},
})
+1
View File
@@ -866,6 +866,7 @@ mod tests {
id: "q1".into(),
answers: vec!["Yes".into()],
},
Event::BackgroundTasks { count: 5 },
Event::Status {
state: SessionStatus::Idle,
},