@ryan_nookpi/pi-extension-subagent 0.5.3 → 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.3",
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: [
@@ -1473,7 +1557,6 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1473
1557
  existingRunState: continueFromRun,
1474
1558
  });
1475
1559
  const launchSummary = toLaunchSummary(runState, continueFromRun ? "continue" : "run");
1476
- const startedState = continueFromRun ? "resumed" : "started";
1477
1560
 
1478
1561
  if (!shouldRunAsync) {
1479
1562
  let finalized: FinalizedRun;
@@ -1506,7 +1589,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1506
1589
  };
1507
1590
  }
1508
1591
 
1509
- pi.sendMessage(buildRunStartMessage(runState, startedState), { deliverAs: "followUp", triggerTurn: false });
1592
+ // No run-start sendMessage here: mid-turn triggerTurn:false messages land between tool call and result (pi #8537).
1510
1593
  ctx.ui?.notify?.(
1511
1594
  `${continueFromRun ? `Resumed subagent #${runState.id}` : `Started subagent #${runState.id}`}: ${resolvedAgent}`,
1512
1595
  "info",
@@ -1531,6 +1614,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1531
1614
  return;
1532
1615
  }
1533
1616
 
1617
+ const terminalLabel = getFinalizedRunLabel(finalized);
1534
1618
  const completionMessage = buildRunCompletionMessage(finalized);
1535
1619
  if (isInOriginSession(ctx, originSessionFile)) {
1536
1620
  pi.sendMessage(completionMessage, { deliverAs: "followUp", triggerTurn: true });
@@ -1541,14 +1625,13 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1541
1625
  }
1542
1626
 
1543
1627
  ctx.ui?.notify?.(
1544
- finalized.isError
1545
- ? `subagent tool run #${runState.id} (${resolvedAgent}) failed`
1546
- : `subagent tool run #${runState.id} (${resolvedAgent}) completed`,
1547
- finalized.isError ? "error" : "info",
1628
+ `subagent tool run #${runState.id} (${resolvedAgent}) ${terminalLabel}`,
1629
+ terminalLabel === "completed" ? "info" : terminalLabel === "aborted" ? "warning" : "error",
1548
1630
  );
1549
1631
  } catch (error: unknown) {
1550
1632
  if (runState.removed || store.disposed) return;
1551
1633
  const finalized = finalizeRunError(runState, error);
1634
+ const terminalLabel = getFinalizedRunLabel(finalized);
1552
1635
  const errorMessage = buildRunCompletionMessage(finalized);
1553
1636
  if (isInOriginSession(ctx, originSessionFile)) {
1554
1637
  pi.sendMessage(errorMessage, { deliverAs: "followUp", triggerTurn: true });
@@ -1557,7 +1640,10 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1557
1640
  const entry = store.globalLiveRuns.get(runState.id);
1558
1641
  if (entry) entry.pendingCompletion = makePendingCompletion(errorMessage, true);
1559
1642
  }
1560
- 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
+ );
1561
1647
  updateCommandRunsWidget(store);
1562
1648
  } finally {
1563
1649
  clearRunAbortState(runState);
@@ -1628,6 +1714,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1628
1714
  runIds: runStates.map(({ runState }) => runState.id),
1629
1715
  completedRunIds: new Set(),
1630
1716
  failedRunIds: new Set(),
1717
+ abortedRunIds: new Set(),
1631
1718
  originSessionFile,
1632
1719
  createdAt: Date.now(),
1633
1720
  pendingResults: new Map(),
@@ -1647,11 +1734,18 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1647
1734
  }),
1648
1735
  );
1649
1736
  const orderedRuns = runStates.map(({ runState }) => runState);
1650
- const hasError = finalizedRuns.some((finalized) => finalized.isError);
1651
- const content = formatBatchSummary(batchId, orderedRuns, hasError ? "error" : "completed");
1652
1737
  const batchForSnapshot = store.batchGroups.get(batchId);
1653
- if (batchForSnapshot)
1654
- 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
+ }
1655
1749
  for (const { runState } of runStates) cleanupRunAfterFinalDelivery(runState.id);
1656
1750
  clearPendingGroupCompletion("batch", batchId);
1657
1751
  store.batchGroups.delete(batchId);
@@ -1673,7 +1767,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1673
1767
  .filter((result): result is SingleResult => Boolean(result)),
1674
1768
  runStates.map(({ runState }) => toLaunchSummary(runState, "batch")),
1675
1769
  ),
1676
- isError: hasError,
1770
+ isError: terminalStatus !== "completed",
1677
1771
  };
1678
1772
  }
1679
1773
 
@@ -1684,16 +1778,14 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1684
1778
 
1685
1779
  const batch = store.batchGroups.get(batchId);
1686
1780
  if (!batch) return;
1687
- batch.completedRunIds.add(runState.id);
1688
- if (finalized.isError) batch.failedRunIds.add(runState.id);
1689
- batch.pendingResults.set(runState.id, finalized.rawOutput);
1781
+ recordBatchResult(batch, finalized);
1690
1782
  updateCommandRunsWidget(store);
1691
1783
 
1692
1784
  if (batch.completedRunIds.size === batch.runIds.length) {
1693
1785
  const orderedRuns = batch.runIds
1694
1786
  .map((runId) => store.commandRuns.get(runId))
1695
1787
  .filter((run): run is CommandRunState => Boolean(run));
1696
- const batchTerminalStatus = batch.failedRunIds.size > 0 ? "error" : "completed";
1788
+ const batchTerminalStatus = resolveGroupTerminalStatus(batch.failedRunIds.size, batch.abortedRunIds.size);
1697
1789
  const content = formatBatchSummary(batchId, orderedRuns, batchTerminalStatus);
1698
1790
  const message = {
1699
1791
  customType: "subagent-tool" as const,
@@ -1702,7 +1794,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1702
1794
  details: {
1703
1795
  batchId,
1704
1796
  runIds: batch.runIds,
1705
- status: batch.failedRunIds.size > 0 ? "error" : "done",
1797
+ status: batchTerminalStatus === "completed" ? "done" : batchTerminalStatus,
1706
1798
  runSummaries: orderedRuns.map((run) => buildRunAnalyticsSummary(run)),
1707
1799
  },
1708
1800
  };
@@ -1730,38 +1822,36 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1730
1822
  removalReason: "trim",
1731
1823
  });
1732
1824
  ctx.ui?.notify?.(
1733
- batch.failedRunIds.size > 0
1825
+ batchTerminalStatus === "error"
1734
1826
  ? `subagent batch ${batchId} finished with errors`
1735
- : `subagent batch ${batchId} completed`,
1736
- 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",
1737
1831
  );
1738
1832
  }
1739
1833
  } catch (error: unknown) {
1740
- runState.status = "error";
1741
- runState.elapsedMs = Date.now() - runState.startedAt;
1742
- runState.lastLine = error instanceof Error ? error.message : "Subagent execution failed";
1743
- runState.lastOutput = runState.lastLine;
1834
+ const finalized = finalizeRunError(runState, error);
1744
1835
  const batch = store.batchGroups.get(batchId);
1745
1836
  if (!batch) return;
1746
- batch.completedRunIds.add(runState.id);
1747
- batch.failedRunIds.add(runState.id);
1748
- batch.pendingResults.set(runState.id, runState.lastLine);
1837
+ recordBatchResult(batch, finalized);
1749
1838
  if (batch.completedRunIds.size === batch.runIds.length) {
1750
1839
  const orderedRuns = batch.runIds
1751
1840
  .map((runId) => store.commandRuns.get(runId))
1752
1841
  .filter((run): run is CommandRunState => Boolean(run));
1842
+ const batchTerminalStatus = resolveGroupTerminalStatus(batch.failedRunIds.size, batch.abortedRunIds.size);
1753
1843
  const message = {
1754
1844
  customType: "subagent-tool" as const,
1755
- content: formatBatchSummary(batchId, orderedRuns, "error"),
1845
+ content: formatBatchSummary(batchId, orderedRuns, batchTerminalStatus),
1756
1846
  display: true,
1757
1847
  details: {
1758
1848
  batchId,
1759
1849
  runIds: batch.runIds,
1760
- status: "error",
1850
+ status: batchTerminalStatus,
1761
1851
  runSummaries: orderedRuns.map((run) => buildRunAnalyticsSummary(run)),
1762
1852
  },
1763
1853
  };
1764
- retireFinishedGroup(store, snapshotBatchGroup(store, batch, "error"));
1854
+ retireFinishedGroup(store, snapshotBatchGroup(store, batch, batchTerminalStatus));
1765
1855
  if (isInOriginSession(ctx, batch.originSessionFile)) {
1766
1856
  pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
1767
1857
  clearPendingGroupCompletion("batch", batchId);
@@ -1779,6 +1869,8 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1779
1869
  }
1780
1870
  }
1781
1871
  updateCommandRunsWidget(store);
1872
+ } finally {
1873
+ clearRunAbortState(runState);
1782
1874
  }
1783
1875
  })();
1784
1876
  }
@@ -1830,7 +1922,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1830
1922
 
1831
1923
  if (!shouldRunAsync) {
1832
1924
  let previousOutput = "";
1833
- let terminalStatus: "completed" | "stopped" | "error" = "completed";
1925
+ let terminalStatus: "completed" | "stopped" | "error" | "aborted" = "completed";
1834
1926
  const finalizedRuns: FinalizedRun[] = [];
1835
1927
  const pipeline = store.pipelines.get(pipelineId);
1836
1928
  if (!pipeline) {
@@ -1897,29 +1989,30 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1897
1989
  finalizedRuns.push(finalized);
1898
1990
 
1899
1991
  if (runState.removed) {
1900
- terminalStatus = finalized.isError ? "error" : "stopped";
1992
+ terminalStatus = "aborted";
1901
1993
  pipeline.stepResults.push({
1902
1994
  runId: runState.id,
1903
1995
  agent: runState.agent,
1904
1996
  task: step.task,
1905
1997
  output: finalized.rawOutput || "Run removed before pipeline completion.",
1906
- status: "error",
1998
+ status: "aborted",
1907
1999
  });
1908
2000
  break;
1909
2001
  }
1910
2002
 
2003
+ const stepStatus = getFinalizedRunStatus(finalized);
1911
2004
  pipeline.stepResults.push({
1912
2005
  runId: runState.id,
1913
2006
  agent: runState.agent,
1914
2007
  task: step.task,
1915
2008
  output: finalized.rawOutput,
1916
- status: finalized.isError ? "error" : "done",
2009
+ status: stepStatus,
1917
2010
  });
1918
2011
  previousOutput = finalized.rawOutput;
1919
2012
  updateCommandRunsWidget(store);
1920
2013
 
1921
- if (finalized.isError) {
1922
- terminalStatus = "error";
2014
+ if (stepStatus !== "done") {
2015
+ terminalStatus = stepStatus;
1923
2016
  break;
1924
2017
  }
1925
2018
  }
@@ -1935,7 +2028,9 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1935
2028
  }
1936
2029
 
1937
2030
  const hasError = pipeline.stepResults.some((step) => step.status === "error");
1938
- 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";
1939
2034
  const content = formatPipelineSummary(pipelineId, pipeline.stepResults, terminalStatus);
1940
2035
  retireFinishedGroup(store, snapshotPipeline(pipeline, terminalStatus));
1941
2036
  for (const runId of pipeline.stepRunIds) cleanupRunAfterFinalDelivery(runId);
@@ -1965,7 +2060,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1965
2060
 
1966
2061
  void (async () => {
1967
2062
  let previousOutput = "";
1968
- let terminalStatus: "completed" | "stopped" | "error" = "completed";
2063
+ let terminalStatus: "completed" | "stopped" | "error" | "aborted" = "completed";
1969
2064
  try {
1970
2065
  for (let index = 0; index < steps.length; index++) {
1971
2066
  const pipeline = store.pipelines.get(pipelineId);
@@ -2023,13 +2118,13 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
2023
2118
  clearRunAbortState(runState);
2024
2119
  }
2025
2120
  if (runState.removed) {
2026
- terminalStatus = finalized.isError ? "error" : "stopped";
2121
+ terminalStatus = "aborted";
2027
2122
  pipeline.stepResults.push({
2028
2123
  runId: runState.id,
2029
2124
  agent: runState.agent,
2030
2125
  task: step.task,
2031
2126
  output: finalized.rawOutput || "Run removed before pipeline completion.",
2032
- status: "error",
2127
+ status: "aborted",
2033
2128
  });
2034
2129
  break;
2035
2130
  }
@@ -2039,14 +2134,14 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
2039
2134
  agent: runState.agent,
2040
2135
  task: step.task,
2041
2136
  output: finalized.rawOutput,
2042
- status: finalized.isError ? "error" : "done",
2137
+ status: getFinalizedRunStatus(finalized),
2043
2138
  };
2044
2139
  pipeline.stepResults.push(stepResult);
2045
2140
  previousOutput = finalized.rawOutput;
2046
2141
  updateCommandRunsWidget(store);
2047
2142
 
2048
- if (finalized.isError) {
2049
- terminalStatus = "error";
2143
+ if (stepResult.status !== "done") {
2144
+ terminalStatus = stepResult.status;
2050
2145
  break;
2051
2146
  }
2052
2147
  }
@@ -2066,9 +2161,9 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
2066
2161
  const pipeline = store.pipelines.get(pipelineId);
2067
2162
  if (pipeline) {
2068
2163
  const hasError = pipeline.stepResults.some((step) => step.status === "error");
2069
- if (terminalStatus === "completed" && hasError) {
2070
- terminalStatus = "error";
2071
- }
2164
+ const hasAborted = pipeline.stepResults.some((step) => step.status === "aborted");
2165
+ if (hasError) terminalStatus = "error";
2166
+ else if (terminalStatus === "completed" && hasAborted) terminalStatus = "aborted";
2072
2167
  const orderedRuns = pipeline.stepRunIds
2073
2168
  .map((runId) => store.commandRuns.get(runId))
2074
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