pi-extended-teams 2.2.9 → 2.3.0

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 (54) 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 +22 -10
  7. package/extensions/index.ts +132 -38
  8. package/extensions/internal/pi-check-operations.ts +24 -0
  9. package/extensions/internal/session-files.ts +10 -2
  10. package/extensions/runtime/teammate-interrupt.ts +11 -5
  11. package/extensions/runtime/types.ts +4 -0
  12. package/extensions/team/lifecycle.ts +26 -0
  13. package/extensions/team/session-cost.ts +155 -0
  14. package/extensions/tools/agent-communication-tools.ts +24 -8
  15. package/extensions/tools/agent-status-tool.ts +50 -7
  16. package/extensions/tools/coordination-tools.ts +78 -8
  17. package/extensions/tools/task-runtime-tools.ts +23 -4
  18. package/extensions/tools/team-tools.ts +287 -40
  19. package/extensions/ui/activity-colors.ts +59 -0
  20. package/extensions/ui/agent-follow-view.ts +22 -12
  21. package/extensions/ui/agent-navigation.ts +35 -1
  22. package/extensions/ui/checkpoints-command.ts +28 -0
  23. package/extensions/ui/extensions-command.ts +5 -1
  24. package/extensions/ui/onboarding-command.ts +218 -0
  25. package/extensions/ui/status-widget.ts +22 -14
  26. package/package.json +1 -1
  27. package/skills/teams.md +16 -0
  28. package/src/orchestration/index.ts +19 -1
  29. package/src/orchestration/status-projection.ts +2 -3
  30. package/src/orchestration/types.ts +9 -1
  31. package/src/results/check-journal.ts +149 -0
  32. package/src/results/check-policy.ts +86 -0
  33. package/src/results/check-runner.ts +110 -0
  34. package/src/results/checkpoint-assignment.ts +60 -0
  35. package/src/results/checkpoint-policy.ts +20 -0
  36. package/src/results/checkpoint-report.ts +109 -0
  37. package/src/results/checkpoint-retention.ts +15 -0
  38. package/src/results/completion-group-delivery.ts +66 -0
  39. package/src/results/completion-group-recovery.ts +61 -0
  40. package/src/results/completion-group-wake.ts +62 -0
  41. package/src/results/completion-group.ts +290 -0
  42. package/src/results/continuation-recipient.ts +37 -0
  43. package/src/results/durable-json.ts +29 -0
  44. package/src/results/repair-policy.ts +24 -0
  45. package/src/results/report-result.ts +77 -0
  46. package/src/results/source-identity.ts +115 -0
  47. package/src/results/specialist-checkpoint.ts +227 -0
  48. package/src/results/verification-controller.ts +308 -0
  49. package/src/utils/messaging.ts +47 -10
  50. package/src/utils/models.ts +13 -0
  51. package/src/utils/paths.ts +4 -0
  52. package/src/utils/report-events.ts +95 -10
  53. package/src/utils/teams.ts +3 -1
  54. package/src/utils/write-queue.ts +3 -0
@@ -97,6 +97,9 @@ export function createWriteAgentRuntime(options: WriteAgentRuntimeOptions) {
97
97
  }
98
98
 
99
99
  async function startWriteAgent(teamName: string, member: Member, prompt: string): Promise<string> {
100
+ if (["checkpoint", "checkpointAssignment", "continueFrom", "continue_from"].some(field => Reflect.get(member, field) !== undefined)) {
101
+ throw new Error("Checkpoint-enabled agents require in-process lead admission; legacy terminal startup is unsupported.");
102
+ }
100
103
  assertWriterUsesConfiguredLevel(member);
101
104
  if (!options.terminal) {
102
105
  throw new Error("pi-extended-teams requires running inside tmux for write agents.");
@@ -9,10 +9,12 @@ import { cleanupAgentSessionFolders, cleanupOrphanedTeams } from "../internal/se
9
9
  import { summarizeSessionUsage } from "../internal/session-usage";
10
10
  import { formatElapsed, formatTokenCount } from "../ui/renderers";
11
11
  import { isWorkflowSpawnedMember } from "../../src/utils/workflow-metadata";
12
- import { FAVORITE_MODEL_SLOTS, loadSettings } from "../../src/utils/settings";
12
+ import { globalSettingsPath, projectSettingsPath } from "../../src/utils/settings";
13
+ import { normalizeRepairPolicy } from "../../src/results/repair-policy";
13
14
  import { generateLifecycleRunId } from "../../src/utils/lifecycle-tombstone";
14
15
  import { cleanupStaleSessionContextReferences } from "../internal/session-context-reference";
15
16
  import { cleanupStalePrivateAgentSessions } from "../internal/agent-session-files";
17
+ import { expireCheckpoints } from "../../src/results/checkpoint-retention";
16
18
 
17
19
  export const LEAD_ORCHESTRATION_GUIDANCE = `\n\npi-extended-teams lead orchestration rules:\n- Choose tiers by the agent's intended outcome, not by vague task importance. read-review is the normal default for focused review, verification, and bounded synthesis.\n- Use read-collect when the lane gathers bounded facts without owning the conclusion. Use read-analyze when it must explain behavior or root cause across connected evidence. Reserve read-critical for irreducible high-stakes security, architecture, concurrency, migration, or data-correctness reasoning.\n- For edits, use write-patch for a narrow localized change, write-feature for a bounded feature with a known design, write-system for a cross-cutting integration/refactor within explicitly claimed files, and write-critical only for high-risk security, concurrency, recovery, migration, or data-integrity changes.\n- Prefer the canonical read-*/write-* tiers. Legacy reading-*/writing-* names are compatibility aliases for this minor release, not intent guidance.\n- A spawned agent owns its assigned lane until it reports, blocks, fails, or the user cancels it. Do not duplicate, take over, test, edit, or synthesize that same lane in parallel; work only on clearly unrelated lanes.\n- When no unrelated work remains, end the turn. The extension resumes you when a report arrives. One get_agent_status snapshot is allowed when current status is needed; do not repeatedly call it, sleep, busy-wait, loop on inbox/status, send nudges, do dummy work, or treat healthy silence as failure.\n- Wait for the actual report before synthesizing. Intervene only on a reported blocker/error, actual health failure, or explicit user cancellation/change.\n- For durable bug, security, or testing claims from an agent report or backlog, concrete, reproducible findings with file/line evidence or a focused failing regression may proceed directly to TDD repair.
18
20
  - Use a separate read-only confirmation only when evidence is missing or weak, the claim is disputed, or irreducible high-risk uncertainty remains; never reconfirm an already confirmed finding.`;
@@ -42,6 +44,13 @@ export function isInboxFileWatchEvent(inboxFile: string, filename: string | Buff
42
44
  return !changedName || changedName === inboxBase || changedName === `${inboxBase}.lock`;
43
45
  }
44
46
 
47
+ function hasPersistedTeamSettings(ctx: any): boolean {
48
+ if (fs.existsSync(globalSettingsPath())) return true;
49
+ return typeof ctx.cwd === "string"
50
+ && ctx.isProjectTrusted?.() === true
51
+ && fs.existsSync(projectSettingsPath(ctx.cwd));
52
+ }
53
+
45
54
  export function registerExtensionEvents(pi: any, options: RegisterEventsOptions): void {
46
55
  let teammateWakeIfUnread: (() => Promise<void>) | null = null;
47
56
  let teammatePendingInboxWake = false;
@@ -163,14 +172,14 @@ export function registerExtensionEvents(pi: any, options: RegisterEventsOptions)
163
172
  } catch {
164
173
  // Session-start janitors are best-effort and must not block initialization.
165
174
  }
166
- const settings = loadSettings({ projectDir: ctx.cwd });
167
- const configuredTiers = FAVORITE_MODEL_SLOTS.filter((slot) => {
168
- const config = settings.favoriteModels[slot];
169
- return !!config?.model && !!config.thinking;
170
- });
171
- if (configuredTiers.length === 0) {
175
+ try {
176
+ for (const diagnostic of await expireCheckpoints()) ctx.ui?.notify?.(diagnostic, "warning");
177
+ } catch (error) {
178
+ ctx.ui?.notify?.(`Checkpoint expiry: ${String(error)}`, "warning");
179
+ }
180
+ if (!hasPersistedTeamSettings(ctx)) {
172
181
  ctx.ui?.notify?.(
173
- "No agent intent tiers are configured. Define them with /agents-favorite-models before spawning agents. See README.md for intent-tier examples.",
182
+ "pi-extended-teams is not configured yet. Run /pi-extended-teams-onboard for agent-led model and shared-extension setup.",
174
183
  "warning"
175
184
  );
176
185
  }
@@ -399,13 +408,16 @@ export function registerExtensionEvents(pi: any, options: RegisterEventsOptions)
399
408
  if (member.thinking) modelInfo += ` with thinking level: ${member.thinking}`;
400
409
  modelInfo += `. When reporting your model or thinking level, use these exact values.`;
401
410
  }
411
+ const repairGuidance = normalizeRepairPolicy(member?.repairPolicy)
412
+ ? "Call report_and_exit when finished. If it returns an unaccepted repair request, remain active, repair only the assigned scope, and resubmit. Report blocked or failed if repair is unsafe. Claims and shutdown wait for an accepted final report."
413
+ : undefined;
402
414
  if ((member?.role ?? "write") === "write") {
403
415
  const workflowGuard = member && isWorkflowSpawnedMember(member)
404
416
  ? "\n- Workflow mode: do not create helper fanout yourself. Ask team-lead with send_message for an explicit workflow assignment."
405
417
  : "\n- If you need read-only help, ask team-lead with send_message. The lead decides whether to spawn another agent.";
406
- roleSpecificGuidance = `\n\nEdit-agent rules:\n- Before editing or writing any repository file, call claim_file with every path you intend to change and wait for the claim to be granted.\n- If claim_file reports conflicts, do not edit those files; coordinate with your lead instead.${workflowGuard}\n- Release claims with release_file as soon as you are done editing those paths.\n- When your work is finished, call report_and_exit. It sends your final report, releases any remaining file claims, and shuts you down. Do not wait for the lead to kill you.`;
418
+ roleSpecificGuidance = `\n\nEdit-agent rules:\n- Before editing or writing any repository file, call claim_file with every path you intend to change and wait for the claim to be granted.\n- If claim_file reports conflicts, do not edit those files; coordinate with your lead instead.${workflowGuard}\n- Release claims with release_file as soon as you are done editing those paths.\n- ${repairGuidance ?? "When your work is finished, call report_and_exit. It sends your final report, releases any remaining file claims, and shuts you down. Do not wait for the lead to kill you."}`;
407
419
  } else {
408
- roleSpecificGuidance = `\n\nRead-agent rules:\n- You are read-only: investigate and report. Do not edit files or make any mutating changes.\n- When finished, produce your final report and stop. Do not wait for the lead to kill you.`;
420
+ roleSpecificGuidance = `\n\nRead-agent rules:\n- You are read-only: investigate and report. Do not edit files or make any mutating changes.\n- ${repairGuidance ?? "When finished, produce your final report and stop. Do not wait for the lead to kill you."}`;
409
421
  }
410
422
  rosterInfo = `\n\n${options.formatRosterForPrompt(await options.buildRoster(teamName))}\nUse this roster as a snapshot. If you need updated roster or liveness details, ask team-lead with send_message. Do not poll.`;
411
423
  } catch {
@@ -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";
@@ -26,11 +29,14 @@ import { createActiveAgentSleepController, runWithActiveAgentSleepAssertion } fr
26
29
  import { createTeammateInterrupter } from "./runtime/teammate-interrupt.js";
27
30
  export { panelBgFill, framePanel, frameWidget, frameWidgetFullWidth, logWindowStart } from "./ui/frame.js";
28
31
  import * as messaging from "../src/utils/messaging";
32
+ import { requestCompletionGroupWake, observeCompletionGroupWakes } from "../src/results/completion-group-wake";
33
+ import { recoverCompletionGroups } from "../src/results/completion-group-recovery";
29
34
  import * as teams from "../src/utils/teams";
30
35
  import * as runtime from "../src/utils/runtime";
31
36
  import * as teamPaths from "../src/utils/paths";
32
37
  import { listReadHelperQueue, removeQueuedReadHelperRequest } from "../src/utils/read-helper-queue";
33
38
  import type { Member } from "../src/utils/models";
39
+ import { projectAgentStatus, projectLifecycleFence } from "../src/orchestration/status-projection";
34
40
  import { getTerminalAdapter } from "../src/adapters/terminal-registry";
35
41
  import { createSpawnResourcePlan, parentProjectTrustForSpawn } from "./resources/spawn-resource-plan.js";
36
42
  import { generateExtensionInstanceId, listLifecycleTombstones, onLifecycleTombstoneCleared, readLifecycleTombstone } from "../src/utils/lifecycle-tombstone";
@@ -40,6 +46,7 @@ export default function (pi: ExtensionAPI) {
40
46
  const agentName = process.env.PI_AGENT_NAME || "team-lead";
41
47
  const extensionInstanceId = generateExtensionInstanceId();
42
48
  const envTeamName = process.env.PI_TEAM_NAME;
49
+ const combinedSessionCost = isTeammate ? undefined : createCombinedSessionCost(pi);
43
50
 
44
51
  // Teammates are explicitly bound by PI_TEAM_NAME. Lead sessions are adopted
45
52
  // later at session_start only when the persisted Pi session id matches.
@@ -172,11 +179,12 @@ export default function (pi: ExtensionAPI) {
172
179
  targetTeamName: string,
173
180
  targetAgentName: string,
174
181
  content: string,
175
- expectedRecipientRunId?: string
182
+ expectedRecipientRunId?: string,
183
+ requireReceipt = false
176
184
  ): Promise<boolean> {
177
185
  const target = runningReadAgents.get(readAgentKey(targetTeamName, targetAgentName));
178
186
  if (expectedRecipientRunId !== undefined && target?.runId !== expectedRecipientRunId) return false;
179
- const delivered = await sendMessageToRunningReadAgent(target, content);
187
+ const delivered = await sendMessageToRunningReadAgent(target, content, requireReceipt);
180
188
  if (delivered) renderReadAgentStatus();
181
189
  return delivered;
182
190
  }
@@ -229,13 +237,6 @@ export default function (pi: ExtensionAPI) {
229
237
  return !!status.lastHeartbeatAt && (now - status.lastHeartbeatAt) <= runtime.HEARTBEAT_STALE_MS;
230
238
  }
231
239
 
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
240
  function isVisibleRuntimeOnlyMember(
240
241
  member: Member,
241
242
  runtimeStatus: runtime.AgentRuntimeStatus | null,
@@ -337,11 +338,11 @@ export default function (pi: ExtensionAPI) {
337
338
  // message with display:false still reaches the model as a user turn (see
338
339
  // convertToLlm) but is never rendered, so team coordination stays silent.
339
340
  // Falls back to a visible user message on older pi builds without sendMessage.
340
- function quietTrigger(content: string): void {
341
+ function quietTrigger(content: string, details?: Record<string, unknown>): void {
341
342
  const api = pi as any;
342
343
  if (typeof api.sendMessage === "function") {
343
344
  api.sendMessage(
344
- { customType: "pi-extended-teams-wake", content, display: false },
345
+ { customType: "pi-extended-teams-wake", content, display: false, ...(details ? { details } : {}) },
345
346
  { triggerTurn: true, deliverAs: "followUp" }
346
347
  );
347
348
  } else {
@@ -378,9 +379,46 @@ export default function (pi: ExtensionAPI) {
378
379
  pi.events?.emit?.("pi-extended-teams:agent-progress", { teamName: progressTeamName, name, status, updatedAt });
379
380
  }
380
381
 
381
- function wakeLeadForInboxReports(unread: any[]): void {
382
- if (!teamName) return;
383
- const keyedUnread = unread.map((message) => ({
382
+ const currentCompletionGroups = new Set<string>();
383
+ const observedWakeEntries = new Set<string>();
384
+ const groupWarnings = new Set<string>();
385
+ const sessionEntries = (ctx: any) => ctx?.sessionManager?.getEntries?.() ?? ctx?.sessionManager?.getBranch?.() ?? [];
386
+ function warnGroup(error: unknown): void {
387
+ const message = error instanceof Error ? error.message : String(error);
388
+ if (!groupWarnings.has(message)) {
389
+ groupWarnings.add(message);
390
+ sessionCtx?.ui?.notify?.(message, "warning");
391
+ }
392
+ }
393
+ async function observeGroupHistory(ctx: any): Promise<void> {
394
+ if (!teamName || extensionShuttingDown) return;
395
+ const target = teamName;
396
+ const entries = sessionEntries(ctx).filter((entry: any) => !observedWakeEntries.has(`${target}:${entry.id}`));
397
+ const result = await observeCompletionGroupWakes(target, getPiSessionId(ctx) ?? "", entries);
398
+ for (const id of result.observed) observedWakeEntries.add(`${target}:${id}`);
399
+ for (const error of result.errors) warnGroup(error);
400
+ }
401
+ async function requestGroupInboxWakes(unread: any[]): Promise<void> {
402
+ if (!teamName || !sessionCtx || extensionShuttingDown) return;
403
+ const target = teamName;
404
+ const ctx = sessionCtx;
405
+ for (const message of unread.filter(item => item.metadata?.completionGroup)) {
406
+ try {
407
+ if (typeof (pi as any).sendMessage !== "function") throw new Error("Grouped wakes require Pi custom messages; use read_inbox for the saved index.");
408
+ await requestCompletionGroupWake(target, getPiSessionId(ctx) ?? "", message, (content, details) => {
409
+ if (extensionShuttingDown || ctx !== sessionCtx) throw new Error("Group wake unconfirmed: the lead session is closing.");
410
+ quietTrigger(content, details);
411
+ });
412
+ } catch (error) { warnGroup(error); }
413
+ }
414
+ }
415
+ async function wakeLeadForInboxReports(unread: any[]): Promise<void> {
416
+ if (!teamName || extensionShuttingDown) return;
417
+ const target = teamName;
418
+ const ctx = sessionCtx;
419
+ await requestGroupInboxWakes(unread);
420
+ if (extensionShuttingDown || target !== teamName || ctx !== sessionCtx) return;
421
+ const keyedUnread = unread.filter(message => !message.metadata?.completionGroup).map((message) => ({
384
422
  message,
385
423
  key: String(message?.id || `${message?.from || "unknown"}:${message?.timestamp || ""}:${message?.text || ""}`),
386
424
  }));
@@ -412,7 +450,7 @@ export default function (pi: ExtensionAPI) {
412
450
  if (isTeammate) return;
413
451
  const unread = await messaging.peekInbox(targetTeamName, agentName, true);
414
452
  await renderLeadInboxStatus();
415
- wakeLeadForInboxReports(unread);
453
+ await wakeLeadForInboxReports(unread);
416
454
  }
417
455
 
418
456
  function getIdleReadAgentNudgeMessage(agent: RunningReadAgent, status: ReadAgentStatusDescription): string | null {
@@ -567,11 +605,12 @@ export default function (pi: ExtensionAPI) {
567
605
 
568
606
  const now = Date.now();
569
607
  const activityTeamName = teamName || null;
570
- const runningAgents = activityTeamName
608
+ const sessionAgents = activityTeamName
571
609
  ? Array.from(runningReadAgents.values())
572
- .filter(agent => agent.teamName === activityTeamName && hasActiveReadAgentLifecycle(agent))
610
+ .filter(agent => agent.teamName === activityTeamName)
573
611
  .sort((a, b) => a.name.localeCompare(b.name))
574
612
  : [];
613
+ const runningAgents = sessionAgents.filter(hasActiveReadAgentLifecycle);
575
614
  const readAgents = runningAgents.filter(agent => (agent.role || "read") === "read");
576
615
  const writeAgents = runningAgents.filter(agent => (agent.role || "read") === "write");
577
616
  const activityConfig = activityTeamName ? await teams.readConfig(activityTeamName).catch(() => null) : null;
@@ -579,17 +618,21 @@ export default function (pi: ExtensionAPI) {
579
618
  const activityTombstones = activityTeamName
580
619
  ? await listLifecycleTombstones(activityTeamName).catch(() => [])
581
620
  : [];
582
- const runtimeOnlyMembers = activityTeamName
583
- ? (await Promise.all(activityMembers
621
+ const unsettledAgents = sessionAgents.filter(agent => !hasActiveReadAgentLifecycle(agent)
622
+ && agent.teardownState !== "finalized"
623
+ && !activityTombstones.some(fence => fence.agentName === agent.name));
624
+ const runtimeOnlyCandidates = activityTeamName
625
+ ? await Promise.all(activityMembers
584
626
  .filter(member => member.name !== "team-lead")
585
627
  .filter(member => !runningReadAgents.has(readAgentKey(activityTeamName, member.name)))
586
628
  .map(async (member) => ({
587
629
  member,
588
630
  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)
631
+ })))
592
632
  : [];
633
+ const runtimeOnlyMembers = runtimeOnlyCandidates
634
+ .filter((entry): entry is { member: Member; runtimeStatus: runtime.AgentRuntimeStatus } => isVisibleRuntimeOnlyMember(entry.member, entry.runtimeStatus, now))
635
+ .filter(({ member, runtimeStatus }) => member.lifecycleRunId === runtimeStatus.lifecycleRunId);
593
636
  const runtimeOnlyMemberNames = new Set(runtimeOnlyMembers.map(({ member }) => member.name));
594
637
  const runtimeOnlyReadMembers = runtimeOnlyMembers.filter(({ member }) => memberActivityRole(member) === "read");
595
638
  const runtimeOnlyWriteMembers = runtimeOnlyMembers.filter(({ member }) => memberActivityRole(member) === "write");
@@ -618,6 +661,11 @@ export default function (pi: ExtensionAPI) {
618
661
  .filter(member => member.name !== "team-lead" && member.isActive !== false && memberActivityRole(member) === "write")
619
662
  .filter(member => !!(member.tmuxPaneId && terminal?.isAlive?.(member.tmuxPaneId)) && !runningReadAgents.has(readAgentKey(activityTeamName, member.name)) && !runtimeOnlyMemberNames.has(member.name)) ?? []
620
663
  : [];
664
+ const unobservedMembers = runtimeOnlyCandidates.filter(({ member }) => member.isActive !== false
665
+ && !runtimeOnlyMemberNames.has(member.name)
666
+ && !activeWriteMembers.some(writer => writer.name === member.name)
667
+ && !activityTombstones.some(fence => fence.agentName === member.name));
668
+ const activeCount = runningAgents.length + runtimeOnlyMembers.length + activeWriteMembers.length + unobservedMembers.length;
621
669
  navigationRuntimeAgents.clear();
622
670
  for (const { member, runtimeStatus } of runtimeOnlyReadMembers) {
623
671
  if (member.lifecycleRunId === runtimeStatus.lifecycleRunId) {
@@ -633,11 +681,12 @@ export default function (pi: ExtensionAPI) {
633
681
  leadInboxUnreadCount = unreadLeadMessages.length;
634
682
  clearLeadInboxWidgetOnce();
635
683
 
636
- if ((readAgents.length > 0 || writeAgents.length > 0 || runtimeOnlyReadMembers.length > 0 || runtimeOnlyWriteMembers.length > 0 || activeWriteMembers.length > 0 || activityTombstones.length > 0) && !readAgentStatusTimer) {
684
+ const hasVisibleAgents = activeCount > 0 || unsettledAgents.length > 0 || activityTombstones.length > 0;
685
+ if (hasVisibleAgents && !readAgentStatusTimer) {
637
686
  readAgentStatusTimer = setInterval(renderReadAgentStatus, 1000);
638
687
  }
639
688
 
640
- if (readAgents.length === 0 && writeAgents.length === 0 && runtimeOnlyReadMembers.length === 0 && runtimeOnlyWriteMembers.length === 0 && activeWriteMembers.length === 0 && activityTombstones.length === 0) {
689
+ if (!hasVisibleAgents) {
641
690
  sessionCtx.ui.setStatus?.("01-pi-extended-teams-read", undefined);
642
691
  clearTeamActivityWidget();
643
692
  sessionCtx.ui.setWidget?.("01-pi-extended-teams-status", undefined);
@@ -648,7 +697,6 @@ export default function (pi: ExtensionAPI) {
648
697
  return;
649
698
  }
650
699
 
651
- const activeCount = readAgents.length + writeAgents.length + runtimeOnlyReadMembers.length + runtimeOnlyWriteMembers.length + activeWriteMembers.length;
652
700
  const entries: TeamActivityStatusEntry[] = [];
653
701
  const footerStatuses: string[] = [];
654
702
  const statusCounts: Record<string, number> = {};
@@ -742,16 +790,43 @@ export default function (pi: ExtensionAPI) {
742
790
  footerStatuses.push(formatAgentProgressStatus(agent.name, agent.model, agent.thinking, agent.modelSlot, elapsed, contextUsage, agent.latestProgress, now));
743
791
  }
744
792
 
793
+ for (const { member, runtimeStatus } of unobservedMembers) {
794
+ const projected = projectAgentStatus({ member, runtime: runtimeStatus, fence: { status: "absent" }, terminalAlive: null, now });
795
+ const telemetry = !projected.runtime ? "runtime telemetry unavailable"
796
+ : runtimeHeartbeatIsRecent(projected.runtime, now) ? "awaiting runtime readiness" : "heartbeat stale";
797
+ countStatus(projected.phase);
798
+ entries.push({ name: member.name, role: memberActivityRole(member), status: projected.phase, detail: telemetry });
799
+ footerStatuses.push(`(${member.name}) ${projected.phase} · ${telemetry}`);
800
+ }
801
+
802
+ for (const agent of unsettledAgents) {
803
+ const status = agent.teardownState === "persistence_failed" ? "persistence-failed"
804
+ : agent.teardownState === "quarantined" ? "quarantined" : "stopping";
805
+ const reason = agent.teardownError instanceof Error ? agent.teardownError.message
806
+ : agent.teardownResult?.error || "cleanup has not settled";
807
+ const detail = status === "stopping" ? "Finishing cleanup" : `Cleanup blocked: ${reason} · use check_teammate`;
808
+ countStatus(status);
809
+ entries.push({ name: agent.name, role: agent.role === "write" ? "write" : "read", status, detail });
810
+ }
811
+
745
812
  for (const { agentName: quarantinedName, result } of activityTombstones) {
813
+ if (result.status === "occupied" && result.tombstone.phase === "persistence_closed"
814
+ && runningAgents.some(agent => agent.name === quarantinedName && agent.runId === result.tombstone.runId)) {
815
+ const entryIndex = entries.findIndex(entry => entry.name === quarantinedName);
816
+ footerStatuses[entryIndex] += " · Finishing cleanup";
817
+ continue;
818
+ }
746
819
  const persistedMember = activityMembers.find(member => member.name === quarantinedName);
747
820
  const role = result.status === "occupied"
748
821
  ? result.tombstone.role
749
822
  : 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 });
823
+ const projected = projectLifecycleFence(persistedMember ?? {}, result);
824
+ const reason = projected.error || (result.status === "occupied" && result.tombstone.phase === "timed_out" ? "cleanup timed out" : "cleanup failed");
825
+ const detail = projected.phase === "stopping"
826
+ ? "Finishing cleanup"
827
+ : `Cleanup blocked: ${reason} · use check_teammate`;
828
+ countStatus(projected.phase);
829
+ entries.push({ name: quarantinedName, role, status: projected.phase, detail });
755
830
  }
756
831
 
757
832
  wakeLeadForIdleReadAgents(idleNudgeMessages);
@@ -762,8 +837,8 @@ export default function (pi: ExtensionAPI) {
762
837
  sessionCtx.ui.setWidget?.("01-pi-extended-teams-status", undefined);
763
838
  updateTeamActivityWidget({
764
839
  activeCount,
765
- readCount: readAgents.length + runtimeOnlyReadMembers.length,
766
- writeCount: writeAgents.length + runtimeOnlyWriteMembers.length + activeWriteMembers.length,
840
+ readCount: readAgents.length + runtimeOnlyReadMembers.length + unobservedMembers.filter(({ member }) => memberActivityRole(member) === "read").length,
841
+ writeCount: writeAgents.length + runtimeOnlyWriteMembers.length + activeWriteMembers.length + unobservedMembers.filter(({ member }) => memberActivityRole(member) === "write").length,
767
842
  unreadCount: leadInboxUnreadCount,
768
843
  entries,
769
844
  statusCounts,
@@ -951,7 +1026,7 @@ export default function (pi: ExtensionAPI) {
951
1026
  try {
952
1027
  const unread = await messaging.readInbox(teamName, agentName, true, false);
953
1028
  await renderLeadInboxStatus();
954
- wakeLeadForInboxReports(unread);
1029
+ await wakeLeadForInboxReports(unread);
955
1030
  } catch {
956
1031
  // Ignore errors for lead polling
957
1032
  }
@@ -965,10 +1040,12 @@ export default function (pi: ExtensionAPI) {
965
1040
  function adoptTeamAsLead(name: string, ctx?: any): void {
966
1041
  if (isTeammate || !name) return;
967
1042
  const sessionId = getPiSessionId(ctx ?? sessionCtx);
1043
+ registerLeadSession(name, sessionId);
1044
+ // Private jobs retain cleanup ownership, not the public roster/inbox/UI scope.
1045
+ if (name.startsWith("prompt-build-")) return;
968
1046
  if (teamName !== name) {
969
1047
  teamName = name;
970
1048
  }
971
- registerLeadSession(name, sessionId);
972
1049
  ensureReadAgentStatusTicker();
973
1050
  startLeadInboxPolling();
974
1051
  startReadHelperQueueDraining();
@@ -1091,6 +1168,7 @@ export default function (pi: ExtensionAPI) {
1091
1168
  try {
1092
1169
  await stopRunningAgentsForShutdown(reason);
1093
1170
  } finally {
1171
+ combinedSessionCost?.deactivate();
1094
1172
  activeAgentSleepController.dispose();
1095
1173
  }
1096
1174
 
@@ -1121,14 +1199,23 @@ export default function (pi: ExtensionAPI) {
1121
1199
  });
1122
1200
 
1123
1201
  if (!isTeammate) {
1202
+ pi.on("context", async (_event: any, ctx: any) => { await observeGroupHistory(ctx); });
1124
1203
  pi.on("session_start", async (_event: any, ctx: any) => {
1204
+ await observeGroupHistory(ctx);
1205
+ if (teamName) {
1206
+ try {
1207
+ const config = await teams.readConfig(teamName);
1208
+ for (const error of await recoverCompletionGroups(teamName, getPiSessionId(ctx) ?? "", config.members, sessionEntries(ctx), currentCompletionGroups)) warnGroup(error);
1209
+ await requestGroupInboxWakes(await messaging.peekInbox(teamName, agentName, true));
1210
+ } catch (error) { warnGroup(error); }
1211
+ }
1125
1212
  installAgentNavigation(ctx, {
1126
1213
  getAgents: () => {
1127
1214
  const readers = Array.from(runningReadAgents.values())
1128
1215
  .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);
1216
+ const runtimeAgents = Array.from(navigationRuntimeAgents.values())
1217
+ .filter(agent => !teamName || agent.teamName === teamName);
1218
+ return orderAgentNavigationEntries(readers, runtimeAgents);
1132
1219
  },
1133
1220
  interruptAgent: async (name: string) => {
1134
1221
  const result = await interruptTeammate(name);
@@ -1188,6 +1275,8 @@ export default function (pi: ExtensionAPI) {
1188
1275
 
1189
1276
  registerFavoriteModelsCommand(pi);
1190
1277
  registerExtensionsCommand(pi);
1278
+ if (!isTeammate) registerOnboardingCommand(pi);
1279
+ if (!isTeammate) registerCheckpointsCommand(pi);
1191
1280
 
1192
1281
  registerWriterScreenShortcut(pi, {
1193
1282
  getTeamName: () => teamName,
@@ -1218,7 +1307,11 @@ export default function (pi: ExtensionAPI) {
1218
1307
  createNestedReadAgentTools: (binding: Parameters<TeamToolsRuntime["createNestedReadAgentTools"]>[0]) => {
1219
1308
  return teamToolsRuntime?.createNestedReadAgentTools(binding) ?? [];
1220
1309
  },
1310
+ nestedChildSnapshot: (binding: Parameters<TeamToolsRuntime["nestedChildSnapshot"]>[0]) => {
1311
+ return teamToolsRuntime?.nestedChildSnapshot(binding) ?? { running: 0, queued: 0 };
1312
+ },
1221
1313
  pendingChildController,
1314
+ beginCostRun: combinedSessionCost?.begin,
1222
1315
  };
1223
1316
  }
1224
1317
 
@@ -1233,6 +1326,7 @@ export default function (pi: ExtensionAPI) {
1233
1326
  startWriteAgent,
1234
1327
  shutdownTeammate,
1235
1328
  adoptTeamAsLead,
1329
+ onCompletionGroupUse: groupId => { currentCompletionGroups.add(groupId); },
1236
1330
  buildRoster,
1237
1331
  isTeammate,
1238
1332
  agentName,
@@ -0,0 +1,24 @@
1
+ import type { CheckOperations } from "../../src/results/check-runner";
2
+ import { loadPiRuntimeApi } from "./pi-runtime-api";
3
+
4
+ export async function loadNativeCheckOperations(suppliedApi?: unknown): Promise<CheckOperations | undefined> {
5
+ const api: unknown = suppliedApi === undefined ? await loadPiRuntimeApi() : suppliedApi;
6
+ if (typeof api !== "object" || api === null) return undefined;
7
+ const factory: unknown = Reflect.get(api, "createLocalBashOperations");
8
+ if (typeof factory !== "function") return undefined;
9
+ const operations: unknown = factory();
10
+ if (typeof operations !== "object" || operations === null) return undefined;
11
+ const exec: unknown = Reflect.get(operations, "exec");
12
+ if (typeof exec !== "function") return undefined;
13
+ return {
14
+ async exec(command, cwd, options) {
15
+ const result: unknown = await Reflect.apply(exec, operations, [command, cwd, options]);
16
+ if (typeof result !== "object" || result === null) throw new Error("Native check returned no exit evidence.");
17
+ const exitCode: unknown = Reflect.get(result, "exitCode");
18
+ if (exitCode !== null && (typeof exitCode !== "number" || !Number.isInteger(exitCode))) {
19
+ throw new Error("Native check returned invalid exit evidence.");
20
+ }
21
+ return { exitCode };
22
+ },
23
+ };
24
+ }
@@ -31,18 +31,22 @@ export function findLeadTeamForSession(sessionId?: string): string | null {
31
31
  const teamsDir = path.dirname(paths.teamDir("__probe__"));
32
32
  if (!fs.existsSync(teamsDir)) return null;
33
33
 
34
+ let latest: { name: string; selectedAt: number } | undefined;
34
35
  for (const teamDir of fs.readdirSync(teamsDir)) {
36
+ if (teamDir.startsWith("prompt-build-")) continue;
35
37
  try {
36
38
  const sessionFile = paths.leadSessionPath(teamDir);
37
39
  if (!fs.existsSync(sessionFile)) continue;
38
40
  const session = JSON.parse(fs.readFileSync(sessionFile, "utf-8"));
39
41
  if (session.sessionId === sessionId && session.pid === process.pid) {
40
- return teamDir;
42
+ const selectedAt = typeof session.startedAt === "number" && Number.isFinite(session.startedAt) ? session.startedAt : 0;
43
+ if (!latest || selectedAt > latest.selectedAt) latest = { name: teamDir, selectedAt };
41
44
  }
42
45
  } catch {
43
46
  // ignore entries that are not valid team names, and invalid session files
44
47
  }
45
48
  }
49
+ return latest?.name ?? null;
46
50
  } catch {
47
51
  // ignore errors
48
52
  }
@@ -50,13 +54,17 @@ export function findLeadTeamForSession(sessionId?: string): string | null {
50
54
  }
51
55
 
52
56
  export function registerLeadSession(teamName: string, sessionId?: string) {
57
+ const previousTeam = findLeadTeamForSession(sessionId);
58
+ const previous = previousTeam ? readJsonFile(paths.leadSessionPath(previousTeam)) : null;
59
+ // Ownership selections stay ordered even when two adoptions share a clock tick.
60
+ const selectedAt = Math.max(Date.now(), typeof previous?.startedAt === "number" && Number.isFinite(previous.startedAt) ? previous.startedAt + 1 : 0);
53
61
  const sessionFile = paths.leadSessionPath(teamName);
54
62
  const dir = path.dirname(sessionFile);
55
63
  fs.mkdirSync(dir, { recursive: true });
56
64
  fs.writeFileSync(sessionFile, JSON.stringify({
57
65
  pid: process.pid,
58
66
  sessionId,
59
- startedAt: Date.now(),
67
+ startedAt: selectedAt,
60
68
  }));
61
69
  }
62
70
 
@@ -12,7 +12,7 @@ export interface TeammateInterruptResult {
12
12
  message: string;
13
13
  agentKind?: "read" | "write";
14
14
  lifecycleRunId?: string;
15
- mechanism?: "agent-session-abort" | "tmux-escape";
15
+ mechanism?: "agent-session-abort" | "assigned-check-abort" | "tmux-escape";
16
16
  reason?: "no-active-session" | "session-not-created" | "lifecycle-closing"
17
17
  | "missing-pane" | "missing-runtime-proof" | "terminal-interrupt-unavailable";
18
18
  error?: string;
@@ -73,15 +73,18 @@ async function interruptReadAgent(
73
73
  }
74
74
 
75
75
  const generation = state.activeOperationGeneration;
76
+ const checkOperation = state.checkOperation;
76
77
  const toolName = state.activeToolName?.trim();
77
- if (!generation || !toolName || state.status !== "working" || !state.session.isStreaming) {
78
+ if (!toolName || state.status !== "working" || (!checkOperation && (!generation || !state.session.isStreaming))) {
78
79
  return result("no_command", state.name, `Agent ${state.name} has no running tool command to interrupt.`, fields);
79
80
  }
80
81
  if (state.operationInterruptPromise) {
81
82
  return result("pending", state.name, `An interrupt is already pending for ${state.name}'s running ${toolName} command.`, fields);
82
83
  }
83
84
 
84
- const operationSettlement = state.activeOperationSettlementPromise;
85
+ const operationSettlement = checkOperation
86
+ ? Promise.all([checkOperation.settled, state.activeOperationSettlementPromise]).then(() => {})
87
+ : state.activeOperationSettlementPromise;
85
88
  if (!operationSettlement) {
86
89
  return result("no_command", state.name, `Agent ${state.name} has no running tool command to interrupt.`, fields);
87
90
  }
@@ -89,7 +92,10 @@ async function interruptReadAgent(
89
92
  state.interruptRequestedGeneration = generation;
90
93
  let nativeAbort: Promise<void>;
91
94
  try {
92
- nativeAbort = Promise.resolve(state.session.abort());
95
+ checkOperation?.controller.abort();
96
+ nativeAbort = checkOperation && !state.session.isStreaming
97
+ ? Promise.resolve()
98
+ : Promise.resolve(state.session.abort());
93
99
  } catch (error) {
94
100
  if (state.interruptRequestedGeneration === generation) state.interruptRequestedGeneration = undefined;
95
101
  const message = errorText(error);
@@ -130,7 +136,7 @@ async function interruptReadAgent(
130
136
  });
131
137
  }
132
138
  return result("interrupted", state.name, `Interrupted ${state.name}'s running ${toolName} command. The agent is still active and can receive a follow-up message.`, {
133
- ...fields, mechanism: "agent-session-abort",
139
+ ...fields, mechanism: checkOperation ? "assigned-check-abort" : "agent-session-abort",
134
140
  });
135
141
  }
136
142
 
@@ -1,4 +1,5 @@
1
1
  import type { AgentSession } from "@mariozechner/pi-coding-agent";
2
+ import type { ReportResult } from "../../src/results/report-result";
2
3
  import type { ContextUsageSnapshot, RuntimeError } from "../../src/utils/runtime";
3
4
  import type { ManagedReadAgentLifecycleState } from "../agents/read-agent-session-lifecycle";
4
5
 
@@ -46,6 +47,8 @@ export interface RunningReadAgent extends ManagedReadAgentLifecycleState {
46
47
  cleanupPrivateSessionOnFinalize?: boolean;
47
48
  /** Fail finalization closed so runtime, member, fence, and transcript remain recoverable. */
48
49
  finalizationBlockedReason?: string;
50
+ /** Optional accounting observer, called only after exact successful lifecycle settlement. */
51
+ onCostSettled?(): void;
49
52
  session?: AgentSession;
50
53
  moveToHerdr?(): Promise<void>;
51
54
  finished?: Promise<void>;
@@ -63,6 +66,7 @@ export interface CompletedAgentReport {
63
66
  name: string;
64
67
  role: string;
65
68
  status: "completed" | "failed";
69
+ result?: ReportResult;
66
70
  report: string;
67
71
  summary?: string;
68
72
  completedAt: number;
@@ -4,6 +4,12 @@ import * as paths from "../../src/utils/paths";
4
4
  import * as runtime from "../../src/utils/runtime";
5
5
  import * as messaging from "../../src/utils/messaging";
6
6
  import * as teams from "../../src/utils/teams";
7
+ import { readStoredTeamReportEvent } from "../../src/utils/report-events";
8
+ import { resyncReportCheckpoint } from "../../src/results/checkpoint-report";
9
+ import { createReportResult } from "../../src/results/report-result";
10
+ import { VerificationController } from "../../src/results/verification-controller";
11
+ import { CompletionGroup } from "../../src/results/completion-group";
12
+ import { enqueueCompletionGroupDeliveries } from "../../src/results/completion-group-delivery";
7
13
  import { loadSettings } from "../../src/utils/settings";
8
14
  import type { Member } from "../../src/utils/models";
9
15
  import type { RunningReadAgent } from "../runtime/types";
@@ -259,6 +265,25 @@ export function createLifecycleRuntime(options: LifecycleRuntimeOptions) {
259
265
  return;
260
266
  }
261
267
 
268
+ const bound = createReportResult(teamName, member.name, expectedRunId, {});
269
+ const report = await readStoredTeamReportEvent(teamName, bound.reportId);
270
+ const result = { ...bound, verification: report?.result?.verification ?? bound.verification, repair: report?.result?.repair };
271
+ const candidate = currentMember ?? member;
272
+ const controller = new VerificationController({ teamName, result, cwd: candidate.cwd,
273
+ checks: candidate.assignedChecks, repair: candidate.repairPolicy });
274
+ if (candidate.repairPolicy || result.repair || fs.existsSync(controller.journalPath)) {
275
+ await controller.cancelAndRequireSettled();
276
+ }
277
+ if ((candidate.checkpointAssignment || member.checkpointAssignment) && !report?.checkpoint) throw new Error("Checkpoint report provenance is unavailable.");
278
+ if (report?.checkpoint) await resyncReportCheckpoint(report);
279
+ const groupBinding = candidate.completionGroup ?? member.completionGroup ?? report?.completionGroup;
280
+ if (groupBinding) {
281
+ const group = new CompletionGroup(teamName, groupBinding.groupId);
282
+ if (report?.result) await group.recordReport(groupBinding.slotId, report.result, report.status);
283
+ else await group.apply({ type: reason === "quit" ? "cancelled" : "interrupted", ...groupBinding,
284
+ runId: expectedRunId, reason: `Agent lifecycle ended (${reason}) without a final report.` });
285
+ await enqueueCompletionGroupDeliveries(group);
286
+ }
262
287
  releasedClaims = await releaseClaims(teamName, member.name);
263
288
 
264
289
  // Private transcript cleanup is the first destructive recovery step.
@@ -327,6 +352,7 @@ export function createLifecycleRuntime(options: LifecycleRuntimeOptions) {
327
352
  if (drainQueue && (member.role ?? "write") === "write") {
328
353
  void options.drainWriteQueue(teamName).catch(() => {});
329
354
  }
355
+ try { expectedState?.onCostSettled?.(); } catch { /* Accounting cannot alter settlement. */ }
330
356
  try {
331
357
  options.onTeammateSettled?.(teamName, { ...member, lifecycleRunId: expectedRunId });
332
358
  } catch {