Vectrum
Sign In

App-owned subagents for every agent, the way T3 Code does them

Agents get delegate_task, task_status, task_cancel and
orchestrator_capabilities. A delegated task runs in a subagent thread in
the parent's folder, on any agent and model the app has. The subagents
panel shows it working and opens its thread; the sidebar leaves it out,
and its header leads back to the parent. Its answer goes to a waiting
call, or to the parent as a message that wakes its agent.

Nothing to install. Claude reaches the tools through an MCP server on
loopback, with a token per thread. Pi gets them from an extension the app
writes and loads with --extension, which also switches off other
subagent tools before every run. jcode gets them as session tools over
its API, with swarm and schedule off.

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

Showing 14 changed files with 1243 additions and 18 deletions.

+13−0README.mdView File
@@ -114,6 +114,19 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
114114 (a row per task, single, parallel or chained); and jcode's swarm, whose agents keep
115115 working in sessions of their own after the turn ends: the app checks on them every few
116116 seconds through jcode's session list, and reads what each last said.
117+ - App-owned subagents, as T3 Code has them: every agent gets `delegate_task`,
118+ `task_status`, `task_cancel` and `orchestrator_capabilities`, with instructions for
119+ them. A delegated task runs in a subagent thread in the parent's folder, on the
120+ parent's agent and model or any other agent and model the app has (Claude, Pi or
121+ jcode). The subagents panel shows it working and opens its thread. The thread is
122+ left out of the sidebar, and its header leads back to the parent. When the task
123+ finishes, its answer goes to the call waiting on it (`mode: "wait"`) or reaches the
124+ parent as a message, which wakes its agent. Nothing to install: Claude reaches the
125+ tools through an MCP server the app runs on loopback, with a token per thread.
126+ Pi reaches them through an extension the app writes and loads with `--extension`;
127+ before every run, that extension also switches off Pi's other subagent tools.
128+ jcode gets them as session tools over its API, with its `swarm` and `schedule`
129+ tools turned off. Subagents can't delegate tasks of their own.
117130 - Background tasks: when a command, monitor or subagent Claude left running finishes, the
118131 transcript says so and Claude picks the work back up and reports back on its own. A
119132 long-running command can be sent to the background from its tool card, like Ctrl+B in
+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
+1−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;
+511−0src/orchestrator.rsaddedView File
@@ -0,0 +1,511 @@
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+ /// The environment that tells the Pi extension where the server is.
35+ const URL_ENV: &str = "VECTRUM_CODE_MCP_URL";
36+ const TOKEN_ENV: &str = "VECTRUM_CODE_MCP_TOKEN";
37+
38+ /// What the tools are for, for agents whose instructions the app adds to:
39+ /// the MCP server's instructions (Claude), the system prompt (Pi) and a
40+ /// reminder with the first message (jcode).
41+ pub const INSTRUCTIONS: &str = "\
42+ ## Vectrum Code subagents
43+
44+ 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.
45+
46+ - `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.
47+ - 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.
48+ - 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.
49+ - `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.
50+ - `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.";
51+
52+ /// What the tools take, as MCP describes tools.
53+ pub fn tool_definitions() -> Value {
54+ json!([
55+ {
56+ "name": DELEGATE,
57+ "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).",
58+ "inputSchema": {
59+ "type": "object",
60+ "properties": {
61+ "task": {"type": "string", "description": "The complete, self-contained task for the child agent."},
62+ "title": {"type": "string", "description": "A short title for the subagent, shown to the user."},
63+ "target": {
64+ "type": "object",
65+ "description": "Which agent and model run the task. Omitted fields inherit this thread's.",
66+ "properties": {
67+ "providerInstanceId": {"type": "string", "description": "A provider instance id from orchestrator_capabilities."},
68+ "model": {"type": "string", "description": "A model id that instance offers."}
69+ }
70+ },
71+ "mode": {"type": "string", "enum": ["async", "wait"], "description": "async (default) returns at once; wait blocks for the result."},
72+ "timeoutMs": {"type": "number", "description": "How long mode 'wait' waits. The child isn't stopped when it runs out."}
73+ },
74+ "required": ["task"]
75+ },
76+ "annotations": {"title": "Delegate a task", "destructiveHint": true, "openWorldHint": true}
77+ },
78+ {
79+ "name": TASK_STATUS,
80+ "description": "Where a delegated task stands: running, completed, failed or cancelled, and once it's done, its result.",
81+ "inputSchema": {
82+ "type": "object",
83+ "properties": {"taskId": {"type": "string"}},
84+ "required": ["taskId"]
85+ },
86+ "annotations": {"title": "Get a task's status", "readOnlyHint": true}
87+ },
88+ {
89+ "name": TASK_CANCEL,
90+ "description": "Stop a delegated task that's still running. Its result so far stays readable with task_status.",
91+ "inputSchema": {
92+ "type": "object",
93+ "properties": {"taskId": {"type": "string"}, "reason": {"type": "string"}},
94+ "required": ["taskId"]
95+ },
96+ "annotations": {"title": "Cancel a task", "destructiveHint": true}
97+ },
98+ {
99+ "name": CAPABILITIES,
100+ "description": "The agents (provider instances) and models Vectrum Code can run a delegated task with, and which ones this thread uses.",
101+ "inputSchema": {"type": "object", "properties": {}},
102+ "annotations": {"title": "List agents and models", "readOnlyHint": true}
103+ }
104+ ])
105+ }
106+
107+ /// Whether a tool call, by name, is one of the app's: plain for Pi and
108+ /// jcode, prefixed for Claude (`mcp__vectrum-code__delegate_task`).
109+ pub fn own_tool(name: &str) -> Option<&'static str> {
110+ let bare = name.strip_prefix("mcp__vectrum-code__").or_else(|| name.strip_prefix("mcp__vectrum_code__")).unwrap_or(name);
111+ [DELEGATE, TASK_STATUS, TASK_CANCEL, CAPABILITIES].into_iter().find(|tool| *tool == bare)
112+ }
113+
114+ /// A call from an agent, for the workspace to answer.
115+ pub struct ToolRequest {
116+ /// The calling thread's id.
117+ pub thread: String,
118+ pub tool: String,
119+ pub args: Value,
120+ /// Takes the answer: the result, or what went wrong.
121+ pub reply: smol::channel::Sender<Result<Value, String>>,
122+ }
123+
124+ struct Server {
125+ url: String,
126+ /// Thread id by token, and token by thread id.
127+ tokens: Mutex<(HashMap<String, String>, HashMap<String, String>)>,
128+ }
129+
130+ static LISTENER: OnceLock<Option<Server>> = OnceLock::new();
131+ static REQUESTS: OnceLock<(smol::channel::Sender<ToolRequest>, smol::channel::Receiver<ToolRequest>)> = OnceLock::new();
132+
133+ fn channel() -> &'static (smol::channel::Sender<ToolRequest>, smol::channel::Receiver<ToolRequest>) {
134+ REQUESTS.get_or_init(smol::channel::unbounded)
135+ }
136+
137+ /// The calls agents make, for the workspace to answer.
138+ pub fn requests() -> smol::channel::Receiver<ToolRequest> {
139+ channel().1.clone()
140+ }
141+
142+ /// Starts the server the first time it's needed. `None` if it can't listen.
143+ fn server() -> Option<&'static Server> {
144+ LISTENER
145+ .get_or_init(|| {
146+ let listener = TcpListener::bind(("127.0.0.1", 0)).ok()?;
147+ let port = listener.local_addr().ok()?.port();
148+ thread::Builder::new()
149+ .name("mcp-listener".into())
150+ .spawn(move || {
151+ for stream in listener.incoming() {
152+ let Ok(stream) = stream else { continue };
153+ let _ = thread::Builder::new().name("mcp-connection".into()).spawn(move || serve(stream));
154+ }
155+ })
156+ .ok()?;
157+ Some(Server { url: format!("http://127.0.0.1:{port}/mcp"), tokens: Mutex::default() })
158+ })
159+ .as_ref()
160+ }
161+
162+ /// The server's address and the token a thread's agent calls it with.
163+ pub fn credentials(thread: &str) -> Option<(String, String)> {
164+ let server = server()?;
165+ let mut tokens = server.tokens.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
166+ let token = match tokens.1.get(thread) {
167+ Some(token) => token.clone(),
168+ None => {
169+ let token = crate::remote::new_key();
170+ tokens.0.insert(token.clone(), thread.to_string());
171+ tokens.1.insert(thread.to_string(), token.clone());
172+ token
173+ }
174+ };
175+ Some((server.url.clone(), token))
176+ }
177+
178+ fn thread_for(token: &str) -> Option<String> {
179+ let server = server()?;
180+ let tokens = server.tokens.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
181+ tokens.0.get(token).cloned()
182+ }
183+
184+ /// Claude's `--mcp-config` for a thread.
185+ pub fn claude_config(thread: &str) -> Option<String> {
186+ let (url, token) = credentials(thread)?;
187+ let config = json!({
188+ "mcpServers": {
189+ SERVER: {
190+ "type": "http",
191+ "url": url,
192+ "headers": {"Authorization": format!("Bearer {token}")},
193+ // Long enough for a `mode: "wait"` call.
194+ "timeout": 3_900_000,
195+ }
196+ }
197+ });
198+ Some(config.to_string())
199+ }
200+
201+ /// Answers one connection's requests, one at a time, for as long as it stays
202+ /// open. Plain HTTP/1.1 with JSON replies: all of MCP's streamable HTTP
203+ /// transport a tools-only server needs.
204+ fn serve(stream: TcpStream) {
205+ let _ = stream.set_nodelay(true);
206+ let Ok(mut writer) = stream.try_clone() else { return };
207+ let mut reader = BufReader::new(stream);
208+ loop {
209+ let mut request_line = String::new();
210+ match reader.read_line(&mut request_line) {
211+ Ok(0) | Err(_) => return,
212+ Ok(_) => {}
213+ }
214+ let method = request_line.split_whitespace().next().unwrap_or_default().to_string();
215+ let mut length = 0usize;
216+ let mut token = None;
217+ loop {
218+ let mut line = String::new();
219+ if reader.read_line(&mut line).unwrap_or(0) == 0 {
220+ return;
221+ }
222+ let line = line.trim_end();
223+ if line.is_empty() {
224+ break;
225+ }
226+ let Some((name, value)) = line.split_once(':') else { continue };
227+ let value = value.trim();
228+ match name.trim().to_ascii_lowercase().as_str() {
229+ "content-length" => length = value.parse().unwrap_or(0),
230+ "authorization" => token = value.strip_prefix("Bearer ").map(str::to_string),
231+ _ => {}
232+ }
233+ }
234+ let mut body = vec![0; length.min(16 << 20)];
235+ if reader.read_exact(&mut body).is_err() {
236+ return;
237+ }
238+ let (status, reply) = if method != "POST" {
239+ // No server-sent event stream: everything's answered in the reply.
240+ ("405 Method Not Allowed", None)
241+ } else {
242+ match token.as_deref().and_then(thread_for) {
243+ None => ("401 Unauthorized", Some(json!({"error": "unknown token"}))),
244+ Some(thread) => match serde_json::from_slice::<Value>(&body) {
245+ Ok(message) => match handle(&thread, &message) {
246+ Some(reply) => ("200 OK", Some(reply)),
247+ // A notification.
248+ None => ("202 Accepted", None),
249+ },
250+ Err(err) => ("400 Bad Request", Some(json!({"error": err.to_string()}))),
251+ },
252+ }
253+ };
254+ let body = reply.map(|reply| reply.to_string()).unwrap_or_default();
255+ let head = format!(
256+ "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
257+ body.len()
258+ );
259+ if writer.write_all(head.as_bytes()).and_then(|_| writer.write_all(body.as_bytes())).is_err() {
260+ return;
261+ }
262+ let _ = writer.flush();
263+ }
264+ }
265+
266+ /// Answers one JSON-RPC message; `None` for a notification.
267+ fn handle(thread: &str, message: &Value) -> Option<Value> {
268+ let id = message.get("id").cloned()?;
269+ let params = message.get("params").cloned().unwrap_or(Value::Null);
270+ let result = match message.get("method").and_then(Value::as_str).unwrap_or_default() {
271+ "initialize" => Ok(json!({
272+ "protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or("2025-06-18"),
273+ "capabilities": {"tools": {"listChanged": false}},
274+ "serverInfo": {"name": SERVER, "version": env!("CARGO_PKG_VERSION")},
275+ "instructions": INSTRUCTIONS,
276+ })),
277+ "ping" => Ok(json!({})),
278+ "tools/list" => Ok(json!({"tools": tool_definitions()})),
279+ "tools/call" => {
280+ let tool = params.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
281+ let args = params.get("arguments").cloned().unwrap_or_else(|| json!({}));
282+ Ok(tool_result(call(thread, tool, args)))
283+ }
284+ method => Err(json!({"code": -32601, "message": format!("unknown method {method}")})),
285+ };
286+ Some(match result {
287+ Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
288+ Err(error) => json!({"jsonrpc": "2.0", "id": id, "error": error}),
289+ })
290+ }
291+
292+ /// Hands a call to the workspace and waits for its answer.
293+ fn call(thread: &str, tool: String, args: Value) -> Result<Value, String> {
294+ let (reply, answer) = smol::channel::bounded(1);
295+ let request = ToolRequest { thread: thread.to_string(), tool, args, reply };
296+ channel().0.send_blocking(request).map_err(|_| "Vectrum Code is shutting down.".to_string())?;
297+ answer.recv_blocking().unwrap_or_else(|_| Err("Vectrum Code dropped the call.".into()))
298+ }
299+
300+ /// An answer as an MCP tool result: the data as text for the model, and
301+ /// structured for scripts.
302+ pub fn tool_result(outcome: Result<Value, String>) -> Value {
303+ match outcome {
304+ Ok(value) => json!({
305+ "content": [{"type": "text", "text": serde_json::to_string_pretty(&value).unwrap_or_default()}],
306+ "structuredContent": value,
307+ }),
308+ Err(error) => json!({"content": [{"type": "text", "text": error}], "isError": true}),
309+ }
310+ }
311+
312+ /// The Pi extension: it bridges the app's tools into Pi (when the thread
313+ /// may delegate), adds the instructions to the system prompt, and switches
314+ /// off other subagent tools before every run.
315+ const PI_EXTENSION: &str = r#"// Written by Vectrum Code, which loads it into the Pi agents it runs.
316+ // Changes are overwritten.
317+ const URL = process.env.__URL_ENV__;
318+ const TOKEN = process.env.__TOKEN_ENV__;
319+ const TOOLS = __TOOLS__;
320+ const INSTRUCTIONS = __INSTRUCTIONS__;
321+ const OWN = new Set(TOOLS.map((tool) => tool.name));
322+ // Tools that start agents of their own: Pi's subagent extension and the like.
323+ const OTHER_SUBAGENTS = /subagent|^(task|tasks|agent|agents|swarm|spawn_agent|spawn_agents|delegate)$/i;
324+
325+ async function schema(inputSchema) {
326+ for (const name of ["typebox", "@sinclair/typebox"]) {
327+ try {
328+ const { Type } = await import(name);
329+ if (typeof Type?.Unsafe === "function") return Type.Unsafe(inputSchema);
330+ } catch {}
331+ }
332+ return inputSchema;
333+ }
334+
335+ let nextId = 1;
336+ async function callTool(name, args, signal) {
337+ const response = await fetch(URL, {
338+ method: "POST",
339+ headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${TOKEN}` },
340+ body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method: "tools/call", params: { name, arguments: args ?? {} } }),
341+ signal,
342+ });
343+ const body = await response.text();
344+ if (!response.ok) throw new Error(`Vectrum Code answered ${response.status}: ${body.slice(0, 400)}`);
345+ const message = JSON.parse(body);
346+ if (message.error) throw new Error(message.error.message ?? "Vectrum Code returned an error");
347+ return message.result;
348+ }
349+
350+ export default async function vectrumCode(pi) {
351+ // Subagents go through the app, so other subagent tools stay off. Checked
352+ // before every run, since extensions can turn tools back on.
353+ const switchOffOthers = () => {
354+ if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
355+ const active = pi.getActiveTools();
356+ const kept = active.filter((name) => OWN.has(name) || !OTHER_SUBAGENTS.test(name));
357+ if (kept.length !== active.length) pi.setActiveTools(kept);
358+ };
359+ pi.on("session_start", switchOffOthers);
360+ pi.on("before_agent_start", (event) => {
361+ switchOffOthers();
362+ if (URL && TOKEN) return { systemPrompt: event.systemPrompt + "\n\n" + INSTRUCTIONS };
363+ });
364+ if (!URL || !TOKEN) return;
365+ for (const tool of TOOLS) {
366+ pi.registerTool({
367+ name: tool.name,
368+ label: tool.annotations?.title ?? tool.name,
369+ description: tool.description,
370+ parameters: await schema(tool.inputSchema),
371+ async execute(_toolCallId, params, signal) {
372+ const result = await callTool(tool.name, params, signal);
373+ const text = (result?.content ?? []).filter((part) => part?.type === "text").map((part) => part.text).join("\n");
374+ if (result?.isError) throw new Error(text || `${tool.name} failed`);
375+ return { content: [{ type: "text", text }], details: result?.structuredContent };
376+ },
377+ });
378+ }
379+ }
380+ "#;
381+
382+ /// Writes the Pi extension where Pi can load it, and returns its path.
383+ fn pi_extension() -> Option<PathBuf> {
384+ static PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
385+ PATH.get_or_init(|| {
386+ let source = PI_EXTENSION
387+ .replace("__URL_ENV__", URL_ENV)
388+ .replace("__TOKEN_ENV__", TOKEN_ENV)
389+ .replace("__TOOLS__", &tool_definitions().to_string())
390+ .replace("__INSTRUCTIONS__", &Value::String(INSTRUCTIONS.into()).to_string());
391+ let path = crate::settings::data_dir()?.join("pi").join("vectrum-code.js");
392+ std::fs::create_dir_all(path.parent()?).ok()?;
393+ if std::fs::read_to_string(&path).ok().as_deref() != Some(source.as_str()) {
394+ std::fs::write(&path, source).ok()?;
395+ }
396+ Some(path)
397+ })
398+ .clone()
399+ }
400+
401+ /// Loads the extension into a Pi command. `thread` gets the tools; without
402+ /// it (a subagent's own thread) Pi only has its other subagent tools turned off.
403+ pub fn prepare_pi(command: &mut std::process::Command, thread: Option<&str>) {
404+ let Some(extension) = pi_extension() else { return };
405+ command.arg("--extension").arg(extension);
406+ if let Some((url, token)) = thread.and_then(credentials) {
407+ command.env(URL_ENV, url).env(TOKEN_ENV, token);
408+ } else {
409+ command.env_remove(URL_ENV).env_remove(TOKEN_ENV);
410+ }
411+ }
412+
413+ /// jcode's session tool configuration: the app's tools (when the thread may
414+ /// delegate) and its own agent-spawning tools off.
415+ pub fn jcode_tools(delegate: bool) -> Value {
416+ let custom: Vec<Value> = if delegate {
417+ tool_definitions()
418+ .as_array()
419+ .into_iter()
420+ .flatten()
421+ .map(|tool| json!({"name": tool["name"], "description": tool["description"], "parameters": tool["inputSchema"]}))
422+ .collect()
423+ } else {
424+ Vec::new()
425+ };
426+ // `schedule` can spawn a session too.
427+ json!({"disabled": ["swarm", "schedule"], "custom": custom})
428+ }
429+
430+ /// How long a call's `mode: "wait"` may block before the agent's own client
431+ /// gives up on it: jcode allows a session tool two minutes, and Node's
432+ /// fetch (Pi) waits five for a reply's headers.
433+ pub fn wait_limit(provider: crate::models::Provider) -> Duration {
434+ match provider {
435+ crate::models::Provider::Claude => Duration::from_secs(30 * 60),
436+ crate::models::Provider::Pi => Duration::from_secs(270),
437+ crate::models::Provider::Jcode => Duration::from_secs(100),
438+ }
439+ }
440+
441+ #[cfg(test)]
442+ mod tests {
443+ use serde_json::{Value, json};
444+
445+ use super::{DELEGATE, credentials, own_tool, requests};
446+
447+ fn post(url: &str, token: &str, body: Value) -> (u16, String) {
448+ let response = ureq::post(url).set("Authorization", &format!("Bearer {token}")).set("Content-Type", "application/json").send_string(&body.to_string());
449+ match response {
450+ Ok(response) => (response.status(), response.into_string().unwrap_or_default()),
451+ Err(ureq::Error::Status(code, response)) => (code, response.into_string().unwrap_or_default()),
452+ Err(err) => panic!("{err}"),
453+ }
454+ }
455+
456+ #[::core::prelude::v1::test]
457+ fn serves_mcp_to_the_threads_it_knows() {
458+ let (url, token) = credentials("thread-a").expect("listens");
459+ assert_eq!(credentials("thread-a").unwrap().1, token, "a thread keeps its token");
460+ assert_ne!(credentials("thread-b").unwrap().1, token);
461+
462+ let (status, _) = post(&url, "nope", json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}));
463+ assert_eq!(status, 401);
464+
465+ let (status, body) = post(&url, &token, json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26"}}));
466+ assert_eq!(status, 200);
467+ let reply: Value = serde_json::from_str(&body).unwrap();
468+ assert_eq!(reply["result"]["protocolVersion"], "2025-03-26");
469+ assert!(reply["result"]["instructions"].as_str().unwrap().contains("delegate_task"));
470+
471+ let (status, body) = post(&url, &token, json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
472+ assert_eq!((status, body.as_str()), (202, ""));
473+
474+ let (_, body) = post(&url, &token, json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}));
475+ let reply: Value = serde_json::from_str(&body).unwrap();
476+ assert_eq!(reply["result"]["tools"][0]["name"], DELEGATE);
477+
478+ // The workspace's side, answering with who called and what.
479+ let incoming = requests();
480+ std::thread::spawn(move || {
481+ while let Ok(request) = incoming.recv_blocking() {
482+ let outcome = match request.tool.as_str() {
483+ "task_status" => Ok(json!({"thread": request.thread, "args": request.args})),
484+ _ => Err("no such task".to_string()),
485+ };
486+ let _ = request.reply.send_blocking(outcome);
487+ }
488+ });
489+ let call = |name: &str| {
490+ let body = json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": name, "arguments": {"taskId": "t1"}}});
491+ let (_, body) = post(&url, &token, body);
492+ serde_json::from_str::<Value>(&body).unwrap()["result"].clone()
493+ };
494+ let result = call("task_status");
495+ assert_eq!(result["structuredContent"], json!({"thread": "thread-a", "args": {"taskId": "t1"}}));
496+ assert!(result.get("isError").is_none());
497+ let result = call("task_cancel");
498+ assert_eq!(result["isError"], true);
499+ assert_eq!(result["content"][0]["text"], "no such task");
500+ }
501+
502+ #[::core::prelude::v1::test]
503+ fn recognizes_its_tools_under_any_prefix() {
504+ assert_eq!(own_tool("delegate_task"), Some(DELEGATE));
505+ assert_eq!(own_tool("mcp__vectrum-code__delegate_task"), Some(DELEGATE));
506+ assert_eq!(own_tool("mcp__vectrum_code__task_status"), Some("task_status"));
507+ assert_eq!(own_tool("mcp__other__delegate_task"), None);
508+ assert_eq!(own_tool("Task"), None);
509+ }
510+ }
511+
+174−9src/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};
@@ -125,9 +126,9 @@ impl ToolCall {
125126 .iter()
126127 .map(|member| {
127128 let mut status = TaskStatus::from_cli(&member.status);
128- // A Pi subagent runs inside its call; a swarm agent
129- // outlives the call that spawned it.
130- if status == TaskStatus::Running && !call_running && self.name != SWARM_TOOL {
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() {
131132 status = TaskStatus::Stopped;
132133 }
133134 let running = status == TaskStatus::Running;
@@ -141,6 +142,7 @@ impl ToolCall {
141142 elapsed: elapsed_between(member.started, member.finished, running),
142143 tool: member.tool.clone().filter(|_| running),
143144 summary: member.summary.clone(),
145+ thread: self.is_delegation().then(|| member.id.clone()),
144146 }
145147 })
146148 .collect();
@@ -156,7 +158,7 @@ impl ToolCall {
156158 (None, ToolState::Done) => TaskStatus::Completed,
157159 };
158160 let running = status == TaskStatus::Running;
159- let label = tool_field(&self.input, &["description", "label", "task"])
161+ let label = tool_field(&self.input, &["description", "label", "title", "task"])
160162 .filter(|label| !label.trim().is_empty())
161163 .or_else(|| {
162164 let tasks = self.input.get("tasks").or_else(|| self.input.get("chain"))?.as_array()?.len();
@@ -173,8 +175,19 @@ impl ToolCall {
173175 elapsed: elapsed_between(progress.started, progress.finished, running),
174176 tool: progress.tool.clone().filter(|_| running),
175177 summary: progress.summary.clone(),
178+ thread: None,
176179 }]
177180 }
181+
182+ /// A call to the app's `delegate_task`, whose task runs in a thread of its own.
183+ pub fn is_delegation(&self) -> bool {
184+ orchestrator::own_tool(&self.name) == Some(orchestrator::DELEGATE)
185+ }
186+
187+ /// Whether the agents it starts carry on after the call returns.
188+ fn outlives_call(&self) -> bool {
189+ self.name == SWARM_TOOL || self.is_delegation()
190+ }
178191 }
179192
180193 /// A subagent as the subagents panel shows it.
@@ -191,6 +204,8 @@ pub struct SubagentEntry {
191204 /// Its latest tool call, while it works.
192205 pub tool: Option<(String, Value)>,
193206 pub summary: Option<String>,
207+ /// The thread it runs in, for a task delegated to the app.
208+ pub thread: Option<String>,
194209 }
195210
196211 /// jcode's tool for running a swarm of agents, each in a session of its own.
@@ -200,6 +215,8 @@ const SWARM_POLL: Duration = Duration::from_secs(3);
200215 /// The jcode requests that check on swarm agents, by what they're for.
201216 const LIST_AGENTS: &str = "list the swarm's agents";
202217 const PEEK_AGENT: &str = "read a swarm agent's progress";
218+ /// The jcode request that registers the app's tools.
219+ const CONFIGURE_TOOLS: &str = "set up the app's tools";
203220
204221 /// One agent among several a call started: a task of Pi's `subagent` call,
205222 /// or an agent a jcode `swarm` call spawned.
@@ -282,7 +299,7 @@ impl SubagentProgress {
282299 /// `subagent` extension (and jcode's old tool of that name), and jcode's
283300 /// swarm, whose spawns do.
284301 pub fn is_subagent_tool(name: &str) -> bool {
285- matches!(name, "Task" | "Agent" | "subagent" | SWARM_TOOL)
302+ matches!(name, "Task" | "Agent" | "subagent" | SWARM_TOOL) || orchestrator::own_tool(name) == Some(orchestrator::DELEGATE)
286303 }
287304
288305 /// The tool call a model's message ends on, from Pi's or jcode's message
@@ -371,7 +388,7 @@ fn last_line(text: &str) -> Option<String> {
371388
372389 /// A tool input cut down to its short text fields: enough to say what the
373390 /// call does, without a file's contents.
374- fn brief_input(input: &Value) -> Value {
391+ pub(crate) fn brief_input(input: &Value) -> Value {
375392 let Some(fields) = input.as_object() else { return Value::Null };
376393 fields
377394 .iter()
@@ -394,7 +411,7 @@ pub enum TaskStatus {
394411 }
395412
396413 impl TaskStatus {
397- fn key(self) -> &'static str {
414+ pub fn key(self) -> &'static str {
398415 match self {
399416 TaskStatus::Running => "running",
400417 TaskStatus::Completed => "completed",
@@ -634,6 +651,29 @@ pub struct RemoteLink {
634651 pub enum SessionEvent {
635652 /// A finished turn's changes on disk are known, by its `TurnEnd` index.
636653 TurnChanges(usize),
654+ /// jcode called one of the app's tools, to be answered with
655+ /// [`Session::answer_app_tool`].
656+ AppTool { call_id: String, tool: String, input: Value },
657+ }
658+
659+ /// Where a subagent's thread starts: its parent thread's id, folder and branch.
660+ pub struct SubagentParent {
661+ pub id: String,
662+ pub cwd: PathBuf,
663+ pub branch: Option<String>,
664+ }
665+
666+ /// What makes a thread a subagent: the thread that delegated its task. The
667+ /// task goes by the subagent thread's id.
668+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
669+ pub struct Delegation {
670+ pub parent: String,
671+ /// Its result reached the parent, in a reply or a message.
672+ #[serde(default)]
673+ pub delivered: bool,
674+ /// Stopped by `task_cancel` or by the user.
675+ #[serde(default)]
676+ pub cancelled: bool,
637677 }
638678
639679 /// A message waiting for the running turn to end.
@@ -748,6 +788,11 @@ struct JcodeLink {
748788 browser_note: Option<String>,
749789 /// Checks on the swarm agents the thread spawned, while any work.
750790 agent_poll: Option<Task<()>>,
791+ /// The app's tools are to be registered again once the turn ends:
792+ /// jcode turns down tool changes mid-turn.
793+ tools_pending: bool,
794+ /// The app's instructions went out with a message.
795+ instructions_sent: bool,
751796 }
752797
753798 pub struct Session {
@@ -772,6 +817,8 @@ pub struct Session {
772817 pub branch: Option<String>,
773818 /// Set for a thread mirrored from another device.
774819 pub remote: Option<RemoteLink>,
820+ /// Set for a subagent's thread: a task another thread delegated.
821+ pub delegation: Option<Delegation>,
775822 pub items: Vec<ChatItem>,
776823 pub status: SessionStatus,
777824 pub thinking: bool,
@@ -895,6 +942,41 @@ impl Session {
895942 session
896943 }
897944
945+ /// A subagent's thread: it runs a task `parent` delegated, in the
946+ /// parent's working folder, and starts on the task right away.
947+ pub fn new_subagent(new: NewSession, parent: &SubagentParent, title: Option<String>, task: String, cx: &mut Context<Self>) -> Self {
948+ let now = SystemTime::now();
949+ let id = format!(
950+ "{:x}-{:x}-{}",
951+ now.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(),
952+ std::process::id(),
953+ NEXT_SESSION.load(Ordering::Relaxed)
954+ );
955+ let mut session = Self::empty(id, new, now);
956+ session.cwd = parent.cwd.clone();
957+ session.branch = parent.branch.clone();
958+ session.delegation = Some(Delegation { parent: parent.id.clone(), delivered: false, cancelled: false });
959+ if let Some(title) = title.filter(|title| !title.trim().is_empty()) {
960+ session.title = title.trim().to_string().into();
961+ session.title_custom = true;
962+ }
963+ session.send_message(task, Vec::new(), cx);
964+ session
965+ }
966+
967+ /// A subagent's thread, which the sidebar leaves out.
968+ pub fn is_subagent(&self) -> bool {
969+ self.delegation.is_some()
970+ }
971+
972+ /// What the thread's agent last said: the end of its latest answer.
973+ pub fn last_answer(&self) -> Option<String> {
974+ self.items.iter().rev().find_map(|item| match item {
975+ ChatItem::Assistant(answer) if !answer.text.trim().is_empty() => Some(answer.text.clone()),
976+ _ => None,
977+ })
978+ }
979+
898980 fn empty(id: String, new: NewSession, now: SystemTime) -> Self {
899981 let snapshot_index = std::env::temp_dir().join(format!(
900982 "vectrum-code-{}-{}.index",
@@ -915,6 +997,7 @@ impl Session {
915997 pull_request: None,
916998 branch: None,
917999 remote: None,
1000+ delegation: None,
9181001 items: Vec::new(),
9191002 status: SessionStatus::Starting,
9201003 thinking: false,
@@ -998,6 +1081,7 @@ impl Session {
9981081 resume: resume.clone(),
9991082 browser_session: settings::get_for(&self.project, |settings| settings.agent_browser)
10001083 .then(|| browser::session_name(&self.id)),
1084+ delegating_thread: self.delegation.is_none().then(|| self.id.clone()),
10011085 };
10021086 let process = match AgentProcess::spawn(&config, tx) {
10031087 Ok(process) => process,
@@ -1401,8 +1485,14 @@ impl Session {
14011485 self.jcode_request("steer the turn", request);
14021486 } else {
14031487 let mut request = json!({"req": "send_message", "content": prompt, "images": images});
1404- if let Some(note) = self.jcode.browser_note.take() {
1405- request["system_reminder"] = note.into();
1488+ // jcode's system prompt can't be added to, so the app's
1489+ // instructions go along with the first message.
1490+ let instructions = (self.delegation.is_none() && !self.jcode.instructions_sent)
1491+ .then_some(orchestrator::INSTRUCTIONS.to_string());
1492+ self.jcode.instructions_sent = true;
1493+ let reminder: Vec<String> = instructions.into_iter().chain(self.jcode.browser_note.take()).collect();
1494+ if !reminder.is_empty() {
1495+ request["system_reminder"] = reminder.join("\n\n").into();
14061496 }
14071497 self.jcode_request("send the message", request);
14081498 }
@@ -3171,6 +3261,7 @@ impl Session {
31713261 for (what, request) in std::mem::take(&mut self.jcode.held) {
31723262 self.jcode_request(what, request);
31733263 }
3264+ self.configure_jcode_tools();
31743265 // Swarm agents carry on in jcode while the app is away.
31753266 self.watch_swarm(cx);
31763267 }
@@ -3194,6 +3285,9 @@ impl Session {
31943285 match request {
31953286 // Checking on swarm agents is best effort, and repeats.
31963287 Some(LIST_AGENTS | PEEK_AGENT) => {}
3288+ // Mid-turn, it waits for the turn to end. Daemons too old
3289+ // for session tools go without.
3290+ Some(CONFIGURE_TOOLS) => self.jcode.tools_pending = error.contains("busy"),
31973291 // The session is gone from jcode's store; carry on in a new one.
31983292 Some("attach") => {
31993293 self.push_notice(
@@ -3221,6 +3315,11 @@ impl Session {
32213315 self.jcode.provider = message.get("provider").and_then(Value::as_str).map(str::to_string);
32223316 }
32233317 "message_accepted" => self.begin_turn(cx),
3318+ "tool_call" => cx.emit(SessionEvent::AppTool {
3319+ call_id: text("call_id").to_string(),
3320+ tool: text("name").to_string(),
3321+ input: message.get("input").cloned().unwrap_or(Value::Null),
3322+ }),
32243323 "soft_interrupt_injected" if text("point") == "turn_start" => self.begin_turn(cx),
32253324 "text_delta" => {
32263325 self.begin_turn(cx);
@@ -3349,12 +3448,74 @@ impl Session {
33493448 self.finish_open_tools("The turn ended before the tool finished.");
33503449 let duration_ms = self.turn_started.map_or(0, |start| start.elapsed().as_millis() as u64);
33513450 self.finish_turn(duration_ms, None, cx);
3451+ if std::mem::take(&mut self.jcode.tools_pending) {
3452+ self.configure_jcode_tools();
3453+ }
33523454 self.send_next_queued(cx);
33533455 }
33543456 _ => {}
33553457 }
33563458 }
33573459
3460+ /// Registers the app's tools with the thread's jcode session (a
3461+ /// subagent's thread gets none) and switches off jcode's own ways of
3462+ /// spawning agents. jcode keeps the setup in memory, so it's sent on
3463+ /// every attach.
3464+ fn configure_jcode_tools(&mut self) {
3465+ let tools = orchestrator::jcode_tools(self.delegation.is_none());
3466+ self.jcode_request(CONFIGURE_TOOLS, json!({"req": "configure_tools", "tools": tools}));
3467+ }
3468+
3469+ /// Answers a call jcode made to one of the app's tools.
3470+ pub fn answer_app_tool(&mut self, call_id: &str, outcome: Result<Value, String>) {
3471+ let (output, error) = match outcome {
3472+ Ok(value) => (serde_json::to_string_pretty(&value).unwrap_or_default(), None),
3473+ Err(error) => (String::new(), Some(error)),
3474+ };
3475+ let request = json!({"req": "tool_result", "call_id": call_id, "output": output, "error": error});
3476+ self.jcode_request("answer a tool call", request);
3477+ }
3478+
3479+ /// Ties a task the app started for this thread to the `delegate_task`
3480+ /// call that asked for it: the latest one with that task and no
3481+ /// subagent yet.
3482+ pub fn link_delegation(&mut self, args: &Value, member: SubagentMember, cx: &mut Context<Self>) {
3483+ let task = args.get("task").and_then(Value::as_str);
3484+ let unlinked = |tool: &ToolCall| {
3485+ tool.is_delegation() && tool.subagent.as_ref().is_some_and(|progress| progress.members.is_empty())
3486+ };
3487+ let ix = self
3488+ .items
3489+ .iter()
3490+ .rposition(|item| {
3491+ matches!(item, ChatItem::Tool(tool) if unlinked(tool) && tool.input.get("task").and_then(Value::as_str) == task)
3492+ })
3493+ .or_else(|| self.items.iter().rposition(|item| matches!(item, ChatItem::Tool(tool) if unlinked(tool))));
3494+ if let Some(ChatItem::Tool(tool)) = ix.and_then(|ix| self.items.get_mut(ix))
3495+ && let Some(progress) = tool.subagent.as_mut()
3496+ {
3497+ progress.members.push(member);
3498+ cx.notify();
3499+ }
3500+ }
3501+
3502+ /// Updates the subagent row of a task this thread delegated.
3503+ pub fn update_delegation(&mut self, task: &str, update: impl FnOnce(&mut SubagentMember), cx: &mut Context<Self>) {
3504+ let member = self.items.iter_mut().find_map(|item| match item {
3505+ ChatItem::Tool(tool) if tool.is_delegation() => {
3506+ tool.subagent.as_mut()?.members.iter_mut().find(|member| member.id == task)
3507+ }
3508+ _ => None,
3509+ });
3510+ if let Some(member) = member {
3511+ let before = member.clone();
3512+ update(member);
3513+ if *member != before {
3514+ cx.notify();
3515+ }
3516+ }
3517+ }
3518+
33583519 /// The chat item a streamed jcode message renders into, made on its first delta.
33593520 fn jcode_text_item(&mut self, message_id: &str, cx: &mut Context<Self>) -> usize {
33603521 if let Some(&ix) = self.jcode.texts.get(message_id) {
@@ -3697,6 +3858,8 @@ pub struct SavedThread {
36973858 pub items: Vec<SavedItem>,
36983859 /// What each finished turn changed, by its `TurnEnd` index.
36993860 pub turn_changes: Vec<(usize, TurnChanges)>,
3861+ #[serde(default, skip_serializing_if = "Option::is_none")]
3862+ pub delegation: Option<Delegation>,
37003863 }
37013864
37023865 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -3865,6 +4028,7 @@ impl Session {
38654028 working: self.is_working(),
38664029 items,
38674030 turn_changes: self.turn_changes.iter().map(|(end, changes)| (*end, changes.clone())).collect(),
4031+ delegation: self.delegation.clone(),
38684032 }
38694033 }
38704034
@@ -3909,6 +4073,7 @@ impl Session {
39094073 session.queue = saved.queue;
39104074 session.interrupted = saved.working;
39114075 session.turn_changes = saved.turn_changes.into_iter().collect();
4076+ session.delegation = saved.delegation;
39124077 for item in saved.items {
39134078 let item = session.item_from_saved(item, false, cx);
39144079 session.items.push(item);
+1−1src/ui/chat.rsView File
@@ -265,7 +265,7 @@ impl ChatView {
265265 _ => {}
266266 }),
267267 cx.subscribe_in(&session, window, |this, session, event: &SessionEvent, window, cx| {
268- let SessionEvent::TurnChanges(end) = event;
268+ let SessionEvent::TurnChanges(end) = event else { return };
269269 let big = session.read(cx).turn_changes(*end).is_some_and(|changes| {
270270 changes.files.len() >= PROACTIVE_FILES || changes.added() + changes.removed() >= PROACTIVE_LINES
271271 });
+11−0src/ui/chat/timeline.rsView File
@@ -114,6 +114,17 @@ pub(crate) fn describe_call(name: &str, input: &Value, cwd: &Path) -> (Icon, Str
114114 fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation {
115115 let field = |keys: &[&str]| tool_field(input, keys);
116116 let first_line = |text: String| text.lines().next().unwrap_or_default().chars().take(200).collect::<String>();
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+ }
117128 let (action, label) = match name.to_ascii_lowercase().as_str() {
118129 "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())),
119130 "bashoutput" | "killshell" => (Action::Command, name.to_string()),
+9−1src/ui/workspace.rsView File
@@ -46,6 +46,7 @@ use crate::{
4646 };
4747
4848 mod actions;
49+ mod delegation;
4950 mod git_actions;
5051 mod header;
5152 mod palette;
@@ -291,6 +292,8 @@ pub struct Workspace {
291292 last_pr_check: Option<Instant>,
292293 last_repaint: Instant,
293294 remote: RemoteState,
295+ /// Tasks agents delegated to the app's subagents.
296+ delegations: delegation::Delegations,
294297 focus_handle: FocusHandle,
295298 observers: HashMap<EntityId, Vec<Subscription>>,
296299 _subscriptions: Vec<Subscription>,
@@ -421,12 +424,14 @@ impl Workspace {
421424 last_pr_check: None,
422425 last_repaint: now,
423426 remote: RemoteState::default(),
427+ delegations: Default::default(),
424428 focus_handle: cx.focus_handle(),
425429 observers: HashMap::new(),
426430 _subscriptions: subscriptions,
427431 _ticker: ticker,
428432 _remote_flush: remote_flush,
429433 };
434+ workspace.listen_for_tools(window, cx);
430435 let active = workspace.restore(window, cx);
431436 match (initial_project, active) {
432437 (Some(path), _) => workspace.add_project(path, window, cx),
@@ -1007,6 +1012,7 @@ impl Workspace {
10071012 session.entity_id(),
10081013 vec![
10091014 cx.observe_in(&session, window, Self::on_session_changed),
1015+ cx.subscribe_in(&session, window, Self::on_session_event),
10101016 cx.subscribe_in(&chat, window, Self::on_chat_event),
10111017 ],
10121018 );
@@ -1042,6 +1048,7 @@ impl Workspace {
10421048 cx.notify();
10431049 self.dirty.insert(session.entity_id());
10441050 self.remote_thread_changed(&session, cx);
1051+ self.track_delegation(&session, cx);
10451052 let state = session.read(cx);
10461053 let now = Activity { working: state.is_working(), attention: state.needs_attention() };
10471054 let before = self.activity.insert(session.entity_id(), now).unwrap_or_default();
@@ -1587,6 +1594,7 @@ impl Workspace {
15871594 .iter()
15881595 .enumerate()
15891596 .flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone())))
1597+ .filter(|(_, session)| !session.read(cx).is_subagent())
15901598 .filter(|(ix, session)| {
15911599 query.is_empty()
15921600 || session.read(cx).title.to_lowercase().contains(&query)
@@ -1715,7 +1723,7 @@ impl Workspace {
17151723 .projects
17161724 .iter()
17171725 .flat_map(|project| project.sessions.iter().map(move |session| (project, session)))
1718- .filter(|(_, session)| session.read(cx).archived)
1726+ .filter(|(_, session)| session.read(cx).archived && !session.read(cx).is_subagent())
17191727 .collect();
17201728 threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
17211729 threads
+452−0src/ui/workspace/delegation.rsaddedView File
@@ -0,0 +1,452 @@
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 thread
6+ //! as a message, which wakes the agent there.
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, which wakes its agent.
408+ let title = child.read(cx).title.to_string();
409+ let mut message = format!(
410+ "[Vectrum Code] Delegated task {task} (\"{title}\") {}.",
411+ match task_state {
412+ TaskState::Completed => "completed",
413+ TaskState::Failed => "failed",
414+ TaskState::Cancelled => "was cancelled",
415+ _ => "stopped",
416+ }
417+ );
418+ match result.get("summary").and_then(Value::as_str) {
419+ Some(summary) => message.push_str(&format!(" Its final answer:\n\n{summary}")),
420+ None => message.push_str(" It gave no answer."),
421+ }
422+ parent.update(cx, |parent, cx| parent.send_message(message, Vec::new(), cx));
423+ }
424+ }
425+
426+ /// What `orchestrator_capabilities` reports: every agent that's on, with
427+ /// its models, and what the calling thread runs.
428+ fn capabilities(thread: &Session, cx: &App) -> Value {
429+ let instances: Vec<Value> = Catalog::global(cx)
430+ .instances
431+ .iter()
432+ .filter(|state| state.config.enabled && !matches!(state.status, ProbeStatus::Disabled))
433+ .map(|state| {
434+ let models: Vec<Value> = state
435+ .models
436+ .iter()
437+ .map(|model| json!({"id": model.id, "name": model.name, "detail": model.detail}))
438+ .collect();
439+ json!({
440+ "providerInstanceId": state.config.id,
441+ "kind": state.config.kind.key(),
442+ "name": if state.config.name.trim().is_empty() { state.config.kind.label().to_string() } else { state.config.name.clone() },
443+ "models": models,
444+ })
445+ })
446+ .collect();
447+ json!({
448+ "current": {"providerInstanceId": thread.instance, "model": thread.model, "activeModel": thread.active_model},
449+ "providerInstances": instances,
450+ "tools": [DELEGATE, TASK_STATUS, TASK_CANCEL, CAPABILITIES],
451+ })
452+ }
+23−0src/ui/workspace/header.rsView File
@@ -114,6 +114,28 @@ impl Workspace {
114114 .child(div().max_w(px(160.)).truncate().font_weight(FontWeight::MEDIUM).child(project.name.clone()))
115115 });
116116
117+ // A subagent's thread leads back to the thread that delegated its task.
118+ let parent = active
119+ .and_then(|session| session.delegation.as_ref())
120+ .and_then(|delegation| self.local_thread(&delegation.parent, cx))
121+ .map(|(parent_ix, parent)| {
122+ let parent_title = parent.read(cx).title.clone();
123+ let tip = SharedString::from(format!("Subagent of {parent_title}"));
124+ h_flex()
125+ .id("parent-thread")
126+ .flex_none()
127+ .gap(px(6.))
128+ .cursor_pointer()
129+ .text_size(px(14.))
130+ .text_color(p.ink3)
131+ .hover(|style| style.text_color(p.ink))
132+ .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx))
133+ .on_click(cx.listener(move |this, _, window, cx| this.select(parent_ix, parent.clone(), window, cx)))
134+ .child(div().max_w(px(200.)).truncate().child(parent_title))
135+ .child(div().text_color(p.icon).child("/"))
136+ .child(Icon::new(Lucide::Bot).size(px(14.)))
137+ });
138+
117139 let title_element = title.map(|title| -> AnyElement {
118140 if let Some(rename) = renaming {
119141 return div()
@@ -202,6 +224,7 @@ impl Workspace {
202224 .when_some(breadcrumb, |this, breadcrumb| {
203225 this.child(breadcrumb).child(div().text_size(px(14.)).text_color(p.icon).child("/"))
204226 })
227+ .when_some(parent, |this, parent| this.child(parent))
205228 .when_some(title_element, |this, title| this.child(title))
206229 .child(div().flex_1())
207230 .child(self.render_layout_controls(panel_visible, cx)),
+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 })
+2−1src/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() {
+29−4src/ui/workspace/subagents.rsView File
@@ -6,6 +6,8 @@
66 //!
77 //! Claude's Task and Agent calls are one subagent each. Pi's `subagent`
88 //! extension and jcode's swarm spawns can start several, each its own row.
9+ //! A task delegated to the app runs in a thread of its own, which a click
10+ //! opens.
911
1012 use std::time::Duration;
1113
@@ -37,6 +39,8 @@ struct Subagent {
3739 /// What it's doing: its latest tool call while it works.
3840 activity: Option<(Icon, String)>,
3941 summary: Option<SharedString>,
42+ /// The thread a task delegated to the app runs in, by id.
43+ thread: Option<String>,
4044 }
4145
4246 /// A thread's subagents, in the order they started.
@@ -61,6 +65,7 @@ fn subagents_of(session: &Entity<Session>, cx: &App) -> Vec<Subagent> {
6165 elapsed: entry.elapsed,
6266 activity: entry.tool.map(|(name, input)| describe_call(&name, &input, &state.cwd)),
6367 summary: entry.summary.map(Into::into),
68+ thread: entry.thread,
6469 });
6570 }
6671 }
@@ -95,8 +100,20 @@ impl Workspace {
95100 cx.notify();
96101 }
97102
98- /// Opens a subagent's thread, if it isn't open, and shows its call.
99- fn reveal_subagent(&mut self, session: Entity<Session>, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
103+ /// Opens a subagent's thread, if it isn't open, and shows its call; or
104+ /// for a task delegated to the app, the thread it runs in.
105+ fn reveal_subagent(
106+ &mut self,
107+ session: Entity<Session>,
108+ ix: usize,
109+ thread: Option<String>,
110+ window: &mut Window,
111+ cx: &mut Context<Self>,
112+ ) {
113+ if let Some((project, child)) = thread.and_then(|thread| self.local_thread(&thread, cx)) {
114+ self.select(project, child, window, cx);
115+ return;
116+ }
100117 if self.active.as_ref() != Some(&session) {
101118 let Some(project) = self.project_of(&session) else { return };
102119 self.select(project, session.clone(), window, cx);
@@ -209,6 +226,14 @@ impl Workspace {
209226 };
210227 let id = subagent.id;
211228 let thread = other.then(|| subagent.session.read(cx).title.clone());
229+ // A delegated task's agent can stop to ask the user something.
230+ let waiting = running
231+ && subagent
232+ .thread
233+ .as_ref()
234+ .and_then(|thread| self.local_thread(thread, cx))
235+ .is_some_and(|(_, child)| child.read(cx).needs_attention());
236+ let status = if waiting { Some("Waiting for you") } else { status };
212237 let meta: Vec<String> = [
213238 subagent.kind.as_ref().map(ToString::to_string),
214239 (subagent.tool_uses > 0)
@@ -219,7 +244,7 @@ impl Workspace {
219244 .into_iter()
220245 .flatten()
221246 .collect();
222- let (session, ix) = (subagent.session.clone(), subagent.ix);
247+ let (session, ix, child) = (subagent.session.clone(), subagent.ix, subagent.thread.clone());
223248 h_flex()
224249 .id(("subagent", id))
225250 .items_start()
@@ -229,7 +254,7 @@ impl Workspace {
229254 .rounded(px(12.))
230255 .cursor_pointer()
231256 .hover(|style| style.bg(p.row_hover))
232- .on_click(cx.listener(move |this, _, window, cx| this.reveal_subagent(session.clone(), ix, window, cx)))
257+ .on_click(cx.listener(move |this, _, window, cx| this.reveal_subagent(session.clone(), ix, child.clone(), window, cx)))
233258 .child(div().flex_none().h(px(18.)).flex().items_center().child(metal::led(("subagent-led", id), led, running, p)))
234259 .child(
235260 v_flex()