pi-extended-teams 2.2.10 → 2.3.1

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.
Files changed (58) hide show
  1. package/README.md +182 -2
  2. package/extensions/agents/read-agent-report.ts +11 -2
  3. package/extensions/agents/read-agent-session-lifecycle.ts +25 -10
  4. package/extensions/agents/read-agent.ts +387 -65
  5. package/extensions/agents/write-agent.ts +3 -0
  6. package/extensions/events/register-events.ts +37 -21
  7. package/extensions/index.ts +257 -60
  8. package/extensions/internal/pi-check-operations.ts +24 -0
  9. package/extensions/internal/session-files.ts +10 -2
  10. package/extensions/runtime/lead-attachment.ts +91 -0
  11. package/extensions/runtime/reload-owner.ts +107 -0
  12. package/extensions/runtime/team-host.ts +124 -0
  13. package/extensions/runtime/teammate-interrupt.ts +11 -5
  14. package/extensions/runtime/types.ts +4 -0
  15. package/extensions/team/lifecycle.ts +26 -0
  16. package/extensions/team/session-cost.ts +225 -0
  17. package/extensions/tools/agent-communication-tools.ts +24 -8
  18. package/extensions/tools/agent-status-tool.ts +50 -7
  19. package/extensions/tools/coordination-tools.ts +78 -8
  20. package/extensions/tools/public-team-tools.ts +51 -0
  21. package/extensions/tools/task-runtime-tools.ts +23 -4
  22. package/extensions/tools/team-tools.ts +365 -107
  23. package/extensions/ui/activity-colors.ts +50 -0
  24. package/extensions/ui/agent-follow-view.ts +15 -11
  25. package/extensions/ui/agent-navigation.ts +35 -1
  26. package/extensions/ui/checkpoints-command.ts +28 -0
  27. package/extensions/ui/onboarding-command.ts +218 -0
  28. package/extensions/ui/status-widget.ts +22 -14
  29. package/package.json +1 -1
  30. package/skills/teams.md +16 -0
  31. package/src/orchestration/index.ts +19 -1
  32. package/src/orchestration/status-projection.ts +2 -3
  33. package/src/orchestration/types.ts +9 -1
  34. package/src/results/check-journal.ts +149 -0
  35. package/src/results/check-policy.ts +86 -0
  36. package/src/results/check-runner.ts +110 -0
  37. package/src/results/checkpoint-assignment.ts +60 -0
  38. package/src/results/checkpoint-policy.ts +20 -0
  39. package/src/results/checkpoint-report.ts +109 -0
  40. package/src/results/checkpoint-retention.ts +15 -0
  41. package/src/results/completion-group-delivery.ts +66 -0
  42. package/src/results/completion-group-recovery.ts +61 -0
  43. package/src/results/completion-group-wake.ts +62 -0
  44. package/src/results/completion-group.ts +290 -0
  45. package/src/results/continuation-recipient.ts +37 -0
  46. package/src/results/durable-json.ts +29 -0
  47. package/src/results/repair-policy.ts +24 -0
  48. package/src/results/report-result.ts +77 -0
  49. package/src/results/source-identity.ts +115 -0
  50. package/src/results/specialist-checkpoint.ts +227 -0
  51. package/src/results/verification-controller.ts +308 -0
  52. package/src/utils/messaging.ts +47 -10
  53. package/src/utils/models.ts +13 -0
  54. package/src/utils/paths.ts +4 -0
  55. package/src/utils/report-events.ts +95 -10
  56. package/src/utils/settings.ts +24 -0
  57. package/src/utils/teams.ts +3 -1
  58. package/src/utils/write-queue.ts +3 -0
@@ -10,14 +10,24 @@ import * as teams from "../../src/utils/teams";
10
10
  import * as runtime from "../../src/utils/runtime";
11
11
  import * as messaging from "../../src/utils/messaging";
12
12
  import * as writeQueue from "../../src/utils/write-queue";
13
- import { ACCEPTED_FAVORITE_MODEL_SLOTS, FAVORITE_MODEL_SLOTS, canonicalPersistedModelSlot, isFavoriteModelSlot, loadSettings, normalizeFavoriteModelSlot, requireFavoriteModelLevel, resolveModel, roleForFavoriteModelSlot, type AgentRole, type CanonicalFavoriteModelSlot } from "../../src/utils/settings";
13
+ import { FAVORITE_MODEL_SLOTS, canonicalPersistedModelSlot, isFavoriteModelSlot, loadSettings, normalizeFavoriteModelSlot, requireFavoriteModelLevel, resolveModel, roleForFavoriteModelSlot, type AgentRole, type CanonicalFavoriteModelSlot } from "../../src/utils/settings";
14
14
  import type { Member } from "../../src/utils/models";
15
+ import { normalizeCheckPolicy } from "../../src/results/check-policy";
16
+ import { normalizeRepairPolicy } from "../../src/results/repair-policy";
17
+ import { createCheckpointAssignment, captureCheckpointAssignment, continuationPrompt } from "../../src/results/checkpoint-assignment";
18
+ import { assertUnusedContinuationRecipient, freshContinuationName } from "../../src/results/continuation-recipient";
19
+ import { readCheckpoint } from "../../src/results/specialist-checkpoint";
20
+ import { CompletionGroup, completionGroupIdentity, normalizeCompletionGroupPolicy, type CompletionGroupBinding } from "../../src/results/completion-group";
21
+ import { listStoredTeamReportEvents } from "../../src/utils/report-events";
22
+ import { enqueueCompletionGroupDeliveries } from "../../src/results/completion-group-delivery";
23
+ import { shouldSuppressLeadReportInjection } from "../../src/utils/workflow-metadata";
15
24
 
16
25
  import type { RunningReadAgent } from "../runtime/types";
17
26
  import type { ReadAgentTeardownResult } from "../agents/read-agent-session-lifecycle";
18
27
  import type { ShutdownTeammateOptions } from "../team/lifecycle";
19
28
  import {
20
29
  onLifecycleTombstoneCleared,
30
+ listLifecycleTombstones,
21
31
  readLifecycleTombstone,
22
32
  withLifecycleTombstoneLock,
23
33
  type LifecycleTombstoneLock,
@@ -37,6 +47,8 @@ import {
37
47
  type QueuedAgentStatus,
38
48
  } from "./agent-status-tool";
39
49
 
50
+ import { publicTeamToolMetadata } from "./public-team-tools";
51
+
40
52
  export const CHILD_AGENT_LIFECYCLE_PROBE = "pi-extended-teams:child-agent-lifecycle-probe";
41
53
 
42
54
  interface ChildAgentLifecycleProbe {
@@ -55,6 +67,7 @@ export interface TeamToolsOptions {
55
67
  startWriteAgent(teamName: string, member: Member, prompt: string): Promise<string>;
56
68
  shutdownTeammate(teamName: string, member: Member, options?: ShutdownTeammateOptions): Promise<ReadAgentTeardownResult>;
57
69
  adoptTeamAsLead(teamName: string, ctx?: any): void;
70
+ onCompletionGroupUse?(groupId: string): void;
58
71
  buildRoster(teamName: string): Promise<any>;
59
72
  isTeammate: boolean;
60
73
  agentName: string;
@@ -62,6 +75,8 @@ export interface TeamToolsOptions {
62
75
  getSessionCtx?(): any;
63
76
  setSessionCtx?(ctx: any): void;
64
77
  pendingChildController?: PendingChildController;
78
+ isHostAttached?(): boolean;
79
+ waitForHost?(): Promise<void>;
65
80
  }
66
81
 
67
82
  export interface NestedReadAgentToolBinding {
@@ -71,13 +86,24 @@ export interface NestedReadAgentToolBinding {
71
86
  outerCtx: any;
72
87
  }
73
88
 
89
+ export interface NestedChildSnapshot {
90
+ running: number;
91
+ queued: number;
92
+ }
93
+
74
94
  export interface TeamToolsRuntime {
75
95
  createNestedReadAgentTools(binding: NestedReadAgentToolBinding): any[];
76
- cancelQueuedAgent(teamName: string, agentName: string): boolean;
96
+ nestedChildSnapshot(binding: NestedReadAgentToolBinding): NestedChildSnapshot;
97
+ cancelQueuedAgent(teamName: string, agentName: string): boolean | Promise<boolean>;
98
+ hasPendingWork(): boolean;
99
+ closeAdmission(): void;
100
+ resumeQueues(): void;
77
101
  }
78
102
 
79
103
  interface SpawnTeammateOptions {
80
104
  once?: boolean;
105
+ signal?: AbortSignal;
106
+ completionGroup?: CompletionGroupBinding;
81
107
  nestedParent?: {
82
108
  teamName: string;
83
109
  name: string;
@@ -97,15 +123,24 @@ interface QueuedReadSpawn {
97
123
  ctx: any;
98
124
  requestedAt: number;
99
125
  nameReservationId?: string;
126
+ signal?: AbortSignal;
100
127
  pendingChildAcceptance?: PendingChildAcceptance;
101
128
  admissionError?: string;
102
129
  quarantineError?: string;
103
130
  launchCommitted?: boolean;
131
+ groupSettlement?: Promise<void>;
104
132
  }
105
133
 
106
134
  export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamToolsRuntime {
107
135
  if (options.isTeammate) {
108
- return { createNestedReadAgentTools: () => [], cancelQueuedAgent: () => false };
136
+ return {
137
+ createNestedReadAgentTools: () => [],
138
+ nestedChildSnapshot: () => ({ running: 0, queued: 0 }),
139
+ cancelQueuedAgent: () => false,
140
+ hasPendingWork: () => false,
141
+ closeAdmission: () => {},
142
+ resumeQueues: () => {},
143
+ };
109
144
  }
110
145
 
111
146
  function emitOrchestrationResponse(requestId: string | undefined, type: string, payload: Record<string, any>): void {
@@ -297,6 +332,20 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
297
332
  return queuedReadSpawnsByTeam.get(teamName) ?? [];
298
333
  }
299
334
 
335
+ function nestedChildSnapshot(binding: NestedReadAgentToolBinding): NestedChildSnapshot {
336
+ const parent = {
337
+ teamName: binding.teamName,
338
+ parentName: binding.parent.name,
339
+ parentRunId: binding.parentRunId,
340
+ };
341
+ const queued = readQueue(binding.teamName).filter((child) => {
342
+ const childParent = pendingParentForMember(binding.teamName, child.member);
343
+ return childParent?.parentName === parent.parentName && childParent.parentRunId === parent.parentRunId;
344
+ }).length;
345
+ const pending = pendingChildController.pendingCount(parent);
346
+ return { running: Math.max(0, pending - queued), queued };
347
+ }
348
+
300
349
  async function listQueuedAgentStatuses(teamName: string): Promise<QueuedAgentStatus[]> {
301
350
  const readers = [...readQueue(teamName), ...(failedAdmissionsByTeam.get(teamName) ?? [])].map((queued, index) => ({
302
351
  name: queued.member.name,
@@ -342,17 +391,62 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
342
391
  queued: readQueue(activeTeamName).length,
343
392
  });
344
393
  });
394
+ const checkpointAdmissions = new Map<string, { controller: AbortController; settled: Promise<void> }>();
395
+ function cancelCheckpointAdmission(teamName: string, name: string): Promise<void> | undefined {
396
+ const pending = checkpointAdmissions.get(options.readAgentKey(teamName, name));
397
+ pending?.controller.abort(new Error("Checkpoint preparation cancelled."));
398
+ return pending?.settled;
399
+ }
345
400
  let lifecycleProbeCleanedUp = false;
401
+ let shutdownStarted = false;
402
+ const closeAdmission = () => { lifecycleProbeCleanedUp = true; };
403
+ const admissions = new Set<Promise<void>>();
404
+ async function trackAdmission<T>(action: () => Promise<T>): Promise<T> {
405
+ if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
406
+ let settle!: () => void;
407
+ const settled = new Promise<void>(resolve => {
408
+ settle = resolve;
409
+ });
410
+ admissions.add(settled);
411
+
412
+ try {
413
+ return await action();
414
+ } finally {
415
+ admissions.delete(settled);
416
+ settle();
417
+ }
418
+ }
346
419
  let pendingChildCancelUnsubscribe = (): void => {};
347
- pi.on?.("session_shutdown", () => {
348
- if (lifecycleProbeCleanedUp) return;
349
- lifecycleProbeCleanedUp = true;
420
+ pi.on?.("session_shutdown", async () => {
421
+ if (shutdownStarted) return;
422
+ shutdownStarted = true;
423
+ closeAdmission();
424
+ const settlements: Promise<void>[] = [];
425
+ for (const pending of checkpointAdmissions.values()) {
426
+ pending.controller.abort(new Error("Checkpoint preparation cancelled: session is closing."));
427
+ settlements.push(pending.settled);
428
+ }
350
429
  for (const [teamName, queue] of queuedReadSpawnsByTeam) {
351
- for (const queued of queue) removeQueuedReadSpawnById(teamName, queued.id);
430
+ for (const queued of queue) {
431
+ const removed = removeQueuedReadSpawnById(teamName, queued.id);
432
+ if (removed?.groupSettlement) settlements.push(removed.groupSettlement);
433
+ }
434
+ }
435
+ let deadline: ReturnType<typeof setTimeout> | undefined;
436
+ const admissionSettlement = Promise.race([
437
+ Promise.all([...admissions]),
438
+ new Promise<never>((_resolve, reject) => {
439
+ deadline = setTimeout(() => reject(new Error("Agent admissions are still settling during shutdown.")), 2_500);
440
+ deadline.unref?.();
441
+ }),
442
+ ]);
443
+ try { await Promise.all([...settlements, admissionSettlement]); }
444
+ finally {
445
+ clearTimeout(deadline);
446
+ if (typeof lifecycleProbeUnsubscribe === "function") lifecycleProbeUnsubscribe();
447
+ pendingChildCancelUnsubscribe();
448
+ lifecycleFenceUnsubscribe();
352
449
  }
353
- if (typeof lifecycleProbeUnsubscribe === "function") lifecycleProbeUnsubscribe();
354
- pendingChildCancelUnsubscribe();
355
- lifecycleFenceUnsubscribe();
356
450
  });
357
451
 
358
452
  function setReadQueue(teamName: string, queue: QueuedReadSpawn[]): void {
@@ -373,6 +467,17 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
373
467
  };
374
468
  }
375
469
 
470
+ function settleQueuedGroup(queued: QueuedReadSpawn): Promise<void> | undefined {
471
+ const binding = queued.member.completionGroup;
472
+ if (!binding) return;
473
+ return Promise.resolve().then(async () => {
474
+ const group = new CompletionGroup(queued.teamName, binding.groupId);
475
+ await group.apply({ type: queued.admissionError ? "rejected" : "cancelled", ...binding,
476
+ queueId: queued.id, reason: queued.admissionError || "Queued assignment cancelled." });
477
+ await enqueueCompletionGroupDeliveries(group);
478
+ });
479
+ }
480
+
376
481
  function removeQueuedReadSpawnById(
377
482
  teamName: string,
378
483
  id: string,
@@ -386,6 +491,10 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
386
491
  if (settlePendingAcceptance && removed.pendingChildAcceptance) {
387
492
  pendingChildController.settleAcceptance(removed.pendingChildAcceptance);
388
493
  }
494
+ if (settlePendingAcceptance) {
495
+ cancelCheckpointAdmission(teamName, removed.member.name);
496
+ removed.groupSettlement = settleQueuedGroup(removed);
497
+ }
389
498
  return removed;
390
499
  }
391
500
 
@@ -397,6 +506,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
397
506
  for (const queued of removed) {
398
507
  releaseNestedReadName(teamName, queued.member.name, queued.nameReservationId);
399
508
  if (queued.pendingChildAcceptance) pendingChildController.settleAcceptance(queued.pendingChildAcceptance);
509
+ const pending = cancelCheckpointAdmission(teamName, queued.member.name);
510
+ const group = settleQueuedGroup(queued);
511
+ queued.groupSettlement = pending ? Promise.all([pending, group]).then(() => {}) : group;
400
512
  }
401
513
  return removed;
402
514
  }
@@ -561,6 +673,12 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
561
673
  }
562
674
 
563
675
  try {
676
+ if (member.completionGroup) {
677
+ if (!member.lifecycleRunId) throw new Error("Grouped admission requires an actual lifecycle run.");
678
+ await new CompletionGroup(teamName, member.completionGroup.groupId).apply({
679
+ type: "running", ...member.completionGroup, runId: member.lifecycleRunId,
680
+ });
681
+ }
564
682
  if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
565
683
  assertPending?.();
566
684
  } catch (error) {
@@ -608,9 +726,10 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
608
726
  sessionContextReference = null;
609
727
  }
610
728
  }
729
+ const continuedPrompt = continuationPrompt(member.checkpointAssignment, prompt);
611
730
  const launchPrompt = sessionContextReference
612
- ? `${prompt}${sessionContextReference.promptSuffix}`
613
- : prompt;
731
+ ? `${continuedPrompt}${sessionContextReference.promptSuffix}`
732
+ : continuedPrompt;
614
733
  const removeReference = () => {
615
734
  try {
616
735
  removeSessionContextReference(sessionContextReference);
@@ -622,6 +741,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
622
741
  try {
623
742
  if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
624
743
  assertPending?.();
744
+ if (member.checkpointAssignment?.parent) readCheckpoint(member.checkpointAssignment.parent.id);
625
745
  commitLaunch?.();
626
746
  const launch = options.runReadAgentInProcess(teamName, member, launchPrompt, ctx, options.readAgentOptions());
627
747
  return {
@@ -654,10 +774,21 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
654
774
  releaseNameOnFailure = true,
655
775
  queuedAcceptance?: PendingChildAcceptance,
656
776
  assertPending?: () => void,
777
+ signal?: AbortSignal,
657
778
  commitLaunch?: () => void,
658
779
  ): Promise<boolean> {
659
780
  const key = options.readAgentKey(teamName, member.name);
781
+ if (checkpointAdmissions.has(key)) throw new Error(`Checkpoint admission for ${member.name} is already in progress.`);
660
782
  reserveReadAdmission(teamName, key, member.role || "read");
783
+ const controller = member.checkpointAssignment ? new AbortController() : undefined;
784
+ let settleCheckpoint = () => {};
785
+ if (controller) checkpointAdmissions.set(key, { controller, settled: new Promise<void>(resolve => { settleCheckpoint = resolve; }) });
786
+ const checkpointSignal = controller ? AbortSignal.any([controller.signal, AbortSignal.timeout(30_000), ...(signal ? [signal] : [])]) : undefined;
787
+ const finishCheckpoint = () => {
788
+ if (controller && checkpointAdmissions.get(key)?.controller === controller) checkpointAdmissions.delete(key);
789
+ settleCheckpoint();
790
+ };
791
+ const assertAdmissionPending = () => { checkpointSignal?.throwIfAborted(); assertPending?.(); };
661
792
 
662
793
  const releaseNameReservation = () => {
663
794
  releaseNestedReadName(teamName, member.name, nameReservationId);
@@ -688,7 +819,15 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
688
819
  };
689
820
 
690
821
  try {
691
- const admitted = await admitAndLaunchReadAgentMember(teamName, member, prompt, ctx, queuedAcceptance, assertPending, commitLaunch);
822
+ if (member.checkpointAssignment && checkpointSignal) {
823
+ if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
824
+ assertAdmissionPending();
825
+ member.checkpointAssignment = await captureCheckpointAssignment(member.checkpointAssignment, member.cwd, checkpointSignal);
826
+ }
827
+ const admitted = await admitAndLaunchReadAgentMember(teamName, member, prompt, ctx, queuedAcceptance, assertAdmissionPending, () => {
828
+ finishCheckpoint();
829
+ commitLaunch?.();
830
+ });
692
831
  releaseNameReservation();
693
832
  void Promise.resolve(admitted.launch).then(
694
833
  () => drainAfterRun(admitted.pendingChildRun),
@@ -699,7 +838,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
699
838
  settlePendingChild(queuedAcceptance);
700
839
  releaseReservationAndDrain();
701
840
  throw error;
702
- }
841
+ } finally { finishCheckpoint(); }
703
842
  }
704
843
 
705
844
  async function enqueueReadSpawn(
@@ -709,13 +848,10 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
709
848
  params: any,
710
849
  resolved: ReturnType<typeof resolveModel>,
711
850
  ctx: any,
712
- nameReservationId?: string
851
+ nameReservationId?: string,
852
+ signal?: AbortSignal,
713
853
  ): Promise<QueuedReadSpawn> {
714
- const append = (pendingChildAcceptance?: PendingChildAcceptance): QueuedReadSpawn => {
715
- const queue = readQueue(teamName);
716
- if (queue.some((queued) => queued.member.name === member.name)) {
717
- throw new Error(`Nested read agent ${member.name} is already active or queued; nested delegation cannot replace an existing run.`);
718
- }
854
+ const append = async (pendingChildAcceptance?: PendingChildAcceptance): Promise<QueuedReadSpawn> => {
719
855
  const queued: QueuedReadSpawn = {
720
856
  id: crypto.randomUUID(),
721
857
  teamName,
@@ -726,9 +862,21 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
726
862
  ctx,
727
863
  requestedAt: Date.now(),
728
864
  nameReservationId,
865
+ signal,
729
866
  pendingChildAcceptance,
730
867
  };
731
- setReadQueue(teamName, [...queue, queued]);
868
+ if (member.completionGroup) {
869
+ const group = new CompletionGroup(teamName, member.completionGroup.groupId);
870
+ await group.apply({ type: "queued", ...member.completionGroup, queueId: queued.id });
871
+ if (lifecycleProbeCleanedUp) {
872
+ await group.apply({ type: "cancelled", ...member.completionGroup, reason: "Agent session is closing." });
873
+ throw new Error("Agent session is closing; admission cancelled.");
874
+ }
875
+ }
876
+ if (readQueue(teamName).some(item => item.member.name === member.name)) {
877
+ throw new Error(`Nested read agent ${member.name} is already active or queued; nested delegation cannot replace an existing run.`);
878
+ }
879
+ setReadQueue(teamName, [...readQueue(teamName), queued]);
732
880
  void drainQueuedReadSpawns(teamName);
733
881
  return queued;
734
882
  };
@@ -744,7 +892,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
744
892
  if (!parent) throw new Error(`Nested read agent ${member.name} is missing exact parent identity.`);
745
893
  const pendingChildAcceptance = pendingChildController.acceptChild(parent, member.name);
746
894
  try {
747
- return append(pendingChildAcceptance);
895
+ return await append(pendingChildAcceptance);
748
896
  } catch (error) {
749
897
  pendingChildController.settleAcceptance(pendingChildAcceptance);
750
898
  throw error;
@@ -753,7 +901,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
753
901
  }
754
902
 
755
903
  async function drainQueuedReadSpawns(teamName: string): Promise<void> {
756
- if (lifecycleProbeCleanedUp) return;
904
+ if (lifecycleProbeCleanedUp || options.isHostAttached?.() === false) return;
757
905
  if (readQueueDrainingTeams.has(teamName)) {
758
906
  pendingQueueDrains.add(teamName);
759
907
  return;
@@ -761,7 +909,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
761
909
  readQueueDrainingTeams.add(teamName);
762
910
  try {
763
911
  let progressed = true;
764
- while (progressed && !lifecycleProbeCleanedUp) {
912
+ while (progressed && !lifecycleProbeCleanedUp && options.isHostAttached?.() !== false) {
765
913
  progressed = false;
766
914
  for (const queued of readQueue(teamName)) {
767
915
  if (queued.admissionError) continue;
@@ -785,7 +933,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
785
933
  if (activeAgentCount(teamName, role, true) >= capacity.maxConcurrent) continue;
786
934
  queued.member.joinedAt = Date.now();
787
935
  await startReadAgentMember(teamName, queued.member, queued.prompt, queued.ctx,
788
- queued.nameReservationId, false, queued.pendingChildAcceptance, assertPending,
936
+ queued.nameReservationId, false, queued.pendingChildAcceptance, assertPending, queued.signal,
789
937
  () => { queued.launchCommitted = true; });
790
938
  removeQueuedReadSpawnById(teamName, queued.id, false);
791
939
  progressed = true;
@@ -799,10 +947,17 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
799
947
  }
800
948
  queued.admissionError = error instanceof Error ? error.message : String(error);
801
949
  failedAdmissionsByTeam.set(teamName, [...(failedAdmissionsByTeam.get(teamName) ?? []), queued].slice(-20));
802
- removeQueuedReadSpawnById(teamName, queued.id);
803
- await messaging.sendPlainMessage(teamName, "system", queued.member.parentAgentName || "team-lead",
804
- `Queued agent ${queued.member.name} failed admission: ${queued.admissionError}`,
805
- `Queued agent ${queued.member.name} failed`, "red").catch(() => {});
950
+ const removed = removeQueuedReadSpawnById(teamName, queued.id);
951
+ let groupSettlementFailed = false;
952
+ await removed?.groupSettlement?.catch(error => {
953
+ groupSettlementFailed = true;
954
+ queued.admissionError += ` Group settlement failed: ${error instanceof Error ? error.message : String(error)}`;
955
+ });
956
+ if (!queued.member.completionGroup || groupSettlementFailed) {
957
+ await messaging.sendPlainMessage(teamName, "system", queued.member.parentAgentName || "team-lead",
958
+ `Queued agent ${queued.member.name} failed admission: ${queued.admissionError}`,
959
+ `Queued agent ${queued.member.name} failed`, "red").catch(() => {});
960
+ }
806
961
  }
807
962
  }
808
963
  }
@@ -817,8 +972,38 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
817
972
  return paths.sanitizeName(`session-${sessionId}`);
818
973
  }
819
974
 
975
+ let admittingPublicTeam: string | undefined;
976
+ let publicAdmissions = 0;
977
+ async function withPublicScope<T>(teamName: string, action: () => Promise<T>): Promise<T> {
978
+ if (teamName.startsWith("prompt-build-")) return action();
979
+ if (publicAdmissions > 0 && admittingPublicTeam !== teamName) {
980
+ throw new Error(`Cannot switch to ${teamName}: ${admittingPublicTeam} has unfinished agents being admitted.`);
981
+ }
982
+ admittingPublicTeam = teamName;
983
+ publicAdmissions += 1;
984
+ try {
985
+ return await action();
986
+ } finally {
987
+ if (--publicAdmissions === 0) admittingPublicTeam = undefined;
988
+ }
989
+ }
990
+
991
+ async function requireSettledPublicScope(nextTeamName: string): Promise<void> {
992
+ if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
993
+ const current = options.getTeamName();
994
+ if (!current || current === nextTeamName || nextTeamName.startsWith("prompt-build-")) return;
995
+ const [config, fences, queued] = await Promise.all([
996
+ teams.readConfig(current), listLifecycleTombstones(current), listQueuedAgentStatuses(current),
997
+ ]);
998
+ const unfinished = config.members.some(member => member.name !== "team-lead")
999
+ || fences.length > 0 || queued.some(item => !item.failed)
1000
+ || Array.from(options.runningReadAgents.values()).some(agent => agent.teamName === current && agent.teardownState !== "finalized");
1001
+ if (unfinished) throw new Error(`Cannot switch from ${current} to ${nextTeamName}: unfinished agents must remain visible. Finish or stop them first.`);
1002
+ }
1003
+
820
1004
  async function ensureCurrentSessionAgentGroup(ctx: any, explicitDefaultModel: string): Promise<string> {
821
1005
  const sessionName = currentSessionAgentGroupName(ctx);
1006
+ await requireSettledPublicScope(sessionName);
822
1007
  if (teams.teamExists(sessionName)) {
823
1008
  options.adoptTeamAsLead(sessionName, ctx);
824
1009
  return sessionName;
@@ -837,10 +1022,23 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
837
1022
  }
838
1023
 
839
1024
  async function spawnTeammate(params: any, ctx: any, spawnOptions: SpawnTeammateOptions = {}): Promise<{ content: any[]; details: any }> {
1025
+ const checkpointAssignment = createCheckpointAssignment(params.checkpoint, params.prompt, params.continue_from);
1026
+ const assignedChecks = normalizeCheckPolicy(params.checks);
1027
+ const repairPolicy = normalizeRepairPolicy(params.repair);
1028
+ if (repairPolicy && !assignedChecks?.length) throw new Error("Repair policy requires explicitly assigned checks.");
840
1029
  const safeName = paths.sanitizeName(params.name);
841
1030
  const safeTeamName = paths.sanitizeName(params.team_name);
842
1031
  const cwd = params.cwd || ctx.cwd;
843
1032
  const teamConfig = await teams.readConfig(safeTeamName);
1033
+ if (checkpointAssignment?.parent) {
1034
+ requireSpawnLevel(params, `Continuation ${safeName}`);
1035
+ if (safeName.toLowerCase() === checkpointAssignment.parent.author.agentName.toLowerCase()
1036
+ || teamConfig.members.some(member => member.name.toLowerCase() === safeName.toLowerCase())
1037
+ || options.runningReadAgents.has(options.readAgentKey(safeTeamName, safeName)) || findQueuedReadSpawn(safeTeamName, { name: safeName })) {
1038
+ throw new Error(`Continuation recipient ${safeName} is not unused; choose a new prefix and retry.`);
1039
+ }
1040
+ assertUnusedContinuationRecipient(safeTeamName, safeName);
1041
+ }
844
1042
  failedAdmissionsByTeam.set(safeTeamName, (failedAdmissionsByTeam.get(safeTeamName) ?? []).filter(item => item.member.name !== safeName));
845
1043
  let nestedNameReservationId: string | undefined;
846
1044
  let nestedNameReservationTransferred = false;
@@ -871,7 +1069,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
871
1069
  }
872
1070
 
873
1071
  try {
874
- if (spawnOptions.once) {
1072
+ if (spawnOptions.once && !checkpointAssignment?.parent) {
875
1073
  const existingOnceMember = teamConfig.members.find(m => m.agentType === "teammate" && (m.name === safeName || memberMatchesOperation(m, params)));
876
1074
  if (existingOnceMember) {
877
1075
  return {
@@ -910,6 +1108,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
910
1108
  }
911
1109
  }
912
1110
 
1111
+ if (checkpointAdmissions.has(options.readAgentKey(safeTeamName, safeName)) && !findQueuedReadSpawn(safeTeamName, { name: safeName })) {
1112
+ throw new Error(`Checkpoint admission for ${safeName} is already in progress.`);
1113
+ }
913
1114
  const existingMember = teamConfig.members.find(m => m.name === safeName && m.agentType === "teammate");
914
1115
  if (existingMember) {
915
1116
  const key = options.readAgentKey(safeTeamName, existingMember.name);
@@ -980,6 +1181,10 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
980
1181
  color: role === "read" ? "cyan" : "blue",
981
1182
  thinking: chosenThinking,
982
1183
  planModeRequired: params.plan_mode_required,
1184
+ assignedChecks,
1185
+ repairPolicy,
1186
+ checkpointAssignment,
1187
+ completionGroup: spawnOptions.completionGroup,
983
1188
  metadata: operationMetadataFromParams(params),
984
1189
  delegationDepth: spawnOptions.nestedParent ? 1 : 0,
985
1190
  allowNestedReadAgents: !spawnOptions.nestedParent && spawnOptions.allowNestedReadAgents === true,
@@ -991,7 +1196,10 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
991
1196
  };
992
1197
 
993
1198
  if (role === "write") await writeQueue.removeQueuedWriteSpawnsByName(safeTeamName, safeName);
994
- if (!spawnOptions.nestedParent) removeQueuedReadSpawnsByName(safeTeamName, safeName);
1199
+ if (!spawnOptions.nestedParent) {
1200
+ const removed = removeQueuedReadSpawnsByName(safeTeamName, safeName);
1201
+ if (removed.some(item => item.groupSettlement)) await Promise.all(removed.map(item => item.groupSettlement));
1202
+ }
995
1203
  if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
996
1204
  const capacity = role === "write" ? settings.writeAgents : settings.readAgents;
997
1205
  const activeCount = activeAgentCount(safeTeamName, role, true);
@@ -999,7 +1207,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
999
1207
  if (!capacity.queueOverflow) {
1000
1208
  throw new Error(`${role === "write" ? "Edit" : "Read"}-agent capacity reached (${activeCount}/${capacity.maxConcurrent}) and queueOverflow is disabled.`);
1001
1209
  }
1002
- const queued = await enqueueReadSpawn(safeTeamName, member, params.prompt, params, resolved, ctx, nestedNameReservationId);
1210
+ const queued = await enqueueReadSpawn(safeTeamName, member, params.prompt, params, resolved, ctx, nestedNameReservationId, spawnOptions.signal);
1003
1211
  nestedNameReservationTransferred = !!nestedNameReservationId;
1004
1212
  const queuePosition = readQueue(safeTeamName).findIndex(item => item.id === queued.id) + 1;
1005
1213
  return {
@@ -1008,7 +1216,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1008
1216
  };
1009
1217
  }
1010
1218
 
1011
- const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx, nestedNameReservationId);
1219
+ const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx, nestedNameReservationId, true, undefined, undefined, spawnOptions.signal);
1012
1220
  if (role === "write") {
1013
1221
  await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.success", {
1014
1222
  agentName: safeName, cwd, requestedRole: role, model: chosenModel,
@@ -1033,7 +1241,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1033
1241
  }
1034
1242
  }
1035
1243
 
1036
- pi.events?.on?.("pi-extended-teams:orchestration-request", async (payload: any) => {
1244
+ async function handleOrchestrationRequest(payload: any): Promise<void> {
1037
1245
  const requestId = payload?.requestId;
1038
1246
  const type = String(payload?.type || "");
1039
1247
  const params = payload?.params || {};
@@ -1045,6 +1253,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1045
1253
  if (options.isTeammate) throw new Error("Teammates cannot satisfy orchestration requests directly.");
1046
1254
  if (!ctx) throw new Error("No active lead session context is available for orchestration request. If pi-extended-teams was registered after session_start, include the current Pi command context as payload.ctx.");
1047
1255
 
1256
+ if (type === "ensure_team" || type === "spawn_teammate_once") {
1257
+ await requireSettledPublicScope(paths.sanitizeName(params.team_name));
1258
+ }
1048
1259
  if (type === "ensure_team") {
1049
1260
  const safeTeamName = paths.sanitizeName(params.team_name);
1050
1261
  if (teams.teamExists(safeTeamName)) {
@@ -1078,7 +1289,17 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1078
1289
  const safeTeamName = paths.sanitizeName(params.team_name);
1079
1290
  if (!teams.teamExists(safeTeamName)) throw new Error(`Team ${params.team_name} does not exist`);
1080
1291
  options.adoptTeamAsLead(safeTeamName, ctx);
1081
- const result = await spawnTeammate(params, ctx, { once: true });
1292
+ const prepared = params.continue_from === undefined ? params : { ...params, name: freshContinuationName(params.name || "agent") };
1293
+ const result = await spawnTeammate(prepared, ctx, {
1294
+ once: true,
1295
+ allowNestedReadAgents: params.allow_nested_read_agents === true,
1296
+ });
1297
+ emitOrchestrationResponse(requestId, type, { ok: true, details: result.details, content: result.content });
1298
+ return;
1299
+ }
1300
+
1301
+ if (type === "spawn_agent") {
1302
+ const result = await spawnPublicAgent(params, ctx);
1082
1303
  emitOrchestrationResponse(requestId, type, { ok: true, details: result.details, content: result.content });
1083
1304
  return;
1084
1305
  }
@@ -1087,43 +1308,35 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1087
1308
  } catch (error) {
1088
1309
  emitOrchestrationResponse(requestId, type, { ok: false, error: error instanceof Error ? error.message : String(error) });
1089
1310
  }
1311
+ }
1312
+ pi.events?.on?.("pi-extended-teams:orchestration-request", (payload: any) => {
1313
+ if (payload?.type !== "ensure_team" && payload?.type !== "spawn_teammate_once") return handleOrchestrationRequest(payload);
1314
+ return trackAdmission(() => withPublicScope(String(payload?.params?.team_name || ""), () => handleOrchestrationRequest(payload)))
1315
+ .catch(error => emitOrchestrationResponse(payload?.requestId, payload?.type, { ok: false, error: error instanceof Error ? error.message : String(error) }));
1090
1316
  });
1091
1317
 
1092
- const levelDescription = "Required intent tier. Configured favorites take priority; an unconfigured tier inherits the current lead model and thinking. Read tiers: read-collect gathers bounded facts without owning the conclusion; read-review is the normal default for focused review, verification, and bounded synthesis; read-analyze explains behavior or root cause across connected evidence; read-critical is only for irreducible high-stakes security, architecture, concurrency, migration, or data-correctness reasoning. Write tiers: write-patch makes a narrow localized change; write-feature implements a bounded feature with a known design; write-system owns a cross-cutting integration or refactor within explicitly claimed files; write-critical is only for high-risk security, concurrency, recovery, migration, or data-integrity changes. Prefer canonical tiers; legacy reading-*/writing-* aliases remain accepted for this minor release. Do not pass role, model, or thinking directly; see README.md.";
1093
- const publicAgentBaseParams = {
1094
- name: Type.Optional(Type.String({ description: "Stable display name. Defaults to a generated agent name." })),
1095
- prompt: Type.String({ description: "The agent's assignment, relevant prior context, evidence already gathered, constraints, and report shape." }),
1096
- cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the lead session cwd." })),
1097
- session_context: Type.Optional(StringEnum(["none", "lazy"] as const, { description: "Optional filtered snapshot of the lead's active session branch. Use lazy only when omitted session history may materially affect the lane; the child reads it on demand rather than receiving transcript content in its prompt.", default: "none" })),
1098
- metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
1099
- allow_nested_read_agents: Type.Optional(Type.Boolean({ description: "Opt in eligible depth-0 write-feature/write-critical agents to restricted read-only child spawning.", default: false })),
1100
- };
1101
- const publicAgentParams = {
1102
- ...publicAgentBaseParams,
1103
- model_slot: StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription, default: "read-review" }),
1104
- };
1105
- const publicSwarmAgentParams = {
1106
- ...publicAgentBaseParams,
1107
- model_slot: Type.Optional(StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription })),
1108
- };
1109
-
1110
1318
  function generatedAgentName(index?: number): string {
1111
1319
  const position = index === undefined ? "" : `-${index + 1}`;
1112
1320
  return `agent-${Date.now().toString(36)}${position}-${crypto.randomUUID().slice(0, 8)}`;
1113
1321
  }
1114
1322
 
1115
- async function spawnPublicAgent(params: any, ctx: any): Promise<{ content: any[]; details: any }> {
1323
+ function spawnPublicAgent(params: any, ctx: any, signal?: AbortSignal): Promise<{ content: any[]; details: any }> {
1324
+ return trackAdmission(() => withPublicScope(currentSessionAgentGroupName(ctx), () => spawnPublicAgentInScope(params, ctx, signal)));
1325
+ }
1326
+
1327
+ async function spawnPublicAgentInScope(params: any, ctx: any, signal?: AbortSignal): Promise<{ content: any[]; details: any }> {
1116
1328
  if (options.isTeammate) throw new Error("Only the lead session can spawn agents.");
1117
1329
  if (!ctx) throw new Error("No active Pi session context is available for spawn_agent.");
1118
1330
 
1331
+ params = { ...params, checkpoint: createCheckpointAssignment(params.checkpoint, params.prompt, params.continue_from)?.policy };
1332
+ const name = params.continue_from === undefined ? params.name || generatedAgentName() : freshContinuationName(params.name || "agent");
1119
1333
  const sessionDefaultModel = configuredFavoriteModelForSpawn(params, ctx);
1120
1334
  const sessionName = await ensureCurrentSessionAgentGroup(ctx, sessionDefaultModel);
1121
- const name = params.name || generatedAgentName();
1122
1335
  const result = await spawnTeammate({
1123
1336
  ...params,
1124
1337
  name,
1125
1338
  team_name: sessionName,
1126
- }, ctx, { allowNestedReadAgents: params.allow_nested_read_agents === true });
1339
+ }, ctx, { allowNestedReadAgents: params.allow_nested_read_agents === true, signal });
1127
1340
 
1128
1341
  const outcome = result.details.queued
1129
1342
  ? `Agent ${name} queued at position ${result.details.queuePosition}.`
@@ -1145,13 +1358,18 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1145
1358
  model_slot: Type.Optional(StringEnum(NESTED_READ_MODEL_SLOTS, { description: "Canonical read-* tier, or inherit defaults.model_slot." })),
1146
1359
  };
1147
1360
 
1148
- async function spawnNestedReadAgent(
1361
+ function spawnNestedReadAgent(params: any, binding: NestedReadAgentToolBinding, context = "spawn_agent") {
1362
+ return trackAdmission(() => spawnNestedReadAgentInScope(params, binding, context));
1363
+ }
1364
+
1365
+ async function spawnNestedReadAgentInScope(
1149
1366
  params: any,
1150
1367
  binding: NestedReadAgentToolBinding,
1151
1368
  context = "spawn_agent"
1152
1369
  ): Promise<{ content: any[]; details: any }> {
1153
1370
  rejectUnexpectedNestedParams(params, ["name", "prompt", "model_slot"], context);
1154
1371
  requireNestedReadSpawnLevel(params, context);
1372
+ if (options.isHostAttached?.() === false) await options.waitForHost?.();
1155
1373
  await assertNestedParentAuthorized(binding.teamName, binding.parent.name, binding.parentRunId, binding.parent.cwd);
1156
1374
  const name = params.name || generatedAgentName();
1157
1375
  const result = await spawnTeammate({
@@ -1160,7 +1378,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1160
1378
  model_slot: params.model_slot,
1161
1379
  team_name: binding.teamName,
1162
1380
  cwd: binding.parent.cwd,
1163
- }, binding.outerCtx, {
1381
+ }, options.isHostAttached ? options.getSessionCtx?.() ?? binding.outerCtx : binding.outerCtx, {
1164
1382
  nestedParent: {
1165
1383
  teamName: binding.teamName,
1166
1384
  name: binding.parent.name,
@@ -1248,72 +1466,112 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1248
1466
  pi.registerTool(statusTool());
1249
1467
 
1250
1468
  pi.registerTool({
1251
- name: "spawn_agent",
1252
- label: "Spawn Agent",
1253
- description: "Spawn one agent by intent tier only. Configured favorites take priority; an unconfigured tier inherits the current lead model and thinking. Give it the relevant goal, decisions, prior attempts, inspected evidence, constraints, and expected delta rather than a context-free task. Use session_context=lazy only as an on-demand fallback when omitted session history may materially matter; it never replaces a good mission prompt. read-review is the normal read default; use read-collect for bounded fact gathering, read-analyze for connected explanation/root cause, and read-critical only for irreducible high-stakes reasoning. For edits, choose write-patch, write-feature, write-system, or the rare high-risk write-critical by scope and risk. After spawning, do not duplicate or take over its lane; work only on unrelated work, then end the turn so the automatic report can resume you. One get_agent_status snapshot is allowed when current status is needed; never sleep, busy-wait, repeatedly read inbox/status, or treat healthy silence as failure. Wait for the actual report before synthesizing; intervene only on a reported blocker/error, actual health failure, or explicit user cancellation. model_slot selects behavior, model, and thinking; do not pass role, model, or thinking directly.",
1254
- parameters: Type.Object(publicAgentParams),
1469
+ ...publicTeamToolMetadata.spawn_agent,
1255
1470
  async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
1256
- return spawnPublicAgent(params, ctx);
1471
+ return spawnPublicAgent(params, ctx, _signal);
1257
1472
  },
1258
1473
  });
1259
1474
 
1260
1475
  pi.registerTool({
1261
- name: "spawn_swarm_agents",
1262
- label: "Spawn Swarm Agents",
1263
- description: "Spawn a batch by intent tier only. Configured favorites take priority; unconfigured tiers inherit the current lead model and thinking. Give each lane the relevant goal, decisions, prior attempts, inspected evidence, constraints, and expected delta. Use session_context=lazy selectively as an on-demand fallback, never instead of a good mission prompt. Use read-review as the normal default, read-collect for bounded collection lanes, read-analyze for connected explanation, and read-critical only for irreducible high-stakes reasoning; choose write-patch/feature/system/critical by edit scope and risk. Each spawned lane is delegation-locked: do not duplicate or take it over. After unrelated work is done, end the turn so automatic reports can resume you. One get_agent_status snapshot is allowed when current status is needed; never sleep, busy-wait, repeatedly read inbox/status, or intervene early. Synthesize only after actual reports; intervene only on blocker/error, actual failure, or explicit cancellation. Each agent gets model_slot directly or from defaults; do not pass role, model, or thinking directly.",
1264
- parameters: Type.Object({
1265
- defaults: Type.Optional(Type.Object({
1266
- cwd: Type.Optional(Type.String()),
1267
- model_slot: Type.Optional(StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription })),
1268
- metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
1269
- session_context: Type.Optional(StringEnum(["none", "lazy"] as const, { description: "Shared lazy session-reference policy.", default: "none" })),
1270
- allow_nested_read_agents: Type.Optional(Type.Boolean({ description: "Shared opt-in for eligible depth-0 write-feature/write-critical agents.", default: false })),
1271
- })),
1272
- agents: Type.Array(Type.Object(publicSwarmAgentParams), { description: "Agents to spawn as one batch. Each one must have model_slot directly or inherit it from defaults." }),
1273
- }),
1476
+ ...publicTeamToolMetadata.spawn_swarm_agents,
1274
1477
  async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
1275
- if (options.isTeammate) throw new Error("Only the lead session can spawn agents.");
1276
- if (!ctx) throw new Error("No active Pi session context is available for spawn_swarm_agents.");
1277
- if (!Array.isArray(params.agents) || params.agents.length === 0) throw new Error("spawn_swarm_agents requires at least one agent.");
1278
-
1279
- const mergedAgents = params.agents.map((agent: any) => mergeSwarmAgentParams(params.defaults || {}, agent));
1280
- const defaultModel = configuredFavoriteModelForSpawn(mergedAgents[0], ctx, "spawn_swarm_agents");
1281
- for (let index = 0; index < mergedAgents.length; index += 1) {
1282
- configuredFavoriteModelForSpawn(mergedAgents[index], ctx, `spawn_swarm_agents agent ${mergedAgents[index].name || index + 1}`);
1283
- }
1284
- const sessionName = await ensureCurrentSessionAgentGroup(ctx, defaultModel);
1285
- const spawned: any[] = [];
1286
- const failed: Array<{ name: string; error: string }> = [];
1478
+ return trackAdmission(async () => {
1479
+ if (options.isTeammate) throw new Error("Only the lead session can spawn agents.");
1480
+ if (!ctx) throw new Error("No active Pi session context is available for spawn_swarm_agents.");
1481
+ if (!Array.isArray(params.agents) || params.agents.length === 0) throw new Error("spawn_swarm_agents requires at least one agent.");
1482
+
1483
+ const groupPolicy = normalizeCompletionGroupPolicy(params.completion_group);
1484
+ const sessionId = getPiSessionId(ctx);
1485
+ if (groupPolicy && (!sessionId || !_toolCallId?.trim())) throw new Error("Completion groups require a bound session and runtime submission identity.");
1486
+ const mergedAgents = params.agents.map((agent: any, index: number) => {
1487
+ const merged = mergeSwarmAgentParams(params.defaults || {}, agent);
1488
+ merged.checkpoint = createCheckpointAssignment(merged.checkpoint, merged.prompt, merged.continue_from)?.policy;
1489
+ if (merged.continue_from !== undefined) merged.name = freshContinuationName(merged.name || "agent", JSON.stringify([sessionId, _toolCallId, index, merged.continue_from]));
1490
+ return merged;
1491
+ });
1492
+ const defaultModel = configuredFavoriteModelForSpawn(mergedAgents[0], ctx, "spawn_swarm_agents");
1493
+ for (let index = 0; index < mergedAgents.length; index += 1) {
1494
+ configuredFavoriteModelForSpawn(mergedAgents[index], ctx, `spawn_swarm_agents agent ${mergedAgents[index].name || index + 1}`);
1495
+ }
1496
+ const sessionName = await ensureCurrentSessionAgentGroup(ctx, defaultModel);
1497
+ let group: CompletionGroup | undefined;
1498
+ if (groupPolicy && sessionId) {
1499
+ const identity = { teamName: sessionName, sessionId, submissionId: `tool:${_toolCallId}` };
1500
+ const groupId = completionGroupIdentity(identity);
1501
+ options.onCompletionGroupUse?.(groupId);
1502
+ const config = await teams.readConfig(sessionName);
1503
+ const reports = await listStoredTeamReportEvents(sessionName);
1504
+ const inbox = await messaging.readInbox(sessionName, "team-lead", false, false);
1505
+ const entries = ctx.sessionManager?.getBranch?.() ?? [];
1506
+ const references = [...config.members.map(member => member.completionGroup),
1507
+ ...readQueue(sessionName).map(queued => queued.member.completionGroup),
1508
+ ...reports.map(report => report.completionGroup), ...inbox.map(message => message.metadata?.completionGroup),
1509
+ ...entries.map((entry: any) => entry.message?.details?.completionGroup)];
1510
+ if (references.some(binding => binding?.groupId === groupId)) new CompletionGroup(sessionName, groupId).read();
1511
+ group = await CompletionGroup.create({ ...identity, policy: groupPolicy, members: mergedAgents.map((agent: any, index: number) => ({
1512
+ name: paths.sanitizeName(agent.name || `agent-${crypto.createHash("sha256").update(`${_toolCallId}:${index}`).digest("hex").slice(0, 12)}`),
1513
+ assignmentKey: crypto.createHash("sha256").update(JSON.stringify([agent, ctx.cwd])).digest("hex"),
1514
+ suppressed: sessionName.startsWith("prompt-build-") || shouldSuppressLeadReportInjection(agent),
1515
+ })) });
1516
+ if (group && !group.created) return {
1517
+ content: [{ type: "text", text: `Completion group ${group.groupId} already exists; no assignments replayed. State: ${group.journalPath}\n${AGENT_WAIT_CONTRACT}` }],
1518
+ details: { session: sessionName, completionGroup: { groupId: group.groupId, journalPath: group.journalPath },
1519
+ idempotent: true, spawned: [], failed: [], members: group.read().members },
1520
+ };
1521
+ }
1522
+ const spawned: any[] = [];
1523
+ const failed: Array<{ name: string; error: string }> = [];
1287
1524
 
1288
- for (let index = 0; index < mergedAgents.length; index += 1) {
1289
- const merged = mergedAgents[index];
1290
- const name = merged.name || generatedAgentName(index);
1291
- try {
1292
- const result = await spawnTeammate({
1293
- ...merged,
1294
- name,
1295
- team_name: sessionName,
1296
- }, ctx, { allowNestedReadAgents: merged.allow_nested_read_agents === true });
1297
- spawned.push({ ...result.details, name });
1298
- } catch (error) {
1299
- failed.push({ name, error: error instanceof Error ? error.message : String(error) });
1525
+ for (let index = 0; index < mergedAgents.length; index += 1) {
1526
+ const merged = mergedAgents[index];
1527
+ const name = group?.read().members[index].name || merged.name || generatedAgentName(index);
1528
+ try {
1529
+ const result = await spawnTeammate({
1530
+ ...merged,
1531
+ name,
1532
+ team_name: sessionName,
1533
+ }, ctx, { allowNestedReadAgents: merged.allow_nested_read_agents === true, completionGroup: group?.binding(index), signal: _signal });
1534
+ spawned.push({ ...result.details, name });
1535
+ } catch (error) {
1536
+ const message = error instanceof Error ? error.message : String(error);
1537
+ failed.push({ name, error: message });
1538
+ if (group) await group.apply({ type: "rejected", ...group.binding(index), runId: group.read().members[index].runId, reason: message });
1539
+ }
1540
+ }
1541
+ if (group) {
1542
+ await group.seal();
1543
+ await enqueueCompletionGroupDeliveries(group);
1300
1544
  }
1301
- }
1302
1545
 
1303
- const lines = [`Spawned ${spawned.length}/${params.agents.length} agents in the current Pi session.`];
1304
- for (const item of spawned) lines.push(`- ${item.name}: ${item.queued ? `queued at position ${item.queuePosition}` : `${item.role}, ${item.mode || "in-process"}`}`);
1305
- for (const item of failed) lines.push(`- ${item.name}: failed — ${item.error}`);
1306
- lines.push(AGENT_WAIT_CONTRACT);
1307
- return { content: [{ type: "text", text: lines.join("\n") }], details: { session: sessionName, spawned, failed } };
1546
+ const lines = [`Spawned ${spawned.length}/${params.agents.length} agents in the current Pi session.`];
1547
+ for (const item of spawned) lines.push(`- ${item.name}: ${item.queued ? `queued at position ${item.queuePosition}` : `${item.role}, ${item.mode || "in-process"}`}`);
1548
+ for (const item of failed) lines.push(`- ${item.name}: failed — ${item.error}`);
1549
+ lines.push(AGENT_WAIT_CONTRACT);
1550
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { session: sessionName, spawned, failed,
1551
+ ...(group ? { completionGroup: { groupId: group.groupId, journalPath: group.journalPath } } : {}) } };
1552
+ });
1308
1553
  },
1309
1554
  });
1310
1555
 
1311
1556
  return {
1312
1557
  createNestedReadAgentTools,
1558
+ nestedChildSnapshot,
1559
+ closeAdmission,
1560
+ hasPendingWork: () => (
1561
+ admissions.size > 0
1562
+ || queuedReadSpawnsByTeam.size > 0
1563
+ || checkpointAdmissions.size > 0
1564
+ || readQueueDrainingTeams.size > 0
1565
+ ),
1566
+ resumeQueues: () => { for (const teamName of queuedReadSpawnsByTeam.keys()) void drainQueuedReadSpawns(teamName); },
1313
1567
  cancelQueuedAgent: (teamName, agentName) => {
1314
1568
  // Retain the entry for admission bookkeeping, but let active teardown own cancellation after launch commits.
1315
1569
  if (readQueue(teamName).some(queued => queued.member.name === agentName && queued.launchCommitted)) return false;
1316
- return removeQueuedReadSpawnsByName(teamName, agentName).length > 0;
1570
+ const pending = cancelCheckpointAdmission(teamName, agentName);
1571
+ const removed = removeQueuedReadSpawnsByName(teamName, agentName);
1572
+ return pending || removed.some(item => item.groupSettlement)
1573
+ ? Promise.all([pending, ...removed.map(item => item.groupSettlement)]).then(() => !!pending || removed.length > 0)
1574
+ : removed.length > 0;
1317
1575
  },
1318
1576
  };
1319
1577
  }