pi-extended-teams 2.2.10 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +182 -2
  2. package/extensions/agents/read-agent-report.ts +11 -2
  3. package/extensions/agents/read-agent-session-lifecycle.ts +25 -10
  4. package/extensions/agents/read-agent.ts +387 -65
  5. package/extensions/agents/write-agent.ts +3 -0
  6. package/extensions/events/register-events.ts +37 -21
  7. package/extensions/index.ts +257 -60
  8. package/extensions/internal/pi-check-operations.ts +24 -0
  9. package/extensions/internal/session-files.ts +10 -2
  10. package/extensions/runtime/lead-attachment.ts +91 -0
  11. package/extensions/runtime/reload-owner.ts +107 -0
  12. package/extensions/runtime/team-host.ts +124 -0
  13. package/extensions/runtime/teammate-interrupt.ts +11 -5
  14. package/extensions/runtime/types.ts +4 -0
  15. package/extensions/team/lifecycle.ts +26 -0
  16. package/extensions/team/session-cost.ts +225 -0
  17. package/extensions/tools/agent-communication-tools.ts +24 -8
  18. package/extensions/tools/agent-status-tool.ts +50 -7
  19. package/extensions/tools/coordination-tools.ts +78 -8
  20. package/extensions/tools/public-team-tools.ts +51 -0
  21. package/extensions/tools/task-runtime-tools.ts +23 -4
  22. package/extensions/tools/team-tools.ts +365 -107
  23. package/extensions/ui/activity-colors.ts +50 -0
  24. package/extensions/ui/agent-follow-view.ts +15 -11
  25. package/extensions/ui/agent-navigation.ts +35 -1
  26. package/extensions/ui/checkpoints-command.ts +28 -0
  27. package/extensions/ui/onboarding-command.ts +218 -0
  28. package/extensions/ui/status-widget.ts +22 -14
  29. package/package.json +1 -1
  30. package/skills/teams.md +16 -0
  31. package/src/orchestration/index.ts +19 -1
  32. package/src/orchestration/status-projection.ts +2 -3
  33. package/src/orchestration/types.ts +9 -1
  34. package/src/results/check-journal.ts +149 -0
  35. package/src/results/check-policy.ts +86 -0
  36. package/src/results/check-runner.ts +110 -0
  37. package/src/results/checkpoint-assignment.ts +60 -0
  38. package/src/results/checkpoint-policy.ts +20 -0
  39. package/src/results/checkpoint-report.ts +109 -0
  40. package/src/results/checkpoint-retention.ts +15 -0
  41. package/src/results/completion-group-delivery.ts +66 -0
  42. package/src/results/completion-group-recovery.ts +61 -0
  43. package/src/results/completion-group-wake.ts +62 -0
  44. package/src/results/completion-group.ts +290 -0
  45. package/src/results/continuation-recipient.ts +37 -0
  46. package/src/results/durable-json.ts +29 -0
  47. package/src/results/repair-policy.ts +24 -0
  48. package/src/results/report-result.ts +77 -0
  49. package/src/results/source-identity.ts +115 -0
  50. package/src/results/specialist-checkpoint.ts +227 -0
  51. package/src/results/verification-controller.ts +308 -0
  52. package/src/utils/messaging.ts +47 -10
  53. package/src/utils/models.ts +13 -0
  54. package/src/utils/paths.ts +4 -0
  55. package/src/utils/report-events.ts +95 -10
  56. package/src/utils/settings.ts +24 -0
  57. package/src/utils/teams.ts +3 -1
  58. package/src/utils/write-queue.ts +3 -0
@@ -8,11 +8,14 @@ import { runReadAgentInProcess, sendMessageToRunningReadAgent } from "./agents/r
8
8
  import { createWriteAgentRuntime } from "./agents/write-agent.js";
9
9
  import { registerExtensionEvents } from "./events/register-events.js";
10
10
  import { createLifecycleRuntime } from "./team/lifecycle.js";
11
+ import { createCombinedSessionCost } from "./team/session-cost.js";
11
12
  import { buildRoster as buildTeamRoster, formatRosterForPrompt, releaseAllClaimsForAgent, requireTeamContext as resolveTeamContext, requireWriteAgentTeam as resolveWriteAgentTeam } from "./team/roster.js";
12
13
  import { createWriterScreenState, registerWriterScreenShortcut, removeWriterScreenTab, upsertWriterScreenTab, type ActiveWriterTab } from "./team/writer-screens.js";
13
14
  import { registerFavoriteModelsCommand } from "./ui/favorite-models-command.js";
14
15
  import { registerExtensionsCommand } from "./ui/extensions-command.js";
15
- import { installAgentNavigation } from "./ui/agent-navigation.js";
16
+ import { registerOnboardingCommand } from "./ui/onboarding-command.js";
17
+ import { registerCheckpointsCommand } from "./ui/checkpoints-command.js";
18
+ import { hasActiveReadAgentLifecycle, installAgentNavigation, orderAgentNavigationEntries } from "./ui/agent-navigation.js";
16
19
  import { buildReadHelperPrompt, registerCoordinationTools } from "./tools/coordination-tools.js";
17
20
  import { createReportProgressTool } from "./tools/agent-communication-tools.js";
18
21
  import { registerTaskRuntimeTools } from "./tools/task-runtime-tools.js";
@@ -22,24 +25,38 @@ import { parseQualifiedModel } from "../src/utils/model-resolution";
22
25
  import { formatAnimatedProgress, formatContextUsage, formatElapsed, formatModelLabel } from "./ui/renderers.js";
23
26
  import type { CompletedAgentReport, RunningReadAgent } from "./runtime/types.js";
24
27
  import { createPendingChildController } from "./runtime/pending-child-controller.js";
28
+ import { createTeamHost } from "./runtime/team-host";
29
+ import { registerLeadAttachment, type TeamExecutionOwner } from "./runtime/lead-attachment";
25
30
  import { createActiveAgentSleepController, runWithActiveAgentSleepAssertion } from "./runtime/active-agent-sleep.js";
26
31
  import { createTeammateInterrupter } from "./runtime/teammate-interrupt.js";
27
32
  export { panelBgFill, framePanel, frameWidget, frameWidgetFullWidth, logWindowStart } from "./ui/frame.js";
28
33
  import * as messaging from "../src/utils/messaging";
34
+ import { requestCompletionGroupWake, observeCompletionGroupWakes } from "../src/results/completion-group-wake";
35
+ import { recoverCompletionGroups } from "../src/results/completion-group-recovery";
29
36
  import * as teams from "../src/utils/teams";
30
37
  import * as runtime from "../src/utils/runtime";
31
38
  import * as teamPaths from "../src/utils/paths";
32
39
  import { listReadHelperQueue, removeQueuedReadHelperRequest } from "../src/utils/read-helper-queue";
33
40
  import type { Member } from "../src/utils/models";
41
+ import { projectAgentStatus, projectLifecycleFence } from "../src/orchestration/status-projection";
34
42
  import { getTerminalAdapter } from "../src/adapters/terminal-registry";
35
43
  import { createSpawnResourcePlan, parentProjectTrustForSpawn } from "./resources/spawn-resource-plan.js";
36
- import { generateExtensionInstanceId, listLifecycleTombstones, onLifecycleTombstoneCleared, readLifecycleTombstone } from "../src/utils/lifecycle-tombstone";
44
+ import { generateExtensionInstanceId, generateLifecycleRunId, listLifecycleTombstones, onLifecycleTombstoneCleared, readLifecycleTombstone } from "../src/utils/lifecycle-tombstone";
37
45
 
38
- export default function (pi: ExtensionAPI) {
46
+ export default function (pi: ExtensionAPI): void {
47
+ if (process.env.PI_AGENT_NAME) { createExecutionRuntime(pi); return; }
48
+ registerLeadAttachment(pi, () => {
49
+ const host = createTeamHost();
50
+ return createExecutionRuntime(host.api as unknown as ExtensionAPI, host);
51
+ });
52
+ }
53
+
54
+ function createExecutionRuntime(pi: ExtensionAPI, host?: ReturnType<typeof createTeamHost>): TeamExecutionOwner {
39
55
  const isTeammate = !!process.env.PI_AGENT_NAME;
40
56
  const agentName = process.env.PI_AGENT_NAME || "team-lead";
41
57
  const extensionInstanceId = generateExtensionInstanceId();
42
58
  const envTeamName = process.env.PI_TEAM_NAME;
59
+ const combinedSessionCost = isTeammate ? undefined : createCombinedSessionCost(pi);
43
60
 
44
61
  // Teammates are explicitly bound by PI_TEAM_NAME. Lead sessions are adopted
45
62
  // later at session_start only when the persisted Pi session id matches.
@@ -60,6 +77,14 @@ export default function (pi: ExtensionAPI) {
60
77
  let readHelperQueueDrainPending = false;
61
78
  let sessionCtx: any = null;
62
79
  let extensionShuttingDown = false;
80
+ let executionClosing = false;
81
+ const pendingRootAdmissions = new Set<Promise<void>>();
82
+ function trackRootAdmission(): () => void {
83
+ let settle!: () => void;
84
+ const pending = new Promise<void>(resolve => { settle = resolve; });
85
+ pendingRootAdmissions.add(pending);
86
+ return () => { pendingRootAdmissions.delete(pending); settle(); };
87
+ }
63
88
  const lifecycleFenceClearUnsubscribe = onLifecycleTombstoneCleared((clearedTeamName) => {
64
89
  if (clearedTeamName === teamName) void drainReadHelperQueueOnce();
65
90
  });
@@ -75,6 +100,7 @@ export default function (pi: ExtensionAPI) {
75
100
  let teamActivityStatusSnapshotSignature: string | null = null;
76
101
  let teamActivityWidgetMounted = false;
77
102
  let teamActivityWidgetTui: { requestRender?: () => void } | null = null;
103
+ let presentationGeneration = 0;
78
104
  let teamActivityRenderInFlight = false;
79
105
  let teamActivityRenderDirty = false;
80
106
  let teamActivityRenderTimer: NodeJS.Timeout | null = null;
@@ -86,8 +112,10 @@ export default function (pi: ExtensionAPI) {
86
112
  // instead of unread count prevents a read message being replaced by a new one
87
113
  // at the same count from silently suppressing the new wake.
88
114
  const leadWakeNotifiedMessageKeys = new Set<string>();
89
- const createCurrentSpawnResourcePlan = (input: { cwd: string; projectTrusted: boolean }) => {
90
- return createSpawnResourcePlan({ ...input, pi });
115
+ const createCurrentSpawnResourcePlan = async (input: { cwd: string; projectTrusted: boolean }) => {
116
+ while (host && !host.ready) await host.whenAttached();
117
+ const projectTrusted = host ? parentProjectTrustForSpawn(sessionCtx, input.cwd) : input.projectTrusted;
118
+ return createSpawnResourcePlan({ ...input, projectTrusted, pi });
91
119
  };
92
120
  let teamToolsRuntime: TeamToolsRuntime | undefined;
93
121
  const pendingChildController = createPendingChildController();
@@ -172,11 +200,12 @@ export default function (pi: ExtensionAPI) {
172
200
  targetTeamName: string,
173
201
  targetAgentName: string,
174
202
  content: string,
175
- expectedRecipientRunId?: string
203
+ expectedRecipientRunId?: string,
204
+ requireReceipt = false
176
205
  ): Promise<boolean> {
177
206
  const target = runningReadAgents.get(readAgentKey(targetTeamName, targetAgentName));
178
207
  if (expectedRecipientRunId !== undefined && target?.runId !== expectedRecipientRunId) return false;
179
- const delivered = await sendMessageToRunningReadAgent(target, content);
208
+ const delivered = await sendMessageToRunningReadAgent(target, content, requireReceipt);
180
209
  if (delivered) renderReadAgentStatus();
181
210
  return delivered;
182
211
  }
@@ -229,13 +258,6 @@ export default function (pi: ExtensionAPI) {
229
258
  return !!status.lastHeartbeatAt && (now - status.lastHeartbeatAt) <= runtime.HEARTBEAT_STALE_MS;
230
259
  }
231
260
 
232
- function hasActiveReadAgentLifecycle(agent: RunningReadAgent): boolean {
233
- return agent.teardownState !== "stopping"
234
- && agent.teardownState !== "quarantined"
235
- && agent.teardownState !== "persistence_failed"
236
- && agent.teardownState !== "finalized";
237
- }
238
-
239
261
  function isVisibleRuntimeOnlyMember(
240
262
  member: Member,
241
263
  runtimeStatus: runtime.AgentRuntimeStatus | null,
@@ -337,11 +359,11 @@ export default function (pi: ExtensionAPI) {
337
359
  // message with display:false still reaches the model as a user turn (see
338
360
  // convertToLlm) but is never rendered, so team coordination stays silent.
339
361
  // Falls back to a visible user message on older pi builds without sendMessage.
340
- function quietTrigger(content: string): void {
362
+ function quietTrigger(content: string, details?: Record<string, unknown>): void {
341
363
  const api = pi as any;
342
364
  if (typeof api.sendMessage === "function") {
343
365
  api.sendMessage(
344
- { customType: "pi-extended-teams-wake", content, display: false },
366
+ { customType: "pi-extended-teams-wake", content, display: false, ...(details ? { details } : {}) },
345
367
  { triggerTurn: true, deliverAs: "followUp" }
346
368
  );
347
369
  } else {
@@ -378,9 +400,46 @@ export default function (pi: ExtensionAPI) {
378
400
  pi.events?.emit?.("pi-extended-teams:agent-progress", { teamName: progressTeamName, name, status, updatedAt });
379
401
  }
380
402
 
381
- function wakeLeadForInboxReports(unread: any[]): void {
382
- if (!teamName) return;
383
- const keyedUnread = unread.map((message) => ({
403
+ const currentCompletionGroups = new Set<string>();
404
+ const observedWakeEntries = new Set<string>();
405
+ const groupWarnings = new Set<string>();
406
+ const sessionEntries = (ctx: any) => ctx?.sessionManager?.getEntries?.() ?? ctx?.sessionManager?.getBranch?.() ?? [];
407
+ function warnGroup(error: unknown): void {
408
+ const message = error instanceof Error ? error.message : String(error);
409
+ if (!groupWarnings.has(message)) {
410
+ groupWarnings.add(message);
411
+ sessionCtx?.ui?.notify?.(message, "warning");
412
+ }
413
+ }
414
+ async function observeGroupHistory(ctx: any): Promise<void> {
415
+ if (!teamName || extensionShuttingDown) return;
416
+ const target = teamName;
417
+ const entries = sessionEntries(ctx).filter((entry: any) => !observedWakeEntries.has(`${target}:${entry.id}`));
418
+ const result = await observeCompletionGroupWakes(target, getPiSessionId(ctx) ?? "", entries);
419
+ for (const id of result.observed) observedWakeEntries.add(`${target}:${id}`);
420
+ for (const error of result.errors) warnGroup(error);
421
+ }
422
+ async function requestGroupInboxWakes(unread: any[]): Promise<void> {
423
+ if (!teamName || !sessionCtx || extensionShuttingDown) return;
424
+ const target = teamName;
425
+ const ctx = sessionCtx;
426
+ for (const message of unread.filter(item => item.metadata?.completionGroup)) {
427
+ try {
428
+ if (typeof (pi as any).sendMessage !== "function") throw new Error("Grouped wakes require Pi custom messages; use read_inbox for the saved index.");
429
+ await requestCompletionGroupWake(target, getPiSessionId(ctx) ?? "", message, (content, details) => {
430
+ if (extensionShuttingDown || ctx !== sessionCtx) throw new Error("Group wake unconfirmed: the lead session is closing.");
431
+ quietTrigger(content, details);
432
+ });
433
+ } catch (error) { warnGroup(error); }
434
+ }
435
+ }
436
+ async function wakeLeadForInboxReports(unread: any[]): Promise<void> {
437
+ if (!teamName || extensionShuttingDown) return;
438
+ const target = teamName;
439
+ const ctx = sessionCtx;
440
+ await requestGroupInboxWakes(unread);
441
+ if (extensionShuttingDown || target !== teamName || ctx !== sessionCtx) return;
442
+ const keyedUnread = unread.filter(message => !message.metadata?.completionGroup).map((message) => ({
384
443
  message,
385
444
  key: String(message?.id || `${message?.from || "unknown"}:${message?.timestamp || ""}:${message?.text || ""}`),
386
445
  }));
@@ -409,10 +468,13 @@ export default function (pi: ExtensionAPI) {
409
468
  }
410
469
 
411
470
  async function notifyLeadOfInboxReports(targetTeamName: string): Promise<void> {
412
- if (isTeammate) return;
471
+ if (isTeammate || extensionShuttingDown) return;
472
+ const generation = presentationGeneration;
413
473
  const unread = await messaging.peekInbox(targetTeamName, agentName, true);
474
+ if (generation !== presentationGeneration) return;
414
475
  await renderLeadInboxStatus();
415
- wakeLeadForInboxReports(unread);
476
+ if (generation !== presentationGeneration) return;
477
+ await wakeLeadForInboxReports(unread);
416
478
  }
417
479
 
418
480
  function getIdleReadAgentNudgeMessage(agent: RunningReadAgent, status: ReadAgentStatusDescription): string | null {
@@ -492,6 +554,7 @@ export default function (pi: ExtensionAPI) {
492
554
  return;
493
555
  }
494
556
 
557
+ const generation = presentationGeneration;
495
558
  teamActivityRenderInFlight = true;
496
559
  let renderError: unknown;
497
560
  try {
@@ -500,6 +563,7 @@ export default function (pi: ExtensionAPI) {
500
563
  } catch (error) {
501
564
  renderError = error;
502
565
  } finally {
566
+ if (generation !== presentationGeneration) return;
503
567
  teamActivityRenderInFlight = false;
504
568
  if (renderError) {
505
569
  teamActivityRenderDirty = false;
@@ -564,14 +628,17 @@ export default function (pi: ExtensionAPI) {
564
628
 
565
629
  async function renderTeamActivityStatusNow() {
566
630
  if (!sessionCtx?.ui) return;
631
+ const generation = presentationGeneration;
632
+ const isCurrent = () => generation === presentationGeneration && !extensionShuttingDown;
567
633
 
568
634
  const now = Date.now();
569
635
  const activityTeamName = teamName || null;
570
- const runningAgents = activityTeamName
636
+ const sessionAgents = activityTeamName
571
637
  ? Array.from(runningReadAgents.values())
572
- .filter(agent => agent.teamName === activityTeamName && hasActiveReadAgentLifecycle(agent))
638
+ .filter(agent => agent.teamName === activityTeamName)
573
639
  .sort((a, b) => a.name.localeCompare(b.name))
574
640
  : [];
641
+ const runningAgents = sessionAgents.filter(hasActiveReadAgentLifecycle);
575
642
  const readAgents = runningAgents.filter(agent => (agent.role || "read") === "read");
576
643
  const writeAgents = runningAgents.filter(agent => (agent.role || "read") === "write");
577
644
  const activityConfig = activityTeamName ? await teams.readConfig(activityTeamName).catch(() => null) : null;
@@ -579,17 +646,22 @@ export default function (pi: ExtensionAPI) {
579
646
  const activityTombstones = activityTeamName
580
647
  ? await listLifecycleTombstones(activityTeamName).catch(() => [])
581
648
  : [];
582
- const runtimeOnlyMembers = activityTeamName
583
- ? (await Promise.all(activityMembers
649
+ const unsettledAgents = sessionAgents.filter(agent => !hasActiveReadAgentLifecycle(agent)
650
+ && agent.teardownState !== "finalized"
651
+ && !activityTombstones.some(fence => fence.agentName === agent.name));
652
+ const runtimeOnlyCandidates = activityTeamName
653
+ ? await Promise.all(activityMembers
584
654
  .filter(member => member.name !== "team-lead")
585
655
  .filter(member => !runningReadAgents.has(readAgentKey(activityTeamName, member.name)))
586
656
  .map(async (member) => ({
587
657
  member,
588
658
  runtimeStatus: await runtime.readRuntimeStatus(activityTeamName, member.name).catch(() => null),
589
- }))))
590
- .filter((entry): entry is { member: Member; runtimeStatus: runtime.AgentRuntimeStatus } => isVisibleRuntimeOnlyMember(entry.member, entry.runtimeStatus, now))
591
- .filter(({ member, runtimeStatus }) => member.lifecycleRunId === runtimeStatus.lifecycleRunId)
659
+ })))
592
660
  : [];
661
+ if (!isCurrent()) return;
662
+ const runtimeOnlyMembers = runtimeOnlyCandidates
663
+ .filter((entry): entry is { member: Member; runtimeStatus: runtime.AgentRuntimeStatus } => isVisibleRuntimeOnlyMember(entry.member, entry.runtimeStatus, now))
664
+ .filter(({ member, runtimeStatus }) => member.lifecycleRunId === runtimeStatus.lifecycleRunId);
593
665
  const runtimeOnlyMemberNames = new Set(runtimeOnlyMembers.map(({ member }) => member.name));
594
666
  const runtimeOnlyReadMembers = runtimeOnlyMembers.filter(({ member }) => memberActivityRole(member) === "read");
595
667
  const runtimeOnlyWriteMembers = runtimeOnlyMembers.filter(({ member }) => memberActivityRole(member) === "write");
@@ -618,6 +690,11 @@ export default function (pi: ExtensionAPI) {
618
690
  .filter(member => member.name !== "team-lead" && member.isActive !== false && memberActivityRole(member) === "write")
619
691
  .filter(member => !!(member.tmuxPaneId && terminal?.isAlive?.(member.tmuxPaneId)) && !runningReadAgents.has(readAgentKey(activityTeamName, member.name)) && !runtimeOnlyMemberNames.has(member.name)) ?? []
620
692
  : [];
693
+ const unobservedMembers = runtimeOnlyCandidates.filter(({ member }) => member.isActive !== false
694
+ && !runtimeOnlyMemberNames.has(member.name)
695
+ && !activeWriteMembers.some(writer => writer.name === member.name)
696
+ && !activityTombstones.some(fence => fence.agentName === member.name));
697
+ const activeCount = runningAgents.length + runtimeOnlyMembers.length + activeWriteMembers.length + unobservedMembers.length;
621
698
  navigationRuntimeAgents.clear();
622
699
  for (const { member, runtimeStatus } of runtimeOnlyReadMembers) {
623
700
  if (member.lifecycleRunId === runtimeStatus.lifecycleRunId) {
@@ -630,14 +707,16 @@ export default function (pi: ExtensionAPI) {
630
707
  }
631
708
  }
632
709
  const unreadLeadMessages = activityTeamName ? await messaging.readInbox(activityTeamName, agentName, true, false).catch(() => []) : [];
710
+ if (!isCurrent()) return;
633
711
  leadInboxUnreadCount = unreadLeadMessages.length;
634
712
  clearLeadInboxWidgetOnce();
635
713
 
636
- if ((readAgents.length > 0 || writeAgents.length > 0 || runtimeOnlyReadMembers.length > 0 || runtimeOnlyWriteMembers.length > 0 || activeWriteMembers.length > 0 || activityTombstones.length > 0) && !readAgentStatusTimer) {
714
+ const hasVisibleAgents = activeCount > 0 || unsettledAgents.length > 0 || activityTombstones.length > 0;
715
+ if (hasVisibleAgents && !readAgentStatusTimer) {
637
716
  readAgentStatusTimer = setInterval(renderReadAgentStatus, 1000);
638
717
  }
639
718
 
640
- if (readAgents.length === 0 && writeAgents.length === 0 && runtimeOnlyReadMembers.length === 0 && runtimeOnlyWriteMembers.length === 0 && activeWriteMembers.length === 0 && activityTombstones.length === 0) {
719
+ if (!hasVisibleAgents) {
641
720
  sessionCtx.ui.setStatus?.("01-pi-extended-teams-read", undefined);
642
721
  clearTeamActivityWidget();
643
722
  sessionCtx.ui.setWidget?.("01-pi-extended-teams-status", undefined);
@@ -648,7 +727,6 @@ export default function (pi: ExtensionAPI) {
648
727
  return;
649
728
  }
650
729
 
651
- const activeCount = readAgents.length + writeAgents.length + runtimeOnlyReadMembers.length + runtimeOnlyWriteMembers.length + activeWriteMembers.length;
652
730
  const entries: TeamActivityStatusEntry[] = [];
653
731
  const footerStatuses: string[] = [];
654
732
  const statusCounts: Record<string, number> = {};
@@ -698,6 +776,7 @@ export default function (pi: ExtensionAPI) {
698
776
  joinedAt: member.joinedAt,
699
777
  });
700
778
  const runtimeStatus = await runtime.readRuntimeStatus(activityTeamName!, member.name).catch(() => null);
779
+ if (!isCurrent()) return;
701
780
  const elapsed = formatElapsed(now - (runtimeStatus?.startedAt || member.joinedAt));
702
781
  const modelLabel = formatModelLabel(member.model, member.thinking);
703
782
  const contextUsage = formatRunningContextUsage(runtimeStatus?.contextUsage);
@@ -742,16 +821,43 @@ export default function (pi: ExtensionAPI) {
742
821
  footerStatuses.push(formatAgentProgressStatus(agent.name, agent.model, agent.thinking, agent.modelSlot, elapsed, contextUsage, agent.latestProgress, now));
743
822
  }
744
823
 
824
+ for (const { member, runtimeStatus } of unobservedMembers) {
825
+ const projected = projectAgentStatus({ member, runtime: runtimeStatus, fence: { status: "absent" }, terminalAlive: null, now });
826
+ const telemetry = !projected.runtime ? "runtime telemetry unavailable"
827
+ : runtimeHeartbeatIsRecent(projected.runtime, now) ? "awaiting runtime readiness" : "heartbeat stale";
828
+ countStatus(projected.phase);
829
+ entries.push({ name: member.name, role: memberActivityRole(member), status: projected.phase, detail: telemetry });
830
+ footerStatuses.push(`(${member.name}) ${projected.phase} · ${telemetry}`);
831
+ }
832
+
833
+ for (const agent of unsettledAgents) {
834
+ const status = agent.teardownState === "persistence_failed" ? "persistence-failed"
835
+ : agent.teardownState === "quarantined" ? "quarantined" : "stopping";
836
+ const reason = agent.teardownError instanceof Error ? agent.teardownError.message
837
+ : agent.teardownResult?.error || "cleanup has not settled";
838
+ const detail = status === "stopping" ? "Finishing cleanup" : `Cleanup blocked: ${reason} · use check_teammate`;
839
+ countStatus(status);
840
+ entries.push({ name: agent.name, role: agent.role === "write" ? "write" : "read", status, detail });
841
+ }
842
+
745
843
  for (const { agentName: quarantinedName, result } of activityTombstones) {
844
+ if (result.status === "occupied" && result.tombstone.phase === "persistence_closed"
845
+ && runningAgents.some(agent => agent.name === quarantinedName && agent.runId === result.tombstone.runId)) {
846
+ const entryIndex = entries.findIndex(entry => entry.name === quarantinedName);
847
+ footerStatuses[entryIndex] += " · Finishing cleanup";
848
+ continue;
849
+ }
746
850
  const persistedMember = activityMembers.find(member => member.name === quarantinedName);
747
851
  const role = result.status === "occupied"
748
852
  ? result.tombstone.role
749
853
  : persistedMember ? memberActivityRole(persistedMember) : "read";
750
- const detail = result.status === "occupied"
751
- ? `inactive · run ${result.tombstone.runId} · ${result.tombstone.phase}`
752
- : `inactive · corrupt lifecycle tombstone · ${result.error}`;
753
- countStatus("quarantined");
754
- entries.push({ name: quarantinedName, role, status: "quarantined", detail });
854
+ const projected = projectLifecycleFence(persistedMember ?? {}, result);
855
+ const reason = projected.error || (result.status === "occupied" && result.tombstone.phase === "timed_out" ? "cleanup timed out" : "cleanup failed");
856
+ const detail = projected.phase === "stopping"
857
+ ? "Finishing cleanup"
858
+ : `Cleanup blocked: ${reason} · use check_teammate`;
859
+ countStatus(projected.phase);
860
+ entries.push({ name: quarantinedName, role, status: projected.phase, detail });
755
861
  }
756
862
 
757
863
  wakeLeadForIdleReadAgents(idleNudgeMessages);
@@ -762,8 +868,8 @@ export default function (pi: ExtensionAPI) {
762
868
  sessionCtx.ui.setWidget?.("01-pi-extended-teams-status", undefined);
763
869
  updateTeamActivityWidget({
764
870
  activeCount,
765
- readCount: readAgents.length + runtimeOnlyReadMembers.length,
766
- writeCount: writeAgents.length + runtimeOnlyWriteMembers.length + activeWriteMembers.length,
871
+ readCount: readAgents.length + runtimeOnlyReadMembers.length + unobservedMembers.filter(({ member }) => memberActivityRole(member) === "read").length,
872
+ writeCount: writeAgents.length + runtimeOnlyWriteMembers.length + activeWriteMembers.length + unobservedMembers.filter(({ member }) => memberActivityRole(member) === "write").length,
767
873
  unreadCount: leadInboxUnreadCount,
768
874
  entries,
769
875
  statusCounts,
@@ -795,12 +901,13 @@ export default function (pi: ExtensionAPI) {
795
901
  }
796
902
 
797
903
  async function drainReadHelperQueueOnce() {
798
- if (isTeammate || !sessionCtx || !teamName) return;
904
+ if (isTeammate || !sessionCtx || !teamName || (host && !host.ready)) return;
799
905
  if (readHelperQueueDraining) {
800
906
  readHelperQueueDrainPending = true;
801
907
  return;
802
908
  }
803
909
  readHelperQueueDraining = true;
910
+ const settled = trackRootAdmission();
804
911
  try {
805
912
  for (let drained = 0; drained < 10 && teamName; drained++) {
806
913
  const [nextQueued] = await listReadHelperQueue(teamName);
@@ -811,6 +918,8 @@ export default function (pi: ExtensionAPI) {
811
918
 
812
919
  try {
813
920
  const config = await teams.readConfig(queued.teamName);
921
+ const launchContext = sessionCtx;
922
+ if (!launchContext || (host && !host.ready)) return;
814
923
  if (config.members.some(member => member.name === queued.name)) {
815
924
  throw new Error(`Teammate ${queued.name} already exists in team ${queued.teamName}.`);
816
925
  }
@@ -819,7 +928,7 @@ export default function (pi: ExtensionAPI) {
819
928
  const parsedModel = parseQualifiedModel(level.model);
820
929
  const provider = parsedModel?.provider;
821
930
  const modelId = parsedModel?.model;
822
- const model = provider && modelId ? sessionCtx.modelRegistry?.find?.(provider, modelId) : undefined;
931
+ const model = provider && modelId ? launchContext.modelRegistry?.find?.(provider, modelId) : undefined;
823
932
  if (!model) throw new Error(`Read helper model \"${level.model}\" from intent tier ${level.slot} is not available in the lead session.`);
824
933
 
825
934
  const helperPrompt = buildReadHelperPrompt(queued.teamName, queued.requester, queued.prompt);
@@ -841,9 +950,15 @@ export default function (pi: ExtensionAPI) {
841
950
  helperKind: "read_helper",
842
951
  };
843
952
 
953
+ member.lifecycleRunId = generateLifecycleRunId();
844
954
  await teams.addMember(queued.teamName, member);
955
+ if (executionClosing) {
956
+ await shutdownTeammate(queued.teamName, member);
957
+ await removeQueuedReadHelperRequest(queued.teamName, queued.id);
958
+ break;
959
+ }
845
960
  observeReadAgentLaunch(
846
- runInProcessAgentWithSleepAssertion(queued.teamName, member, helperPrompt, sessionCtx, readAgentOptions())
961
+ runInProcessAgentWithSleepAssertion(queued.teamName, member, helperPrompt, launchContext, readAgentOptions())
847
962
  );
848
963
  await removeQueuedReadHelperRequest(teamName, queued.id);
849
964
  } catch (e) {
@@ -896,6 +1011,7 @@ export default function (pi: ExtensionAPI) {
896
1011
  }
897
1012
  }
898
1013
  } finally {
1014
+ settled();
899
1015
  readHelperQueueDraining = false;
900
1016
  if (readHelperQueueDrainPending) {
901
1017
  readHelperQueueDrainPending = false;
@@ -948,10 +1064,13 @@ export default function (pi: ExtensionAPI) {
948
1064
 
949
1065
  leadInboxPollTimer = setInterval(async () => {
950
1066
  if (!teamName) return;
1067
+ const generation = presentationGeneration;
951
1068
  try {
952
1069
  const unread = await messaging.readInbox(teamName, agentName, true, false);
1070
+ if (generation !== presentationGeneration) return;
953
1071
  await renderLeadInboxStatus();
954
- wakeLeadForInboxReports(unread);
1072
+ if (generation !== presentationGeneration) return;
1073
+ await wakeLeadForInboxReports(unread);
955
1074
  } catch {
956
1075
  // Ignore errors for lead polling
957
1076
  }
@@ -965,10 +1084,12 @@ export default function (pi: ExtensionAPI) {
965
1084
  function adoptTeamAsLead(name: string, ctx?: any): void {
966
1085
  if (isTeammate || !name) return;
967
1086
  const sessionId = getPiSessionId(ctx ?? sessionCtx);
1087
+ registerLeadSession(name, sessionId);
1088
+ // Private jobs retain cleanup ownership, not the public roster/inbox/UI scope.
1089
+ if (name.startsWith("prompt-build-")) return;
968
1090
  if (teamName !== name) {
969
1091
  teamName = name;
970
1092
  }
971
- registerLeadSession(name, sessionId);
972
1093
  ensureReadAgentStatusTicker();
973
1094
  startLeadInboxPolling();
974
1095
  startReadHelperQueueDraining();
@@ -978,11 +1099,12 @@ export default function (pi: ExtensionAPI) {
978
1099
  pi.events?.on?.("pi-prompt:prompt-build:start", async (payload: any) => {
979
1100
  if (isTeammate || !sessionCtx) return;
980
1101
  const requestedTeamName = teamPaths.sanitizeName(payload?.teamName || `prompt-build-${Date.now()}`);
981
-
1102
+ const launchContext = sessionCtx;
1103
+ const settled = trackRootAdmission();
982
1104
  try {
983
1105
  const prompts = Array.isArray(payload?.prompts) ? payload.prompts : [];
984
1106
  activePromptBuildTeamName = requestedTeamName;
985
- const cwd = payload?.cwd || sessionCtx.cwd;
1107
+ const cwd = payload?.cwd || launchContext.cwd;
986
1108
  const agentNamePrefix = teamPaths.sanitizeName(payload?.agentNamePrefix || "prompt-branch");
987
1109
  if (payload?.model || payload?.thinking || payload?.role) {
988
1110
  throw new Error("Prompt-build agents must use model_slot only; direct model, thinking, or role is not allowed.");
@@ -992,7 +1114,7 @@ export default function (pi: ExtensionAPI) {
992
1114
  if (level.role !== "read") throw new Error(`Prompt-build requires a read-* intent tier configured via /agents-favorite-models, got ${level.slot}.`);
993
1115
 
994
1116
  if (!teams.teamExists(requestedTeamName)) {
995
- teams.createTeam(requestedTeamName, getPiSessionId(sessionCtx) || "local-session", "lead-agent", payload?.description || "pi-prompt prompt-build", level.model);
1117
+ teams.createTeam(requestedTeamName, getPiSessionId(launchContext) || "local-session", "lead-agent", payload?.description || "pi-prompt prompt-build", level.model);
996
1118
  }
997
1119
  // Prompt-build teams are private fanout jobs owned by pi-prompt. Do not
998
1120
  // adopt them as the lead's current team, or normal session context and
@@ -1017,21 +1139,25 @@ export default function (pi: ExtensionAPI) {
1017
1139
  thinking: level.thinking,
1018
1140
  modelSlot: level.slot,
1019
1141
  };
1142
+ if (executionClosing) break;
1143
+ member.lifecycleRunId = generateLifecycleRunId();
1020
1144
  await teams.addMember(requestedTeamName, member);
1145
+ if (executionClosing) { await shutdownTeammate(requestedTeamName, member); break; }
1021
1146
  observeReadAgentLaunch(
1022
- runInProcessAgentWithSleepAssertion(requestedTeamName, member, prompt, sessionCtx, readAgentOptions())
1147
+ runInProcessAgentWithSleepAssertion(requestedTeamName, member, prompt, launchContext, readAgentOptions())
1023
1148
  );
1024
1149
  pi.events?.emit?.("pi-prompt:prompt-build:progress", { teamName: requestedTeamName, status: "spawned", started: i + 1, total: prompts.length, text: `building prompt — ${i + 1}/${prompts.length} branches started` });
1025
1150
  }
1026
1151
  } catch (err) {
1027
1152
  const message = err instanceof Error ? err.message : String(err);
1028
1153
  pi.events?.emit?.("pi-prompt:prompt-build:error", { teamName: requestedTeamName, error: message });
1029
- }
1154
+ } finally { settled(); }
1030
1155
  });
1031
1156
 
1032
1157
  function setSessionCtx(ctx: any): void {
1158
+ if (host && !host.attached) return;
1033
1159
  extensionShuttingDown = false;
1034
- sessionCtx = ctx;
1160
+ sessionCtx = host?.context ?? ctx;
1035
1161
  if (!isTeammate && !teamName) {
1036
1162
  const foundTeam = findLeadTeamForSession(getPiSessionId(ctx));
1037
1163
  if (foundTeam) adoptTeamAsLead(foundTeam, ctx);
@@ -1065,7 +1191,10 @@ export default function (pi: ExtensionAPI) {
1065
1191
  }
1066
1192
  }
1067
1193
 
1068
- async function cleanupExtensionRuntime(ctx: any, reason: unknown): Promise<void> {
1194
+ function detachPresentation(): void {
1195
+ presentationGeneration++;
1196
+ teamActivityRenderInFlight = false;
1197
+ navigationRuntimeAgents.clear();
1069
1198
  extensionShuttingDown = true;
1070
1199
  sessionCtx = null;
1071
1200
  stopLeadWatchdog();
@@ -1081,16 +1210,24 @@ export default function (pi: ExtensionAPI) {
1081
1210
  readHelperQueueFallbackTimer = null;
1082
1211
  readHelperQueueWatcher = null;
1083
1212
  leadPollingStarted = false;
1084
- leadWakeNotifiedMessageKeys.clear();
1085
1213
  readHelperQueueFallbackStarted = false;
1086
1214
  readHelperQueueWatchedTeam = null;
1087
- lifecycleFenceClearUnsubscribe();
1088
1215
  teamActivityRenderDirty = false;
1089
1216
  settleTeamActivityRenderWaiters();
1217
+ teamActivityStatusSnapshot = null;
1218
+ teamActivityStatusSnapshotSignature = null;
1219
+ teamActivityWidgetMounted = false;
1220
+ teamActivityWidgetTui = null;
1221
+ }
1090
1222
 
1223
+ async function cleanupExtensionRuntime(ctx: any, reason: unknown): Promise<void> {
1224
+ detachPresentation();
1225
+ leadWakeNotifiedMessageKeys.clear();
1226
+ lifecycleFenceClearUnsubscribe();
1091
1227
  try {
1092
1228
  await stopRunningAgentsForShutdown(reason);
1093
1229
  } finally {
1230
+ combinedSessionCost?.deactivate();
1094
1231
  activeAgentSleepController.dispose();
1095
1232
  }
1096
1233
 
@@ -1121,14 +1258,23 @@ export default function (pi: ExtensionAPI) {
1121
1258
  });
1122
1259
 
1123
1260
  if (!isTeammate) {
1261
+ pi.on("context", async (_event: any, ctx: any) => { await observeGroupHistory(ctx); });
1124
1262
  pi.on("session_start", async (_event: any, ctx: any) => {
1263
+ await observeGroupHistory(ctx);
1264
+ if (teamName) {
1265
+ try {
1266
+ const config = await teams.readConfig(teamName);
1267
+ for (const error of await recoverCompletionGroups(teamName, getPiSessionId(ctx) ?? "", config.members, sessionEntries(ctx), currentCompletionGroups)) warnGroup(error);
1268
+ await requestGroupInboxWakes(await messaging.peekInbox(teamName, agentName, true));
1269
+ } catch (error) { warnGroup(error); }
1270
+ }
1125
1271
  installAgentNavigation(ctx, {
1126
1272
  getAgents: () => {
1127
1273
  const readers = Array.from(runningReadAgents.values())
1128
1274
  .filter(agent => !teamName || agent.teamName === teamName);
1129
- return [...readers, ...Array.from(navigationRuntimeAgents.values())
1130
- .filter(agent => !teamName || agent.teamName === teamName)]
1131
- .filter((agent, index, agents) => agents.findIndex(candidate => candidate.name === agent.name) === index);
1275
+ const runtimeAgents = Array.from(navigationRuntimeAgents.values())
1276
+ .filter(agent => !teamName || agent.teamName === teamName);
1277
+ return orderAgentNavigationEntries(readers, runtimeAgents);
1132
1278
  },
1133
1279
  interruptAgent: async (name: string) => {
1134
1280
  const result = await interruptTeammate(name);
@@ -1188,6 +1334,8 @@ export default function (pi: ExtensionAPI) {
1188
1334
 
1189
1335
  registerFavoriteModelsCommand(pi);
1190
1336
  registerExtensionsCommand(pi);
1337
+ if (!isTeammate) registerOnboardingCommand(pi);
1338
+ if (!isTeammate) registerCheckpointsCommand(pi);
1191
1339
 
1192
1340
  registerWriterScreenShortcut(pi, {
1193
1341
  getTeamName: () => teamName,
@@ -1218,7 +1366,11 @@ export default function (pi: ExtensionAPI) {
1218
1366
  createNestedReadAgentTools: (binding: Parameters<TeamToolsRuntime["createNestedReadAgentTools"]>[0]) => {
1219
1367
  return teamToolsRuntime?.createNestedReadAgentTools(binding) ?? [];
1220
1368
  },
1369
+ nestedChildSnapshot: (binding: Parameters<TeamToolsRuntime["nestedChildSnapshot"]>[0]) => {
1370
+ return teamToolsRuntime?.nestedChildSnapshot(binding) ?? { running: 0, queued: 0 };
1371
+ },
1221
1372
  pendingChildController,
1373
+ beginCostRun: combinedSessionCost?.begin,
1222
1374
  };
1223
1375
  }
1224
1376
 
@@ -1233,6 +1385,7 @@ export default function (pi: ExtensionAPI) {
1233
1385
  startWriteAgent,
1234
1386
  shutdownTeammate,
1235
1387
  adoptTeamAsLead,
1388
+ onCompletionGroupUse: groupId => { currentCompletionGroups.add(groupId); },
1236
1389
  buildRoster,
1237
1390
  isTeammate,
1238
1391
  agentName,
@@ -1240,6 +1393,8 @@ export default function (pi: ExtensionAPI) {
1240
1393
  getSessionCtx: () => sessionCtx,
1241
1394
  setSessionCtx,
1242
1395
  pendingChildController,
1396
+ isHostAttached: () => !host || host.ready,
1397
+ waitForHost: () => host?.whenAttached() ?? Promise.resolve(),
1243
1398
  });
1244
1399
 
1245
1400
  if (isTeammate) {
@@ -1251,7 +1406,7 @@ export default function (pi: ExtensionAPI) {
1251
1406
  }));
1252
1407
  }
1253
1408
 
1254
- registerCoordinationTools(pi, {
1409
+ const coordination: Parameters<typeof registerCoordinationTools>[1] = {
1255
1410
  agentName,
1256
1411
  isTeammate,
1257
1412
  terminal,
@@ -1266,9 +1421,10 @@ export default function (pi: ExtensionAPI) {
1266
1421
  resetLeadWakeNotifiedCount: () => { leadWakeNotifiedMessageKeys.clear(); },
1267
1422
  deliverMessageToActiveAgent,
1268
1423
  extensionInstanceId,
1269
- });
1424
+ };
1425
+ if (isTeammate) registerCoordinationTools(pi, coordination);
1270
1426
 
1271
- registerTaskRuntimeTools(pi, {
1427
+ const taskTools: Parameters<typeof registerTaskRuntimeTools>[1] = {
1272
1428
  isTeammate,
1273
1429
  terminal,
1274
1430
  runningReadAgents,
@@ -1277,6 +1433,47 @@ export default function (pi: ExtensionAPI) {
1277
1433
  cancelQueuedAgent: (targetTeamName, targetAgentName) => teamToolsRuntime?.cancelQueuedAgent(targetTeamName, targetAgentName) ?? false,
1278
1434
  shutdownTeammate,
1279
1435
  getTeamName: () => teamName,
1280
- });
1281
-
1436
+ };
1437
+ if (isTeammate) registerTaskRuntimeTools(pi, taskTools);
1438
+
1439
+ return {
1440
+ host: host!, coordination, taskTools,
1441
+ hasWork: () => runningReadAgents.size > 0 || activeWritersTabs.length > 0 || pendingRootAdmissions.size > 0 || teamToolsRuntime?.hasPendingWork() === true,
1442
+ async attach(binding) {
1443
+ host!.attach(binding);
1444
+ const ctx = host!.snapshot(binding.ctx);
1445
+ const costAttached = combinedSessionCost?.attach(binding.pi, ctx);
1446
+ await host!.emit("session_start", binding.event, ctx);
1447
+ if (!host!.attached) return;
1448
+ if (!costAttached) combinedSessionCost?.attach(binding.pi, ctx);
1449
+ },
1450
+ activate() {
1451
+ host!.flush();
1452
+ teamToolsRuntime?.resumeQueues();
1453
+ void drainReadHelperQueueOnce();
1454
+ if (teamName) void notifyLeadOfInboxReports(teamName).catch(warnGroup);
1455
+ },
1456
+ detach() {
1457
+ sessionCtx?.ui?.setWidget?.("01-pi-extended-teams-readers", undefined);
1458
+ sessionCtx?.ui?.setWidget?.("01-pi-extended-teams-status", undefined);
1459
+ detachPresentation();
1460
+ combinedSessionCost?.detach();
1461
+ host?.detach();
1462
+ },
1463
+ async close(reason) {
1464
+ teamToolsRuntime.closeAdmission();
1465
+ executionClosing = true;
1466
+ sessionCtx?.ui?.setWidget?.("01-pi-extended-teams-readers", undefined);
1467
+ sessionCtx?.ui?.setWidget?.("01-pi-extended-teams-status", undefined);
1468
+ detachPresentation();
1469
+ host?.close();
1470
+ let timer: ReturnType<typeof setTimeout> | undefined;
1471
+ const admissions = Promise.race([
1472
+ Promise.all([...pendingRootAdmissions]),
1473
+ new Promise<never>((_resolve, reject) => { timer = setTimeout(() => reject(new Error("Team admission cleanup is still pending.")), 2_500); }),
1474
+ ]).finally(() => clearTimeout(timer));
1475
+ await Promise.all([host!.emit("session_shutdown", { reason }, undefined), admissions]);
1476
+ if (runningReadAgents.size) throw new Error("Team cleanup is still fenced by unsettled agents.");
1477
+ },
1478
+ };
1282
1479
  }