List a session's background tasks above its subagents

The count beside the status said how much work was going and never what,
so "3 bg tasks" was a number with no way to find out what it was about.

Drivers now report the tasks themselves rather than a size:
`Driver::background_tasks` returns `Vec<BackgroundTask>` -- id, the
provider's own description, and a kind -- served by
`GET /sessions/{id}/background`. It is runtime state, never persisted,
and `null` is "nobody has said", which is what a session with no process
answers and what the panel says in words rather than drawing as an empty
list. `description` is optional because Codex names a background terminal
by a process id, and a number drawn as a name is worse than admitting
there is none.

Claude's `background_tasks_changed` entries turn out to be objects
carrying `task_id`, `task_type` and `description`, so each is read rather
than counted -- and an `ambient` one is now dropped from the list and the
count alike, on the CLI's own instruction: a live-update watcher is not
activity, and counting one left a session reading `waiting` with nothing
to wait for.

The phone draws them in the right-hand panel above the subagents,
collapsed to "2 bg tasks running" and pushing the subagents down when
opened. Both lists are items of one lazy column, so neither can run off
the panel, and the section is refetched whenever the live count moves --
a card for work that has finished is exactly the stale measurement the
count exists not to be.

Verified against the real Claude CLI (2.1.261): a backgrounded `sleep 120`
came back as `{"id":"br16327wr","description":"Sleep for 120 seconds",
"kind":"command"}`, and on the emulator against the echo rig the section
appeared, expanded, and dropped a card as its task finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-19 23:29:40 -04:00
1 parent c8bfc958ad
commit 942edd6b31
17 files changed
+620 -115

No files matched your search

+8 -2
View File
@@ -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". - `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
`AppRoot.kt` is the navigation `when`; `SidePanels.kt` the one drag that `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`) 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 the session composed underneath; `MainScreen.kt` the root's three tabs
(sessions, import, machines); `MachineModels.kt` the models on one machine (sessions, import, machines); `MachineModels.kt` the models on one machine
and the downloads putting them there, drawn inside `ProviderScreen.kt` for a 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` 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 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 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: for the current set. Reconcile only between turns or at a result boundary:
a foreground agent is legitimately absent from a background-only snapshot. a foreground agent is legitimately absent from a background-only snapshot.
Older CLIs still need both edge sources: `open_tasks` knows about a Older CLIs still need both edge sources: `open_tasks` knows about a
+25
View File
@@ -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 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 and event stream, and is drawn beside the status; background tasks do not become
subagent cards. 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 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 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, apply it immediately; one adopted mid-turn waits for the result boundary,
+9 -4
View File
@@ -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 Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is
the authoritative level beside those edges: its set replaces the previous the authoritative level beside those edges: its set replaces the previous
set, so a missed terminal edge cannot leave a subagent running forever. Its 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 ids are deliberately not correlated with the edge stream; what is read off
here are whether the set is empty and its measured size. The session API and each entry is its own description and kind, and what is read off the set is
stream expose that size as `backgroundTasks`, which the phone draws beside whether it is empty and how large. The session API and stream expose that
the status without pretending those tasks are subagents. The edges still 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`, 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, 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 leaving the edge-based path unchanged. A snapshot is reconciled immediately
@@ -308,6 +308,46 @@ data class SubagentSummary(
val lastActivity: Double, 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<BackgroundTaskSummary>? =
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<SubagentSummary> = fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSummary> =
requestFromServer(settings, "/sessions/$sessionId/subagents") { requestFromServer(settings, "/sessions/$sessionId/subagents") {
it.jsonObjects { row -> it.jsonObjects { row ->
@@ -235,6 +235,13 @@ fun AppRoot(
else Modifier 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 // 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 // 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 -- // rather than screens of their own for the same reason the explorer is --
@@ -269,6 +276,7 @@ fun AppRoot(
settings = current, settings = current,
summary = here.summary, summary = here.summary,
active = active, active = active,
backgroundTasks = backgroundTasks,
onClose = close, onClose = close,
onOpenSubagent = { screen = here.copy(subagent = it) }, onOpenSubagent = { screen = here.copy(subagent = it) },
) )
@@ -284,6 +292,7 @@ fun AppRoot(
onFiles = { screen = here.copy(files = it) }, onFiles = { screen = here.copy(files = it) },
share = share, share = share,
onShareTaken = { share = null }, onShareTaken = { share = null },
onBackgroundTasks = { backgroundTasks = it },
) )
} }
} }
@@ -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<List<BackgroundTaskSummary>?>,
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)
}
}
@@ -195,6 +195,12 @@ fun SessionScreen(
onFiles: (FilesTarget) -> Unit, onFiles: (FilesTarget) -> Unit,
/** What another app shared in while this session is the one open; see [ShareRequest]. */ /** What another app shared in while this session is the one open; see [ShareRequest]. */
share: ShareRequest? = null, 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. */ /** Said once [share] has been attached here, so it is not attached again. */
onShareTaken: () -> Unit = {}, onShareTaken: () -> Unit = {},
/** /**
@@ -525,6 +531,7 @@ fun SessionScreen(
} }
if (event is SessionEvent.BackgroundTasks && !isSubagent) { if (event is SessionEvent.BackgroundTasks && !isSubagent) {
backgroundTasks = event.count backgroundTasks = event.count
onBackgroundTasks(event.count)
} }
if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event) if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event)
// In order, always: one late event recorded ahead of the backlog would fold a // In order, always: one late event recorded ahead of the backlog would fold a
@@ -39,16 +39,26 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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. * 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 @Composable
fun SubagentPanel( fun SubagentPanel(
settings: ServerSettings, settings: ServerSettings,
summary: SessionSummary, summary: SessionSummary,
active: Boolean, active: Boolean,
backgroundTasks: Int,
onClose: () -> Unit, onClose: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit, onOpenSubagent: (SubagentSummary) -> Unit,
) { ) {
@@ -62,6 +72,11 @@ fun SubagentPanel(
var deleteError by remember(summary.id) { mutableStateOf<String?>(null) } var deleteError by remember(summary.id) { mutableStateOf<String?>(null) }
var confirming by remember(summary.id) { mutableStateOf<List<SubagentSummary>?>(null) } var confirming by remember(summary.id) { mutableStateOf<List<SubagentSummary>?>(null) }
var refreshToken by remember(summary.id) { mutableIntStateOf(0) } var refreshToken by remember(summary.id) { mutableIntStateOf(0) }
var background by
remember(summary.id) {
mutableStateOf<LoadState<List<BackgroundTaskSummary>?>>(LoadState.Loading)
}
var backgroundExpanded by remember(summary.id) { mutableStateOf(false) }
LaunchedEffect(active, refreshToken) { LaunchedEffect(active, refreshToken) {
if (!active) return@LaunchedEffect 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() } BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
val ordered = (rows as? LoadState.Loaded)?.value?.let(::subagentOrder)
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Row( Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) { ) {
Text( MarkButton("Close panel", onClose) { Chevron(Pointing.Right) }
"Subagents",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
MarkButton("Close subagents", onClose) { Chevron(Pointing.Right) }
} }
when (val state = rows) { LazyColumn(
is LoadState.Loading -> verticalArrangement = Arrangement.spacedBy(8.dp),
CircularProgressIndicator( modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
modifier = Modifier.padding(16.dp).width(24.dp).height(24.dp) ) {
) backgroundTaskSection(
is LoadState.Error -> count = backgroundTasks,
Column(Modifier.padding(16.dp)) { tasks = background,
Text( expanded = backgroundExpanded,
state.message, onToggle = { backgroundExpanded = !backgroundExpanded },
color = MaterialTheme.colorScheme.error, onRetry = { refreshToken++ },
style = MaterialTheme.typography.bodySmall, )
) item(key = "subagents-heading") { PanelSectionHeading("Subagents") }
TextButton(onClick = { refreshToken++ }) { Text("Try again") } when (val state = rows) {
} is LoadState.Loading ->
is LoadState.Loaded -> { item(key = "subagents-loading") {
val ordered = subagentOrder(state.value) CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
if (ordered.isEmpty()) { }
Text( is LoadState.Error ->
"No subagents in this session.", item(key = "subagents-error") {
color = MaterialTheme.colorScheme.onSurfaceVariant, Column {
style = MaterialTheme.typography.bodyMedium, Text(
modifier = Modifier.padding(16.dp), state.message,
) color = MaterialTheme.colorScheme.error,
} else { style = MaterialTheme.typography.bodySmall,
LazyColumn( )
verticalArrangement = Arrangement.spacedBy(8.dp), TextButton(onClick = { refreshToken++ }) { Text("Try again") }
modifier = Modifier.weight(1f).padding(horizontal = 16.dp), }
) { }
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 -> uniqueItems(ordered, key = { it.id }) { subagent ->
SubagentCard( SubagentCard(
subagent = subagent, 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 -> confirming?.let { picked ->
+16 -1
View File
@@ -45,6 +45,9 @@
//! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent), //! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent),
//! ?limit=N, ?coalesce=true to count rows not deltas, //! ?limit=N, ?coalesce=true to count rows not deltas,
//! ?after=N to floor it at what the caller already holds //! ?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 //! GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest
//! first -- see SUBAGENTS.md //! first -- see SUBAGENTS.md
//! GET /sessions/{id}/subagents/{sub}/transcript exactly the transcript route above, //! 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::StreamExt;
use tokio_stream::wrappers::{BroadcastStream, ReceiverStream}; 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::pending::Operation;
use crate::session::subagent::{Subagent, SubagentInfo}; use crate::session::subagent::{Subagent, SubagentInfo};
use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up}; use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up};
@@ -188,6 +191,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}", get(read_session).delete(delete_session)) .route("/sessions/{id}", get(read_session).delete(delete_session))
.route("/sessions/{id}/events", get(events)) .route("/sessions/{id}/events", get(events))
.route("/sessions/{id}/transcript", get(transcript)) .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", get(list_subagents))
.route("/sessions/{id}/subagents/delete", post(delete_subagents)) .route("/sessions/{id}/subagents/delete", post(delete_subagents))
.route( .route(
@@ -2347,6 +2351,17 @@ fn sse_stream(
Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default()) 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<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<Option<Vec<BackgroundTask>>>, ApiError> {
Ok(axum::Json(lookup(&manager, &id)?.background_tasks()))
}
/// `GET /sessions/{id}/subagents`: every subagent this session has started, /// `GET /sessions/{id}/subagents`: every subagent this session has started,
/// oldest first, with a status read from its own transcript -- see /// oldest first, with a status read from its own transcript -- see
/// `SUBAGENTS.md`'s wire shape. A subagent whose last status is `Running` is /// `SUBAGENTS.md`'s wire shape. A subagent whose last status is `Running` is
+5 -3
View File
@@ -56,7 +56,9 @@ use serde_json::{Value, json};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc; 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::process;
use super::subagent::Subagents; use super::subagent::Subagents;
use super::transport::{Launch, Streams, Transport}; use super::transport::{Launch, Streams, Transport};
@@ -510,8 +512,8 @@ impl ClaudeDriver {
} }
impl Driver for ClaudeDriver { impl Driver for ClaudeDriver {
fn background_tasks(&self) -> Option<usize> { fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
self.state.lock().unwrap().background_task_count() self.state.lock().unwrap().background_tasks()
} }
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) { fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
+107 -21
View File
@@ -16,7 +16,8 @@ use std::sync::{Arc, Mutex};
use serde_json::{Value, json}; use serde_json::{Value, json};
use super::super::driver::{ 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; 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 /// 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 /// 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 /// 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`]. /// [`Translator::translate_background_tasks`].
background_tasks: Option<usize>, background_tasks: Option<Vec<BackgroundTask>>,
/// Tasks the level signal closed before their ordinary notification /// Tasks the level signal closed before their ordinary notification
/// arrived. That notification still owns the useful summary, so it gets /// arrived. That notification still owns the useful summary, so it gets
/// one chance to update the transcript or tool card after the status was /// 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 /// Claude 2.1.261's authoritative account of whether background work is
/// alive. The `tasks` array has replace semantics, but its ids are not /// alive. The `tasks` array has replace semantics, and each entry carries
/// promised to correlate with task edges, so only its emptiness is used. /// `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<Event> { fn translate_background_tasks(&mut self, message: &Value) -> Vec<Event> {
let Some(tasks) = message.get("tasks").and_then(Value::as_array) else { let Some(tasks) = message.get("tasks").and_then(Value::as_array) else {
tracing::warn!("background_tasks_changed without a tasks array"); tracing::warn!("background_tasks_changed without a tasks array");
return Vec::new(); return Vec::new();
}; };
let was_outstanding = self.work_outstanding(); let was_outstanding = self.work_outstanding();
// The CLI explicitly says not to correlate these ids with its edge let live: Vec<BackgroundTask> = tasks
// stream. Their useful claims here are the level and its exact size. .iter()
self.background_tasks = Some(tasks.len()); .filter(|task| {
let mut events = vec![Event::BackgroundTasks { count: tasks.len() }]; !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 // During a turn the snapshot omits a foreground task, so wait for the
// result boundary before using it to close anything. Between turns, // 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; /// foreground task can remain. Returns updates for background commands;
/// subagents carry the same correction in their own status transcript. /// subagents carry the same correction in their own status transcript.
fn reconcile_background_tasks(&mut self) -> Vec<Event> { fn reconcile_background_tasks(&mut self) -> Vec<Event> {
let Some(0) = self.background_tasks else { if !self.background_tasks.as_ref().is_some_and(Vec::is_empty) {
return Vec::new(); return Vec::new();
}; }
let mut events = Vec::new(); let mut events = Vec::new();
for info in self.subagents.list(true) { for info in self.subagents.list(true) {
if info.status == SessionStatus::Running { if info.status == SessionStatus::Running {
@@ -727,15 +758,17 @@ impl Translator {
/// `session_running` is true by construction: this is only ever asked /// `session_running` is true by construction: this is only ever asked
/// while translating a line the session's process just wrote. /// while translating a line the session's process just wrote.
fn work_outstanding(&self) -> bool { 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.open_tasks.is_empty()
|| self.subagents.any_open(true) || self.subagents.any_open(true)
} }
/// The latest count Claude supplied, and `None` until this process has /// The latest snapshot Claude supplied, and `None` until this process has
/// supplied its first authoritative snapshot. /// had its first authoritative one.
pub(super) fn background_task_count(&self) -> Option<usize> { pub(super) fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
self.background_tasks self.background_tasks.clone()
} }
/// A task reporting back, from whichever of the two lines got here first. /// A task reporting back, from whichever of the two lines got here first.
@@ -1344,6 +1377,16 @@ mod tests {
.collect() .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<Value> = 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 /// 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 /// 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 /// events, so what a registry does with a subagent is `subagent.rs`'s
@@ -1885,13 +1928,17 @@ mod tests {
assert_eq!( assert_eq!(
translate_lines( translate_lines(
&mut translator, &mut translator,
&[ &[&background_tasks_line(&[
r#"{"type":"system","subtype":"background_tasks_changed","tasks":["toolu_stale","command-2","command-3","command-4","command-5"]}"# "toolu_stale",
], "command-2",
"command-3",
"command-4",
"command-5",
])],
), ),
vec![Event::BackgroundTasks { count: 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!(subagents.list(true)[0].status, SessionStatus::Running);
assert_eq!( assert_eq!(
translate_lines( 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); 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 /// An adopted process may be in the middle of a foreground agent when its
/// initialize snapshot arrives. Foreground work is absent from that /// initialize snapshot arrives. Foreground work is absent from that
/// snapshot, so it is only safe to reconcile at the result boundary. /// snapshot, so it is only safe to reconcile at the result boundary.
+37 -3
View File
@@ -21,7 +21,8 @@ use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use super::driver::{ 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::process;
use super::subagent::Subagents; use super::subagent::Subagents;
@@ -228,8 +229,11 @@ impl CodexDriver {
} }
impl Driver for CodexDriver { impl Driver for CodexDriver {
fn background_tasks(&self) -> Option<usize> { fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
Some(background_task_count(&self.inner)) Some(background_tasks(
&self.inner.subagents,
&self.inner.background_processes,
))
} }
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) { fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
@@ -652,6 +656,36 @@ fn spawn_follower(inner: Arc<Inner>, record: process::Record) {
tokio::spawn(follow(inner, record, offset)); 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<BackgroundTask> {
let mut tasks: Vec<BackgroundTask> = subagents
.open_list()
.into_iter()
.map(|(id, title)| BackgroundTask {
id,
description: Some(title),
kind: BackgroundTaskKind::Agent,
})
.collect();
let mut running: Vec<String> = 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 { fn background_task_count(inner: &Inner) -> usize {
inner.subagents.open_count() + inner.background_processes.lock().unwrap().len() inner.subagents.open_count() + inner.background_processes.lock().unwrap().len()
} }
+3
View File
@@ -124,6 +124,9 @@ impl Translator {
prefix 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<usize> { fn background_task_count(&self) -> Option<usize> {
self.subagents.as_ref().map(|subagents| { self.subagents.as_ref().map(|subagents| {
subagents.open_count() subagents.open_count()
+38 -3
View File
@@ -728,6 +728,40 @@ pub enum Unqueued {
/// is the backpressure-free buffer of record. /// is the backpressure-free buffer of record.
pub type EventSink = mpsc::UnboundedSender<Event>; pub type EventSink = mpsc::UnboundedSender<Event>;
/// 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<String>,
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 /// The inbound half of a session. Deliberately small; see PLAN.md for the
/// per-driver mapping of each method onto its dialect. /// per-driver mapping of each method onto its dialect.
/// ///
@@ -735,9 +769,10 @@ pub type EventSink = mpsc::UnboundedSender<Event>;
/// with live input injects it at the next tool boundary, while a turn-at-a-time /// with live input injects it at the next tool boundary, while a turn-at-a-time
/// dialect queues it for the next child process. /// dialect queues it for the next child process.
pub trait Driver: Send + Sync { pub trait Driver: Send + Sync {
/// The provider's latest measured number of live background tasks. /// The background work the provider says is alive now, in the order it
/// `None` means it has not reported one, not that the count is zero. /// wants it read. `None` means it has not reported, not that there is
fn background_tasks(&self) -> Option<usize> { /// none -- see [`BackgroundTask`].
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
None None
} }
+19 -14
View File
@@ -75,7 +75,8 @@ use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use super::driver::{ 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; use super::subagent::Subagents;
@@ -124,9 +125,9 @@ pub struct EchoDriver {
/// says it recovered, and a clear leaves it unmeasured. What is real is /// says it recovered, and a clear leaves it unmeasured. What is real is
/// which way the numbers move. /// which way the numbers move.
context: Arc<AtomicU64>, context: Arc<AtomicU64>,
/// 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. /// This is the deterministic UI/session-lifecycle rig for that state.
background_tasks: Arc<Mutex<usize>>, background_tasks: Arc<Mutex<Vec<BackgroundTask>>>,
/// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the /// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
/// test rig for the same registry the claude driver routes real Task /// test rig for the same registry the claude driver routes real Task
/// calls into. /// calls into.
@@ -505,9 +506,13 @@ impl EchoDriver {
}); });
let background_tasks = Arc::clone(&self.background_tasks); let background_tasks = Arc::clone(&self.background_tasks);
{ {
let mut count = background_tasks.lock().unwrap(); let mut tasks = background_tasks.lock().unwrap();
*count += 1; tasks.push(BackgroundTask {
self.emit(Event::BackgroundTasks { count: *count }); 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(' ') { for word in "Started it; I'll pick this up when it lands.".split_inclusive(' ') {
self.emit(Event::AssistantText { self.emit(Event::AssistantText {
@@ -523,9 +528,9 @@ impl EchoDriver {
tokio::spawn(async move { tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(seconds)).await; tokio::time::sleep(Duration::from_secs(seconds)).await;
{ {
let mut count = background_tasks.lock().unwrap(); let mut tasks = background_tasks.lock().unwrap();
*count -= 1; tasks.retain(|task| task.id != id);
let _ = sink.send(Event::BackgroundTasks { count: *count }); let _ = sink.send(Event::BackgroundTasks { count: tasks.len() });
} }
let _ = sink.send(Event::ToolUpdate { let _ = sink.send(Event::ToolUpdate {
id, id,
@@ -541,9 +546,9 @@ impl EchoDriver {
tokio::time::sleep(DELTA_DELAY).await; tokio::time::sleep(DELTA_DELAY).await;
} }
{ {
let count = background_tasks.lock().unwrap(); let tasks = background_tasks.lock().unwrap();
let _ = sink.send(Event::Status { let _ = sink.send(Event::Status {
state: if *count == 0 { state: if tasks.is_empty() {
SessionStatus::Idle SessionStatus::Idle
} else { } else {
SessionStatus::Waiting SessionStatus::Waiting
@@ -911,7 +916,7 @@ impl EchoDriver {
sink, sink,
pending_questions: Mutex::new(Vec::new()), pending_questions: Mutex::new(Vec::new()),
context: Arc::new(AtomicU64::new(0)), 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)), busy: Arc::new(AtomicBool::new(false)),
queued: Arc::new(Mutex::new(Vec::new())), queued: Arc::new(Mutex::new(Vec::new())),
session_dir, session_dir,
@@ -1222,8 +1227,8 @@ fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
} }
impl Driver for EchoDriver { impl Driver for EchoDriver {
fn background_tasks(&self) -> Option<usize> { fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
Some(*self.background_tasks.lock().unwrap()) Some(self.background_tasks.lock().unwrap().clone())
} }
fn between_turns(&self) -> bool { fn between_turns(&self) -> bool {
+13 -3
View File
@@ -36,8 +36,8 @@ use crate::config::{
use claude::ClaudeDriver; use claude::ClaudeDriver;
use codex::CodexDriver; use codex::CodexDriver;
use driver::{ use driver::{
AttachmentRef, Driver, Event, EventSink, SessionCommand, SessionStatus, Unqueued, AttachmentRef, BackgroundTask, Driver, Event, EventSink, SessionCommand, SessionStatus,
context_after, context_limit_after, Unqueued, context_after, context_limit_after,
}; };
use echo::EchoDriver; use echo::EchoDriver;
use llama::LlamaDriver; use llama::LlamaDriver;
@@ -525,6 +525,13 @@ impl LiveSession {
&self.subagents &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<Vec<BackgroundTask>> {
self.driver().and_then(|driver| driver.background_tasks())
}
/// What this session is doing right now, as the pump last recorded it -- /// What this session is doing right now, as the pump last recorded it --
/// the same word `SessionInfo::status` reports. Read here rather than /// the same word `SessionInfo::status` reports. Read here rather than
/// only through `SessionManager::sessions` for /// only through `SessionManager::sessions` for
@@ -616,7 +623,10 @@ impl LiveSession {
last_activity: *self.shared.last_activity.lock().unwrap(), last_activity: *self.shared.last_activity.lock().unwrap(),
created: self.meta.created, created: self.meta.created,
started: current.started_at(), 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()), subagents: subagent::count(self.dir()),
} }
} }
+39 -8
View File
@@ -12,7 +12,7 @@
//! side uses. Only ids matching [`is_subagent_id`] are ever turned into a //! side uses. Only ids matching [`is_subagent_id`] are ever turned into a
//! path. //! path.
use std::collections::{HashMap, HashSet}; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -68,6 +68,9 @@ pub struct SubagentInfo {
/// session's but with no driver behind it. /// session's but with no driver behind it.
pub struct Subagent { pub struct Subagent {
dir: PathBuf, 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<Transcript>, transcript: Mutex<Transcript>,
events: broadcast::Sender<SeqEvent>, events: broadcast::Sender<SeqEvent>,
/// Mirrors the transcript's last `Status` event, kept live rather than /// Mirrors the transcript's last `Status` event, kept live rather than
@@ -81,6 +84,10 @@ pub struct Subagent {
} }
impl Subagent { impl Subagent {
fn title(&self) -> &str {
&self.title
}
pub fn transcript_path(&self) -> PathBuf { pub fn transcript_path(&self) -> PathBuf {
self.dir.join("transcript.jsonl") self.dir.join("transcript.jsonl")
} }
@@ -129,9 +136,11 @@ pub struct Subagents {
/// The session's own directory; subagents live under `<dir>/subagents`. /// The session's own directory; subagents live under `<dir>/subagents`.
dir: PathBuf, dir: PathBuf,
live: Mutex<HashMap<String, Arc<Subagent>>>, live: Mutex<HashMap<String, Arc<Subagent>>>,
/// Open ids, seeded from disk so an adopted session starts with the measured count rather than /// Open subagents by id, with the title each is known by, seeded from disk so an adopted
/// waiting to see lifecycle edges which are already behind its stdout cursor. /// session starts with the measured set rather than waiting to see lifecycle edges which are
open: Mutex<HashSet<String>>, /// 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<HashMap<String, String>>,
} }
impl Subagents { impl Subagents {
@@ -139,14 +148,14 @@ impl Subagents {
let subagents = Self { let subagents = Self {
dir: session_dir, dir: session_dir,
live: Mutex::new(HashMap::new()), live: Mutex::new(HashMap::new()),
open: Mutex::new(HashSet::new()), open: Mutex::new(HashMap::new()),
}; };
subagents.open.lock().unwrap().extend( subagents.open.lock().unwrap().extend(
subagents subagents
.list(true) .list(true)
.into_iter() .into_iter()
.filter(|info| info.status == SessionStatus::Running) .filter(|info| info.status == SessionStatus::Running)
.map(|info| info.id), .map(|info| (info.id, info.title)),
); );
subagents subagents
} }
@@ -217,6 +226,7 @@ impl Subagents {
let (events, _) = broadcast::channel(EVENT_BUFFER); let (events, _) = broadcast::channel(EVENT_BUFFER);
Ok(Arc::new(Subagent { Ok(Arc::new(Subagent {
dir, dir,
title: meta.title,
transcript: Mutex::new(transcript), transcript: Mutex::new(transcript),
events, events,
status: Mutex::new(status), status: Mutex::new(status),
@@ -239,7 +249,10 @@ impl Subagents {
match self.open_or_create(id, title, prompt) { match self.open_or_create(id, title, prompt) {
Ok(subagent) => { Ok(subagent) => {
if subagent.is_open() { 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); live.insert(id.to_string(), subagent);
} }
@@ -302,6 +315,21 @@ impl Subagents {
self.open.lock().unwrap().len() 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 /// 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 /// debug log, for an id nothing was started under -- a child line for a
/// subagent this registry never opened is dropped rather than guessed /// subagent this registry never opened is dropped rather than guessed
@@ -344,7 +372,10 @@ impl Subagents {
state: SessionStatus::Running, state: SessionStatus::Running,
}) })
{ {
self.open.lock().unwrap().insert(id.to_string()); self.open
.lock()
.unwrap()
.insert(id.to_string(), subagent.title().to_string());
} }
} }