@arhen/pi-core-subagent 1.3.64 → 1.3.66

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