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.
- package/README.md +32 -18
- package/package.json +4 -2
- package/pi-extension/subagents/completion.ts +65 -2
- package/pi-extension/subagents/herdr.ts +68 -0
- package/pi-extension/subagents/index.ts +292 -135
- package/pi-extension/subagents/lifecycle.ts +477 -0
- package/pi-extension/subagents/subagent-done.ts +11 -9
- package/pi-extension/subagents/terminal.ts +12 -0
- package/test/integration/subagent-lifecycle.test.ts +1 -1
- package/test/test.ts +693 -46
|
@@ -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
|
-
|
|
43
|
-
createStatusState,
|
|
44
|
-
forceStatusAfterInterrupt,
|
|
41
|
+
formatElapsedDuration,
|
|
45
42
|
formatStatusAggregate,
|
|
46
|
-
|
|
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:
|
|
61
|
-
//
|
|
62
|
-
// the
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
512
|
+
interface SubagentRuntime {
|
|
513
|
+
runningSubagents: Map<string, RunningSubagent>;
|
|
514
|
+
pi?: ExtensionAPI;
|
|
515
|
+
latestCtx?: ExtensionContext;
|
|
516
|
+
}
|
|
520
517
|
|
|
521
|
-
|
|
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
|
-
|
|
524
|
-
|
|
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((
|
|
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
|
|
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 `${
|
|
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 `${
|
|
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 `${
|
|
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 `${
|
|
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 `${
|
|
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 `${
|
|
629
|
+
if (width === 1) return `${accent}╰${RST}`;
|
|
594
630
|
|
|
595
631
|
const inner = Math.max(0, width - 2);
|
|
596
|
-
return `${
|
|
632
|
+
return `${accent}╰${"─".repeat(inner)}╯${RST}`;
|
|
597
633
|
}
|
|
598
634
|
|
|
599
|
-
function
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
607
|
-
|
|
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
|
-
?
|
|
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
|
|
710
|
-
if (
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
if (
|
|
714
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
856
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", (
|
|
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
|
-
|
|
1379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)}`,
|