//! A session's subagents -- see `SUBAGENTS.md`. //! //! **A subagent is a second transcript owned by a session, in the same event //! model, with no process and no controls.** It shares the transcript file //! format, the paging routes, and the SSE stream with a session by //! addressing, not by copying: `Transcript`, `read_window` and `catch_up` //! work on a subagent's file unchanged. //! //! Storage is `/subagents//{meta.json,transcript.jsonl}`, //! where `` is Claude's Task tool_use id or Codex's child thread id -- //! unique, stable across a backend restart, and already the key the parent //! side uses. Only ids matching [`is_subagent_id`] are ever turned into a //! path. use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; use super::driver::{Event, SessionStatus}; use super::transcript::{SeqEvent, Transcript}; /// Fan-out buffer for one subagent's SSE subscribers. Smaller than a /// session's: a subagent's whole conversation is usually a handful of tool /// calls, not an hours-long session. const EVENT_BUFFER: usize = 64; /// Whether `id` is safe to become a path segment under a session's /// `subagents/` directory. Mirrors `import::is_session_id`'s reasoning: the /// id arrives as a value inside JSON the CLI sent, and it becomes a /// directory name, so a `/` or `..` in it must never be trusted. fn is_subagent_id(id: &str) -> bool { !id.is_empty() && id.len() <= 200 && id .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') } /// What a subagent's directory holds beside its transcript. Small and /// separate from `Subagent` itself because this is exactly what survives a /// backend restart on disk, and nothing else does. #[derive(Debug, Clone, Serialize, Deserialize)] struct Meta { title: String, /// Epoch seconds. Absent from `SubagentInfo`'s sort key deliberately: /// `list` sorts by this rather than by directory order, which a /// filesystem does not promise. created: f64, } /// One row of `GET /sessions/{id}/subagents`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SubagentInfo { pub id: String, pub title: String, pub status: SessionStatus, pub created: f64, pub last_activity: f64, } /// One subagent: its own transcript and broadcast, same shape as a /// 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 /// read back from `Transcript::last_status` -- that answers "as of /// opening" (see its own doc comment) and never moves for an append made /// through *this* object, which is every append a live subagent ever /// makes. Without this, `finish` immediately after `start` in the same /// process read the file's stale opening status and reported itself /// still open. status: Mutex, } impl Subagent { fn title(&self) -> &str { &self.title } pub fn transcript_path(&self) -> PathBuf { self.dir.join("transcript.jsonl") } pub fn subscribe(&self) -> broadcast::Receiver { self.events.subscribe() } /// Whether this subagent's last recorded status is not `Exited` -- /// what decides whether a further child line reopens it (see /// `Subagents::reopen`) rather than continuing straight through. See /// `SUBAGENTS.md`'s lifecycle. pub fn is_open(&self) -> bool { *self.status.lock().unwrap() != SessionStatus::Exited } fn append(&self, event: Event) -> bool { let mut transcript = self.transcript.lock().unwrap(); match transcript.append(event, super::now()) { Ok(entry) => { if let Event::Status { state } = &entry.event { *self.status.lock().unwrap() = *state; } // No subscribers is fine; the transcript already has it, // same as a session's pump. let _ = self.events.send(entry); true } Err(err) => { tracing::error!("subagent transcript append failed: {err:#}"); false } } } } /// Every subagent one session has started, keyed by the provider's stable /// parent-side id for it. /// /// Lives beside a session's driver rather than inside it: a claude driver /// holds an `Arc` to this and routes child lines into it; echo uses it for /// its `/subagent` rig; llama ignores it, since it has no notion of a Task /// call. One instance per live session, built at launch and handed to /// whichever driver replaces it across a stop/start. pub struct Subagents { /// The session's own directory; subagents live under `/subagents`. dir: PathBuf, live: 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 { pub fn new(session_dir: PathBuf) -> Self { let subagents = Self { dir: session_dir, live: Mutex::new(HashMap::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, info.title)), ); subagents } fn subagents_dir(&self) -> PathBuf { self.dir.join("subagents") } /// Opens the subagent named `id`, creating it if this is the first /// anyone has heard of it -- on disk as well as in memory, so a /// subagent from before a backend restart is reopened rather than /// recreated. `title`/`prompt` are used only at creation: reopening an /// existing one keeps its original title and never repeats the prompt /// into its transcript a second time. fn open_or_create(&self, id: &str, title: &str, prompt: Option<&str>) -> Result> { let dir = self.subagents_dir().join(id); let meta_path = dir.join("meta.json"); let existed = meta_path.is_file(); let meta = if existed { let text = fs::read_to_string(&meta_path) .with_context(|| format!("read {}", meta_path.display()))?; serde_json::from_str::(&text).context("parse subagent meta")? } else { wg_app_link::private::create_dir(&dir)?; let meta = Meta { title: title.to_string(), created: super::now(), }; wg_app_link::private::write_file( &meta_path, serde_json::to_string(&meta) .context("serialize subagent meta")? .as_bytes(), )?; meta }; let mut transcript = Transcript::open(&dir.join("transcript.jsonl"))?; if !existed { // First lines, in order: the subagent is running the moment it // exists, and its prompt -- when known -- is genuinely its first // user turn. Written once, here, so a reopen never repeats them. transcript.append( Event::Status { state: SessionStatus::Running, }, meta.created, )?; if let Some(prompt) = prompt { transcript.append( Event::UserMessage { id: None, text: prompt.to_string(), attachments: Vec::new(), }, meta.created, )?; } } // A freshly created subagent is running by construction (its only // lines so far are `Status::Running` and maybe its prompt); a // reopened one takes whatever the file last said, since this // `Transcript` has not been appended to yet in this process. let status = if existed { transcript.last_status().unwrap_or(SessionStatus::Running) } else { SessionStatus::Running }; let (events, _) = broadcast::channel(EVENT_BUFFER); Ok(Arc::new(Subagent { dir, title: meta.title, transcript: Mutex::new(transcript), events, status: Mutex::new(status), })) } /// Starts a subagent unless one is already known by this id -- see /// `SUBAGENTS.md`'s lifecycle: created at the Task call or at the first /// child line, whichever comes first, and never twice. A bad id is /// refused rather than turned into a path. pub fn start(&self, id: &str, title: &str, prompt: Option<&str>) { if !is_subagent_id(id) { tracing::debug!("refusing to start a subagent with a bad id {id:?}"); return; } let mut live = self.live.lock().unwrap(); if live.contains_key(id) { return; } match self.open_or_create(id, title, prompt) { Ok(subagent) => { if subagent.is_open() { self.open .lock() .unwrap() .insert(id.to_string(), subagent.title().to_string()); } live.insert(id.to_string(), subagent); } Err(err) => tracing::error!("couldn't start subagent {id}: {err:#}"), } } /// The subagent named `id`, reopening it from disk on first use in this /// process if one is there. `None` for an id nothing has ever started -- /// deliberately not created here, since a route or a routing decision is /// not the Task call that is supposed to be the only way one begins. pub fn get(&self, id: &str) -> Option> { if !is_subagent_id(id) { return None; } if let Some(existing) = self.live.lock().unwrap().get(id).cloned() { return Some(existing); } if !self.subagents_dir().join(id).join("meta.json").is_file() { return None; } // Title and prompt are ignored: the directory already exists, so // `open_or_create` reads its own meta rather than using either. match self.open_or_create(id, "", None) { Ok(subagent) => { self.live .lock() .unwrap() .insert(id.to_string(), Arc::clone(&subagent)); Some(subagent) } Err(err) => { tracing::error!("couldn't reopen subagent {id}: {err:#}"); None } } } /// Whether the subagent named `id` exists and has not finished. `false` /// for an id that is not a subagent's at all -- a backgrounded command's /// tool call reaches here with the same shape. pub fn is_open(&self, id: &str) -> bool { self.get(id).is_some_and(|subagent| subagent.is_open()) } /// Whether this session has any subagent still working. /// /// That is the whole point of it. A backend restart adopts a session's /// process and picks its stdout back up from a recorded offset, so the /// `task_started` lines for subagents launched before the restart are /// already behind that offset and the translator never sees them -- it /// starts with an empty set and reports the session `Idle` at the end of /// a turn it should have called [`SessionStatus::Waiting`]. pub fn any_open(&self, session_running: bool) -> bool { session_running && self.open_count() > 0 } /// Latest measured number of live subagents, including ones found on disk at construction. pub fn open_count(&self) -> usize { 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 /// at. pub fn record(&self, id: &str, event: Event) { match self.live.lock().unwrap().get(id).cloned() { Some(subagent) => { subagent.append(event); } None => tracing::debug!("dropping an event for unknown subagent {id}"), } } /// The subagent's own turn ended: its `Status::Exited`. Called from /// `translate_child` on the subagent's own `end_turn`, never on the /// parent's `tool_result` -- a background Task's `tool_result` arrives /// at launch, not at completion, so it says nothing about whether this /// is over. A no-op for an id that is not a subagent's or is already /// closed. pub fn finish(&self, id: &str) { if let Some(subagent) = self.live.lock().unwrap().get(id).cloned() && subagent.is_open() && subagent.append(Event::Status { state: SessionStatus::Exited, }) { self.open.lock().unwrap().remove(id); } } /// A line arrived for a subagent that had already finished: it is /// working again, not stale -- a background Task can be sent another /// message long after its first turn ended. Appends `Status::Running` /// so the list stops reporting it as finished; a no-op if it was not /// actually closed, so a caller need not check first. pub fn reopen(&self, id: &str) { if let Some(subagent) = self.live.lock().unwrap().get(id).cloned() && !subagent.is_open() && subagent.append(Event::Status { state: SessionStatus::Running, }) { self.open .lock() .unwrap() .insert(id.to_string(), subagent.title().to_string()); } } /// The parent session's process is gone, so nothing still open here has /// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #5. /// /// Read from the directory rather than from `live`, because a subagent /// left `Running` by a *previous* run of this server is exactly the one /// that needs closing and is the one `live` does not have: nothing in /// this process ever touched it, so it would keep reading `running` /// every time its session was started again, with nothing able to /// correct it. pub fn finish_all(&self) { // `list(true)` reports each one's own last status rather than // rewriting a running one as unknown -- what is wanted here is which // are open on disk, and this call is the very thing that decides the // session is not running. for info in self.list(true) { if info.status != SessionStatus::Running { continue; } let _ = self.get(&info.id); self.finish(&info.id); } } /// Removes subagents, transcripts and all -- `POST /// /sessions/{id}/subagents/delete`, and the path out for [`start`] /// short of deleting the whole session. /// /// **All or nothing, and only for one that has finished.** Every id is /// checked before anything is removed, so a batch naming one that is /// still running leaves the others exactly as they were rather than /// deleting up to the offender -- the reader picked a set, and a set /// half-deleted is indistinguishable, on the list, from rows they never /// picked. Refusing a running one is not withholding the capability: /// its transcript is still being written to, and its process is the /// session's to stop. /// /// `session_running` decides what "running" means here, exactly as it /// does in [`Subagents::list`]. /// /// [`start`]: Subagents::start pub fn delete(&self, ids: &[String], session_running: bool) -> Result<()> { let dirs: Vec = ids .iter() .map(|id| { anyhow::ensure!(is_subagent_id(id), "{id} is not a subagent id"); Ok(self.subagents_dir().join(id)) }) .collect::>()?; for (id, dir) in ids.iter().zip(&dirs) { let info = info_of(dir, session_running) .with_context(|| format!("there is no subagent {id} here"))?; anyhow::ensure!( info.status != SessionStatus::Running, "\"{}\" is still running -- it can be deleted once it has finished", info.title ); } let mut live = self.live.lock().unwrap(); for (id, dir) in ids.iter().zip(&dirs) { fs::remove_dir_all(dir).with_context(|| format!("delete {}", dir.display()))?; // Out of the registry as well as off the disk, so a later child // line for this id starts a new subagent rather than appending // to an unlinked file nothing can read. live.remove(id); self.open.lock().unwrap().remove(id); } Ok(()) } /// Every subagent under this session's directory, oldest first -- /// `GET /sessions/{id}/subagents`. Read straight from disk rather than /// from `live`, so a subagent from before this process started (or one /// this run has not yet touched) still shows up; one file read per /// subagent, which is fine at the handful a session usually has. /// /// `session_running` is what turns a subagent whose last status is /// `Running` into `Unknown`: its process was the session's, and the /// session has none. pub fn list(&self, session_running: bool) -> Vec { let mut rows: Vec = match fs::read_dir(self.subagents_dir()) { Ok(entries) => entries .filter_map(Result::ok) .filter_map(|entry| info_of(&entry.path(), session_running)) .collect(), // No directory is no subagents, not a fault worth reporting. Err(_) => Vec::new(), }; rows.sort_by(|a, b| { a.created .partial_cmp(&b.created) .unwrap_or(std::cmp::Ordering::Equal) }); rows } } fn info_of(subagent_dir: &Path, session_running: bool) -> Option { let id = subagent_dir.file_name()?.to_str()?.to_string(); let meta_path = subagent_dir.join("meta.json"); let text = fs::read_to_string(&meta_path).ok()?; let meta: Meta = serde_json::from_str(&text).ok()?; let transcript = Transcript::open(&subagent_dir.join("transcript.jsonl")).ok()?; // The subagent's first line is always `Status::Running`, written before // this directory is discoverable at all, so `None` here is not a state a // reader can actually observe -- but it is not this function's place to // invent one, so a status this build does not expect to see falls back // to the word the lifecycle promises it started in. let last_status = transcript.last_status().unwrap_or(SessionStatus::Running); let status = if last_status == SessionStatus::Running && !session_running { SessionStatus::Unknown } else { last_status }; Some(SubagentInfo { id, title: meta.title, status, created: meta.created, last_activity: transcript.last_activity().unwrap_or(meta.created), }) } /// How many subagents a session has, for `SessionInfo::subagents`: a /// directory listing, so the session list stays cheap and only the /// dedicated route pays for reading a status out of each one. pub fn count(session_dir: &Path) -> usize { fs::read_dir(session_dir.join("subagents")) .map(|entries| entries.filter_map(Result::ok).count()) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; #[test] fn a_bad_id_is_refused_rather_than_turned_into_a_path() { let dir = tempfile::tempdir().expect("tempdir"); let subagents = Subagents::new(dir.path().to_path_buf()); subagents.start("../../etc", "escape", None); assert!(subagents.get("../../etc").is_none()); assert!(!dir.path().join("subagents").exists()); } #[test] fn starting_twice_keeps_the_first_title_and_does_not_repeat_the_prompt() { let dir = tempfile::tempdir().expect("tempdir"); let subagents = Subagents::new(dir.path().to_path_buf()); subagents.start("toolu_1", "first title", Some("do the thing")); subagents.start("toolu_1", "second title", Some("do the thing")); let rows = subagents.list(true); assert_eq!(rows.len(), 1); assert_eq!(rows[0].title, "first title"); let events = crate::session::transcript::read_after( &subagents.get("toolu_1").unwrap().transcript_path(), 0, ) .expect("read"); assert_eq!( events .iter() .filter(|e| matches!(e.event, Event::UserMessage { .. })) .count(), 1 ); assert_eq!(subagents.open_count(), 1); } #[test] fn a_reopened_subagent_continues_its_own_transcript() { let dir = tempfile::tempdir().expect("tempdir"); { let subagents = Subagents::new(dir.path().to_path_buf()); subagents.start("toolu_2", "helper", Some("go")); subagents.record( "toolu_2", Event::AssistantText { delta: "working".to_string(), }, ); } // A fresh registry, the way a backend restart builds one. let subagents = Subagents::new(dir.path().to_path_buf()); assert_eq!(subagents.open_count(), 1); let subagent = subagents.get("toolu_2").expect("reopened"); assert!(subagent.is_open()); subagents.record( "toolu_2", Event::AssistantText { delta: " more".to_string(), }, ); let events = crate::session::transcript::read_after(&subagent.transcript_path(), 0).expect("read"); // Status, UserMessage, two AssistantText deltas, seq continuing. assert_eq!(events.len(), 4); assert_eq!(events.last().unwrap().seq, 4); } #[test] fn finishing_appends_exited_and_further_lines_are_droppable() { let dir = tempfile::tempdir().expect("tempdir"); let subagents = Subagents::new(dir.path().to_path_buf()); subagents.start("toolu_3", "helper", None); subagents.finish("toolu_3"); assert_eq!(subagents.open_count(), 0); let subagent = subagents.get("toolu_3").unwrap(); assert!(!subagent.is_open()); // On disk too, not only in the live cache `is_open` reads. assert_eq!( Transcript::open(&subagent.transcript_path()) .expect("reopen") .last_status(), Some(SessionStatus::Exited) ); // Finishing an id that was never a subagent is a no-op, not a panic. subagents.finish("never-started"); subagents.reopen("toolu_3"); assert_eq!(subagents.open_count(), 1); subagents.finish("toolu_3"); assert_eq!(subagents.open_count(), 0); } #[test] fn finish_all_closes_one_left_running_by_an_earlier_run() { // The state a backend restart leaves behind: the subagent is on disk // reading `Running` and nothing in this process has touched it, so a // registry that only knew its own `live` map left it running for // ever -- and the phone showed it as running every time the session // was started again. let dir = tempfile::tempdir().expect("tempdir"); { let subagents = Subagents::new(dir.path().to_path_buf()); subagents.start("toolu_stale", "helper", None); } let subagents = Subagents::new(dir.path().to_path_buf()); subagents.finish_all(); let rows = subagents.list(true); assert_eq!(rows.len(), 1); assert_eq!(rows[0].status, SessionStatus::Exited); } #[test] fn deleting_a_finished_subagent_takes_its_directory_with_it() { let dir = tempfile::tempdir().expect("tempdir"); let subagents = Subagents::new(dir.path().to_path_buf()); subagents.start("toolu_done", "helper", None); subagents.finish("toolu_done"); subagents .delete(&["toolu_done".to_string()], true) .expect("delete"); assert!(subagents.list(true).is_empty()); assert!(!dir.path().join("subagents").join("toolu_done").exists()); // Out of the live registry too, so a later line starts a new one rather than appending to // a file nothing can read. assert!(subagents.get("toolu_done").is_none()); } #[test] fn deleting_a_batch_with_a_running_one_in_it_deletes_none_of_it() { let dir = tempfile::tempdir().expect("tempdir"); let subagents = Subagents::new(dir.path().to_path_buf()); subagents.start("toolu_done", "finished helper", None); subagents.finish("toolu_done"); subagents.start("toolu_busy", "busy helper", None); let err = subagents .delete(&["toolu_done".to_string(), "toolu_busy".to_string()], true) .expect_err("refused"); assert!(err.to_string().contains("busy helper"), "{err:#}"); assert_eq!(subagents.list(true).len(), 2); // The same batch once the session behind it has no process: nothing there is running, so // both go. subagents .delete(&["toolu_done".to_string(), "toolu_busy".to_string()], false) .expect("delete"); assert!(subagents.list(false).is_empty()); } #[test] fn deleting_an_id_that_is_not_there_is_refused_rather_than_ignored() { let dir = tempfile::tempdir().expect("tempdir"); let subagents = Subagents::new(dir.path().to_path_buf()); assert!( subagents .delete(&["toolu_ghost".to_string()], true) .is_err() ); assert!(subagents.delete(&["../../etc".to_string()], true).is_err()); } #[test] fn finish_all_closes_only_what_is_still_open() { let dir = tempfile::tempdir().expect("tempdir"); let subagents = Subagents::new(dir.path().to_path_buf()); subagents.start("toolu_4", "one", None); subagents.start("toolu_5", "two", None); subagents.finish("toolu_4"); subagents.finish_all(); let rows = subagents.list(false); assert_eq!(rows.len(), 2); for row in rows { assert_eq!(row.status, SessionStatus::Exited); } } #[test] fn a_subagent_still_running_when_the_session_is_not_reports_unknown() { let dir = tempfile::tempdir().expect("tempdir"); let subagents = Subagents::new(dir.path().to_path_buf()); subagents.start("toolu_6", "helper", None); assert_eq!(subagents.list(true)[0].status, SessionStatus::Running); assert_eq!(subagents.list(false)[0].status, SessionStatus::Unknown); } #[test] fn list_is_oldest_first_and_the_count_matches_the_directory() { let dir = tempfile::tempdir().expect("tempdir"); let subagents = Subagents::new(dir.path().to_path_buf()); assert_eq!(count(dir.path()), 0); subagents.start("toolu_a", "a", None); std::thread::sleep(std::time::Duration::from_millis(2)); subagents.start("toolu_b", "b", None); let rows = subagents.list(true); assert_eq!( rows.iter().map(|r| r.id.as_str()).collect::>(), ["toolu_a", "toolu_b"] ); assert_eq!(count(dir.path()), 2); } }