Vectrum
Sign In

Merge subagents: the panel, app-owned subagents for every agent, and the right panel's surfaces

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 2 parents 31037aa 1d43ce0commit2e4890f

Showing 17 changed files with 2638 additions and 70 deletions.

Merge commit — showing changes against the first parent.

+27−1README.mdView File
@@ -104,6 +104,31 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
104104 Sublime Text, Kate or the file manager, whichever are installed), the branch with a
105105 switcher, **Commit** (asks the agent to commit, or commit and push), the size of the
106106 uncommitted changes, and the thread's cost.
107+ - A right panel, as T3 Code has it: its button in the top bar (Ctrl+Alt+B) opens "Open a
108+ surface", which lists the subagents, the browser and a terminal, keyed S, B and T. A
109+ terminal there holds the thread's terminals instead of the drawer under the conversation.
110+ - A subagents panel (Ctrl+Alt+S, or S in the right panel), in the project panel's
111+ place: every subagent the thread started, the working ones first with what each is
112+ doing right now (its latest tool call and progress summary), how long it has run, its
113+ 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
114+ panel is closed, a light on the right panel's button says subagents are working. It covers Claude's
115+ Task and Agent calls; Pi's [`subagent` extension](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions/subagent)
116+ (a row per task, single, parallel or chained); and jcode's swarm, whose agents keep
117+ working in sessions of their own after the turn ends: the app checks on them every few
118+ seconds through jcode's session list, and reads what each last said.
119+ - App-owned subagents, as T3 Code has them: every agent gets `delegate_task`,
120+ `task_status`, `task_cancel` and `orchestrator_capabilities`, with instructions for
121+ them. A delegated task runs in a subagent thread in the parent's folder, on the
122+ parent's agent and model or any other agent and model the app has (Claude, Pi or
123+ jcode). The subagents panel shows it working and opens its thread. The thread is
124+ left out of the sidebar, and its header leads back to the parent. When the task
125+ finishes, its answer goes to the call waiting on it (`mode: "wait"`) or reaches the
126+ parent's agent as a message the transcript doesn't show, which wakes it. Nothing to install: Claude reaches the
127+ tools through an MCP server the app runs on loopback, with a token per thread.
128+ Pi reaches them through an extension the app writes and loads with `--extension`;
129+ before every run, that extension also switches off Pi's other subagent tools.
130+ jcode gets them as session tools over its API, with its `swarm` and `schedule`
131+ tools turned off. Subagents can't delegate tasks of their own.
107132 - Background tasks: when a command, monitor or subagent Claude left running finishes, the
108133 transcript says so and Claude picks the work back up and reports back on its own. A
109134 long-running command can be sent to the background from its tool card, like Ctrl+B in
@@ -252,8 +277,9 @@ The shortcuts follow T3 Code's default keymap.
252277 | Ctrl+D / Ctrl+Shift+D | Split the terminal side by side / stacked (terminal focused) |
253278 | Ctrl+N / Ctrl+W | New terminal / close the terminal (terminal focused) |
254279 | Ctrl+Shift+C / Ctrl+Shift+V | Copy / paste in the terminal |
255- | Ctrl+Alt+B | Show or hide the right panel (the browser) |
280+ | Ctrl+Alt+B | Show or hide the right panel: Open a surface |
256281 | Ctrl+Shift+B | Show or hide the thread details (project panel) |
282+ | Ctrl+Alt+S | Show or hide the subagents panel |
257283 | Ctrl+O | Add a project folder |
258284 | Ctrl+, | Open or close settings |
259285 | Ctrl+Q | Quit |
+15−0src/claude.rsView File
@@ -16,6 +16,7 @@ use anyhow::{Context as _, Result, anyhow};
1616 use serde_json::{Value, json};
1717
1818 use crate::browser;
19+ use crate::orchestrator;
1920 use crate::models::Provider;
2021 use crate::settings::{ProviderInstance, expand_home};
2122
@@ -152,6 +153,9 @@ pub struct LaunchConfig {
152153 pub resume: Option<String>,
153154 /// The thread's agent-browser session, shared with the browser panel.
154155 pub browser_session: Option<String>,
156+ /// The thread, when its agent may delegate tasks to the app's subagents
157+ /// (subagents' own threads don't).
158+ pub delegating_thread: Option<String>,
155159 }
156160
157161 /// Everything the background reader threads report back to the UI.
@@ -176,6 +180,17 @@ impl AgentProcess {
176180 // environment; the thread tells it about the browser in a message.
177181 Provider::Jcode => jcode_command(config)?,
178182 };
183+ // The app's subagent tools; jcode's are registered over its API.
184+ match config.instance.kind {
185+ Provider::Claude => {
186+ if let Some(mcp) = config.delegating_thread.as_deref().and_then(orchestrator::claude_config) {
187+ let allowed = format!("mcp__{}", orchestrator::SERVER);
188+ command.args(["--mcp-config", &mcp, "--allowedTools", &allowed]);
189+ }
190+ }
191+ Provider::Pi => orchestrator::prepare_pi(&mut command, config.delegating_thread.as_deref()),
192+ Provider::Jcode => {}
193+ }
179194 // Claude and Pi take extra system prompt with this flag.
180195 if config.instance.kind != Provider::Jcode
181196 && let Some(session) = &config.browser_session
+3−0src/main.rsView File
@@ -9,6 +9,7 @@ mod clock;
99 mod git;
1010 mod http;
1111 mod models;
12+ mod orchestrator;
1213 mod remote;
1314 mod session;
1415 mod settings;
@@ -39,6 +40,7 @@ gpui_kit::actions!(
3940 Interrupt,
4041 ToggleSidebar,
4142 ToggleProjectPanel,
43+ ToggleSubagents,
4244 ToggleBrowser,
4345 FocusSearch,
4446 OpenSettings,
@@ -127,6 +129,7 @@ fn main() {
127129 // The app's own.
128130 KeyBinding::new("ctrl-o", OpenInEditor, None),
129131 KeyBinding::new("ctrl-shift-b", ToggleProjectPanel, None),
132+ KeyBinding::new("ctrl-alt-s", ToggleSubagents, None),
130133 KeyBinding::new("ctrl-shift-j", ToggleBrowser, None),
131134 KeyBinding::new("ctrl-,", OpenSettings, None),
132135 KeyBinding::new("ctrl-q", Quit, None),
+514−0src/orchestrator.rsaddedView File
@@ -0,0 +1,514 @@
1+ //! App-owned subagents, the way T3 Code does them: the agent calls
2+ //! `delegate_task`, and the app starts a child thread for the task, shows it
3+ //! working and hands its result back. Any agent can run any task.
4+ //!
5+ //! The tools reach each agent its own way. Claude and Pi call an MCP server
6+ //! the app runs on loopback: Claude through `--mcp-config`, Pi through an
7+ //! extension the app writes and loads with `--extension`. That extension
8+ //! also switches off Pi's other subagent tools before each run. jcode runs
9+ //! them as session tools registered over its API, with its swarm switched
10+ //! off. Nothing needs installing or setting up.
11+ //!
12+ //! Each thread's agent gets a token of its own, which tells the server who's
13+ //! calling. Calls go to the workspace on the UI thread, which answers them.
14+
15+ use std::{
16+ collections::HashMap,
17+ io::{BufRead, BufReader, Read, Write},
18+ net::{TcpListener, TcpStream},
19+ path::PathBuf,
20+ sync::{Mutex, OnceLock},
21+ thread,
22+ time::Duration,
23+ };
24+
25+ use serde_json::{Value, json};
26+
27+ /// The MCP server's name, as Claude's tool names carry it.
28+ pub const SERVER: &str = "vectrum-code";
29+ pub const DELEGATE: &str = "delegate_task";
30+ pub const TASK_STATUS: &str = "task_status";
31+ pub const TASK_CANCEL: &str = "task_cancel";
32+ pub const CAPABILITIES: &str = "orchestrator_capabilities";
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+
37+ /// The environment that tells the Pi extension where the server is.
38+ const URL_ENV: &str = "VECTRUM_CODE_MCP_URL";
39+ const TOKEN_ENV: &str = "VECTRUM_CODE_MCP_TOKEN";
40+
41+ /// What the tools are for, for agents whose instructions the app adds to:
42+ /// the MCP server's instructions (Claude), the system prompt (Pi) and a
43+ /// reminder with the first message (jcode).
44+ pub const INSTRUCTIONS: &str = "\
45+ ## Vectrum Code subagents
46+
47+ Vectrum Code runs this conversation and offers app-owned subagents through its `delegate_task`, `task_status`, `task_cancel` and `orchestrator_capabilities` tools. Tool names may carry a prefix, such as `mcp__vectrum-code__delegate_task`; they work the same.
48+
49+ - `delegate_task` starts a child agent of this thread, working in the same folder. It sees only the task you give it, none of this conversation, so make the task self-contained: the goal, the files and facts that matter, and what to report back. The user watches it work in Vectrum Code's subagents panel.
50+ - The child runs this thread's agent and model unless `target` names others. `orchestrator_capabilities` lists every agent (provider instance) and model the app has, so a task can go to another agent entirely.
51+ - Use `delegate_task` for subagent work; other subagent tools may be switched off. For independent pieces of work, start several tasks so they run in parallel.
52+ - `mode` defaults to `async`: the call returns a `taskId` at once, and the child's result arrives in this thread as a message once it finishes. Carry on with other work, or end your turn instead of polling. Use `task_status` only when you need a result mid-turn. `mode: \"wait\"` blocks until the child is done or `timeoutMs` runs out; the child keeps working after a timeout.
53+ - `task_cancel` stops a task. For a further round of work, call `delegate_task` again with the full context: the brief, what came back and what's still open.";
54+
55+ /// What the tools take, as MCP describes tools.
56+ pub fn tool_definitions() -> Value {
57+ json!([
58+ {
59+ "name": DELEGATE,
60+ "description": "Delegate one task to a subagent: a child agent of this thread that Vectrum Code runs in a thread of its own, in this thread's working folder. It gets only the task text, not this conversation, so make it self-contained. The provider instance and model default to this thread's; orchestrator_capabilities lists the others. mode 'async' (the default) returns a taskId at once, and the result arrives in this thread as a message when the child finishes; end your turn rather than polling. mode 'wait' blocks until the child finishes or timeoutMs passes (the child carries on after a timeout; its result then arrives as a message).",
61+ "inputSchema": {
62+ "type": "object",
63+ "properties": {
64+ "task": {"type": "string", "description": "The complete, self-contained task for the child agent."},
65+ "title": {"type": "string", "description": "A short title for the subagent, shown to the user."},
66+ "target": {
67+ "type": "object",
68+ "description": "Which agent and model run the task. Omitted fields inherit this thread's.",
69+ "properties": {
70+ "providerInstanceId": {"type": "string", "description": "A provider instance id from orchestrator_capabilities."},
71+ "model": {"type": "string", "description": "A model id that instance offers."}
72+ }
73+ },
74+ "mode": {"type": "string", "enum": ["async", "wait"], "description": "async (default) returns at once; wait blocks for the result."},
75+ "timeoutMs": {"type": "number", "description": "How long mode 'wait' waits. The child isn't stopped when it runs out."}
76+ },
77+ "required": ["task"]
78+ },
79+ "annotations": {"title": "Delegate a task", "destructiveHint": true, "openWorldHint": true}
80+ },
81+ {
82+ "name": TASK_STATUS,
83+ "description": "Where a delegated task stands: running, completed, failed or cancelled, and once it's done, its result.",
84+ "inputSchema": {
85+ "type": "object",
86+ "properties": {"taskId": {"type": "string"}},
87+ "required": ["taskId"]
88+ },
89+ "annotations": {"title": "Get a task's status", "readOnlyHint": true}
90+ },
91+ {
92+ "name": TASK_CANCEL,
93+ "description": "Stop a delegated task that's still running. Its result so far stays readable with task_status.",
94+ "inputSchema": {
95+ "type": "object",
96+ "properties": {"taskId": {"type": "string"}, "reason": {"type": "string"}},
97+ "required": ["taskId"]
98+ },
99+ "annotations": {"title": "Cancel a task", "destructiveHint": true}
100+ },
101+ {
102+ "name": CAPABILITIES,
103+ "description": "The agents (provider instances) and models Vectrum Code can run a delegated task with, and which ones this thread uses.",
104+ "inputSchema": {"type": "object", "properties": {}},
105+ "annotations": {"title": "List agents and models", "readOnlyHint": true}
106+ }
107+ ])
108+ }
109+
110+ /// Whether a tool call, by name, is one of the app's: plain for Pi and
111+ /// jcode, prefixed for Claude (`mcp__vectrum-code__delegate_task`).
112+ pub fn own_tool(name: &str) -> Option<&'static str> {
113+ let bare = name.strip_prefix("mcp__vectrum-code__").or_else(|| name.strip_prefix("mcp__vectrum_code__")).unwrap_or(name);
114+ [DELEGATE, TASK_STATUS, TASK_CANCEL, CAPABILITIES].into_iter().find(|tool| *tool == bare)
115+ }
116+
117+ /// A call from an agent, for the workspace to answer.
118+ pub struct ToolRequest {
119+ /// The calling thread's id.
120+ pub thread: String,
121+ pub tool: String,
122+ pub args: Value,
123+ /// Takes the answer: the result, or what went wrong.
124+ pub reply: smol::channel::Sender<Result<Value, String>>,
125+ }
126+
127+ struct Server {
128+ url: String,
129+ /// Thread id by token, and token by thread id.
130+ tokens: Mutex<(HashMap<String, String>, HashMap<String, String>)>,
131+ }
132+
133+ static LISTENER: OnceLock<Option<Server>> = OnceLock::new();
134+ static REQUESTS: OnceLock<(smol::channel::Sender<ToolRequest>, smol::channel::Receiver<ToolRequest>)> = OnceLock::new();
135+
136+ fn channel() -> &'static (smol::channel::Sender<ToolRequest>, smol::channel::Receiver<ToolRequest>) {
137+ REQUESTS.get_or_init(smol::channel::unbounded)
138+ }
139+
140+ /// The calls agents make, for the workspace to answer.
141+ pub fn requests() -> smol::channel::Receiver<ToolRequest> {
142+ channel().1.clone()
143+ }
144+
145+ /// Starts the server the first time it's needed. `None` if it can't listen.
146+ fn server() -> Option<&'static Server> {
147+ LISTENER
148+ .get_or_init(|| {
149+ let listener = TcpListener::bind(("127.0.0.1", 0)).ok()?;
150+ let port = listener.local_addr().ok()?.port();
151+ thread::Builder::new()
152+ .name("mcp-listener".into())
153+ .spawn(move || {
154+ for stream in listener.incoming() {
155+ let Ok(stream) = stream else { continue };
156+ let _ = thread::Builder::new().name("mcp-connection".into()).spawn(move || serve(stream));
157+ }
158+ })
159+ .ok()?;
160+ Some(Server { url: format!("http://127.0.0.1:{port}/mcp"), tokens: Mutex::default() })
161+ })
162+ .as_ref()
163+ }
164+
165+ /// The server's address and the token a thread's agent calls it with.
166+ pub fn credentials(thread: &str) -> Option<(String, String)> {
167+ let server = server()?;
168+ let mut tokens = server.tokens.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
169+ let token = match tokens.1.get(thread) {
170+ Some(token) => token.clone(),
171+ None => {
172+ let token = crate::remote::new_key();
173+ tokens.0.insert(token.clone(), thread.to_string());
174+ tokens.1.insert(thread.to_string(), token.clone());
175+ token
176+ }
177+ };
178+ Some((server.url.clone(), token))
179+ }
180+
181+ fn thread_for(token: &str) -> Option<String> {
182+ let server = server()?;
183+ let tokens = server.tokens.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
184+ tokens.0.get(token).cloned()
185+ }
186+
187+ /// Claude's `--mcp-config` for a thread.
188+ pub fn claude_config(thread: &str) -> Option<String> {
189+ let (url, token) = credentials(thread)?;
190+ let config = json!({
191+ "mcpServers": {
192+ SERVER: {
193+ "type": "http",
194+ "url": url,
195+ "headers": {"Authorization": format!("Bearer {token}")},
196+ // Long enough for a `mode: "wait"` call.
197+ "timeout": 3_900_000,
198+ }
199+ }
200+ });
201+ Some(config.to_string())
202+ }
203+
204+ /// Answers one connection's requests, one at a time, for as long as it stays
205+ /// open. Plain HTTP/1.1 with JSON replies: all of MCP's streamable HTTP
206+ /// transport a tools-only server needs.
207+ fn serve(stream: TcpStream) {
208+ let _ = stream.set_nodelay(true);
209+ let Ok(mut writer) = stream.try_clone() else { return };
210+ let mut reader = BufReader::new(stream);
211+ loop {
212+ let mut request_line = String::new();
213+ match reader.read_line(&mut request_line) {
214+ Ok(0) | Err(_) => return,
215+ Ok(_) => {}
216+ }
217+ let method = request_line.split_whitespace().next().unwrap_or_default().to_string();
218+ let mut length = 0usize;
219+ let mut token = None;
220+ loop {
221+ let mut line = String::new();
222+ if reader.read_line(&mut line).unwrap_or(0) == 0 {
223+ return;
224+ }
225+ let line = line.trim_end();
226+ if line.is_empty() {
227+ break;
228+ }
229+ let Some((name, value)) = line.split_once(':') else { continue };
230+ let value = value.trim();
231+ match name.trim().to_ascii_lowercase().as_str() {
232+ "content-length" => length = value.parse().unwrap_or(0),
233+ "authorization" => token = value.strip_prefix("Bearer ").map(str::to_string),
234+ _ => {}
235+ }
236+ }
237+ let mut body = vec![0; length.min(16 << 20)];
238+ if reader.read_exact(&mut body).is_err() {
239+ return;
240+ }
241+ let (status, reply) = if method != "POST" {
242+ // No server-sent event stream: everything's answered in the reply.
243+ ("405 Method Not Allowed", None)
244+ } else {
245+ match token.as_deref().and_then(thread_for) {
246+ None => ("401 Unauthorized", Some(json!({"error": "unknown token"}))),
247+ Some(thread) => match serde_json::from_slice::<Value>(&body) {
248+ Ok(message) => match handle(&thread, &message) {
249+ Some(reply) => ("200 OK", Some(reply)),
250+ // A notification.
251+ None => ("202 Accepted", None),
252+ },
253+ Err(err) => ("400 Bad Request", Some(json!({"error": err.to_string()}))),
254+ },
255+ }
256+ };
257+ let body = reply.map(|reply| reply.to_string()).unwrap_or_default();
258+ let head = format!(
259+ "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
260+ body.len()
261+ );
262+ if writer.write_all(head.as_bytes()).and_then(|_| writer.write_all(body.as_bytes())).is_err() {
263+ return;
264+ }
265+ let _ = writer.flush();
266+ }
267+ }
268+
269+ /// Answers one JSON-RPC message; `None` for a notification.
270+ fn handle(thread: &str, message: &Value) -> Option<Value> {
271+ let id = message.get("id").cloned()?;
272+ let params = message.get("params").cloned().unwrap_or(Value::Null);
273+ let result = match message.get("method").and_then(Value::as_str).unwrap_or_default() {
274+ "initialize" => Ok(json!({
275+ "protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or("2025-06-18"),
276+ "capabilities": {"tools": {"listChanged": false}},
277+ "serverInfo": {"name": SERVER, "version": env!("CARGO_PKG_VERSION")},
278+ "instructions": INSTRUCTIONS,
279+ })),
280+ "ping" => Ok(json!({})),
281+ "tools/list" => Ok(json!({"tools": tool_definitions()})),
282+ "tools/call" => {
283+ let tool = params.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
284+ let args = params.get("arguments").cloned().unwrap_or_else(|| json!({}));
285+ Ok(tool_result(call(thread, tool, args)))
286+ }
287+ method => Err(json!({"code": -32601, "message": format!("unknown method {method}")})),
288+ };
289+ Some(match result {
290+ Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
291+ Err(error) => json!({"jsonrpc": "2.0", "id": id, "error": error}),
292+ })
293+ }
294+
295+ /// Hands a call to the workspace and waits for its answer.
296+ fn call(thread: &str, tool: String, args: Value) -> Result<Value, String> {
297+ let (reply, answer) = smol::channel::bounded(1);
298+ let request = ToolRequest { thread: thread.to_string(), tool, args, reply };
299+ channel().0.send_blocking(request).map_err(|_| "Vectrum Code is shutting down.".to_string())?;
300+ answer.recv_blocking().unwrap_or_else(|_| Err("Vectrum Code dropped the call.".into()))
301+ }
302+
303+ /// An answer as an MCP tool result: the data as text for the model, and
304+ /// structured for scripts.
305+ pub fn tool_result(outcome: Result<Value, String>) -> Value {
306+ match outcome {
307+ Ok(value) => json!({
308+ "content": [{"type": "text", "text": serde_json::to_string_pretty(&value).unwrap_or_default()}],
309+ "structuredContent": value,
310+ }),
311+ Err(error) => json!({"content": [{"type": "text", "text": error}], "isError": true}),
312+ }
313+ }
314+
315+ /// The Pi extension: it bridges the app's tools into Pi (when the thread
316+ /// may delegate), adds the instructions to the system prompt, and switches
317+ /// off other subagent tools before every run.
318+ const PI_EXTENSION: &str = r#"// Written by Vectrum Code, which loads it into the Pi agents it runs.
319+ // Changes are overwritten.
320+ const URL = process.env.__URL_ENV__;
321+ const TOKEN = process.env.__TOKEN_ENV__;
322+ const TOOLS = __TOOLS__;
323+ const INSTRUCTIONS = __INSTRUCTIONS__;
324+ const OWN = new Set(TOOLS.map((tool) => tool.name));
325+ // Tools that start agents of their own: Pi's subagent extension and the like.
326+ const OTHER_SUBAGENTS = /subagent|^(task|tasks|agent|agents|swarm|spawn_agent|spawn_agents|delegate)$/i;
327+
328+ async function schema(inputSchema) {
329+ for (const name of ["typebox", "@sinclair/typebox"]) {
330+ try {
331+ const { Type } = await import(name);
332+ if (typeof Type?.Unsafe === "function") return Type.Unsafe(inputSchema);
333+ } catch {}
334+ }
335+ return inputSchema;
336+ }
337+
338+ let nextId = 1;
339+ async function callTool(name, args, signal) {
340+ const response = await fetch(URL, {
341+ method: "POST",
342+ headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${TOKEN}` },
343+ body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method: "tools/call", params: { name, arguments: args ?? {} } }),
344+ signal,
345+ });
346+ const body = await response.text();
347+ if (!response.ok) throw new Error(`Vectrum Code answered ${response.status}: ${body.slice(0, 400)}`);
348+ const message = JSON.parse(body);
349+ if (message.error) throw new Error(message.error.message ?? "Vectrum Code returned an error");
350+ return message.result;
351+ }
352+
353+ export default async function vectrumCode(pi) {
354+ // Subagents go through the app, so other subagent tools stay off. Checked
355+ // before every run, since extensions can turn tools back on.
356+ const switchOffOthers = () => {
357+ if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
358+ const active = pi.getActiveTools();
359+ const kept = active.filter((name) => OWN.has(name) || !OTHER_SUBAGENTS.test(name));
360+ if (kept.length !== active.length) pi.setActiveTools(kept);
361+ };
362+ pi.on("session_start", switchOffOthers);
363+ pi.on("before_agent_start", (event) => {
364+ switchOffOthers();
365+ if (URL && TOKEN) return { systemPrompt: event.systemPrompt + "\n\n" + INSTRUCTIONS };
366+ });
367+ if (!URL || !TOKEN) return;
368+ for (const tool of TOOLS) {
369+ pi.registerTool({
370+ name: tool.name,
371+ label: tool.annotations?.title ?? tool.name,
372+ description: tool.description,
373+ parameters: await schema(tool.inputSchema),
374+ async execute(_toolCallId, params, signal) {
375+ const result = await callTool(tool.name, params, signal);
376+ const text = (result?.content ?? []).filter((part) => part?.type === "text").map((part) => part.text).join("\n");
377+ if (result?.isError) throw new Error(text || `${tool.name} failed`);
378+ return { content: [{ type: "text", text }], details: result?.structuredContent };
379+ },
380+ });
381+ }
382+ }
383+ "#;
384+
385+ /// Writes the Pi extension where Pi can load it, and returns its path.
386+ fn pi_extension() -> Option<PathBuf> {
387+ static PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
388+ PATH.get_or_init(|| {
389+ let source = PI_EXTENSION
390+ .replace("__URL_ENV__", URL_ENV)
391+ .replace("__TOKEN_ENV__", TOKEN_ENV)
392+ .replace("__TOOLS__", &tool_definitions().to_string())
393+ .replace("__INSTRUCTIONS__", &Value::String(INSTRUCTIONS.into()).to_string());
394+ let path = crate::settings::data_dir()?.join("pi").join("vectrum-code.js");
395+ std::fs::create_dir_all(path.parent()?).ok()?;
396+ if std::fs::read_to_string(&path).ok().as_deref() != Some(source.as_str()) {
397+ std::fs::write(&path, source).ok()?;
398+ }
399+ Some(path)
400+ })
401+ .clone()
402+ }
403+
404+ /// Loads the extension into a Pi command. `thread` gets the tools; without
405+ /// it (a subagent's own thread) Pi only has its other subagent tools turned off.
406+ pub fn prepare_pi(command: &mut std::process::Command, thread: Option<&str>) {
407+ let Some(extension) = pi_extension() else { return };
408+ command.arg("--extension").arg(extension);
409+ if let Some((url, token)) = thread.and_then(credentials) {
410+ command.env(URL_ENV, url).env(TOKEN_ENV, token);
411+ } else {
412+ command.env_remove(URL_ENV).env_remove(TOKEN_ENV);
413+ }
414+ }
415+
416+ /// jcode's session tool configuration: the app's tools (when the thread may
417+ /// delegate) and its own agent-spawning tools off.
418+ pub fn jcode_tools(delegate: bool) -> Value {
419+ let custom: Vec<Value> = if delegate {
420+ tool_definitions()
421+ .as_array()
422+ .into_iter()
423+ .flatten()
424+ .map(|tool| json!({"name": tool["name"], "description": tool["description"], "parameters": tool["inputSchema"]}))
425+ .collect()
426+ } else {
427+ Vec::new()
428+ };
429+ // `schedule` can spawn a session too.
430+ json!({"disabled": ["swarm", "schedule"], "custom": custom})
431+ }
432+
433+ /// How long a call's `mode: "wait"` may block before the agent's own client
434+ /// gives up on it: jcode allows a session tool two minutes, and Node's
435+ /// fetch (Pi) waits five for a reply's headers.
436+ pub fn wait_limit(provider: crate::models::Provider) -> Duration {
437+ match provider {
438+ crate::models::Provider::Claude => Duration::from_secs(30 * 60),
439+ crate::models::Provider::Pi => Duration::from_secs(270),
440+ crate::models::Provider::Jcode => Duration::from_secs(100),
441+ }
442+ }
443+
444+ #[cfg(test)]
445+ mod tests {
446+ use serde_json::{Value, json};
447+
448+ use super::{DELEGATE, credentials, own_tool, requests};
449+
450+ fn post(url: &str, token: &str, body: Value) -> (u16, String) {
451+ let response = ureq::post(url).set("Authorization", &format!("Bearer {token}")).set("Content-Type", "application/json").send_string(&body.to_string());
452+ match response {
453+ Ok(response) => (response.status(), response.into_string().unwrap_or_default()),
454+ Err(ureq::Error::Status(code, response)) => (code, response.into_string().unwrap_or_default()),
455+ Err(err) => panic!("{err}"),
456+ }
457+ }
458+
459+ #[::core::prelude::v1::test]
460+ fn serves_mcp_to_the_threads_it_knows() {
461+ let (url, token) = credentials("thread-a").expect("listens");
462+ assert_eq!(credentials("thread-a").unwrap().1, token, "a thread keeps its token");
463+ assert_ne!(credentials("thread-b").unwrap().1, token);
464+
465+ let (status, _) = post(&url, "nope", json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}));
466+ assert_eq!(status, 401);
467+
468+ let (status, body) = post(&url, &token, json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26"}}));
469+ assert_eq!(status, 200);
470+ let reply: Value = serde_json::from_str(&body).unwrap();
471+ assert_eq!(reply["result"]["protocolVersion"], "2025-03-26");
472+ assert!(reply["result"]["instructions"].as_str().unwrap().contains("delegate_task"));
473+
474+ let (status, body) = post(&url, &token, json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
475+ assert_eq!((status, body.as_str()), (202, ""));
476+
477+ let (_, body) = post(&url, &token, json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}));
478+ let reply: Value = serde_json::from_str(&body).unwrap();
479+ assert_eq!(reply["result"]["tools"][0]["name"], DELEGATE);
480+
481+ // The workspace's side, answering with who called and what.
482+ let incoming = requests();
483+ std::thread::spawn(move || {
484+ while let Ok(request) = incoming.recv_blocking() {
485+ let outcome = match request.tool.as_str() {
486+ "task_status" => Ok(json!({"thread": request.thread, "args": request.args})),
487+ _ => Err("no such task".to_string()),
488+ };
489+ let _ = request.reply.send_blocking(outcome);
490+ }
491+ });
492+ let call = |name: &str| {
493+ let body = json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": name, "arguments": {"taskId": "t1"}}});
494+ let (_, body) = post(&url, &token, body);
495+ serde_json::from_str::<Value>(&body).unwrap()["result"].clone()
496+ };
497+ let result = call("task_status");
498+ assert_eq!(result["structuredContent"], json!({"thread": "thread-a", "args": {"taskId": "t1"}}));
499+ assert!(result.get("isError").is_none());
500+ let result = call("task_cancel");
501+ assert_eq!(result["isError"], true);
502+ assert_eq!(result["content"][0]["text"], "no such task");
503+ }
504+
505+ #[::core::prelude::v1::test]
506+ fn recognizes_its_tools_under_any_prefix() {
507+ assert_eq!(own_tool("delegate_task"), Some(DELEGATE));
508+ assert_eq!(own_tool("mcp__vectrum-code__delegate_task"), Some(DELEGATE));
509+ assert_eq!(own_tool("mcp__vectrum_code__task_status"), Some("task_status"));
510+ assert_eq!(own_tool("mcp__other__delegate_task"), None);
511+ assert_eq!(own_tool("Task"), None);
512+ }
513+ }
514+
+851−13src/session.rsView File
@@ -19,6 +19,7 @@ use crate::browser;
1919 use crate::claude::{AgentProcess, Effort, LaunchConfig, PermissionMode, ProcessEvent};
2020 use crate::git::{self, TurnChanges};
2121 use crate::models::{Catalog, Provider};
22+ use crate::orchestrator;
2223 use crate::remote::{ClientMessage, RemoteTask, ThreadAction, ThreadDetail, ThreadSummary, Upload, client::RemoteClient};
2324 use crate::settings::{self, BranchNaming, Streaming};
2425 use crate::worktree::{self, PullRequest, SetupStage, StageStatus, Worktree, WorktreePlan};
@@ -108,6 +109,324 @@ pub struct ToolCall {
108109 pub task_id: Option<String>,
109110 /// Set once the tool runs as a background task.
110111 pub background: Option<TaskStatus>,
112+ /// What a subagent (Task or Agent tool) is up to; `None` for other tools.
113+ pub subagent: Option<SubagentProgress>,
114+ }
115+
116+ impl ToolCall {
117+ /// The subagents this call started, as the subagents panel lists them:
118+ /// one for Claude's Task or Agent call, one per task for Pi's `subagent`
119+ /// extension, and the agents a jcode `swarm` spawn started.
120+ pub fn subagents(&self) -> Vec<SubagentEntry> {
121+ let Some(progress) = &self.subagent else { return Vec::new() };
122+ let call_running = self.state == ToolState::Running;
123+ if !progress.members.is_empty() {
124+ return progress
125+ .members
126+ .iter()
127+ .map(|member| {
128+ let mut status = TaskStatus::from_cli(&member.status);
129+ // A Pi subagent runs inside its call; a swarm agent or
130+ // a delegated task outlives the call that started it.
131+ if status == TaskStatus::Running && !call_running && !self.outlives_call() {
132+ status = TaskStatus::Stopped;
133+ }
134+ let running = status == TaskStatus::Running;
135+ SubagentEntry {
136+ label: member.label.clone(),
137+ kind: member.kind.clone(),
138+ status,
139+ background: false,
140+ tool_uses: member.tool_uses,
141+ tokens: member.tokens,
142+ elapsed: elapsed_between(member.started, member.finished, running),
143+ tool: member.tool.clone().filter(|_| running),
144+ summary: member.summary.clone(),
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+ }),
155+ }
156+ })
157+ .collect();
158+ }
159+ // Only a swarm call that spawns an agent starts one.
160+ if self.name == SWARM_TOOL && tool_field(&self.input, &["action"]).as_deref() != Some("spawn") {
161+ return Vec::new();
162+ }
163+ let status = match (self.background, self.state) {
164+ (Some(status), ToolState::Running | ToolState::Done) => status,
165+ (_, ToolState::Error) => TaskStatus::Failed,
166+ (None, ToolState::Running) => TaskStatus::Running,
167+ (None, ToolState::Done) => TaskStatus::Completed,
168+ };
169+ let running = status == TaskStatus::Running;
170+ let label = tool_field(&self.input, &["description", "label", "title", "task"])
171+ .filter(|label| !label.trim().is_empty())
172+ .or_else(|| {
173+ let tasks = self.input.get("tasks").or_else(|| self.input.get("chain"))?.as_array()?.len();
174+ Some(format!("{tasks} subagents"))
175+ })
176+ .unwrap_or_else(|| "Subagent".into());
177+ vec![SubagentEntry {
178+ label,
179+ kind: tool_field(&self.input, &["subagent_type", "agent"]),
180+ status,
181+ background: self.background.is_some(),
182+ tool_uses: self.subagent_tool_count,
183+ tokens: progress.tokens,
184+ elapsed: elapsed_between(progress.started, progress.finished, running),
185+ tool: progress.tool.clone().filter(|_| running),
186+ summary: progress.summary.clone(),
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),
191+ }]
192+ }
193+
194+ /// A call to the app's `delegate_task`, whose task runs in a thread of its own.
195+ pub fn is_delegation(&self) -> bool {
196+ orchestrator::own_tool(&self.name) == Some(orchestrator::DELEGATE)
197+ }
198+
199+ /// Whether the agents it starts carry on after the call returns.
200+ fn outlives_call(&self) -> bool {
201+ self.name == SWARM_TOOL || self.is_delegation()
202+ }
203+ }
204+
205+ /// A subagent as the subagents panel shows it.
206+ pub struct SubagentEntry {
207+ /// What it was asked to do.
208+ pub label: String,
209+ /// The kind of agent, e.g. `Explore`.
210+ pub kind: Option<String>,
211+ pub status: TaskStatus,
212+ pub background: bool,
213+ pub tool_uses: usize,
214+ pub tokens: Option<u64>,
215+ pub elapsed: Option<Duration>,
216+ /// Its latest tool call, while it works.
217+ pub tool: Option<(String, Value)>,
218+ pub summary: Option<String>,
219+ /// The thread it runs in, for a task delegated to the app.
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,
237+ }
238+
239+ /// jcode's tool for running a swarm of agents, each in a session of its own.
240+ const SWARM_TOOL: &str = "swarm";
241+ /// How often the app checks on a thread's working swarm agents.
242+ const SWARM_POLL: Duration = Duration::from_secs(3);
243+ /// The jcode requests that check on swarm agents, by what they're for.
244+ const LIST_AGENTS: &str = "list the swarm's agents";
245+ const PEEK_AGENT: &str = "read a swarm agent's progress";
246+ /// The jcode request that registers the app's tools.
247+ const CONFIGURE_TOOLS: &str = "set up the app's tools";
248+
249+ /// One agent among several a call started: a task of Pi's `subagent` call,
250+ /// or an agent a jcode `swarm` call spawned.
251+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
252+ pub struct SubagentMember {
253+ /// The task's place in a Pi call, or the jcode agent's session id.
254+ pub id: String,
255+ pub label: String,
256+ #[serde(default, skip_serializing_if = "Option::is_none")]
257+ pub kind: Option<String>,
258+ /// `running`, `completed`, `failed` or `stopped`.
259+ pub status: String,
260+ pub started: u64,
261+ #[serde(default, skip_serializing_if = "Option::is_none")]
262+ pub finished: Option<u64>,
263+ #[serde(default, skip_serializing_if = "Option::is_none")]
264+ pub tokens: Option<u64>,
265+ #[serde(default)]
266+ pub tool_uses: usize,
267+ #[serde(default, skip_serializing_if = "Option::is_none")]
268+ pub tool: Option<(String, Value)>,
269+ #[serde(default, skip_serializing_if = "Option::is_none")]
270+ pub summary: Option<String>,
271+ }
272+
273+ impl SubagentMember {
274+ /// Moves it to `status`, noting when it stopped running (or started
275+ /// again: a swarm agent can be handed more work).
276+ fn set_status(&mut self, status: TaskStatus) {
277+ self.status = status.key().to_string();
278+ if status == TaskStatus::Running {
279+ self.finished = None;
280+ } else {
281+ self.finished.get_or_insert_with(|| to_millis(SystemTime::now()));
282+ }
283+ }
284+ }
285+
286+ /// How long something ran from `started` to `finished` (or now, while it
287+ /// runs), in milliseconds since the epoch; `None` when that isn't known.
288+ fn elapsed_between(started: u64, finished: Option<u64>, running: bool) -> Option<Duration> {
289+ let end = match finished {
290+ Some(finished) => finished,
291+ None if running => to_millis(SystemTime::now()),
292+ None => return None,
293+ };
294+ (started > 0).then(|| Duration::from_millis(end.saturating_sub(started)))
295+ }
296+
297+ /// A subagent's progress, for the subagents panel. It's saved with the
298+ /// thread, so it reaches remote clients too.
299+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
300+ pub struct SubagentProgress {
301+ /// When it started and finished, in milliseconds since the epoch.
302+ pub started: u64,
303+ #[serde(default, skip_serializing_if = "Option::is_none")]
304+ pub finished: Option<u64>,
305+ /// The tokens it has used, as the CLI reports them.
306+ #[serde(default, skip_serializing_if = "Option::is_none")]
307+ pub tokens: Option<u64>,
308+ /// Its latest tool call: the tool and the input's short text fields.
309+ #[serde(default, skip_serializing_if = "Option::is_none")]
310+ pub tool: Option<(String, Value)>,
311+ /// The CLI's running summary of what it's doing.
312+ #[serde(default, skip_serializing_if = "Option::is_none")]
313+ pub summary: Option<String>,
314+ /// The agents it started, when it starts several or they live apart
315+ /// from it (Pi's `subagent` tasks, jcode's swarm agents).
316+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
317+ pub members: Vec<SubagentMember>,
318+ }
319+
320+ impl SubagentProgress {
321+ fn finish(&mut self) {
322+ self.finished.get_or_insert_with(|| to_millis(SystemTime::now()));
323+ }
324+ }
325+
326+ /// Whether a tool starts subagents: Claude's Task and Agent, Pi's
327+ /// `subagent` extension (and jcode's old tool of that name), and jcode's
328+ /// swarm, whose spawns do.
329+ pub fn is_subagent_tool(name: &str) -> bool {
330+ matches!(name, "Task" | "Agent" | "subagent" | SWARM_TOOL) || orchestrator::own_tool(name) == Some(orchestrator::DELEGATE)
331+ }
332+
333+ /// The tool call a model's message ends on, from Pi's or jcode's message
334+ /// content: its name and its input's short text fields.
335+ fn last_tool_call(content: &Value) -> Option<(String, Value)> {
336+ content.as_array()?.iter().rev().find_map(|block| {
337+ (block.get("type").and_then(Value::as_str) == Some("toolCall")).then(|| {
338+ let name = block.get("name").and_then(Value::as_str).unwrap_or("tool").to_string();
339+ (name, brief_input(&block["arguments"]))
340+ })
341+ })
342+ }
343+
344+ /// Folds the results Pi's `subagent` extension reports (one per task, in
345+ /// `details.results`) into the call's members.
346+ fn pi_subagent_results(tool: &mut ToolCall, details: Option<&Value>) {
347+ let (Some(progress), Some(results)) =
348+ (tool.subagent.as_mut(), details.and_then(|details| details.get("results")).and_then(Value::as_array))
349+ else {
350+ return;
351+ };
352+ let now = to_millis(SystemTime::now());
353+ for (ix, result) in results.iter().enumerate() {
354+ let id = ix.to_string();
355+ if !progress.members.iter().any(|member| member.id == id) {
356+ progress.members.push(SubagentMember { id: id.clone(), started: now, ..Default::default() });
357+ }
358+ let Some(member) = progress.members.iter_mut().find(|member| member.id == id) else { continue };
359+ let text = |key: &str| result.get(key).and_then(Value::as_str).filter(|text| !text.trim().is_empty());
360+ member.label = text("task")
361+ .and_then(|task| task.lines().map(str::trim).find(|line| !line.is_empty()))
362+ .map(|line| line.chars().take(200).collect())
363+ .unwrap_or_else(|| "Subagent".into());
364+ member.kind = text("agent").map(str::to_string);
365+ let messages: Vec<&Value> = result.get("messages").and_then(Value::as_array).into_iter().flatten().collect();
366+ let content = |message: &&Value| message.get("content").cloned().unwrap_or(Value::Null);
367+ member.tool_uses = messages
368+ .iter()
369+ .filter_map(|message| message.get("content")?.as_array())
370+ .flatten()
371+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("toolCall"))
372+ .count();
373+ member.tool = messages.iter().rev().find_map(|message| last_tool_call(&content(message)));
374+ member.summary = messages
375+ .iter()
376+ .rev()
377+ .filter(|message| message.get("role").and_then(Value::as_str) == Some("assistant"))
378+ .find_map(|message| last_line(&tool_result_text(&content(message))));
379+ let usage = &result["usage"];
380+ let tokens: u64 = ["input", "output", "cacheRead", "cacheWrite"]
381+ .iter()
382+ .filter_map(|key| usage.get(*key).and_then(Value::as_u64))
383+ .sum();
384+ member.tokens = (tokens > 0).then_some(tokens);
385+ let exit = result.get("exitCode").and_then(Value::as_i64).unwrap_or(-1);
386+ let status = match text("stopReason") {
387+ _ if exit < 0 => TaskStatus::Running,
388+ Some("aborted") => TaskStatus::Stopped,
389+ Some("error") => TaskStatus::Failed,
390+ _ if exit != 0 => TaskStatus::Failed,
391+ _ => TaskStatus::Completed,
392+ };
393+ member.set_status(status);
394+ }
395+ }
396+
397+ /// A swarm agent's state from jcode's session list: its swarm lifecycle
398+ /// status, or failing that whether its session is mid-turn. `ready` is an
399+ /// agent done with its work and waiting for more.
400+ fn jcode_agent_status(swarm: Option<&str>, session: Option<&str>) -> TaskStatus {
401+ match swarm {
402+ Some("completed" | "done" | "ready") => TaskStatus::Completed,
403+ Some("failed" | "crashed") => TaskStatus::Failed,
404+ Some("stopped" | "closed" | "disconnected") => TaskStatus::Stopped,
405+ Some(_) => TaskStatus::Running,
406+ None if session == Some("running") => TaskStatus::Running,
407+ None => TaskStatus::Completed,
408+ }
409+ }
410+
411+ /// The last line of what an agent said, for its summary.
412+ fn last_line(text: &str) -> Option<String> {
413+ let line = text.lines().rev().map(str::trim).find(|line| !line.is_empty())?;
414+ Some(line.chars().take(200).collect())
415+ }
416+
417+ /// A tool input cut down to its short text fields: enough to say what the
418+ /// call does, without a file's contents.
419+ pub(crate) fn brief_input(input: &Value) -> Value {
420+ let Some(fields) = input.as_object() else { return Value::Null };
421+ fields
422+ .iter()
423+ .filter_map(|(key, value)| {
424+ let text = value.as_str()?;
425+ let first = text.lines().next().unwrap_or_default();
426+ Some((key.clone(), Value::String(first.chars().take(200).collect())))
427+ })
428+ .collect::<serde_json::Map<_, _>>()
429+ .into()
111430 }
112431
113432 /// Where a background task stands.
@@ -120,7 +439,7 @@ pub enum TaskStatus {
120439 }
121440
122441 impl TaskStatus {
123- fn key(self) -> &'static str {
442+ pub fn key(self) -> &'static str {
124443 match self {
125444 TaskStatus::Running => "running",
126445 TaskStatus::Completed => "completed",
@@ -360,6 +679,29 @@ pub struct RemoteLink {
360679 pub enum SessionEvent {
361680 /// A finished turn's changes on disk are known, by its `TurnEnd` index.
362681 TurnChanges(usize),
682+ /// jcode called one of the app's tools, to be answered with
683+ /// [`Session::answer_app_tool`].
684+ AppTool { call_id: String, tool: String, input: Value },
685+ }
686+
687+ /// Where a subagent's thread starts: its parent thread's id, folder and branch.
688+ pub struct SubagentParent {
689+ pub id: String,
690+ pub cwd: PathBuf,
691+ pub branch: Option<String>,
692+ }
693+
694+ /// What makes a thread a subagent: the thread that delegated its task. The
695+ /// task goes by the subagent thread's id.
696+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
697+ pub struct Delegation {
698+ pub parent: String,
699+ /// Its result reached the parent, in a reply or a message.
700+ #[serde(default)]
701+ pub delivered: bool,
702+ /// Stopped by `task_cancel` or by the user.
703+ #[serde(default)]
704+ pub cancelled: bool,
363705 }
364706
365707 /// A message waiting for the running turn to end.
@@ -472,6 +814,13 @@ struct JcodeLink {
472814 provider: Option<String>,
473815 /// The browser panel's instructions, sent along with the next message.
474816 browser_note: Option<String>,
817+ /// Checks on the swarm agents the thread spawned, while any work.
818+ agent_poll: Option<Task<()>>,
819+ /// The app's tools are to be registered again once the turn ends:
820+ /// jcode turns down tool changes mid-turn.
821+ tools_pending: bool,
822+ /// The app's instructions went out with a message.
823+ instructions_sent: bool,
475824 }
476825
477826 pub struct Session {
@@ -496,6 +845,8 @@ pub struct Session {
496845 pub branch: Option<String>,
497846 /// Set for a thread mirrored from another device.
498847 pub remote: Option<RemoteLink>,
848+ /// Set for a subagent's thread: a task another thread delegated.
849+ pub delegation: Option<Delegation>,
499850 pub items: Vec<ChatItem>,
500851 pub status: SessionStatus,
501852 pub thinking: bool,
@@ -619,6 +970,41 @@ impl Session {
619970 session
620971 }
621972
973+ /// A subagent's thread: it runs a task `parent` delegated, in the
974+ /// parent's working folder, and starts on the task right away.
975+ pub fn new_subagent(new: NewSession, parent: &SubagentParent, title: Option<String>, task: String, cx: &mut Context<Self>) -> Self {
976+ let now = SystemTime::now();
977+ let id = format!(
978+ "{:x}-{:x}-{}",
979+ now.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(),
980+ std::process::id(),
981+ NEXT_SESSION.load(Ordering::Relaxed)
982+ );
983+ let mut session = Self::empty(id, new, now);
984+ session.cwd = parent.cwd.clone();
985+ session.branch = parent.branch.clone();
986+ session.delegation = Some(Delegation { parent: parent.id.clone(), delivered: false, cancelled: false });
987+ if let Some(title) = title.filter(|title| !title.trim().is_empty()) {
988+ session.title = title.trim().to_string().into();
989+ session.title_custom = true;
990+ }
991+ session.send_message(task, Vec::new(), cx);
992+ session
993+ }
994+
995+ /// A subagent's thread, which the sidebar leaves out.
996+ pub fn is_subagent(&self) -> bool {
997+ self.delegation.is_some()
998+ }
999+
1000+ /// What the thread's agent last said: the end of its latest answer.
1001+ pub fn last_answer(&self) -> Option<String> {
1002+ self.items.iter().rev().find_map(|item| match item {
1003+ ChatItem::Assistant(answer) if !answer.text.trim().is_empty() => Some(answer.text.clone()),
1004+ _ => None,
1005+ })
1006+ }
1007+
6221008 fn empty(id: String, new: NewSession, now: SystemTime) -> Self {
6231009 let snapshot_index = std::env::temp_dir().join(format!(
6241010 "vectrum-code-{}-{}.index",
@@ -639,6 +1025,7 @@ impl Session {
6391025 pull_request: None,
6401026 branch: None,
6411027 remote: None,
1028+ delegation: None,
6421029 items: Vec::new(),
6431030 status: SessionStatus::Starting,
6441031 thinking: false,
@@ -722,6 +1109,7 @@ impl Session {
7221109 resume: resume.clone(),
7231110 browser_session: settings::get_for(&self.project, |settings| settings.agent_browser)
7241111 .then(|| browser::session_name(&self.id)),
1112+ delegating_thread: self.delegation.is_none().then(|| self.id.clone()),
7251113 };
7261114 let process = match AgentProcess::spawn(&config, tx) {
7271115 Ok(process) => process,
@@ -968,6 +1356,90 @@ impl Session {
9681356 cx.notify();
9691357 }
9701358
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+
9711443 /// Holds a message until the running turn ends; sends it now if none runs.
9721444 pub fn queue_message(&mut self, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) {
9731445 if self.remote.is_some() {
@@ -1125,8 +1597,14 @@ impl Session {
11251597 self.jcode_request("steer the turn", request);
11261598 } else {
11271599 let mut request = json!({"req": "send_message", "content": prompt, "images": images});
1128- if let Some(note) = self.jcode.browser_note.take() {
1129- request["system_reminder"] = note.into();
1600+ // jcode's system prompt can't be added to, so the app's
1601+ // instructions go along with the first message.
1602+ let instructions = (self.delegation.is_none() && !self.jcode.instructions_sent)
1603+ .then_some(orchestrator::INSTRUCTIONS.to_string());
1604+ self.jcode.instructions_sent = true;
1605+ let reminder: Vec<String> = instructions.into_iter().chain(self.jcode.browser_note.take()).collect();
1606+ if !reminder.is_empty() {
1607+ request["system_reminder"] = reminder.join("\n\n").into();
11301608 }
11311609 self.jcode_request("send the message", request);
11321610 }
@@ -2188,6 +2666,27 @@ impl Session {
21882666 }
21892667 }
21902668 }
2669+ // A subagent's running totals, as it works.
2670+ Some("task_progress") => {
2671+ let Some(tool) = self.find_tool_mut(text("tool_use_id").unwrap_or_default()) else { return };
2672+ let uses = message.pointer("/usage/tool_uses").and_then(Value::as_u64);
2673+ let tokens = message.pointer("/usage/total_tokens").and_then(Value::as_u64);
2674+ if let Some(uses) = uses {
2675+ tool.subagent_tool_count = tool.subagent_tool_count.max(uses as usize);
2676+ }
2677+ if let Some(progress) = tool.subagent.as_mut() {
2678+ progress.tokens = tokens.or(progress.tokens);
2679+ // A background subagent's own messages don't come through.
2680+ if let Some(name) = text("last_tool_name")
2681+ && progress.tool.as_ref().is_none_or(|(tool, _)| tool != name)
2682+ {
2683+ progress.tool = Some((name.to_string(), Value::Null));
2684+ }
2685+ if let Some(summary) = text("summary").filter(|summary| !summary.trim().is_empty()) {
2686+ progress.summary = Some(summary.trim().to_string());
2687+ }
2688+ }
2689+ }
21912690 Some("task_updated") => {
21922691 let backgrounded = message.pointer("/patch/is_backgrounded").and_then(Value::as_bool) == Some(true);
21932692 let tool_use_id = text("task_id")
@@ -2232,6 +2731,14 @@ impl Session {
22322731 if tool.background.is_some() {
22332732 tool.background = Some(status);
22342733 }
2734+ if let Some(progress) = tool.subagent.as_mut() {
2735+ if let Some(tokens) = message.pointer("/usage/total_tokens").and_then(Value::as_u64) {
2736+ progress.tokens = Some(tokens);
2737+ }
2738+ if finished && tool.background.is_some() {
2739+ progress.finish();
2740+ }
2741+ }
22352742 }
22362743 // Long foreground commands report finishing too; only
22372744 // background tasks wake Claude.
@@ -2353,19 +2860,21 @@ impl Session {
23532860 }
23542861 }
23552862
2863+ /// A subagent's own message: counts its tool calls and notes the latest.
23562864 fn count_subagent_tools(&mut self, parent: &str, message: &Value) {
2357- let count = message
2865+ let calls: Vec<&Value> = message
23582866 .get("content")
23592867 .and_then(Value::as_array)
2360- .map(|blocks| {
2361- blocks
2362- .iter()
2363- .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
2364- .count()
2365- })
2366- .unwrap_or(0);
2868+ .into_iter()
2869+ .flatten()
2870+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
2871+ .collect();
23672872 if let Some(tool) = self.find_tool_mut(parent) {
2368- tool.subagent_tool_count += count;
2873+ tool.subagent_tool_count += calls.len();
2874+ if let (Some(progress), Some(call)) = (tool.subagent.as_mut(), calls.last()) {
2875+ let name = call.get("name").and_then(Value::as_str).unwrap_or("Tool").to_string();
2876+ progress.tool = Some((name, brief_input(&call["input"])));
2877+ }
23692878 }
23702879 }
23712880
@@ -2384,6 +2893,12 @@ impl Session {
23842893 if let Some(tool) = self.find_tool_mut(id) {
23852894 tool.result = Some(text);
23862895 tool.state = if is_error { ToolState::Error } else { ToolState::Done };
2896+ // A background subagent's tool returns as it starts.
2897+ if (is_error || tool.background.is_none())
2898+ && let Some(progress) = tool.subagent.as_mut()
2899+ {
2900+ progress.finish();
2901+ }
23872902 if let Some(prompt) = tool.permission.as_mut()
23882903 && prompt.state == PermissionState::Pending
23892904 {
@@ -2609,13 +3124,24 @@ impl Session {
26093124 );
26103125 }
26113126 }
3127+ // Pi's `subagent` extension reports its tasks as they go.
3128+ Some("tool_execution_update") => {
3129+ let id = message.get("toolCallId").and_then(Value::as_str).unwrap_or_default();
3130+ if let Some(tool) = self.find_tool_mut(id) {
3131+ pi_subagent_results(tool, message.pointer("/partialResult/details"));
3132+ }
3133+ }
26123134 Some("tool_execution_end") => {
26133135 let id = message.get("toolCallId").and_then(Value::as_str).unwrap_or_default();
26143136 let is_error = message.get("isError").and_then(Value::as_bool).unwrap_or(false);
26153137 let text = tool_result_text(message.pointer("/result/content").unwrap_or(&Value::Null));
26163138 if let Some(tool) = self.find_tool_mut(id) {
3139+ pi_subagent_results(tool, message.pointer("/result/details"));
26173140 tool.result = Some(text);
26183141 tool.state = if is_error { ToolState::Error } else { ToolState::Done };
3142+ if let Some(progress) = tool.subagent.as_mut() {
3143+ progress.finish();
3144+ }
26193145 }
26203146 }
26213147 Some("agent_settled") => {
@@ -2847,10 +3373,33 @@ impl Session {
28473373 for (what, request) in std::mem::take(&mut self.jcode.held) {
28483374 self.jcode_request(what, request);
28493375 }
3376+ self.configure_jcode_tools();
3377+ // Swarm agents carry on in jcode while the app is away.
3378+ self.watch_swarm(cx);
3379+ }
3380+ "sessions" if request == Some(LIST_AGENTS) => self.update_swarm(&message["sessions"]),
3381+ "history" if request == Some(PEEK_AGENT) => {
3382+ let agent = text("session_id");
3383+ let said = message
3384+ .get("messages")
3385+ .and_then(Value::as_array)
3386+ .into_iter()
3387+ .flatten()
3388+ .rev()
3389+ .filter(|message| message.get("role").and_then(Value::as_str) == Some("assistant"))
3390+ .find_map(|message| last_line(message.get("content").and_then(Value::as_str).unwrap_or_default()));
3391+ if let Some(member) = self.swarm_member_mut(agent) {
3392+ member.summary = said.or(member.summary.take());
3393+ }
28503394 }
28513395 "error" => {
28523396 let error = text("message");
28533397 match request {
3398+ // Checking on swarm agents is best effort, and repeats.
3399+ Some(LIST_AGENTS | PEEK_AGENT) => {}
3400+ // Mid-turn, it waits for the turn to end. Daemons too old
3401+ // for session tools go without.
3402+ Some(CONFIGURE_TOOLS) => self.jcode.tools_pending = error.contains("busy"),
28543403 // The session is gone from jcode's store; carry on in a new one.
28553404 Some("attach") => {
28563405 self.push_notice(
@@ -2878,6 +3427,11 @@ impl Session {
28783427 self.jcode.provider = message.get("provider").and_then(Value::as_str).map(str::to_string);
28793428 }
28803429 "message_accepted" => self.begin_turn(cx),
3430+ "tool_call" => cx.emit(SessionEvent::AppTool {
3431+ call_id: text("call_id").to_string(),
3432+ tool: text("name").to_string(),
3433+ input: message.get("input").cloned().unwrap_or(Value::Null),
3434+ }),
28813435 "soft_interrupt_injected" if text("point") == "turn_start" => self.begin_turn(cx),
28823436 "text_delta" => {
28833437 self.begin_turn(cx);
@@ -2937,11 +3491,30 @@ impl Session {
29373491 };
29383492 take_partial_input(tool);
29393493 tool.state = if error.is_some() { ToolState::Error } else { ToolState::Done };
3494+ // A swarm spawn names the agent's session: watch it from here.
3495+ let spawned = (tool.name == SWARM_TOOL && error.is_none())
3496+ .then(|| output.trim().strip_prefix("Spawned new agent:").map(|id| id.trim().to_string()))
3497+ .flatten()
3498+ .filter(|id| !id.is_empty());
3499+ if let (Some(agent), Some(progress)) = (spawned, tool.subagent.as_mut()) {
3500+ let label = tool_field(&tool.input, &["label", "prompt"])
3501+ .and_then(|label| label.lines().next().map(|line| line.chars().take(200).collect()))
3502+ .unwrap_or_else(|| "Agent".into());
3503+ progress.members.push(SubagentMember {
3504+ id: agent,
3505+ label,
3506+ kind: tool_field(&tool.input, &["role"]).filter(|role| role != "agent"),
3507+ status: TaskStatus::Running.key().to_string(),
3508+ started: progress.started,
3509+ ..Default::default()
3510+ });
3511+ }
29403512 tool.result = Some(match error {
29413513 Some(error) if output.trim().is_empty() => error,
29423514 Some(error) => format!("{output}\n\n{error}"),
29433515 None => output,
29443516 });
3517+ self.watch_swarm(cx);
29453518 }
29463519 "token_usage" => {
29473520 let count = |key: &str| message.get(key).and_then(Value::as_u64).unwrap_or(0);
@@ -2987,12 +3560,74 @@ impl Session {
29873560 self.finish_open_tools("The turn ended before the tool finished.");
29883561 let duration_ms = self.turn_started.map_or(0, |start| start.elapsed().as_millis() as u64);
29893562 self.finish_turn(duration_ms, None, cx);
3563+ if std::mem::take(&mut self.jcode.tools_pending) {
3564+ self.configure_jcode_tools();
3565+ }
29903566 self.send_next_queued(cx);
29913567 }
29923568 _ => {}
29933569 }
29943570 }
29953571
3572+ /// Registers the app's tools with the thread's jcode session (a
3573+ /// subagent's thread gets none) and switches off jcode's own ways of
3574+ /// spawning agents. jcode keeps the setup in memory, so it's sent on
3575+ /// every attach.
3576+ fn configure_jcode_tools(&mut self) {
3577+ let tools = orchestrator::jcode_tools(self.delegation.is_none());
3578+ self.jcode_request(CONFIGURE_TOOLS, json!({"req": "configure_tools", "tools": tools}));
3579+ }
3580+
3581+ /// Answers a call jcode made to one of the app's tools.
3582+ pub fn answer_app_tool(&mut self, call_id: &str, outcome: Result<Value, String>) {
3583+ let (output, error) = match outcome {
3584+ Ok(value) => (serde_json::to_string_pretty(&value).unwrap_or_default(), None),
3585+ Err(error) => (String::new(), Some(error)),
3586+ };
3587+ let request = json!({"req": "tool_result", "call_id": call_id, "output": output, "error": error});
3588+ self.jcode_request("answer a tool call", request);
3589+ }
3590+
3591+ /// Ties a task the app started for this thread to the `delegate_task`
3592+ /// call that asked for it: the latest one with that task and no
3593+ /// subagent yet.
3594+ pub fn link_delegation(&mut self, args: &Value, member: SubagentMember, cx: &mut Context<Self>) {
3595+ let task = args.get("task").and_then(Value::as_str);
3596+ let unlinked = |tool: &ToolCall| {
3597+ tool.is_delegation() && tool.subagent.as_ref().is_some_and(|progress| progress.members.is_empty())
3598+ };
3599+ let ix = self
3600+ .items
3601+ .iter()
3602+ .rposition(|item| {
3603+ matches!(item, ChatItem::Tool(tool) if unlinked(tool) && tool.input.get("task").and_then(Value::as_str) == task)
3604+ })
3605+ .or_else(|| self.items.iter().rposition(|item| matches!(item, ChatItem::Tool(tool) if unlinked(tool))));
3606+ if let Some(ChatItem::Tool(tool)) = ix.and_then(|ix| self.items.get_mut(ix))
3607+ && let Some(progress) = tool.subagent.as_mut()
3608+ {
3609+ progress.members.push(member);
3610+ cx.notify();
3611+ }
3612+ }
3613+
3614+ /// Updates the subagent row of a task this thread delegated.
3615+ pub fn update_delegation(&mut self, task: &str, update: impl FnOnce(&mut SubagentMember), cx: &mut Context<Self>) {
3616+ let member = self.items.iter_mut().find_map(|item| match item {
3617+ ChatItem::Tool(tool) if tool.is_delegation() => {
3618+ tool.subagent.as_mut()?.members.iter_mut().find(|member| member.id == task)
3619+ }
3620+ _ => None,
3621+ });
3622+ if let Some(member) = member {
3623+ let before = member.clone();
3624+ update(member);
3625+ if *member != before {
3626+ cx.notify();
3627+ }
3628+ }
3629+ }
3630+
29963631 /// The chat item a streamed jcode message renders into, made on its first delta.
29973632 fn jcode_text_item(&mut self, message_id: &str, cx: &mut Context<Self>) -> usize {
29983633 if let Some(&ix) = self.jcode.texts.get(message_id) {
@@ -3015,6 +3650,130 @@ impl Session {
30153650 })
30163651 }
30173652
3653+ /// The swarm agents this thread spawned, across its swarm calls.
3654+ fn swarm_members(&self) -> impl Iterator<Item = &SubagentMember> {
3655+ self.items.iter().filter_map(|item| match item {
3656+ ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_ref(),
3657+ _ => None,
3658+ })
3659+ .flat_map(|progress| &progress.members)
3660+ }
3661+
3662+ fn swarm_member_mut(&mut self, agent: &str) -> Option<&mut SubagentMember> {
3663+ self.items
3664+ .iter_mut()
3665+ .filter_map(|item| match item {
3666+ ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_mut(),
3667+ _ => None,
3668+ })
3669+ .flat_map(|progress| progress.members.iter_mut())
3670+ .find(|member| member.id == agent)
3671+ }
3672+
3673+ /// Whether the thread has swarm agents to keep an eye on: ones that work,
3674+ /// or a spawn that hasn't named its agent yet.
3675+ fn swarm_busy(&self) -> bool {
3676+ self.swarm_members().any(|member| member.status == TaskStatus::Running.key())
3677+ || self.items.iter().any(|item| {
3678+ matches!(item, ChatItem::Tool(tool) if tool.name == SWARM_TOOL && tool.state == ToolState::Running)
3679+ })
3680+ }
3681+
3682+ /// Starts checking on the thread's swarm agents every few seconds, if
3683+ /// any work and it isn't already. jcode's API doesn't stream another
3684+ /// session's progress, so the app asks: the session list says where each
3685+ /// agent stands, and a peek at its transcript what it last said.
3686+ fn watch_swarm(&mut self, cx: &mut Context<Self>) {
3687+ if self.jcode.agent_poll.is_some() || !self.swarm_busy() {
3688+ return;
3689+ }
3690+ self.jcode.agent_poll = Some(cx.spawn(async move |this, cx| {
3691+ loop {
3692+ cx.background_executor().timer(SWARM_POLL).await;
3693+ let busy = this.update(cx, |this, _| {
3694+ let busy = this.process.is_some() && this.swarm_busy();
3695+ if busy {
3696+ this.poll_swarm();
3697+ } else if let Some(poll) = this.jcode.agent_poll.take() {
3698+ // This very loop: let it end on its own.
3699+ poll.detach();
3700+ }
3701+ busy
3702+ });
3703+ if !matches!(busy, Ok(true)) {
3704+ break;
3705+ }
3706+ }
3707+ }));
3708+ }
3709+
3710+ fn poll_swarm(&mut self) {
3711+ self.jcode_send(LIST_AGENTS, json!({"req": "list_sessions", "limit": 200}));
3712+ let working: Vec<String> = self
3713+ .swarm_members()
3714+ .filter(|member| member.status == TaskStatus::Running.key())
3715+ .map(|member| member.id.clone())
3716+ .collect();
3717+ for agent in working {
3718+ self.jcode_send(PEEK_AGENT, json!({"req": "peek_session", "session_id": agent, "limit": 4}));
3719+ }
3720+ }
3721+
3722+ /// Takes in where the thread's swarm agents stand, from jcode's session
3723+ /// list. Agents another agent of the thread spawned list too, under the
3724+ /// latest swarm call.
3725+ fn update_swarm(&mut self, sessions: &Value) {
3726+ let Some(own) = self.jcode.session.clone() else { return };
3727+ let Some(sessions) = sessions.as_array() else { return };
3728+ let ours: HashSet<String> = self.swarm_members().map(|member| member.id.clone()).collect();
3729+ let mut family: HashSet<String> = ours.iter().cloned().chain([own.clone()]).collect();
3730+ // Spawned by this session, or by one of its agents.
3731+ loop {
3732+ let before = family.len();
3733+ for session in sessions {
3734+ let parent = session.get("parent_session_id").and_then(Value::as_str).unwrap_or_default();
3735+ if let Some(id) = session.get("session_id").and_then(Value::as_str)
3736+ && family.contains(parent)
3737+ {
3738+ family.insert(id.to_string());
3739+ }
3740+ }
3741+ if family.len() == before {
3742+ break;
3743+ }
3744+ }
3745+ for session in sessions {
3746+ let Some(id) = session.get("session_id").and_then(Value::as_str) else { continue };
3747+ if id == own || !family.contains(id) {
3748+ continue;
3749+ }
3750+ let text = |key: &str| session.get(key).and_then(Value::as_str).filter(|text| !text.trim().is_empty());
3751+ let status = jcode_agent_status(text("swarm_status"), text("status"));
3752+ if self.swarm_member_mut(id).is_none() {
3753+ // An agent the app didn't see spawned: file it under the latest swarm call.
3754+ let Some(progress) = self.items.iter_mut().rev().find_map(|item| match item {
3755+ ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_mut(),
3756+ _ => None,
3757+ }) else {
3758+ return;
3759+ };
3760+ progress.members.push(SubagentMember {
3761+ id: id.to_string(),
3762+ started: to_millis(SystemTime::now()),
3763+ ..Default::default()
3764+ });
3765+ }
3766+ let Some(member) = self.swarm_member_mut(id) else { continue };
3767+ if let Some(label) = text("agent_label").or(text("title")) {
3768+ member.label = label.to_string();
3769+ }
3770+ if member.label.is_empty() {
3771+ member.label = "Agent".into();
3772+ }
3773+ member.set_status(status);
3774+ }
3775+ }
3776+
30183777 // ---- helpers -----------------------------------------------------
30193778
30203779 fn push_assistant_text(&mut self, text: &str, cx: &mut Context<Self>) -> usize {
@@ -3073,6 +3832,8 @@ impl Session {
30733832 ask: None,
30743833 task_id: None,
30753834 background: None,
3835+ subagent: is_subagent_tool(name)
3836+ .then(|| SubagentProgress { started: to_millis(SystemTime::now()), ..Default::default() }),
30763837 }));
30773838 self.items.len() - 1
30783839 }
@@ -3131,6 +3892,9 @@ impl Session {
31313892 && tool.background == Some(TaskStatus::Running)
31323893 {
31333894 tool.background = Some(TaskStatus::Stopped);
3895+ if let Some(progress) = tool.subagent.as_mut() {
3896+ progress.finish();
3897+ }
31343898 }
31353899 }
31363900 }
@@ -3142,6 +3906,9 @@ impl Session {
31423906 {
31433907 tool.state = ToolState::Error;
31443908 tool.result.get_or_insert_with(|| reason.to_string());
3909+ if let Some(progress) = tool.subagent.as_mut() {
3910+ progress.finish();
3911+ }
31453912 }
31463913 }
31473914 }
@@ -3203,6 +3970,8 @@ pub struct SavedThread {
32033970 pub items: Vec<SavedItem>,
32043971 /// What each finished turn changed, by its `TurnEnd` index.
32053972 pub turn_changes: Vec<(usize, TurnChanges)>,
3973+ #[serde(default, skip_serializing_if = "Option::is_none")]
3974+ pub delegation: Option<Delegation>,
32063975 }
32073976
32083977 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -3232,6 +4001,8 @@ pub enum SavedItem {
32324001 /// background.
32334002 #[serde(default, skip_serializing_if = "Option::is_none")]
32344003 background: Option<String>,
4004+ #[serde(default, skip_serializing_if = "Option::is_none")]
4005+ subagent: Option<SubagentProgress>,
32354006 },
32364007 Notice { error: bool, text: String },
32374008 TaskEvent { summary: String, status: String },
@@ -3308,6 +4079,7 @@ impl Session {
33084079 .as_ref()
33094080 .map(|ask| ask.answers.iter().map(|answer| answer.as_ref().map(ToString::to_string)).collect()),
33104081 background: tool.background.map(|status| status.key().to_string()),
4082+ subagent: tool.subagent.clone(),
33114083 },
33124084 ChatItem::Notice(kind, text) => {
33134085 SavedItem::Notice { error: *kind == NoticeKind::Error, text: text.to_string() }
@@ -3368,6 +4140,7 @@ impl Session {
33684140 working: self.is_working(),
33694141 items,
33704142 turn_changes: self.turn_changes.iter().map(|(end, changes)| (*end, changes.clone())).collect(),
4143+ delegation: self.delegation.clone(),
33714144 }
33724145 }
33734146
@@ -3412,6 +4185,7 @@ impl Session {
34124185 session.queue = saved.queue;
34134186 session.interrupted = saved.working;
34144187 session.turn_changes = saved.turn_changes.into_iter().collect();
4188+ session.delegation = saved.delegation;
34154189 for item in saved.items {
34164190 let item = session.item_from_saved(item, false, cx);
34174191 session.items.push(item);
@@ -3448,6 +4222,7 @@ impl Session {
34484222 subagent_tool_count,
34494223 answers,
34504224 background,
4225+ subagent,
34514226 } => {
34524227 let pending = live && permission.as_deref() == Some("pending");
34534228 let ask = answers.and_then(|answers| {
@@ -3458,6 +4233,8 @@ impl Session {
34584233 ask.current = ask.answers.iter().position(Option::is_none).unwrap_or(0);
34594234 Some(ask)
34604235 });
4236+ // Threads saved before subagents were tracked still list them.
4237+ let subagent = subagent.or_else(|| is_subagent_tool(&name).then(SubagentProgress::default));
34614238 let background = background.map(|status| match TaskStatus::from_cli(&status) {
34624239 // Background tasks die with the agent process.
34634240 TaskStatus::Running if !live => TaskStatus::Stopped,
@@ -3491,6 +4268,8 @@ impl Session {
34914268 ask,
34924269 task_id: None,
34934270 background,
4271+ // Threads saved before subagents were tracked still list them.
4272+ subagent,
34944273 })
34954274 }
34964275 SavedItem::Notice { error, text } => {
@@ -4030,7 +4809,66 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String
40304809
40314810 #[cfg(test)]
40324811 mod tests {
4033- use super::finished_prefix;
4812+ use super::{TaskStatus, ToolCall, ToolState, finished_prefix, jcode_agent_status, pi_subagent_results};
4813+ use serde_json::{Value, json};
4814+
4815+ fn tool(name: &str, input: Value) -> ToolCall {
4816+ ToolCall {
4817+ id: "call".into(),
4818+ name: name.into(),
4819+ input,
4820+ partial_input: String::new(),
4821+ result: None,
4822+ state: ToolState::Running,
4823+ permission: None,
4824+ expanded: false,
4825+ subagent_tool_count: 0,
4826+ detached: false,
4827+ ask: None,
4828+ task_id: None,
4829+ background: None,
4830+ subagent: super::is_subagent_tool(name).then(super::SubagentProgress::default),
4831+ }
4832+ }
4833+
4834+ #[::core::prelude::v1::test]
4835+ fn pi_parallel_subagents() {
4836+ let mut call = tool("subagent", json!({"tasks": [{"agent": "scout", "task": "Find it"}, {"agent": "fixer", "task": "Fix it"}]}));
4837+ let details = json!({"mode": "parallel", "results": [
4838+ {"agent": "scout", "task": "Find it\nthoroughly", "exitCode": -1,
4839+ "usage": {"input": 1000, "output": 200},
4840+ "messages": [{"role": "assistant", "content": [
4841+ {"type": "text", "text": "Looking around"},
4842+ {"type": "toolCall", "id": "a", "name": "read", "arguments": {"path": "src/main.rs", "content": 7}}]}]},
4843+ {"agent": "fixer", "task": "Fix it", "exitCode": 1, "stopReason": "error", "messages": []},
4844+ ]});
4845+ pi_subagent_results(&mut call, Some(&details));
4846+ let entries = call.subagents();
4847+ assert_eq!(entries.len(), 2);
4848+ assert_eq!(entries[0].label, "Find it");
4849+ assert_eq!(entries[0].kind.as_deref(), Some("scout"));
4850+ assert_eq!(entries[0].status, TaskStatus::Running);
4851+ assert_eq!(entries[0].tokens, Some(1200));
4852+ assert_eq!(entries[0].tool_uses, 1);
4853+ assert_eq!(entries[0].tool, Some(("read".into(), json!({"path": "src/main.rs"}))));
4854+ assert_eq!(entries[0].summary.as_deref(), Some("Looking around"));
4855+ assert_eq!(entries[1].status, TaskStatus::Failed);
4856+ // Once the call is over, a task still marked running was cut off.
4857+ call.state = ToolState::Done;
4858+ assert_eq!(call.subagents()[0].status, TaskStatus::Stopped);
4859+ }
4860+
4861+ #[::core::prelude::v1::test]
4862+ fn swarm_calls() {
4863+ // Only spawns start agents; talking to the swarm doesn't.
4864+ assert!(tool("swarm", json!({"action": "message", "message": "hi"})).subagents().is_empty());
4865+ let spawn = tool("swarm", json!({"action": "spawn", "label": "Refactor"}));
4866+ assert_eq!(spawn.subagents()[0].label, "Refactor");
4867+ assert_eq!(jcode_agent_status(Some("running"), None), TaskStatus::Running);
4868+ assert_eq!(jcode_agent_status(Some("ready"), Some("idle")), TaskStatus::Completed);
4869+ assert_eq!(jcode_agent_status(Some("crashed"), None), TaskStatus::Failed);
4870+ assert_eq!(jcode_agent_status(None, Some("running")), TaskStatus::Running);
4871+ }
40344872
40354873 #[::core::prelude::v1::test]
40364874 fn finished_paragraphs() {
+36−1src/ui/chat.rsView File
@@ -9,6 +9,8 @@ mod footer;
99 mod strip;
1010 mod timeline;
1111
12+ pub(crate) use timeline::{describe_call, format_duration};
13+
1214 use std::{
1315 path::PathBuf,
1416 time::{Duration, SystemTime},
@@ -263,7 +265,7 @@ impl ChatView {
263265 _ => {}
264266 }),
265267 cx.subscribe_in(&session, window, |this, session, event: &SessionEvent, window, cx| {
266- let SessionEvent::TurnChanges(end) = event;
268+ let SessionEvent::TurnChanges(end) = event else { return };
267269 let big = session.read(cx).turn_changes(*end).is_some_and(|changes| {
268270 changes.files.len() >= PROACTIVE_FILES || changes.added() + changes.removed() >= PROACTIVE_LINES
269271 });
@@ -374,6 +376,39 @@ impl ChatView {
374376 self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(enter_sends, cx));
375377 }
376378
379+ /// Shows a tool call in the conversation, unfolded: opens the finished
380+ /// turn and the run of calls it's folded into, and scrolls to it. The
381+ /// subagents panel's rows lead here.
382+ pub fn reveal_tool(&mut self, ix: usize, cx: &mut Context<Self>) {
383+ let session = self.session.read(cx);
384+ if !matches!(session.items.get(ix), Some(ChatItem::Tool(_))) {
385+ return;
386+ }
387+ let fold = settings::get(|settings| settings.fold_turns);
388+ let hidden = session
389+ .finished_turn(ix)
390+ .filter(|turn| turn.answer != Some(ix) && session.turn_expanded(turn.end) != fold)
391+ .map(|turn| turn.end);
392+ let group = timeline::group_of(&session.items, ix);
393+ if group.len() > 1 {
394+ self.expanded_groups.insert(group[0]);
395+ }
396+ self.session.update(cx, |session, cx| {
397+ if let Some(end) = hidden {
398+ session.toggle_turn(end, cx);
399+ }
400+ if let Some(ChatItem::Tool(tool)) = session.items.get_mut(ix) {
401+ tool.expanded = true;
402+ }
403+ cx.notify();
404+ });
405+ self.scroller.update(cx, |scroller, cx| {
406+ scroller.remeasure(cx);
407+ scroller.scroll_to_item(ix, cx);
408+ });
409+ cx.notify();
410+ }
411+
377412 pub fn focus_composer(&self, window: &mut Window, cx: &mut App) {
378413 self.composer.update(cx, |composer, cx| composer.focus(window, cx));
379414 }
+53−7src/ui/chat/timeline.rsView File
@@ -9,6 +9,8 @@ use std::collections::HashSet;
99 use std::path::Path;
1010 use std::time::SystemTime;
1111
12+ use serde_json::Value;
13+
1214 use gpui_kit::component::{
1315 Icon, IconName, h_flex,
1416 menu::{DropdownMenu as _, PopupMenuItem},
@@ -99,9 +101,30 @@ fn relative_path(path: &str, cwd: &Path) -> String {
99101 }
100102
101103 fn present(tool: &ToolCall, cwd: &Path) -> Presentation {
102- let field = |keys: &[&str]| tool_field(&tool.input, keys);
104+ present_call(&tool.name, &tool.input, cwd)
105+ }
106+
107+ /// A tool call's icon and label as the timeline shows them, for a call
108+ /// known only by its name and input (a subagent's).
109+ pub(crate) fn describe_call(name: &str, input: &Value, cwd: &Path) -> (Icon, String) {
110+ let presentation = present_call(name, input, cwd);
111+ (presentation.icon, presentation.label)
112+ }
113+
114+ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation {
115+ let field = |keys: &[&str]| tool_field(input, keys);
103116 let first_line = |text: String| text.lines().next().unwrap_or_default().chars().take(200).collect::<String>();
104- let name = tool.name.as_str();
117+ // The app's own subagent tools, under whatever prefix the agent gives them.
118+ if let Some(tool) = crate::orchestrator::own_tool(name) {
119+ use crate::orchestrator::{CAPABILITIES, DELEGATE, TASK_CANCEL};
120+ let label = match tool {
121+ DELEGATE => field(&["title"]).or_else(|| field(&["task"])).map(first_line).unwrap_or_else(|| "Subagent".into()),
122+ TASK_CANCEL => "Stopped a subagent".into(),
123+ CAPABILITIES => "Looked up agents and models".into(),
124+ _ => "Checked on a subagent".into(),
125+ };
126+ return Presentation { icon: Icon::new(IconName::Bot), label, action: Action::Other };
127+ }
105128 let (action, label) = match name.to_ascii_lowercase().as_str() {
106129 "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())),
107130 "bashoutput" | "killshell" => (Action::Command, name.to_string()),
@@ -121,6 +144,27 @@ fn present(tool: &ToolCall, cwd: &Path) -> Presentation {
121144 "websearch" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())),
122145 "webfetch" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())),
123146 "task" | "agent" => (Action::Other, field(&["description"]).unwrap_or_else(|| "Subagent".into())),
147+ // Pi's `subagent` extension (one task, parallel tasks or a chain),
148+ // and jcode's old subagent tool.
149+ "subagent" => {
150+ let several = input.get("tasks").or_else(|| input.get("chain")).and_then(Value::as_array).map(Vec::len);
151+ let label = match several {
152+ Some(count) => format!("{count} subagents"),
153+ None => field(&["description", "task"]).map(first_line).unwrap_or_else(|| "Subagent".into()),
154+ };
155+ (Action::Other, label)
156+ }
157+ // jcode's swarm: spawning an agent, or talking to the swarm.
158+ "swarm" => {
159+ let action = field(&["action"]).unwrap_or_default();
160+ let label = match field(&["label", "prompt"]).map(first_line) {
161+ Some(label) if action == "spawn" => format!("Spawn agent: {label}"),
162+ _ if action == "spawn" => "Spawn agent".into(),
163+ _ if action.is_empty() => "Swarm".into(),
164+ _ => format!("Swarm: {}", action.replace('_', " ")),
165+ };
166+ (Action::Other, label)
167+ }
124168 "monitor" => (Action::Other, field(&["description", "command"]).map(first_line).unwrap_or_else(|| "Monitor".into())),
125169 _ if name.starts_with("mcp__") => {
126170 let mut parts = name.trim_start_matches("mcp__").splitn(2, "__");
@@ -129,12 +173,12 @@ fn present(tool: &ToolCall, cwd: &Path) -> Presentation {
129173 (Action::Other, format!("{server}: {tool_name}"))
130174 }
131175 _ => {
132- let summary = crate::session::tool_summary(name, &tool.input, cwd);
176+ let summary = crate::session::tool_summary(name, input, cwd);
133177 (Action::Other, if summary.is_empty() { name.to_string() } else { format!("{name} {summary}") })
134178 }
135179 };
136180 let icon = match name.to_ascii_lowercase().as_str() {
137- "task" | "agent" => Icon::new(IconName::Bot),
181+ "task" | "agent" | "subagent" | "swarm" => Icon::new(IconName::Bot),
138182 "monitor" => Icon::new(IconName::Eye),
139183 _ if name.starts_with("mcp__") => Icon::new(Lucide::Hammer),
140184 _ => action.icon(),
@@ -188,7 +232,7 @@ fn group_summary(actions: &[Action]) -> String {
188232 }
189233
190234 /// `4.2s`, `42s`, `2m 14s`, `1h 3m 20s`, as T3 Code writes durations.
191- pub(super) fn format_duration(ms: u64) -> String {
235+ pub(crate) fn format_duration(ms: u64) -> String {
192236 if ms < 1_000 {
193237 return format!("{}ms", ms.max(1));
194238 }
@@ -220,7 +264,7 @@ fn is_hidden(item: &ChatItem) -> bool {
220264 }
221265
222266 /// The run of consecutive tool calls `ix` belongs to: its work rows' indices.
223- fn group_of(items: &[ChatItem], ix: usize) -> Vec<usize> {
267+ pub(super) fn group_of(items: &[ChatItem], ix: usize) -> Vec<usize> {
224268 let mut start = ix;
225269 while start > 0 && (is_work(&items[start - 1]) || is_hidden(&items[start - 1])) {
226270 start -= 1;
@@ -340,6 +384,8 @@ pub(super) fn render_item(
340384 .filter(|turn| turn.first == ix)
341385 .map(|turn| render_turn_fold(session, scroller, turn, expanded, p));
342386 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,
343389 (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(),
344390 (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true,
345391 (_, ChatItem::Tool(ToolCall { ask: Some(_), .. }) | ChatItem::TaskEvent { .. }) => true,
@@ -350,7 +396,7 @@ pub(super) fn render_item(
350396 let last_user = state.items.iter().rposition(|item| matches!(item, ChatItem::User { .. }));
351397 let working = (state.is_working() && Some(ix) == last_user && !state.preparing && !state.needs_attention())
352398 .then(|| render_working(state, p));
353- if !visible && summary.is_none() {
399+ if !visible && summary.is_none() && working.is_none() {
354400 return div().into_any_element();
355401 }
356402
+2−1src/ui/settings/keybindings.rsView File
@@ -27,6 +27,7 @@ const COMMANDS: &[Command] = &[
2727 Command { id: "sidebar.toggle", label: "Sidebar: Toggle", actions: &["vectrum_code::ToggleSidebar"] },
2828 Command { id: "rightPanel.toggle", label: "Right Panel: Toggle", actions: &["vectrum_code::ToggleRightPanel"] },
2929 Command { id: "threadPanel.toggle", label: "Thread Panel: Toggle", actions: &["vectrum_code::ToggleProjectPanel"] },
30+ Command { id: "subagentsPanel.toggle", label: "Subagents Panel: Toggle", actions: &["vectrum_code::ToggleSubagents"] },
3031 Command {
3132 id: "commandPalette.toggle",
3233 label: "Command Palette: Toggle",
@@ -99,7 +100,7 @@ const COMPOSER_COMMANDS: &[(&str, &str)] = &[
99100 /// T3 Code's groups, in page order; a command joins the first group
100101 /// listing its id's prefix.
101102 const GROUPS: &[(&str, &[&str])] = &[
102- ("Navigation", &["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"]),
103+ ("Navigation", &["sidebar", "rightPanel", "threadPanel", "subagentsPanel", "commandPalette", "filePicker", "projectSearch", "editor"]),
103104 ("Threads", &["thread", "chat", "pullRequest"]),
104105 ("Composer", &["composer", "modelPicker"]),
105106 ("Terminal", &["terminal"]),
+41−21src/ui/workspace.rsView File
@@ -42,15 +42,18 @@ use crate::{
4242 CopyThreadReference, CycleAppearance, FocusSearch, NewThread, NewThreadLocal, NewThreadWithoutProject, NextThread,
4343 CloseTerminal, NewTerminal, OpenInEditor, OpenProject, OpenSettings, OpenUsage, PinThread, PreviousThread, SelectTheme,
4444 SettleThread, SplitTerminal, SplitTerminalVertical, ToggleBrowser, ToggleCommandPalette, ToggleProjectPanel,
45- ToggleRightPanel, ToggleSidebar, ToggleTerminal, UndoThreadAction,
45+ ToggleRightPanel, ToggleSidebar, ToggleSubagents, ToggleTerminal, UndoThreadAction,
4646 };
4747
4848 mod actions;
49+ mod delegation;
4950 mod git_actions;
5051 mod header;
5152 mod palette;
5253 mod remote;
5354 mod sidebar;
55+ mod subagents;
56+ mod surfaces;
5457 mod terminal_drawer;
5558
5659 use actions::UndoEntry;
@@ -217,8 +220,11 @@ pub struct Workspace {
217220 search: Entity<InputState>,
218221 sidebar_open: bool,
219222 panel_open: bool,
220- /// The browser takes the project panel's place while open.
221- browser_open: bool,
223+ /// What the right panel shows, in the project panel's place: a surface
224+ /// (subagents, browser, terminal) or the list to pick one from.
225+ right_panel: Option<surfaces::Surface>,
226+ /// The surface picker's focus, for its keys.
227+ surface_focus: FocusHandle,
222228 settled_open: bool,
223229 snoozed_open: bool,
224230 working_open: bool,
@@ -288,6 +294,8 @@ pub struct Workspace {
288294 last_pr_check: Option<Instant>,
289295 last_repaint: Instant,
290296 remote: RemoteState,
297+ /// Tasks agents delegated to the app's subagents.
298+ delegations: delegation::Delegations,
291299 focus_handle: FocusHandle,
292300 observers: HashMap<EntityId, Vec<Subscription>>,
293301 _subscriptions: Vec<Subscription>,
@@ -370,7 +378,8 @@ impl Workspace {
370378 search,
371379 sidebar_open: true,
372380 panel_open: true,
373- browser_open: false,
381+ right_panel: None,
382+ surface_focus: cx.focus_handle(),
374383 settled_open: false,
375384 snoozed_open: false,
376385 working_open: false,
@@ -417,12 +426,14 @@ impl Workspace {
417426 last_pr_check: None,
418427 last_repaint: now,
419428 remote: RemoteState::default(),
429+ delegations: Default::default(),
420430 focus_handle: cx.focus_handle(),
421431 observers: HashMap::new(),
422432 _subscriptions: subscriptions,
423433 _ticker: ticker,
424434 _remote_flush: remote_flush,
425435 };
436+ workspace.listen_for_tools(window, cx);
426437 let active = workspace.restore(window, cx);
427438 match (initial_project, active) {
428439 (Some(path), _) => workspace.add_project(path, window, cx),
@@ -595,7 +606,7 @@ impl Workspace {
595606 }
596607 // Working threads show how long they've worked.
597608 let working = self.projects.iter().flat_map(|project| &project.sessions).any(|session| session.read(cx).is_working());
598- if working && self.sidebar_open {
609+ if working && (self.sidebar_open || self.surface_open(surfaces::Surface::Subagents)) {
599610 cx.notify();
600611 }
601612 // Repaint about once a minute so thread ages stay current.
@@ -1003,6 +1014,7 @@ impl Workspace {
10031014 session.entity_id(),
10041015 vec![
10051016 cx.observe_in(&session, window, Self::on_session_changed),
1017+ cx.subscribe_in(&session, window, Self::on_session_event),
10061018 cx.subscribe_in(&chat, window, Self::on_chat_event),
10071019 ],
10081020 );
@@ -1038,6 +1050,7 @@ impl Workspace {
10381050 cx.notify();
10391051 self.dirty.insert(session.entity_id());
10401052 self.remote_thread_changed(&session, cx);
1053+ self.track_delegation(&session, cx);
10411054 let state = session.read(cx);
10421055 let now = Activity { working: state.is_working(), attention: state.needs_attention() };
10431056 let before = self.activity.insert(session.entity_id(), now).unwrap_or_default();
@@ -1583,6 +1596,7 @@ impl Workspace {
15831596 .iter()
15841597 .enumerate()
15851598 .flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone())))
1599+ .filter(|(_, session)| !session.read(cx).is_subagent())
15861600 .filter(|(ix, session)| {
15871601 query.is_empty()
15881602 || session.read(cx).title.to_lowercase().contains(&query)
@@ -1711,7 +1725,7 @@ impl Workspace {
17111725 .projects
17121726 .iter()
17131727 .flat_map(|project| project.sessions.iter().map(move |session| (project, session)))
1714- .filter(|(_, session)| session.read(cx).archived)
1728+ .filter(|(_, session)| session.read(cx).archived && !session.read(cx).is_subagent())
17151729 .collect();
17161730 threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
17171731 threads
@@ -1749,9 +1763,9 @@ impl Workspace {
17491763 }
17501764
17511765 fn toggle_project_panel(&mut self, cx: &mut Context<Self>) {
1752- // With the browser in its place, the project panel comes back.
1753- if self.browser_open {
1754- self.browser_open = false;
1766+ // With a surface in its place, the project panel comes back.
1767+ if self.right_panel.is_some() {
1768+ self.right_panel = None;
17551769 self.panel_open = true;
17561770 } else {
17571771 self.panel_open = !self.panel_open;
@@ -1762,7 +1776,7 @@ impl Workspace {
17621776 /// The open thread's browser panel while it shows, made on first use.
17631777 /// The others let go of their streams.
17641778 fn shown_browser(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Entity<BrowserPanel>> {
1765- let session = self.active.clone().filter(|_| self.browser_open && !self.settings_open);
1779+ let session = self.active.clone().filter(|_| self.surface_open(surfaces::Surface::Browser) && !self.settings_open);
17661780 let panel = session.map(|session| {
17671781 let id = session.entity_id();
17681782 if let Some(panel) = self.browsers.get(&id) {
@@ -1772,7 +1786,7 @@ impl Workspace {
17721786 let panel = cx.new(|cx| BrowserPanel::new(&thread_id, window, cx));
17731787 let subscription = cx.subscribe(&panel, |this, _, event: &BrowserPanelEvent, cx| match event {
17741788 BrowserPanelEvent::Close => {
1775- this.browser_open = false;
1789+ this.right_panel = None;
17761790 cx.notify();
17771791 }
17781792 });
@@ -2069,12 +2083,16 @@ impl Render for Workspace {
20692083 cx.set_global(directory);
20702084 let browser = self.shown_browser(window, cx);
20712085 let p = metal::palette(cx);
2086+ let subagents_visible = self.surface_open(surfaces::Surface::Subagents);
2087+ let picker_visible = self.surface_open(surfaces::Surface::Picker) && !self.settings_open;
2088+ let docked_terminal =
2089+ if self.surface_open(surfaces::Surface::Terminal) { self.render_terminal_drawer(true, window, cx) } else { None };
20722090 let panel_visible = self.panel_open
2073- && browser.is_none()
2091+ && self.right_panel.is_none()
20742092 && self.active_project().is_some()
20752093 && window.viewport_size().width >= px(PANEL_MIN_WINDOW);
20762094 // The browser gives way before the conversation gets too narrow.
2077- let chat_min = if browser.is_some() { px(360.) } else { px(0.) };
2095+ let chat_min = if browser.is_some() || docked_terminal.is_some() { px(360.) } else { px(0.) };
20782096
20792097 h_flex()
20802098 .key_context("Workspace")
@@ -2119,10 +2137,7 @@ impl Render for Workspace {
21192137 }
21202138 }))
21212139 .on_action(cx.listener(|this, _: &OpenUsage, window, cx| this.open_settings_page("Usage", window, cx)))
2122- .on_action(cx.listener(|this, _: &ToggleRightPanel, _, cx| {
2123- this.browser_open = !this.browser_open;
2124- cx.notify();
2125- }))
2140+ .on_action(cx.listener(|this, _: &ToggleRightPanel, window, cx| this.toggle_right_panel(window, cx)))
21262141 .on_action(cx.listener(|this, _: &SelectTheme, window, cx| {
21272142 this.open_palette(palette::PaletteView::Themes, window, cx)
21282143 }))
@@ -2167,9 +2182,11 @@ impl Render for Workspace {
21672182 cx.notify();
21682183 }))
21692184 .on_action(cx.listener(|this, _: &ToggleProjectPanel, _, cx| this.toggle_project_panel(cx)))
2170- .on_action(cx.listener(|this, _: &ToggleBrowser, _, cx| {
2171- this.browser_open = !this.browser_open;
2172- cx.notify();
2185+ .on_action(cx.listener(|this, _: &ToggleSubagents, window, cx| {
2186+ this.toggle_surface(surfaces::Surface::Subagents, window, cx)
2187+ }))
2188+ .on_action(cx.listener(|this, _: &ToggleBrowser, window, cx| {
2189+ this.toggle_surface(surfaces::Surface::Browser, window, cx)
21732190 }))
21742191 .on_action(cx.listener(|this, _: &FocusSearch, window, cx| {
21752192 this.settings_open = false;
@@ -2214,9 +2231,12 @@ impl Render for Workspace {
22142231 Some(chat) => this.child(chat),
22152232 None => this.child(self.render_empty_sheet(cx)),
22162233 }))
2217- .children(self.render_terminal_drawer(window, cx)),
2234+ .children(self.render_terminal_drawer(false, window, cx)),
22182235 )
22192236 .when(panel_visible, |this| this.child(self.render_panel(cx)))
2237+ .when(subagents_visible, |this| this.child(self.render_subagents_panel(cx)))
2238+ .when(picker_visible, |this| this.child(self.render_surface_picker(cx)))
2239+ .children(docked_terminal)
22202240 .when_some(browser, |this, browser| this.child(browser)),
22212241 ),
22222242 ),
+467−0src/ui/workspace/delegation.rsaddedView File
@@ -0,0 +1,467 @@
1+ //! Tasks agents delegate to the app (see [`crate::orchestrator`]). Each runs
2+ //! in a subagent thread: a thread of the parent's project, in its folder,
3+ //! that the sidebar leaves out and the subagents panel opens. The parent's
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's agent
6+ //! as a message the transcript doesn't show, which wakes it.
7+
8+ use std::time::{Duration, SystemTime};
9+
10+ use gpui_kit::*;
11+ use serde_json::{Value, json};
12+
13+ use super::Workspace;
14+ use crate::models::{Catalog, ProbeStatus};
15+ use crate::orchestrator::{self, CAPABILITIES, DELEGATE, TASK_CANCEL, TASK_STATUS, ToolRequest};
16+ use crate::session::{
17+ ChatItem, NewSession, Reasoning, Session, SessionEvent, SessionStatus, SubagentMember, SubagentParent, TaskStatus,
18+ brief_input, to_millis,
19+ };
20+ use crate::settings;
21+
22+ /// How long `mode: "wait"` waits when the call doesn't say.
23+ const DEFAULT_WAIT: Duration = Duration::from_secs(10 * 60);
24+ /// The most of a subagent's answer that goes back in a message.
25+ const RESULT_LIMIT: usize = 40_000;
26+
27+ /// Whoever is waiting for a tool call's answer.
28+ pub(super) enum Answer {
29+ /// Claude or Pi, through the MCP server.
30+ Mcp(smol::channel::Sender<Result<Value, String>>),
31+ /// jcode, whose session tool call its thread answers.
32+ Jcode { session: WeakEntity<Session>, call_id: String },
33+ }
34+
35+ impl Answer {
36+ fn send(self, outcome: Result<Value, String>, cx: &mut App) {
37+ match self {
38+ Answer::Mcp(reply) => {
39+ let _ = reply.try_send(outcome);
40+ }
41+ Answer::Jcode { session, call_id } => {
42+ let _ = session.update(cx, |session, _| session.answer_app_tool(&call_id, outcome));
43+ }
44+ }
45+ }
46+ }
47+
48+ /// A `delegate_task` call waiting for its task to finish.
49+ struct Waiter {
50+ id: u64,
51+ task: String,
52+ answer: Answer,
53+ _timeout: Task<()>,
54+ }
55+
56+ #[derive(Default)]
57+ pub(super) struct Delegations {
58+ waiting: Vec<Waiter>,
59+ next_waiter: u64,
60+ _requests: Option<Task<()>>,
61+ }
62+
63+ /// Where a task stands, as the tools report it.
64+ #[derive(Clone, Copy, PartialEq, Eq)]
65+ enum TaskState {
66+ Running,
67+ /// Its agent waits for the user: a permission prompt or a question.
68+ WaitingForUser,
69+ Completed,
70+ Failed,
71+ Cancelled,
72+ /// The app stopped while it worked.
73+ Interrupted,
74+ }
75+
76+ impl TaskState {
77+ fn of(session: &Session) -> Self {
78+ let cancelled = session.delegation.as_ref().is_some_and(|delegation| delegation.cancelled);
79+ let finished = session.items.iter().any(|item| matches!(item, ChatItem::TurnEnd { .. }));
80+ if cancelled && !session.is_working() {
81+ TaskState::Cancelled
82+ } else if session.needs_attention() {
83+ TaskState::WaitingForUser
84+ } else if session.is_working() || session.preparing {
85+ TaskState::Running
86+ } else if session.status == SessionStatus::Exited || (finished && session.ended_with_error()) {
87+ TaskState::Failed
88+ } else if finished {
89+ TaskState::Completed
90+ } else if session.interrupted {
91+ TaskState::Interrupted
92+ } else {
93+ // Starting up.
94+ TaskState::Running
95+ }
96+ }
97+
98+ fn key(self) -> &'static str {
99+ match self {
100+ TaskState::Running => "running",
101+ TaskState::WaitingForUser => "waiting_for_user",
102+ TaskState::Completed => "completed",
103+ TaskState::Failed => "failed",
104+ TaskState::Cancelled => "cancelled",
105+ TaskState::Interrupted => "interrupted",
106+ }
107+ }
108+
109+ fn is_terminal(self) -> bool {
110+ !matches!(self, TaskState::Running | TaskState::WaitingForUser)
111+ }
112+
113+ fn panel_status(self) -> TaskStatus {
114+ match self {
115+ TaskState::Running | TaskState::WaitingForUser => TaskStatus::Running,
116+ TaskState::Completed => TaskStatus::Completed,
117+ TaskState::Failed => TaskStatus::Failed,
118+ TaskState::Cancelled | TaskState::Interrupted => TaskStatus::Stopped,
119+ }
120+ }
121+ }
122+
123+ /// A task as `delegate_task` and `task_status` report it.
124+ fn report(session: &Session, wait_timed_out: bool) -> Value {
125+ let state = TaskState::of(session);
126+ let summary = state.is_terminal().then(|| session.last_answer()).flatten().map(|answer| truncate(&answer));
127+ json!({
128+ "taskId": session.id,
129+ "childThreadId": session.id,
130+ "title": session.title.to_string(),
131+ "status": state.key(),
132+ "providerInstanceId": session.instance,
133+ "model": session.active_model.clone().or_else(|| session.model.clone()),
134+ "summary": summary,
135+ "waitTimedOut": wait_timed_out,
136+ })
137+ }
138+
139+ fn truncate(text: &str) -> String {
140+ if text.len() <= RESULT_LIMIT {
141+ return text.to_string();
142+ }
143+ let mut end = RESULT_LIMIT;
144+ while !text.is_char_boundary(end) {
145+ end -= 1;
146+ }
147+ format!("{}\n\n[Cut short; task_status has the same text. The subagent's thread has the rest.]", &text[..end])
148+ }
149+
150+ /// The last line of what an agent said.
151+ fn last_line(text: &str) -> Option<String> {
152+ let line = text.lines().rev().map(str::trim).find(|line| !line.is_empty())?;
153+ Some(line.chars().take(200).collect())
154+ }
155+
156+ impl Workspace {
157+ /// Starts answering the calls agents make to the app's tools.
158+ pub(super) fn listen_for_tools(&mut self, window: &mut Window, cx: &mut Context<Self>) {
159+ let requests = orchestrator::requests();
160+ self.delegations._requests = Some(cx.spawn_in(window, async move |this, cx| {
161+ while let Ok(request) = requests.recv().await {
162+ let ToolRequest { thread, tool, args, reply } = request;
163+ let answered = this.update_in(cx, |this, window, cx| {
164+ this.answer_tool(&thread, &tool, args, Answer::Mcp(reply), window, cx)
165+ });
166+ if answered.is_err() {
167+ break;
168+ }
169+ }
170+ }));
171+ }
172+
173+ /// jcode asks for the app's tools through its thread.
174+ pub(super) fn on_session_event(
175+ &mut self,
176+ session: &Entity<Session>,
177+ event: &SessionEvent,
178+ window: &mut Window,
179+ cx: &mut Context<Self>,
180+ ) {
181+ if let SessionEvent::AppTool { call_id, tool, input } = event {
182+ let thread = session.read(cx).id.clone();
183+ let answer = Answer::Jcode { session: session.downgrade(), call_id: call_id.clone() };
184+ self.answer_tool(&thread, tool, input.clone(), answer, window, cx);
185+ }
186+ }
187+
188+ /// A thread on this device, by id.
189+ fn thread_by_id(&self, id: &str, cx: &App) -> Option<Entity<Session>> {
190+ self.local_thread(id, cx).map(|(_, session)| session)
191+ }
192+
193+ fn answer_tool(
194+ &mut self,
195+ thread: &str,
196+ tool: &str,
197+ args: Value,
198+ answer: Answer,
199+ window: &mut Window,
200+ cx: &mut Context<Self>,
201+ ) {
202+ let Some(parent) = self.thread_by_id(thread, cx) else {
203+ answer.send(Err("The thread calling this is gone from Vectrum Code.".into()), cx);
204+ return;
205+ };
206+ if parent.read(cx).is_subagent() {
207+ answer.send(Err("A subagent can't delegate tasks of its own; do the work yourself.".into()), cx);
208+ return;
209+ }
210+ let outcome = match orchestrator::own_tool(tool) {
211+ Some(CAPABILITIES) => Ok(capabilities(parent.read(cx), cx)),
212+ Some(DELEGATE) => match self.delegate(&parent, &args, window, cx) {
213+ Ok(child) if args.get("mode").and_then(Value::as_str) == Some("wait") => {
214+ self.wait_for(&parent, child, &args, answer, cx);
215+ return;
216+ }
217+ Ok(child) => Ok(report(child.read(cx), false)),
218+ Err(err) => Err(err),
219+ },
220+ Some(TASK_STATUS) => self.task_of(&parent, &args, cx).map(|child| {
221+ // Reading a finished task's result counts as hearing of it.
222+ if TaskState::of(child.read(cx)).is_terminal() {
223+ self.mark_delivered(&child, cx);
224+ }
225+ report(child.read(cx), false)
226+ }),
227+ Some(TASK_CANCEL) => self.task_of(&parent, &args, cx).map(|child| {
228+ if !TaskState::of(child.read(cx)).is_terminal() {
229+ // The parent knows: no message about it.
230+ child.update(cx, |child, cx| {
231+ if let Some(delegation) = child.delegation.as_mut() {
232+ delegation.cancelled = true;
233+ delegation.delivered = true;
234+ }
235+ child.interrupt(cx);
236+ });
237+ self.dirty.insert(child.entity_id());
238+ }
239+ let mut report = report(child.read(cx), false);
240+ if !TaskState::of(child.read(cx)).is_terminal() {
241+ report["status"] = "cancel_requested".into();
242+ }
243+ report
244+ }),
245+ _ => Err(format!("Vectrum Code has no tool named {tool}.")),
246+ };
247+ answer.send(outcome, cx);
248+ }
249+
250+ /// Starts a subagent thread on a task `parent` delegated.
251+ fn delegate(
252+ &mut self,
253+ parent: &Entity<Session>,
254+ args: &Value,
255+ window: &mut Window,
256+ cx: &mut Context<Self>,
257+ ) -> Result<Entity<Session>, String> {
258+ fn text(value: Option<&Value>) -> Option<&str> {
259+ value.and_then(Value::as_str).map(str::trim).filter(|text| !text.is_empty())
260+ }
261+ let task = text(args.get("task")).ok_or("delegate_task needs a task.")?.to_string();
262+ let title = text(args.get("title")).map(str::to_string);
263+ let target = args.get("target");
264+ let state = parent.read(cx);
265+ let instance_id = text(target.and_then(|target| target.get("providerInstanceId"))).unwrap_or(&state.instance);
266+ let instance = settings::instances()
267+ .into_iter()
268+ .find(|instance| instance.id == instance_id)
269+ .ok_or_else(|| format!("There's no provider instance \"{instance_id}\"; orchestrator_capabilities lists them."))?;
270+ if !instance.enabled {
271+ return Err(format!("The provider instance \"{instance_id}\" is turned off in Vectrum Code's settings."));
272+ }
273+ // What the parent runs carries over to a child on the same agent.
274+ let same = instance.id == state.instance;
275+ let model = text(target.and_then(|target| target.get("model")))
276+ .map(str::to_string)
277+ .or_else(|| state.model.clone().filter(|_| same));
278+ let new = NewSession {
279+ instance: instance.id.clone(),
280+ provider: instance.kind,
281+ project: state.project.clone(),
282+ model: model.clone(),
283+ permission_mode: if instance.kind == state.provider { state.permission_mode } else { Default::default() },
284+ reasoning: if same { state.reasoning } else { Reasoning::Default },
285+ fast_mode: same && state.fast_mode,
286+ worktree: None,
287+ };
288+ let origin = SubagentParent { id: state.id.clone(), cwd: state.cwd.clone(), branch: state.branch.clone() };
289+ // A different agent or model is worth naming in the panel.
290+ let kind = (!same || model != state.model).then(|| {
291+ let agent = if instance.name.trim().is_empty() { instance.kind.label().to_string() } else { instance.name.clone() };
292+ match &model {
293+ Some(model) => format!("{agent} · {model}"),
294+ None => agent,
295+ }
296+ });
297+ let project_ix = self.project_of(parent).ok_or("The thread's project is gone.")?;
298+ let label = title.clone().unwrap_or_else(|| {
299+ task.lines().map(str::trim).find(|line| !line.is_empty()).unwrap_or_default().chars().take(200).collect()
300+ });
301+ let child = cx.new(|cx| Session::new_subagent(new, &origin, title, task, cx));
302+ self.attach(project_ix, child.clone(), window, cx);
303+ let member = SubagentMember {
304+ id: child.read(cx).id.clone(),
305+ label,
306+ kind,
307+ status: "running".into(),
308+ started: to_millis(SystemTime::now()),
309+ ..Default::default()
310+ };
311+ parent.update(cx, |parent, cx| parent.link_delegation(args, member, cx));
312+ self.dirty.insert(child.entity_id());
313+ cx.notify();
314+ Ok(child)
315+ }
316+
317+ /// Holds a `mode: "wait"` call until its task finishes, or until it's
318+ /// waited as long as it may.
319+ fn wait_for(&mut self, parent: &Entity<Session>, child: Entity<Session>, args: &Value, answer: Answer, cx: &mut Context<Self>) {
320+ let asked = args.get("timeoutMs").and_then(Value::as_f64).filter(|ms| *ms > 0.).map(|ms| Duration::from_millis(ms as u64));
321+ let timeout = asked.unwrap_or(DEFAULT_WAIT).min(orchestrator::wait_limit(parent.read(cx).provider));
322+ self.delegations.next_waiter += 1;
323+ let id = self.delegations.next_waiter;
324+ let task_id = child.read(cx).id.clone();
325+ let timer = cx.spawn(async move |this, cx| {
326+ cx.background_executor().timer(timeout).await;
327+ let _ = this.update(cx, |this, cx| {
328+ let Some(ix) = this.delegations.waiting.iter().position(|waiter| waiter.id == id) else { return };
329+ let waiter = this.delegations.waiting.remove(ix);
330+ let report = report(child.read(cx), true);
331+ waiter.answer.send(Ok(report), cx);
332+ });
333+ });
334+ let task = task_id;
335+ self.delegations.waiting.push(Waiter { id, task, answer, _timeout: timer });
336+ }
337+
338+ /// A task `parent` delegated, by the `taskId` in a call.
339+ fn task_of(&self, parent: &Entity<Session>, args: &Value, cx: &App) -> Result<Entity<Session>, String> {
340+ let task = args.get("taskId").and_then(Value::as_str).ok_or("Name the task with taskId.")?;
341+ let parent_id = &parent.read(cx).id;
342+ self.thread_by_id(task, cx)
343+ .filter(|child| child.read(cx).delegation.as_ref().is_some_and(|delegation| &delegation.parent == parent_id))
344+ .ok_or_else(|| format!("This thread delegated no task {task}."))
345+ }
346+
347+ fn mark_delivered(&mut self, child: &Entity<Session>, cx: &mut Context<Self>) {
348+ child.update(cx, |child, _| {
349+ if let Some(delegation) = child.delegation.as_mut() {
350+ delegation.delivered = true;
351+ }
352+ });
353+ self.dirty.insert(child.entity_id());
354+ }
355+
356+ /// Keeps a subagent's row in its parent current, and once its task is
357+ /// done, hands the result over.
358+ pub(super) fn track_delegation(&mut self, child: &Entity<Session>, cx: &mut Context<Self>) {
359+ let state = child.read(cx);
360+ let Some(delegation) = state.delegation.clone() else { return };
361+ let task_state = TaskState::of(state);
362+ let task = state.id.clone();
363+ let tools: Vec<_> = state
364+ .items
365+ .iter()
366+ .filter_map(|item| match item {
367+ ChatItem::Tool(tool) => Some(tool),
368+ _ => None,
369+ })
370+ .collect();
371+ let tool_uses = tools.len();
372+ let latest_tool = tools.last().map(|tool| (tool.name.clone(), brief_input(&tool.input)));
373+ let summary = state.last_answer().as_deref().and_then(last_line);
374+ let tokens = state.context_tokens;
375+ let Some(parent) = self.thread_by_id(&delegation.parent, cx) else { return };
376+ parent.update(cx, |parent, cx| {
377+ parent.update_delegation(
378+ &task,
379+ |member| {
380+ let status = task_state.panel_status();
381+ if member.status != status.key() {
382+ member.status = status.key().to_string();
383+ member.finished = (status != TaskStatus::Running).then(|| to_millis(SystemTime::now()));
384+ }
385+ member.tool_uses = tool_uses;
386+ member.tool = latest_tool;
387+ member.summary = summary.or(member.summary.take());
388+ member.tokens = tokens.or(member.tokens);
389+ },
390+ cx,
391+ )
392+ });
393+ if !task_state.is_terminal() || delegation.delivered {
394+ return;
395+ }
396+ self.mark_delivered(child, cx);
397+ let (waiting, rest): (Vec<Waiter>, Vec<Waiter>) =
398+ std::mem::take(&mut self.delegations.waiting).into_iter().partition(|waiter| waiter.task == task);
399+ self.delegations.waiting = rest;
400+ let result = report(child.read(cx), false);
401+ if !waiting.is_empty() {
402+ for waiter in waiting {
403+ waiter.answer.send(Ok(result.clone()), cx);
404+ }
405+ return;
406+ }
407+ // Nobody's waiting: tell the parent's agent, which wakes it.
408+ let title = child.read(cx).title.to_string();
409+ let mut message = format!(
410+ "{} {task} (\"{title}\") {}.",
411+ orchestrator::RESULT_PREFIX,
412+ match task_state {
413+ TaskState::Completed => "completed",
414+ TaskState::Failed => "failed",
415+ TaskState::Cancelled => "was stopped by the user",
416+ _ => "stopped",
417+ }
418+ );
419+ match result.get("summary").and_then(Value::as_str) {
420+ Some(summary) => message.push_str(&format!(" Its final answer:\n\n{summary}")),
421+ None => message.push_str(" It gave no answer."),
422+ }
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());
438+ }
439+ }
440+
441+ /// What `orchestrator_capabilities` reports: every agent that's on, with
442+ /// its models, and what the calling thread runs.
443+ fn capabilities(thread: &Session, cx: &App) -> Value {
444+ let instances: Vec<Value> = Catalog::global(cx)
445+ .instances
446+ .iter()
447+ .filter(|state| state.config.enabled && !matches!(state.status, ProbeStatus::Disabled))
448+ .map(|state| {
449+ let models: Vec<Value> = state
450+ .models
451+ .iter()
452+ .map(|model| json!({"id": model.id, "name": model.name, "detail": model.detail}))
453+ .collect();
454+ json!({
455+ "providerInstanceId": state.config.id,
456+ "kind": state.config.kind.key(),
457+ "name": if state.config.name.trim().is_empty() { state.config.kind.label().to_string() } else { state.config.name.clone() },
458+ "models": models,
459+ })
460+ })
461+ .collect();
462+ json!({
463+ "current": {"providerInstanceId": thread.instance, "model": thread.model, "activeModel": thread.active_model},
464+ "providerInstances": instances,
465+ "tools": [DELEGATE, TASK_STATUS, TASK_CANCEL, CAPABILITIES],
466+ })
467+ }
+36−9src/ui/workspace/header.rsView File
@@ -9,6 +9,7 @@ use gpui_kit::prelude::FluentBuilder as _;
99 use gpui_kit::*;
1010
1111 use super::Workspace;
12+ use super::surfaces::Surface;
1213 use crate::Dismiss;
1314 use crate::ui::browser::BrowserWatch;
1415 use crate::ui::icons::Lucide;
@@ -114,6 +115,28 @@ impl Workspace {
114115 .child(div().max_w(px(160.)).truncate().font_weight(FontWeight::MEDIUM).child(project.name.clone()))
115116 });
116117
118+ // A subagent's thread leads back to the thread that delegated its task.
119+ let parent = active
120+ .and_then(|session| session.delegation.as_ref())
121+ .and_then(|delegation| self.local_thread(&delegation.parent, cx))
122+ .map(|(parent_ix, parent)| {
123+ let parent_title = parent.read(cx).title.clone();
124+ let tip = SharedString::from(format!("Subagent of {parent_title}"));
125+ h_flex()
126+ .id("parent-thread")
127+ .flex_none()
128+ .gap(px(6.))
129+ .cursor_pointer()
130+ .text_size(px(14.))
131+ .text_color(p.ink3)
132+ .hover(|style| style.text_color(p.ink))
133+ .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx))
134+ .on_click(cx.listener(move |this, _, window, cx| this.select(parent_ix, parent.clone(), window, cx)))
135+ .child(div().max_w(px(200.)).truncate().child(parent_title))
136+ .child(div().text_color(p.icon).child("/"))
137+ .child(Icon::new(Lucide::Bot).size(px(14.)))
138+ });
139+
117140 let title_element = title.map(|title| -> AnyElement {
118141 if let Some(rename) = renaming {
119142 return div()
@@ -202,6 +225,7 @@ impl Workspace {
202225 .when_some(breadcrumb, |this, breadcrumb| {
203226 this.child(breadcrumb).child(div().text_size(px(14.)).text_color(p.icon).child("/"))
204227 })
228+ .when_some(parent, |this, parent| this.child(parent))
205229 .when_some(title_element, |this, title| this.child(title))
206230 .child(div().flex_1())
207231 .child(self.render_layout_controls(panel_visible, cx)),
@@ -210,11 +234,15 @@ impl Workspace {
210234 }
211235
212236 /// T3's panel toggles: thread details (our project panel), the terminal
213- /// drawer, and the right panel (the browser).
237+ /// drawer, and the right panel (the browser), plus the subagents panel.
214238 fn render_layout_controls(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement {
215239 let p = metal::palette(cx);
216- let live = !self.browser_open
240+ // While the panel shows something else, its light says subagents are
241+ // working, or the agent is using the browser.
242+ let subagents_working = !self.surface_open(Surface::Subagents) && self.working_subagents(cx) > 0;
243+ let browser_live = !self.surface_open(Surface::Browser)
217244 && self.active.as_ref().is_some_and(|session| BrowserWatch::is_live(&session.read(cx).id, cx));
245+ let live = subagents_working || browser_live;
218246 h_flex()
219247 .flex_none()
220248 .gap(px(4.))
@@ -232,7 +260,7 @@ impl Workspace {
232260 .ghost()
233261 .mini()
234262 .icon(IconName::PanelBottom)
235- .selected(self.terminal_open())
263+ .selected(self.terminal_open() || self.surface_open(Surface::Terminal))
236264 .tooltip("Toggle terminal drawer (Ctrl+J)")
237265 .on_click(cx.listener(|this, _, window, cx| this.toggle_terminal(window, cx)))
238266 .into_any_element()
@@ -252,15 +280,14 @@ impl Workspace {
252280 .ghost()
253281 .mini()
254282 .icon(IconName::PanelRight)
255- .selected(self.browser_open)
283+ .selected(self.right_panel.is_some())
256284 .tooltip("Toggle right panel (Ctrl+Alt+B)")
257- .on_click(cx.listener(|this, _, _, cx| {
258- this.browser_open = !this.browser_open;
259- cx.notify();
260- })),
285+ .on_click(cx.listener(|this, _, window, cx| this.toggle_right_panel(window, cx))),
261286 )
262287 .when(live, |this| {
263- this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("browser-live", Led::Live, false, p)))
288+ this.child(
289+ div().absolute().top(px(1.)).right(px(1.)).child(metal::led("right-panel-live", Led::Live, subagents_working, p)),
290+ )
264291 }),
265292 )
266293 }
+1−1src/ui/workspace/palette.rsView File
@@ -441,7 +441,7 @@ impl Workspace {
441441 .flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone())))
442442 .filter(|(_, session)| {
443443 let state = session.read(cx);
444- !state.archived && !state.is_blank()
444+ !state.archived && !state.is_blank() && !state.is_subagent()
445445 })
446446 .collect();
447447 threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
+1−1src/ui/workspace/remote.rsView File
@@ -366,7 +366,7 @@ impl Workspace {
366366 }
367367
368368 /// The local thread with a given id.
369- fn local_thread(&self, id: &str, cx: &App) -> Option<(usize, Entity<Session>)> {
369+ pub(super) fn local_thread(&self, id: &str, cx: &App) -> Option<(usize, Entity<Session>)> {
370370 self.projects.iter().enumerate().filter(|(_, project)| project.environment.is_none()).find_map(|(ix, project)| {
371371 project.sessions.iter().find(|session| session.read(cx).id == id).map(|session| (ix, session.clone()))
372372 })
+23−4src/ui/workspace/sidebar.rsView File
@@ -166,7 +166,8 @@ impl Workspace {
166166 for session in &project.sessions {
167167 let state = session.read(cx);
168168 let entry = (ix, session.clone());
169- if state.archived {
169+ // Subagents' threads open from the subagents panel.
170+ if state.archived || state.is_subagent() {
170171 continue;
171172 }
172173 if state.is_blank() {
@@ -1372,12 +1373,30 @@ impl Workspace {
13721373 } else if selected {
13731374 this.bg(metal::vertical(p.pressed)).shadow(p.pressed_sh.clone())
13741375 } else if draft {
1375- this.bg(p.amber.opacity(0.06)).hover(|style| style.bg(p.amber.opacity(0.12)))
1376+ this.bg(p.amber.opacity(0.06))
13761377 } else {
1377- this.hover(|style| style.bg(p.row_hover))
1378+ this
1379+ }
1380+ })
1381+ .when(working_dim, |this| this.opacity(0.7))
1382+ // A row takes one hover style, so the tint and the undimming share it.
1383+ .map(|this| {
1384+ let tint = match (active || selected, draft) {
1385+ (true, _) => None,
1386+ (false, true) => Some(p.amber.opacity(0.12)),
1387+ (false, false) => Some(p.row_hover),
1388+ };
1389+ if tint.is_none() && !working_dim {
1390+ return this;
13781391 }
1392+ this.hover(move |style| {
1393+ let style = match tint {
1394+ Some(tint) => style.bg(tint),
1395+ None => style,
1396+ };
1397+ if working_dim { style.opacity(1.) } else { style }
1398+ })
13791399 })
1380- .when(working_dim, |this| this.opacity(0.7).hover(|style| style.opacity(1.)))
13811400 .map(|this| self.drag_and_drop(this, session, if pinned { DropZone::Pinned } else { DropZone::Active }, cx))
13821401 .map(|this| self.with_hover_card(this, project_ix, session, cx))
13831402 .on_click({
+341−0src/ui/workspace/subagents.rsaddedView File

Large diffs are not rendered by default.

+160−0src/ui/workspace/surfaces.rsaddedView File

Large diffs are not rendered by default.

+67−11src/ui/workspace/terminal_drawer.rsView File

Large diffs are not rendered by default.