pi-herdr-subagents 0.1.0 → 0.1.2

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.
@@ -26,6 +26,7 @@ import {
26
26
  renameCurrentWorkspace,
27
27
  readPane,
28
28
  readPaneAsync,
29
+ inspectPane,
29
30
  } from "./terminal.ts";
30
31
  import { waitForCompletion } from "./completion.ts";
31
32
 
@@ -35,16 +36,11 @@ import {
35
36
  seedSubagentSessionFile,
36
37
  } from "./session.ts";
37
38
  import {
38
- type StatusSnapshot,
39
39
  type SubagentStatusState,
40
- advanceStatusState,
41
40
  capStatusLines,
42
- classifyStatus,
43
- createStatusState,
44
- forceStatusAfterInterrupt,
41
+ formatElapsedDuration,
45
42
  formatStatusAggregate,
46
- formatTransitionLine,
47
- observeStatus,
43
+ normalizeStatusName,
48
44
  loadStatusConfig,
49
45
  } from "./status.ts";
50
46
  import {
@@ -53,16 +49,33 @@ import {
53
49
  type ActivityReadResult,
54
50
  type SubagentActivityState,
55
51
  } from "./activity.ts";
52
+ import {
53
+ createLifecycle,
54
+ formatLifecycleTransitionLine,
55
+ lifecycleTransition,
56
+ markCompleted,
57
+ markCompletionDetected,
58
+ markDelivery,
59
+ markFailed,
60
+ markInterruptRequested,
61
+ markProcessRunning,
62
+ observeActivity,
63
+ observePaneInspection,
64
+ projectLifecycle,
65
+ type LifecycleProjection,
66
+ type SubagentLifecycle,
67
+ type PaneInspection,
68
+ } from "./lifecycle.ts";
56
69
 
57
70
  /** Absolute path to `pi-extension/subagents`. https://github.com/nodejs/node/issues/37845 */
58
71
  const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url));
59
72
 
60
- // Survive /reload: clear timers and abort poll loops from the previous module load.
61
- // /reload re-imports this file, giving fresh module-level state, but closures from
62
- // the old module keep running. These global symbols preserve reload compatibility.
73
+ // Survive /reload: replace presentation timers while keeping active completion
74
+ // watchers and their registry alive. Old module closures continue watching the
75
+ // children; the reloaded module adopts the shared registry for status/interrupts.
63
76
  const WIDGET_INTERVAL_KEY = Symbol.for("pi-subagents/widget-interval");
64
77
  const STATUS_INTERVAL_KEY = Symbol.for("pi-subagents/status-interval");
65
- const POLL_ABORT_KEY = Symbol.for("pi-subagents/poll-abort-controller");
78
+ const RUNTIME_KEY = Symbol.for("pi-subagents/runtime");
66
79
 
67
80
  {
68
81
  const prevInterval = (globalThis as any)[WIDGET_INTERVAL_KEY];
@@ -75,13 +88,6 @@ const POLL_ABORT_KEY = Symbol.for("pi-subagents/poll-abort-controller");
75
88
  clearInterval(prevStatusInterval);
76
89
  (globalThis as any)[STATUS_INTERVAL_KEY] = null;
77
90
  }
78
- const prevAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
79
- if (prevAbort) prevAbort.abort();
80
- (globalThis as any)[POLL_ABORT_KEY] = new AbortController();
81
- }
82
-
83
- function getModuleAbortSignal(): AbortSignal {
84
- return ((globalThis as any)[POLL_ABORT_KEY] as AbortController).signal;
85
91
  }
86
92
 
87
93
  const SubagentParams = Type.Object({
@@ -417,25 +423,6 @@ function getArtifactDir(sessionDir: string, sessionId: string): string {
417
423
 
418
424
  const statusConfig = loadStatusConfig();
419
425
 
420
- function formatWidgetRightLabel(snapshot: StatusSnapshot): string {
421
- if (snapshot.kind === "starting") return " starting… ";
422
- if (snapshot.kind === "running") return ` running ${snapshot.elapsedText} `;
423
- if (snapshot.kind === "active") {
424
- const label = snapshot.activityLabel ?? snapshot.activeScope;
425
- const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : "";
426
- return label ? ` active · ${label}${duration} ` : " active ";
427
- }
428
- if (snapshot.kind === "waiting") {
429
- const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : "";
430
- const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
431
- return ` waiting${duration}${detail} `;
432
- }
433
-
434
- const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
435
- const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : "";
436
- return ` stalled${detail}${duration} `;
437
- }
438
-
439
426
  function resolveResultPresentation(
440
427
  result: Pick<
441
428
  SubagentResult,
@@ -505,7 +492,14 @@ interface RunningSubagent {
505
492
  abortController?: AbortController;
506
493
  cli?: string;
507
494
  sentinelFile?: string;
508
- statusState: SubagentStatusState;
495
+ /**
496
+ * Optional legacy status snapshot retained only for hydrating pre-lifecycle
497
+ * runtime entries after /reload. Live observation uses `lifecycle` only.
498
+ */
499
+ statusState?: SubagentStatusState;
500
+ lifecycle: SubagentLifecycle;
501
+ /** Last projected kind used to detect stalled/recovered transitions. */
502
+ lastProjectedKind?: LifecycleProjection["kind"];
509
503
  /**
510
504
  * When true, status transitions (stalled/recovered) do not wake the parent
511
505
  * session via a steer message. The widget still updates locally. Used for
@@ -515,13 +509,54 @@ interface RunningSubagent {
515
509
  interactive: boolean;
516
510
  }
517
511
 
518
- /** All currently running subagents, keyed by id. */
519
- const runningSubagents = new Map<string, RunningSubagent>();
512
+ interface SubagentRuntime {
513
+ runningSubagents: Map<string, RunningSubagent>;
514
+ pi?: ExtensionAPI;
515
+ latestCtx?: ExtensionContext;
516
+ }
520
517
 
521
- // ── Widget management ──
518
+ function createSubagentRuntime(): SubagentRuntime {
519
+ return { runningSubagents: new Map<string, RunningSubagent>() };
520
+ }
521
+
522
+ /** Runtime state preserved across /reload. */
523
+ const runtime: SubagentRuntime =
524
+ (globalThis as any)[RUNTIME_KEY] ??
525
+ ((globalThis as any)[RUNTIME_KEY] = createSubagentRuntime());
526
+ const runningSubagents = runtime.runningSubagents;
527
+
528
+ export function shouldPreserveSubagentsOnShutdown(reason: unknown): boolean {
529
+ return reason === "reload";
530
+ }
522
531
 
523
- /** Latest ExtensionContext from session_start, used for widget updates. */
524
- let latestCtx: ExtensionContext | null = null;
532
+ export function cleanupSubagentsForShutdown(
533
+ reason: unknown,
534
+ agents: Map<string, Pick<RunningSubagent, "abortController" | "lifecycle">>,
535
+ ): void {
536
+ if (shouldPreserveSubagentsOnShutdown(reason)) return;
537
+
538
+ for (const agent of agents.values()) {
539
+ if (agent.lifecycle) {
540
+ agent.lifecycle = markDelivery(agent.lifecycle, "suppressed");
541
+ }
542
+ agent.abortController?.abort();
543
+ }
544
+ agents.clear();
545
+ }
546
+
547
+ export function shouldDeliverSubagentCompletion(
548
+ running: Pick<RunningSubagent, "lifecycle">,
549
+ ): boolean {
550
+ // Authoritative gate: only pending deliveries may be sent.
551
+ // Missing lifecycle (pre-migration fixtures) defaults to pending/true.
552
+ return (running.lifecycle?.delivery ?? "pending") === "pending";
553
+ }
554
+
555
+ export function selectCompletionApi<T>(previous: T, current: T | undefined): T {
556
+ return current ?? previous;
557
+ }
558
+
559
+ // ── Widget management ──
525
560
 
526
561
  /** Interval timer for widget re-renders. */
527
562
  let widgetInterval: ReturnType<typeof setInterval> | null = null;
@@ -529,23 +564,24 @@ let widgetInterval: ReturnType<typeof setInterval> | null = null;
529
564
  /** Interval timer for status transition checks. */
530
565
  let statusInterval: ReturnType<typeof setInterval> | null = null;
531
566
 
532
- function formatElapsedMMSS(startTime: number): string {
533
- const seconds = Math.floor((Date.now() - startTime) / 1000);
567
+ function formatElapsedMMSS(startTime: number, endTime = Date.now()): string {
568
+ const seconds = Math.floor((endTime - startTime) / 1000);
534
569
  const m = Math.floor(seconds / 60);
535
570
  const s = seconds % 60;
536
571
  return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
537
572
  }
538
573
 
539
- const ACCENT = "\x1b[38;2;77;163;255m";
574
+ const ACTIVE_ACCENT = "\x1b[38;2;77;163;255m";
575
+ const OPEN_ACCENT = "\x1b[38;2;214;158;46m";
540
576
  const RST = "\x1b[0m";
541
577
 
542
578
  /**
543
579
  * Build a bordered content line: │left right│
544
580
  * Left content is truncated if needed, right is preserved, padded to fill width.
545
581
  */
546
- function borderLine(left: string, right: string, width: number): string {
582
+ function borderLine(left: string, right: string, width: number, accent = ACTIVE_ACCENT): string {
547
583
  if (width <= 0) return "";
548
- if (width === 1) return `${ACCENT}│${RST}`;
584
+ if (width === 1) return `${accent}│${RST}`;
549
585
 
550
586
  // width = total visible chars for the whole line including │ and │
551
587
  const contentWidth = Math.max(0, width - 2); // space inside the two │ chars
@@ -556,23 +592,23 @@ function borderLine(left: string, right: string, width: number): string {
556
592
  if (rightVis >= contentWidth) {
557
593
  const truncRight = truncateToWidth(right, contentWidth);
558
594
  const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight));
559
- return `${ACCENT}│${RST}${truncRight}${" ".repeat(rightPad)}${ACCENT}│${RST}`;
595
+ return `${accent}│${RST}${truncRight}${" ".repeat(rightPad)}${accent}│${RST}`;
560
596
  }
561
597
 
562
598
  const maxLeft = Math.max(0, contentWidth - rightVis);
563
599
  const truncLeft = truncateToWidth(left, maxLeft);
564
600
  const leftVis = visibleWidth(truncLeft);
565
601
  const pad = Math.max(0, contentWidth - leftVis - rightVis);
566
- return `${ACCENT}│${RST}${truncLeft}${" ".repeat(pad)}${right}${ACCENT}│${RST}`;
602
+ return `${accent}│${RST}${truncLeft}${" ".repeat(pad)}${right}${accent}│${RST}`;
567
603
  }
568
604
 
569
605
  /**
570
606
  * Build the bordered top line: ╭─ Title ──── info ─╮
571
607
  * All chars are accounted for within `width`.
572
608
  */
573
- function borderTop(title: string, info: string, width: number): string {
609
+ function borderTop(title: string, info: string, width: number, accent = ACTIVE_ACCENT): string {
574
610
  if (width <= 0) return "";
575
- if (width === 1) return `${ACCENT}╭${RST}`;
611
+ if (width === 1) return `${accent}╭${RST}`;
576
612
 
577
613
  // ╭─ Title ───...─── info ─╮
578
614
  // overhead: ╭─ (2) + space around title (2) + space around info (2) + ─╮ (2) = but we simplify
@@ -582,46 +618,84 @@ function borderTop(title: string, info: string, width: number): string {
582
618
  const fillLen = Math.max(0, inner - titlePart.length - infoPart.length);
583
619
  const fill = "─".repeat(fillLen);
584
620
  const content = `${titlePart}${fill}${infoPart}`.slice(0, inner).padEnd(inner, "─");
585
- return `${ACCENT}╭${content}╮${RST}`;
621
+ return `${accent}╭${content}╮${RST}`;
586
622
  }
587
623
 
588
624
  /**
589
625
  * Build the bordered bottom line: ╰──────────────────╯
590
626
  */
591
- function borderBottom(width: number): string {
627
+ function borderBottom(width: number, accent = ACTIVE_ACCENT): string {
592
628
  if (width <= 0) return "";
593
- if (width === 1) return `${ACCENT}╰${RST}`;
629
+ if (width === 1) return `${accent}╰${RST}`;
594
630
 
595
631
  const inner = Math.max(0, width - 2);
596
- return `${ACCENT}╰${"─".repeat(inner)}╯${RST}`;
632
+ return `${accent}╰${"─".repeat(inner)}╯${RST}`;
597
633
  }
598
634
 
599
- function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): string[] {
600
- const count = agents.length;
601
- const title = "Subagents";
602
- const info = `${count} running`;
603
-
604
- const lines: string[] = [borderTop(title, info, width)];
635
+ function formatLifecycleWidgetLabel(
636
+ projection: ReturnType<typeof projectLifecycle>,
637
+ now: number,
638
+ ): string {
639
+ const duration = projection.stateDurationSince == null
640
+ ? ""
641
+ : ` ${formatElapsedDuration(now - projection.stateDurationSince)}`;
642
+ if (projection.kind === "active") return projection.label
643
+ ? ` active · ${projection.label}${duration} `
644
+ : ` active${duration} `;
645
+ if (projection.kind === "blocked") return ` blocked${duration} `;
646
+ if (projection.kind === "running") return " running… ";
647
+ if (projection.kind === "waiting") return ` waiting${duration} `;
648
+ if (projection.kind === "interrupted") return ` interrupted${duration} `;
649
+ if (projection.kind === "stalled") return ` stalled${duration} `;
650
+ // completed/failed exist as lifecycle projections for delivery bookkeeping,
651
+ // but the row is removed immediately after result delivery — so the only
652
+ // visible terminal handoff label is finalizing.
653
+ if (
654
+ projection.kind === "finalizing" ||
655
+ projection.kind === "completed" ||
656
+ projection.kind === "failed"
657
+ ) {
658
+ return " finalizing… ";
659
+ }
660
+ return " starting… ";
661
+ }
605
662
 
606
- for (const agent of agents) {
607
- const elapsed = formatElapsedMMSS(agent.startTime);
663
+ function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): string[] {
664
+ const now = Date.now();
665
+ const rendered = agents.map((agent) => ({ agent, projection: projectLifecycle(ensureLifecycle(agent), now) }));
666
+ const activeCount = rendered.filter(({ projection }) =>
667
+ projection.kind === "active" ||
668
+ projection.kind === "starting" ||
669
+ projection.kind === "running" ||
670
+ projection.kind === "blocked"
671
+ ).length;
672
+ const openCount = agents.length - activeCount;
673
+ const info = activeCount > 0
674
+ ? openCount > 0 ? `${activeCount} active · ${openCount} open` : `${activeCount} active`
675
+ : `${openCount} open`;
676
+ const accent = activeCount > 0 ? ACTIVE_ACCENT : OPEN_ACCENT;
677
+
678
+ const lines: string[] = [borderTop("Subagents", info, width, accent)];
679
+
680
+ for (const { agent, projection } of rendered) {
681
+ const elapsed = formatElapsedMMSS(agent.startTime, projection.runtimeEndedAt ?? now);
608
682
  const agentTag = agent.agent ? ` (${agent.agent})` : "";
609
683
  const left = ` ${elapsed} ${agent.name}${agentTag} `;
610
- const snapshot = classifyStatus(agent.statusState, Date.now());
611
684
  const right = statusConfig.enabled
612
- ? formatWidgetRightLabel(snapshot)
685
+ ? formatLifecycleWidgetLabel(projection, now)
613
686
  : agent.cli === "claude"
614
687
  ? " running… "
615
688
  : " starting… ";
616
689
 
617
- lines.push(borderLine(left, right, width));
690
+ lines.push(borderLine(left, right, width, accent));
618
691
  }
619
692
 
620
- lines.push(borderBottom(width));
693
+ lines.push(borderBottom(width, accent));
621
694
  return lines;
622
695
  }
623
696
 
624
697
  function updateWidget() {
698
+ const latestCtx = runtime.latestCtx;
625
699
  if (!latestCtx?.hasUI) return;
626
700
 
627
701
  if (runningSubagents.size === 0) {
@@ -706,15 +780,59 @@ function buildPiPromptArgs(params: {
706
780
  ];
707
781
  }
708
782
 
709
- function activityLabel(activity: SubagentActivityState): string | undefined {
710
- if (activity.phase !== "active") return undefined;
711
- if (activity.activeScope === "tool") return activity.toolName ?? "tool";
712
- if (activity.activeScope === "provider") return "provider";
713
- if (activity.activeScope === "streaming") return "streaming";
714
- return activity.activeScope;
783
+ function ensureLifecycle(running: RunningSubagent): SubagentLifecycle {
784
+ if (running.lifecycle) return running.lifecycle;
785
+ let lifecycle = createLifecycle(running.startTime);
786
+ // Claude agents have no activity snapshots; treat confirmed launch as running.
787
+ if (running.cli === "claude") {
788
+ lifecycle = markProcessRunning(lifecycle, running.startTime);
789
+ running.lifecycle = lifecycle;
790
+ return lifecycle;
791
+ }
792
+ const state = running.statusState;
793
+ if (state?.activityLabel === "interrupted" && state.localOverrideAtMs != null) {
794
+ lifecycle = markInterruptRequested(lifecycle, state.localOverrideAtMs);
795
+ } else if (state?.phase === "done") {
796
+ // Legacy activity "done" means the turn ended, not that completion
797
+ // evidence was recorded. Hydrate as Herdr-style waiting and let the
798
+ // preserved watcher consume sidecar/sentinel evidence.
799
+ const observedAt = state.lastActivityAtMs ?? running.startTime;
800
+ lifecycle = observePaneInspection(
801
+ lifecycle,
802
+ { kind: "present", observedAt, agentStatus: "done" },
803
+ observedAt,
804
+ );
805
+ } else if (state?.phase === "active" || state?.phase === "waiting" || state?.phase === "starting") {
806
+ lifecycle = observeActivity(lifecycle, {
807
+ ok: true,
808
+ activity: {
809
+ version: 1,
810
+ runningChildId: running.id,
811
+ createdAt: running.startTime,
812
+ updatedAt: state.lastActivityAtMs ?? running.startTime,
813
+ sequence: state.lastActivitySequence ?? 0,
814
+ latestEvent: state.latestEvent === "agent_end" ? "agent_end" : "agent_start",
815
+ phase: state.phase,
816
+ agentActive: state.phase === "active",
817
+ turnActive: state.phase === "active",
818
+ providerActive: false,
819
+ toolActive: state.activeScope === "tool",
820
+ ...(state.activeScope ? { activeScope: state.activeScope as any } : {}),
821
+ ...(state.activeSinceMs != null ? { activeSince: state.activeSinceMs } : {}),
822
+ ...(state.waitingSinceMs != null ? { waitingSince: state.waitingSinceMs } : {}),
823
+ ...(state.activityLabel && state.activeScope === "tool" ? { toolName: state.activityLabel } : {}),
824
+ },
825
+ }, state.lastActivityAtMs ?? running.startTime);
826
+ } else if (state?.source === "claude" || running.startTime) {
827
+ // Pre-lifecycle Pi agents without a known phase still get a running process.
828
+ lifecycle = markProcessRunning(lifecycle, running.startTime);
829
+ }
830
+ running.lifecycle = lifecycle;
831
+ return lifecycle;
715
832
  }
716
833
 
717
834
  function observeRunningSubagent(running: RunningSubagent, observedAt = Date.now()) {
835
+ ensureLifecycle(running);
718
836
  if (running.cli === "claude") return;
719
837
 
720
838
  const activityFile = running.activityFile;
@@ -726,27 +844,8 @@ function observeRunningSubagent(running: RunningSubagent, observedAt = Date.now(
726
844
  ? { ok: true }
727
845
  : { ok: false, reason: read.reason, error: read.error };
728
846
 
729
- if (read.ok) {
730
- running.activity = read.activity;
731
- running.statusState = observeStatus(running.statusState, {
732
- snapshot: "present",
733
- updatedAt: read.activity.updatedAt,
734
- sequence: read.activity.sequence,
735
- phase: read.activity.phase,
736
- active: read.activity.phase === "active",
737
- activeScope: read.activity.activeScope,
738
- activeSince: read.activity.activeSince,
739
- waitingSince: read.activity.waitingSince,
740
- latestEvent: read.activity.latestEvent,
741
- activityLabel: activityLabel(read.activity),
742
- }, observedAt);
743
- return;
744
- }
745
-
746
- running.statusState = observeStatus(running.statusState, {
747
- snapshot: read.reason,
748
- snapshotError: read.error,
749
- }, observedAt);
847
+ if (read.ok) running.activity = read.activity;
848
+ running.lifecycle = observeActivity(ensureLifecycle(running), read, observedAt);
750
849
  }
751
850
 
752
851
  function resolveInterruptTarget(params: { id?: string; name?: string }):
@@ -824,7 +923,7 @@ function handleSubagentInterrupt(
824
923
  };
825
924
  }
826
925
 
827
- running.statusState = forceStatusAfterInterrupt(running.statusState, now);
926
+ running.lifecycle = markInterruptRequested(ensureLifecycle(running), now);
828
927
  updateWidget();
829
928
 
830
929
  return {
@@ -851,19 +950,30 @@ function startStatusRefresh(pi: ExtensionAPI) {
851
950
  let shouldRefreshWidget = false;
852
951
 
853
952
  for (const running of runningSubagents.values()) {
953
+ // Dual-writes lifecycle + statusState for reload hydration; steers use lifecycle only.
854
954
  observeRunningSubagent(running, now);
855
- const { nextState, snapshot, transition } = advanceStatusState(running.statusState, now);
856
- if (nextState.currentKind !== running.statusState.currentKind) {
955
+ const projection = projectLifecycle(ensureLifecycle(running), now);
956
+ const transition = lifecycleTransition(running.lastProjectedKind, projection.kind);
957
+ if (running.lastProjectedKind !== projection.kind) {
857
958
  shouldRefreshWidget = true;
858
959
  }
859
- running.statusState = nextState;
960
+ running.lastProjectedKind = projection.kind;
860
961
 
861
962
  // Interactive subagents (long-running, user-driven) intentionally don't
862
963
  // wake the parent session on stalled/recovered transitions — the user is
863
964
  // working in the subagent's pane, and a steer message here would burn an
864
965
  // orchestrator turn on a no-op "still waiting" ping. Widget still updates.
865
966
  if (transition && !running.interactive) {
866
- transitionLines.push(formatTransitionLine(running.name, snapshot, transition));
967
+ transitionLines.push(
968
+ formatLifecycleTransitionLine(
969
+ normalizeStatusName(running.name),
970
+ projection,
971
+ transition,
972
+ now,
973
+ running.startTime,
974
+ formatElapsedDuration,
975
+ ),
976
+ );
867
977
  }
868
978
  }
869
979
 
@@ -902,7 +1012,6 @@ export const __test__ = {
902
1012
  resolveEffectiveInteractive,
903
1013
  buildSubagentToolAllowlist,
904
1014
  buildPiPromptArgs,
905
- formatWidgetRightLabel,
906
1015
  observeRunningSubagent,
907
1016
  resolveDenyTools,
908
1017
  resolveInterruptTarget,
@@ -1068,10 +1177,7 @@ async function launchSubagent(
1068
1177
  cli: "claude",
1069
1178
  sentinelFile,
1070
1179
  interactive: effectiveInteractive,
1071
- statusState: createStatusState({
1072
- source: "claude",
1073
- startTimeMs: startTime,
1074
- }),
1180
+ lifecycle: markProcessRunning(createLifecycle(startTime), Date.now()),
1075
1181
  };
1076
1182
 
1077
1183
  runningSubagents.set(id, running);
@@ -1206,10 +1312,7 @@ async function launchSubagent(
1206
1312
  launchScriptFile,
1207
1313
  activityFile,
1208
1314
  interactive: effectiveInteractive,
1209
- statusState: createStatusState({
1210
- source: "pi",
1211
- startTimeMs: startTime,
1212
- }),
1315
+ lifecycle: createLifecycle(startTime),
1213
1316
  };
1214
1317
 
1215
1318
  runningSubagents.set(id, running);
@@ -1249,17 +1352,26 @@ async function watchSubagent(
1249
1352
  const { name, task, surface, startTime, sessionFile } = running;
1250
1353
 
1251
1354
  try {
1252
- const result = await waitForCompletion(AbortSignal.any([signal, getModuleAbortSignal()]), {
1355
+ const result = await waitForCompletion(signal, {
1253
1356
  intervalMs: 1000,
1254
1357
  sessionFile,
1255
1358
  sentinelFile: running.sentinelFile,
1256
1359
  readTerminalTail: () => readPaneAsync(surface, 5),
1360
+ inspectPane: async () => inspectPane(surface),
1361
+ onPaneInspection: (inspection: PaneInspection, observedAt: number) => {
1362
+ ensureLifecycle(running);
1363
+ running.lifecycle = observePaneInspection(running.lifecycle, inspection, observedAt);
1364
+ updateWidget();
1365
+ },
1257
1366
  onTick() {
1258
1367
  observeRunningSubagent(running);
1259
1368
  },
1260
1369
  });
1261
1370
 
1262
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
1371
+ const detectedAt = Date.now();
1372
+ running.lifecycle = markCompletionDetected(running.lifecycle, result, detectedAt);
1373
+ updateWidget();
1374
+ const elapsed = Math.floor((detectedAt - startTime) / 1000);
1263
1375
 
1264
1376
  if (running.cli === "claude") {
1265
1377
  // Claude Code result extraction
@@ -1292,7 +1404,9 @@ async function watchSubagent(
1292
1404
  }
1293
1405
 
1294
1406
  closePane(surface);
1295
- runningSubagents.delete(running.id);
1407
+ running.lifecycle = result.exitCode === 0
1408
+ ? markCompleted(running.lifecycle, Date.now())
1409
+ : markFailed(running.lifecycle, result.errorMessage ?? summary, Date.now(), result.exitCode);
1296
1410
 
1297
1411
  return { name, task, summary, exitCode: result.exitCode, elapsed, ...(sessionId ? { claudeSessionId: sessionId } : {}) };
1298
1412
  }
@@ -1317,7 +1431,9 @@ async function watchSubagent(
1317
1431
  }
1318
1432
 
1319
1433
  closePane(surface);
1320
- runningSubagents.delete(running.id);
1434
+ running.lifecycle = result.exitCode === 0
1435
+ ? markCompleted(running.lifecycle, Date.now())
1436
+ : markFailed(running.lifecycle, result.errorMessage ?? summary, Date.now(), result.exitCode);
1321
1437
 
1322
1438
  return {
1323
1439
  name,
@@ -1333,7 +1449,13 @@ async function watchSubagent(
1333
1449
  try {
1334
1450
  closePane(surface);
1335
1451
  } catch {}
1336
- runningSubagents.delete(running.id);
1452
+ running.lifecycle = markFailed(
1453
+ running.lifecycle,
1454
+ signal.aborted ? "Subagent cancelled." : err?.message ?? String(err),
1455
+ Date.now(),
1456
+ 1,
1457
+ );
1458
+ updateWidget();
1337
1459
 
1338
1460
  if (signal.aborted) {
1339
1461
  return {
@@ -1358,13 +1480,21 @@ async function watchSubagent(
1358
1480
  }
1359
1481
 
1360
1482
  export default function subagentsExtension(pi: ExtensionAPI) {
1361
- // Capture the UI context for widget updates
1483
+ runtime.pi = pi;
1484
+
1485
+ // Capture the UI context for widget updates and restore presentation for
1486
+ // subagents whose watchers survived a reload.
1362
1487
  pi.on("session_start", (_event, ctx) => {
1363
- latestCtx = ctx;
1488
+ runtime.latestCtx = ctx;
1489
+ if (runningSubagents.size > 0) {
1490
+ startWidgetRefresh();
1491
+ startStatusRefresh(pi);
1492
+ updateWidget();
1493
+ }
1364
1494
  });
1365
1495
 
1366
1496
  // Clean up on session shutdown
1367
- pi.on("session_shutdown", (_event, _ctx) => {
1497
+ pi.on("session_shutdown", (event, _ctx) => {
1368
1498
  if (widgetInterval) {
1369
1499
  clearInterval(widgetInterval);
1370
1500
  widgetInterval = null;
@@ -1375,17 +1505,13 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1375
1505
  statusInterval = null;
1376
1506
  (globalThis as any)[STATUS_INTERVAL_KEY] = null;
1377
1507
  }
1378
- const moduleAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
1379
- if (moduleAbort) moduleAbort.abort();
1380
- for (const [_id, agent] of runningSubagents) {
1381
- agent.abortController?.abort();
1382
- }
1383
- runningSubagents.clear();
1508
+
1509
+ cleanupSubagentsForShutdown((event as any).reason, runningSubagents);
1384
1510
  });
1385
1511
 
1386
1512
  // Tools denied via PI_DENY_TOOLS env var (set by parent agent based on frontmatter)
1387
1513
  const deniedTools = new Set(
1388
- (process.env.PI_DENY_TOOLS ?? "")
1514
+ (process.env.PI_SUBAGENT_ID ? process.env.PI_DENY_TOOLS ?? "" : "")
1389
1515
  .split(",")
1390
1516
  .map((s) => s.trim())
1391
1517
  .filter(Boolean),
@@ -1461,12 +1587,21 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1461
1587
  // Fire-and-forget: start watching in background
1462
1588
  watchSubagent(running, watcherAbort.signal)
1463
1589
  .then((result) => {
1464
- updateWidget(); // reflect removal from Map immediately
1590
+ if (!shouldDeliverSubagentCompletion(running)) {
1591
+ running.lifecycle = markDelivery(running.lifecycle, "suppressed");
1592
+ runningSubagents.delete(running.id);
1593
+ updateWidget();
1594
+ return;
1595
+ }
1596
+ running.lifecycle = markDelivery(running.lifecycle, "delivered");
1597
+ runningSubagents.delete(running.id);
1598
+ updateWidget();
1599
+ const completionApi = selectCompletionApi(pi, runtime.pi);
1465
1600
 
1466
1601
  if (result.ping) {
1467
1602
  // Subagent is requesting help — steer a ping message with session path for resume
1468
1603
  const sessionRef = `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}`;
1469
- pi.sendMessage(
1604
+ completionApi.sendMessage(
1470
1605
  {
1471
1606
  customType: "subagent_ping",
1472
1607
  content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`,
@@ -1485,7 +1620,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1485
1620
 
1486
1621
  const presentation = resolveResultPresentation(result, running.name);
1487
1622
 
1488
- pi.sendMessage(
1623
+ completionApi.sendMessage(
1489
1624
  {
1490
1625
  customType: "subagent_result",
1491
1626
  content: presentation,
@@ -1505,8 +1640,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1505
1640
  );
1506
1641
  })
1507
1642
  .catch((err) => {
1643
+ if (!shouldDeliverSubagentCompletion(running)) {
1644
+ running.lifecycle = markDelivery(running.lifecycle, "suppressed");
1645
+ runningSubagents.delete(running.id);
1646
+ updateWidget();
1647
+ return;
1648
+ }
1649
+ running.lifecycle = markDelivery(running.lifecycle, "delivered");
1650
+ runningSubagents.delete(running.id);
1508
1651
  updateWidget();
1509
- pi.sendMessage(
1652
+ selectCompletionApi(pi, runtime.pi).sendMessage(
1510
1653
  {
1511
1654
  customType: "subagent_result",
1512
1655
  content: `Sub-agent "${running.name}" error: ${err?.message ?? String(err)}`,
@@ -1873,10 +2016,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1873
2016
  launchScriptFile,
1874
2017
  activityFile,
1875
2018
  interactive,
1876
- statusState: createStatusState({
1877
- source: "pi",
1878
- startTimeMs: startTime,
1879
- }),
2019
+ lifecycle: createLifecycle(startTime),
1880
2020
  };
1881
2021
  runningSubagents.set(id, running);
1882
2022
  startWidgetRefresh();
@@ -1888,11 +2028,20 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1888
2028
 
1889
2029
  watchSubagent(running, watcherAbort.signal)
1890
2030
  .then((result) => {
2031
+ if (!shouldDeliverSubagentCompletion(running)) {
2032
+ running.lifecycle = markDelivery(running.lifecycle, "suppressed");
2033
+ runningSubagents.delete(running.id);
2034
+ updateWidget();
2035
+ return;
2036
+ }
2037
+ running.lifecycle = markDelivery(running.lifecycle, "delivered");
2038
+ runningSubagents.delete(running.id);
1891
2039
  updateWidget();
2040
+ const completionApi = selectCompletionApi(pi, runtime.pi);
1892
2041
 
1893
2042
  if (result.ping) {
1894
2043
  const sessionRef = `\n\nSession: ${params.sessionPath}\nResume: pi --session ${params.sessionPath}`;
1895
- pi.sendMessage(
2044
+ completionApi.sendMessage(
1896
2045
  {
1897
2046
  customType: "subagent_ping",
1898
2047
  content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`,
@@ -1920,7 +2069,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1920
2069
  name,
1921
2070
  );
1922
2071
 
1923
- pi.sendMessage(
2072
+ completionApi.sendMessage(
1924
2073
  {
1925
2074
  customType: "subagent_result",
1926
2075
  content: presentation,
@@ -1938,8 +2087,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1938
2087
  );
1939
2088
  })
1940
2089
  .catch((err) => {
2090
+ if (!shouldDeliverSubagentCompletion(running)) {
2091
+ running.lifecycle = markDelivery(running.lifecycle, "suppressed");
2092
+ runningSubagents.delete(running.id);
2093
+ updateWidget();
2094
+ return;
2095
+ }
2096
+ running.lifecycle = markDelivery(running.lifecycle, "delivered");
2097
+ runningSubagents.delete(running.id);
1941
2098
  updateWidget();
1942
- pi.sendMessage(
2099
+ selectCompletionApi(pi, runtime.pi).sendMessage(
1943
2100
  {
1944
2101
  customType: "subagent_result",
1945
2102
  content: `Resume error: ${err?.message ?? String(err)}`,