Vectrum
Sign In

Subagents panel: Pi's subagent extension and jcode's swarm too

Pi: the subagent extension's progress (tool_execution_update details)
becomes a row per task, single, parallel or chained, with its agent,
latest tool, last words, tokens and exit status.

jcode: a swarm spawn's result names the agent's session. While any work,
the thread asks jcode's API for the session list (each agent's swarm
status and parent, so agents spawned by agents count too) and peeks at
working agents' transcripts for what they last said. Polling resumes
when a thread reattaches.

The timeline labels subagent and swarm calls instead of dumping JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 00c6f6ecommitc34716b

Showing 4 changed files with 511 additions and 61 deletions.

+7−3README.mdView File
@@ -105,11 +105,15 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
105105 switcher, **Commit** (asks the agent to commit, or commit and push), the size of the
106106 uncommitted changes, and the thread's cost.
107107 - A subagents panel (Ctrl+Alt+S, or the robot in the top bar), in the project panel's
108- place: every subagent Claude started in the thread, the working ones first with what each
109- is doing right now (its latest tool call and progress summary), how long it has run, its
108+ place: every subagent the thread started, the working ones first with what each is
109+ doing right now (its latest tool call and progress summary), how long it has run, its
110110 kind, tool uses and tokens, then the finished and failed ones. Subagents working in other
111111 threads list underneath. Click one to jump to its call in the conversation. While the
112- panel is closed, a light on the robot says subagents are working.
112+ panel is closed, a light on the robot says subagents are working. It covers Claude's
113+ Task and Agent calls; Pi's [`subagent` extension](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions/subagent)
114+ (a row per task, single, parallel or chained); and jcode's swarm, whose agents keep
115+ working in sessions of their own after the turn ends: the app checks on them every few
116+ seconds through jcode's session list, and reads what each last said.
113117 - Background tasks: when a command, monitor or subagent Claude left running finishes, the
114118 transcript says so and Claude picks the work back up and reports back on its own. A
115119 long-running command can be sent to the background from its tool card, like Ctrl+B in
+454−18src/session.rsView File
@@ -113,19 +113,142 @@ pub struct ToolCall {
113113 }
114114
115115 impl ToolCall {
116- /// Where a subagent stands: running until its tool returns, or, sent to
117- /// the background, until its task ends. `None` for other tools.
118- pub fn subagent_status(&self) -> Option<TaskStatus> {
119- self.subagent.as_ref()?;
120- Some(match (self.background, self.state) {
116+ /// The subagents this call started, as the subagents panel lists them:
117+ /// one for Claude's Task or Agent call, one per task for Pi's `subagent`
118+ /// extension, and the agents a jcode `swarm` spawn started.
119+ pub fn subagents(&self) -> Vec<SubagentEntry> {
120+ let Some(progress) = &self.subagent else { return Vec::new() };
121+ let call_running = self.state == ToolState::Running;
122+ if !progress.members.is_empty() {
123+ return progress
124+ .members
125+ .iter()
126+ .map(|member| {
127+ let mut status = TaskStatus::from_cli(&member.status);
128+ // A Pi subagent runs inside its call; a swarm agent
129+ // outlives the call that spawned it.
130+ if status == TaskStatus::Running && !call_running && self.name != SWARM_TOOL {
131+ status = TaskStatus::Stopped;
132+ }
133+ let running = status == TaskStatus::Running;
134+ SubagentEntry {
135+ label: member.label.clone(),
136+ kind: member.kind.clone(),
137+ status,
138+ background: false,
139+ tool_uses: member.tool_uses,
140+ tokens: member.tokens,
141+ elapsed: elapsed_between(member.started, member.finished, running),
142+ tool: member.tool.clone().filter(|_| running),
143+ summary: member.summary.clone(),
144+ }
145+ })
146+ .collect();
147+ }
148+ // Only a swarm call that spawns an agent starts one.
149+ if self.name == SWARM_TOOL && tool_field(&self.input, &["action"]).as_deref() != Some("spawn") {
150+ return Vec::new();
151+ }
152+ let status = match (self.background, self.state) {
121153 (Some(status), ToolState::Running | ToolState::Done) => status,
122154 (_, ToolState::Error) => TaskStatus::Failed,
123155 (None, ToolState::Running) => TaskStatus::Running,
124156 (None, ToolState::Done) => TaskStatus::Completed,
125- })
157+ };
158+ let running = status == TaskStatus::Running;
159+ let label = tool_field(&self.input, &["description", "label", "task"])
160+ .filter(|label| !label.trim().is_empty())
161+ .or_else(|| {
162+ let tasks = self.input.get("tasks").or_else(|| self.input.get("chain"))?.as_array()?.len();
163+ Some(format!("{tasks} subagents"))
164+ })
165+ .unwrap_or_else(|| "Subagent".into());
166+ vec![SubagentEntry {
167+ label,
168+ kind: tool_field(&self.input, &["subagent_type", "agent"]),
169+ status,
170+ background: self.background.is_some(),
171+ tool_uses: self.subagent_tool_count,
172+ tokens: progress.tokens,
173+ elapsed: elapsed_between(progress.started, progress.finished, running),
174+ tool: progress.tool.clone().filter(|_| running),
175+ summary: progress.summary.clone(),
176+ }]
126177 }
127178 }
128179
180+ /// A subagent as the subagents panel shows it.
181+ pub struct SubagentEntry {
182+ /// What it was asked to do.
183+ pub label: String,
184+ /// The kind of agent, e.g. `Explore`.
185+ pub kind: Option<String>,
186+ pub status: TaskStatus,
187+ pub background: bool,
188+ pub tool_uses: usize,
189+ pub tokens: Option<u64>,
190+ pub elapsed: Option<Duration>,
191+ /// Its latest tool call, while it works.
192+ pub tool: Option<(String, Value)>,
193+ pub summary: Option<String>,
194+ }
195+
196+ /// jcode's tool for running a swarm of agents, each in a session of its own.
197+ const SWARM_TOOL: &str = "swarm";
198+ /// How often the app checks on a thread's working swarm agents.
199+ const SWARM_POLL: Duration = Duration::from_secs(3);
200+ /// The jcode requests that check on swarm agents, by what they're for.
201+ const LIST_AGENTS: &str = "list the swarm's agents";
202+ const PEEK_AGENT: &str = "read a swarm agent's progress";
203+
204+ /// One agent among several a call started: a task of Pi's `subagent` call,
205+ /// or an agent a jcode `swarm` call spawned.
206+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
207+ pub struct SubagentMember {
208+ /// The task's place in a Pi call, or the jcode agent's session id.
209+ pub id: String,
210+ pub label: String,
211+ #[serde(default, skip_serializing_if = "Option::is_none")]
212+ pub kind: Option<String>,
213+ /// `running`, `completed`, `failed` or `stopped`.
214+ pub status: String,
215+ pub started: u64,
216+ #[serde(default, skip_serializing_if = "Option::is_none")]
217+ pub finished: Option<u64>,
218+ #[serde(default, skip_serializing_if = "Option::is_none")]
219+ pub tokens: Option<u64>,
220+ #[serde(default)]
221+ pub tool_uses: usize,
222+ #[serde(default, skip_serializing_if = "Option::is_none")]
223+ pub tool: Option<(String, Value)>,
224+ #[serde(default, skip_serializing_if = "Option::is_none")]
225+ pub summary: Option<String>,
226+ }
227+
228+ impl SubagentMember {
229+ /// Moves it to `status`, noting when it stopped running (or started
230+ /// again: a swarm agent can be handed more work).
231+ fn set_status(&mut self, status: TaskStatus) {
232+ self.status = status.key().to_string();
233+ if status == TaskStatus::Running {
234+ self.finished = None;
235+ } else {
236+ self.finished.get_or_insert_with(|| to_millis(SystemTime::now()));
237+ }
238+ }
239+ }
240+
241+ /// How long something ran from `started` to `finished` (or now, while it
242+ /// runs), in milliseconds since the epoch; `None` when that isn't known.
243+ fn elapsed_between(started: u64, finished: Option<u64>, running: bool) -> Option<Duration> {
244+ let end = match finished {
245+ Some(finished) => finished,
246+ None if running => to_millis(SystemTime::now()),
247+ None => return None,
248+ };
249+ (started > 0).then(|| Duration::from_millis(end.saturating_sub(started)))
250+ }
251+
129252 /// A subagent's progress, for the subagents panel. It's saved with the
130253 /// thread, so it reaches remote clients too.
131254 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -143,28 +266,107 @@ pub struct SubagentProgress {
143266 /// The CLI's running summary of what it's doing.
144267 #[serde(default, skip_serializing_if = "Option::is_none")]
145268 pub summary: Option<String>,
269+ /// The agents it started, when it starts several or they live apart
270+ /// from it (Pi's `subagent` tasks, jcode's swarm agents).
271+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
272+ pub members: Vec<SubagentMember>,
146273 }
147274
148275 impl SubagentProgress {
149276 fn finish(&mut self) {
150277 self.finished.get_or_insert_with(|| to_millis(SystemTime::now()));
151278 }
279+ }
280+
281+ /// Whether a tool starts subagents: Claude's Task and Agent, Pi's
282+ /// `subagent` extension (and jcode's old tool of that name), and jcode's
283+ /// swarm, whose spawns do.
284+ pub fn is_subagent_tool(name: &str) -> bool {
285+ matches!(name, "Task" | "Agent" | "subagent" | SWARM_TOOL)
286+ }
287+
288+ /// The tool call a model's message ends on, from Pi's or jcode's message
289+ /// content: its name and its input's short text fields.
290+ fn last_tool_call(content: &Value) -> Option<(String, Value)> {
291+ content.as_array()?.iter().rev().find_map(|block| {
292+ (block.get("type").and_then(Value::as_str) == Some("toolCall")).then(|| {
293+ let name = block.get("name").and_then(Value::as_str).unwrap_or("tool").to_string();
294+ (name, brief_input(&block["arguments"]))
295+ })
296+ })
297+ }
152298
153- /// How long it has run, or ran, if that's known: threads saved before
154- /// subagents were timed, and ones cut off by a restart, don't say.
155- pub fn elapsed(&self, running: bool) -> Option<Duration> {
156- let end = match self.finished {
157- Some(finished) => finished,
158- None if running => to_millis(SystemTime::now()),
159- None => return None,
299+ /// Folds the results Pi's `subagent` extension reports (one per task, in
300+ /// `details.results`) into the call's members.
301+ fn pi_subagent_results(tool: &mut ToolCall, details: Option<&Value>) {
302+ let (Some(progress), Some(results)) =
303+ (tool.subagent.as_mut(), details.and_then(|details| details.get("results")).and_then(Value::as_array))
304+ else {
305+ return;
306+ };
307+ let now = to_millis(SystemTime::now());
308+ for (ix, result) in results.iter().enumerate() {
309+ let id = ix.to_string();
310+ if !progress.members.iter().any(|member| member.id == id) {
311+ progress.members.push(SubagentMember { id: id.clone(), started: now, ..Default::default() });
312+ }
313+ let Some(member) = progress.members.iter_mut().find(|member| member.id == id) else { continue };
314+ let text = |key: &str| result.get(key).and_then(Value::as_str).filter(|text| !text.trim().is_empty());
315+ member.label = text("task")
316+ .and_then(|task| task.lines().map(str::trim).find(|line| !line.is_empty()))
317+ .map(|line| line.chars().take(200).collect())
318+ .unwrap_or_else(|| "Subagent".into());
319+ member.kind = text("agent").map(str::to_string);
320+ let messages: Vec<&Value> = result.get("messages").and_then(Value::as_array).into_iter().flatten().collect();
321+ let content = |message: &&Value| message.get("content").cloned().unwrap_or(Value::Null);
322+ member.tool_uses = messages
323+ .iter()
324+ .filter_map(|message| message.get("content")?.as_array())
325+ .flatten()
326+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("toolCall"))
327+ .count();
328+ member.tool = messages.iter().rev().find_map(|message| last_tool_call(&content(message)));
329+ member.summary = messages
330+ .iter()
331+ .rev()
332+ .filter(|message| message.get("role").and_then(Value::as_str) == Some("assistant"))
333+ .find_map(|message| last_line(&tool_result_text(&content(message))));
334+ let usage = &result["usage"];
335+ let tokens: u64 = ["input", "output", "cacheRead", "cacheWrite"]
336+ .iter()
337+ .filter_map(|key| usage.get(*key).and_then(Value::as_u64))
338+ .sum();
339+ member.tokens = (tokens > 0).then_some(tokens);
340+ let exit = result.get("exitCode").and_then(Value::as_i64).unwrap_or(-1);
341+ let status = match text("stopReason") {
342+ _ if exit < 0 => TaskStatus::Running,
343+ Some("aborted") => TaskStatus::Stopped,
344+ Some("error") => TaskStatus::Failed,
345+ _ if exit != 0 => TaskStatus::Failed,
346+ _ => TaskStatus::Completed,
160347 };
161- (self.started > 0).then(|| Duration::from_millis(end.saturating_sub(self.started)))
348+ member.set_status(status);
162349 }
163350 }
164351
165- /// Whether a tool starts a subagent.
166- pub fn is_subagent_tool(name: &str) -> bool {
167- matches!(name, "Task" | "Agent")
352+ /// A swarm agent's state from jcode's session list: its swarm lifecycle
353+ /// status, or failing that whether its session is mid-turn. `ready` is an
354+ /// agent done with its work and waiting for more.
355+ fn jcode_agent_status(swarm: Option<&str>, session: Option<&str>) -> TaskStatus {
356+ match swarm {
357+ Some("completed" | "done" | "ready") => TaskStatus::Completed,
358+ Some("failed" | "crashed") => TaskStatus::Failed,
359+ Some("stopped" | "closed" | "disconnected") => TaskStatus::Stopped,
360+ Some(_) => TaskStatus::Running,
361+ None if session == Some("running") => TaskStatus::Running,
362+ None => TaskStatus::Completed,
363+ }
364+ }
365+
366+ /// The last line of what an agent said, for its summary.
367+ fn last_line(text: &str) -> Option<String> {
368+ let line = text.lines().rev().map(str::trim).find(|line| !line.is_empty())?;
369+ Some(line.chars().take(200).collect())
168370 }
169371
170372 /// A tool input cut down to its short text fields: enough to say what the
@@ -544,6 +746,8 @@ struct JcodeLink {
544746 provider: Option<String>,
545747 /// The browser panel's instructions, sent along with the next message.
546748 browser_note: Option<String>,
749+ /// Checks on the swarm agents the thread spawned, while any work.
750+ agent_poll: Option<Task<()>>,
547751 }
548752
549753 pub struct Session {
@@ -2718,13 +2922,24 @@ impl Session {
27182922 );
27192923 }
27202924 }
2925+ // Pi's `subagent` extension reports its tasks as they go.
2926+ Some("tool_execution_update") => {
2927+ let id = message.get("toolCallId").and_then(Value::as_str).unwrap_or_default();
2928+ if let Some(tool) = self.find_tool_mut(id) {
2929+ pi_subagent_results(tool, message.pointer("/partialResult/details"));
2930+ }
2931+ }
27212932 Some("tool_execution_end") => {
27222933 let id = message.get("toolCallId").and_then(Value::as_str).unwrap_or_default();
27232934 let is_error = message.get("isError").and_then(Value::as_bool).unwrap_or(false);
27242935 let text = tool_result_text(message.pointer("/result/content").unwrap_or(&Value::Null));
27252936 if let Some(tool) = self.find_tool_mut(id) {
2937+ pi_subagent_results(tool, message.pointer("/result/details"));
27262938 tool.result = Some(text);
27272939 tool.state = if is_error { ToolState::Error } else { ToolState::Done };
2940+ if let Some(progress) = tool.subagent.as_mut() {
2941+ progress.finish();
2942+ }
27282943 }
27292944 }
27302945 Some("agent_settled") => {
@@ -2956,10 +3171,29 @@ impl Session {
29563171 for (what, request) in std::mem::take(&mut self.jcode.held) {
29573172 self.jcode_request(what, request);
29583173 }
3174+ // Swarm agents carry on in jcode while the app is away.
3175+ self.watch_swarm(cx);
3176+ }
3177+ "sessions" if request == Some(LIST_AGENTS) => self.update_swarm(&message["sessions"]),
3178+ "history" if request == Some(PEEK_AGENT) => {
3179+ let agent = text("session_id");
3180+ let said = message
3181+ .get("messages")
3182+ .and_then(Value::as_array)
3183+ .into_iter()
3184+ .flatten()
3185+ .rev()
3186+ .filter(|message| message.get("role").and_then(Value::as_str) == Some("assistant"))
3187+ .find_map(|message| last_line(message.get("content").and_then(Value::as_str).unwrap_or_default()));
3188+ if let Some(member) = self.swarm_member_mut(agent) {
3189+ member.summary = said.or(member.summary.take());
3190+ }
29593191 }
29603192 "error" => {
29613193 let error = text("message");
29623194 match request {
3195+ // Checking on swarm agents is best effort, and repeats.
3196+ Some(LIST_AGENTS | PEEK_AGENT) => {}
29633197 // The session is gone from jcode's store; carry on in a new one.
29643198 Some("attach") => {
29653199 self.push_notice(
@@ -3046,11 +3280,30 @@ impl Session {
30463280 };
30473281 take_partial_input(tool);
30483282 tool.state = if error.is_some() { ToolState::Error } else { ToolState::Done };
3283+ // A swarm spawn names the agent's session: watch it from here.
3284+ let spawned = (tool.name == SWARM_TOOL && error.is_none())
3285+ .then(|| output.trim().strip_prefix("Spawned new agent:").map(|id| id.trim().to_string()))
3286+ .flatten()
3287+ .filter(|id| !id.is_empty());
3288+ if let (Some(agent), Some(progress)) = (spawned, tool.subagent.as_mut()) {
3289+ let label = tool_field(&tool.input, &["label", "prompt"])
3290+ .and_then(|label| label.lines().next().map(|line| line.chars().take(200).collect()))
3291+ .unwrap_or_else(|| "Agent".into());
3292+ progress.members.push(SubagentMember {
3293+ id: agent,
3294+ label,
3295+ kind: tool_field(&tool.input, &["role"]).filter(|role| role != "agent"),
3296+ status: TaskStatus::Running.key().to_string(),
3297+ started: progress.started,
3298+ ..Default::default()
3299+ });
3300+ }
30493301 tool.result = Some(match error {
30503302 Some(error) if output.trim().is_empty() => error,
30513303 Some(error) => format!("{output}\n\n{error}"),
30523304 None => output,
30533305 });
3306+ self.watch_swarm(cx);
30543307 }
30553308 "token_usage" => {
30563309 let count = |key: &str| message.get(key).and_then(Value::as_u64).unwrap_or(0);
@@ -3124,6 +3377,130 @@ impl Session {
31243377 })
31253378 }
31263379
3380+ /// The swarm agents this thread spawned, across its swarm calls.
3381+ fn swarm_members(&self) -> impl Iterator<Item = &SubagentMember> {
3382+ self.items.iter().filter_map(|item| match item {
3383+ ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_ref(),
3384+ _ => None,
3385+ })
3386+ .flat_map(|progress| &progress.members)
3387+ }
3388+
3389+ fn swarm_member_mut(&mut self, agent: &str) -> Option<&mut SubagentMember> {
3390+ self.items
3391+ .iter_mut()
3392+ .filter_map(|item| match item {
3393+ ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_mut(),
3394+ _ => None,
3395+ })
3396+ .flat_map(|progress| progress.members.iter_mut())
3397+ .find(|member| member.id == agent)
3398+ }
3399+
3400+ /// Whether the thread has swarm agents to keep an eye on: ones that work,
3401+ /// or a spawn that hasn't named its agent yet.
3402+ fn swarm_busy(&self) -> bool {
3403+ self.swarm_members().any(|member| member.status == TaskStatus::Running.key())
3404+ || self.items.iter().any(|item| {
3405+ matches!(item, ChatItem::Tool(tool) if tool.name == SWARM_TOOL && tool.state == ToolState::Running)
3406+ })
3407+ }
3408+
3409+ /// Starts checking on the thread's swarm agents every few seconds, if
3410+ /// any work and it isn't already. jcode's API doesn't stream another
3411+ /// session's progress, so the app asks: the session list says where each
3412+ /// agent stands, and a peek at its transcript what it last said.
3413+ fn watch_swarm(&mut self, cx: &mut Context<Self>) {
3414+ if self.jcode.agent_poll.is_some() || !self.swarm_busy() {
3415+ return;
3416+ }
3417+ self.jcode.agent_poll = Some(cx.spawn(async move |this, cx| {
3418+ loop {
3419+ cx.background_executor().timer(SWARM_POLL).await;
3420+ let busy = this.update(cx, |this, _| {
3421+ let busy = this.process.is_some() && this.swarm_busy();
3422+ if busy {
3423+ this.poll_swarm();
3424+ } else if let Some(poll) = this.jcode.agent_poll.take() {
3425+ // This very loop: let it end on its own.
3426+ poll.detach();
3427+ }
3428+ busy
3429+ });
3430+ if !matches!(busy, Ok(true)) {
3431+ break;
3432+ }
3433+ }
3434+ }));
3435+ }
3436+
3437+ fn poll_swarm(&mut self) {
3438+ self.jcode_send(LIST_AGENTS, json!({"req": "list_sessions", "limit": 200}));
3439+ let working: Vec<String> = self
3440+ .swarm_members()
3441+ .filter(|member| member.status == TaskStatus::Running.key())
3442+ .map(|member| member.id.clone())
3443+ .collect();
3444+ for agent in working {
3445+ self.jcode_send(PEEK_AGENT, json!({"req": "peek_session", "session_id": agent, "limit": 4}));
3446+ }
3447+ }
3448+
3449+ /// Takes in where the thread's swarm agents stand, from jcode's session
3450+ /// list. Agents another agent of the thread spawned list too, under the
3451+ /// latest swarm call.
3452+ fn update_swarm(&mut self, sessions: &Value) {
3453+ let Some(own) = self.jcode.session.clone() else { return };
3454+ let Some(sessions) = sessions.as_array() else { return };
3455+ let ours: HashSet<String> = self.swarm_members().map(|member| member.id.clone()).collect();
3456+ let mut family: HashSet<String> = ours.iter().cloned().chain([own.clone()]).collect();
3457+ // Spawned by this session, or by one of its agents.
3458+ loop {
3459+ let before = family.len();
3460+ for session in sessions {
3461+ let parent = session.get("parent_session_id").and_then(Value::as_str).unwrap_or_default();
3462+ if let Some(id) = session.get("session_id").and_then(Value::as_str)
3463+ && family.contains(parent)
3464+ {
3465+ family.insert(id.to_string());
3466+ }
3467+ }
3468+ if family.len() == before {
3469+ break;
3470+ }
3471+ }
3472+ for session in sessions {
3473+ let Some(id) = session.get("session_id").and_then(Value::as_str) else { continue };
3474+ if id == own || !family.contains(id) {
3475+ continue;
3476+ }
3477+ let text = |key: &str| session.get(key).and_then(Value::as_str).filter(|text| !text.trim().is_empty());
3478+ let status = jcode_agent_status(text("swarm_status"), text("status"));
3479+ if self.swarm_member_mut(id).is_none() {
3480+ // An agent the app didn't see spawned: file it under the latest swarm call.
3481+ let Some(progress) = self.items.iter_mut().rev().find_map(|item| match item {
3482+ ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_mut(),
3483+ _ => None,
3484+ }) else {
3485+ return;
3486+ };
3487+ progress.members.push(SubagentMember {
3488+ id: id.to_string(),
3489+ started: to_millis(SystemTime::now()),
3490+ ..Default::default()
3491+ });
3492+ }
3493+ let Some(member) = self.swarm_member_mut(id) else { continue };
3494+ if let Some(label) = text("agent_label").or(text("title")) {
3495+ member.label = label.to_string();
3496+ }
3497+ if member.label.is_empty() {
3498+ member.label = "Agent".into();
3499+ }
3500+ member.set_status(status);
3501+ }
3502+ }
3503+
31273504 // ---- helpers -----------------------------------------------------
31283505
31293506 fn push_assistant_text(&mut self, text: &str, cx: &mut Context<Self>) -> usize {
@@ -4155,7 +4532,66 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String
41554532
41564533 #[cfg(test)]
41574534 mod tests {
4158- use super::finished_prefix;
4535+ use super::{TaskStatus, ToolCall, ToolState, finished_prefix, jcode_agent_status, pi_subagent_results};
4536+ use serde_json::{Value, json};
4537+
4538+ fn tool(name: &str, input: Value) -> ToolCall {
4539+ ToolCall {
4540+ id: "call".into(),
4541+ name: name.into(),
4542+ input,
4543+ partial_input: String::new(),
4544+ result: None,
4545+ state: ToolState::Running,
4546+ permission: None,
4547+ expanded: false,
4548+ subagent_tool_count: 0,
4549+ detached: false,
4550+ ask: None,
4551+ task_id: None,
4552+ background: None,
4553+ subagent: super::is_subagent_tool(name).then(super::SubagentProgress::default),
4554+ }
4555+ }
4556+
4557+ #[::core::prelude::v1::test]
4558+ fn pi_parallel_subagents() {
4559+ let mut call = tool("subagent", json!({"tasks": [{"agent": "scout", "task": "Find it"}, {"agent": "fixer", "task": "Fix it"}]}));
4560+ let details = json!({"mode": "parallel", "results": [
4561+ {"agent": "scout", "task": "Find it\nthoroughly", "exitCode": -1,
4562+ "usage": {"input": 1000, "output": 200},
4563+ "messages": [{"role": "assistant", "content": [
4564+ {"type": "text", "text": "Looking around"},
4565+ {"type": "toolCall", "id": "a", "name": "read", "arguments": {"path": "src/main.rs", "content": 7}}]}]},
4566+ {"agent": "fixer", "task": "Fix it", "exitCode": 1, "stopReason": "error", "messages": []},
4567+ ]});
4568+ pi_subagent_results(&mut call, Some(&details));
4569+ let entries = call.subagents();
4570+ assert_eq!(entries.len(), 2);
4571+ assert_eq!(entries[0].label, "Find it");
4572+ assert_eq!(entries[0].kind.as_deref(), Some("scout"));
4573+ assert_eq!(entries[0].status, TaskStatus::Running);
4574+ assert_eq!(entries[0].tokens, Some(1200));
4575+ assert_eq!(entries[0].tool_uses, 1);
4576+ assert_eq!(entries[0].tool, Some(("read".into(), json!({"path": "src/main.rs"}))));
4577+ assert_eq!(entries[0].summary.as_deref(), Some("Looking around"));
4578+ assert_eq!(entries[1].status, TaskStatus::Failed);
4579+ // Once the call is over, a task still marked running was cut off.
4580+ call.state = ToolState::Done;
4581+ assert_eq!(call.subagents()[0].status, TaskStatus::Stopped);
4582+ }
4583+
4584+ #[::core::prelude::v1::test]
4585+ fn swarm_calls() {
4586+ // Only spawns start agents; talking to the swarm doesn't.
4587+ assert!(tool("swarm", json!({"action": "message", "message": "hi"})).subagents().is_empty());
4588+ let spawn = tool("swarm", json!({"action": "spawn", "label": "Refactor"}));
4589+ assert_eq!(spawn.subagents()[0].label, "Refactor");
4590+ assert_eq!(jcode_agent_status(Some("running"), None), TaskStatus::Running);
4591+ assert_eq!(jcode_agent_status(Some("ready"), Some("idle")), TaskStatus::Completed);
4592+ assert_eq!(jcode_agent_status(Some("crashed"), None), TaskStatus::Failed);
4593+ assert_eq!(jcode_agent_status(None, Some("running")), TaskStatus::Running);
4594+ }
41594595
41604596 #[::core::prelude::v1::test]
41614597 fn finished_paragraphs() {
+22−1src/ui/chat/timeline.rsView File
@@ -133,6 +133,27 @@ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation {
133133 "websearch" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())),
134134 "webfetch" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())),
135135 "task" | "agent" => (Action::Other, field(&["description"]).unwrap_or_else(|| "Subagent".into())),
136+ // Pi's `subagent` extension (one task, parallel tasks or a chain),
137+ // and jcode's old subagent tool.
138+ "subagent" => {
139+ let several = input.get("tasks").or_else(|| input.get("chain")).and_then(Value::as_array).map(Vec::len);
140+ let label = match several {
141+ Some(count) => format!("{count} subagents"),
142+ None => field(&["description", "task"]).map(first_line).unwrap_or_else(|| "Subagent".into()),
143+ };
144+ (Action::Other, label)
145+ }
146+ // jcode's swarm: spawning an agent, or talking to the swarm.
147+ "swarm" => {
148+ let action = field(&["action"]).unwrap_or_default();
149+ let label = match field(&["label", "prompt"]).map(first_line) {
150+ Some(label) if action == "spawn" => format!("Spawn agent: {label}"),
151+ _ if action == "spawn" => "Spawn agent".into(),
152+ _ if action.is_empty() => "Swarm".into(),
153+ _ => format!("Swarm: {}", action.replace('_', " ")),
154+ };
155+ (Action::Other, label)
156+ }
136157 "monitor" => (Action::Other, field(&["description", "command"]).map(first_line).unwrap_or_else(|| "Monitor".into())),
137158 _ if name.starts_with("mcp__") => {
138159 let mut parts = name.trim_start_matches("mcp__").splitn(2, "__");
@@ -146,7 +167,7 @@ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation {
146167 }
147168 };
148169 let icon = match name.to_ascii_lowercase().as_str() {
149- "task" | "agent" => Icon::new(IconName::Bot),
170+ "task" | "agent" | "subagent" | "swarm" => Icon::new(IconName::Bot),
150171 "monitor" => Icon::new(IconName::Eye),
151172 _ if name.starts_with("mcp__") => Icon::new(Lucide::Hammer),
152173 _ => action.icon(),
+28−39src/ui/workspace/subagents.rsView File
@@ -3,6 +3,9 @@
33 //! call, its progress summary, how long it's run, its tool uses and
44 //! tokens), then the finished ones. Subagents working in other threads
55 //! list under them. A click shows the subagent's call in its conversation.
6+ //!
7+ //! Claude's Task and Agent calls are one subagent each. Pi's `subagent`
8+ //! extension and jcode's swarm spawns can start several, each its own row.
69
710 use std::time::Duration;
811
@@ -11,7 +14,7 @@ use gpui_kit::prelude::FluentBuilder as _;
1114 use gpui_kit::*;
1215
1316 use super::{PANEL_WIDTH, Workspace};
14- use crate::session::{ChatItem, Session, TaskStatus, tool_field};
17+ use crate::session::{ChatItem, Session, TaskStatus};
1518 use crate::ui::chat::{describe_call, format_duration};
1619 use crate::ui::icons::Lucide;
1720 use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
@@ -21,6 +24,8 @@ struct Subagent {
2124 session: Entity<Session>,
2225 /// Its tool call's place in the thread's transcript.
2326 ix: usize,
27+ /// Tells apart the rows, several of which can share a call.
28+ id: usize,
2429 description: SharedString,
2530 /// The kind of agent, e.g. `Explore`.
2631 kind: Option<SharedString>,
@@ -37,40 +42,29 @@ struct Subagent {
3742 /// A thread's subagents, in the order they started.
3843 fn subagents_of(session: &Entity<Session>, cx: &App) -> Vec<Subagent> {
3944 let state = session.read(cx);
40- state
41- .items
42- .iter()
43- .enumerate()
44- .filter_map(|(ix, item)| {
45- let ChatItem::Tool(tool) = item else { return None };
46- let progress = tool.subagent.as_ref()?;
47- let status = tool.subagent_status()?;
48- let running = status == TaskStatus::Running;
49- let description = tool_field(&tool.input, &["description"])
50- .filter(|description| !description.trim().is_empty())
51- .unwrap_or_else(|| "Subagent".into());
52- Some(Subagent {
45+ let thread = session.entity_id().as_u64() as usize;
46+ let mut subagents = Vec::new();
47+ for (ix, item) in state.items.iter().enumerate() {
48+ let ChatItem::Tool(tool) = item else { continue };
49+ for (member, entry) in tool.subagents().into_iter().enumerate() {
50+ subagents.push(Subagent {
5351 session: session.clone(),
5452 ix,
55- description: description.into(),
53+ id: thread << 32 | ix << 8 | member,
54+ description: entry.label.into(),
5655 // The default kind goes without saying.
57- kind: tool_field(&tool.input, &["subagent_type"])
58- .filter(|kind| !kind.is_empty() && kind != "general-purpose")
59- .map(Into::into),
60- status,
61- background: tool.background.is_some(),
62- tool_uses: tool.subagent_tool_count,
63- tokens: progress.tokens,
64- elapsed: progress.elapsed(running),
65- activity: progress
66- .tool
67- .as_ref()
68- .filter(|_| running)
69- .map(|(name, input)| describe_call(name, input, &state.cwd)),
70- summary: progress.summary.clone().map(Into::into),
71- })
72- })
73- .collect()
56+ kind: entry.kind.filter(|kind| !kind.is_empty() && kind != "general-purpose").map(Into::into),
57+ status: entry.status,
58+ background: entry.background,
59+ tool_uses: entry.tool_uses,
60+ tokens: entry.tokens,
61+ elapsed: entry.elapsed,
62+ activity: entry.tool.map(|(name, input)| describe_call(&name, &input, &state.cwd)),
63+ summary: entry.summary.map(Into::into),
64+ });
65+ }
66+ }
67+ subagents
7468 }
7569
7670 /// `812 tokens`, `23.4k tokens`, `1.2M tokens`.
@@ -86,12 +80,7 @@ impl Workspace {
8680 /// How many subagents the open thread has working, for the header's light.
8781 pub(super) fn working_subagents(&self, cx: &App) -> usize {
8882 self.active.as_ref().map_or(0, |session| {
89- session
90- .read(cx)
91- .items
92- .iter()
93- .filter(|item| matches!(item, ChatItem::Tool(tool) if tool.subagent_status() == Some(TaskStatus::Running)))
94- .count()
83+ subagents_of(session, cx).iter().filter(|subagent| subagent.status == TaskStatus::Running).count()
9584 })
9685 }
9786
@@ -218,7 +207,7 @@ impl Workspace {
218207 TaskStatus::Failed => (Led::Failed, Some("Failed")),
219208 TaskStatus::Stopped => (Led::Off, Some("Stopped")),
220209 };
221- let id = (subagent.session.entity_id().as_u64() as usize) << 20 | subagent.ix;
210+ let id = subagent.id;
222211 let thread = other.then(|| subagent.session.read(cx).title.clone());
223212 let meta: Vec<String> = [
224213 subagent.kind.as_ref().map(ToString::to_string),