pi-extended-teams 2.3.0 → 2.3.2

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.
@@ -10,15 +10,14 @@ 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 { CheckPolicySchema, normalizeCheckPolicy } from "../../src/results/check-policy";
16
- import { RepairPolicySchema, normalizeRepairPolicy } from "../../src/results/repair-policy";
17
- import { CheckpointPolicySchema } from "../../src/results/checkpoint-policy";
15
+ import { normalizeCheckPolicy } from "../../src/results/check-policy";
16
+ import { normalizeRepairPolicy } from "../../src/results/repair-policy";
18
17
  import { createCheckpointAssignment, captureCheckpointAssignment, continuationPrompt } from "../../src/results/checkpoint-assignment";
19
18
  import { assertUnusedContinuationRecipient, freshContinuationName } from "../../src/results/continuation-recipient";
20
19
  import { readCheckpoint } from "../../src/results/specialist-checkpoint";
21
- import { CompletionGroup, CompletionGroupPolicySchema, completionGroupIdentity, normalizeCompletionGroupPolicy, type CompletionGroupBinding } from "../../src/results/completion-group";
20
+ import { CompletionGroup, completionGroupIdentity, normalizeCompletionGroupPolicy, type CompletionGroupBinding } from "../../src/results/completion-group";
22
21
  import { listStoredTeamReportEvents } from "../../src/utils/report-events";
23
22
  import { enqueueCompletionGroupDeliveries } from "../../src/results/completion-group-delivery";
24
23
  import { shouldSuppressLeadReportInjection } from "../../src/utils/workflow-metadata";
@@ -48,6 +47,8 @@ import {
48
47
  type QueuedAgentStatus,
49
48
  } from "./agent-status-tool";
50
49
 
50
+ import { publicTeamToolMetadata } from "./public-team-tools";
51
+
51
52
  export const CHILD_AGENT_LIFECYCLE_PROBE = "pi-extended-teams:child-agent-lifecycle-probe";
52
53
 
53
54
  interface ChildAgentLifecycleProbe {
@@ -74,6 +75,8 @@ export interface TeamToolsOptions {
74
75
  getSessionCtx?(): any;
75
76
  setSessionCtx?(ctx: any): void;
76
77
  pendingChildController?: PendingChildController;
78
+ isHostAttached?(): boolean;
79
+ waitForHost?(): Promise<void>;
77
80
  }
78
81
 
79
82
  export interface NestedReadAgentToolBinding {
@@ -92,6 +95,9 @@ export interface TeamToolsRuntime {
92
95
  createNestedReadAgentTools(binding: NestedReadAgentToolBinding): any[];
93
96
  nestedChildSnapshot(binding: NestedReadAgentToolBinding): NestedChildSnapshot;
94
97
  cancelQueuedAgent(teamName: string, agentName: string): boolean | Promise<boolean>;
98
+ hasPendingWork(): boolean;
99
+ closeAdmission(): void;
100
+ resumeQueues(): void;
95
101
  }
96
102
 
97
103
  interface SpawnTeammateOptions {
@@ -131,6 +137,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
131
137
  createNestedReadAgentTools: () => [],
132
138
  nestedChildSnapshot: () => ({ running: 0, queued: 0 }),
133
139
  cancelQueuedAgent: () => false,
140
+ hasPendingWork: () => false,
141
+ closeAdmission: () => {},
142
+ resumeQueues: () => {},
134
143
  };
135
144
  }
136
145
 
@@ -389,10 +398,29 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
389
398
  return pending?.settled;
390
399
  }
391
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
+ }
392
419
  let pendingChildCancelUnsubscribe = (): void => {};
393
420
  pi.on?.("session_shutdown", async () => {
394
- if (lifecycleProbeCleanedUp) return;
395
- lifecycleProbeCleanedUp = true;
421
+ if (shutdownStarted) return;
422
+ shutdownStarted = true;
423
+ closeAdmission();
396
424
  const settlements: Promise<void>[] = [];
397
425
  for (const pending of checkpointAdmissions.values()) {
398
426
  pending.controller.abort(new Error("Checkpoint preparation cancelled: session is closing."));
@@ -404,8 +432,17 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
404
432
  if (removed?.groupSettlement) settlements.push(removed.groupSettlement);
405
433
  }
406
434
  }
407
- try { await Promise.all(settlements); }
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]); }
408
444
  finally {
445
+ clearTimeout(deadline);
409
446
  if (typeof lifecycleProbeUnsubscribe === "function") lifecycleProbeUnsubscribe();
410
447
  pendingChildCancelUnsubscribe();
411
448
  lifecycleFenceUnsubscribe();
@@ -864,7 +901,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
864
901
  }
865
902
 
866
903
  async function drainQueuedReadSpawns(teamName: string): Promise<void> {
867
- if (lifecycleProbeCleanedUp) return;
904
+ if (lifecycleProbeCleanedUp || options.isHostAttached?.() === false) return;
868
905
  if (readQueueDrainingTeams.has(teamName)) {
869
906
  pendingQueueDrains.add(teamName);
870
907
  return;
@@ -872,7 +909,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
872
909
  readQueueDrainingTeams.add(teamName);
873
910
  try {
874
911
  let progressed = true;
875
- while (progressed && !lifecycleProbeCleanedUp) {
912
+ while (progressed && !lifecycleProbeCleanedUp && options.isHostAttached?.() !== false) {
876
913
  progressed = false;
877
914
  for (const queued of readQueue(teamName)) {
878
915
  if (queued.admissionError) continue;
@@ -1274,39 +1311,17 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1274
1311
  }
1275
1312
  pi.events?.on?.("pi-extended-teams:orchestration-request", (payload: any) => {
1276
1313
  if (payload?.type !== "ensure_team" && payload?.type !== "spawn_teammate_once") return handleOrchestrationRequest(payload);
1277
- return withPublicScope(String(payload?.params?.team_name || ""), () => handleOrchestrationRequest(payload))
1314
+ return trackAdmission(() => withPublicScope(String(payload?.params?.team_name || ""), () => handleOrchestrationRequest(payload)))
1278
1315
  .catch(error => emitOrchestrationResponse(payload?.requestId, payload?.type, { ok: false, error: error instanceof Error ? error.message : String(error) }));
1279
1316
  });
1280
1317
 
1281
- 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.";
1282
- const publicAgentBaseParams = {
1283
- name: Type.Optional(Type.String({ description: "Stable display name. Defaults to a generated agent name." })),
1284
- prompt: Type.String({ description: "The agent's assignment, relevant prior context, evidence already gathered, constraints, and report shape." }),
1285
- cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the lead session cwd." })),
1286
- checks: Type.Optional(CheckPolicySchema),
1287
- repair: Type.Optional(RepairPolicySchema),
1288
- checkpoint: Type.Optional(CheckpointPolicySchema),
1289
- continue_from: Type.Optional(Type.String({ description: "Continue this checkpoint as a fresh recipient/run with the current assignment and tier. name is a prefix, not a recipient to reuse." })),
1290
- 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" })),
1291
- metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
1292
- 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 })),
1293
- };
1294
- const publicAgentParams = {
1295
- ...publicAgentBaseParams,
1296
- model_slot: StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription, default: "read-review" }),
1297
- };
1298
- const publicSwarmAgentParams = {
1299
- ...publicAgentBaseParams,
1300
- model_slot: Type.Optional(StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription })),
1301
- };
1302
-
1303
1318
  function generatedAgentName(index?: number): string {
1304
1319
  const position = index === undefined ? "" : `-${index + 1}`;
1305
1320
  return `agent-${Date.now().toString(36)}${position}-${crypto.randomUUID().slice(0, 8)}`;
1306
1321
  }
1307
1322
 
1308
1323
  function spawnPublicAgent(params: any, ctx: any, signal?: AbortSignal): Promise<{ content: any[]; details: any }> {
1309
- return withPublicScope(currentSessionAgentGroupName(ctx), () => spawnPublicAgentInScope(params, ctx, signal));
1324
+ return trackAdmission(() => withPublicScope(currentSessionAgentGroupName(ctx), () => spawnPublicAgentInScope(params, ctx, signal)));
1310
1325
  }
1311
1326
 
1312
1327
  async function spawnPublicAgentInScope(params: any, ctx: any, signal?: AbortSignal): Promise<{ content: any[]; details: any }> {
@@ -1343,13 +1358,18 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1343
1358
  model_slot: Type.Optional(StringEnum(NESTED_READ_MODEL_SLOTS, { description: "Canonical read-* tier, or inherit defaults.model_slot." })),
1344
1359
  };
1345
1360
 
1346
- 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(
1347
1366
  params: any,
1348
1367
  binding: NestedReadAgentToolBinding,
1349
1368
  context = "spawn_agent"
1350
1369
  ): Promise<{ content: any[]; details: any }> {
1351
1370
  rejectUnexpectedNestedParams(params, ["name", "prompt", "model_slot"], context);
1352
1371
  requireNestedReadSpawnLevel(params, context);
1372
+ if (options.isHostAttached?.() === false) await options.waitForHost?.();
1353
1373
  await assertNestedParentAuthorized(binding.teamName, binding.parent.name, binding.parentRunId, binding.parent.cwd);
1354
1374
  const name = params.name || generatedAgentName();
1355
1375
  const result = await spawnTeammate({
@@ -1358,7 +1378,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1358
1378
  model_slot: params.model_slot,
1359
1379
  team_name: binding.teamName,
1360
1380
  cwd: binding.parent.cwd,
1361
- }, binding.outerCtx, {
1381
+ }, options.isHostAttached ? options.getSessionCtx?.() ?? binding.outerCtx : binding.outerCtx, {
1362
1382
  nestedParent: {
1363
1383
  teamName: binding.teamName,
1364
1384
  name: binding.parent.name,
@@ -1446,113 +1466,104 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
1446
1466
  pi.registerTool(statusTool());
1447
1467
 
1448
1468
  pi.registerTool({
1449
- name: "spawn_agent",
1450
- label: "Spawn Agent",
1451
- 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.",
1452
- parameters: Type.Object(publicAgentParams),
1469
+ ...publicTeamToolMetadata.spawn_agent,
1453
1470
  async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
1454
1471
  return spawnPublicAgent(params, ctx, _signal);
1455
1472
  },
1456
1473
  });
1457
1474
 
1458
1475
  pi.registerTool({
1459
- name: "spawn_swarm_agents",
1460
- label: "Spawn Swarm Agents",
1461
- 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.",
1462
- parameters: Type.Object({
1463
- defaults: Type.Optional(Type.Object({
1464
- cwd: Type.Optional(Type.String()),
1465
- checks: Type.Optional(CheckPolicySchema),
1466
- repair: Type.Optional(RepairPolicySchema),
1467
- checkpoint: Type.Optional(CheckpointPolicySchema),
1468
- model_slot: Type.Optional(StringEnum(ACCEPTED_FAVORITE_MODEL_SLOTS, { description: levelDescription })),
1469
- metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
1470
- session_context: Type.Optional(StringEnum(["none", "lazy"] as const, { description: "Shared lazy session-reference policy.", default: "none" })),
1471
- allow_nested_read_agents: Type.Optional(Type.Boolean({ description: "Shared opt-in for eligible depth-0 write-feature/write-critical agents.", default: false })),
1472
- })),
1473
- completion_group: Type.Optional(CompletionGroupPolicySchema),
1474
- 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." }),
1475
- }),
1476
+ ...publicTeamToolMetadata.spawn_swarm_agents,
1476
1477
  async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
1477
- if (options.isTeammate) throw new Error("Only the lead session can spawn agents.");
1478
- if (!ctx) throw new Error("No active Pi session context is available for spawn_swarm_agents.");
1479
- if (!Array.isArray(params.agents) || params.agents.length === 0) throw new Error("spawn_swarm_agents requires at least one agent.");
1480
-
1481
- const groupPolicy = normalizeCompletionGroupPolicy(params.completion_group);
1482
- const sessionId = getPiSessionId(ctx);
1483
- if (groupPolicy && (!sessionId || !_toolCallId?.trim())) throw new Error("Completion groups require a bound session and runtime submission identity.");
1484
- const mergedAgents = params.agents.map((agent: any, index: number) => {
1485
- const merged = mergeSwarmAgentParams(params.defaults || {}, agent);
1486
- merged.checkpoint = createCheckpointAssignment(merged.checkpoint, merged.prompt, merged.continue_from)?.policy;
1487
- if (merged.continue_from !== undefined) merged.name = freshContinuationName(merged.name || "agent", JSON.stringify([sessionId, _toolCallId, index, merged.continue_from]));
1488
- return merged;
1489
- });
1490
- const defaultModel = configuredFavoriteModelForSpawn(mergedAgents[0], ctx, "spawn_swarm_agents");
1491
- for (let index = 0; index < mergedAgents.length; index += 1) {
1492
- configuredFavoriteModelForSpawn(mergedAgents[index], ctx, `spawn_swarm_agents agent ${mergedAgents[index].name || index + 1}`);
1493
- }
1494
- const sessionName = await ensureCurrentSessionAgentGroup(ctx, defaultModel);
1495
- let group: CompletionGroup | undefined;
1496
- if (groupPolicy && sessionId) {
1497
- const identity = { teamName: sessionName, sessionId, submissionId: `tool:${_toolCallId}` };
1498
- const groupId = completionGroupIdentity(identity);
1499
- options.onCompletionGroupUse?.(groupId);
1500
- const config = await teams.readConfig(sessionName);
1501
- const reports = await listStoredTeamReportEvents(sessionName);
1502
- const inbox = await messaging.readInbox(sessionName, "team-lead", false, false);
1503
- const entries = ctx.sessionManager?.getBranch?.() ?? [];
1504
- const references = [...config.members.map(member => member.completionGroup),
1505
- ...readQueue(sessionName).map(queued => queued.member.completionGroup),
1506
- ...reports.map(report => report.completionGroup), ...inbox.map(message => message.metadata?.completionGroup),
1507
- ...entries.map((entry: any) => entry.message?.details?.completionGroup)];
1508
- if (references.some(binding => binding?.groupId === groupId)) new CompletionGroup(sessionName, groupId).read();
1509
- group = await CompletionGroup.create({ ...identity, policy: groupPolicy, members: mergedAgents.map((agent: any, index: number) => ({
1510
- name: paths.sanitizeName(agent.name || `agent-${crypto.createHash("sha256").update(`${_toolCallId}:${index}`).digest("hex").slice(0, 12)}`),
1511
- assignmentKey: crypto.createHash("sha256").update(JSON.stringify([agent, ctx.cwd])).digest("hex"),
1512
- suppressed: sessionName.startsWith("prompt-build-") || shouldSuppressLeadReportInjection(agent),
1513
- })) });
1514
- if (group && !group.created) return {
1515
- content: [{ type: "text", text: `Completion group ${group.groupId} already exists; no assignments replayed. State: ${group.journalPath}\n${AGENT_WAIT_CONTRACT}` }],
1516
- details: { session: sessionName, completionGroup: { groupId: group.groupId, journalPath: group.journalPath },
1517
- idempotent: true, spawned: [], failed: [], members: group.read().members },
1518
- };
1519
- }
1520
- const spawned: any[] = [];
1521
- 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 }> = [];
1522
1524
 
1523
- for (let index = 0; index < mergedAgents.length; index += 1) {
1524
- const merged = mergedAgents[index];
1525
- const name = group?.read().members[index].name || merged.name || generatedAgentName(index);
1526
- try {
1527
- const result = await spawnTeammate({
1528
- ...merged,
1529
- name,
1530
- team_name: sessionName,
1531
- }, ctx, { allowNestedReadAgents: merged.allow_nested_read_agents === true, completionGroup: group?.binding(index), signal: _signal });
1532
- spawned.push({ ...result.details, name });
1533
- } catch (error) {
1534
- const message = error instanceof Error ? error.message : String(error);
1535
- failed.push({ name, error: message });
1536
- if (group) await group.apply({ type: "rejected", ...group.binding(index), runId: group.read().members[index].runId, reason: message });
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);
1537
1544
  }
1538
- }
1539
- if (group) {
1540
- await group.seal();
1541
- await enqueueCompletionGroupDeliveries(group);
1542
- }
1543
1545
 
1544
- const lines = [`Spawned ${spawned.length}/${params.agents.length} agents in the current Pi session.`];
1545
- for (const item of spawned) lines.push(`- ${item.name}: ${item.queued ? `queued at position ${item.queuePosition}` : `${item.role}, ${item.mode || "in-process"}`}`);
1546
- for (const item of failed) lines.push(`- ${item.name}: failed — ${item.error}`);
1547
- lines.push(AGENT_WAIT_CONTRACT);
1548
- return { content: [{ type: "text", text: lines.join("\n") }], details: { session: sessionName, spawned, failed,
1549
- ...(group ? { completionGroup: { groupId: group.groupId, journalPath: group.journalPath } } : {}) } };
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
+ });
1550
1553
  },
1551
1554
  });
1552
1555
 
1553
1556
  return {
1554
1557
  createNestedReadAgentTools,
1555
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); },
1556
1567
  cancelQueuedAgent: (teamName, agentName) => {
1557
1568
  // Retain the entry for admission bookkeeping, but let active teardown own cancellation after launch commits.
1558
1569
  if (readQueue(teamName).some(queued => queued.member.name === agentName && queued.launchCommitted)) return false;
@@ -1,35 +1,26 @@
1
- import { canonicalPersistedModelSlot } from "../../src/utils/settings";
1
+ import { canonicalPersistedModelSlot, loadSettings, type ActivityColors } from "../../src/utils/settings";
2
2
 
3
3
  export const CONTEXT_USAGE_STATUS_SUFFIX = /^(?:\?|[\d.]+[kM]?) tok(?: \((?:\?|[\d.]+)%\))?$/;
4
4
 
5
- const ACTIVITY_COLORS = {
6
- name: "51;153;255",
7
- model: "255;215;0",
8
- thinking: "255;242;168",
9
- tier: "255;146;200",
10
- text: "248;248;242",
11
- message: "150;156;171",
12
- warning: "255;215;0",
13
- error: "255;85;85",
14
- } as const;
15
-
16
- const TIER_PINKS: Record<string, string> = {
17
- "read-collect": "255;214;235",
18
- "write-patch": "255;214;235",
19
- "read-review": "255;189;222",
20
- "write-feature": "255;189;222",
21
- "read-analyze": "255;167;211",
22
- "write-system": "255;167;211",
23
- "read-critical": ACTIVITY_COLORS.tier,
24
- "write-critical": ACTIVITY_COLORS.tier,
5
+ const TIER_COLORS: Record<string, keyof ActivityColors> = {
6
+ "read-collect": "tierCollect",
7
+ "write-patch": "tierCollect",
8
+ "read-review": "tierReview",
9
+ "write-feature": "tierReview",
10
+ "read-analyze": "tierAnalyze",
11
+ "write-system": "tierAnalyze",
25
12
  };
26
13
 
27
- // The shared activity palette is fixed; each view's surrounding chrome stays theme-owned.
14
+ // Views share settings; surrounding chrome remains theme-owned.
28
15
  export function createActivityColors(enabled: boolean) {
29
- const foreground = (rgb: string, text: string): string =>
30
- enabled ? `\x1b[38;2;${rgb}m${text}\x1b[39m` : text;
31
- const color = (token: keyof typeof ACTIVITY_COLORS, text: string): string => foreground(ACTIVITY_COLORS[token], text);
32
- const tier = (text: string): string => foreground(TIER_PINKS[canonicalPersistedModelSlot(text)] ?? ACTIVITY_COLORS.tier, text);
16
+ const palette = loadSettings({ projectDir: process.cwd() }).activityColors;
17
+ const color = (token: keyof ActivityColors, text: string): string => {
18
+ if (!enabled) return text;
19
+ const hex = palette[token];
20
+ const rgb = [1, 3, 5].map(offset => parseInt(hex.slice(offset, offset + 2), 16)).join(";");
21
+ return `\x1b[38;2;${rgb}m${text}\x1b[39m`;
22
+ };
23
+ const tier = (text: string): string => color(TIER_COLORS[canonicalPersistedModelSlot(text)] ?? "tier", text);
33
24
  const metadata = (text: string, name: string): string => {
34
25
  const identity = `(${name})`;
35
26
  return text.split(" · ").map((part, index) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extended-teams",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Control-first, session-connected subagents for Pi with live navigation and intent-tier routing",
5
5
  "repository": {
6
6
  "type": "git",
@@ -52,7 +52,9 @@
52
52
  "README.md",
53
53
  "LICENSE",
54
54
  "SECURITY.md",
55
- "docs/access.md"
55
+ "docs/access.md",
56
+ "docs/reference.md",
57
+ "assets/pi-extended-teams-demo.gif"
56
58
  ],
57
59
  "dependencies": {
58
60
  "uuid": "^11.1.0"
@@ -116,7 +116,23 @@ export interface AgentSessionsConfig {
116
116
  showInResume: boolean;
117
117
  }
118
118
 
119
+ export const DEFAULT_ACTIVITY_COLORS = {
120
+ name: "#50FA7B",
121
+ model: "#FFD700",
122
+ thinking: "#FFF2A8",
123
+ tier: "#FF92C8",
124
+ tierCollect: "#FFD6EB",
125
+ tierReview: "#FFBDDE",
126
+ tierAnalyze: "#FFA7D3",
127
+ text: "#F8F8F2",
128
+ message: "#969CAB",
129
+ warning: "#FFD700",
130
+ error: "#FF5555",
131
+ };
132
+ export type ActivityColors = typeof DEFAULT_ACTIVITY_COLORS;
133
+
119
134
  export interface PiExtendedTeamsSettings {
135
+ activityColors: ActivityColors;
120
136
  watchdog: WatchdogConfig;
121
137
  writeAgents: WriteAgentsConfig;
122
138
  readAgents: ReadAgentsConfig;
@@ -134,6 +150,7 @@ export const DEFAULT_READ_AGENT_MAX_CONCURRENT = 25;
134
150
  export const DEFAULT_READ_HELPER_MAX_CONCURRENT = 10;
135
151
 
136
152
  export const DEFAULT_SETTINGS: PiExtendedTeamsSettings = {
153
+ activityColors: DEFAULT_ACTIVITY_COLORS,
137
154
  watchdog: { bufferSeconds: 30 },
138
155
  writeAgents: { maxConcurrent: DEFAULT_WRITE_AGENT_MAX_CONCURRENT, queueOverflow: true },
139
156
  readAgents: { maxConcurrent: DEFAULT_READ_AGENT_MAX_CONCURRENT, queueOverflow: true },
@@ -269,6 +286,13 @@ function applyLayer(acc: PiExtendedTeamsSettings, raw: any, options: { favoriteM
269
286
  if (!raw || typeof raw !== "object") return;
270
287
  const includeFavoriteModels = options.favoriteModels !== false;
271
288
 
289
+ if (raw.activityColors && typeof raw.activityColors === "object" && !Array.isArray(raw.activityColors)) {
290
+ for (const key of Object.keys(DEFAULT_ACTIVITY_COLORS) as Array<keyof ActivityColors>) {
291
+ const value = raw.activityColors[key];
292
+ if (typeof value === "string" && /^#[\da-f]{6}$/i.test(value)) acc.activityColors[key] = value;
293
+ }
294
+ }
295
+
272
296
  if (raw.watchdog && typeof raw.watchdog === "object") {
273
297
  const b = Number(raw.watchdog.bufferSeconds);
274
298
  if (Number.isFinite(b) && b >= 0) acc.watchdog.bufferSeconds = b;