@ferris1225/pi-subagents 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README-zh.md CHANGED
@@ -70,7 +70,7 @@ pi 从不把每个 agent 的描述展示给主模型——它只看到 `subagent
70
70
  3. **`Use PROACTIVELY when …` 描述** —— 在 Claude Code agent 生态被验证过的触发措辞。
71
71
 
72
72
  这段指令会引导出一条干净的流程:**`explore` → `worker` → `reviewer`**,独立任务并行扇出,
73
- 以及「信任但需验证」的交接。
73
+ 以及「信任但需验证」的交接。由于子代理在后台运行,依赖前序结果的步骤必须等结果送达后再启动。
74
74
 
75
75
  ## 配置
76
76
 
@@ -121,20 +121,25 @@ pi 从不把每个 agent 的描述展示给主模型——它只看到 `subagent
121
121
  { "tasks": [ { "agent": "explore", "task": "..." }, { "agent": "explore", "task": "..." } ] }
122
122
  ```
123
123
 
124
+ 所有子代理默认在后台启动。工具会立即结束当前主代理回合,编辑器可直接继续输入,
125
+ 无需按 Escape;完成输出会显示出来,并在后续用户请求前加入上下文。启动完成后,
126
+ Escape 只中止前台工作;切换会话、`/reload` 或退出才会清理仍在运行的后台进程。
127
+
124
128
  ## 实时状态与通知
125
129
 
126
130
  子代理运行期间,编辑器上方的挂件为每个运行显示一行状态(图标、agent、模型、
127
- token 用量、耗时),其下缩进一行显示它正在做什么:`thinking`、`writing`、
131
+ token 用量、耗时),其下缩进一行显示它正在做什么:`thinking`、`responding`、
128
132
  `read src/index.ts`、`bash npm test`……(不会是一坨 JSON 参数)。
133
+ `responding` 表示模型正在流式输出普通文本,**不**表示向文件系统写入。
129
134
 
130
135
  运行结束(成功**或**失败)时,该行立即从挂件消失,主窗口收到一条通知,
131
- 给出最终摘要(`✓ worker · openai/gpt-5 · ↑12.4k ↓3.1k · 47s`)。工具结果
132
- 本身仍是对话里的持久记录。
136
+ 给出最终摘要(`✓ worker · openai/gpt-5 · ↑12.4k ↓3.1k · 47s`)。完成结果消息
137
+ 才是对话中持久保留、并会供后续请求使用的记录。
133
138
 
134
139
  子代理使用配置的思考强度(默认 `--thinking max`);pi 会按目标模型实际支持
135
140
  的级别自适应降级(`max → xhigh → high → … → off`),弱模型也能平稳运行。
136
141
  任务内容通过 stdin 传递,只有 agent system prompt 使用短生命周期临时文件。
137
- 子进程输出在内存中流式处理;中止或卡住时会通过 watchdog 清理整个进程树。
142
+ 子进程输出在内存中流式处理;默认不设运行时限,只有显式中止时才清理整个进程树。
138
143
 
139
144
  ## 开发
140
145
 
package/README.md CHANGED
@@ -79,7 +79,8 @@ tool. Three levers fix that:
79
79
  Claude Code agent ecosystem.
80
80
 
81
81
  The directive encourages a clean flow: **`explore` → `worker` → `reviewer`**, parallel
82
- fan-out for independent tasks, and trust-but-verify handoffs.
82
+ fan-out for independent tasks, and trust-but-verify handoffs. Because runs are backgrounded,
83
+ start dependent steps only after the preceding result is delivered.
83
84
 
84
85
  ## Configuration
85
86
 
@@ -130,24 +131,30 @@ Tool shape:
130
131
  { "tasks": [ { "agent": "explore", "task": "..." }, { "agent": "explore", "task": "..." } ] }
131
132
  ```
132
133
 
134
+ Every run starts in the background. The tool immediately ends the current main-agent turn,
135
+ so the editor is ready for another request without pressing Escape. The completed output is
136
+ shown and added to the context before a later user prompt. Escape only interrupts foreground
137
+ work after launch; session switch, reload, or exit cancels remaining background processes.
138
+
133
139
  ## Live status & notifications
134
140
 
135
141
  While sub-agents run, a widget above the editor shows one line per run — status
136
142
  icon, agent, model, token usage, elapsed time — plus a second, indented line
137
- with what the agent is doing right now: `thinking`, `writing`,
143
+ with what the agent is doing right now: `thinking`, `responding`,
138
144
  `read src/index.ts`, `bash npm test`, … (never a raw JSON args blob).
145
+ `responding` means the model is streaming normal text, **not** writing to the filesystem.
139
146
 
140
147
  When a run finishes (done **or** failed), its row disappears from the widget and
141
148
  the main window gets a notification with the final summary
142
- (`✓ worker · openai/gpt-5 · ↑12.4k ↓3.1k · 47s`). The tool result itself
143
- remains the durable record in the conversation.
149
+ (`✓ worker · openai/gpt-5 · ↑12.4k ↓3.1k · 47s`). Its completed result message
150
+ is the durable record in the conversation and context for a later request.
144
151
 
145
152
  Sub-agents use the configured thinking level (default `--thinking max`);
146
153
  pi clamps it adaptively to what the resolved model supports
147
154
  (`max → xhigh → high → … → off`), so weaker models degrade gracefully.
148
155
  The task is sent through stdin; only the agent system prompt uses a short-lived
149
- file. Child output is streamed in memory, with a watchdog and process-tree cleanup
150
- for aborted or stuck runs.
156
+ file. Child output is streamed in memory. Runs have no default time limit;
157
+ explicit cancellation cleans up the process tree.
151
158
 
152
159
  ## Development
153
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Focused sub-agent delegation for pi: explore / plan / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Bounded background task scheduler.
3
+ *
4
+ * Tasks get their own AbortSignal rather than inheriting the foreground agent
5
+ * turn's signal. The owning extension cancels all work only on session teardown.
6
+ */
7
+
8
+ export type BackgroundTask = (signal: AbortSignal) => Promise<void>;
9
+
10
+ interface PendingTask {
11
+ task: BackgroundTask;
12
+ controller: AbortController;
13
+ onCancelled?: () => void;
14
+ }
15
+
16
+ export class BackgroundTaskQueue {
17
+ private readonly concurrency: number;
18
+ private readonly pending: PendingTask[] = [];
19
+ private readonly active = new Set<AbortController>();
20
+ private stopped = false;
21
+
22
+ constructor(concurrency: number) {
23
+ this.concurrency = Math.max(1, concurrency);
24
+ }
25
+
26
+ enqueue(task: BackgroundTask, onCancelled?: () => void): AbortController {
27
+ const controller = new AbortController();
28
+ if (this.stopped) {
29
+ controller.abort();
30
+ onCancelled?.();
31
+ return controller;
32
+ }
33
+
34
+ this.pending.push({ task, controller, onCancelled });
35
+ this.drain();
36
+ return controller;
37
+ }
38
+
39
+ /** Stop queued work and request cancellation for running work. */
40
+ cancelAll(): void {
41
+ if (this.stopped) return;
42
+ this.stopped = true;
43
+
44
+ for (const entry of this.pending.splice(0)) {
45
+ entry.controller.abort();
46
+ entry.onCancelled?.();
47
+ }
48
+ for (const controller of this.active) controller.abort();
49
+ }
50
+
51
+ private drain(): void {
52
+ while (!this.stopped && this.active.size < this.concurrency) {
53
+ const entry = this.pending.shift();
54
+ if (!entry) return;
55
+ if (entry.controller.signal.aborted) {
56
+ entry.onCancelled?.();
57
+ continue;
58
+ }
59
+
60
+ this.active.add(entry.controller);
61
+ void entry.task(entry.controller.signal).catch(() => undefined).finally(() => {
62
+ this.active.delete(entry.controller);
63
+ this.drain();
64
+ });
65
+ }
66
+ }
67
+ }
package/src/index.ts CHANGED
@@ -13,11 +13,11 @@
13
13
  */
14
14
 
15
15
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
16
- import { StringEnum } from "@earendil-works/pi-ai";
17
16
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
17
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
19
18
  import { Type } from "typebox";
20
19
  import { discoverAgents, type AgentConfig } from "./agents.ts";
20
+ import { BackgroundTaskQueue } from "./background.ts";
21
21
  import { getConfigPath, loadConfig, saveConfig } from "./config.ts";
22
22
  import { repairUnavailableModelOverrides } from "./models.ts";
23
23
  import { buildDelegationDirective } from "./prompt.ts";
@@ -30,9 +30,7 @@ import {
30
30
  getFinalOutput,
31
31
  getResultOutput,
32
32
  isFailedResult,
33
- mapWithConcurrencyLimit,
34
33
  runSingleAgent,
35
- type OnUpdateCallback,
36
34
  type SingleResult,
37
35
  type SubagentDetails,
38
36
  type SubagentLiveEvent,
@@ -57,6 +55,32 @@ function emptyUsage(): UsageStats {
57
55
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
58
56
  }
59
57
 
58
+ function queuedResult(agent: AgentConfig, task: string): SingleResult {
59
+ return {
60
+ agent: agent.name,
61
+ agentSource: agent.source,
62
+ task,
63
+ exitCode: -1,
64
+ messages: [],
65
+ stderr: "",
66
+ usage: emptyUsage(),
67
+ model: agent.model,
68
+ };
69
+ }
70
+
71
+ function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
72
+ return {
73
+ agent: agentName,
74
+ agentSource: "unknown",
75
+ task,
76
+ exitCode: 1,
77
+ messages: [],
78
+ stderr: errorMessage,
79
+ usage: emptyUsage(),
80
+ errorMessage,
81
+ };
82
+ }
83
+
60
84
  function aggregateUsage(results: SingleResult[]): UsageStats {
61
85
  const total = emptyUsage();
62
86
  for (const r of results) {
@@ -88,6 +112,8 @@ function formatUsage(usage: UsageStats): string {
88
112
 
89
113
  export default function (pi: ExtensionAPI): void {
90
114
  const configPath = getConfigPath(getAgentDir());
115
+ const backgroundQueue = new BackgroundTaskQueue(MAX_CONCURRENCY);
116
+ let sessionActive = true;
91
117
 
92
118
  // Recursion guard: child sub-agents are leaf processes and cannot delegate again.
93
119
  if (currentSubagentDepth() >= MAX_SUBAGENT_DEPTH) {
@@ -100,6 +126,19 @@ export default function (pi: ExtensionAPI): void {
100
126
  return;
101
127
  }
102
128
 
129
+ pi.registerMessageRenderer("subagent-result", (message, _options, theme) =>
130
+ new Text(
131
+ `${theme.fg("toolTitle", theme.bold("subagent result"))}\n${message.content}`,
132
+ 0,
133
+ 0,
134
+ ),
135
+ );
136
+
137
+ pi.on("session_shutdown", () => {
138
+ sessionActive = false;
139
+ backgroundQueue.cancelAll();
140
+ });
141
+
103
142
  pi.registerTool({
104
143
  name: "subagent",
105
144
  label: "Subagent",
@@ -107,17 +146,18 @@ export default function (pi: ExtensionAPI): void {
107
146
  "Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
108
147
  "Agents: explore (read-only codebase recon), plan (implementation plan, opt-in), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
109
148
  "Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
110
- "Use it to keep the main conversation clean: delegate the work, then orchestrate and verify the results yourself.",
111
- "Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
149
+ "It starts agents in the background and immediately returns control to the main window; completed results arrive in a later user prompt.",
150
+ "Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output)."
112
151
  ].join(" "),
113
152
  promptSnippet:
114
- "Delegate discrete tasks to isolated sub-agents: explore (read-only search), worker (implement), reviewer (adversarial pre-commit review); plan is opt-in.",
153
+ "Start background subagents: explore (read-only search), worker (implement), reviewer (adversarial review); completed results arrive in a later prompt.",
115
154
  promptGuidelines: [
116
155
  "Use subagent to delegate discrete, self-contained tasks so the main context stays clean; do orchestration and verification yourself.",
117
156
  "Use subagent with agent 'explore' for broad or open-ended code search before large changes.",
118
157
  "Use subagent with agent 'worker' to implement a well-scoped task; it plans internally.",
119
158
  "Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
120
- "Run independent tasks in parallel by passing a tasks array to subagent; keep dependent work sequential.",
159
+ "subagent launches work in the background and ends the current turn; do not assume a result is available until a later user prompt.",
160
+ "Run independent tasks in parallel by passing a tasks array to subagent; start dependent work only after its result arrives.",
121
161
  ],
122
162
  parameters: SubagentParams,
123
163
 
@@ -143,12 +183,13 @@ export default function (pi: ExtensionAPI): void {
143
183
  }
144
184
  }
145
185
 
146
- // Finished runs leave the widget immediately; the main window gets a
147
- // notification instead (the tool result remains the durable record).
186
+ // Finished runs leave the widget immediately. Their final findings arrive
187
+ // as a custom message before the next foreground prompt.
148
188
  const finishRun = (runId: number, status: "done" | "failed"): void => {
149
189
  monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
150
190
  const run = monitor.removeRun(runId);
151
191
  if (!run) return; // already finished — stay idempotent
192
+ if (!sessionActive) return;
152
193
  const icon = status === "done" ? "✓" : "✗";
153
194
  ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
154
195
  };
@@ -174,7 +215,8 @@ export default function (pi: ExtensionAPI): void {
174
215
  monitor.setActivity(runId, "thinking");
175
216
  break;
176
217
  case "text":
177
- monitor.setActivity(runId, "writing");
218
+ // A text delta is model output, not a filesystem write.
219
+ monitor.setActivity(runId, "responding");
178
220
  break;
179
221
  }
180
222
  };
@@ -194,8 +236,8 @@ export default function (pi: ExtensionAPI): void {
194
236
  const hasSingle = Boolean(params.agent && params.task);
195
237
 
196
238
  const makeDetails =
197
- (mode: "single" | "parallel") =>
198
- (results: SingleResult[]): SubagentDetails => ({ mode, results });
239
+ (mode: "single" | "parallel", background = false) =>
240
+ (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
199
241
 
200
242
  const catalog = agents.map((a) => a.name).join(", ") || "none";
201
243
 
@@ -211,124 +253,104 @@ export default function (pi: ExtensionAPI): void {
211
253
  };
212
254
  }
213
255
 
214
- // ---- Parallel mode ----
256
+ const startBackground = (agentName: string, task: string, cwd?: string): SingleResult => {
257
+ const agent = agents.find((candidate) => candidate.name === agentName);
258
+ if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
259
+
260
+ const pending = queuedResult(agent, task);
261
+ const runId = monitor.addRun(agent.name, agent.model);
262
+ const onLive = makeLiveHandler(runId);
263
+
264
+ backgroundQueue.enqueue(
265
+ async (backgroundSignal) => {
266
+ let result: SingleResult;
267
+ try {
268
+ result = await runSingleAgent({
269
+ defaultCwd: ctx.cwd,
270
+ agent,
271
+ agentName,
272
+ task,
273
+ cwd,
274
+ thinkingLevel: config.thinkingLevel,
275
+ signal: backgroundSignal,
276
+ onLive,
277
+ makeDetails: makeDetails("single", true),
278
+ });
279
+ } catch (error) {
280
+ const errorMessage = error instanceof Error ? error.message : String(error);
281
+ result = {
282
+ ...pending,
283
+ exitCode: 1,
284
+ stderr: errorMessage,
285
+ stopReason: backgroundSignal.aborted ? "aborted" : "error",
286
+ errorMessage,
287
+ };
288
+ finishRun(runId, "failed");
289
+ }
290
+
291
+ if (!sessionActive) return;
292
+ const status = isFailedResult(result) ? "failed" : "completed";
293
+ const usage = formatUsage(result.usage);
294
+ pi.sendMessage(
295
+ {
296
+ customType: "subagent-result",
297
+ content: `### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}\n\n${getResultOutput(result)}`,
298
+ display: true,
299
+ },
300
+ { deliverAs: "nextTurn" },
301
+ );
302
+ },
303
+ () => finishRun(runId, "failed"),
304
+ );
305
+
306
+ return pending;
307
+ };
308
+
309
+ // Sub-agents intentionally detach from the foreground turn. This makes the
310
+ // editor available immediately; completed findings arrive before the next prompt.
215
311
  if (params.tasks && params.tasks.length > 0) {
216
312
  if (params.tasks.length > MAX_PARALLEL_TASKS) {
217
313
  return {
218
314
  content: [
219
315
  { type: "text", text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.` },
220
316
  ],
221
- details: makeDetails("parallel")([]),
317
+ details: makeDetails("parallel", true)([]),
222
318
  };
223
319
  }
224
320
 
225
- const allResults: SingleResult[] = params.tasks.map((t) => ({
226
- agent: t.agent,
227
- agentSource: "unknown",
228
- task: t.task,
229
- exitCode: -1,
230
- messages: [],
231
- stderr: "",
232
- usage: emptyUsage(),
233
- }));
234
-
235
- const emitParallelUpdate = (): void => {
236
- if (!onUpdate) return;
237
- const done = allResults.filter((r) => r.exitCode !== -1).length;
238
- onUpdate({
239
- content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done...` }],
240
- details: makeDetails("parallel")([...allResults]),
241
- });
242
- };
243
-
244
- const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
245
- const resolvedModel = agents.find((a) => a.name === t.agent)?.model;
246
- const runId = monitor.addRun(t.agent, resolvedModel);
247
- const onLive = makeLiveHandler(runId);
248
- const perTaskUpdate: OnUpdateCallback | undefined = onUpdate
249
- ? (partial) => {
250
- const current = partial.details?.results[0];
251
- if (current) {
252
- allResults[index] = current;
253
- emitParallelUpdate();
254
- }
255
- }
256
- : undefined;
257
- let result: SingleResult;
258
- try {
259
- result = await runSingleAgent({
260
- defaultCwd: ctx.cwd,
261
- agent: agents.find((a) => a.name === t.agent),
262
- agentName: t.agent,
263
- task: t.task,
264
- cwd: t.cwd,
265
- thinkingLevel: config.thinkingLevel,
266
- signal,
267
- onUpdate: perTaskUpdate,
268
- onLive,
269
- makeDetails: makeDetails("parallel"),
270
- });
271
- } catch (err) {
272
- finishRun(runId, "failed");
273
- throw err;
274
- }
275
- allResults[index] = result;
276
- emitParallelUpdate();
277
- return result;
278
- });
279
-
280
- const successCount = results.filter((r) => !isFailedResult(r)).length;
281
- const summaries = results.map((r) => {
282
- const output = getResultOutput(r);
283
- const status = isFailedResult(r) ? "failed" : "completed";
284
- const usage = formatUsage(r.usage);
285
- return `### [${r.agent}] ${status}${usage ? ` (${usage})` : ""}\n\n${output}`;
286
- });
321
+ const results = params.tasks.map((task) => startBackground(task.agent, task.task, task.cwd));
322
+ const started = results.filter((result) => result.exitCode === -1).length;
323
+ const failures = results.filter((result) => result.exitCode !== -1);
287
324
  return {
288
325
  content: [
289
326
  {
290
327
  type: "text",
291
- text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
328
+ text:
329
+ started > 0
330
+ ? `Started ${started} background subagent${started === 1 ? "" : "s"}. Completed results will be added before a later user prompt.`
331
+ : failures.map((result) => getResultOutput(result)).join("\n"),
292
332
  },
293
333
  ],
294
- details: makeDetails("parallel")(results),
334
+ details: makeDetails("parallel", true)(results),
335
+ isError: failures.length > 0,
336
+ terminate: true,
295
337
  };
296
338
  }
297
339
 
298
- // ---- Single mode ----
299
- const resolvedModel = agents.find((a) => a.name === params.agent)?.model;
300
- const runId = monitor.addRun(params.agent as string, resolvedModel);
301
- const onLive = makeLiveHandler(runId);
302
- let result: SingleResult;
303
- try {
304
- result = await runSingleAgent({
305
- defaultCwd: ctx.cwd,
306
- agent: agents.find((a) => a.name === params.agent),
307
- agentName: params.agent as string,
308
- task: params.task as string,
309
- cwd: params.cwd,
310
- thinkingLevel: config.thinkingLevel,
311
- signal,
312
- onUpdate,
313
- onLive,
314
- makeDetails: makeDetails("single"),
315
- });
316
- } catch (err) {
317
- finishRun(runId, "failed");
318
- throw err;
319
- }
320
-
321
- if (isFailedResult(result)) {
340
+ const result = startBackground(params.agent as string, params.task as string, params.cwd);
341
+ if (result.exitCode !== -1) {
322
342
  return {
323
- content: [{ type: "text", text: `Agent ${result.agent} ${result.stopReason || "failed"}: ${getResultOutput(result)}` }],
343
+ content: [{ type: "text", text: getResultOutput(result) }],
324
344
  details: makeDetails("single")([result]),
325
345
  isError: true,
326
346
  };
327
347
  }
328
348
  return {
329
- content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
330
- details: makeDetails("single")([result]),
349
+ content: [{ type: "text", text: `Started ${result.agent} in the background. Its completed result will be added before a later user prompt.` }],
350
+ details: makeDetails("single", true)([result]),
351
+ terminate: true,
331
352
  };
353
+
332
354
  },
333
355
 
334
356
  renderCall(args, theme) {
@@ -356,10 +378,11 @@ export default function (pi: ExtensionAPI): void {
356
378
 
357
379
  if (details.mode === "single") {
358
380
  const r = details.results[0];
359
- const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
381
+ const pending = r.exitCode === -1;
382
+ const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
360
383
  const usage = formatUsage(r.usage);
361
384
  const model = r.model ?? "?";
362
- const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`;
385
+ const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
363
386
  return new Text(line, 0, 0);
364
387
  }
365
388
 
@@ -368,10 +391,11 @@ export default function (pi: ExtensionAPI): void {
368
391
  `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
369
392
  ];
370
393
  for (const r of details.results) {
371
- const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
394
+ const pending = r.exitCode === -1;
395
+ const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
372
396
  const usage = formatUsage(r.usage);
373
397
  const model = r.model ?? "?";
374
- lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`);
398
+ lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
375
399
  }
376
400
  return new Text(lines.join("\n"), 0, 0);
377
401
  },
package/src/prompt.ts CHANGED
@@ -37,18 +37,19 @@ export function buildDelegationDirective(agents: AgentConfig[]): string {
37
37
  return `
38
38
  ## Sub-agent delegation (pi-subagents)
39
39
 
40
- You have a \`subagent\` tool that runs specialized agents in ISOLATED context windows.
41
- Delegate discrete, self-contained tasks to it instead of doing everything inline, so the
42
- main window stays focused on orchestration, synthesis, and verification.
40
+ You have a \`subagent\` tool that starts specialized agents in ISOLATED background processes.
41
+ It immediately ends the current main-agent turn so the user can keep working. Completed
42
+ findings are added before a later user prompt; do not assume a launched result is available
43
+ in the same turn.
43
44
 
44
45
  Available agents:
45
46
  ${catalog}
46
47
 
47
48
  ${routing ? `Routing:\n${routing}\n` : ""}Dispatch discipline:
48
- - Default to delegating every discrete task to a sub-agent; do the orchestration and verification yourself in the main window.
49
+ - Default to delegating every discrete task to a sub-agent; use completed findings in a later user turn for orchestration and verification.
49
50
  - Only handle inline: pure Q&A, a single trivial edit/lookup, or when the user explicitly says to do it directly. When in doubt, delegate.
50
51
  - For an already-known or trivial target, use a direct search/read tool (e.g. grep/find/read) — do not over-delegate a one-line lookup.
51
- ${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `tasks` array, and track them with your todo list. Keep dependent work sequential (e.g. explore, then worker, then reviewer).\n" : ""}- Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
52
+ ${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `tasks` array, and track them with your todo list. Launch dependent work only after its prerequisite result arrives (e.g. explore, then worker, then reviewer).\n" : ""}- Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
52
53
  - Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool.
53
54
  - Trust but verify: a sub-agent's summary describes intent, not outcome. Check the actual changes/results before reporting work done.
54
55
 
package/src/spawn.ts CHANGED
@@ -27,8 +27,8 @@ export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
27
27
  /** Child processes are leaf agents: they never receive the subagent tool. */
28
28
  export const MAX_SUBAGENT_DEPTH = 1;
29
29
  export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
30
- /** Absolute limit so a child that stops emitting events cannot hang the parent forever. */
31
- export const SUBAGENT_TIMEOUT_MS = 10 * 60 * 1000;
30
+ /** No default deadline: sub-agents may run until completion or explicit cancellation. */
31
+ export const SUBAGENT_TIMEOUT_MS = 0;
32
32
  export const SUBAGENT_KILL_GRACE_MS = 5_000;
33
33
 
34
34
  export interface UsageStats {
@@ -57,6 +57,8 @@ export interface SingleResult {
57
57
  export interface SubagentDetails {
58
58
  mode: "single" | "parallel";
59
59
  results: SingleResult[];
60
+ /** The tool returned immediately while the child process continues in the background. */
61
+ background?: boolean;
60
62
  }
61
63
 
62
64
  export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
@@ -179,7 +181,7 @@ export interface RunSingleOptions {
179
181
  cwd?: string;
180
182
  /** Thinking level passed to the child pi process. */
181
183
  thinkingLevel?: ThinkingLevel;
182
- /** Override the watchdog timeout; intended for tests and controlled callers. */
184
+ /** Optional timeout; zero (the default) disables it. Intended for tests and controlled callers. */
183
185
  timeoutMs?: number;
184
186
  signal?: AbortSignal;
185
187
  onUpdate?: OnUpdateCallback;