Each working subagent in the panel has a Stop button: Claude's stop the task, delegated tasks stop their thread (the parent's agent hears the user stopped it), swarm agents get their turn cancelled over a short connection of their own, and Pi's subagent extension tasks stop the turn. A finished task's result reaches the parent's agent without a message in the transcript; older threads hide the ones they kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 6 changed files with 173 additions and 10 deletions.
| @@ -107,7 +107,7 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 107 | 107 | - A subagents panel (Ctrl+Alt+S, or the robot in the top bar), in the project panel's |
| 108 | 108 | place: every subagent the thread started, the working ones first with what each is |
| 109 | 109 | doing right now (its latest tool call and progress summary), how long it has run, its |
| 110 | - kind, tool uses and tokens, then the finished and failed ones. Click one to jump to its call in the conversation. While the | |
| 110 | + kind, tool uses and tokens, then the finished and failed ones. Every working one has a Stop button. Click one to jump to its call in the conversation. While the | |
| 111 | 111 | panel is closed, a light on the robot says subagents are working. It covers Claude's |
| 112 | 112 | Task and Agent calls; Pi's [`subagent` extension](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions/subagent) |
| 113 | 113 | (a row per task, single, parallel or chained); and jcode's swarm, whose agents keep |
| @@ -120,7 +120,7 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 120 | 120 | jcode). The subagents panel shows it working and opens its thread. The thread is |
| 121 | 121 | left out of the sidebar, and its header leads back to the parent. When the task |
| 122 | 122 | finishes, its answer goes to the call waiting on it (`mode: "wait"`) or reaches the |
| 123 | - parent as a message, which wakes its agent. Nothing to install: Claude reaches the | |
| 123 | + parent's agent as a message the transcript doesn't show, which wakes it. Nothing to install: Claude reaches the | |
| 124 | 124 | tools through an MCP server the app runs on loopback, with a token per thread. |
| 125 | 125 | Pi reaches them through an extension the app writes and loads with `--extension`; |
| 126 | 126 | before every run, that extension also switches off Pi's other subagent tools. |
| @@ -31,6 +31,9 @@ pub const TASK_STATUS: &str = "task_status"; | ||
| 31 | 31 | pub const TASK_CANCEL: &str = "task_cancel"; |
| 32 | 32 | pub const CAPABILITIES: &str = "orchestrator_capabilities"; |
| 33 | 33 | |
| 34 | + /// How the message that hands a subagent's result to its parent starts. | |
| 35 | + pub const RESULT_PREFIX: &str = "[Vectrum Code] Delegated task"; | |
| 36 | + | |
| 34 | 37 | /// The environment that tells the Pi extension where the server is. |
| 35 | 38 | const URL_ENV: &str = "VECTRUM_CODE_MCP_URL"; |
| 36 | 39 | const TOKEN_ENV: &str = "VECTRUM_CODE_MCP_TOKEN"; |
| @@ -143,6 +143,15 @@ impl ToolCall { | ||
| 143 | 143 | tool: member.tool.clone().filter(|_| running), |
| 144 | 144 | summary: member.summary.clone(), |
| 145 | 145 | thread: self.is_delegation().then(|| member.id.clone()), |
| 146 | + stop: running.then(|| { | |
| 147 | + if self.is_delegation() { | |
| 148 | + SubagentStop::Thread(member.id.clone()) | |
| 149 | + } else if self.name == SWARM_TOOL { | |
| 150 | + SubagentStop::SwarmAgent(member.id.clone()) | |
| 151 | + } else { | |
| 152 | + SubagentStop::Turn | |
| 153 | + } | |
| 154 | + }), | |
| 146 | 155 | } |
| 147 | 156 | }) |
| 148 | 157 | .collect(); |
| @@ -176,6 +185,9 @@ impl ToolCall { | ||
| 176 | 185 | tool: progress.tool.clone().filter(|_| running), |
| 177 | 186 | summary: progress.summary.clone(), |
| 178 | 187 | thread: None, |
| 188 | + // Claude's subagents are tasks; a delegation that isn't tied to | |
| 189 | + // its thread yet has nothing to stop. | |
| 190 | + stop: self.task_id.clone().filter(|_| running).map(SubagentStop::Task), | |
| 179 | 191 | }] |
| 180 | 192 | } |
| 181 | 193 | |
| @@ -206,6 +218,22 @@ pub struct SubagentEntry { | ||
| 206 | 218 | pub summary: Option<String>, |
| 207 | 219 | /// The thread it runs in, for a task delegated to the app. |
| 208 | 220 | pub thread: Option<String>, |
| 221 | + /// How to stop it, while it runs. | |
| 222 | + pub stop: Option<SubagentStop>, | |
| 223 | + } | |
| 224 | + | |
| 225 | + /// How the subagents panel stops a subagent. | |
| 226 | + #[derive(Clone, Debug, PartialEq)] | |
| 227 | + pub enum SubagentStop { | |
| 228 | + /// Claude's background-task control, by task id. | |
| 229 | + Task(String), | |
| 230 | + /// A task delegated to the app: its thread, by id. | |
| 231 | + Thread(String), | |
| 232 | + /// A jcode swarm agent, by session id. | |
| 233 | + SwarmAgent(String), | |
| 234 | + /// Nothing smaller than the whole turn can stop it (Pi's `subagent` | |
| 235 | + /// extension runs its tasks inside one tool call). | |
| 236 | + Turn, | |
| 209 | 237 | } |
| 210 | 238 | |
| 211 | 239 | /// jcode's tool for running a swarm of agents, each in a session of its own. |
| @@ -1328,6 +1356,90 @@ impl Session { | ||
| 1328 | 1356 | cx.notify(); |
| 1329 | 1357 | } |
| 1330 | 1358 | |
| 1359 | + /// Tells the agent something the user doesn't need to see, like a | |
| 1360 | + /// subagent's result: it reaches the agent like a message (mid-turn, as | |
| 1361 | + /// soon as it can take it in; otherwise it starts a turn), but the | |
| 1362 | + /// transcript only shows the agent's reply. | |
| 1363 | + pub fn send_hidden(&mut self, text: String, cx: &mut Context<Self>) { | |
| 1364 | + if self.remote.is_some() || text.trim().is_empty() { | |
| 1365 | + return; | |
| 1366 | + } | |
| 1367 | + self.updated = SystemTime::now(); | |
| 1368 | + self.resume_at = None; | |
| 1369 | + self.interrupted = false; | |
| 1370 | + if !self.is_working() { | |
| 1371 | + self.turn_started = Some(Instant::now()); | |
| 1372 | + self.turn_cost = 0.; | |
| 1373 | + self.snapshot_base(cx); | |
| 1374 | + } | |
| 1375 | + self.deliver(text, Vec::new(), cx); | |
| 1376 | + cx.notify(); | |
| 1377 | + } | |
| 1378 | + | |
| 1379 | + /// Stops one subagent of the thread's, by the panel's Stop button. | |
| 1380 | + pub fn stop_subagent(&mut self, stop: &SubagentStop, cx: &mut Context<Self>) { | |
| 1381 | + match stop { | |
| 1382 | + SubagentStop::Task(task_id) => self.stop_task(task_id, cx), | |
| 1383 | + SubagentStop::Turn => self.interrupt(cx), | |
| 1384 | + SubagentStop::SwarmAgent(agent) => { | |
| 1385 | + self.stop_swarm_agent(agent); | |
| 1386 | + if let Some(member) = self.swarm_member_mut(agent) { | |
| 1387 | + member.set_status(TaskStatus::Stopped); | |
| 1388 | + } | |
| 1389 | + cx.notify(); | |
| 1390 | + } | |
| 1391 | + // The workspace stops a delegated task's thread. | |
| 1392 | + SubagentStop::Thread(_) => {} | |
| 1393 | + } | |
| 1394 | + } | |
| 1395 | + | |
| 1396 | + /// Cancels a swarm agent's turn. jcode's API cancels only the session a | |
| 1397 | + /// connection is attached to, so this attaches a short connection of its | |
| 1398 | + /// own to the agent's session. | |
| 1399 | + fn stop_swarm_agent(&self, agent: &str) { | |
| 1400 | + let instance = settings::instance(&self.instance, self.provider); | |
| 1401 | + let agent = agent.to_string(); | |
| 1402 | + let _ = std::thread::Builder::new().name("swarm-stop".into()).spawn(move || { | |
| 1403 | + use std::io::{BufRead as _, Write as _}; | |
| 1404 | + let Ok((_, mut command)) = crate::claude::base_command(&instance) else { return }; | |
| 1405 | + command.args(crate::claude::split_args(&instance.args)); | |
| 1406 | + command | |
| 1407 | + .args(["--no-update", "api", "--stdio"]) | |
| 1408 | + .stdin(std::process::Stdio::piped()) | |
| 1409 | + .stdout(std::process::Stdio::piped()) | |
| 1410 | + .stderr(std::process::Stdio::null()); | |
| 1411 | + let Ok(mut child) = command.spawn() else { return }; | |
| 1412 | + let pid = child.id() as i32; | |
| 1413 | + // Don't hang around if jcode doesn't answer. | |
| 1414 | + let _ = std::thread::spawn(move || { | |
| 1415 | + std::thread::sleep(Duration::from_secs(15)); | |
| 1416 | + unsafe { libc::kill(pid, libc::SIGTERM) }; | |
| 1417 | + }); | |
| 1418 | + if let Some(mut stdin) = child.stdin.take() { | |
| 1419 | + let requests = [ | |
| 1420 | + json!({"v": 1, "id": 1, "req": "hello", "min_version": 1, "max_version": 1, "client": JCODE_CLIENT}), | |
| 1421 | + json!({"v": 1, "id": 2, "req": "attach_session", "session_id": agent}), | |
| 1422 | + json!({"v": 1, "id": 3, "req": "cancel", "session_id": agent}), | |
| 1423 | + ]; | |
| 1424 | + for request in requests { | |
| 1425 | + let _ = writeln!(stdin, "{request}"); | |
| 1426 | + } | |
| 1427 | + let _ = stdin.flush(); | |
| 1428 | + // Kept open until the cancel is through. | |
| 1429 | + if let Some(stdout) = child.stdout.take() { | |
| 1430 | + for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) { | |
| 1431 | + let reply = serde_json::from_str::<Value>(&line).unwrap_or_default(); | |
| 1432 | + if reply.get("reply_to").and_then(Value::as_u64) == Some(3) { | |
| 1433 | + break; | |
| 1434 | + } | |
| 1435 | + } | |
| 1436 | + } | |
| 1437 | + } | |
| 1438 | + let _ = child.kill(); | |
| 1439 | + let _ = child.wait(); | |
| 1440 | + }); | |
| 1441 | + } | |
| 1442 | + | |
| 1331 | 1443 | /// Holds a message until the running turn ends; sends it now if none runs. |
| 1332 | 1444 | pub fn queue_message(&mut self, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) { |
| 1333 | 1445 | if self.remote.is_some() { |
| @@ -384,6 +384,8 @@ pub(super) fn render_item( | ||
| 384 | 384 | .filter(|turn| turn.first == ix) |
| 385 | 385 | .map(|turn| render_turn_fold(session, scroller, turn, expanded, p)); |
| 386 | 386 | let visible = match (&turn, item) { |
| 387 | + // A subagent's result, which older threads kept as a message. | |
| 388 | + (_, ChatItem::User { text, .. }) if text.starts_with(crate::orchestrator::RESULT_PREFIX) => false, | |
| 387 | 389 | (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(), |
| 388 | 390 | (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true, |
| 389 | 391 | (_, ChatItem::Tool(ToolCall { ask: Some(_), .. }) | ChatItem::TaskEvent { .. }) => true, |
| @@ -394,7 +396,7 @@ pub(super) fn render_item( | ||
| 394 | 396 | let last_user = state.items.iter().rposition(|item| matches!(item, ChatItem::User { .. })); |
| 395 | 397 | let working = (state.is_working() && Some(ix) == last_user && !state.preparing && !state.needs_attention()) |
| 396 | 398 | .then(|| render_working(state, p)); |
| 397 | - if !visible && summary.is_none() { | |
| 399 | + if !visible && summary.is_none() && working.is_none() { | |
| 398 | 400 | return div().into_any_element(); |
| 399 | 401 | } |
| 400 | 402 | |
| @@ -2,8 +2,8 @@ | ||
| 2 | 2 | //! in a subagent thread: a thread of the parent's project, in its folder, |
| 3 | 3 | //! that the sidebar leaves out and the subagents panel opens. The parent's |
| 4 | 4 | //! `delegate_task` call shows it working. When it finishes, its result goes |
| 5 | - //! to the call waiting on it (`mode: "wait"`), or else to the parent thread | |
| 6 | - //! as a message, which wakes the agent there. | |
| 5 | + //! to the call waiting on it (`mode: "wait"`), or else to the parent's agent | |
| 6 | + //! as a message the transcript doesn't show, which wakes it. | |
| 7 | 7 | |
| 8 | 8 | use std::time::{Duration, SystemTime}; |
| 9 | 9 | |
| @@ -404,14 +404,15 @@ impl Workspace { | ||
| 404 | 404 | } |
| 405 | 405 | return; |
| 406 | 406 | } |
| 407 | - // Nobody's waiting: tell the parent, which wakes its agent. | |
| 407 | + // Nobody's waiting: tell the parent's agent, which wakes it. | |
| 408 | 408 | let title = child.read(cx).title.to_string(); |
| 409 | 409 | let mut message = format!( |
| 410 | - "[Vectrum Code] Delegated task {task} (\"{title}\") {}.", | |
| 410 | + "{} {task} (\"{title}\") {}.", | |
| 411 | + orchestrator::RESULT_PREFIX, | |
| 411 | 412 | match task_state { |
| 412 | 413 | TaskState::Completed => "completed", |
| 413 | 414 | TaskState::Failed => "failed", |
| 414 | - TaskState::Cancelled => "was cancelled", | |
| 415 | + TaskState::Cancelled => "was stopped by the user", | |
| 415 | 416 | _ => "stopped", |
| 416 | 417 | } |
| 417 | 418 | ); |
| @@ -419,7 +420,21 @@ impl Workspace { | ||
| 419 | 420 | Some(summary) => message.push_str(&format!(" Its final answer:\n\n{summary}")), |
| 420 | 421 | None => message.push_str(" It gave no answer."), |
| 421 | 422 | } |
| 422 | - parent.update(cx, |parent, cx| parent.send_message(message, Vec::new(), cx)); | |
| 423 | + parent.update(cx, |parent, cx| parent.send_hidden(message, cx)); | |
| 424 | + } | |
| 425 | + | |
| 426 | + /// Stops a delegated task from the subagents panel. Its parent's agent | |
| 427 | + /// hears that the user stopped it. | |
| 428 | + pub(super) fn stop_delegated(&mut self, task: &str, cx: &mut Context<Self>) { | |
| 429 | + let Some(child) = self.thread_by_id(task, cx) else { return }; | |
| 430 | + child.update(cx, |child, cx| { | |
| 431 | + if let Some(delegation) = child.delegation.as_mut() { | |
| 432 | + delegation.cancelled = true; | |
| 433 | + } | |
| 434 | + child.interrupt(cx); | |
| 435 | + cx.notify(); | |
| 436 | + }); | |
| 437 | + self.dirty.insert(child.entity_id()); | |
| 423 | 438 | } |
| 424 | 439 | } |
| 425 | 440 | |
| @@ -16,7 +16,7 @@ use gpui_kit::prelude::FluentBuilder as _; | ||
| 16 | 16 | use gpui_kit::*; |
| 17 | 17 | |
| 18 | 18 | use super::{PANEL_WIDTH, Workspace}; |
| 19 | - use crate::session::{ChatItem, Session, TaskStatus}; | |
| 19 | + use crate::session::{ChatItem, Session, SubagentStop, TaskStatus}; | |
| 20 | 20 | use crate::ui::chat::{describe_call, format_duration}; |
| 21 | 21 | use crate::ui::icons::Lucide; |
| 22 | 22 | use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; |
| @@ -41,6 +41,8 @@ struct Subagent { | ||
| 41 | 41 | summary: Option<SharedString>, |
| 42 | 42 | /// The thread a task delegated to the app runs in, by id. |
| 43 | 43 | thread: Option<String>, |
| 44 | + /// How its Stop button stops it, while it runs. | |
| 45 | + stop: Option<SubagentStop>, | |
| 44 | 46 | } |
| 45 | 47 | |
| 46 | 48 | /// A thread's subagents, in the order they started. |
| @@ -66,6 +68,7 @@ fn subagents_of(session: &Entity<Session>, cx: &App) -> Vec<Subagent> { | ||
| 66 | 68 | activity: entry.tool.map(|(name, input)| describe_call(&name, &input, &state.cwd)), |
| 67 | 69 | summary: entry.summary.map(Into::into), |
| 68 | 70 | thread: entry.thread, |
| 71 | + stop: entry.stop, | |
| 69 | 72 | }); |
| 70 | 73 | } |
| 71 | 74 | } |
| @@ -123,6 +126,14 @@ impl Workspace { | ||
| 123 | 126 | } |
| 124 | 127 | } |
| 125 | 128 | |
| 129 | + fn stop_subagent(&mut self, session: &Entity<Session>, stop: &SubagentStop, cx: &mut Context<Self>) { | |
| 130 | + match stop { | |
| 131 | + SubagentStop::Thread(task) => self.stop_delegated(task, cx), | |
| 132 | + _ => session.update(cx, |session, cx| session.stop_subagent(stop, cx)), | |
| 133 | + } | |
| 134 | + cx.notify(); | |
| 135 | + } | |
| 136 | + | |
| 126 | 137 | pub(super) fn render_subagents_panel(&self, cx: &mut Context<Self>) -> impl IntoElement { |
| 127 | 138 | let p = metal::palette(cx); |
| 128 | 139 | let mine = self.active.as_ref().map(|session| subagents_of(session, cx)).unwrap_or_default(); |
| @@ -232,6 +243,7 @@ impl Workspace { | ||
| 232 | 243 | .flatten() |
| 233 | 244 | .collect(); |
| 234 | 245 | let (session, ix, child) = (subagent.session.clone(), subagent.ix, subagent.thread.clone()); |
| 246 | + let (stop, stop_session) = (subagent.stop.clone(), subagent.session.clone()); | |
| 235 | 247 | h_flex() |
| 236 | 248 | .id(("subagent", id)) |
| 237 | 249 | .items_start() |
| @@ -278,6 +290,25 @@ impl Workspace { | ||
| 278 | 290 | .text_color(p.ink3) |
| 279 | 291 | .child(format_duration(elapsed.as_millis() as u64)), |
| 280 | 292 | ) |
| 293 | + }) | |
| 294 | + .when_some(stop, |this, stop| { | |
| 295 | + let tip = if stop == SubagentStop::Turn { | |
| 296 | + "Stop (stops the whole turn: Pi runs these inside one tool call)" | |
| 297 | + } else { | |
| 298 | + "Stop this subagent" | |
| 299 | + }; | |
| 300 | + let session = stop_session.clone(); | |
| 301 | + this.child( | |
| 302 | + MetalButton::new(("stop-subagent", id)) | |
| 303 | + .ghost() | |
| 304 | + .mini() | |
| 305 | + .icon(Lucide::Square) | |
| 306 | + .tooltip(tip) | |
| 307 | + .on_click(cx.listener(move |this, _, _, cx| { | |
| 308 | + cx.stop_propagation(); | |
| 309 | + this.stop_subagent(&session, &stop, cx); | |
| 310 | + })), | |
| 311 | + ) | |
| 281 | 312 | }), |
| 282 | 313 | ) |
| 283 | 314 | .when_some(subagent.activity, |this, (icon, label)| { |