pi-extended-teams 2.2.6 → 2.2.8

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.
@@ -73,6 +73,7 @@ export interface NestedReadAgentToolBinding {
73
73
 
74
74
  export interface TeamToolsRuntime {
75
75
  createNestedReadAgentTools(binding: NestedReadAgentToolBinding): any[];
76
+ cancelQueuedAgent(teamName: string, agentName: string): boolean;
76
77
  }
77
78
 
78
79
  interface SpawnTeammateOptions {
@@ -97,11 +98,14 @@ interface QueuedReadSpawn {
97
98
  requestedAt: number;
98
99
  nameReservationId?: string;
99
100
  pendingChildAcceptance?: PendingChildAcceptance;
101
+ admissionError?: string;
102
+ quarantineError?: string;
103
+ launchCommitted?: boolean;
100
104
  }
101
105
 
102
106
  export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamToolsRuntime {
103
107
  if (options.isTeammate) {
104
- return { createNestedReadAgentTools: () => [] };
108
+ return { createNestedReadAgentTools: () => [], cancelQueuedAgent: () => false };
105
109
  }
106
110
 
107
111
  function emitOrchestrationResponse(requestId: string | undefined, type: string, payload: Record<string, any>): void {
@@ -234,39 +238,38 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
234
238
 
235
239
  const pendingChildController = options.pendingChildController ?? createPendingChildController();
236
240
  const queuedReadSpawnsByTeam = new Map<string, QueuedReadSpawn[]>();
241
+ const failedAdmissionsByTeam = new Map<string, QueuedReadSpawn[]>();
237
242
  const readQueueDrainingTeams = new Set<string>();
238
- const readAdmissionReservationsByTeam = new Map<string, Map<string, number>>();
243
+ const pendingQueueDrains = new Set<string>();
244
+ const readAdmissionReservationsByTeam = new Map<string, Map<string, { count: number; role: string }>>();
239
245
  const nestedReadNameReservationsByTeam = new Map<string, Map<string, string>>();
240
246
 
241
- function activeAgentCount(teamName: string, role?: string): number {
242
- let count = 0;
243
- for (const agent of options.runningReadAgents.values()) {
244
- if (agent.teamName !== teamName) continue;
245
- if (role && (agent.role || "read") !== role) continue;
246
- count += 1;
247
+ function activeAgentCount(teamName: string, role?: string, includeReservations = false): number {
248
+ const activeKeys = new Set<string>();
249
+ if (includeReservations) {
250
+ for (const [key, reservation] of readAdmissionReservationsByTeam.get(teamName) ?? []) {
251
+ if (!role || reservation.role === role) activeKeys.add(key);
252
+ }
247
253
  }
248
- return count;
249
- }
250
-
251
- function activeReadCount(teamName: string): number {
252
- const activeKeys = new Set(readAdmissionReservationsByTeam.get(teamName)?.keys() ?? []);
253
254
  for (const [key, agent] of options.runningReadAgents) {
254
- if (agent.teamName === teamName && (agent.role || "read") === "read") activeKeys.add(key);
255
+ if (agent.teamName === teamName && (!role || (agent.role || "read") === role)) activeKeys.add(key);
255
256
  }
256
257
  return activeKeys.size;
257
258
  }
258
259
 
259
- function reserveReadAdmission(teamName: string, key: string): void {
260
- const reservations = readAdmissionReservationsByTeam.get(teamName) ?? new Map<string, number>();
261
- reservations.set(key, (reservations.get(key) ?? 0) + 1);
260
+ function reserveReadAdmission(teamName: string, key: string, role: string): void {
261
+ const reservations = readAdmissionReservationsByTeam.get(teamName) ?? new Map<string, { count: number; role: string }>();
262
+ const pending = reservations.get(key);
263
+ if (pending && pending.role !== role) throw new Error(`Agent ${key} already has a ${pending.role} admission in progress.`);
264
+ reservations.set(key, { count: (pending?.count ?? 0) + 1, role });
262
265
  readAdmissionReservationsByTeam.set(teamName, reservations);
263
266
  }
264
267
 
265
268
  function releaseReadAdmission(teamName: string, key: string): void {
266
269
  const reservations = readAdmissionReservationsByTeam.get(teamName);
267
270
  if (!reservations) return;
268
- const count = reservations.get(key) ?? 0;
269
- if (count > 1) reservations.set(key, count - 1);
271
+ const reservation = reservations.get(key);
272
+ if (reservation && reservation.count > 1) reservation.count -= 1;
270
273
  else reservations.delete(key);
271
274
  if (reservations.size === 0) readAdmissionReservationsByTeam.delete(teamName);
272
275
  }
@@ -295,13 +298,15 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
295
298
  }
296
299
 
297
300
  async function listQueuedAgentStatuses(teamName: string): Promise<QueuedAgentStatus[]> {
298
- const readers = readQueue(teamName).map((queued, index) => ({
301
+ const readers = [...readQueue(teamName), ...(failedAdmissionsByTeam.get(teamName) ?? [])].map((queued, index) => ({
299
302
  name: queued.member.name,
300
303
  role: queued.member.role || "read",
301
304
  queuedAt: queued.requestedAt,
302
305
  queuePosition: index + 1,
303
306
  parentAgentName: queued.member.parentAgentName,
304
307
  parentLifecycleRunId: queued.member.parentLifecycleRunId,
308
+ error: queued.admissionError || queued.quarantineError,
309
+ failed: !!queued.admissionError,
305
310
  }));
306
311
  const writers = (await writeQueue.listWriteQueue(teamName)).map((queued, index) => ({
307
312
  name: queued.name,
@@ -342,6 +347,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
342
347
  pi.on?.("session_shutdown", () => {
343
348
  if (lifecycleProbeCleanedUp) return;
344
349
  lifecycleProbeCleanedUp = true;
350
+ for (const [teamName, queue] of queuedReadSpawnsByTeam) {
351
+ for (const queued of queue) removeQueuedReadSpawnById(teamName, queued.id);
352
+ }
345
353
  if (typeof lifecycleProbeUnsubscribe === "function") lifecycleProbeUnsubscribe();
346
354
  pendingChildCancelUnsubscribe();
347
355
  lifecycleFenceUnsubscribe();
@@ -495,7 +503,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
495
503
  else pendingChildController.settleAcceptance(identity);
496
504
  }
497
505
 
498
- async function rollbackNestedChildAdmission(
506
+ async function rollbackAgentAdmission(
499
507
  teamName: string,
500
508
  member: Member,
501
509
  cause: unknown,
@@ -508,11 +516,11 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
508
516
  }
509
517
  try {
510
518
  const removed = await teams.removeMemberMatchingRun(teamName, member.name, runId);
511
- if (!removed) throw new Error(`matching child run ${runId} was not present`);
519
+ if (!removed) throw new Error(`matching agent run ${runId} was not present`);
512
520
  } catch (rollbackError) {
513
521
  const causeMessage = cause instanceof Error ? cause.message : String(cause);
514
522
  const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
515
- throw new Error(`${causeMessage} Exact-run rollback for nested read agent ${member.name} failed: ${rollbackMessage}`);
523
+ throw new Error(`${causeMessage} Exact-run rollback for agent ${member.name} failed: ${rollbackMessage}`);
516
524
  } finally {
517
525
  settlePendingChild(pendingChild);
518
526
  }
@@ -530,7 +538,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
530
538
  member: Member,
531
539
  prompt: string,
532
540
  ctx: any,
533
- queuedAcceptance?: PendingChildAcceptance
541
+ queuedAcceptance?: PendingChildAcceptance,
542
+ assertPending?: () => void,
543
+ commitLaunch?: () => void,
534
544
  ): Promise<AdmittedReadAgentLaunch> {
535
545
  const addValidateAndLaunch = async (parentLifecycleLock?: LifecycleTombstoneLock): Promise<AdmittedReadAgentLaunch> => {
536
546
  let pendingAcceptance = queuedAcceptance;
@@ -542,17 +552,25 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
542
552
  }
543
553
 
544
554
  try {
555
+ if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
556
+ assertPending?.();
545
557
  await teams.addMember(teamName, member);
546
558
  } catch (error) {
547
559
  settlePendingChild(pendingAcceptance);
548
560
  throw error;
549
561
  }
550
562
 
563
+ try {
564
+ if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
565
+ assertPending?.();
566
+ } catch (error) {
567
+ return rollbackAgentAdmission(teamName, member, error, pendingAcceptance);
568
+ }
551
569
  let pendingChildRun: PendingChildRun | undefined;
552
570
  if (member.delegationDepth === 1) {
553
571
  const runId = member.lifecycleRunId;
554
572
  if (!pendingAcceptance || !runId) {
555
- return rollbackNestedChildAdmission(
573
+ return rollbackAgentAdmission(
556
574
  teamName,
557
575
  member,
558
576
  new Error(`Nested read agent ${member.name} did not receive an exact lifecycle run identity.`),
@@ -561,7 +579,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
561
579
  }
562
580
  pendingChildRun = pendingChildController.bindAcceptedChild(pendingAcceptance, member.name, runId);
563
581
  if (!pendingChildRun) {
564
- return rollbackNestedChildAdmission(
582
+ return rollbackAgentAdmission(
565
583
  teamName,
566
584
  member,
567
585
  new Error(`Nested read agent ${member.name} lost its parent acceptance before launch.`),
@@ -572,7 +590,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
572
590
  try {
573
591
  await assertNestedChildAdmission(teamName, member, parentLifecycleLock);
574
592
  } catch (error) {
575
- return rollbackNestedChildAdmission(teamName, member, error, pendingChildRun);
593
+ return rollbackAgentAdmission(teamName, member, error, pendingChildRun);
576
594
  }
577
595
  }
578
596
 
@@ -602,6 +620,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
602
620
  };
603
621
 
604
622
  try {
623
+ if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
624
+ assertPending?.();
625
+ commitLaunch?.();
605
626
  const launch = options.runReadAgentInProcess(teamName, member, launchPrompt, ctx, options.readAgentOptions());
606
627
  return {
607
628
  launch: sessionContextReference
@@ -612,10 +633,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
612
633
  };
613
634
  } catch (error) {
614
635
  removeReference();
615
- if (member.delegationDepth === 1) {
616
- return rollbackNestedChildAdmission(teamName, member, error, pendingChildRun);
617
- }
618
- throw error;
636
+ return rollbackAgentAdmission(teamName, member, error, pendingChildRun);
619
637
  }
620
638
  };
621
639
 
@@ -634,23 +652,24 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
634
652
  ctx: any,
635
653
  nameReservationId?: string,
636
654
  releaseNameOnFailure = true,
637
- queuedAcceptance?: PendingChildAcceptance
655
+ queuedAcceptance?: PendingChildAcceptance,
656
+ assertPending?: () => void,
657
+ commitLaunch?: () => void,
638
658
  ): Promise<boolean> {
639
659
  const key = options.readAgentKey(teamName, member.name);
640
- const reservesReadCapacity = (member.role || "read") === "read";
641
- if (reservesReadCapacity) reserveReadAdmission(teamName, key);
660
+ reserveReadAdmission(teamName, key, member.role || "read");
642
661
 
643
662
  const releaseNameReservation = () => {
644
663
  releaseNestedReadName(teamName, member.name, nameReservationId);
645
664
  };
646
665
  const releaseReservationAndDrain = () => {
647
666
  if (releaseNameOnFailure) releaseNameReservation();
648
- if (reservesReadCapacity) releaseReadAdmission(teamName, key);
667
+ releaseReadAdmission(teamName, key);
649
668
  void drainQueuedReadSpawns(teamName);
650
669
  };
651
670
  const finishRun = (pendingChildRun: PendingChildRun | undefined) => {
652
671
  settlePendingChild(pendingChildRun);
653
- if (reservesReadCapacity) releaseReadAdmission(teamName, key);
672
+ releaseReadAdmission(teamName, key);
654
673
  void drainQueuedReadSpawns(teamName);
655
674
  };
656
675
  const drainAfterRun = (pendingChildRun: PendingChildRun | undefined) => {
@@ -669,7 +688,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
669
688
  };
670
689
 
671
690
  try {
672
- const admitted = await admitAndLaunchReadAgentMember(teamName, member, prompt, ctx, queuedAcceptance);
691
+ const admitted = await admitAndLaunchReadAgentMember(teamName, member, prompt, ctx, queuedAcceptance, assertPending, commitLaunch);
673
692
  releaseNameReservation();
674
693
  void Promise.resolve(admitted.launch).then(
675
694
  () => drainAfterRun(admitted.pendingChildRun),
@@ -710,6 +729,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
710
729
  pendingChildAcceptance,
711
730
  };
712
731
  setReadQueue(teamName, [...queue, queued]);
732
+ void drainQueuedReadSpawns(teamName);
713
733
  return queued;
714
734
  };
715
735
 
@@ -733,55 +753,62 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
733
753
  }
734
754
 
735
755
  async function drainQueuedReadSpawns(teamName: string): Promise<void> {
736
- if (readQueueDrainingTeams.has(teamName)) return;
756
+ if (lifecycleProbeCleanedUp) return;
757
+ if (readQueueDrainingTeams.has(teamName)) {
758
+ pendingQueueDrains.add(teamName);
759
+ return;
760
+ }
737
761
  readQueueDrainingTeams.add(teamName);
738
762
  try {
739
- while (true) {
740
- const next = readQueue(teamName)[0];
741
- if (!next) return;
742
- const settings = loadSettings({ projectDir: next.member.cwd });
743
- if (activeReadCount(teamName) >= settings.readAgents.maxConcurrent) return;
744
-
745
- const queued = readQueue(teamName)[0];
746
- if (!queued) return;
747
- const fence = await readLifecycleTombstone(teamName, queued.member.name);
748
- if (fence.status !== "absent") return;
749
- try {
750
- const config = await teams.readConfig(teamName);
751
- if (config.members.some((member) => member.name === queued.member.name)) {
763
+ let progressed = true;
764
+ while (progressed && !lifecycleProbeCleanedUp) {
765
+ progressed = false;
766
+ for (const queued of readQueue(teamName)) {
767
+ if (queued.admissionError) continue;
768
+ const role = queued.member.role || "read";
769
+ const settings = loadSettings({ projectDir: queued.member.cwd });
770
+ const capacity = role === "write" ? settings.writeAgents : settings.readAgents;
771
+ if (activeAgentCount(teamName, role, true) >= capacity.maxConcurrent) continue;
772
+ const assertPending = () => {
773
+ if (!readQueue(teamName).some(item => item.id === queued.id)) throw new Error(`Queued agent ${queued.member.name} was cancelled.`);
774
+ };
775
+ try {
776
+ const fence = await readLifecycleTombstone(teamName, queued.member.name);
777
+ if (fence.status !== "absent") {
778
+ queued.quarantineError = fence.status === "corrupt" ? fence.error : `Lifecycle run ${fence.tombstone.runId} is quarantined.`;
779
+ continue;
780
+ }
781
+ queued.quarantineError = undefined;
782
+ const config = await teams.readConfig(teamName);
783
+ assertPending();
784
+ if (config.members.some(member => member.name === queued.member.name)) throw new Error(`A teammate named ${queued.member.name} already exists.`);
785
+ if (activeAgentCount(teamName, role, true) >= capacity.maxConcurrent) continue;
786
+ queued.member.joinedAt = Date.now();
787
+ await startReadAgentMember(teamName, queued.member, queued.prompt, queued.ctx,
788
+ queued.nameReservationId, false, queued.pendingChildAcceptance, assertPending,
789
+ () => { queued.launchCommitted = true; });
790
+ removeQueuedReadSpawnById(teamName, queued.id, false);
791
+ progressed = true;
792
+ } catch (error) {
793
+ if (!readQueue(teamName).some(item => item.id === queued.id)) continue;
794
+ queued.launchCommitted = false;
795
+ const latestFence = await readLifecycleTombstone(teamName, queued.member.name).catch(() => null);
796
+ if (latestFence && latestFence.status !== "absent") {
797
+ queued.quarantineError = "Lifecycle quarantine appeared before admission.";
798
+ continue;
799
+ }
800
+ queued.admissionError = error instanceof Error ? error.message : String(error);
801
+ failedAdmissionsByTeam.set(teamName, [...(failedAdmissionsByTeam.get(teamName) ?? []), queued].slice(-20));
752
802
  removeQueuedReadSpawnById(teamName, queued.id);
753
- continue;
754
- }
755
- queued.member.joinedAt = Date.now();
756
- await startReadAgentMember(
757
- teamName,
758
- queued.member,
759
- queued.prompt,
760
- queued.ctx,
761
- queued.nameReservationId,
762
- false,
763
- queued.pendingChildAcceptance
764
- );
765
- removeQueuedReadSpawnById(teamName, queued.id, false);
766
- } catch (error) {
767
- const latestFence = await readLifecycleTombstone(teamName, queued.member.name);
768
- if (latestFence.status !== "absent") {
769
- await messaging.sendPlainMessage(
770
- teamName,
771
- "system",
772
- "team-lead",
773
- `Retained queued agent ${queued.member.name}: lifecycle quarantine appeared before admission.`,
774
- `Queued agent ${queued.member.name} retained by quarantine`,
775
- "yellow"
776
- ).catch(() => {});
777
- return;
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(() => {});
778
806
  }
779
- // Invalid non-lifecycle requests may be dropped so later work can run.
780
- removeQueuedReadSpawnById(teamName, queued.id);
781
807
  }
782
808
  }
783
809
  } finally {
784
810
  readQueueDrainingTeams.delete(teamName);
811
+ if (pendingQueueDrains.delete(teamName)) void drainQueuedReadSpawns(teamName);
785
812
  }
786
813
  }
787
814
 
@@ -800,7 +827,11 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
800
827
  const { availableModels } = await getModelSelectionState(ctx, ctx.cwd, [explicitDefaultModel]);
801
828
  const defaultModel = requireQualifiedKnownModel(explicitDefaultModel, availableModels, "model_slot");
802
829
  if (!defaultModel) throw new Error("Agent sessions require a configured model_slot level before spawning.");
803
- teams.createTeam(sessionName, getPiSessionId(ctx) || "local-session", "lead-agent", "Pi session agents", defaultModel);
830
+ try {
831
+ teams.createTeamIfAbsent(sessionName, getPiSessionId(ctx) || "local-session", "lead-agent", "Pi session agents", defaultModel);
832
+ } catch (error) {
833
+ if (!(error instanceof teams.TeamAlreadyExistsError)) throw error;
834
+ }
804
835
  options.adoptTeamAsLead(sessionName, ctx);
805
836
  return sessionName;
806
837
  }
@@ -810,6 +841,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
810
841
  const safeTeamName = paths.sanitizeName(params.team_name);
811
842
  const cwd = params.cwd || ctx.cwd;
812
843
  const teamConfig = await teams.readConfig(safeTeamName);
844
+ failedAdmissionsByTeam.set(safeTeamName, (failedAdmissionsByTeam.get(safeTeamName) ?? []).filter(item => item.member.name !== safeName));
813
845
  let nestedNameReservationId: string | undefined;
814
846
  let nestedNameReservationTransferred = false;
815
847
 
@@ -958,90 +990,40 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
958
990
  helperKind: spawnOptions.nestedParent ? "read_helper" : undefined,
959
991
  };
960
992
 
961
- if (role === "read") {
962
- if (!spawnOptions.nestedParent) removeQueuedReadSpawnsByName(safeTeamName, safeName);
963
- const currentReadCount = activeReadCount(safeTeamName);
964
- if (currentReadCount >= settings.readAgents.maxConcurrent) {
965
- if (!settings.readAgents.queueOverflow) {
966
- throw new Error(`Read-agent capacity reached (${currentReadCount}/${settings.readAgents.maxConcurrent}) and queueOverflow is disabled.`);
967
- }
968
- const queued = await enqueueReadSpawn(
969
- safeTeamName,
970
- member,
971
- params.prompt,
972
- params,
973
- resolved,
974
- ctx,
975
- nestedNameReservationId
976
- );
977
- nestedNameReservationTransferred = !!nestedNameReservationId;
978
- const queuePosition = readQueue(safeTeamName).findIndex((item) => item.id === queued.id) + 1;
979
- return {
980
- content: [{ type: "text", text: `Read teammate ${params.name} queued at position ${queuePosition}; capacity is ${currentReadCount}/${settings.readAgents.maxConcurrent}.` }],
981
- details: queuedReadResolutionDetails(queued, params, { queuePosition }),
982
- };
993
+ if (role === "write") await writeQueue.removeQueuedWriteSpawnsByName(safeTeamName, safeName);
994
+ if (!spawnOptions.nestedParent) removeQueuedReadSpawnsByName(safeTeamName, safeName);
995
+ if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
996
+ const capacity = role === "write" ? settings.writeAgents : settings.readAgents;
997
+ const activeCount = activeAgentCount(safeTeamName, role, true);
998
+ if (activeCount >= capacity.maxConcurrent) {
999
+ if (!capacity.queueOverflow) {
1000
+ throw new Error(`${role === "write" ? "Edit" : "Read"}-agent capacity reached (${activeCount}/${capacity.maxConcurrent}) and queueOverflow is disabled.`);
983
1001
  }
984
-
985
- const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx, nestedNameReservationId);
1002
+ const queued = await enqueueReadSpawn(safeTeamName, member, params.prompt, params, resolved, ctx, nestedNameReservationId);
1003
+ nestedNameReservationTransferred = !!nestedNameReservationId;
1004
+ const queuePosition = readQueue(safeTeamName).findIndex(item => item.id === queued.id) + 1;
986
1005
  return {
987
- content: [{ type: "text", text: `Read teammate ${params.name} started in-process.` }],
988
- details: spawnResolutionDetails(member, params, resolved, {
989
- mode: "in-process",
990
- terminalId: null,
991
- queued: false,
992
- sessionContextAvailable,
993
- }),
1006
+ content: [{ type: "text", text: `Agent ${params.name} queued at position ${queuePosition}; capacity is ${activeCount}/${capacity.maxConcurrent}.` }],
1007
+ details: queuedReadResolutionDetails(queued, params, { queuePosition }),
994
1008
  };
995
1009
  }
996
1010
 
997
- await writeQueue.removeQueuedWriteSpawnsByName(safeTeamName, safeName);
998
- const activeWriteCount = activeAgentCount(safeTeamName, "write");
999
- await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.request", {
1000
- agentName: safeName,
1001
- cwd,
1002
- category: params.category ?? null,
1003
- requestedRole: role,
1004
- resolvedRole: role,
1005
- model: chosenModel,
1006
- modelSource: resolved.modelSource,
1007
- thinking: chosenThinking ?? null,
1008
- activeWriteCount,
1009
- maxConcurrent: settings.writeAgents.maxConcurrent,
1010
- queueOverflow: false,
1011
- mode: "in-process",
1012
- debugLogPath: debugLogPath ?? null,
1013
- }, settings);
1014
-
1015
- if (activeWriteCount >= settings.writeAgents.maxConcurrent) {
1016
- await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.failure", {
1017
- agentName: safeName,
1018
- reason: "capacity-reached",
1019
- activeWriteCount,
1020
- maxConcurrent: settings.writeAgents.maxConcurrent,
1021
- mode: "in-process",
1011
+ const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx, nestedNameReservationId);
1012
+ if (role === "write") {
1013
+ await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.success", {
1014
+ agentName: safeName, cwd, requestedRole: role, model: chosenModel,
1015
+ modelSource: resolved.modelSource, thinking: chosenThinking ?? null,
1016
+ activeWriteCount: activeCount, maxConcurrent: capacity.maxConcurrent,
1017
+ queueOverflow: capacity.queueOverflow, terminalId: null, mode: "in-process",
1022
1018
  debugLogPath: debugLogPath ?? null,
1023
1019
  }, settings);
1024
- throw new Error(`Edit-agent capacity reached (${activeWriteCount}/${settings.writeAgents.maxConcurrent}). Wait for an active edit agent to finish before spawning another.`);
1025
1020
  }
1026
-
1027
- await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.success", {
1028
- agentName: safeName,
1029
- terminalId: null,
1030
- windowId: null,
1031
- mode: "in-process",
1032
- debugLogPath: debugLogPath ?? null,
1033
- }, settings);
1034
- const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx);
1035
1021
  options.renderReadAgentStatus();
1036
1022
  const debugSuffix = debugLogPath ? ` Debug log: ${debugLogPath}.` : "";
1037
1023
  return {
1038
- content: [{ type: "text", text: `Edit agent ${params.name} started in-process and is followable from Pi.${debugSuffix}` }],
1024
+ content: [{ type: "text", text: `${role === "write" ? "Edit agent" : "Read teammate"} ${params.name} started in-process and is followable from Pi.${debugSuffix}` }],
1039
1025
  details: spawnResolutionDetails(member, params, resolved, {
1040
- mode: "in-process",
1041
- terminalId: null,
1042
- queued: false,
1043
- debugLogPath,
1044
- sessionContextAvailable,
1026
+ mode: "in-process", terminalId: null, queued: false, debugLogPath, sessionContextAvailable,
1045
1027
  }),
1046
1028
  };
1047
1029
  } finally {
@@ -1326,5 +1308,12 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1326
1308
  },
1327
1309
  });
1328
1310
 
1329
- return { createNestedReadAgentTools };
1311
+ return {
1312
+ createNestedReadAgentTools,
1313
+ cancelQueuedAgent: (teamName, agentName) => {
1314
+ // Retain the entry for admission bookkeeping, but let active teardown own cancellation after launch commits.
1315
+ if (readQueue(teamName).some(queued => queued.member.name === agentName && queued.launchCommitted)) return false;
1316
+ return removeQueuedReadSpawnsByName(teamName, agentName).length > 0;
1317
+ },
1318
+ };
1330
1319
  }
@@ -605,11 +605,12 @@ export function createAgentFollowComponent(
605
605
  const logAction = expandLargeToolResults ? "l collapse logs" : "l expand logs";
606
606
  const messageAction = options.sendMessage ? " · m message" : "";
607
607
  const interruptAction = options.interruptAgent ? " · i interrupt" : "";
608
- const help = composingMessage
608
+ const herdrAction = process.env.HERDR_ENV === "1" && agent.moveToHerdr ? " · h Herdr" : "";
609
+ const help = messageStatus && !options.sendMessage ? messageStatus : composingMessage
609
610
  ? `message ${agent.name} · enter send · esc cancel`
610
611
  : agents.length > 1
611
- ? `↑ previous/main · ↓ next agent · ←/→ agent · ${logAction}${messageAction}${interruptAction} · x stop · pgup/pgdn scroll · esc main`
612
- : `↑/esc main · ${logAction}${messageAction}${interruptAction} · x stop · pgup/pgdn scroll · end follow`;
612
+ ? `↑ previous/main · ↓ next agent · ←/→ agent · ${logAction}${messageAction}${interruptAction}${herdrAction} · x stop · pgup/pgdn scroll · esc main`
613
+ : `↑/esc main · ${logAction}${messageAction}${interruptAction}${herdrAction} · x stop · pgup/pgdn scroll · end follow`;
613
614
 
614
615
  const currentTranscriptWidth = Math.max(20, innerWidth);
615
616
  if (transcriptAgent !== agent
@@ -766,6 +767,14 @@ export function createAgentFollowComponent(
766
767
  tui.requestRender();
767
768
  return;
768
769
  }
770
+ if (data.toLowerCase() === "h" && process.env.HERDR_ENV === "1") {
771
+ const agent = currentAgent(sortedAgents(), selectedName);
772
+ if (agent?.moveToHerdr) void agent.moveToHerdr().then(done).catch(error => {
773
+ messageStatus = sanitizePlainTuiLine(error instanceof Error ? error.message : String(error));
774
+ tui.requestRender();
775
+ });
776
+ return;
777
+ }
769
778
  if (data.toLowerCase() === "i" && options.interruptAgent) {
770
779
  const agent = currentAgent(sortedAgents(), selectedName);
771
780
  if (!agent || interruptingAgents.has(agent.name)) return;
@@ -1,8 +1,9 @@
1
1
  import type { RunningReadAgent } from "../runtime/types";
2
+ import { AGENT_HANGING_MS } from "../../src/orchestration/status-projection";
2
3
  import { formatElapsed } from "./renderers.js";
3
4
 
4
5
  export const READ_AGENT_IDLE_NUDGE_MS = 5 * 60_000;
5
- export const READ_AGENT_HANGING_NUDGE_MS = 15 * 60_000;
6
+ export const READ_AGENT_HANGING_NUDGE_MS = AGENT_HANGING_MS;
6
7
 
7
8
  export type ReadAgentStatusLabel = RunningReadAgent["status"] | "idle" | "hanging";
8
9
  export type ReadAgentIdleLevel = "none" | "soft" | "hard";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extended-teams",
3
- "version": "2.2.6",
3
+ "version": "2.2.8",
4
4
  "description": "Control-first, session-connected subagents for Pi with live navigation and intent-tier routing",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,7 @@
28
28
  "scripts": {
29
29
  "typecheck": "tsc --noEmit",
30
30
  "test": "vitest run",
31
- "test:focused": "vitest run src/utils/claims.test.ts src/utils/tasks.test.ts src/utils/model-resolution.test.ts src/utils/settings.test.ts src/utils/write-queue.test.ts src/utils/shared-memory.test.ts extensions/index.test.ts extensions/events/register-events.test.ts extensions/internal/session-files.test.ts extensions/team/contracts.test.ts extensions/tools/delegation-guard.test.ts extensions/tools/agent-communication-tools.test.ts extensions/tools/read-helper.test.ts extensions/agents/read-agent.test.ts extensions/agents/write-agent.test.ts extensions/tools/team-tools.read-agent.test.ts",
31
+ "test:focused": "vitest run src/utils/claims.test.ts src/utils/tasks.test.ts src/utils/model-resolution.test.ts src/utils/settings.test.ts src/utils/write-queue.test.ts src/utils/shared-memory.test.ts extensions/index.test.ts extensions/events/register-events.test.ts extensions/internal/session-files.test.ts extensions/team/contracts.test.ts extensions/tools/delegation-guard.test.ts extensions/tools/agent-communication-tools.test.ts extensions/tools/read-helper.test.ts extensions/agents/read-agent.test.ts extensions/agents/write-agent.test.ts extensions/tools/team-tools.read-agent.test.ts extensions/ui/agent-follow-view.test.ts extensions/tools/coordination-tools.test.ts",
32
32
  "build": "tsc --noEmit"
33
33
  },
34
34
  "main": "extensions/index.ts",
package/skills/teams.md CHANGED
@@ -69,7 +69,7 @@ Inspect only the concurrent rotation path and focused tests. Report gaps, covera
69
69
 
70
70
  Then:
71
71
 
72
- 1. Read agents run in-process; edit agents run in separate Pi sessions through the configured terminal adapter. Both report back to the lead.
72
+ 1. Public read and edit agents run in separate in-process Pi sessions and report back to the lead. A write tier grants edit tools, not a terminal pane. The terminal runtime remains available for existing integrations.
73
73
  2. The lead synthesizes the reports for the user.
74
74
  3. Finished agents leave the active status list; completed reports remain available in the session UI.
75
75
 
@@ -138,7 +138,7 @@ When the user says "agents", "use agents", "spawn agents", "send agents", "agent
138
138
  - When no unrelated work remains, end the turn. The extension resumes the lead when reports arrive.
139
139
  - One `get_agent_status` snapshot is allowed when current status is needed. Never call it repeatedly, sleep, busy-wait, or loop on inbox/status.
140
140
  - Trust quiet agents. Do not ping, message, or check an agent just because it has been quiet for less than several minutes; active status remains visible in the activity card and Down-key live view.
141
- - When new, changed, or previously omitted evidence affects an active owner, use `send_message` with an **Evidence delta** as defined below instead of replacing or stopping it. Active in-process read agents receive the message as a steering turn and can continue intelligently; active tmux writers wake through their inbox.
141
+ - When new, changed, or previously omitted evidence affects an active owner, use `send_message` with an **Evidence delta** as defined below instead of replacing or stopping it. Active in-process agents receive the message as a steering turn; legacy terminal-backed agents receive inbox delivery.
142
142
  - Once a final report is accepted, new message admission is closed and the agent is self-exiting; teardown may still be finishing. Do not call `stop_teammate` after normal completion. If genuinely new work appears after the report, spawn a fresh bounded `read-collect` lane rather than trying to revive that closing session.
143
143
  - Do not wake the lead just to ping idle agents.
144
144
  - Use `check_teammate` only when `get_agent_status` shows a suspected stall or failure. It is a lifecycle diagnostic and may clean up an agent classified as dead.