@ryan_nookpi/pi-extension-subagent 0.5.4 → 0.5.5

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/commands.ts CHANGED
@@ -35,6 +35,12 @@ import {
35
35
  shouldSummarizeSubagentTask,
36
36
  summarizeSubagentDisplayTask,
37
37
  } from "./display-task.js";
38
+ import {
39
+ classifySubagentFailure,
40
+ getSubagentRunStatusLabel,
41
+ getSubagentTerminalLabel,
42
+ isSubagentAbort,
43
+ } from "./failure-telemetry.js";
38
44
  import {
39
45
  AGENT_NAME_PALETTE,
40
46
  agentBgIndex,
@@ -215,10 +221,11 @@ class SubagentHistoryOverlay {
215
221
  const marker = isSelected ? "▸" : " ";
216
222
 
217
223
  // Status color
224
+ const statusLabel = getSubagentRunStatusLabel(run.status, run.errorClass);
218
225
  let statusColor: "success" | "error" | "warning" | "dim" = "dim";
219
- if (run.status === "done") statusColor = "success";
220
- else if (run.status === "error") statusColor = "error";
221
- else if (run.status === "running") statusColor = "warning";
226
+ if (statusLabel === "done") statusColor = "success";
227
+ else if (statusLabel === "error") statusColor = "error";
228
+ else if (statusLabel === "running" || statusLabel === "aborted") statusColor = "warning";
222
229
 
223
230
  const timeLabel = new Date(run.startedAt).toLocaleTimeString([], {
224
231
  hour: "2-digit",
@@ -227,7 +234,7 @@ class SubagentHistoryOverlay {
227
234
  });
228
235
 
229
236
  const removedBadge = run.removed ? theme.fg("dim", " [removed]") : "";
230
- const statusStr = theme.fg(statusColor, `[${run.status}]`);
237
+ const statusStr = theme.fg(statusColor, `[${statusLabel}]`);
231
238
  const agentStr = theme.fg("accent", run.agent);
232
239
  const taskPreview = run.task
233
240
  .replace(/\s*\n+\s*/g, " ")
@@ -395,7 +402,7 @@ function attachRunResponseToEditor(ctx: ExtensionContext, run: CommandRunState,
395
402
  async function showRunLatestResponseOverlay(ctx: ExtensionContext, run: CommandRunState): Promise<void> {
396
403
  const response = getRunLatestResponse(run);
397
404
  const contextLabel = run.contextMode === "main" ? "main" : "sub";
398
- const subtitle = `#${run.id} · ${run.agent} · ${run.status} · ctx:${contextLabel} · turn:${run.turnCount ?? DEFAULT_TURN_COUNT}`;
405
+ const subtitle = `#${run.id} · ${run.agent} · ${getSubagentRunStatusLabel(run.status, run.errorClass)} · ctx:${contextLabel} · turn:${run.turnCount ?? DEFAULT_TURN_COUNT}`;
399
406
 
400
407
  await ctx.ui.custom(
401
408
  (tui, theme, _kb, done) => {
@@ -520,7 +527,11 @@ function restoreRunsFromSession(store: SubagentStore, ctx: any, pi?: ExtensionAP
520
527
  try {
521
528
  const entries = ctx.sessionManager.getEntries();
522
529
  const restoredRuns = new Map<number, CommandRunState>();
523
- const removedRunIds = new Set<number>();
530
+ const removedRuns = new Map<
531
+ number,
532
+ { entryIndex: number; startedAt?: number; aborted: boolean; message?: string }
533
+ >();
534
+ const latestRunEntryIndexes = new Map<number, number>();
524
535
  const displayTaskUpdates = new Map<number, { task?: string; displayTask?: string; startedAt?: number }>();
525
536
  let maxRunId = 0;
526
537
 
@@ -541,13 +552,19 @@ function restoreRunsFromSession(store: SubagentStore, ctx: any, pi?: ExtensionAP
541
552
  store.currentParentSessionFile = latestParentSessionFile;
542
553
 
543
554
  // First pass: collect removed run IDs and persisted displayTask updates
544
- for (const entry of entries) {
555
+ for (const [entryIndex, entry] of entries.entries()) {
545
556
  if (entry.type === "custom") {
546
557
  const ce = entry as any;
547
558
  if (ce.customType === "subagent-removed") {
548
559
  sawSubagentMarkers = true;
549
560
  if (ce.data?.runId != null) {
550
- removedRunIds.add(ce.data.runId);
561
+ removedRuns.set(ce.data.runId, {
562
+ entryIndex,
563
+ startedAt: toValidTimestampMs(ce.data?.startedAt),
564
+ aborted:
565
+ ce.data?.status === "aborted" || ce.data?.errorClass === "aborted" || ce.data?.stopReason === "aborted",
566
+ message: typeof ce.data?.message === "string" ? ce.data.message : undefined,
567
+ });
551
568
  }
552
569
  continue;
553
570
  }
@@ -562,7 +579,7 @@ function restoreRunsFromSession(store: SubagentStore, ctx: any, pi?: ExtensionAP
562
579
  }
563
580
  }
564
581
 
565
- for (const entry of entries) {
582
+ for (const [entryIndex, entry] of entries.entries()) {
566
583
  if (entry.type !== "custom_message") continue;
567
584
  const cm = entry as any;
568
585
  if (cm.customType !== "subagent-command" && cm.customType !== "subagent-tool") continue;
@@ -571,6 +588,7 @@ function restoreRunsFromSession(store: SubagentStore, ctx: any, pi?: ExtensionAP
571
588
  if (!d || typeof d.runId !== "number") continue;
572
589
 
573
590
  const runId = d.runId;
591
+ latestRunEntryIndexes.set(runId, entryIndex);
574
592
  if (runId > maxRunId) maxRunId = runId;
575
593
 
576
594
  const existing = restoredRuns.get(runId);
@@ -584,11 +602,12 @@ function restoreRunsFromSession(store: SubagentStore, ctx: any, pi?: ExtensionAP
584
602
 
585
603
  // Determine final status primarily from structured metadata.
586
604
  const content = typeof cm.content === "string" ? cm.content : "";
605
+ const firstContentLine = content.split("\n", 1)[0]?.trim() ?? "";
587
606
  const statusRaw = typeof d.status === "string" ? d.status.trim().toLowerCase() : "";
588
607
  const statusFromDetails: "done" | "error" | null =
589
608
  statusRaw === "done" || statusRaw === "completed"
590
609
  ? "done"
591
- : statusRaw === "error" || statusRaw === "failed"
610
+ : statusRaw === "error" || statusRaw === "failed" || statusRaw === "aborted"
592
611
  ? "error"
593
612
  : null;
594
613
  const statusFromExitCode: "done" | "error" | null =
@@ -597,13 +616,25 @@ function restoreRunsFromSession(store: SubagentStore, ctx: any, pi?: ExtensionAP
597
616
  typeof d.error === "string" && d.error.trim() ? "error" : null;
598
617
 
599
618
  // Legacy fallback for old sessions where structured fields are missing.
600
- const legacyStatusFromContent: "done" | "error" | null = content.includes("] completed")
619
+ const legacyStatusFromContent: "done" | "error" | null = firstContentLine.includes("] completed")
601
620
  ? "done"
602
- : content.includes("] failed") || content.includes("] error")
621
+ : firstContentLine.includes("] failed") ||
622
+ firstContentLine.includes("] error") ||
623
+ firstContentLine.includes("] aborted")
603
624
  ? "error"
604
625
  : null;
605
626
 
606
627
  const finalStatus = statusFromDetails ?? statusFromExitCode ?? statusFromErrorField ?? legacyStatusFromContent;
628
+ const hasStructuredTerminalOutcome =
629
+ statusFromDetails !== null ||
630
+ statusFromExitCode !== null ||
631
+ typeof d.stopReason === "string" ||
632
+ typeof d.errorClass === "string";
633
+ const isAbortedCompletion =
634
+ d.stopReason === "aborted" ||
635
+ d.errorClass === "aborted" ||
636
+ statusRaw === "aborted" ||
637
+ (!hasStructuredTerminalOutcome && firstContentLine.includes("] aborted"));
607
638
 
608
639
  // Derive source from customType for backward-compatible restored run metadata.
609
640
  const restoredSource: "tool" | "command" = cm.customType === "subagent-tool" ? "tool" : "command";
@@ -648,7 +679,7 @@ function restoreRunsFromSession(store: SubagentStore, ctx: any, pi?: ExtensionAP
648
679
  contextMode: d.contextMode ?? existing?.contextMode,
649
680
  usage: d.usage ?? existing?.usage,
650
681
  model: d.model ?? existing?.model,
651
- errorClass: d.errorClass ?? existing?.errorClass,
682
+ errorClass: d.errorClass ?? (isAbortedCompletion ? "aborted" : existing?.errorClass),
652
683
  peakContextTokens: d.peakContextTokens ?? existing?.peakContextTokens,
653
684
  lastToolName: d.lastToolName ?? existing?.lastToolName,
654
685
  lastToolOutputChars: d.lastToolOutputChars ?? existing?.lastToolOutputChars,
@@ -723,8 +754,26 @@ function restoreRunsFromSession(store: SubagentStore, ctx: any, pi?: ExtensionAP
723
754
  }
724
755
 
725
756
  for (const [id, run] of restoredRuns) {
726
- if (removedRunIds.has(id)) {
727
- store.commandRuns.set(id, { ...run, removed: true }); // restore removed runs while preserving removed=true
757
+ const removal = removedRuns.get(id);
758
+ const latestRunEntryIndex = latestRunEntryIndexes.get(id) ?? -1;
759
+ const removalApplies = removal
760
+ ? removal.startedAt !== undefined
761
+ ? removal.startedAt === run.startedAt
762
+ : removal.entryIndex > latestRunEntryIndex
763
+ : false;
764
+ if (removal && removalApplies) {
765
+ store.commandRuns.set(id, {
766
+ ...run,
767
+ ...(removal.aborted
768
+ ? {
769
+ status: "error" as const,
770
+ errorClass: "aborted" as const,
771
+ lastLine: removal.message ?? run.lastLine,
772
+ lastOutput: removal.message ?? run.lastOutput,
773
+ }
774
+ : {}),
775
+ removed: true,
776
+ });
728
777
  continue;
729
778
  }
730
779
  store.commandRuns.set(id, run);
@@ -1107,6 +1156,13 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1107
1156
  const targetRunId = Number(firstToken);
1108
1157
  const targetRun = continuationRun;
1109
1158
 
1159
+ if (targetRun.abortPending) {
1160
+ ctx.ui.notify(
1161
+ `Subagent #${targetRunId} is still aborting. Wait for cancellation to settle before continuing it.`,
1162
+ "warning",
1163
+ );
1164
+ return;
1165
+ }
1110
1166
  if (targetRun.status === "running") {
1111
1167
  ctx.ui.notify(`Subagent #${targetRunId} is already running.`, "warning");
1112
1168
  return;
@@ -1274,6 +1330,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1274
1330
  runState.lastRetryReason = undefined;
1275
1331
  runState.errorClass = undefined;
1276
1332
  runState.autoAbortReason = undefined;
1333
+ runState.abortPending = false;
1277
1334
  runState.removed = false;
1278
1335
  runState.deliveryMode = deliveryMode;
1279
1336
  runState.turnCount = Math.max(DEFAULT_TURN_COUNT, runState.turnCount || DEFAULT_TURN_COUNT) + 1;
@@ -1509,6 +1566,22 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1509
1566
  updateRunFromResult(runState, result);
1510
1567
  const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
1511
1568
  runState.status = isError ? "error" : "done";
1569
+ if (isError) {
1570
+ runState.errorClass =
1571
+ result.errorClass ??
1572
+ classifySubagentFailure({
1573
+ failed: true,
1574
+ stopReason: result.stopReason,
1575
+ exitCode: result.exitCode,
1576
+ errorMessage: result.errorMessage,
1577
+ stderr: result.stderr,
1578
+ output: getFinalOutput(result.messages),
1579
+ });
1580
+ }
1581
+ const terminalLabel = getSubagentTerminalLabel(isError, {
1582
+ stopReason: result.stopReason,
1583
+ errorClass: runState.errorClass,
1584
+ });
1512
1585
  runState.elapsedMs = Date.now() - runState.startedAt;
1513
1586
  updateCommandRunsWidget(store);
1514
1587
 
@@ -1527,7 +1600,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1527
1600
  const completionMessage = {
1528
1601
  customType: "subagent-command" as const,
1529
1602
  content:
1530
- `[subagent:${selectedAgent}#${runId}] ${isError ? "failed" : "completed"}` +
1603
+ `[subagent:${selectedAgent}#${runId}] ${terminalLabel}` +
1531
1604
  `\nPrompt: ${truncateLines(taskForDisplay, 2)}` +
1532
1605
  (usage ? `\nUsage: ${usage}` : "") +
1533
1606
  (runState.retryCount ? `\nRetries: ${runState.retryCount}/${MAX_SUBAGENT_AUTO_RETRIES}` : "") +
@@ -1548,6 +1621,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1548
1621
  elapsedMs: runState.elapsedMs,
1549
1622
  lastActivityAt: runState.lastActivityAt,
1550
1623
  exitCode: result.exitCode,
1624
+ stopReason: result.stopReason,
1551
1625
  usage: result.usage,
1552
1626
  model: result.model,
1553
1627
  source: result.agentSource,
@@ -1570,24 +1644,27 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1570
1644
  runId,
1571
1645
  `Hidden subagent #${runId} · ${selectedAgent}`,
1572
1646
  completionMessage.content,
1573
- isError
1574
- ? `hidden subagent #${runId} (${selectedAgent}) failed`
1575
- : `hidden subagent #${runId} (${selectedAgent}) completed`,
1576
- isError ? "error" : "info",
1647
+ `hidden subagent #${runId} (${selectedAgent}) ${terminalLabel}`,
1648
+ terminalLabel === "completed" ? "info" : terminalLabel === "aborted" ? "warning" : "error",
1577
1649
  );
1578
1650
  } else {
1579
1651
  deliverOrQueueCompletion(store, pi, ctx, runId, runState, completionMessage);
1580
1652
  ctx.ui.notify(
1581
- isError
1582
- ? `subagent #${runId} (${selectedAgent}) failed`
1583
- : `subagent #${runId} (${selectedAgent}) completed`,
1584
- isError ? "error" : "info",
1653
+ `subagent #${runId} (${selectedAgent}) ${terminalLabel}`,
1654
+ terminalLabel === "completed" ? "info" : terminalLabel === "aborted" ? "warning" : "error",
1585
1655
  );
1586
1656
  }
1587
1657
  } catch (error: any) {
1588
1658
  if (runState.removed || store.disposed) return;
1589
1659
  runState.status = "error";
1590
- runState.errorClass = "process_error";
1660
+ runState.errorClass = isSubagentAbort({
1661
+ signal: runState.abortController?.signal,
1662
+ autoAbortReason: runState.autoAbortReason,
1663
+ error,
1664
+ })
1665
+ ? "aborted"
1666
+ : "process_error";
1667
+ const terminalLabel = getSubagentTerminalLabel(true, { errorClass: runState.errorClass });
1591
1668
  runState.elapsedMs = Date.now() - runState.startedAt;
1592
1669
  runState.lastLine =
1593
1670
  runState.autoAbortReason ?? (error?.message ? String(error.message) : "Subagent execution failed");
@@ -1596,7 +1673,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1596
1673
  const cmdErrorMessage = {
1597
1674
  customType: "subagent-command" as const,
1598
1675
  content:
1599
- `[subagent:${selectedAgent}#${runId}] failed` +
1676
+ `[subagent:${selectedAgent}#${runId}] ${terminalLabel}` +
1600
1677
  `\nPrompt: ${truncateLines(taskForDisplay, 2)}` +
1601
1678
  `\n\n${runState.lastLine}`,
1602
1679
  display: true,
@@ -1615,6 +1692,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1615
1692
  lastActivityAt: runState.lastActivityAt,
1616
1693
  error: runState.lastLine,
1617
1694
  errorClass: runState.errorClass,
1695
+ stopReason: runState.errorClass === "aborted" ? "aborted" : undefined,
1618
1696
  peakContextTokens: runState.peakContextTokens,
1619
1697
  lastToolName: runState.lastToolName,
1620
1698
  lastToolOutputChars: runState.lastToolOutputChars,
@@ -1632,16 +1710,20 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1632
1710
  runId,
1633
1711
  `Hidden subagent #${runId} · ${selectedAgent}`,
1634
1712
  cmdErrorMessage.content,
1635
- `hidden subagent #${runId} failed: ${runState.lastLine}`,
1636
- "error",
1713
+ `hidden subagent #${runId} ${terminalLabel}: ${runState.lastLine}`,
1714
+ terminalLabel === "aborted" ? "warning" : "error",
1637
1715
  );
1638
1716
  } else {
1639
1717
  deliverOrQueueCompletion(store, pi, ctx, runId, runState, cmdErrorMessage);
1640
- ctx.ui.notify(`subagent #${runId} failed: ${runState.lastLine}`, "error");
1718
+ ctx.ui.notify(
1719
+ `subagent #${runId} ${terminalLabel}: ${runState.lastLine}`,
1720
+ terminalLabel === "aborted" ? "warning" : "error",
1721
+ );
1641
1722
  }
1642
1723
  } finally {
1643
1724
  clearInterval(tick);
1644
1725
  runState.abortController = undefined;
1726
+ runState.abortPending = false;
1645
1727
  if (!store.disposed) {
1646
1728
  trimCommandRunHistory(store, {
1647
1729
  maxRuns: 10,
@@ -1783,7 +1865,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1783
1865
  run.status === "running" ? "(still running; no final output yet)" : run.lastLine || "(no output captured)";
1784
1866
  const contextLabel = run.contextMode === "main" ? "main" : "isolated";
1785
1867
  const content =
1786
- `Subagent #${run.id} [${run.status}] ${run.agent} ctx:${contextLabel} turn:${run.turnCount ?? DEFAULT_TURN_COUNT} ${elapsedSec}s tools:${run.toolCalls}` +
1868
+ `Subagent #${run.id} [${getSubagentRunStatusLabel(run.status, run.errorClass)}] ${run.agent} ctx:${contextLabel} turn:${run.turnCount ?? DEFAULT_TURN_COUNT} ${elapsedSec}s tools:${run.toolCalls}` +
1787
1869
  `\n${run.task}` +
1788
1870
  usageLine +
1789
1871
  `\n\n${output || fallback}`;
@@ -1840,7 +1922,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1840
1922
  .replace(/\s*\n+\s*/g, " ")
1841
1923
  .trim()
1842
1924
  .slice(0, COMMAND_TASK_PREVIEW_CHARS);
1843
- return `#${r.id} [${r.status}]${removed} ${r.agent}: ${task}`;
1925
+ return `#${r.id} [${getSubagentRunStatusLabel(r.status, r.errorClass)}]${removed} ${r.agent}: ${task}`;
1844
1926
  });
1845
1927
  ctx.ui.notify(lines.join("\n"), "info");
1846
1928
  return;
@@ -18,6 +18,36 @@ export interface FailureTelemetryInput {
18
18
  output?: string;
19
19
  }
20
20
 
21
+ export interface AbortTelemetryInput {
22
+ stopReason?: string;
23
+ errorClass?: SubagentErrorClass;
24
+ signal?: AbortSignal;
25
+ autoAbortReason?: string;
26
+ error?: unknown;
27
+ }
28
+
29
+ export function isSubagentAbort(input: AbortTelemetryInput): boolean {
30
+ return (
31
+ input.stopReason === "aborted" ||
32
+ input.errorClass === "aborted" ||
33
+ input.signal?.aborted === true ||
34
+ Boolean(input.autoAbortReason) ||
35
+ (input.error instanceof Error && input.error.message === "Subagent was aborted")
36
+ );
37
+ }
38
+
39
+ export function getSubagentTerminalLabel(
40
+ failed: boolean,
41
+ input: AbortTelemetryInput,
42
+ ): "completed" | "aborted" | "failed" {
43
+ if (!failed) return "completed";
44
+ return isSubagentAbort(input) ? "aborted" : "failed";
45
+ }
46
+
47
+ export function getSubagentRunStatusLabel(status: string, errorClass?: SubagentErrorClass): string {
48
+ return errorClass === "aborted" ? "aborted" : status;
49
+ }
50
+
21
51
  const OVERLOADED_PATTERNS = [
22
52
  /servers? (?:are|is) currently overloaded/i,
23
53
  /server overloaded/i,
@@ -38,10 +68,10 @@ export function classifySubagentFailure(input: FailureTelemetryInput): SubagentE
38
68
  if (!input.failed) return undefined;
39
69
  const text = [input.errorMessage, input.stderr, input.output].filter(Boolean).join("\n");
40
70
 
71
+ if (input.stopReason === "aborted") return "aborted";
41
72
  if (isContextOverflowText(text)) return "context_overflow";
42
73
  if (OVERLOADED_PATTERNS.some((pattern) => pattern.test(text))) return "overloaded";
43
74
  if (RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(text))) return "rate_limit";
44
- if (input.stopReason === "aborted") return "aborted";
45
75
  if (TOOL_ERROR_PATTERNS.some((pattern) => pattern.test(text))) return "tool_error";
46
76
  if ((input.exitCode ?? 0) !== 0) return "process_error";
47
77
  return "unknown";
package/lifecycle.ts CHANGED
@@ -36,6 +36,7 @@ function reconcileRunWithPersistedSession(run: CommandRunState): void {
36
36
  snapshot.completionMarker?.exitCode ??
37
37
  (snapshot.terminalStopReason === "error" || snapshot.terminalStopReason === "aborted" ? 1 : 0);
38
38
  run.status = exitCode === 0 ? "done" : "error";
39
+ if (snapshot.terminalStopReason === "aborted") run.errorClass = "aborted";
39
40
  if (snapshot.finalOutput) {
40
41
  run.lastOutput = snapshot.finalOutput;
41
42
  run.lastLine = getLastNonEmptyLine(snapshot.finalOutput) || run.lastLine;
@@ -90,7 +91,7 @@ export function shutdownSubagentRuns(store: SubagentStore, pi: ExtensionAPI, rea
90
91
  pi.sendMessage(
91
92
  {
92
93
  customType: run.source === "tool" ? "subagent-tool" : "subagent-command",
93
- content: `[subagent:${run.agent}#${runId}] failed\n\n${message}`,
94
+ content: `[subagent:${run.agent}#${runId}] aborted\n\n${message}`,
94
95
  display: false,
95
96
  details: {
96
97
  runId,
@@ -100,6 +101,7 @@ export function shutdownSubagentRuns(store: SubagentStore, pi: ExtensionAPI, rea
100
101
  status: "error",
101
102
  error: message,
102
103
  errorClass: run.errorClass,
104
+ stopReason: "aborted",
103
105
  peakContextTokens: run.peakContextTokens,
104
106
  lastToolName: run.lastToolName,
105
107
  lastToolOutputChars: run.lastToolOutputChars,
@@ -155,7 +157,9 @@ export function checkForHungRuns(store: SubagentStore, _pi: ExtensionAPI): void
155
157
  run.lastLine = reason;
156
158
  run.lastOutput = reason;
157
159
  run.status = "error";
160
+ run.errorClass = "aborted";
158
161
  run.autoAbortReason = reason;
162
+ run.abortPending = Boolean(controller);
159
163
 
160
164
  if (controller) {
161
165
  controller.abort({ source: "hang_timeout", runId, idleMs, reason });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryan_nookpi/pi-extension-subagent",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Asynchronous subagent delegation for pi with run, batch, chain, and continuation workflows.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/replay.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  TOOL_RESULT_DETAILS_SUMMARY_MAX_CHARS,
45
45
  USAGE_EXTRA_ROWS,
46
46
  } from "./constants.js";
47
+ import { getSubagentRunStatusLabel } from "./failure-telemetry.js";
47
48
  import { formatUsageStats, truncatePlainToWidth } from "./format.js";
48
49
  import type { CommandRunState, SessionReplayItem } from "./types.js";
49
50
  import { formatDuration, formatDurationBetween } from "./utils/time-utils.js";
@@ -351,7 +352,7 @@ export class SubagentSessionReplayOverlay {
351
352
  theme.fg("toolTitle", theme.bold(`#${this.run.id} ${this.run.agent}`)) +
352
353
  theme.fg(
353
354
  "dim",
354
- ` [${this.run.status}] ctx:${contextLabel} turn:${this.run.turnCount ?? DEFAULT_TURN_COUNT} ${elapsedLabel} tools:${this.run.toolCalls}`,
355
+ ` [${getSubagentRunStatusLabel(this.run.status, this.run.errorClass)}] ctx:${contextLabel} turn:${this.run.turnCount ?? DEFAULT_TURN_COUNT} ${elapsedLabel} tools:${this.run.toolCalls}`,
355
356
  ),
356
357
  0,
357
358
  0,
package/run-utils.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import { FINISHED_GROUP_TTL_MS, MAX_FINISHED_GROUPS, STATUS_OUTPUT_PREVIEW_MAX_CHARS } from "./constants.js";
11
+ import { getSubagentRunStatusLabel } from "./failure-telemetry.js";
11
12
  import type { SubagentStore } from "./store.js";
12
13
  import type {
13
14
  BatchGroupState,
@@ -57,7 +58,7 @@ export interface ClearFinishedRunsOptions {
57
58
  export function formatCommandRunSummary(run: CommandRunState): string {
58
59
  const elapsedSec = Math.max(0, Math.round(run.elapsedMs / 1000));
59
60
  const contextLabel = run.contextMode === "main" ? "main" : "isolated";
60
- return `#${run.id} [${run.status}] ${run.agent} ctx:${contextLabel} turn:${run.turnCount ?? 1} ${elapsedSec}s tools:${run.toolCalls}`;
61
+ return `#${run.id} [${getSubagentRunStatusLabel(run.status, run.errorClass)}] ${run.agent} ctx:${contextLabel} turn:${run.turnCount ?? 1} ${elapsedSec}s tools:${run.toolCalls}`;
61
62
  }
62
63
 
63
64
  /**
@@ -95,12 +96,19 @@ export function removeRun(store: SubagentStore, runId: number, options: RemoveRu
95
96
  const globalEntry = store.globalLiveRuns.get(runId);
96
97
  const controller = run.abortController ?? globalEntry?.abortController;
97
98
 
98
- if (abortIfRunning && run.status === "running" && controller) {
99
+ if (abortIfRunning && run.status === "running") {
99
100
  const reason = options.reason ?? "Aborting by remove...";
101
+ run.status = "error";
102
+ run.errorClass = "aborted";
103
+ run.elapsedMs = Date.now() - run.startedAt;
104
+ run.lastActivityAt = Date.now();
100
105
  run.lastLine = reason;
101
106
  run.lastOutput = reason;
102
- controller.abort({ source: "remove_run", runId, reason, removalReason: options.removalReason });
103
- aborted = true;
107
+ run.abortPending = Boolean(controller);
108
+ if (controller) {
109
+ controller.abort({ source: "remove_run", runId, reason, removalReason: options.removalReason });
110
+ aborted = true;
111
+ }
104
112
  }
105
113
 
106
114
  run.abortController = undefined;
@@ -112,6 +120,13 @@ export function removeRun(store: SubagentStore, runId: number, options: RemoveRu
112
120
  if (persistRemovedEntry && options.pi && run.deliveryMode !== "humanOnly") {
113
121
  const payload: Record<string, unknown> = { runId };
114
122
  if (options.removalReason) payload.reason = options.removalReason;
123
+ if (run.errorClass === "aborted") {
124
+ payload.startedAt = run.startedAt;
125
+ payload.status = "aborted";
126
+ payload.errorClass = "aborted";
127
+ payload.stopReason = "aborted";
128
+ payload.message = run.lastLine;
129
+ }
115
130
  try {
116
131
  options.pi.appendEntry("subagent-removed", payload);
117
132
  } catch {
@@ -225,16 +240,19 @@ export function snapshotBatchGroup(
225
240
  terminalStatus: FinishedGroupSnapshot["terminalStatus"],
226
241
  ): FinishedGroupSnapshot {
227
242
  let failed = 0;
243
+ let aborted = 0;
228
244
  const members: FinishedGroupMember[] = batch.runIds.map((runId) => {
229
245
  const run = store.commandRuns.get(runId);
230
246
  if (!run) {
231
- if (batch.failedRunIds.has(runId)) failed++;
247
+ if (batch.abortedRunIds.has(runId)) aborted++;
248
+ else if (batch.failedRunIds.has(runId)) failed++;
232
249
  return {
233
250
  summaryLine: `#${runId} [gone] (run no longer available)`,
234
251
  output: batch.pendingResults.get(runId)?.trim() || "(no output)",
235
252
  };
236
253
  }
237
- if (run.status === "error" || batch.failedRunIds.has(runId)) failed++;
254
+ if (run.errorClass === "aborted" || batch.abortedRunIds.has(runId)) aborted++;
255
+ else if (run.status === "error" || batch.failedRunIds.has(runId)) failed++;
238
256
  return { summaryLine: formatCommandRunSummary(run), output: memberOutput(run, batch.pendingResults.get(runId)) };
239
257
  });
240
258
  return {
@@ -244,6 +262,7 @@ export function snapshotBatchGroup(
244
262
  finishedAt: Date.now(),
245
263
  total: batch.runIds.length,
246
264
  failed,
265
+ aborted,
247
266
  members,
248
267
  };
249
268
  }
@@ -265,6 +284,7 @@ export function snapshotPipeline(
265
284
  finishedAt: Date.now(),
266
285
  total: pipeline.stepResults.length,
267
286
  failed: pipeline.stepResults.filter((step) => step.status === "error").length,
287
+ aborted: pipeline.stepResults.filter((step) => step.status === "aborted").length,
268
288
  members,
269
289
  };
270
290
  }
@@ -304,9 +324,10 @@ function formatFinishedAge(finishedAt: number, now = Date.now()): string {
304
324
  export function formatFinishedGroupStatus(snapshot: FinishedGroupSnapshot, detailed: boolean): string {
305
325
  const label = snapshot.kind === "batch" ? "subagent-batch" : "subagent-chain";
306
326
  const failedSuffix = snapshot.failed > 0 ? `, ${snapshot.failed} failed` : "";
327
+ const abortedSuffix = snapshot.aborted > 0 ? `, ${snapshot.aborted} aborted` : "";
307
328
  const header = `[${label}#${snapshot.groupId}] ${snapshot.terminalStatus} · ${snapshot.total} ${
308
329
  snapshot.kind === "batch" ? "runs" : "steps"
309
- }${failedSuffix} · finished ${formatFinishedAge(snapshot.finishedAt)}`;
330
+ }${failedSuffix}${abortedSuffix} · finished ${formatFinishedAge(snapshot.finishedAt)}`;
310
331
  const body = snapshot.members
311
332
  .map((member) => {
312
333
  if (!detailed) return member.summaryLine;
package/tool-execute.ts CHANGED
@@ -32,7 +32,13 @@ import {
32
32
  summarizeSubagentDisplayTask,
33
33
  } from "./display-task.js";
34
34
  import { ESCALATION_EXIT_CODE, readAndConsumeEscalation } from "./escalation.js";
35
- import { classifySubagentFailure, type SubagentErrorClass } from "./failure-telemetry.js";
35
+ import {
36
+ classifySubagentFailure,
37
+ getSubagentRunStatusLabel,
38
+ getSubagentTerminalLabel,
39
+ isSubagentAbort,
40
+ type SubagentErrorClass,
41
+ } from "./failure-telemetry.js";
36
42
  import {
37
43
  formatContextUsageBar,
38
44
  formatUsageStats,
@@ -299,6 +305,9 @@ export function diagnoseResultFailure(result: SingleResult): ResultFailureDiagno
299
305
  output: finalOutput,
300
306
  });
301
307
 
308
+ if (result.stopReason === "aborted") {
309
+ return { failed: true, errorClass: "aborted", reason: "Subagent execution was aborted." };
310
+ }
302
311
  if (result.exitCode !== 0 || result.stopReason === "error") {
303
312
  const overflowText = result.errorMessage || result.stderr || finalOutput;
304
313
  if (isContextOverflowText(overflowText)) {
@@ -324,8 +333,6 @@ export function diagnoseResultFailure(result: SingleResult): ResultFailureDiagno
324
333
  }
325
334
  if (result.stopReason === "error")
326
335
  return { failed: true, errorClass, reason: result.errorMessage || "Subagent reported stopReason=error." };
327
- if (result.stopReason === "aborted")
328
- return { failed: true, errorClass: errorClass ?? "aborted", reason: "Subagent execution was aborted." };
329
336
 
330
337
  const hasAssistantText = finalOutput.length > 0;
331
338
  if (hasAssistantText) return { failed: false };
@@ -469,13 +476,42 @@ function buildRunStartMessage(runState: CommandRunState, status: "started" | "re
469
476
  };
470
477
  }
471
478
 
479
+ function getFinalizedRunLabel(finalized: FinalizedRun): "completed" | "aborted" | "failed" {
480
+ return getSubagentTerminalLabel(finalized.isError, {
481
+ stopReason: finalized.result?.stopReason,
482
+ errorClass: finalized.runState.errorClass,
483
+ autoAbortReason: finalized.runState.autoAbortReason,
484
+ });
485
+ }
486
+
487
+ function getFinalizedRunStatus(finalized: FinalizedRun): PipelineStepResult["status"] {
488
+ const label = getFinalizedRunLabel(finalized);
489
+ return label === "completed" ? "done" : label === "aborted" ? "aborted" : "error";
490
+ }
491
+
492
+ function resolveGroupTerminalStatus(failed: number, aborted: number): "completed" | "error" | "aborted" {
493
+ if (failed > 0) return "error";
494
+ if (aborted > 0) return "aborted";
495
+ return "completed";
496
+ }
497
+
498
+ function recordBatchResult(batch: BatchGroupState, finalized: FinalizedRun): void {
499
+ const runId = finalized.runState.id;
500
+ const status = finalized.runState.removed ? "aborted" : getFinalizedRunStatus(finalized);
501
+ batch.completedRunIds.add(runId);
502
+ if (status === "aborted") batch.abortedRunIds.add(runId);
503
+ else if (status === "error") batch.failedRunIds.add(runId);
504
+ batch.pendingResults.set(runId, finalized.rawOutput);
505
+ }
506
+
472
507
  function buildRunCompletionMessage(finalized: FinalizedRun, options?: { display?: boolean }) {
473
- const { runState, result, isError, rawOutput } = finalized;
508
+ const { runState, result, rawOutput } = finalized;
474
509
  const usage = result ? formatUsageStats(result.usage, result.model) : "";
510
+ const terminalLabel = getFinalizedRunLabel(finalized);
475
511
  return {
476
512
  customType: "subagent-tool" as const,
477
513
  content:
478
- `[subagent:${runState.agent}#${runState.id}] ${isError ? "failed" : "completed"}` +
514
+ `[subagent:${runState.agent}#${runState.id}] ${terminalLabel}` +
479
515
  `\nPrompt: ${truncateLines(runState.task, 2)}` +
480
516
  (usage ? `\nUsage: ${usage}` : "") +
481
517
  (runState.thoughtText ? `\nThought: ${runState.thoughtText}` : "") +
@@ -495,6 +531,7 @@ function buildRunCompletionMessage(finalized: FinalizedRun, options?: { display?
495
531
  elapsedMs: runState.elapsedMs,
496
532
  lastActivityAt: runState.lastActivityAt,
497
533
  exitCode: result?.exitCode,
534
+ stopReason: result?.stopReason ?? (runState.errorClass === "aborted" ? "aborted" : undefined),
498
535
  usage: result?.usage,
499
536
  model: result?.model,
500
537
  source: result?.agentSource,
@@ -560,6 +597,7 @@ function finalizeRunState(runState: CommandRunState, result: SingleResult): Fina
560
597
 
561
598
  if (runState.autoAbortReason) {
562
599
  runState.status = "error";
600
+ runState.errorClass = "aborted";
563
601
  runState.elapsedMs = Date.now() - runState.startedAt;
564
602
  runState.lastOutput = runState.autoAbortReason;
565
603
  runState.lastLine = runState.autoAbortReason;
@@ -606,29 +644,46 @@ function finalizeRunState(runState: CommandRunState, result: SingleResult): Fina
606
644
  return { runState, result, isError, rawOutput };
607
645
  }
608
646
 
609
- function formatBatchSummary(batchId: string, runs: CommandRunState[], terminalStatus: "completed" | "error"): string {
610
- const headerStatus = runs.map((run) => `#${run.id} ${run.status === "done" ? "done" : "error"}`).join(", ");
647
+ function formatOutcomeCounts(failed: number, aborted: number): string {
648
+ return [failed > 0 ? `${failed} failed` : "", aborted > 0 ? `${aborted} aborted` : ""].filter(Boolean).join(", ");
649
+ }
650
+
651
+ function formatBatchSummary(
652
+ batchId: string,
653
+ runs: CommandRunState[],
654
+ terminalStatus: "completed" | "error" | "aborted",
655
+ ): string {
656
+ const statuses = runs.map((run) => getSubagentRunStatusLabel(run.status, run.errorClass));
657
+ const headerStatus = runs.map((run, index) => `#${run.id} ${statuses[index]}`).join(", ");
658
+ const outcomeCounts = formatOutcomeCounts(
659
+ statuses.filter((status) => status === "error").length,
660
+ statuses.filter((status) => status === "aborted").length,
661
+ );
611
662
  const body = runs
612
663
  .map((run) => {
613
664
  const summary = run.lastOutput?.trim() || run.lastLine?.trim() || "(no output)";
614
665
  return `#${run.id} ${run.agent}\n- ${summary}`;
615
666
  })
616
667
  .join("\n\n");
617
- return `[subagent-batch#${batchId}] ${terminalStatus}\nRuns: ${headerStatus}\n\n${body}`;
668
+ return `[subagent-batch#${batchId}] ${terminalStatus}\nRuns: ${headerStatus}${outcomeCounts ? `\nOutcomes: ${outcomeCounts}` : ""}\n\n${body}`;
618
669
  }
619
670
 
620
671
  function formatPipelineSummary(
621
672
  pipelineId: string,
622
673
  stepResults: PipelineStepResult[],
623
- terminalStatus: "completed" | "stopped" | "error",
674
+ terminalStatus: "completed" | "stopped" | "error" | "aborted",
624
675
  ): string {
676
+ const outcomeCounts = formatOutcomeCounts(
677
+ stepResults.filter((step) => step.status === "error").length,
678
+ stepResults.filter((step) => step.status === "aborted").length,
679
+ );
625
680
  const steps = stepResults
626
681
  .map(
627
682
  (step, index) =>
628
683
  `Step ${index + 1} · #${step.runId} ${step.agent} · ${step.status}\nTask: ${step.task}\n${step.output}`,
629
684
  )
630
685
  .join("\n\n");
631
- return `[subagent-chain#${pipelineId}] ${terminalStatus}\n\n${steps}`;
686
+ return `[subagent-chain#${pipelineId}] ${terminalStatus}${outcomeCounts ? `\nOutcomes: ${outcomeCounts}` : ""}\n\n${steps}`;
632
687
  }
633
688
 
634
689
  function previewOutput(output: string): string {
@@ -642,9 +697,10 @@ function formatBatchGroupStatus(store: SubagentStore, batch: BatchGroupState, de
642
697
  const total = batch.runIds.length;
643
698
  const done = batch.completedRunIds.size;
644
699
  const failed = batch.failedRunIds.size;
700
+ const aborted = batch.abortedRunIds.size;
645
701
  const header = `[subagent-batch#${batch.batchId}] running · ${done}/${total} finished${
646
702
  failed > 0 ? `, ${failed} failed` : ""
647
- }`;
703
+ }${aborted > 0 ? `, ${aborted} aborted` : ""}`;
648
704
  const body = batch.runIds
649
705
  .map((runId) => {
650
706
  const run = store.commandRuns.get(runId);
@@ -735,7 +791,13 @@ function isInteractiveTuiContext(ctx: SubagentToolExecuteContext): boolean {
735
791
 
736
792
  function finalizeRunError(runState: CommandRunState, error: unknown): FinalizedRun {
737
793
  runState.status = "error";
738
- runState.errorClass = "process_error";
794
+ runState.errorClass = isSubagentAbort({
795
+ signal: runState.abortController?.signal,
796
+ autoAbortReason: runState.autoAbortReason,
797
+ error,
798
+ })
799
+ ? "aborted"
800
+ : "process_error";
739
801
  runState.elapsedMs = Date.now() - runState.startedAt;
740
802
  runState.lastLine =
741
803
  runState.autoAbortReason ?? (error instanceof Error ? error.message : "Subagent execution failed");
@@ -1266,6 +1328,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1266
1328
  function clearRunAbortState(runState: CommandRunState): void {
1267
1329
  clearParentAbortListener(runState.id);
1268
1330
  runState.abortController = undefined;
1331
+ runState.abortPending = false;
1269
1332
  }
1270
1333
 
1271
1334
  function registerRunLaunch(config: RunLaunchConfig): CommandRunState {
@@ -1289,6 +1352,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1289
1352
  runState.lastRetryReason = undefined;
1290
1353
  runState.errorClass = undefined;
1291
1354
  runState.autoAbortReason = undefined;
1355
+ runState.abortPending = false;
1292
1356
  runState.removed = false;
1293
1357
  runState.turnCount = Math.max(DEFAULT_TURN_COUNT, runState.turnCount || DEFAULT_TURN_COUNT) + 1;
1294
1358
  runState.contextMode = runState.contextMode ?? (config.inheritMainContext ? "main" : "sub");
@@ -1350,15 +1414,17 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1350
1414
 
1351
1415
  function launchRunInBackground(runState: CommandRunState, taskForAgent: string): Promise<FinalizedRun> {
1352
1416
  let claudeCheckpointSent = !!runState.claudeSessionId;
1417
+ const abortSignal = runState.abortController?.signal;
1353
1418
  const recordActivity = createRunActivityRecorder(pi, runState, (model) => resolveContextWindow(ctx, model));
1354
- return enqueueSubagentInvocation(() =>
1355
- runSingleAgent(
1419
+ return enqueueSubagentInvocation(async () => {
1420
+ if (runState.removed || abortSignal?.aborted) throw new Error("Subagent was aborted");
1421
+ return runSingleAgent(
1356
1422
  ctx.cwd,
1357
1423
  agents,
1358
1424
  runState.agent,
1359
1425
  taskForAgent,
1360
1426
  runState.pipelineStepIndex,
1361
- runState.abortController?.signal,
1427
+ abortSignal,
1362
1428
  (partial) => {
1363
1429
  if (runState.removed || store.disposed) return;
1364
1430
  const current = partial.details?.results?.[0];
@@ -1382,8 +1448,12 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1382
1448
  persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
1383
1449
  onDiagnostic: createRunDiagnosticSink(pi, runState),
1384
1450
  },
1385
- ),
1386
- ).then((result) => finalizeRunState(runState, result));
1451
+ );
1452
+ }).then((result) =>
1453
+ runState.removed || abortSignal?.aborted
1454
+ ? finalizeRunError(runState, new Error("Subagent was aborted"))
1455
+ : finalizeRunState(runState, result),
1456
+ );
1387
1457
  }
1388
1458
 
1389
1459
  if (hasSingle) {
@@ -1405,6 +1475,20 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1405
1475
  isError: true,
1406
1476
  };
1407
1477
  }
1478
+ if (continueFromRun.abortPending) {
1479
+ return {
1480
+ content: [
1481
+ {
1482
+ type: "text",
1483
+ text: withIdleRunWarning(
1484
+ `Subagent #${continuationRunId} is still aborting. Wait for cancellation to settle before continuing it.`,
1485
+ ),
1486
+ },
1487
+ ],
1488
+ details: makeDetails("single"),
1489
+ isError: true,
1490
+ };
1491
+ }
1408
1492
  if (continueFromRun.status === "running") {
1409
1493
  return {
1410
1494
  content: [
@@ -1505,6 +1589,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1505
1589
  };
1506
1590
  }
1507
1591
 
1592
+ // No run-start sendMessage here: mid-turn triggerTurn:false messages land between tool call and result (pi #8537).
1508
1593
  ctx.ui?.notify?.(
1509
1594
  `${continueFromRun ? `Resumed subagent #${runState.id}` : `Started subagent #${runState.id}`}: ${resolvedAgent}`,
1510
1595
  "info",
@@ -1529,6 +1614,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1529
1614
  return;
1530
1615
  }
1531
1616
 
1617
+ const terminalLabel = getFinalizedRunLabel(finalized);
1532
1618
  const completionMessage = buildRunCompletionMessage(finalized);
1533
1619
  if (isInOriginSession(ctx, originSessionFile)) {
1534
1620
  pi.sendMessage(completionMessage, { deliverAs: "followUp", triggerTurn: true });
@@ -1539,14 +1625,13 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1539
1625
  }
1540
1626
 
1541
1627
  ctx.ui?.notify?.(
1542
- finalized.isError
1543
- ? `subagent tool run #${runState.id} (${resolvedAgent}) failed`
1544
- : `subagent tool run #${runState.id} (${resolvedAgent}) completed`,
1545
- finalized.isError ? "error" : "info",
1628
+ `subagent tool run #${runState.id} (${resolvedAgent}) ${terminalLabel}`,
1629
+ terminalLabel === "completed" ? "info" : terminalLabel === "aborted" ? "warning" : "error",
1546
1630
  );
1547
1631
  } catch (error: unknown) {
1548
1632
  if (runState.removed || store.disposed) return;
1549
1633
  const finalized = finalizeRunError(runState, error);
1634
+ const terminalLabel = getFinalizedRunLabel(finalized);
1550
1635
  const errorMessage = buildRunCompletionMessage(finalized);
1551
1636
  if (isInOriginSession(ctx, originSessionFile)) {
1552
1637
  pi.sendMessage(errorMessage, { deliverAs: "followUp", triggerTurn: true });
@@ -1555,7 +1640,10 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1555
1640
  const entry = store.globalLiveRuns.get(runState.id);
1556
1641
  if (entry) entry.pendingCompletion = makePendingCompletion(errorMessage, true);
1557
1642
  }
1558
- ctx.ui?.notify?.(`subagent tool run #${runState.id} failed: ${runState.lastLine}`, "error");
1643
+ ctx.ui?.notify?.(
1644
+ `subagent tool run #${runState.id} ${terminalLabel}: ${runState.lastLine}`,
1645
+ terminalLabel === "aborted" ? "warning" : "error",
1646
+ );
1559
1647
  updateCommandRunsWidget(store);
1560
1648
  } finally {
1561
1649
  clearRunAbortState(runState);
@@ -1626,6 +1714,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1626
1714
  runIds: runStates.map(({ runState }) => runState.id),
1627
1715
  completedRunIds: new Set(),
1628
1716
  failedRunIds: new Set(),
1717
+ abortedRunIds: new Set(),
1629
1718
  originSessionFile,
1630
1719
  createdAt: Date.now(),
1631
1720
  pendingResults: new Map(),
@@ -1645,11 +1734,18 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1645
1734
  }),
1646
1735
  );
1647
1736
  const orderedRuns = runStates.map(({ runState }) => runState);
1648
- const hasError = finalizedRuns.some((finalized) => finalized.isError);
1649
- const content = formatBatchSummary(batchId, orderedRuns, hasError ? "error" : "completed");
1650
1737
  const batchForSnapshot = store.batchGroups.get(batchId);
1651
- if (batchForSnapshot)
1652
- retireFinishedGroup(store, snapshotBatchGroup(store, batchForSnapshot, hasError ? "error" : "completed"));
1738
+ if (batchForSnapshot) {
1739
+ for (const finalized of finalizedRuns) recordBatchResult(batchForSnapshot, finalized);
1740
+ }
1741
+ const terminalStatus = resolveGroupTerminalStatus(
1742
+ batchForSnapshot?.failedRunIds.size ?? 0,
1743
+ batchForSnapshot?.abortedRunIds.size ?? 0,
1744
+ );
1745
+ const content = formatBatchSummary(batchId, orderedRuns, terminalStatus);
1746
+ if (batchForSnapshot) {
1747
+ retireFinishedGroup(store, snapshotBatchGroup(store, batchForSnapshot, terminalStatus));
1748
+ }
1653
1749
  for (const { runState } of runStates) cleanupRunAfterFinalDelivery(runState.id);
1654
1750
  clearPendingGroupCompletion("batch", batchId);
1655
1751
  store.batchGroups.delete(batchId);
@@ -1671,7 +1767,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1671
1767
  .filter((result): result is SingleResult => Boolean(result)),
1672
1768
  runStates.map(({ runState }) => toLaunchSummary(runState, "batch")),
1673
1769
  ),
1674
- isError: hasError,
1770
+ isError: terminalStatus !== "completed",
1675
1771
  };
1676
1772
  }
1677
1773
 
@@ -1682,16 +1778,14 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1682
1778
 
1683
1779
  const batch = store.batchGroups.get(batchId);
1684
1780
  if (!batch) return;
1685
- batch.completedRunIds.add(runState.id);
1686
- if (finalized.isError) batch.failedRunIds.add(runState.id);
1687
- batch.pendingResults.set(runState.id, finalized.rawOutput);
1781
+ recordBatchResult(batch, finalized);
1688
1782
  updateCommandRunsWidget(store);
1689
1783
 
1690
1784
  if (batch.completedRunIds.size === batch.runIds.length) {
1691
1785
  const orderedRuns = batch.runIds
1692
1786
  .map((runId) => store.commandRuns.get(runId))
1693
1787
  .filter((run): run is CommandRunState => Boolean(run));
1694
- const batchTerminalStatus = batch.failedRunIds.size > 0 ? "error" : "completed";
1788
+ const batchTerminalStatus = resolveGroupTerminalStatus(batch.failedRunIds.size, batch.abortedRunIds.size);
1695
1789
  const content = formatBatchSummary(batchId, orderedRuns, batchTerminalStatus);
1696
1790
  const message = {
1697
1791
  customType: "subagent-tool" as const,
@@ -1700,7 +1794,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1700
1794
  details: {
1701
1795
  batchId,
1702
1796
  runIds: batch.runIds,
1703
- status: batch.failedRunIds.size > 0 ? "error" : "done",
1797
+ status: batchTerminalStatus === "completed" ? "done" : batchTerminalStatus,
1704
1798
  runSummaries: orderedRuns.map((run) => buildRunAnalyticsSummary(run)),
1705
1799
  },
1706
1800
  };
@@ -1728,38 +1822,36 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1728
1822
  removalReason: "trim",
1729
1823
  });
1730
1824
  ctx.ui?.notify?.(
1731
- batch.failedRunIds.size > 0
1825
+ batchTerminalStatus === "error"
1732
1826
  ? `subagent batch ${batchId} finished with errors`
1733
- : `subagent batch ${batchId} completed`,
1734
- batch.failedRunIds.size > 0 ? "error" : "info",
1827
+ : batchTerminalStatus === "aborted"
1828
+ ? `subagent batch ${batchId} aborted`
1829
+ : `subagent batch ${batchId} completed`,
1830
+ batchTerminalStatus === "error" ? "error" : batchTerminalStatus === "aborted" ? "warning" : "info",
1735
1831
  );
1736
1832
  }
1737
1833
  } catch (error: unknown) {
1738
- runState.status = "error";
1739
- runState.elapsedMs = Date.now() - runState.startedAt;
1740
- runState.lastLine = error instanceof Error ? error.message : "Subagent execution failed";
1741
- runState.lastOutput = runState.lastLine;
1834
+ const finalized = finalizeRunError(runState, error);
1742
1835
  const batch = store.batchGroups.get(batchId);
1743
1836
  if (!batch) return;
1744
- batch.completedRunIds.add(runState.id);
1745
- batch.failedRunIds.add(runState.id);
1746
- batch.pendingResults.set(runState.id, runState.lastLine);
1837
+ recordBatchResult(batch, finalized);
1747
1838
  if (batch.completedRunIds.size === batch.runIds.length) {
1748
1839
  const orderedRuns = batch.runIds
1749
1840
  .map((runId) => store.commandRuns.get(runId))
1750
1841
  .filter((run): run is CommandRunState => Boolean(run));
1842
+ const batchTerminalStatus = resolveGroupTerminalStatus(batch.failedRunIds.size, batch.abortedRunIds.size);
1751
1843
  const message = {
1752
1844
  customType: "subagent-tool" as const,
1753
- content: formatBatchSummary(batchId, orderedRuns, "error"),
1845
+ content: formatBatchSummary(batchId, orderedRuns, batchTerminalStatus),
1754
1846
  display: true,
1755
1847
  details: {
1756
1848
  batchId,
1757
1849
  runIds: batch.runIds,
1758
- status: "error",
1850
+ status: batchTerminalStatus,
1759
1851
  runSummaries: orderedRuns.map((run) => buildRunAnalyticsSummary(run)),
1760
1852
  },
1761
1853
  };
1762
- retireFinishedGroup(store, snapshotBatchGroup(store, batch, "error"));
1854
+ retireFinishedGroup(store, snapshotBatchGroup(store, batch, batchTerminalStatus));
1763
1855
  if (isInOriginSession(ctx, batch.originSessionFile)) {
1764
1856
  pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
1765
1857
  clearPendingGroupCompletion("batch", batchId);
@@ -1777,6 +1869,8 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1777
1869
  }
1778
1870
  }
1779
1871
  updateCommandRunsWidget(store);
1872
+ } finally {
1873
+ clearRunAbortState(runState);
1780
1874
  }
1781
1875
  })();
1782
1876
  }
@@ -1828,7 +1922,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1828
1922
 
1829
1923
  if (!shouldRunAsync) {
1830
1924
  let previousOutput = "";
1831
- let terminalStatus: "completed" | "stopped" | "error" = "completed";
1925
+ let terminalStatus: "completed" | "stopped" | "error" | "aborted" = "completed";
1832
1926
  const finalizedRuns: FinalizedRun[] = [];
1833
1927
  const pipeline = store.pipelines.get(pipelineId);
1834
1928
  if (!pipeline) {
@@ -1895,29 +1989,30 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1895
1989
  finalizedRuns.push(finalized);
1896
1990
 
1897
1991
  if (runState.removed) {
1898
- terminalStatus = finalized.isError ? "error" : "stopped";
1992
+ terminalStatus = "aborted";
1899
1993
  pipeline.stepResults.push({
1900
1994
  runId: runState.id,
1901
1995
  agent: runState.agent,
1902
1996
  task: step.task,
1903
1997
  output: finalized.rawOutput || "Run removed before pipeline completion.",
1904
- status: "error",
1998
+ status: "aborted",
1905
1999
  });
1906
2000
  break;
1907
2001
  }
1908
2002
 
2003
+ const stepStatus = getFinalizedRunStatus(finalized);
1909
2004
  pipeline.stepResults.push({
1910
2005
  runId: runState.id,
1911
2006
  agent: runState.agent,
1912
2007
  task: step.task,
1913
2008
  output: finalized.rawOutput,
1914
- status: finalized.isError ? "error" : "done",
2009
+ status: stepStatus,
1915
2010
  });
1916
2011
  previousOutput = finalized.rawOutput;
1917
2012
  updateCommandRunsWidget(store);
1918
2013
 
1919
- if (finalized.isError) {
1920
- terminalStatus = "error";
2014
+ if (stepStatus !== "done") {
2015
+ terminalStatus = stepStatus;
1921
2016
  break;
1922
2017
  }
1923
2018
  }
@@ -1933,7 +2028,9 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1933
2028
  }
1934
2029
 
1935
2030
  const hasError = pipeline.stepResults.some((step) => step.status === "error");
1936
- if (terminalStatus === "completed" && hasError) terminalStatus = "error";
2031
+ const hasAborted = pipeline.stepResults.some((step) => step.status === "aborted");
2032
+ if (hasError) terminalStatus = "error";
2033
+ else if (terminalStatus === "completed" && hasAborted) terminalStatus = "aborted";
1937
2034
  const content = formatPipelineSummary(pipelineId, pipeline.stepResults, terminalStatus);
1938
2035
  retireFinishedGroup(store, snapshotPipeline(pipeline, terminalStatus));
1939
2036
  for (const runId of pipeline.stepRunIds) cleanupRunAfterFinalDelivery(runId);
@@ -1963,7 +2060,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1963
2060
 
1964
2061
  void (async () => {
1965
2062
  let previousOutput = "";
1966
- let terminalStatus: "completed" | "stopped" | "error" = "completed";
2063
+ let terminalStatus: "completed" | "stopped" | "error" | "aborted" = "completed";
1967
2064
  try {
1968
2065
  for (let index = 0; index < steps.length; index++) {
1969
2066
  const pipeline = store.pipelines.get(pipelineId);
@@ -2021,13 +2118,13 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
2021
2118
  clearRunAbortState(runState);
2022
2119
  }
2023
2120
  if (runState.removed) {
2024
- terminalStatus = finalized.isError ? "error" : "stopped";
2121
+ terminalStatus = "aborted";
2025
2122
  pipeline.stepResults.push({
2026
2123
  runId: runState.id,
2027
2124
  agent: runState.agent,
2028
2125
  task: step.task,
2029
2126
  output: finalized.rawOutput || "Run removed before pipeline completion.",
2030
- status: "error",
2127
+ status: "aborted",
2031
2128
  });
2032
2129
  break;
2033
2130
  }
@@ -2037,14 +2134,14 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
2037
2134
  agent: runState.agent,
2038
2135
  task: step.task,
2039
2136
  output: finalized.rawOutput,
2040
- status: finalized.isError ? "error" : "done",
2137
+ status: getFinalizedRunStatus(finalized),
2041
2138
  };
2042
2139
  pipeline.stepResults.push(stepResult);
2043
2140
  previousOutput = finalized.rawOutput;
2044
2141
  updateCommandRunsWidget(store);
2045
2142
 
2046
- if (finalized.isError) {
2047
- terminalStatus = "error";
2143
+ if (stepResult.status !== "done") {
2144
+ terminalStatus = stepResult.status;
2048
2145
  break;
2049
2146
  }
2050
2147
  }
@@ -2064,9 +2161,9 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
2064
2161
  const pipeline = store.pipelines.get(pipelineId);
2065
2162
  if (pipeline) {
2066
2163
  const hasError = pipeline.stepResults.some((step) => step.status === "error");
2067
- if (terminalStatus === "completed" && hasError) {
2068
- terminalStatus = "error";
2069
- }
2164
+ const hasAborted = pipeline.stepResults.some((step) => step.status === "aborted");
2165
+ if (hasError) terminalStatus = "error";
2166
+ else if (terminalStatus === "completed" && hasAborted) terminalStatus = "aborted";
2070
2167
  const orderedRuns = pipeline.stepRunIds
2071
2168
  .map((runId) => store.commandRuns.get(runId))
2072
2169
  .filter((run): run is CommandRunState => Boolean(run));
package/types.ts CHANGED
@@ -106,6 +106,8 @@ export interface CommandRunState {
106
106
  lastToolOutputChars?: number;
107
107
  /** Hang detector reason preserved until the normal finalizer emits the sole completion. */
108
108
  autoAbortReason?: string;
109
+ /** True while a removed invocation is still settling; blocks same-ID continuation races. */
110
+ abortPending?: boolean;
109
111
  runtime?: AgentRuntime;
110
112
  claudeSessionId?: string;
111
113
  claudeProjectDir?: string;
@@ -172,6 +174,7 @@ export interface BatchGroupState {
172
174
  runIds: number[];
173
175
  completedRunIds: Set<number>;
174
176
  failedRunIds: Set<number>;
177
+ abortedRunIds: Set<number>;
175
178
  originSessionFile: string;
176
179
  createdAt: number;
177
180
  pendingResults: Map<number, string>;
@@ -183,7 +186,7 @@ export interface PipelineStepResult {
183
186
  agent: string;
184
187
  task: string;
185
188
  output: string;
186
- status: "done" | "error";
189
+ status: "done" | "error" | "aborted";
187
190
  }
188
191
 
189
192
  export interface PipelineState {
@@ -213,10 +216,11 @@ export interface FinishedGroupMember {
213
216
  export interface FinishedGroupSnapshot {
214
217
  groupId: string;
215
218
  kind: "batch" | "chain";
216
- terminalStatus: "completed" | "error" | "stopped";
219
+ terminalStatus: "completed" | "error" | "aborted" | "stopped";
217
220
  finishedAt: number;
218
221
  total: number;
219
222
  failed: number;
223
+ aborted: number;
220
224
  members: FinishedGroupMember[];
221
225
  }
222
226
 
package/widget.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
6
6
  import { Box, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
7
  import { HANG_WARNING_IDLE_MS, PARENT_HINT } from "./constants.js";
8
+ import { getSubagentRunStatusLabel } from "./failure-telemetry.js";
8
9
  import {
9
10
  AGENT_NAME_PALETTE,
10
11
  agentBgIndex,
@@ -79,9 +80,12 @@ export function cleanupCommandRunsWidgetTimer(): void {
79
80
  }
80
81
 
81
82
  function getStatusVisual(run: CommandRunState): { statusColor: ThemeColor; statusIcon: string } {
82
- const statusColor: ThemeColor = run.status === "running" ? "warning" : run.status === "done" ? "success" : "error";
83
+ const statusLabel = getSubagentRunStatusLabel(run.status, run.errorClass);
84
+ const statusColor: ThemeColor =
85
+ statusLabel === "running" || statusLabel === "aborted" ? "warning" : statusLabel === "done" ? "success" : "error";
83
86
  const spinnerFrame = SPINNER_FRAMES[Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length];
84
- const statusIcon = run.status === "running" ? spinnerFrame : run.status === "done" ? "✓" : "✗";
87
+ const statusIcon =
88
+ statusLabel === "running" ? spinnerFrame : statusLabel === "done" ? "✓" : statusLabel === "aborted" ? "■" : "✗";
85
89
  return { statusColor, statusIcon };
86
90
  }
87
91