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

+16 -1
View File
@@ -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<SessionManager>) -> 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<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,
/// oldest first, with a status read from its own transcript -- see
/// `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::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<usize> {
self.state.lock().unwrap().background_task_count()
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
self.state.lock().unwrap().background_tasks()
}
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 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<usize>,
background_tasks: Option<Vec<BackgroundTask>>,
/// 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<Event> {
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<BackgroundTask> = 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<Event> {
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<usize> {
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<Vec<BackgroundTask>> {
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<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
/// 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.
+37 -3
View File
@@ -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<usize> {
Some(background_task_count(&self.inner))
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
Some(background_tasks(
&self.inner.subagents,
&self.inner.background_processes,
))
}
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));
}
/// 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 {
inner.subagents.open_count() + inner.background_processes.lock().unwrap().len()
}
+3
View File
@@ -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<usize> {
self.subagents.as_ref().map(|subagents| {
subagents.open_count()
+38 -3
View File
@@ -728,6 +728,40 @@ pub enum Unqueued {
/// is the backpressure-free buffer of record.
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
/// 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
/// dialect queues it for the next child process.
pub trait Driver: Send + Sync {
/// The provider's latest measured number of live background tasks.
/// `None` means it has not reported one, not that the count is zero.
fn background_tasks(&self) -> Option<usize> {
/// 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<Vec<BackgroundTask>> {
None
}
+19 -14
View File
@@ -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<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.
background_tasks: Arc<Mutex<usize>>,
background_tasks: Arc<Mutex<Vec<BackgroundTask>>>,
/// 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<Vec<Held>>, busy: &AtomicBool) {
}
impl Driver for EchoDriver {
fn background_tasks(&self) -> Option<usize> {
Some(*self.background_tasks.lock().unwrap())
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
Some(self.background_tasks.lock().unwrap().clone())
}
fn between_turns(&self) -> bool {
+13 -3
View File
@@ -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<Vec<BackgroundTask>> {
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()),
}
}
+39 -8
View File
@@ -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<Transcript>,
events: broadcast::Sender<SeqEvent>,
/// 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 `<dir>/subagents`.
dir: PathBuf,
live: Mutex<HashMap<String, Arc<Subagent>>>,
/// 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<HashSet<String>>,
/// 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<HashMap<String, String>>,
}
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());
}
}