@clanker-code/pi-subagents 0.10.7 → 0.11.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.
@@ -23,7 +23,7 @@ export function snipMiddleLines(text, edgeLines = 20) {
23
23
  const omitted = lines.length - maxLines;
24
24
  return [
25
25
  ...lines.slice(0, edgeLines),
26
- `... ${omitted} lines omitted; expand for full output ...`,
26
+ `─────── ${omitted} lines hidden from preview ───────`,
27
27
  ...lines.slice(-edgeLines),
28
28
  ];
29
29
  }
@@ -1,5 +1,6 @@
1
1
  import { truncateToWidth } from "@earendil-works/pi-tui";
2
2
  import { getConfig } from "../agent-types.js";
3
+ import { getSessionTokens } from "../usage.js";
3
4
  function statusRank(status) {
4
5
  if (status === "running")
5
6
  return 0;
@@ -55,6 +56,14 @@ function formatElapsed(startedAt, now) {
55
56
  const rest = seconds % 60;
56
57
  return rest > 0 ? `${minutes}m ${rest}s` : `${minutes}m`;
57
58
  }
59
+ /** Compact token count for widget rows: "12.3k tok", "1.2M tok". */
60
+ function formatCompactTokens(count) {
61
+ if (count >= 1_000_000)
62
+ return `${(count / 1_000_000).toFixed(1)}M tok`;
63
+ if (count >= 1_000)
64
+ return `${(count / 1_000).toFixed(1)}k tok`;
65
+ return `${count} tok`;
66
+ }
58
67
  function statusIcon(snapshot, frame, theme) {
59
68
  if (snapshot.status === "running")
60
69
  return theme.fg("accent", frame);
@@ -62,6 +71,8 @@ function statusIcon(snapshot, frame, theme) {
62
71
  return theme.fg("muted", "◦");
63
72
  if (snapshot.status === "completed")
64
73
  return theme.fg("success", "✓");
74
+ if (snapshot.status === "steered")
75
+ return theme.fg("warning", "✓");
65
76
  if (snapshot.status === "stopped")
66
77
  return theme.fg("dim", "■");
67
78
  return theme.fg("error", "✗");
@@ -77,13 +88,19 @@ function collectRows(nodes, options, mode, prefix = "") {
77
88
  const childPrefix = prefix + (isLast ? " " : "│ ");
78
89
  const s = node.snapshot;
79
90
  const name = displayName(s.type);
80
- const elapsed = formatElapsed(s.startedAt, options.now ?? Date.now());
91
+ const elapsedUntil = s.status === "running" || s.status === "queued" ? (options.now ?? Date.now()) : (s.completedAt ?? options.now ?? Date.now());
92
+ const elapsed = formatElapsed(s.startedAt, elapsedUntil);
81
93
  const stats = [];
82
94
  if (s.activity?.turnCount)
83
95
  stats.push(`↻${s.activity.turnCount}`);
84
96
  if (s.toolUses > 0)
85
97
  stats.push(`${s.toolUses} tool${s.toolUses === 1 ? "" : "s"}`);
86
98
  stats.push(elapsed);
99
+ if (s.activity?.session) {
100
+ const tokens = getSessionTokens(s.activity.session);
101
+ if (tokens > 0)
102
+ stats.push(formatCompactTokens(tokens));
103
+ }
87
104
  const orphan = node.orphaned ? " ⚠ orphan" : "";
88
105
  const error = s.error ? ` error: ${s.error}` : "";
89
106
  rows.push(`${prefix}${connector} ${statusIcon(s, options.frame, options.theme)} ${options.theme.bold(name)} ${options.theme.fg("muted", s.description)} ${options.theme.fg("dim", `· ${stats.join(" · ")}${orphan}${error}`)}`);
@@ -126,5 +143,5 @@ export function renderAgentTree(records, options) {
126
143
  ? options.theme.fg("dim", ` ${active} running · ${queued} queued · depth ${maxDepth}/4`)
127
144
  : "";
128
145
  const heading = `${active > 0 ? options.theme.fg("accent", "●") : options.theme.fg("dim", "○")} ${options.theme.fg(active > 0 ? "accent" : "dim", "Agents")}${suffix}`;
129
- return applyOverflow([heading, ...rows], options.maxLines, options.width);
146
+ return applyOverflow([heading, ...rows], options.maxLines, options.width, mode === "rich" ? "lines" : "agents");
130
147
  }
@@ -113,8 +113,14 @@ export declare class AgentWidget {
113
113
  private widgetInterval;
114
114
  /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
115
115
  private finishedTurnAge;
116
+ /** Tracks wall-clock finish time so long-running turns cannot keep completed rows forever. */
117
+ private finishedAt;
116
118
  /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
117
119
  private static readonly ERROR_LINGER_TURNS;
120
+ /** Max wall-clock linger for successful completions when no new parent turn starts. */
121
+ private static readonly COMPLETED_LINGER_MS;
122
+ /** Max wall-clock linger for non-success outcomes when no new parent turn starts. */
123
+ private static readonly ERROR_LINGER_MS;
118
124
  /** Whether the widget callback is currently registered with the TUI. */
119
125
  private widgetRegistered;
120
126
  /** Cached TUI reference from widget factory callback, used for requestRender(). */
@@ -142,7 +148,7 @@ export declare class AgentWidget {
142
148
  /** Check if a finished agent should still be shown in the widget. */
143
149
  private shouldShowFinished;
144
150
  /** Record an agent as finished (call when agent completes). */
145
- markFinished(agentId: string): void;
151
+ markFinished(agentId: string, completedAt?: number): void;
146
152
  private recordToSnapshot;
147
153
  private visibleSnapshots;
148
154
  /**
@@ -14,7 +14,7 @@ const DEFAULT_STATUS_TEXT_WIDTH = 20;
14
14
  /** Braille spinner frames for animated running indicator. */
15
15
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
16
  /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
17
- export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
17
+ export const ERROR_STATUSES = new Set(["error", "aborted", "stopped"]);
18
18
  /** Tool name → human-readable action for activity descriptions. */
19
19
  const TOOL_DISPLAY = {
20
20
  read: "reading",
@@ -193,8 +193,14 @@ export class AgentWidget {
193
193
  widgetInterval;
194
194
  /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
195
195
  finishedTurnAge = new Map();
196
+ /** Tracks wall-clock finish time so long-running turns cannot keep completed rows forever. */
197
+ finishedAt = new Map();
196
198
  /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
197
199
  static ERROR_LINGER_TURNS = 2;
200
+ /** Max wall-clock linger for successful completions when no new parent turn starts. */
201
+ static COMPLETED_LINGER_MS = 30_000;
202
+ /** Max wall-clock linger for non-success outcomes when no new parent turn starts. */
203
+ static ERROR_LINGER_MS = 120_000;
198
204
  /** Whether the widget callback is currently registered with the TUI. */
199
205
  widgetRegistered = false;
200
206
  /** Cached TUI reference from widget factory callback, used for requestRender(). */
@@ -215,17 +221,25 @@ export class AgentWidget {
215
221
  }
216
222
  upsertSnapshot(snapshot) {
217
223
  this.descendantSnapshots.set(snapshot.id, snapshot);
218
- if (snapshot.status !== "running" && snapshot.status !== "queued") {
219
- this.markFinished(snapshot.id);
224
+ if (snapshot.status === "running" || snapshot.status === "queued") {
225
+ this.finishedTurnAge.delete(snapshot.id);
226
+ this.finishedAt.delete(snapshot.id);
227
+ }
228
+ else {
229
+ this.markFinished(snapshot.id, snapshot.completedAt);
220
230
  }
221
231
  this.update();
222
232
  }
223
233
  removeSnapshot(id) {
224
234
  this.descendantSnapshots.delete(id);
235
+ this.finishedTurnAge.delete(id);
236
+ this.finishedAt.delete(id);
225
237
  this.update();
226
238
  }
227
239
  clearSnapshots() {
228
240
  this.descendantSnapshots.clear();
241
+ this.finishedTurnAge.clear();
242
+ this.finishedAt.clear();
229
243
  this.update();
230
244
  }
231
245
  /** Set the UI context (grabbed from first tool execution). */
@@ -258,16 +272,25 @@ export class AgentWidget {
258
272
  }
259
273
  }
260
274
  /** Check if a finished agent should still be shown in the widget. */
261
- shouldShowFinished(agentId, status) {
275
+ shouldShowFinished(agentId, status, completedAt) {
262
276
  const age = this.finishedTurnAge.get(agentId) ?? 0;
263
277
  const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
264
- return age < maxAge;
278
+ if (age >= maxAge)
279
+ return false;
280
+ const finishedAt = this.finishedAt.get(agentId) ?? completedAt;
281
+ if (finishedAt == null)
282
+ return true;
283
+ const maxMs = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_MS : AgentWidget.COMPLETED_LINGER_MS;
284
+ return Date.now() - finishedAt < maxMs;
265
285
  }
266
286
  /** Record an agent as finished (call when agent completes). */
267
- markFinished(agentId) {
287
+ markFinished(agentId, completedAt = Date.now()) {
268
288
  if (!this.finishedTurnAge.has(agentId)) {
269
289
  this.finishedTurnAge.set(agentId, 0);
270
290
  }
291
+ if (!this.finishedAt.has(agentId)) {
292
+ this.finishedAt.set(agentId, completedAt);
293
+ }
271
294
  }
272
295
  recordToSnapshot(a) {
273
296
  const activity = this.agentActivity.get(a.id);
@@ -290,13 +313,25 @@ export class AgentWidget {
290
313
  const merged = new Map(this.descendantSnapshots);
291
314
  const allAgents = this.manager.listAgents();
292
315
  for (const a of allAgents) {
293
- if (a.status === "running" || a.status === "queued" || (a.completedAt && this.shouldShowFinished(a.id, a.status))) {
316
+ if (a.status === "running" || a.status === "queued") {
317
+ this.finishedTurnAge.delete(a.id);
318
+ this.finishedAt.delete(a.id);
319
+ merged.set(a.id, this.recordToSnapshot(a));
320
+ }
321
+ else if (a.completedAt && this.shouldShowFinished(a.id, a.status, a.completedAt)) {
294
322
  merged.set(a.id, this.recordToSnapshot(a));
295
323
  }
296
324
  }
325
+ const liveRecordIds = new Set(allAgents.map(a => a.id));
297
326
  for (const [id, snapshot] of merged) {
298
- if (snapshot.status !== "running" && snapshot.status !== "queued" && snapshot.completedAt && !this.shouldShowFinished(id, snapshot.status)) {
327
+ if (snapshot.status !== "running" && snapshot.status !== "queued" && !this.shouldShowFinished(id, snapshot.status, snapshot.completedAt)) {
299
328
  merged.delete(id);
329
+ if (this.descendantSnapshots.has(id))
330
+ this.descendantSnapshots.delete(id);
331
+ if (!liveRecordIds.has(id)) {
332
+ this.finishedTurnAge.delete(id);
333
+ this.finishedAt.delete(id);
334
+ }
300
335
  }
301
336
  }
302
337
  return [...merged.values()];
@@ -334,7 +369,7 @@ export class AgentWidget {
334
369
  else if (a.status === "queued") {
335
370
  queuedCount++;
336
371
  }
337
- else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) {
372
+ else if (this.shouldShowFinished(a.id, a.status, a.completedAt)) {
338
373
  hasFinished = true;
339
374
  }
340
375
  }
@@ -356,11 +391,14 @@ export class AgentWidget {
356
391
  }
357
392
  // Clean up stale entries
358
393
  for (const [id] of this.finishedTurnAge) {
359
- if (!allAgents.some(a => a.id === id))
394
+ if (!allAgents.some(a => a.id === id) && !this.descendantSnapshots.has(id)) {
360
395
  this.finishedTurnAge.delete(id);
396
+ this.finishedAt.delete(id);
397
+ }
361
398
  }
362
399
  return;
363
400
  }
401
+ this.ensureTimer();
364
402
  // Status bar — only call setStatus when the text actually changes
365
403
  const statusWidth = this.tui?.terminal.columns ?? DEFAULT_STATUS_TEXT_WIDTH;
366
404
  const newStatusText = hasActive
@@ -403,6 +441,10 @@ export class AgentWidget {
403
441
  }
404
442
  this.widgetRegistered = false;
405
443
  this.tui = undefined;
444
+ this.uiCtx = undefined;
406
445
  this.lastStatusText = undefined;
446
+ this.descendantSnapshots.clear();
447
+ this.finishedTurnAge.clear();
448
+ this.finishedAt.clear();
407
449
  }
408
450
  }
package/dist/wait.d.ts CHANGED
@@ -1,10 +1,24 @@
1
- export type WaitOutcome = "completed" | "timeout" | "aborted";
1
+ export type WaitOutcome = "completed" | "timeout" | "aborted" | "pending_message";
2
2
  /** Human-readable "Xm Ys" for a duration in seconds. */
3
3
  export declare function formatWaitTimeout(seconds: number): string;
4
4
  /**
5
- * Race an agent completion promise against the configured wait timeout and the
6
- * parent abort signal. The subagent is never aborted here.
5
+ * Race an agent completion promise against the configured wait timeout, the
6
+ * parent abort signal, and an optional pending-message check. The subagent is
7
+ * never aborted here.
8
+ *
9
+ * @param pendingCheck - Optional promise that resolves when the parent session
10
+ * has queued user messages waiting to be delivered. When it resolves, the
11
+ * wait ends early so the parent turn can process the incoming message.
7
12
  */
8
- export declare function raceWait(promise: Promise<string>, signal: AbortSignal | undefined, timeoutSeconds: number): Promise<WaitOutcome>;
13
+ export declare function raceWait(promise: Promise<string>, signal: AbortSignal | undefined, timeoutSeconds: number, pendingCheck?: Promise<void>): Promise<WaitOutcome>;
9
14
  /** Message returned when a wait ends with the agent still running. */
10
15
  export declare function waitTimeoutMessage(outcome: WaitOutcome, timeoutSeconds: number): string;
16
+ /**
17
+ * Create a promise that resolves when the parent session has queued user
18
+ * messages. Polls at the given interval until `hasPendingMessages()` returns
19
+ * true. The caller should race this against the agent completion / timeout.
20
+ */
21
+ export declare function pollPendingMessages(hasPendingMessages: () => boolean, intervalMs?: number): {
22
+ promise: Promise<void>;
23
+ cancel: () => void;
24
+ };
package/dist/wait.js CHANGED
@@ -5,10 +5,15 @@ export function formatWaitTimeout(seconds) {
5
5
  return m > 0 ? `${m}m${s > 0 ? ` ${s}s` : ""}` : `${s}s`;
6
6
  }
7
7
  /**
8
- * Race an agent completion promise against the configured wait timeout and the
9
- * parent abort signal. The subagent is never aborted here.
8
+ * Race an agent completion promise against the configured wait timeout, the
9
+ * parent abort signal, and an optional pending-message check. The subagent is
10
+ * never aborted here.
11
+ *
12
+ * @param pendingCheck - Optional promise that resolves when the parent session
13
+ * has queued user messages waiting to be delivered. When it resolves, the
14
+ * wait ends early so the parent turn can process the incoming message.
10
15
  */
11
- export function raceWait(promise, signal, timeoutSeconds) {
16
+ export function raceWait(promise, signal, timeoutSeconds, pendingCheck) {
12
17
  return new Promise((resolve) => {
13
18
  let settled = false;
14
19
  const finish = (outcome) => {
@@ -23,6 +28,7 @@ export function raceWait(promise, signal, timeoutSeconds) {
23
28
  const onAbort = () => finish("aborted");
24
29
  signal?.addEventListener("abort", onAbort, { once: true });
25
30
  promise.then(() => finish("completed"));
31
+ pendingCheck?.then(() => finish("pending_message"));
26
32
  });
27
33
  }
28
34
  /** Message returned when a wait ends with the agent still running. */
@@ -33,5 +39,44 @@ export function waitTimeoutMessage(outcome, timeoutSeconds) {
33
39
  if (outcome === "aborted") {
34
40
  return `Agent is still running. The wait was cancelled by the user (parent turn aborted). The subagent was NOT stopped — it continues in the background.\nCall get_subagent_result with wait: true again to keep waiting, use peek to check progress, or omit wait to check status.`;
35
41
  }
42
+ if (outcome === "pending_message") {
43
+ return `Agent is still running. The wait was interrupted by an incoming user message. The subagent was NOT stopped — it continues in the background.\nThe queued message will be delivered after this tool returns.\nCall get_subagent_result with wait: true again to keep waiting, use peek to check progress, or omit wait to check status.`;
44
+ }
36
45
  return "Agent is still running. Use peek to check recent progress, wait: true to block until it finishes, or check back later.";
37
46
  }
47
+ /**
48
+ * Create a promise that resolves when the parent session has queued user
49
+ * messages. Polls at the given interval until `hasPendingMessages()` returns
50
+ * true. The caller should race this against the agent completion / timeout.
51
+ */
52
+ export function pollPendingMessages(hasPendingMessages, intervalMs = 1000) {
53
+ let settled = false;
54
+ let resolve;
55
+ const promise = new Promise((r) => { resolve = r; });
56
+ // Check immediately in case a message arrived between the tool call
57
+ // start and this poll setup.
58
+ if (hasPendingMessages()) {
59
+ settled = true;
60
+ resolve();
61
+ return { promise, cancel: () => { } };
62
+ }
63
+ const timer = setInterval(() => {
64
+ if (settled)
65
+ return;
66
+ if (hasPendingMessages()) {
67
+ settled = true;
68
+ clearInterval(timer);
69
+ resolve();
70
+ }
71
+ }, intervalMs);
72
+ return {
73
+ promise,
74
+ cancel: () => {
75
+ if (!settled) {
76
+ settled = true;
77
+ clearInterval(timer);
78
+ resolve();
79
+ }
80
+ },
81
+ };
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clanker-code/pi-subagents",
3
- "version": "0.10.7",
3
+ "version": "0.11.0",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "clankercode",
6
6
  "license": "MIT",
@@ -126,6 +126,10 @@ export class AgentManager {
126
126
  private queue: { id: string; args: SpawnArgs }[] = [];
127
127
  /** Number of currently running background agents. */
128
128
  private runningBackground = 0;
129
+ /** Background agents by id; foreground agents must not emit background completion callbacks. */
130
+ private backgroundAgentIds = new Set<string>();
131
+ /** Background terminal callbacks already emitted; prevents abort/settle double delivery. */
132
+ private completedBackgroundCallbacks = new Set<string>();
129
133
 
130
134
  constructor(
131
135
  onComplete?: OnAgentComplete,
@@ -194,6 +198,7 @@ export class AgentManager {
194
198
  options.onOutputFileCreated?.(record.outputFile, id);
195
199
  }
196
200
  this.agents.set(id, record);
201
+ if (options.isBackground) this.backgroundAgentIds.add(id);
197
202
 
198
203
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
199
204
 
@@ -208,12 +213,22 @@ export class AgentManager {
208
213
  try {
209
214
  this.startAgent(id, record, args);
210
215
  } catch (err) {
216
+ this.backgroundAgentIds.delete(id);
211
217
  this.agents.delete(id);
212
218
  throw err;
213
219
  }
214
220
  return id;
215
221
  }
216
222
 
223
+ /** Emit background completion once, optionally releasing a running concurrency slot. */
224
+ private completeBackground(record: AgentRecord, releaseRunningSlot: boolean, drain = true): void {
225
+ if (this.completedBackgroundCallbacks.has(record.id)) return;
226
+ this.completedBackgroundCallbacks.add(record.id);
227
+ if (releaseRunningSlot) this.runningBackground = Math.max(0, this.runningBackground - 1);
228
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
229
+ if (drain) this.drainQueue();
230
+ }
231
+
217
232
  /** Actually start an agent (called immediately or from queue drain). */
218
233
  private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
219
234
  // Re-validate a caller-supplied cwd: queued spawns can start minutes after
@@ -338,9 +353,7 @@ export class AgentManager {
338
353
  }
339
354
 
340
355
  if (options.isBackground) {
341
- this.runningBackground--;
342
- try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
343
- this.drainQueue();
356
+ this.completeBackground(record, true);
344
357
  }
345
358
  return responseText;
346
359
  })
@@ -369,9 +382,7 @@ export class AgentManager {
369
382
  }
370
383
 
371
384
  if (options.isBackground) {
372
- this.runningBackground--;
373
- this.onComplete?.(record);
374
- this.drainQueue();
385
+ this.completeBackground(record, true);
375
386
  }
376
387
  return "";
377
388
  });
@@ -393,7 +404,7 @@ export class AgentManager {
393
404
  record.status = "error";
394
405
  record.error = err instanceof Error ? err.message : String(err);
395
406
  record.completedAt = Date.now();
396
- this.onComplete?.(record);
407
+ this.completeBackground(record, false, false);
397
408
  }
398
409
  }
399
410
  }
@@ -434,6 +445,8 @@ export class AgentManager {
434
445
  record.error = undefined;
435
446
  record.resultConsumed = false;
436
447
  record.abortController = new AbortController();
448
+ this.backgroundAgentIds.add(id);
449
+ this.completedBackgroundCallbacks.delete(id);
437
450
  this.runningBackground++;
438
451
  this.onStart?.(record);
439
452
 
@@ -463,18 +476,14 @@ export class AgentManager {
463
476
  record.result = responseText;
464
477
  record.completedAt = Date.now();
465
478
  detach();
466
- this.runningBackground--;
467
- try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
468
- this.drainQueue();
479
+ this.completeBackground(record, true);
469
480
  return responseText;
470
481
  }).catch((err) => {
471
482
  if (record.status !== "stopped") record.status = "error";
472
483
  record.error = err instanceof Error ? err.message : String(err);
473
484
  record.completedAt = Date.now();
474
485
  detach();
475
- this.runningBackground--;
476
- try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
477
- this.drainQueue();
486
+ this.completeBackground(record, true);
478
487
  return "";
479
488
  });
480
489
 
@@ -501,6 +510,7 @@ export class AgentManager {
501
510
  this.queue = this.queue.filter(q => q.id !== id);
502
511
  record.status = "stopped";
503
512
  record.completedAt = Date.now();
513
+ this.completeBackground(record, false);
504
514
  return true;
505
515
  }
506
516
 
@@ -508,6 +518,7 @@ export class AgentManager {
508
518
  record.abortController?.abort();
509
519
  record.status = "stopped";
510
520
  record.completedAt = Date.now();
521
+ if (this.backgroundAgentIds.has(id)) this.completeBackground(record, true);
511
522
  return true;
512
523
  }
513
524
 
@@ -515,9 +526,24 @@ export class AgentManager {
515
526
  private removeRecord(id: string, record: AgentRecord): void {
516
527
  record.session?.dispose?.();
517
528
  record.session = undefined;
529
+ this.backgroundAgentIds.delete(id);
530
+ this.completedBackgroundCallbacks.delete(id);
518
531
  this.agents.delete(id);
519
532
  }
520
533
 
534
+ /** Remove selected terminal records. Running and queued records are never removed. */
535
+ clearRecords(ids: string[]): string[] {
536
+ const removed: string[] = [];
537
+ for (const id of ids) {
538
+ const record = this.agents.get(id);
539
+ if (!record) continue;
540
+ if (record.status === "running" || record.status === "queued") continue;
541
+ this.removeRecord(id, record);
542
+ removed.push(id);
543
+ }
544
+ return removed;
545
+ }
546
+
521
547
  private cleanup() {
522
548
  const cutoff = Date.now() - 10 * 60_000;
523
549
  for (const [id, record] of this.agents) {
@@ -554,6 +580,7 @@ export class AgentManager {
554
580
  if (record) {
555
581
  record.status = "stopped";
556
582
  record.completedAt = Date.now();
583
+ if (this.backgroundAgentIds.has(record.id)) this.completedBackgroundCallbacks.add(record.id);
557
584
  count++;
558
585
  }
559
586
  }
@@ -564,9 +591,11 @@ export class AgentManager {
564
591
  record.abortController?.abort();
565
592
  record.status = "stopped";
566
593
  record.completedAt = Date.now();
594
+ if (this.backgroundAgentIds.has(record.id)) this.completedBackgroundCallbacks.add(record.id);
567
595
  count++;
568
596
  }
569
597
  }
598
+ this.runningBackground = 0;
570
599
  return count;
571
600
  }
572
601
 
@@ -593,6 +622,8 @@ export class AgentManager {
593
622
  record.session?.dispose();
594
623
  }
595
624
  this.agents.clear();
625
+ this.backgroundAgentIds.clear();
626
+ this.completedBackgroundCallbacks.clear();
596
627
  // Prune any orphaned git worktrees (crash recovery)
597
628
  try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
598
629
  // Also prune repos that caller-supplied cwds created worktrees in — a clean
@@ -36,6 +36,8 @@ export const SUBAGENT_TOOL_NAMES = {
36
36
  AGENT: "Agent",
37
37
  GET_RESULT: "get_subagent_result",
38
38
  STEER: "steer_subagent",
39
+ LIST_SUBAGENTS: "list_subagents",
40
+ CLEAR_SUBAGENTS: "clear_subagents",
39
41
  LIST_MODELS: "list_models",
40
42
  } as const;
41
43
 
@@ -69,7 +71,12 @@ function getLoadingExtensionAgentId(): string | undefined {
69
71
  return value && typeof value.agentId === "string" ? value.agentId : undefined;
70
72
  }
71
73
 
72
- async function withLoadingExtensionDepth<T>(depth: number, agentId: string | undefined, fn: () => Promise<T>): Promise<T> {
74
+ function getLoadingExtensionParentAgentId(): string | undefined {
75
+ const value = (globalThis as any)[EXTENSION_DEPTH_KEY];
76
+ return value && typeof value.parentAgentId === "string" ? value.parentAgentId : undefined;
77
+ }
78
+
79
+ async function withLoadingExtensionDepth<T>(depth: number, agentId: string | undefined, parentAgentId: string | undefined, fn: () => Promise<T>): Promise<T> {
73
80
  const previous = extensionDepthLoadChain;
74
81
  let release!: () => void;
75
82
  extensionDepthLoadChain = new Promise<void>((resolve) => {
@@ -80,7 +87,7 @@ async function withLoadingExtensionDepth<T>(depth: number, agentId: string | und
80
87
  try {
81
88
  const g = globalThis as any;
82
89
  const prev = g[EXTENSION_DEPTH_KEY];
83
- g[EXTENSION_DEPTH_KEY] = { depth, agentId };
90
+ g[EXTENSION_DEPTH_KEY] = { depth, agentId, parentAgentId };
84
91
  try {
85
92
  return await fn();
86
93
  } finally {
@@ -100,6 +107,10 @@ export function getCurrentExtensionAgentId(): string | undefined {
100
107
  return getLoadingExtensionAgentId();
101
108
  }
102
109
 
110
+ export function getCurrentExtensionParentAgentId(): string | undefined {
111
+ return getLoadingExtensionParentAgentId();
112
+ }
113
+
103
114
  /**
104
115
  * Canonical name of an extension for `extensions: [...]` allowlist matching.
105
116
  * Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
@@ -557,7 +568,7 @@ export async function runAgent(
557
568
  systemPromptOverride: () => systemPrompt,
558
569
  appendSystemPromptOverride: () => [],
559
570
  });
560
- await withLoadingExtensionDepth(depth, options.agentId, () => loader.reload());
571
+ await withLoadingExtensionDepth(depth, options.agentId, options.parentAgentId, () => loader.reload());
561
572
 
562
573
  // Plain entries in `tools:` are expected to be built-in names (extension tools
563
574
  // go through `ext:`), so an unknown name there is unambiguously a typo. Previously