Show a session's subagents as subcards, each with a read-only transcript

A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls. The claude translator routes lines
carrying parent_tool_use_id to a per-subagent translator and transcript
under <session>/subagents/<tool_use_id>; three routes expose the list, a
transcript page and the SSE stream. Echo grows /subagent [n] as the rig.

On the phone a card with subagents ends in a chevron expander, collapsed by
default, opening to outlined subcards styled like dev-updater's components;
a subcard opens SessionScreen in read-only form, addressed through
TranscriptAddress so paging, cache and stream are shared.

Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-05 13:41:15 -04:00
1 parent eff5c8b0c0
commit 9fa09b0af1
21 files changed
+1953 -332

No files matched your search

+127 -2
View File
@@ -15,6 +15,7 @@ pub mod import;
pub mod llama;
pub mod pending;
pub mod process;
pub mod subagent;
pub mod transcript;
pub mod transport;
@@ -37,6 +38,7 @@ use driver::{
};
use echo::EchoDriver;
use llama::LlamaDriver;
use subagent::Subagents;
use transcript::{SeqEvent, Transcript};
use transport::Transport;
@@ -265,6 +267,11 @@ pub struct SessionInfo {
pub status: SessionStatus,
pub last_activity: f64,
pub created: f64,
/// How many subagents this session has started, from a directory
/// listing rather than reading each one's status -- see
/// `GET /sessions/{id}/subagents` for that. 0 when it has none, not
/// absent: every session can say this without asking anything.
pub subagents: usize,
}
/// What is running a session at this moment, and `None` when nothing is.
@@ -293,6 +300,10 @@ pub struct LiveSession {
events: broadcast::Sender<SeqEvent>,
transcript_path: PathBuf,
shared: Arc<Shared>,
/// This session's subagents -- see `SUBAGENTS.md`. Built once at launch
/// and handed to whichever driver replaces it across a stop/start, so a
/// subagent started before a Stop is still there to read after a Start.
subagents: Arc<Subagents>,
}
/// Commands waiting for the session to be between turns.
@@ -502,6 +513,19 @@ impl LiveSession {
&self.transcript_path
}
pub fn subagents(&self) -> &Arc<Subagents> {
&self.subagents
}
/// 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
/// `GET /sessions/{id}/subagents`, which needs exactly this and nothing
/// else `SessionInfo` carries.
pub fn status(&self) -> SessionStatus {
*self.shared.status.lock().unwrap()
}
/// The session's directory (attachments in, produced files out live in
/// `attachments/` and `files/` under it).
pub fn dir(&self) -> &Path {
@@ -578,6 +602,7 @@ impl LiveSession {
status: *self.shared.status.lock().unwrap(),
last_activity: *self.shared.last_activity.lock().unwrap(),
created: self.meta.created,
subagents: subagent::count(self.dir()),
}
}
}
@@ -1065,6 +1090,7 @@ impl SessionManager {
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
last_activity: meta.created,
created: meta.created,
subagents: subagent::count(&self.data_dir.join(&meta.id)),
},
})
.collect()
@@ -1828,6 +1854,7 @@ impl SessionManager {
session.dir(),
session.transcript_path(),
&session.sink,
session.subagents(),
)?);
}
// Nothing is live for this one -- a session whose launch failed
@@ -2289,6 +2316,11 @@ fn launch(
let (sink, source) = mpsc::unbounded_channel();
let (events, _) = broadcast::channel(EVENT_BUFFER);
// Built once per session, here, rather than per driver: a subagent
// started before a Stop has to still be there to read after a Start,
// and only `launch` runs once across that boundary -- `start_if_exited`
// replaces the driver alone.
let subagents = Arc::new(subagent::Subagents::new(dir.clone()));
let shared = Arc::new(Shared {
// What it was last known to be doing, not an assumption. A driver
// that has something to say corrects this within its first poll.
@@ -2350,7 +2382,18 @@ fn launch(
let driver = Arc::new(Mutex::new(
driving
.then(|| make_driver(&meta, setup, provider, env, &dir, &transcript_path, &sink))
.then(|| {
make_driver(
&meta,
setup,
provider,
env,
&dir,
&transcript_path,
&sink,
&subagents,
)
})
.transpose()?,
));
@@ -2368,6 +2411,7 @@ fn launch(
events.clone(),
Arc::clone(&commands),
announce,
Arc::clone(&subagents),
));
Ok(Arc::new(LiveSession {
@@ -2378,6 +2422,7 @@ fn launch(
events,
transcript_path,
shared,
subagents,
}))
}
@@ -2388,6 +2433,7 @@ fn launch(
/// what [`SessionManager::start_session`] builds. That path replaces the
/// driver and nothing else, so it has to construct one the same way rather
/// than becoming a second answer to "what runs this".
#[allow(clippy::too_many_arguments)]
fn make_driver(
meta: &SessionConfig,
setup: &SetupConfig,
@@ -2396,13 +2442,17 @@ fn make_driver(
dir: &Path,
transcript_path: &Path,
sink: &EventSink,
subagents: &Arc<Subagents>,
) -> Result<Arc<dyn Driver>> {
Ok(match provider.kind {
DriverKind::Echo => Arc::new(EchoDriver::new(
sink.clone(),
dir.to_path_buf(),
env.usage.clone(),
Arc::clone(subagents),
)),
// llama.cpp has no notion of a Task call, so it takes the registry
// and never touches it -- see `SUBAGENTS.md`'s "Server layout".
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
meta,
provider,
@@ -2411,6 +2461,7 @@ fn make_driver(
transcript_path,
dir,
sink.clone(),
Arc::clone(subagents),
)?),
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
meta,
@@ -2418,6 +2469,7 @@ fn make_driver(
&Transport::for_setup(setup),
dir,
sink.clone(),
Arc::clone(subagents),
)?),
})
}
@@ -2482,6 +2534,7 @@ fn notification_for(
}
}
#[allow(clippy::too_many_arguments)]
async fn pump(
id: String,
mut transcript: Transcript,
@@ -2490,6 +2543,7 @@ async fn pump(
events: broadcast::Sender<SeqEvent>,
commands: Arc<Commands>,
announce: Announcements,
subagents: Arc<Subagents>,
) {
// Messages the session has been given and not started reading, which is
// what makes a turn ending not the same thing as the work ending.
@@ -2611,7 +2665,12 @@ async fn pump(
} => commands.take_one(),
Event::Status {
state: SessionStatus::Exited,
} => commands.abandon("this session's process has exited"),
} => {
commands.abandon("this session's process has exited");
// The process behind every open subagent was this
// session's own -- see `SUBAGENTS.md`'s lifecycle #4.
subagents.finish_all();
}
// The two ends of a message's wait. A `UserMessage` with
// no id never waited -- it was sent between turns, and
// counting it would take the total below zero.
@@ -2752,6 +2811,7 @@ mod tests {
sink.clone(),
dir.path().to_path_buf(),
crate::usage::Fixture::new(),
Arc::new(subagent::Subagents::new(dir.path().to_path_buf())),
))))),
sink,
waiting: Mutex::new(VecDeque::new()),
@@ -4399,4 +4459,69 @@ mod tests {
let seen = collect_turn(&mut rx).await;
assert!(seen.first().expect("events").seq > last_seq);
}
/// `/subagent 2` is the test rig for `SUBAGENTS.md`'s whole feature:
/// each helper gets its own transcript with its prompt as its first
/// user message, `SessionInfo::subagents` counts them from the
/// directory, and each finishes on its own a few seconds later.
#[tokio::test]
async fn subagent_helpers_get_their_own_transcripts_and_finish() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.ron");
let data_dir = dir.path().join("sessions");
seed_echo_only(&config_path);
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
let session = manager.session(&info.id).expect("live");
session.send_message("/subagent 2".to_string(), Vec::new());
// Both helpers exist as soon as their Task calls go out, well before
// either finishes.
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
loop {
if session.subagents().list(true).len() == 2 {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"both helpers should have started by now"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
let rows = session.subagents().list(true);
let mut titles: Vec<&str> = rows.iter().map(|row| row.title.as_str()).collect();
titles.sort_unstable();
assert_eq!(titles, ["helper 1", "helper 2"]);
assert!(rows.iter().all(|row| row.status == SessionStatus::Running));
assert_eq!(manager.sessions()[0].subagents, 2);
// Each subagent's own transcript opens with its prompt.
let first = session.subagents().get(&rows[0].id).expect("subagent");
let events =
transcript::read_after(&first.transcript_path(), 0).expect("read subagent transcript");
assert!(
events
.iter()
.any(|entry| matches!(&entry.event, Event::UserMessage { text, .. } if text.contains("helper")))
);
// Each finishes on its own about three seconds after it started.
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
if session
.subagents()
.list(true)
.iter()
.all(|row| row.status == SessionStatus::Exited)
{
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"both helpers should have finished by now"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}