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.
- package/README.md +6 -2
- package/docs/access.md +2 -2
- package/extensions/agents/read-agent.ts +103 -3
- package/extensions/events/register-events.ts +15 -3
- package/extensions/index.ts +1 -0
- package/extensions/runtime/herdr.ts +13 -0
- package/extensions/runtime/types.ts +1 -0
- package/extensions/team/lifecycle.ts +6 -2
- package/extensions/tools/agent-status-tool.ts +15 -69
- package/extensions/tools/coordination-tools.ts +2 -0
- package/extensions/tools/task-runtime-tools.ts +7 -0
- package/extensions/tools/team-tools.ts +141 -152
- package/extensions/ui/agent-follow-view.ts +12 -3
- package/extensions/ui/read-agent-status.ts +2 -1
- package/package.json +2 -2
- package/skills/teams.md +2 -2
- package/src/orchestration/index.ts +21 -33
- package/src/orchestration/status-projection.ts +89 -0
- package/src/orchestration/types.ts +4 -1
- package/src/utils/models.ts +2 -1
|
@@ -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
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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") ===
|
|
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
|
-
|
|
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
|
|
269
|
-
if (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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
667
|
+
releaseReadAdmission(teamName, key);
|
|
649
668
|
void drainQueuedReadSpawns(teamName);
|
|
650
669
|
};
|
|
651
670
|
const finishRun = (pendingChildRun: PendingChildRun | undefined) => {
|
|
652
671
|
settlePendingChild(pendingChildRun);
|
|
653
|
-
|
|
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 (
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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 === "
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
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: `
|
|
988
|
-
details:
|
|
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
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
|
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.
|