@arhen/pi-core-subagent 1.3.64 → 1.3.65

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/src/manager.ts CHANGED
@@ -27,10 +27,10 @@ import {
27
27
  activitySnippet,
28
28
  describeCall,
29
29
  getFirstText,
30
- isStartupFailure,
31
30
  isTalking,
32
31
  makeAskNotice,
33
32
  makeNotice,
33
+ makeTaskArtifactNotice,
34
34
  makeTaskNotice,
35
35
  SubagentsWidget,
36
36
  truncateText,
@@ -38,6 +38,7 @@ import {
38
38
  import { applyUpstream, resolveNeeds, runWaveScheduler } from "./graph.ts";
39
39
  import { createMailbox, type Mailbox } from "./mailbox.ts";
40
40
  import { chooseModel, resolveChildModel } from "./models.ts";
41
+ import { artifactFingerprint, outcomeFingerprint, textFingerprint } from "./notification-state.ts";
41
42
  import type { SubagentParamsShape, TaskInput } from "./schemas.ts";
42
43
  import {
43
44
  MAX_TASKS,
@@ -77,6 +78,12 @@ const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write", "cod
77
78
  const WRITE_CAPABLE = ["bash", "edit", "write"];
78
79
  const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
79
80
  const WIDGET_THROTTLE_MS = 150;
81
+ /** A submitted notice missing from the canonical context this long is treated as lost. */
82
+ const RECEIPT_GRACE_MS = 3_000;
83
+ /** Minimum wait before retrying a failed submission, so one flush cannot burn every attempt. */
84
+ const RETRY_BACKOFF_MS = 1_000;
85
+ /** Total submission attempts before a notice is declared lost. */
86
+ const MAX_DELIVERY_ATTEMPTS = 3;
80
87
 
81
88
  function newId(prefix: string): string {
82
89
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -291,6 +298,36 @@ export interface ParkedMsg {
291
298
  taskId: string;
292
299
  agent: string;
293
300
  text: string;
301
+ final?: boolean;
302
+ }
303
+
304
+ interface LeaderMessageReceipt {
305
+ runId: string;
306
+ taskIds: string[];
307
+ kind: "update" | "final" | "terminal";
308
+ deliverAs: "steer" | "followUp";
309
+ delivered: boolean;
310
+ submitted: boolean;
311
+ lost: boolean;
312
+ submittedAt: number;
313
+ attempts: number;
314
+ /** Agent runs started when this receipt was submitted; a later run is needed to prove loss. */
315
+ submittedRunCount: number;
316
+ }
317
+
318
+ interface OutboxEntry {
319
+ runId: string;
320
+ taskIds: string[];
321
+ kind: "update" | "final" | "terminal";
322
+ deliverAs: "steer" | "followUp";
323
+ attempts?: number;
324
+ attemptedAt?: number;
325
+ }
326
+
327
+ interface TerminalNotice {
328
+ body: string;
329
+ fingerprint: string;
330
+ delivery: "pending" | "received" | "lost";
294
331
  }
295
332
 
296
333
  export class SubagentManager {
@@ -313,6 +350,17 @@ export class SubagentManager {
313
350
  private persistChain: Promise<unknown> = Promise.resolve();
314
351
  private readonly instanceNonce = Math.random().toString(36).slice(2, 8);
315
352
  private cleared = false;
353
+ private leaderMessages = new Map<string, LeaderMessageReceipt>();
354
+ private outbox = new Map<string, OutboxEntry>();
355
+ private parkedBodies = new Map<string, Set<string>>();
356
+ private agentRunCount = 0;
357
+ private terminalNotices = new Map<string, TerminalNotice>();
358
+ private awaitedTaskNotices = new Map<string, string>();
359
+ private pendingTaskNotices = new Map<string, { run: RunSnapshot; task: TaskSnapshot }>();
360
+ private pendingRunNotices = new Map<string, RunSnapshot>();
361
+ private notificationRetries = new Set<string>();
362
+ private notificationFlushTimer: ReturnType<typeof setTimeout> | undefined;
363
+ private notificationContext: ExtensionContext | undefined;
316
364
 
317
365
  private autoLimit = false;
318
366
 
@@ -384,6 +432,17 @@ export class SubagentManager {
384
432
  pending.resolve("(session ended — stop work immediately)");
385
433
  }
386
434
  this.parked.clear();
435
+ this.leaderMessages.clear();
436
+ this.outbox.clear();
437
+ this.parkedBodies.clear();
438
+ this.terminalNotices.clear();
439
+ this.awaitedTaskNotices.clear();
440
+ this.pendingTaskNotices.clear();
441
+ this.pendingRunNotices.clear();
442
+ this.notificationRetries.clear();
443
+ if (this.notificationFlushTimer) clearTimeout(this.notificationFlushTimer);
444
+ this.notificationFlushTimer = undefined;
445
+ this.notificationContext = undefined;
387
446
 
388
447
  this.liveWorktrees.clear();
389
448
  this.runs.clear();
@@ -483,27 +542,511 @@ export class SubagentManager {
483
542
  this.pi.events.emit(type, { type, timestamp: Date.now(), ...payload });
484
543
  }
485
544
 
486
- private deliverMode(kind: string, task: TaskSnapshot): "steer" | "followUp" {
487
- // failures interrupt the leader's turn: it must decide immediately (resume, swap model, respawn)
488
- // instead of finding out after its own turn ended. Aborts are user-initiated, so they queue.
489
- return kind === "failed" || isStartupFailure(task, kind) ? "steer" : "followUp";
545
+ private finalReportMatches(task: TaskSnapshot): boolean {
546
+ const report = task.finalParentReport;
547
+ if (!report || report.toolCalls !== task.toolCalls) return false;
548
+ const fingerprint = task.finalTextFingerprint ?? textFingerprint(task.finalText ?? "");
549
+ return report.fingerprint === fingerprint;
490
550
  }
491
551
 
492
- private notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
493
- // ponytail: child already pushed its summary via notify_parent — pointer only, no second copy. Upgrade: drop notice entirely if the pointer noise also proves useless.
494
- const body =
495
- kind === "completed" && task.notifiedParent
496
- ? `Task ${task.agent} (${task.id}) completed in run ${run.id} — summary already reported. Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`
497
- : makeTaskNotice(run, task, kind);
552
+ private hasArtifactDelta(task: TaskSnapshot): boolean {
553
+ return artifactFingerprint(task) !== task.finalParentReport?.artifacts;
554
+ }
555
+
556
+ private leaderMessageText(content: unknown): string {
557
+ return typeof content === "string"
558
+ ? content
559
+ : Array.isArray(content)
560
+ ? content
561
+ .filter((part) => part?.type === "text")
562
+ .map((part) => part.text ?? "")
563
+ .join("\n")
564
+ : "";
565
+ }
566
+
567
+ /**
568
+ * One pass over the finalized leader projection. Returns undefined when the pinned pi build
569
+ * cannot project (never treat that as loss, or every receipt would be replayed).
570
+ */
571
+ private projectionBody(ctx: ExtensionContext): string | undefined {
572
+ try {
573
+ const manager = ctx.sessionManager as unknown as {
574
+ buildSessionProjection?: () => { messages: readonly { role?: string; content?: unknown }[] };
575
+ };
576
+ if (typeof manager.buildSessionProjection !== "function") return undefined;
577
+ const messages = manager.buildSessionProjection().messages;
578
+ const parts: string[] = [];
579
+ for (const message of messages) {
580
+ if (message.role !== "user" && message.role !== "custom") continue;
581
+ parts.push(this.leaderMessageText(message.content));
582
+ }
583
+ return parts.join("\n");
584
+ } catch {
585
+ return undefined;
586
+ }
587
+ }
588
+
589
+ /** An agent run started; a receipt can only be declared lost after one full later run. */
590
+ noteAgentStart(): void {
591
+ this.agentRunCount += 1;
592
+ }
593
+
594
+ /** True when a terminal await already rendered this task's outcome. */
595
+ private coveredByAwait(runId: string, taskId: string, fingerprint: string): boolean {
596
+ return this.awaitedTaskNotices.get(`${runId}:${taskId}`) === fingerprint;
597
+ }
598
+
599
+ /** Record what a terminal await actually rendered, so held notices are dropped instead of echoed. */
600
+ markAwaitCoverage(run: RunSnapshot, coveredTaskIds?: ReadonlySet<string>): void {
601
+ for (const task of run.tasks) {
602
+ if (!TERMINAL.includes(task.status)) continue;
603
+ if (coveredTaskIds && !coveredTaskIds.has(task.id)) continue;
604
+ const scope = `${run.id}:${task.id}`;
605
+ this.awaitedTaskNotices.set(scope, outcomeFingerprint(task));
606
+ this.pendingTaskNotices.delete(scope);
607
+ }
608
+ for (const [body, entry] of [...this.outbox]) {
609
+ if (entry.runId !== run.id) continue;
610
+ if (!this.outboxCovered(entry)) continue;
611
+ const task = run.tasks.find((t) => t.id === entry.taskIds[0]);
612
+ // An update is not part of the rendered summary; only terminal/aggregate entries are safe to drop.
613
+ if (entry.kind === "update") continue;
614
+ if (entry.kind === "final" && (!task || !this.finalReportMatches(task))) continue;
615
+ this.dropOutbox(body, entry, "awaited");
616
+ }
617
+ }
618
+
619
+ private outboxCovered(entry: OutboxEntry): boolean {
620
+ const run = this.runs.get(entry.runId);
621
+ if (!run) return false;
622
+ return entry.taskIds.every((taskId) => {
623
+ const task = run.tasks.find((t) => t.id === taskId);
624
+ if (!task || !TERMINAL.includes(task.status)) return false;
625
+ return this.coveredByAwait(entry.runId, taskId, outcomeFingerprint(task));
626
+ });
627
+ }
628
+
629
+ private dropOutbox(body: string, entry: OutboxEntry, delivery: string): void {
630
+ this.outbox.delete(body);
631
+ const task = this.runs.get(entry.runId)?.tasks.find((t) => t.id === entry.taskIds[0]);
632
+ if (entry.kind === "final" && task?.finalParentReport?.body === body) task.finalParentReport.delivery = "awaited";
633
+ if (entry.kind === "terminal") {
634
+ for (const taskId of entry.taskIds) {
635
+ const notice = this.terminalNotices.get(`${entry.runId}:${taskId}`);
636
+ if (notice?.body === body) notice.delivery = "received";
637
+ }
638
+ }
639
+ this.emit("subagent:notification", {
640
+ runId: entry.runId,
641
+ taskId: entry.taskIds[0],
642
+ kind: "completed",
643
+ body: "",
644
+ suppressed: true,
645
+ delivery,
646
+ });
647
+ }
648
+
649
+ private enqueueNotification(body: string, entry: OutboxEntry, ctx?: ExtensionContext): void {
650
+ this.outbox.set(body, entry);
651
+ const flushContext = ctx ?? this.notificationContext;
652
+ if (flushContext) this.scheduleNotificationFlush(flushContext);
653
+ }
654
+
655
+ private noteTerminal(runId: string, taskId: string, body: string, fingerprint: string): void {
656
+ this.terminalNotices.set(`${runId}:${taskId}`, { body, fingerprint, delivery: "pending" });
657
+ }
658
+
659
+ private submitLeaderReport(
660
+ run: RunSnapshot,
661
+ task: TaskSnapshot,
662
+ message: string,
663
+ level: "info" | "warning" | "error",
664
+ final: boolean,
665
+ ctx: ExtensionContext,
666
+ ): boolean {
667
+ if (this.cleared) return false;
668
+ this.notificationContext = ctx;
669
+ const body = `[Subagent ${task.agent} (${task.id}, ${run.id})]${final ? " Final result:" : ""}${level === "info" ? "" : ` [${level}]`} ${message}`;
670
+ const previous = this.leaderMessages.get(body);
671
+ if ((previous && !previous.lost) || this.outbox.has(body)) {
672
+ if (final) {
673
+ task.finalParentReport = {
674
+ body,
675
+ message,
676
+ toolCalls: task.toolCalls,
677
+ fingerprint: textFingerprint(message),
678
+ artifacts: artifactFingerprint(task),
679
+ delivery: previous?.delivered ? "message" : "pending",
680
+ };
681
+ }
682
+ return true;
683
+ }
684
+ if (previous?.lost) this.leaderMessages.delete(body);
685
+ const report: TaskSnapshot["finalParentReport"] = final
686
+ ? {
687
+ body,
688
+ message,
689
+ toolCalls: task.toolCalls,
690
+ fingerprint: textFingerprint(message),
691
+ artifacts: artifactFingerprint(task),
692
+ delivery: "pending",
693
+ }
694
+ : undefined;
695
+ if (report) task.finalParentReport = report;
696
+ this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message, final });
697
+ if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message, final })) {
698
+ let parked = this.parkedBodies.get(run.id);
699
+ if (!parked) {
700
+ parked = new Set();
701
+ this.parkedBodies.set(run.id, parked);
702
+ }
703
+ if (parked.has(body)) return true;
704
+ parked.add(body);
705
+ task.notifiedParent = true;
706
+ if (report) report.delivery = "parked";
707
+ return true;
708
+ }
709
+ this.enqueueNotification(body, {
710
+ runId: run.id,
711
+ taskIds: [task.id],
712
+ kind: final ? "final" : "update",
713
+ deliverAs: "followUp",
714
+ });
715
+ return true;
716
+ }
717
+
718
+ private hasPendingSuccessMessages(run: RunSnapshot): boolean {
719
+ return run.tasks.some((task) => {
720
+ const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
721
+ if (notice?.delivery !== "pending") return false;
722
+ const receipt = this.leaderMessages.get(notice.body);
723
+ return receipt ? !receipt.delivered && !receipt.lost : this.outbox.has(notice.body);
724
+ });
725
+ }
498
726
 
727
+ confirmLeaderMessages(ctx: ExtensionContext): void {
728
+ if (this.cleared) return;
729
+ const projection = this.projectionBody(ctx);
730
+ let changed = false;
731
+ for (const [body, receipt] of [...this.leaderMessages]) {
732
+ if (receipt.delivered || receipt.lost || projection === undefined || !projection.includes(body)) continue;
733
+ receipt.delivered = true;
734
+ changed = true;
735
+ const run = this.runs.get(receipt.runId);
736
+ for (const taskId of receipt.taskIds) {
737
+ const task = run?.tasks.find((t) => t.id === taskId);
738
+ if (!task) continue;
739
+ task.notifiedParent = true;
740
+ if (task.finalParentReport?.body === body) task.finalParentReport.delivery = "message";
741
+ const key = `${receipt.runId}:${taskId}`;
742
+ const notice = this.terminalNotices.get(key);
743
+ if (notice?.body === body) notice.delivery = "received";
744
+ const pending = this.pendingTaskNotices.get(key);
745
+ if (pending && this.finalReportMatches(task)) {
746
+ this.pendingTaskNotices.delete(key);
747
+ if (this.hasArtifactDelta(task)) this.notifyTask(pending.run, task, "completed");
748
+ }
749
+ }
750
+ if (run && !this.hasPendingSuccessMessages(run)) this.pendingRunNotices.delete(run.id);
751
+ }
752
+ if (changed) this.persist(ctx);
753
+ for (const [body, receipt] of this.leaderMessages) {
754
+ if (!receipt.delivered && !receipt.lost) continue;
755
+ const run = this.runs.get(receipt.runId);
756
+ const tasks = run?.tasks.filter((task) => receipt.taskIds.includes(task.id)) ?? [];
757
+ if (
758
+ tasks.length > 0 &&
759
+ tasks.every((task) => TERMINAL.includes(task.status) && !this.liveChildren.has(`${receipt.runId}:${task.id}`))
760
+ )
761
+ this.leaderMessages.delete(body);
762
+ }
763
+ }
764
+
765
+ /**
766
+ * A submitted receipt that never appears in the canonical context after a grace period is lost.
767
+ * The grace period keeps the check from racing Pi's input/auth/start hooks, which run before the
768
+ * message is appended to the session and can be slow while the session still reports idle.
769
+ */
770
+ private markLostReceipts(ctx: ExtensionContext, existing: ReadonlySet<string>): void {
771
+ const now = Date.now();
772
+ const projection = this.projectionBody(ctx);
773
+ if (projection === undefined) return;
774
+ for (const [body, receipt] of [...this.leaderMessages]) {
775
+ if (receipt.delivered || receipt.lost || !existing.has(body)) continue;
776
+ if (this.outbox.has(body)) continue;
777
+ if (now - receipt.submittedAt < RECEIPT_GRACE_MS) continue;
778
+ // A later agent run proves the submitted prompt had its turn; a message that never runs
779
+ // at all is caught by the longer bound. While a prompt is still queued or in preflight
780
+ // (deferred settle, slow input/auth hooks) the session can report idle without the
781
+ // message being appended yet, so the short window alone must not declare loss.
782
+ const laterRun = this.agentRunCount > receipt.submittedRunCount;
783
+ const longWait = now - receipt.submittedAt >= RECEIPT_GRACE_MS * 2;
784
+ if (!laterRun && !longWait) continue;
785
+ if (projection.includes(body)) continue;
786
+ if (receipt.attempts < MAX_DELIVERY_ATTEMPTS) {
787
+ this.leaderMessages.delete(body);
788
+ this.outbox.set(body, {
789
+ runId: receipt.runId,
790
+ taskIds: receipt.taskIds,
791
+ kind: receipt.kind,
792
+ deliverAs: receipt.deliverAs,
793
+ attempts: receipt.attempts,
794
+ attemptedAt: now,
795
+ });
796
+ continue;
797
+ }
798
+ receipt.lost = true;
799
+ for (const taskId of receipt.taskIds) {
800
+ const key = `${receipt.runId}:${taskId}`;
801
+ const notice = this.terminalNotices.get(key);
802
+ if (notice?.body === body) notice.delivery = "lost";
803
+ const task = this.runs.get(receipt.runId)?.tasks.find((t) => t.id === taskId);
804
+ if (task?.finalParentReport?.body === body) task.finalParentReport.delivery = "lost";
805
+ }
806
+ this.emit("subagent:notification", {
807
+ runId: receipt.runId,
808
+ taskId: receipt.taskIds[0],
809
+ kind: "completed",
810
+ body,
811
+ delivery: "lost",
812
+ });
813
+ // A lost final report must still reach the leader: per-task hold or a direct full fallback.
814
+ if (receipt.kind === "final") {
815
+ const run = this.runs.get(receipt.runId);
816
+ for (const taskId of receipt.taskIds) {
817
+ if (this.pendingTaskNotices.has(`${receipt.runId}:${taskId}`)) continue;
818
+ const task = run?.tasks.find((t) => t.id === taskId);
819
+ if (run && task) this.notifyTask(run, task, "completed");
820
+ }
821
+ }
822
+ }
823
+ }
824
+
825
+ private flushOutbox(): void {
826
+ for (const [body, entry] of [...this.outbox]) {
827
+ if (this.outboxCovered(entry)) {
828
+ this.dropOutbox(body, entry, "awaited");
829
+ continue;
830
+ }
831
+ if (entry.attemptedAt && Date.now() - entry.attemptedAt < RETRY_BACKOFF_MS) continue;
832
+ this.outbox.delete(body);
833
+ try {
834
+ this.pi.sendUserMessage(body, { deliverAs: entry.deliverAs });
835
+ this.leaderMessages.set(body, {
836
+ runId: entry.runId,
837
+ taskIds: entry.taskIds,
838
+ kind: entry.kind,
839
+ deliverAs: entry.deliverAs,
840
+ delivered: false,
841
+ submitted: true,
842
+ lost: false,
843
+ submittedAt: Date.now(),
844
+ attempts: (entry.attempts ?? 0) + 1,
845
+ submittedRunCount: this.agentRunCount,
846
+ });
847
+ if (entry.kind === "terminal") {
848
+ for (const taskId of entry.taskIds) {
849
+ const notice = this.terminalNotices.get(`${entry.runId}:${taskId}`);
850
+ if (notice?.body === body) notice.delivery = "pending";
851
+ }
852
+ }
853
+ this.emit("subagent:notification", {
854
+ runId: entry.runId,
855
+ taskId: entry.taskIds[0],
856
+ kind: "completed",
857
+ body,
858
+ delivery: "submitted",
859
+ });
860
+ } catch {
861
+ const attempts = (entry.attempts ?? 0) + 1;
862
+ if (attempts < MAX_DELIVERY_ATTEMPTS) {
863
+ this.outbox.set(body, { ...entry, attempts, attemptedAt: Date.now() });
864
+ continue;
865
+ }
866
+ this.emit("subagent:notification", {
867
+ runId: entry.runId,
868
+ taskId: entry.taskIds[0],
869
+ kind: "completed",
870
+ body,
871
+ delivery: "failed",
872
+ });
873
+ }
874
+ }
875
+ }
876
+
877
+ flushPendingNotifications(ctx: ExtensionContext): void {
878
+ const existing = new Set([...this.leaderMessages.keys(), ...this.outbox.keys()]);
879
+ this.confirmLeaderMessages(ctx);
880
+ if (this.cleared) return;
881
+ let idle = false;
882
+ try {
883
+ idle = ctx.isIdle() && !ctx.hasPendingMessages();
884
+ } catch {
885
+ idle = false;
886
+ }
887
+ if (idle) this.markLostReceipts(ctx, existing);
888
+ const outstandingReceipts = () =>
889
+ [...this.leaderMessages.values()].some((receipt) => !receipt.delivered && !receipt.lost);
890
+ if (!this.pendingTaskNotices.size && !this.pendingRunNotices.size && !this.outbox.size && !outstandingReceipts())
891
+ return;
892
+ if (idle) {
893
+ this.flushOutbox();
894
+ for (const [key, pending] of [...this.pendingTaskNotices]) {
895
+ const report = pending.task.finalParentReport;
896
+ if (report?.delivery === "lost") {
897
+ this.pendingTaskNotices.delete(key);
898
+ pending.task.finalParentReport = undefined;
899
+ this.notifyTask(pending.run, pending.task, "completed");
900
+ continue;
901
+ }
902
+ if (report?.delivery === "pending") {
903
+ const outstanding = this.leaderMessages.has(report.body) || this.outbox.has(report.body);
904
+ if (outstanding) continue;
905
+ this.pendingTaskNotices.delete(key);
906
+ pending.task.finalParentReport = undefined;
907
+ this.notifyTask(pending.run, pending.task, "completed");
908
+ continue;
909
+ }
910
+ this.pendingTaskNotices.delete(key);
911
+ if (report && this.finalReportMatches(pending.task) && this.hasArtifactDelta(pending.task))
912
+ this.notifyTask(pending.run, pending.task, "completed");
913
+ }
914
+ for (const [key, run] of [...this.pendingRunNotices]) {
915
+ const notices = run.tasks
916
+ .map((task) => this.terminalNotices.get(`${run.id}:${task.id}`))
917
+ .filter((notice): notice is TerminalNotice => Boolean(notice));
918
+ const waiting = notices.some((notice) => {
919
+ const receipt = this.leaderMessages.get(notice.body);
920
+ return notice.delivery === "pending" && (!receipt || (!receipt.delivered && !receipt.lost));
921
+ });
922
+ if (waiting) continue;
923
+ this.pendingRunNotices.delete(key);
924
+ const lost = notices.some((notice) => notice.delivery === "lost");
925
+ if (!lost || this.notificationRetries.has(run.id)) continue;
926
+ this.notificationRetries.add(run.id);
927
+ this.notifyParent(run, run.status === "aborted" ? "aborted" : run.status === "failed" ? "failed" : "completed");
928
+ }
929
+ this.flushOutbox();
930
+ }
931
+ // Anything still unresolved (including a retry backoff) must be revisited without unrelated activity.
932
+ this.scheduleNotificationFlush(ctx, RETRY_BACKOFF_MS);
933
+ }
934
+
935
+ scheduleNotificationFlush(ctx: ExtensionContext, delayMs = 100): void {
936
+ if (
937
+ this.notificationFlushTimer ||
938
+ this.cleared ||
939
+ (!this.pendingTaskNotices.size &&
940
+ !this.pendingRunNotices.size &&
941
+ !this.outbox.size &&
942
+ ![...this.leaderMessages.values()].some((receipt) => !receipt.delivered && !receipt.lost))
943
+ )
944
+ return;
945
+ this.notificationFlushTimer = setTimeout(() => {
946
+ this.notificationFlushTimer = undefined;
947
+ this.flushPendingNotifications(ctx);
948
+ }, delayMs);
949
+ this.notificationFlushTimer.unref?.();
950
+ }
951
+
952
+ private notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
953
+ const key = `${run.id}:${task.id}`;
954
+ const outcome = outcomeFingerprint(task);
955
+ const final = kind === "completed" && this.finalReportMatches(task);
956
+ const report = task.finalParentReport;
957
+ if (final && report?.delivery === "pending") {
958
+ this.pendingTaskNotices.set(key, { run, task });
959
+ if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
960
+ this.emit("subagent:notification", {
961
+ runId: run.id,
962
+ taskId: task.id,
963
+ kind,
964
+ body: "",
965
+ suppressed: true,
966
+ delivery: "pending",
967
+ });
968
+ return;
969
+ }
970
+ if (
971
+ final &&
972
+ report &&
973
+ report.delivery !== "lost" &&
974
+ report.delivery !== "pending" &&
975
+ !this.hasArtifactDelta(task)
976
+ ) {
977
+ this.emit("subagent:notification", {
978
+ runId: run.id,
979
+ taskId: task.id,
980
+ kind,
981
+ body: "",
982
+ suppressed: true,
983
+ delivery: report.delivery,
984
+ });
985
+ return;
986
+ }
987
+ const covered = this.terminalNotices.get(key);
988
+ if (covered?.fingerprint === outcome && covered.delivery !== "lost") {
989
+ this.emit("subagent:notification", {
990
+ runId: run.id,
991
+ taskId: task.id,
992
+ kind,
993
+ body: "",
994
+ suppressed: true,
995
+ delivery: covered.delivery,
996
+ });
997
+ return;
998
+ }
999
+ if (this.coveredByAwait(run.id, task.id, outcome)) {
1000
+ this.emit("subagent:notification", {
1001
+ runId: run.id,
1002
+ taskId: task.id,
1003
+ kind,
1004
+ body: "",
1005
+ suppressed: true,
1006
+ delivery: "awaited",
1007
+ });
1008
+ return;
1009
+ }
1010
+ const artifactOnly = final && report?.delivery !== "lost";
1011
+ const body = artifactOnly ? makeTaskArtifactNotice(run, task) : makeTaskNotice(run, task, kind);
499
1012
  if (this.collectParked(run.id, { kind: "done", taskId: task.id, agent: task.agent, text: body })) {
500
- this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body });
1013
+ this.noteTerminal(run.id, task.id, body, outcome);
1014
+ this.terminalNotices.get(key)!.delivery = "received";
1015
+ this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "parked" });
501
1016
  return;
502
1017
  }
503
- try {
504
- this.pi.sendUserMessage(body, { deliverAs: this.deliverMode(kind, task) });
505
- } catch {}
506
- this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body });
1018
+ if (kind === "failed") {
1019
+ this.noteTerminal(run.id, task.id, body, outcome);
1020
+ try {
1021
+ this.pi.sendUserMessage(body, { deliverAs: "steer" });
1022
+ this.leaderMessages.set(body, {
1023
+ runId: run.id,
1024
+ taskIds: [task.id],
1025
+ kind: "terminal",
1026
+ deliverAs: "steer",
1027
+ delivered: false,
1028
+ submitted: true,
1029
+ lost: false,
1030
+ submittedAt: Date.now(),
1031
+ attempts: 0,
1032
+ submittedRunCount: this.agentRunCount,
1033
+ });
1034
+ this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "submitted" });
1035
+ if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext, RECEIPT_GRACE_MS);
1036
+ } catch {
1037
+ this.terminalNotices.get(key)!.delivery = "lost";
1038
+ this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "failed" });
1039
+ }
1040
+ return;
1041
+ }
1042
+ this.noteTerminal(run.id, task.id, body, outcome);
1043
+ this.enqueueNotification(body, {
1044
+ runId: run.id,
1045
+ taskIds: [task.id],
1046
+ kind: "terminal",
1047
+ deliverAs: "followUp",
1048
+ });
1049
+ this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "held" });
507
1050
  }
508
1051
 
509
1052
  private notifyParent(
@@ -511,19 +1054,103 @@ export class SubagentManager {
511
1054
  kind: "completed" | "failed" | "aborted" | "asked",
512
1055
  extra?: { taskId?: string; agent?: string; question?: string; urgent?: boolean },
513
1056
  ): void {
514
- if (kind !== "asked" && run.awaited) return;
515
- // single-task completed run: the task notice already said everything (failure paths may not have notified per-task)
516
- if (kind === "completed" && run.tasks.length === 1 && run.notifyPerTask) return;
517
- const body = kind === "asked" ? makeAskNotice(run, extra ?? {}) : makeNotice(run, kind);
518
- // asks steer: the leader sees the question during its turn and the urgent flag tells it whether to
519
- // answer now or after the current step. Failures steer for the same reason — a broken result must
520
- // not be consumed. Completions and aborts queue as follow-ups.
521
- try {
522
- this.pi.sendUserMessage(body, {
523
- deliverAs: kind === "completed" || kind === "aborted" ? "followUp" : "steer",
1057
+ if (kind !== "asked" && run.completionAwaited) return;
1058
+ let tasks = run.tasks;
1059
+ if (kind !== "asked") {
1060
+ tasks = tasks.filter((task) => {
1061
+ const key = `${run.id}:${task.id}`;
1062
+ const outcome = outcomeFingerprint(task);
1063
+ const covered = this.terminalNotices.get(key);
1064
+ if (covered?.fingerprint === outcome && covered.delivery !== "lost") return false;
1065
+ if (this.pendingTaskNotices.has(key)) return false;
1066
+ if (this.coveredByAwait(run.id, task.id, outcome)) return false;
1067
+ if (
1068
+ kind === "completed" &&
1069
+ task.finalParentReport?.delivery !== "lost" &&
1070
+ this.finalReportMatches(task) &&
1071
+ !this.hasArtifactDelta(task)
1072
+ )
1073
+ return false;
1074
+ return true;
524
1075
  });
525
- } catch {}
526
- this.emit("subagent:notification", { runId: run.id, taskId: extra?.taskId, kind, body });
1076
+ if (tasks.length === 0) {
1077
+ if (!this.notificationRetries.has(run.id) && this.hasPendingSuccessMessages(run)) {
1078
+ this.pendingRunNotices.set(run.id, run);
1079
+ if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
1080
+ }
1081
+ this.emit("subagent:notification", { runId: run.id, kind, body: "", suppressed: true });
1082
+ return;
1083
+ }
1084
+ // Aggregate-only mode has no per-task hold: keep a run-level watch so a pending report
1085
+ // that later goes lost still produces a fallback.
1086
+ if (
1087
+ !this.notificationRetries.has(run.id) &&
1088
+ !this.pendingRunNotices.has(run.id) &&
1089
+ run.tasks.some((task) => task.finalParentReport?.delivery === "pending")
1090
+ ) {
1091
+ this.pendingRunNotices.set(run.id, run);
1092
+ if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
1093
+ }
1094
+ }
1095
+ if (kind === "asked") {
1096
+ const body = makeAskNotice(run, extra ?? {});
1097
+ try {
1098
+ this.pi.sendUserMessage(body, { deliverAs: "steer" });
1099
+ this.emit("subagent:notification", { runId: run.id, taskId: extra?.taskId, kind, body, delivery: "submitted" });
1100
+ } catch {
1101
+ this.emit("subagent:notification", { runId: run.id, taskId: extra?.taskId, kind, body, delivery: "failed" });
1102
+ }
1103
+ return;
1104
+ }
1105
+ const body = makeNotice(run, kind, tasks);
1106
+ const deliverAs = kind === "failed" ? "steer" : "followUp";
1107
+ for (const task of tasks) this.noteTerminal(run.id, task.id, body, outcomeFingerprint(task));
1108
+ if (deliverAs === "steer") {
1109
+ try {
1110
+ this.pi.sendUserMessage(body, { deliverAs });
1111
+ this.leaderMessages.set(body, {
1112
+ runId: run.id,
1113
+ taskIds: tasks.map((t) => t.id),
1114
+ kind: "terminal",
1115
+ deliverAs,
1116
+ delivered: false,
1117
+ submitted: true,
1118
+ lost: false,
1119
+ submittedAt: Date.now(),
1120
+ attempts: 0,
1121
+ submittedRunCount: this.agentRunCount,
1122
+ });
1123
+ if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext, RECEIPT_GRACE_MS);
1124
+ } catch {
1125
+ for (const task of tasks) {
1126
+ const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
1127
+ if (notice?.body === body) notice.delivery = "lost";
1128
+ }
1129
+ }
1130
+ this.emit("subagent:notification", { runId: run.id, kind, body, delivery: "submitted" });
1131
+ this.holdRunFallback(run, tasks);
1132
+ return;
1133
+ }
1134
+ this.enqueueNotification(body, {
1135
+ runId: run.id,
1136
+ taskIds: tasks.map((t) => t.id),
1137
+ kind: "terminal",
1138
+ deliverAs,
1139
+ });
1140
+ this.emit("subagent:notification", { runId: run.id, kind, body, delivery: "held" });
1141
+ this.holdRunFallback(run, tasks);
1142
+ }
1143
+
1144
+ /** Keep one bounded retry for aggregates whose per-task receipts never arrived. */
1145
+ private holdRunFallback(run: RunSnapshot, tasks: TaskSnapshot[]): void {
1146
+ if (this.notificationRetries.has(run.id)) return;
1147
+ const covered = tasks.filter((task) => {
1148
+ const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
1149
+ return Boolean(notice && notice.body !== "");
1150
+ });
1151
+ if (covered.length === 0) return;
1152
+ this.pendingRunNotices.set(run.id, run);
1153
+ if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
527
1154
  }
528
1155
 
529
1156
  private widgetTui: TUI | null = null;
@@ -630,6 +1257,7 @@ export class SubagentManager {
630
1257
  }
631
1258
 
632
1259
  private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
1260
+ this.notificationContext = ctx;
633
1261
  return {
634
1262
  onAskParent: async (_taskId, question, urgent) => {
635
1263
  if (TERMINAL.includes(task.status)) {
@@ -649,30 +1277,10 @@ export class SubagentManager {
649
1277
  this.updateTask(run, task, { status: "running" }, ctx);
650
1278
  return reply;
651
1279
  },
652
- onNotifyParent: (_taskId, message, level) => {
653
- this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
654
- task.notifiedParent = true;
655
-
656
- if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message })) return;
657
- try {
658
- this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
659
- } catch {}
660
- },
661
- onSendMessage: (_taskId, to, text) => {
662
- if (to === "leader") {
663
- this.emit("subagent:intercom", {
664
- runId: run.id,
665
- taskId: task.id,
666
- kind: "notify",
667
- level: "info",
668
- message: text,
669
- });
670
- if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text })) return true;
671
- try {
672
- this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
673
- } catch {}
674
- return true;
675
- }
1280
+ onNotifyParent: (_taskId, message, level, final = false) =>
1281
+ this.submitLeaderReport(run, task, message, level, final, ctx),
1282
+ onSendMessage: (_taskId, to, text, final = false) => {
1283
+ if (to === "leader") return this.submitLeaderReport(run, task, text, "info", final, ctx);
676
1284
 
677
1285
  return this.mailboxes.send(`${run.id}:${task.id}`, `${run.id}:${to}`, text);
678
1286
  },
@@ -750,6 +1358,7 @@ export class SubagentManager {
750
1358
  const text = getFirstText(message);
751
1359
  if (text) {
752
1360
  task.finalText = truncateText(text);
1361
+ task.finalTextFingerprint = textFingerprint(text);
753
1362
  task.lastActivity = activitySnippet(text);
754
1363
  }
755
1364
  state.pendingFailure = classifyFailure(message.stopReason, message.errorMessage);
@@ -908,7 +1517,7 @@ export class SubagentManager {
908
1517
  const worktreeNote = wt
909
1518
  ? ` You work in an isolated git worktree (branch ${wt.branch})${task.stackedOn ? `, stacked on ${task.stackedOn} (its changes are already in your tree)` : ""}. Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes. node_modules is a SHARED symlink to the main checkout: never install, upgrade, or delete dependencies (no npm/bun/yarn/pnpm install, no \`rm -rf node_modules\`) — those writes escape your worktree and damage the user's project. If the task truly needs a dependency change, edit the manifest only and say so in your answer.`
910
1519
  : "";
911
- const subagentInstruction = `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has (set urgent: true only when nothing else can proceed while you wait); notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. An unanswered ask_parent times out after 10 minutes — proceed with your best judgment then. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${worktreeNote}`;
1520
+ const subagentInstruction = `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has (set urgent: true only when nothing else can proceed while you wait); notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. An unanswered ask_parent times out after 10 minutes — proceed with your best judgment then. When your work is done, call notify_parent ONCE with final: true and your complete concise result — key findings, verdicts, file:line evidence — as your last tool call. Repeat that exact message as your final answer; changing it or doing more tool work keeps a completion follow-up. Progress updates omit final. Identical updates to the leader are deduplicated.${worktreeNote}`;
912
1521
 
913
1522
  const loader = new DefaultResourceLoader({
914
1523
  cwd: childCwd,
@@ -1014,12 +1623,22 @@ export class SubagentManager {
1014
1623
  const finalText =
1015
1624
  task.finalText ||
1016
1625
  truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
1626
+ // Prefer the fingerprint captured from the raw assistant text; never hash the 24-KiB-truncated copy.
1627
+ const finalFingerprint =
1628
+ task.finalTextFingerprint ??
1629
+ textFingerprint((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || finalText);
1017
1630
 
1018
1631
  if (task.status === "awaiting_parent") {
1019
1632
  this.pendingReplies.get(key)?.resolve("(your task is being finalized — stop work and return now)");
1020
1633
  }
1021
1634
  if (!TERMINAL.includes(task.status)) {
1022
- this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
1635
+ this.updateTask(
1636
+ run,
1637
+ task,
1638
+ { status: "completed", finalText, finalTextFingerprint: finalFingerprint, endedAt: Date.now() },
1639
+ ctx,
1640
+ onUpdate,
1641
+ );
1023
1642
  if (wt) {
1024
1643
  let committed: "committed" | "empty" | undefined;
1025
1644
  try {
@@ -1075,11 +1694,9 @@ export class SubagentManager {
1075
1694
  await Promise.race([child?.abort(), new Promise((r) => setTimeout(r, 5000))]);
1076
1695
  } catch {}
1077
1696
 
1078
- const salvaged =
1079
- task.finalText ||
1080
- truncateText(
1081
- (child?.messages as AssistantMessage[] | undefined)?.map(getFirstText).filter(Boolean).at(-1) || "",
1082
- );
1697
+ const salvageText =
1698
+ (child?.messages as AssistantMessage[] | undefined)?.map(getFirstText).filter(Boolean).at(-1) || "";
1699
+ const salvaged = task.finalText || truncateText(salvageText);
1083
1700
  this.updateTask(
1084
1701
  run,
1085
1702
  task,
@@ -1087,6 +1704,7 @@ export class SubagentManager {
1087
1704
  status: aborted ? "aborted" : ((subagentStatus as TaskStatus) ?? "failed"),
1088
1705
  error: err instanceof Error ? err.message : String(err),
1089
1706
  finalText: salvaged || undefined,
1707
+ finalTextFingerprint: task.finalTextFingerprint ?? (salvageText ? textFingerprint(salvageText) : undefined),
1090
1708
  endedAt: Date.now(),
1091
1709
  },
1092
1710
  ctx,
@@ -1459,14 +2077,46 @@ export class SubagentManager {
1459
2077
  error: undefined,
1460
2078
  endedAt: undefined,
1461
2079
  finalText: undefined,
2080
+ finalTextFingerprint: undefined,
1462
2081
  notifiedParent: false,
2082
+ finalParentReport: undefined,
1463
2083
  diffStat: undefined,
1464
2084
  changedFiles: undefined,
1465
2085
  worktreeError: undefined,
1466
2086
  });
2087
+ const scope = `${run.id}:${task.id}`;
2088
+ this.terminalNotices.delete(scope);
2089
+ this.pendingTaskNotices.delete(scope);
2090
+ this.pendingRunNotices.delete(run.id);
2091
+ this.notificationRetries.delete(run.id);
2092
+ for (const [body, receipt] of this.leaderMessages) {
2093
+ if (receipt.runId !== run.id || !receipt.taskIds.includes(task.id)) continue;
2094
+ if (receipt.taskIds.length === 1) {
2095
+ this.leaderMessages.delete(body);
2096
+ continue;
2097
+ }
2098
+ // A shared aggregate stays for its remaining tasks; the resumed task's slice is published
2099
+ // again by the run's next settle.
2100
+ receipt.taskIds = receipt.taskIds.filter((id) => id !== task.id);
2101
+ }
2102
+ for (const [body, entry] of this.outbox) {
2103
+ if (entry.runId !== run.id || !entry.taskIds.includes(task.id)) continue;
2104
+ if (entry.taskIds.length === 1) {
2105
+ this.outbox.delete(body);
2106
+ continue;
2107
+ }
2108
+ this.outbox.delete(body);
2109
+ for (const otherId of entry.taskIds) {
2110
+ if (otherId === task.id) continue;
2111
+ const notice = this.terminalNotices.get(`${run.id}:${otherId}`);
2112
+ if (notice) notice.delivery = "lost";
2113
+ }
2114
+ }
2115
+ this.awaitedTaskNotices.delete(scope);
1467
2116
  run.status = "running";
1468
2117
  run.endedAt = undefined;
1469
2118
  run.awaited = false;
2119
+ run.completionAwaited = false;
1470
2120
  this.settlers.set(run.id, true);
1471
2121
  this.runControllers.set(run.id, new AbortController());
1472
2122
  for (const t of run.tasks) this.mailboxes.open(`${run.id}:${t.id}`);
@@ -1579,7 +2229,11 @@ export class SubagentManager {
1579
2229
  this.settlers.delete(runId);
1580
2230
  const waiters = this.settleWaiters.get(runId);
1581
2231
  this.settleWaiters.delete(runId);
1582
- if (!waiters) return;
2232
+ if (!waiters?.size) return;
2233
+ if (this.parked.get(runId)?.size) {
2234
+ run.awaited = true;
2235
+ run.completionAwaited = true;
2236
+ }
1583
2237
  const snapshot = cloneRun(run);
1584
2238
  for (const waiter of waiters) waiter(snapshot);
1585
2239
  }
@@ -1625,6 +2279,7 @@ export class SubagentManager {
1625
2279
  };
1626
2280
  if (TERMINAL.includes(run.status)) {
1627
2281
  run.awaited = true;
2282
+ run.completionAwaited = true;
1628
2283
  return Promise.resolve({ run: cloneRun(run), intercom: [] });
1629
2284
  }
1630
2285
  const msgs: ParkedMsg[] = [];
@@ -1654,7 +2309,10 @@ export class SubagentManager {
1654
2309
  settled.then((r) => {
1655
2310
  finish();
1656
2311
 
1657
- if (!timedOut) run.awaited = true;
2312
+ if (!timedOut) {
2313
+ run.awaited = true;
2314
+ if (r && TERMINAL.includes(r.status)) run.completionAwaited = true;
2315
+ }
1658
2316
  return { run: r, intercom: msgs };
1659
2317
  }),
1660
2318
  new Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined>((resolve) => {
@@ -1670,6 +2328,7 @@ export class SubagentManager {
1670
2328
  return settled.then((r) => {
1671
2329
  finish();
1672
2330
  run.awaited = true;
2331
+ if (r && TERMINAL.includes(r.status)) run.completionAwaited = true;
1673
2332
  return { run: r, intercom: msgs };
1674
2333
  });
1675
2334
  }