Vectrum
Sign In

Subagents: a Stop button on each, and results reach the parent unseen

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>
JjohannSocommitted 1 parent 14066a2commit4bc1f4d

Showing 6 changed files with 173 additions and 10 deletions.

+2−2README.mdView File
@@ -107,7 +107,7 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
107107 - A subagents panel (Ctrl+Alt+S, or the robot in the top bar), in the project panel's
108108 place: every subagent the thread started, the working ones first with what each is
109109 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
111111 panel is closed, a light on the robot says subagents are working. It covers Claude's
112112 Task and Agent calls; Pi's [`subagent` extension](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions/subagent)
113113 (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.
120120 jcode). The subagents panel shows it working and opens its thread. The thread is
121121 left out of the sidebar, and its header leads back to the parent. When the task
122122 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
124124 tools through an MCP server the app runs on loopback, with a token per thread.
125125 Pi reaches them through an extension the app writes and loads with `--extension`;
126126 before every run, that extension also switches off Pi's other subagent tools.
+3−0src/orchestrator.rsView File
@@ -31,6 +31,9 @@ pub const TASK_STATUS: &str = "task_status";
3131 pub const TASK_CANCEL: &str = "task_cancel";
3232 pub const CAPABILITIES: &str = "orchestrator_capabilities";
3333
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+
3437 /// The environment that tells the Pi extension where the server is.
3538 const URL_ENV: &str = "VECTRUM_CODE_MCP_URL";
3639 const TOKEN_ENV: &str = "VECTRUM_CODE_MCP_TOKEN";
+112−0src/session.rsView File
@@ -143,6 +143,15 @@ impl ToolCall {
143143 tool: member.tool.clone().filter(|_| running),
144144 summary: member.summary.clone(),
145145 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+ }),
146155 }
147156 })
148157 .collect();
@@ -176,6 +185,9 @@ impl ToolCall {
176185 tool: progress.tool.clone().filter(|_| running),
177186 summary: progress.summary.clone(),
178187 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),
179191 }]
180192 }
181193
@@ -206,6 +218,22 @@ pub struct SubagentEntry {
206218 pub summary: Option<String>,
207219 /// The thread it runs in, for a task delegated to the app.
208220 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,
209237 }
210238
211239 /// jcode's tool for running a swarm of agents, each in a session of its own.
@@ -1328,6 +1356,90 @@ impl Session {
13281356 cx.notify();
13291357 }
13301358
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+
13311443 /// Holds a message until the running turn ends; sends it now if none runs.
13321444 pub fn queue_message(&mut self, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) {
13331445 if self.remote.is_some() {
+3−1src/ui/chat/timeline.rsView File
@@ -384,6 +384,8 @@ pub(super) fn render_item(
384384 .filter(|turn| turn.first == ix)
385385 .map(|turn| render_turn_fold(session, scroller, turn, expanded, p));
386386 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,
387389 (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(),
388390 (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true,
389391 (_, ChatItem::Tool(ToolCall { ask: Some(_), .. }) | ChatItem::TaskEvent { .. }) => true,
@@ -394,7 +396,7 @@ pub(super) fn render_item(
394396 let last_user = state.items.iter().rposition(|item| matches!(item, ChatItem::User { .. }));
395397 let working = (state.is_working() && Some(ix) == last_user && !state.preparing && !state.needs_attention())
396398 .then(|| render_working(state, p));
397- if !visible && summary.is_none() {
399+ if !visible && summary.is_none() && working.is_none() {
398400 return div().into_any_element();
399401 }
400402
+21−6src/ui/workspace/delegation.rsView File
@@ -2,8 +2,8 @@
22 //! in a subagent thread: a thread of the parent's project, in its folder,
33 //! that the sidebar leaves out and the subagents panel opens. The parent's
44 //! `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.
77
88 use std::time::{Duration, SystemTime};
99
@@ -404,14 +404,15 @@ impl Workspace {
404404 }
405405 return;
406406 }
407- // Nobody's waiting: tell the parent, which wakes its agent.
407+ // Nobody's waiting: tell the parent's agent, which wakes it.
408408 let title = child.read(cx).title.to_string();
409409 let mut message = format!(
410- "[Vectrum Code] Delegated task {task} (\"{title}\") {}.",
410+ "{} {task} (\"{title}\") {}.",
411+ orchestrator::RESULT_PREFIX,
411412 match task_state {
412413 TaskState::Completed => "completed",
413414 TaskState::Failed => "failed",
414- TaskState::Cancelled => "was cancelled",
415+ TaskState::Cancelled => "was stopped by the user",
415416 _ => "stopped",
416417 }
417418 );
@@ -419,7 +420,21 @@ impl Workspace {
419420 Some(summary) => message.push_str(&format!(" Its final answer:\n\n{summary}")),
420421 None => message.push_str(" It gave no answer."),
421422 }
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());
423438 }
424439 }
425440
+32−1src/ui/workspace/subagents.rsView File
@@ -16,7 +16,7 @@ use gpui_kit::prelude::FluentBuilder as _;
1616 use gpui_kit::*;
1717
1818 use super::{PANEL_WIDTH, Workspace};
19- use crate::session::{ChatItem, Session, TaskStatus};
19+ use crate::session::{ChatItem, Session, SubagentStop, TaskStatus};
2020 use crate::ui::chat::{describe_call, format_duration};
2121 use crate::ui::icons::Lucide;
2222 use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
@@ -41,6 +41,8 @@ struct Subagent {
4141 summary: Option<SharedString>,
4242 /// The thread a task delegated to the app runs in, by id.
4343 thread: Option<String>,
44+ /// How its Stop button stops it, while it runs.
45+ stop: Option<SubagentStop>,
4446 }
4547
4648 /// A thread's subagents, in the order they started.
@@ -66,6 +68,7 @@ fn subagents_of(session: &Entity<Session>, cx: &App) -> Vec<Subagent> {
6668 activity: entry.tool.map(|(name, input)| describe_call(&name, &input, &state.cwd)),
6769 summary: entry.summary.map(Into::into),
6870 thread: entry.thread,
71+ stop: entry.stop,
6972 });
7073 }
7174 }
@@ -123,6 +126,14 @@ impl Workspace {
123126 }
124127 }
125128
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+
126137 pub(super) fn render_subagents_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
127138 let p = metal::palette(cx);
128139 let mine = self.active.as_ref().map(|session| subagents_of(session, cx)).unwrap_or_default();
@@ -232,6 +243,7 @@ impl Workspace {
232243 .flatten()
233244 .collect();
234245 let (session, ix, child) = (subagent.session.clone(), subagent.ix, subagent.thread.clone());
246+ let (stop, stop_session) = (subagent.stop.clone(), subagent.session.clone());
235247 h_flex()
236248 .id(("subagent", id))
237249 .items_start()
@@ -278,6 +290,25 @@ impl Workspace {
278290 .text_color(p.ink3)
279291 .child(format_duration(elapsed.as_millis() as u64)),
280292 )
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+ )
281312 }),
282313 )
283314 .when_some(subagent.activity, |this, (icon, label)| {