diff --git a/AGENTS.md b/AGENTS.md index 7ce8573..26e1c4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,7 +138,8 @@ Module-by-module intent is in PLAN.md's "Backend layout". - `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation `when`; `SidePanels.kt` the one drag that slides the whole main screen over a session from the left (`MainPanel.kt`) - and its subagents over it from the right (`SubagentPanel.kt`), both keeping + and what it has running beside the turn -- its background tasks over its + subagents (`BackgroundTasks.kt`, `SubagentPanel.kt`) -- from the right, both keeping the session composed underneath; `MainScreen.kt` the root's three tabs (sessions, import, machines); `MachineModels.kt` the models on one machine and the downloads putting them there, drawn inside `ProviderScreen.kt` for a @@ -361,7 +362,12 @@ written, and the fold uses that same predicate to decide a reply is settled. 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 + subagent directory. **What those tasks are is `GET /sessions/{id}/background`**, + listed in the session's right panel above the subagents: runtime state, so it + is never persisted and `null` -- not an empty list -- is what a session with + no process answers. An `ambient` task is dropped from both the list and the + count, on the CLI's own instruction: a live-update watcher is not activity, + and counting one leaves a session `waiting` for ever. 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 61a5d1c..ba24bda 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1112,6 +1112,31 @@ 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. + +**They are listed in the session's panel, above its subagents** (2026-09-20). +The count beside the status says how much is going and never what, which left +"3 bg tasks" as a number with no way to find out what it was about. The list is +`GET /sessions/{id}/background`: whatever the driver says right now, never +written to a transcript and never kept here, because it is a measurement of a +running process and a session with none has nothing to report -- that route +answers `null` for one, which the panel says in words rather than drawing as an +empty list. Each entry is an id the reader never sees, the provider's own +description, and a kind; `description` is optional because Codex names a +background terminal by a process id, and a card with nothing to say says the +kind instead of dressing the number up as a name. The section is collapsed to +its one-line count by default and pushes the subagents down when opened, and it +is refetched whenever the live `backgroundTasks` count moves, since a card for +work that has finished is exactly the stale measurement the count was designed +not to be. A backgrounded subagent appears in both lists: the background one +because it is running, the subagent one because it has a transcript -- and only +the subagent card opens, because only it has anything to open. + +**An `ambient` task is not background work.** The same level signal carries +live-update watchers and housekeeping marked `ambient`, which the CLI says +outright to exclude from activity indicators; counted, they would leave a +session reading `waiting` with nothing to wait for. They are dropped from the +count and the list alike. + 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 a0ec78a..c7b29e0 100644 --- a/SUBAGENTS.md +++ b/SUBAGENTS.md @@ -94,10 +94,15 @@ 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 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 + ids are deliberately not correlated with the edge stream; what is read off + each entry is its own description and kind, and what is read off the set is + whether it is empty and how large. The session API and stream expose that + size as `backgroundTasks`, which the phone draws beside the status, and + `GET /sessions/{id}/background` serves the entries themselves -- listed in + the session's panel *above* the subagents and never as subagent cards. An + `ambient` entry is excluded from both, on the CLI's own instruction: a + live-update watcher is not activity. A backgrounded subagent is legitimately + in both lists, since it is both running and a transcript. 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 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 8c63ca9..764d82c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -308,6 +308,46 @@ data class SubagentSummary( val lastActivity: Double, ) +/** + * One thing a session has running while it is free to do something else: a backgrounded command, a + * subagent, whatever else its provider can leave going. See `GET /sessions/{id}/background`. + */ +data class BackgroundTaskSummary( + /** The provider's own id. Never shown -- it is what keys the list and matches two snapshots. */ + val id: String, + /** + * What it is doing, in the provider's own words. Null where it names a task by nothing a reader + * would recognise -- Codex reports a process id -- and the card says so instead. + */ + val description: String?, + /** `agent`, `command`, `workflow`, or `other` for a kind this build has not heard of. */ + val kind: String, +) + +/** + * What [sessionId] has running in the background right now. + * + * Null is "its provider has not said", which a stopped session and an old backend both answer, and + * is a different thing from the empty list. + */ +fun fetchBackgroundTasks( + settings: ServerSettings, + sessionId: String, +): List? = + requestFromServer(settings, "/sessions/$sessionId/background") { connection -> + val body = connection.inputStream.bufferedReader().readText() + if (body.trim() == "null") null + else + JSONArray(body).mapObjects { row -> + BackgroundTaskSummary( + id = row.getString("id"), + description = + if (row.isNull("description")) null else row.getString("description"), + kind = row.getString("kind"), + ) + } + } + fun fetchSubagents(settings: ServerSettings, sessionId: String): List = requestFromServer(settings, "/sessions/$sessionId/subagents") { it.jsonObjects { row -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index 402a559..7522f2e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -235,6 +235,13 @@ fun AppRoot( else Modifier ) ) { + // How much background work the session has, from the one subscription + // to its events the screen below holds. Here because the panel and that + // screen both draw it, and must draw the same number. + var backgroundTasks by + remember(here.summary.id) { + mutableIntStateOf(here.summary.backgroundTasks) + } // The two panels this session can be pulled aside for: its subagents // from the right, and the whole main screen from the left. Both are here // rather than screens of their own for the same reason the explorer is -- @@ -269,6 +276,7 @@ fun AppRoot( settings = current, summary = here.summary, active = active, + backgroundTasks = backgroundTasks, onClose = close, onOpenSubagent = { screen = here.copy(subagent = it) }, ) @@ -284,6 +292,7 @@ fun AppRoot( onFiles = { screen = here.copy(files = it) }, share = share, onShareTaken = { share = null }, + onBackgroundTasks = { backgroundTasks = it }, ) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BackgroundTasks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BackgroundTasks.kt new file mode 100644 index 0000000..f118762 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BackgroundTasks.kt @@ -0,0 +1,155 @@ +package com.example.aiapp + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * The background work a session has going, above its subagents in the panel [SidePanels] slides + * over it from the right. + * + * Collapsed to its one-line count by default, the way everything else this app adds to a screen + * arrives: what a reader came to the panel for is the subagents, and a run of cards about work + * nobody asked after would push them off it. Expanding pushes them down instead of covering them, + * so the two are read together. + * + * Nothing is drawn at all when the count is zero -- including when the provider never said, which + * is the same absence the status row draws. A permanently visible "0 bg tasks" would be a line + * about nothing on every session that has never backgrounded anything, which is most of them. + */ +fun LazyListScope.backgroundTaskSection( + count: Int, + tasks: LoadState?>, + expanded: Boolean, + onToggle: () -> Unit, + onRetry: () -> Unit, +) { + if (count == 0) return + item(key = "background-heading") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable(onClick = onToggle), + ) { + Text( + "${backgroundTaskLabel(count)} running", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Chevron(if (expanded) Pointing.Up else Pointing.Down) + } + } + if (!expanded) return + when (tasks) { + is LoadState.Loading -> + item(key = "background-loading") { + CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp)) + } + is LoadState.Error -> + item(key = "background-error") { + Column { + Text( + tasks.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + TextButton(onClick = onRetry) { Text("Try again") } + } + } + // Null is the provider declining to say, which a session whose process has gone answers. + // Said in words: the count above came from somewhere, and an empty space under it would + // read as the tasks having finished rather than as nobody being left to ask. + is LoadState.Loaded -> + when (val rows = tasks.value) { + null -> + item(key = "background-unknown") { + Text( + "This session isn't saying what these are.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } + else -> + uniqueItems(rows, key = { "background-${it.id}" }) { BackgroundTaskCard(it) } + } + } +} + +/** + * One background task: what it is doing, and what kind of thing is doing it. + * + * Not something to open, unlike the subagent cards below it -- a task is a provider's runtime state + * and has no transcript of its own. A backgrounded agent that does is also in the subagent list, + * under its own name. + */ +@Composable +private fun BackgroundTaskCard(task: BackgroundTaskSummary) { + val kind = backgroundTaskKindLabel(task.kind) + OutlinedCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + // The kind stands in as the title where the provider gave no description, rather than + // the id it named the task by: Codex reports a process number, which says nothing to + // the person reading and would look like a name somebody chose. + Text( + task.description ?: kind, + style = MaterialTheme.typography.titleSmall, + color = + if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant + else LocalContentColor.current, + ) + if (task.description != null) { + Spacer(Modifier.height(2.dp)) + Text( + kind, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * What a [BackgroundTaskSummary.kind] is called on screen. + * + * A kind this build has not heard of is named by what every one of them has in common rather than + * by the nearest word we do know, which would be this screen asserting something the server never + * said. + */ +private fun backgroundTaskKindLabel(kind: String) = + when (kind) { + "agent" -> "subagent" + "command" -> "background command" + "workflow" -> "workflow" + else -> "background task" + } + +/** + * The heading over one group in the panel, so neither list is a run of cards with no name. + * + * The same band as the background section's own heading row above, rather than a gap chosen to look + * right here: what separates a heading from the cards above it is that both headings sit in a row + * of one height. + */ +@Composable +fun PanelSectionHeading(text: String) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 48.dp)) { + Text(text, style = MaterialTheme.typography.titleMedium) + } +} 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 93affc7..8ef7619 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -195,6 +195,12 @@ fun SessionScreen( onFiles: (FilesTarget) -> Unit, /** What another app shared in while this session is the one open; see [ShareRequest]. */ share: ShareRequest? = null, + /** + * The live background-task count, passed out so the session's panel can list those tasks + * against the same number this screen draws beside the status. This screen holds the only + * subscription to the session's events, and the panel opening its own would be a second copy. + */ + onBackgroundTasks: (Int) -> Unit = {}, /** Said once [share] has been attached here, so it is not attached again. */ onShareTaken: () -> Unit = {}, /** @@ -525,6 +531,7 @@ fun SessionScreen( } if (event is SessionEvent.BackgroundTasks && !isSubagent) { backgroundTasks = event.count + onBackgroundTasks(event.count) } if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event) // In order, always: one late event recorded ahead of the backlog would fold a diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt index 0abdddd..6441b83 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt @@ -39,16 +39,26 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * A session's subagents, listed in the panel [SidePanels] slides over it from the right. + * What a session has running beside the turn you are reading: its background tasks, then its + * subagents, in the panel [SidePanels] slides over it from the right. * - * [active] is whether the panel is being looked at: the list is fetched then rather than on + * [active] is whether the panel is being looked at: the lists are fetched then rather than on * composition, since the panel is composed for every session whether or not anybody opens it. + * + * [backgroundTasks] is the live count from the session's own event stream, and is what the + * background list is refetched against: a card for work that has since finished is a stale + * measurement drawn as a current one, which is the one thing a list of what is running now must not + * do. + * + * Both lists are items of one lazy column rather than two stacked scrollers, so expanding the + * background section pushes the subagents down without either being able to run off the panel. */ @Composable fun SubagentPanel( settings: ServerSettings, summary: SessionSummary, active: Boolean, + backgroundTasks: Int, onClose: () -> Unit, onOpenSubagent: (SubagentSummary) -> Unit, ) { @@ -62,6 +72,11 @@ fun SubagentPanel( var deleteError by remember(summary.id) { mutableStateOf(null) } var confirming by remember(summary.id) { mutableStateOf?>(null) } var refreshToken by remember(summary.id) { mutableIntStateOf(0) } + var background by + remember(summary.id) { + mutableStateOf?>>(LoadState.Loading) + } + var backgroundExpanded by remember(summary.id) { mutableStateOf(false) } LaunchedEffect(active, refreshToken) { if (!active) return@LaunchedEffect @@ -76,49 +91,71 @@ fun SubagentPanel( } } + // No reset to Loading on a refetch: the spinner belongs to the first fetch, and one flashed + // over the list at every start and end would blink precisely when something happened. + LaunchedEffect(active, backgroundTasks, refreshToken) { + if (!active || backgroundTasks == 0) return@LaunchedEffect + background = + try { + LoadState.Loaded( + withContext(Dispatchers.IO) { fetchBackgroundTasks(settings, summary.id) } + ) + } catch (e: ApiException) { + LoadState.failed(e) + } + } + BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() } + val ordered = (rows as? LoadState.Loaded)?.value?.let(::subagentOrder) + Column(Modifier.fillMaxSize()) { Row( + horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) { - Text( - "Subagents", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), - ) - MarkButton("Close subagents", onClose) { Chevron(Pointing.Right) } + MarkButton("Close panel", onClose) { Chevron(Pointing.Right) } } - when (val state = rows) { - is LoadState.Loading -> - CircularProgressIndicator( - modifier = Modifier.padding(16.dp).width(24.dp).height(24.dp) - ) - is LoadState.Error -> - Column(Modifier.padding(16.dp)) { - Text( - state.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - TextButton(onClick = { refreshToken++ }) { Text("Try again") } - } - is LoadState.Loaded -> { - val ordered = subagentOrder(state.value) - if (ordered.isEmpty()) { - Text( - "No subagents in this session.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp), - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.weight(1f).padding(horizontal = 16.dp), - ) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f).padding(horizontal = 16.dp), + ) { + backgroundTaskSection( + count = backgroundTasks, + tasks = background, + expanded = backgroundExpanded, + onToggle = { backgroundExpanded = !backgroundExpanded }, + onRetry = { refreshToken++ }, + ) + item(key = "subagents-heading") { PanelSectionHeading("Subagents") } + when (val state = rows) { + is LoadState.Loading -> + item(key = "subagents-loading") { + CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp)) + } + is LoadState.Error -> + item(key = "subagents-error") { + Column { + Text( + state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + TextButton(onClick = { refreshToken++ }) { Text("Try again") } + } + } + is LoadState.Loaded -> + if (ordered.isNullOrEmpty()) { + item(key = "subagents-empty") { + Text( + "No subagents in this session.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { uniqueItems(ordered, key = { it.id }) { subagent -> SubagentCard( subagent = subagent, @@ -134,25 +171,25 @@ fun SubagentPanel( ) } } - if (selected.isNotEmpty()) { - val picked = ordered.filter { it.id in selected } - SubagentSelectionBar( - picked = picked, - onDelete = { confirming = picked }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - } - deleteError?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - } } } + + if (selected.isNotEmpty()) { + val picked = ordered.orEmpty().filter { it.id in selected } + SubagentSelectionBar( + picked = picked, + onDelete = { confirming = picked }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + deleteError?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } } confirming?.let { picked -> diff --git a/server/src/routes.rs b/server/src/routes.rs index aeb42bd..a5d5c37 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -45,6 +45,9 @@ //! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent), //! ?limit=N, ?coalesce=true to count rows not deltas, //! ?after=N to floor it at what the caller already holds +//! GET /sessions/{id}/background what it has running in the background right now: +//! [{id, description?, kind}], or null when its provider +//! has not said -- runtime state, never a transcript row //! GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest //! first -- see SUBAGENTS.md //! GET /sessions/{id}/subagents/{sub}/transcript exactly the transcript route above, @@ -118,7 +121,7 @@ use tokio::sync::{broadcast, mpsc}; use tokio_stream::StreamExt; use tokio_stream::wrappers::{BroadcastStream, ReceiverStream}; -use crate::session::driver::{SessionCommand, Unqueued}; +use crate::session::driver::{BackgroundTask, SessionCommand, Unqueued}; use crate::session::pending::Operation; use crate::session::subagent::{Subagent, SubagentInfo}; use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up}; @@ -188,6 +191,7 @@ pub fn router(manager: Arc) -> Router { .route("/sessions/{id}", get(read_session).delete(delete_session)) .route("/sessions/{id}/events", get(events)) .route("/sessions/{id}/transcript", get(transcript)) + .route("/sessions/{id}/background", get(list_background_tasks)) .route("/sessions/{id}/subagents", get(list_subagents)) .route("/sessions/{id}/subagents/delete", post(delete_subagents)) .route( @@ -2347,6 +2351,17 @@ fn sse_stream( Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default()) } +/// `GET /sessions/{id}/background`: the provider's own snapshot of what this +/// session has running -- see [`BackgroundTask`]. `null` is "nobody has +/// said", which a session with no process answers, rather than "there is +/// none". +async fn list_background_tasks( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result>>, ApiError> { + Ok(axum::Json(lookup(&manager, &id)?.background_tasks())) +} + /// `GET /sessions/{id}/subagents`: every subagent this session has started, /// oldest first, with a status read from its own transcript -- see /// `SUBAGENTS.md`'s wire shape. A subagent whose last status is `Running` is diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 09d8621..a733b8e 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -56,7 +56,9 @@ use serde_json::{Value, json}; use tokio::io::AsyncWriteExt; use tokio::sync::mpsc; -use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued}; +use super::driver::{ + AttachmentRef, BackgroundTask, Driver, Event, EventSink, SessionStatus, Unqueued, +}; use super::process; use super::subagent::Subagents; use super::transport::{Launch, Streams, Transport}; @@ -510,8 +512,8 @@ impl ClaudeDriver { } impl Driver for ClaudeDriver { - fn background_tasks(&self) -> Option { - self.state.lock().unwrap().background_task_count() + fn background_tasks(&self) -> Option> { + self.state.lock().unwrap().background_tasks() } fn send_user_message(&self, text: String, attachments: Vec) { diff --git a/server/src/session/claude/translate.rs b/server/src/session/claude/translate.rs index bfc6575..15f48e5 100644 --- a/server/src/session/claude/translate.rs +++ b/server/src/session/claude/translate.rs @@ -16,7 +16,8 @@ use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; use super::super::driver::{ - Event, QuestionOption, SessionStatus, context_tokens, patch_start, prefixed_lines, + BackgroundTask, BackgroundTaskKind, Event, QuestionOption, SessionStatus, context_tokens, + patch_start, prefixed_lines, }; use super::super::subagent::Subagents; @@ -138,9 +139,10 @@ 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. The count is also shown beside the session's status. See + /// forever. Its size is shown beside the session's status and the tasks + /// themselves are listed in the session's panel. See /// [`Translator::translate_background_tasks`]. - background_tasks: Option, + 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,18 +651,47 @@ impl Translator { } /// Claude 2.1.261's authoritative account of whether background work is - /// alive. The `tasks` array has replace semantics, but its ids are not - /// promised to correlate with task edges, so only its emptiness is used. + /// alive. The `tasks` array has replace semantics, and each entry carries + /// `task_id`, `task_type` and a `description` -- the sentence the panel + /// draws. The ids are not promised to correlate with the edge stream, so + /// nothing here is matched against `open_tasks`; they are only what makes + /// one snapshot comparable with the next. + /// + /// An `ambient` task is excluded outright, on the CLI's own instruction: + /// a live-update watcher is not activity, and counting one leaves a + /// session saying `waiting` with nothing to wait for. fn translate_background_tasks(&mut self, message: &Value) -> Vec { let Some(tasks) = message.get("tasks").and_then(Value::as_array) else { tracing::warn!("background_tasks_changed without a tasks array"); return Vec::new(); }; let was_outstanding = self.work_outstanding(); - // The CLI explicitly says not to correlate these ids with its edge - // 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() }]; + let live: Vec = tasks + .iter() + .filter(|task| { + !task + .get("ambient") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .map(|task| BackgroundTask { + id: task + .get("task_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + description: text_field(task, "description"), + kind: match task.get("task_type").and_then(Value::as_str) { + Some("local_agent") => BackgroundTaskKind::Agent, + Some("local_bash" | "local_shell") => BackgroundTaskKind::Command, + Some("local_workflow") => BackgroundTaskKind::Workflow, + _ => BackgroundTaskKind::Other, + }, + }) + .collect(); + let count = live.len(); + self.background_tasks = Some(live); + let mut events = vec![Event::BackgroundTasks { count }]; // During a turn the snapshot omits a foreground task, so wait for the // result boundary before using it to close anything. Between turns, @@ -687,9 +718,9 @@ 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(0) = self.background_tasks else { + if !self.background_tasks.as_ref().is_some_and(Vec::is_empty) { return Vec::new(); - }; + } let mut events = Vec::new(); for info in self.subagents.list(true) { if info.status == SessionStatus::Running { @@ -727,15 +758,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.is_some_and(|count| count > 0) + self.background_tasks + .as_ref() + .is_some_and(|tasks| !tasks.is_empty()) || !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 + /// The latest snapshot Claude supplied, and `None` until this process has + /// had its first authoritative one. + pub(super) fn background_tasks(&self) -> Option> { + self.background_tasks.clone() } /// A task reporting back, from whichever of the two lines got here first. @@ -1344,6 +1377,16 @@ mod tests { .collect() } + /// A `background_tasks_changed` line naming these tasks, in the shape the + /// CLI actually sends: an object per task rather than a bare id. + fn background_tasks_line(ids: &[&str]) -> String { + let tasks: Vec = ids + .iter() + .map(|id| json!({"task_id": id, "task_type": "local_bash", "description": "a job"})) + .collect(); + json!({"type": "system", "subtype": "background_tasks_changed", "tasks": tasks}).to_string() + } + /// A fresh, empty subagent registry over the same temp dir a test's /// translator writes into -- every test here is about the parent's own /// events, so what a registry does with a subagent is `subagent.rs`'s @@ -1885,13 +1928,17 @@ mod tests { assert_eq!( translate_lines( &mut translator, - &[ - r#"{"type":"system","subtype":"background_tasks_changed","tasks":["toolu_stale","command-2","command-3","command-4","command-5"]}"# - ], + &[&background_tasks_line(&[ + "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!(translator.background_tasks().map(|t| t.len()), Some(5)); assert_eq!(subagents.list(true)[0].status, SessionStatus::Running); assert_eq!( translate_lines( @@ -1905,10 +1952,49 @@ mod tests { } ] ); - assert_eq!(translator.background_task_count(), Some(0)); + assert_eq!(translator.background_tasks().map(|t| t.len()), Some(0)); assert_eq!(subagents.list(true)[0].status, SessionStatus::Exited); } + /// The panel lists these, so each entry is read rather than counted -- + /// and an `ambient` one is dropped from the list and the count alike, + /// since a watcher counted as work leaves a session `waiting` for ever. + #[test] + fn a_background_snapshot_describes_each_task_and_drops_ambient_ones() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir)); + + let line = json!({ + "type": "system", + "subtype": "background_tasks_changed", + "tasks": [ + {"task_id": "t1", "task_type": "local_bash", "description": "run the tests"}, + {"task_id": "t2", "task_type": "local_agent", "description": "review the diff"}, + {"task_id": "t3", "task_type": "live_update", "ambient": true}, + ], + }) + .to_string(); + assert_eq!( + translate_lines(&mut translator, &[&line]), + vec![Event::BackgroundTasks { count: 2 }] + ); + assert_eq!( + translator.background_tasks(), + Some(vec![ + BackgroundTask { + id: "t1".to_string(), + description: Some("run the tests".to_string()), + kind: BackgroundTaskKind::Command, + }, + BackgroundTask { + id: "t2".to_string(), + description: Some("review the diff".to_string()), + kind: BackgroundTaskKind::Agent, + }, + ]) + ); + } + /// An adopted process may be in the middle of a foreground agent when its /// initialize snapshot arrives. Foreground work is absent from that /// snapshot, so it is only safe to reconcile at the result boundary. diff --git a/server/src/session/codex.rs b/server/src/session/codex.rs index f86dad2..f32a6f2 100644 --- a/server/src/session/codex.rs +++ b/server/src/session/codex.rs @@ -21,7 +21,8 @@ use tokio::io::AsyncWriteExt; use tokio::sync::mpsc; use super::driver::{ - AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued, store_image, + AttachmentRef, BackgroundTask, BackgroundTaskKind, Driver, Event, EventSink, SessionStatus, + Unqueued, store_image, }; use super::process; use super::subagent::Subagents; @@ -228,8 +229,11 @@ impl CodexDriver { } impl Driver for CodexDriver { - fn background_tasks(&self) -> Option { - Some(background_task_count(&self.inner)) + fn background_tasks(&self) -> Option> { + Some(background_tasks( + &self.inner.subagents, + &self.inner.background_processes, + )) } fn send_user_message(&self, text: String, attachments: Vec) { @@ -652,6 +656,36 @@ fn spawn_follower(inner: Arc, record: process::Record) { tokio::spawn(follow(inner, record, offset)); } +/// What a Codex session has running in the background: the child threads its +/// subagent registry holds open, and the terminals app-server says are still +/// alive. Two id sets added together, and this is the only place that +/// addition is written -- the driver answers `GET /sessions/{id}/background` +/// with it and the translator watches its size to announce a change. +pub(super) fn background_tasks( + subagents: &Subagents, + processes: &BackgroundProcesses, +) -> Vec { + let mut tasks: Vec = subagents + .open_list() + .into_iter() + .map(|(id, title)| BackgroundTask { + id, + description: Some(title), + kind: BackgroundTaskKind::Agent, + }) + .collect(); + let mut running: Vec = processes.lock().unwrap().iter().cloned().collect(); + running.sort(); + tasks.extend(running.into_iter().map(|id| BackgroundTask { + id, + // The terminal list carries a process id and no name, and a number + // drawn as a name is worse than the panel saying it does not know. + description: None, + kind: BackgroundTaskKind::Command, + })); + tasks +} + fn background_task_count(inner: &Inner) -> usize { inner.subagents.open_count() + inner.background_processes.lock().unwrap().len() } diff --git a/server/src/session/codex/translate.rs b/server/src/session/codex/translate.rs index 80c0b2b..b28a1c6 100644 --- a/server/src/session/codex/translate.rs +++ b/server/src/session/codex/translate.rs @@ -124,6 +124,9 @@ impl Translator { prefix } + /// The size of the same two sets [`super::background_tasks`] lists, + /// counted rather than built: this is asked twice per translated line, + /// and the names are only wanted by the route that draws them. fn background_task_count(&self) -> Option { self.subagents.as_ref().map(|subagents| { subagents.open_count() diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs index 76fa676..d026859 100644 --- a/server/src/session/driver.rs +++ b/server/src/session/driver.rs @@ -728,6 +728,40 @@ pub enum Unqueued { /// is the backpressure-free buffer of record. pub type EventSink = mpsc::UnboundedSender; +/// One piece of work a session has running while it is free to do something +/// else: a backgrounded command, a subagent, whatever else a provider can +/// leave going. +/// +/// Runtime state, never written to a transcript: it is what the provider +/// says right now, so a session with no process has nothing to say. Served +/// by `GET /sessions/{id}/background`; the `BackgroundTasks` event carries +/// only the size, which is what the status row draws. +/// +/// [`description`](Self::description) is `None` where the provider names a +/// task by something no reader would recognise -- a process id -- rather +/// than by a sentence worked out here; the phone says it does not know. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BackgroundTask { + /// The provider's own id for it. Never shown; it is what makes two + /// snapshots comparable, and what keys the list on the phone. + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub kind: BackgroundTaskKind, +} + +/// What kind of thing a [`BackgroundTask`] is, in the terms the app draws. +/// `Other` is deliberately a state of its own rather than a guess: a +/// provider word this build has not seen is not a command. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum BackgroundTaskKind { + Agent, + Command, + Workflow, + Other, +} + /// The inbound half of a session. Deliberately small; see PLAN.md for the /// per-driver mapping of each method onto its dialect. /// @@ -735,9 +769,10 @@ 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 { + /// The background work the provider says is alive now, in the order it + /// wants it read. `None` means it has not reported, not that there is + /// none -- see [`BackgroundTask`]. + fn background_tasks(&self) -> Option> { None } diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index a83a563..bc21cef 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -75,7 +75,8 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use super::driver::{ - AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued, patch_start, + AttachmentRef, BackgroundTask, BackgroundTaskKind, Driver, Event, EventSink, QuestionOption, + SessionStatus, Unqueued, patch_start, }; use super::subagent::Subagents; @@ -124,9 +125,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. + /// Live background commands, for the same list a real provider reports. /// This is the deterministic UI/session-lifecycle rig for that state. - background_tasks: Arc>, + 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. @@ -505,9 +506,13 @@ impl EchoDriver { }); let background_tasks = Arc::clone(&self.background_tasks); { - let mut count = background_tasks.lock().unwrap(); - *count += 1; - self.emit(Event::BackgroundTasks { count: *count }); + let mut tasks = background_tasks.lock().unwrap(); + tasks.push(BackgroundTask { + id: id.clone(), + description: Some(command.clone()), + kind: BackgroundTaskKind::Command, + }); + self.emit(Event::BackgroundTasks { count: tasks.len() }); } for word in "Started it; I'll pick this up when it lands.".split_inclusive(' ') { self.emit(Event::AssistantText { @@ -523,9 +528,9 @@ impl EchoDriver { 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 mut tasks = background_tasks.lock().unwrap(); + tasks.retain(|task| task.id != id); + let _ = sink.send(Event::BackgroundTasks { count: tasks.len() }); } let _ = sink.send(Event::ToolUpdate { id, @@ -541,9 +546,9 @@ impl EchoDriver { tokio::time::sleep(DELTA_DELAY).await; } { - let count = background_tasks.lock().unwrap(); + let tasks = background_tasks.lock().unwrap(); let _ = sink.send(Event::Status { - state: if *count == 0 { + state: if tasks.is_empty() { SessionStatus::Idle } else { SessionStatus::Waiting @@ -911,7 +916,7 @@ impl EchoDriver { sink, pending_questions: Mutex::new(Vec::new()), context: Arc::new(AtomicU64::new(0)), - background_tasks: Arc::new(Mutex::new(0)), + background_tasks: Arc::new(Mutex::new(Vec::new())), busy: Arc::new(AtomicBool::new(false)), queued: Arc::new(Mutex::new(Vec::new())), session_dir, @@ -1222,8 +1227,8 @@ 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 background_tasks(&self) -> Option> { + Some(self.background_tasks.lock().unwrap().clone()) } fn between_turns(&self) -> bool { diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 3e93f4b..016b0fa 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -36,8 +36,8 @@ use crate::config::{ use claude::ClaudeDriver; use codex::CodexDriver; use driver::{ - AttachmentRef, Driver, Event, EventSink, SessionCommand, SessionStatus, Unqueued, - context_after, context_limit_after, + AttachmentRef, BackgroundTask, Driver, Event, EventSink, SessionCommand, SessionStatus, + Unqueued, context_after, context_limit_after, }; use echo::EchoDriver; use llama::LlamaDriver; @@ -525,6 +525,13 @@ impl LiveSession { &self.subagents } + /// What this session has running in the background, as its provider last + /// said -- `None` when nothing has said, which includes a session with no + /// process. Serves `GET /sessions/{id}/background`. + pub fn background_tasks(&self) -> Option> { + self.driver().and_then(|driver| driver.background_tasks()) + } + /// What this session is doing right now, as the pump last recorded it -- /// the same word `SessionInfo::status` reports. Read here rather than /// only through `SessionManager::sessions` for @@ -616,7 +623,10 @@ impl LiveSession { last_activity: *self.shared.last_activity.lock().unwrap(), created: self.meta.created, started: current.started_at(), - background_tasks: self.driver().and_then(|driver| driver.background_tasks()), + background_tasks: self + .driver() + .and_then(|driver| driver.background_tasks()) + .map(|tasks| tasks.len()), subagents: subagent::count(self.dir()), } } diff --git a/server/src/session/subagent.rs b/server/src/session/subagent.rs index f884d9c..a3692fc 100644 --- a/server/src/session/subagent.rs +++ b/server/src/session/subagent.rs @@ -12,7 +12,7 @@ //! side uses. Only ids matching [`is_subagent_id`] are ever turned into a //! path. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -68,6 +68,9 @@ pub struct SubagentInfo { /// session's but with no driver behind it. pub struct Subagent { dir: PathBuf, + /// Its own name, from `meta.json`, so an open subagent can be listed + /// without reading every subagent's directory back off disk. + title: String, transcript: Mutex, events: broadcast::Sender, /// Mirrors the transcript's last `Status` event, kept live rather than @@ -81,6 +84,10 @@ pub struct Subagent { } impl Subagent { + fn title(&self) -> &str { + &self.title + } + pub fn transcript_path(&self) -> PathBuf { self.dir.join("transcript.jsonl") } @@ -129,9 +136,11 @@ pub struct Subagents { /// The session's own directory; subagents live under `/subagents`. dir: PathBuf, live: Mutex>>, - /// Open ids, seeded from disk so an adopted session starts with the measured count rather than - /// waiting to see lifecycle edges which are already behind its stdout cursor. - open: Mutex>, + /// Open subagents by id, with the title each is known by, seeded from disk so an adopted + /// session starts with the measured set rather than waiting to see lifecycle edges which are + /// already behind its stdout cursor. The title is held here so that listing what is open + /// costs no directory read -- `GET /sessions/{id}/background` asks often. + open: Mutex>, } impl Subagents { @@ -139,14 +148,14 @@ impl Subagents { let subagents = Self { dir: session_dir, live: Mutex::new(HashMap::new()), - open: Mutex::new(HashSet::new()), + open: Mutex::new(HashMap::new()), }; subagents.open.lock().unwrap().extend( subagents .list(true) .into_iter() .filter(|info| info.status == SessionStatus::Running) - .map(|info| info.id), + .map(|info| (info.id, info.title)), ); subagents } @@ -217,6 +226,7 @@ impl Subagents { let (events, _) = broadcast::channel(EVENT_BUFFER); Ok(Arc::new(Subagent { dir, + title: meta.title, transcript: Mutex::new(transcript), events, status: Mutex::new(status), @@ -239,7 +249,10 @@ impl Subagents { match self.open_or_create(id, title, prompt) { Ok(subagent) => { if subagent.is_open() { - self.open.lock().unwrap().insert(id.to_string()); + self.open + .lock() + .unwrap() + .insert(id.to_string(), subagent.title().to_string()); } live.insert(id.to_string(), subagent); } @@ -302,6 +315,21 @@ impl Subagents { self.open.lock().unwrap().len() } + /// The live subagents, each with the title it is known by. Ordered by + /// id, which says nothing about when they started but does mean two + /// readings agree; read from memory, so a caller may ask often. + pub fn open_list(&self) -> Vec<(String, String)> { + let mut open: Vec<(String, String)> = self + .open + .lock() + .unwrap() + .iter() + .map(|(id, title)| (id.clone(), title.clone())) + .collect(); + open.sort(); + open + } + /// Appends one event to a subagent's own transcript. A no-op, with a /// debug log, for an id nothing was started under -- a child line for a /// subagent this registry never opened is dropped rather than guessed @@ -344,7 +372,10 @@ impl Subagents { state: SessionStatus::Running, }) { - self.open.lock().unwrap().insert(id.to_string()); + self.open + .lock() + .unwrap() + .insert(id.to_string(), subagent.title().to_string()); } }