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
@@ -0,0 +1,155 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ export const COST_ENTRY_TYPE = "pi-extended-teams-cost-v1";
4
+ export type CostOutcome = "completed" | "failed" | "cancelled";
5
+ export interface AgentCostSnapshot { childSessionId: string; costUsd: number | null }
6
+ export interface CostRun {
7
+ settle(snapshot: AgentCostSnapshot | undefined, outcome: CostOutcome): void;
8
+ exclude(): void;
9
+ }
10
+ interface Receipt {
11
+ rootSessionId: string;
12
+ teamName: string;
13
+ lifecycleRunId: string;
14
+ phase: "pending" | "final" | "excluded";
15
+ childSessionId?: string;
16
+ costUsd?: number | null;
17
+ outcome?: CostOutcome;
18
+ }
19
+ const object = (value: unknown): Record<string, unknown> =>
20
+ value !== null && typeof value === "object" ? value as Record<string, unknown> : {};
21
+ const validCost = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
22
+ const identity = (receipt: Receipt) => JSON.stringify([receipt.rootSessionId, receipt.teamName, receipt.lifecycleRunId]);
23
+
24
+ export function recordedSessionCost(entries: readonly unknown[]): { usd: number; complete: boolean } {
25
+ let usd = 0;
26
+ let complete = true;
27
+ for (const item of entries) {
28
+ const entry = object(item);
29
+ const message = object(entry.message);
30
+ const summary = entry.type === "compaction" || entry.type === "branch_summary";
31
+ const assistant = entry.type === "message" && message.role === "assistant";
32
+ const tool = entry.type === "message" && message.role === "toolResult";
33
+ if (!summary && !assistant && !tool) continue;
34
+ const usage = summary ? entry.usage : message.usage;
35
+ if (tool && usage === undefined) continue; // Optional native nested-tool accounting.
36
+ const cost = object(object(usage).cost).total;
37
+ if (validCost(cost) && validCost(usd + cost)) usd += cost;
38
+ else complete = false; // Includes legacy summaries without recorded usage.
39
+ }
40
+ return { usd, complete };
41
+ }
42
+
43
+ export function createCombinedSessionCost(pi: Pick<ExtensionAPI, "on" | "appendEntry" | "events">) {
44
+ let scope: { root: string; ctx: ExtensionContext; failed: Set<string>; pending: Map<string, Receipt> } | undefined;
45
+ const receipts = (entries: readonly unknown[]) => {
46
+ const runs = new Map<string, Receipt>();
47
+ let complete = true;
48
+ for (const item of entries) {
49
+ const entry = object(item);
50
+ if (entry.type !== "custom" || entry.customType !== COST_ENTRY_TYPE) continue;
51
+ const data = object(entry.data);
52
+ if (![data.rootSessionId, data.teamName, data.lifecycleRunId].every(value => typeof value === "string" && value.length > 0)
53
+ || !["pending", "final", "excluded"].includes(String(data.phase))
54
+ || (data.phase === "final" && (!["completed", "failed", "cancelled"].includes(String(data.outcome))
55
+ || !(data.costUsd === null || (validCost(data.costUsd) && typeof data.childSessionId === "string" && data.childSessionId.length > 0))))) {
56
+ complete = false;
57
+ continue;
58
+ }
59
+ const receipt = data as unknown as Receipt;
60
+ const key = identity(receipt);
61
+ if (!runs.has(key) || runs.get(key)?.phase === "pending") runs.set(key, receipt);
62
+ }
63
+ return { runs, complete };
64
+ };
65
+ const total = () => {
66
+ try {
67
+ if (!scope || scope.ctx.sessionManager.getSessionId() !== scope.root) return { usd: 0, complete: false };
68
+ const entries = scope.ctx.sessionManager.getEntries();
69
+ const own = recordedSessionCost(entries);
70
+ const replay = receipts(entries);
71
+ let complete = own.complete && replay.complete && scope.failed.size === 0;
72
+ let usd = own.usd;
73
+ const sessions = new Set([scope.root]);
74
+ for (const [key, receipt] of new Map([...replay.runs, ...scope.pending])) {
75
+ if (receipt.phase === "excluded") continue;
76
+ if (scope.failed.has(key) || receipt.phase !== "final" || !validCost(receipt.costUsd) || !receipt.childSessionId) {
77
+ complete = false;
78
+ continue;
79
+ }
80
+ if (sessions.has(receipt.childSessionId)) continue;
81
+ sessions.add(receipt.childSessionId);
82
+ if (validCost(usd + receipt.costUsd)) usd += receipt.costUsd;
83
+ else complete = false;
84
+ }
85
+ return { usd, complete };
86
+ } catch { return { usd: 0, complete: false }; }
87
+ };
88
+ let displayTotal: ReturnType<typeof recordedSessionCost> | undefined;
89
+ // Synchronous display-only query: native usage and durable receipts stay untouched.
90
+ const unsubscribe = pi.events.on("pi-extended-teams:cost-request", (value) => {
91
+ const request = object(value);
92
+ if (scope && request.sessionId === scope.root && scope.ctx.sessionManager.getSessionId() === scope.root) {
93
+ request.result = displayTotal;
94
+ }
95
+ });
96
+ const refresh = () => {
97
+ displayTotal = scope ? total() : undefined;
98
+ try { pi.events.emit("pi-extended-teams:cost-changed", undefined); }
99
+ catch { /* Display observers must not affect lifecycle cleanup. */ }
100
+ };
101
+ pi.on("session_start", (_event, ctx) => {
102
+ scope = { root: ctx.sessionManager.getSessionId(), ctx, failed: new Set(), pending: new Map() };
103
+ refresh();
104
+ });
105
+ pi.on("turn_end", refresh);
106
+ pi.on("agent_end", refresh);
107
+ pi.on("session_compact", refresh);
108
+ pi.on("session_tree", refresh);
109
+ return {
110
+ total,
111
+ deactivate() {
112
+ scope = undefined;
113
+ unsubscribe();
114
+ refresh();
115
+ },
116
+ begin(rootSessionId: string, teamName: string, lifecycleRunId: string): CostRun {
117
+ const origin = scope;
118
+ if (!origin || origin.root !== rootSessionId) return { settle() {}, exclude() {} };
119
+ const pending: Receipt = { rootSessionId, teamName, lifecycleRunId, phase: "pending" };
120
+ const key = identity(pending);
121
+ const active = () => scope === origin && origin.ctx.sessionManager.getSessionId() === rootSessionId;
122
+ const append = (receipt: Receipt) => {
123
+ try {
124
+ if (!active()) return false;
125
+ pi.appendEntry(COST_ENTRY_TYPE, receipt);
126
+ origin.failed.delete(key);
127
+ origin.pending.delete(key);
128
+ refresh();
129
+ return true;
130
+ } catch {
131
+ origin.failed.add(key);
132
+ origin.pending.set(key, pending);
133
+ refresh();
134
+ return false;
135
+ }
136
+ };
137
+ try {
138
+ if (!receipts(origin.ctx.sessionManager.getEntries()).runs.has(key)) append(pending);
139
+ } catch { origin.failed.add(key); origin.pending.set(key, pending); refresh(); }
140
+ const finish = (receipt: Receipt) => {
141
+ try {
142
+ if (!active()) return;
143
+ const previous = origin.pending.get(key) ?? receipts(origin.ctx.sessionManager.getEntries()).runs.get(key);
144
+ if (previous?.phase !== "pending") return;
145
+ append(receipt);
146
+ } catch { origin.failed.add(key); refresh(); }
147
+ };
148
+ return {
149
+ settle: (snapshot, outcome) => finish({ ...pending, phase: "final", outcome,
150
+ childSessionId: snapshot?.childSessionId, costUsd: snapshot?.costUsd ?? null }),
151
+ exclude: () => finish({ ...pending, phase: "excluded" }),
152
+ };
153
+ },
154
+ };
155
+ }
@@ -4,16 +4,26 @@ import * as runtime from "../../src/utils/runtime";
4
4
  import * as claims from "../../src/utils/claims";
5
5
  import { formatInboxMessagesForModel, sanitizePlainTuiLine } from "../ui/renderers";
6
6
  import { createFileClaimTools } from "./file-claim-tools";
7
+ import { normalizeReportedTaskDetails, ReportedTaskDetailsSchema, type ReportedTaskDetails, type ReportResult } from "../../src/results/report-result";
8
+ import type { RepairRequest } from "../../src/results/verification-controller";
7
9
 
8
- export interface SubmittedAgentReport {
10
+ export interface SubmittedAgentReport extends ReportedTaskDetails {
9
11
  content: string;
10
12
  summary?: string;
11
13
  }
12
14
 
13
- export interface AgentReportSubmissionResult {
14
- accepted: boolean;
15
+ export type AgentReportSubmissionResult = {
16
+ verification?: ReportResult["verification"];
15
17
  cancelledDeliveries?: number;
16
18
  deliveryOutcome?: "cancelled" | "none";
19
+ } & ({ accepted: boolean; repairRequest?: never } | { accepted: false; repairRequest: RepairRequest });
20
+
21
+ export function formatRepairRequest(request: RepairRequest): string {
22
+ return [
23
+ `Final report not accepted. Repair attempt ${request.attempt} is reserved (${request.id}).`,
24
+ "Use the observed failures below to repair only your assigned scope. Do not add checks or broaden side effects. If you cannot repair safely, report blocked or failed. Submit a new complete report when done; do not exit yet.",
25
+ ...request.checks.map(check => `${check.assignment.name}: ${check.state}; exit ${check.exitCode ?? "unknown"}; full log: ${check.logPath}${check.error ? `; ${check.error}` : ""}`),
26
+ ].join("\n");
17
27
  }
18
28
 
19
29
  export interface AgentCommunicationToolsOptions {
@@ -24,7 +34,8 @@ export interface AgentCommunicationToolsOptions {
24
34
  getLifecycleRunId(): string | undefined;
25
35
  authorizeWriteMember(teamName: string, agentName: string): Promise<void>;
26
36
  onProgress?(status: string, updatedAt: number): void;
27
- onReportAndExit(report: SubmittedAgentReport): Promise<AgentReportSubmissionResult>;
37
+ repairEnabled?: boolean;
38
+ onReportAndExit(report: SubmittedAgentReport, signal?: AbortSignal, submissionId?: string): Promise<AgentReportSubmissionResult>;
28
39
  }
29
40
 
30
41
  function requireCurrentSession(options: Pick<AgentCommunicationToolsOptions, "getTeamName">): string {
@@ -139,17 +150,20 @@ export function createAgentCommunicationTools(options: AgentCommunicationToolsOp
139
150
  const reportAndExitTool = {
140
151
  name: "report_and_exit",
141
152
  label: "Report and Exit",
142
- description: "Submit the complete final report to the lead and finish this nested agent run.",
153
+ description: options.repairEnabled
154
+ ? "Submit the complete report for verification. If repair is requested, stay in this run and follow the returned feedback; finish only after acceptance."
155
+ : "Submit the complete final report to the lead and finish this nested agent run.",
143
156
  parameters: Type.Object({
144
157
  content: Type.String({ minLength: 1, description: "Complete non-empty final report to send to the lead; do not replace required output with a summary." }),
145
158
  summary: Type.Optional(Type.String({ description: "Short report summary." })),
159
+ ...ReportedTaskDetailsSchema.properties,
146
160
  }),
147
- async execute(_toolCallId: string, params: SubmittedAgentReport) {
161
+ async execute(_toolCallId: string, params: SubmittedAgentReport, signal?: AbortSignal) {
148
162
  const teamName = requireCurrentSession(options);
149
163
  const content = normalizeFinalReportContent(params.content);
150
164
  const summary = typeof params.summary === "string" && params.summary.trim() ? params.summary.trim() : undefined;
151
- const result = await options.onReportAndExit({ content, summary });
152
- const text = result.accepted
165
+ const result = await options.onReportAndExit({ content, summary, ...normalizeReportedTaskDetails(params) }, signal, _toolCallId);
166
+ const text = result.repairRequest ? formatRepairRequest(result.repairRequest) : result.accepted
153
167
  ? "Final report accepted. Finish immediately; the outer runner will release claims and stop this nested session."
154
168
  : "A final report was already accepted for this run. This duplicate was ignored; finish immediately.";
155
169
  return {
@@ -157,6 +171,8 @@ export function createAgentCommunicationTools(options: AgentCommunicationToolsOp
157
171
  details: {
158
172
  session: teamName,
159
173
  accepted: result.accepted,
174
+ ...(result.verification ? { verification: result.verification } : {}),
175
+ ...(result.repairRequest ? { repairRequest: result.repairRequest } : {}),
160
176
  ...(result.cancelledDeliveries === undefined ? {} : {
161
177
  cancelledDeliveries: result.cancelledDeliveries,
162
178
  deliveryOutcome: result.deliveryOutcome,
@@ -2,9 +2,11 @@ import { Type } from "@sinclair/typebox";
2
2
  import * as teams from "../../src/utils/teams";
3
3
  import * as runtime from "../../src/utils/runtime";
4
4
  import * as reportEvents from "../../src/utils/report-events";
5
- import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone";
6
- import { projectAgentStatus, type ActiveAgentPhase } from "../../src/orchestration/status-projection";
5
+ import { listLifecycleTombstones, readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone";
6
+ import { projectAgentStatus, projectLifecycleFence, type ActiveAgentPhase } from "../../src/orchestration/status-projection";
7
7
  import type { Member, TeamReportEvent } from "../../src/utils/models";
8
+ import { createReportResult, effectiveTaskOutcome, type ReportResult } from "../../src/results/report-result";
9
+ import { VerificationController } from "../../src/results/verification-controller";
8
10
  import type { RunningReadAgent } from "../runtime/types";
9
11
  import { isWriteMemberAlive } from "../team/roster";
10
12
  import { formatElapsed } from "../ui/renderers";
@@ -30,6 +32,7 @@ export interface AgentStatusScope {
30
32
 
31
33
  export interface AgentStatusSnapshot {
32
34
  name: string;
35
+ runId?: string;
33
36
  role: string;
34
37
  phase: AgentStatusPhase;
35
38
  progress?: string;
@@ -40,6 +43,13 @@ export interface AgentStatusSnapshot {
40
43
  queuePosition?: number;
41
44
  queuedAgeMs?: number;
42
45
  completedAgeMs?: number;
46
+ taskId?: string;
47
+ reportId?: string;
48
+ outcome?: ReportResult["outcome"];
49
+ effectiveOutcome?: ReportResult["outcome"];
50
+ repair?: ReportResult["repair"];
51
+ verification?: ReportResult["verification"]["state"];
52
+ acceptance?: ReportResult["acceptance"]["state"];
43
53
  summary?: string;
44
54
  error?: string;
45
55
  }
@@ -85,6 +95,7 @@ async function activeStatus(
85
95
  member: Member,
86
96
  options: AgentStatusToolOptions,
87
97
  now: number,
98
+ report?: TeamReportEvent,
88
99
  ): Promise<AgentStatusSnapshot> {
89
100
  const candidateState = options.runningReadAgents.get(options.readAgentKey(teamName, member.name));
90
101
  const [candidateRuntimeStatus, lifecycleResult] = await Promise.all([
@@ -102,6 +113,9 @@ async function activeStatus(
102
113
  const { state, runtime: runtimeStatus } = projected;
103
114
  const progress = state?.latestProgress || runtimeStatus?.latestProgress;
104
115
  const progressUpdatedAt = state?.progressUpdatedAt || runtimeStatus?.progressUpdatedAt;
116
+ const result = report?.result ?? (member.lifecycleRunId
117
+ ? (await VerificationController.observe(teamName, createReportResult(teamName, member.name, member.lifecycleRunId, {}))).result
118
+ : undefined);
105
119
  return {
106
120
  name: member.name,
107
121
  role: member.role || state?.role || "read",
@@ -111,7 +125,17 @@ async function activeStatus(
111
125
  activeTool: state?.activeToolName || runtimeStatus?.activeToolName,
112
126
  activityAgeMs: age(now, state?.lastActivityAt),
113
127
  heartbeatAgeMs: age(now, runtimeStatus?.lastHeartbeatAt),
114
- error: projected.error,
128
+ ...(result?.repair ? resultStatus(result) : {}),
129
+ error: projected.error ?? result?.verification.error,
130
+ };
131
+ }
132
+
133
+ function resultStatus(result: ReportResult) {
134
+ return {
135
+ taskId: result.taskId, runId: result.runId, reportId: result.reportId,
136
+ outcome: result.outcome, verification: result.verification.state, acceptance: result.acceptance.state,
137
+ error: result.verification.error,
138
+ ...(result.repair ? { repair: result.repair, effectiveOutcome: effectiveTaskOutcome(result) } : {}),
115
139
  };
116
140
  }
117
141
 
@@ -131,6 +155,7 @@ function completedStatus(report: TeamReportEvent, now: number): AgentStatusSnaps
131
155
  name: report.agentName,
132
156
  role: report.role || "read",
133
157
  phase: report.status,
158
+ ...(report.result ? resultStatus(report.result) : {}),
134
159
  completedAgeMs: age(now, report.createdAt),
135
160
  summary: report.summary,
136
161
  };
@@ -145,6 +170,7 @@ export function formatAgentStatusesForModel(statuses: AgentStatusSnapshot[]): st
145
170
 
146
171
  const blocks = statuses.map(status => {
147
172
  const lines = [`${status.name}: ${status.phase} (${status.role})`];
173
+ if (status.runId) lines.push(` run: ${status.runId}`);
148
174
  if (status.progress) lines.push(` progress: ${status.progress}${status.progressAgeMs === undefined ? "" : ` (${formatElapsed(status.progressAgeMs)} ago)`}`);
149
175
  if (status.activeTool) lines.push(` tool: ${status.activeTool}`);
150
176
  const activity = formatAge("activity", status.activityAgeMs)
@@ -155,6 +181,8 @@ export function formatAgentStatusesForModel(statuses: AgentStatusSnapshot[]): st
155
181
  if (queued) lines.push(` ${queued}`);
156
182
  const completed = formatAge(status.phase === "failed" ? "failed" : "completed", status.completedAgeMs);
157
183
  if (completed) lines.push(` ${completed}`);
184
+ if (status.reportId) lines.push(` task: ${status.outcome ?? "unspecified"}; verification: ${status.verification}; acceptance: ${status.acceptance}`);
185
+ if (status.repair) lines.push(` repair: ${status.repair.state}; effective task: ${status.effectiveOutcome ?? "unspecified"}; ledger: ${status.repair.journalPath}`);
158
186
  if (status.summary) lines.push(` summary: ${status.summary}`);
159
187
  if (status.error) lines.push(` error: ${status.error}`);
160
188
  return lines.join("\n");
@@ -188,9 +216,24 @@ export function createAgentStatusTool(options: AgentStatusToolOptions): any {
188
216
  }).catch(() => []))
189
217
  .filter(report => ownsReport(report, options.scope));
190
218
 
191
- const activeStatuses = await Promise.all(activeMembers.map(member => activeStatus(teamName, member, options, now)));
192
- const queuedStatuses = queued.map(item => queuedStatus(item, now));
193
- const currentNames = new Set([...activeStatuses, ...queuedStatuses].map(status => status.name));
219
+ const activeStatuses = await Promise.all(activeMembers.map(member => activeStatus(teamName, member, options, now,
220
+ reports.filter(report => report.agentName === member.name && report.result?.runId === member.lifecycleRunId).at(-1))));
221
+ // Orphan fences lack parent metadata; only the lead can inspect them.
222
+ const fencedStatuses: AgentStatusSnapshot[] = options.scope ? [] : (await listLifecycleTombstones(teamName))
223
+ .filter(({ agentName }) => !rosterNames.has(agentName))
224
+ .map(({ agentName, result }) => {
225
+ const pending = queued.find(item => item.name === agentName);
226
+ return {
227
+ ...(pending ? queuedStatus(pending, now) : {}),
228
+ name: agentName,
229
+ runId: result.status === "occupied" ? result.tombstone.runId : undefined,
230
+ role: result.status === "occupied" ? result.tombstone.role : pending?.role || "unknown",
231
+ ...projectLifecycleFence({}, result),
232
+ };
233
+ });
234
+ const fencedNames = new Set(fencedStatuses.map(status => status.name));
235
+ const queuedStatuses = queued.filter(item => !fencedNames.has(item.name)).map(item => queuedStatus(item, now));
236
+ const currentNames = new Set([...activeStatuses, ...fencedStatuses, ...queuedStatuses].map(status => status.name));
194
237
  const completedByName = new Map<string, TeamReportEvent>();
195
238
  for (const report of reports) {
196
239
  if (currentNames.has(report.agentName)) continue;
@@ -198,7 +241,7 @@ export function createAgentStatusTool(options: AgentStatusToolOptions): any {
198
241
  completedByName.set(report.agentName, report);
199
242
  }
200
243
  const completedStatuses = Array.from(completedByName.values()).slice(-20).map(report => completedStatus(report, now));
201
- const statuses = [...activeStatuses, ...queuedStatuses, ...completedStatuses];
244
+ const statuses = [...activeStatuses, ...fencedStatuses, ...queuedStatuses, ...completedStatuses];
202
245
  const selected = params.agent_name
203
246
  ? statuses.filter(status => status.name === params.agent_name)
204
247
  : statuses;
@@ -7,7 +7,15 @@ import * as messaging from "../../src/utils/messaging";
7
7
  import * as runtime from "../../src/utils/runtime";
8
8
  import * as claims from "../../src/utils/claims";
9
9
  import * as reportEvents from "../../src/utils/report-events";
10
+ import { checkpointReference, saveReportCheckpoint, resyncReportCheckpoint } from "../../src/results/checkpoint-report";
11
+ import { createReportResult, effectiveTaskOutcome, normalizeReportedTaskDetails, ReportedTaskDetailsSchema, type ReportResult } from "../../src/results/report-result";
10
12
  import { canonicalPersistedModelSlot } from "../../src/utils/settings";
13
+ import { normalizeCheckPolicy } from "../../src/results/check-policy";
14
+ import { VerificationController } from "../../src/results/verification-controller";
15
+ import { deliverCompletionGroupReport } from "../../src/results/completion-group-delivery";
16
+ import { formatRepairRequest } from "./agent-communication-tools";
17
+ import type { CheckRunnerOptions } from "../../src/results/check-runner";
18
+ import { loadNativeCheckOperations } from "../internal/pi-check-operations";
11
19
  import { createFileClaimTools } from "./file-claim-tools";
12
20
  import { formatInboxMessagesForModel, renderInboxMessages } from "../ui/renderers";
13
21
  import { unlinkPidFile } from "../internal/session-files";
@@ -33,6 +41,7 @@ export interface CoordinationToolsOptions {
33
41
  resetLeadWakeNotifiedCount(): void;
34
42
  deliverMessageToActiveAgent?(teamName: string, recipient: string, content: string): Promise<boolean>;
35
43
  extensionInstanceId?: string;
44
+ loadCheckOperations?: CheckRunnerOptions["loadOperations"];
36
45
  }
37
46
 
38
47
  export function buildReadHelperPrompt(teamName: string, requester: string, prompt: string): string {
@@ -53,9 +62,26 @@ function requireCurrentSession(options: CoordinationToolsOptions): string {
53
62
 
54
63
  export function registerCoordinationTools(pi: any, options: CoordinationToolsOptions): void {
55
64
  const extensionInstanceId = options.extensionInstanceId ?? generateExtensionInstanceId();
56
- const pendingWriterFinalization = new Map<string, { teamName: string; agentName: string; runId: string }>();
65
+ const pendingWriterFinalization = new Map<string, { teamName: string; agentName: string; runId: string; checkpointId?: string }>();
66
+ const verifyingReports = new Set<string>();
67
+ const blockedReports = new Set<string>();
68
+ const pendingRepairs = new Map<string, { controller: VerificationController; result: ReportResult }>();
69
+ let repairCancellation: Promise<void> | undefined;
70
+ const cancelPendingRepairs = () => repairCancellation ??= (async () => {
71
+ for (const [reportId, { controller }] of pendingRepairs) {
72
+ try {
73
+ await controller.cancel();
74
+ pendingRepairs.delete(reportId);
75
+ } catch (error) {
76
+ blockedReports.add(reportId);
77
+ throw error;
78
+ }
79
+ }
80
+ })().finally(() => { repairCancellation = undefined; });
57
81
 
82
+ pi.on?.("agent_settled", cancelPendingRepairs);
58
83
  pi.on?.("session_shutdown", async () => {
84
+ await cancelPendingRepairs();
59
85
  const pending = Array.from(pendingWriterFinalization.values());
60
86
  for (const item of pending) {
61
87
  let cleared = false;
@@ -77,6 +103,11 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
77
103
  throw new Error(`Refusing to remove replacement runtime run ${currentRuntime.lifecycleRunId || "unknown"} of ${item.agentName}.`);
78
104
  }
79
105
 
106
+ if (item.checkpointId) {
107
+ const report = await reportEvents.readStoredTeamReportEvent(item.teamName, createReportResult(item.teamName, item.agentName, item.runId, {}).reportId);
108
+ if (report?.checkpoint?.id !== item.checkpointId) throw new Error("Checkpoint report provenance is unavailable.");
109
+ await resyncReportCheckpoint(report);
110
+ }
80
111
  const pidFile = path.join(paths.teamDir(item.teamName), `${item.agentName}.pid`);
81
112
  const pidFileExisted = fs.existsSync(pidFile);
82
113
  const pidFileUnlinked = unlinkPidFile(pidFile);
@@ -143,10 +174,12 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
143
174
  parameters: Type.Object({
144
175
  content: Type.String({ description: "Final report to send to the lead." }),
145
176
  summary: Type.Optional(Type.String({ description: "Short report summary." })),
177
+ ...ReportedTaskDetailsSchema.properties,
146
178
  }),
147
179
  async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
148
180
  const targetTeamName = requireCurrentSession(options);
149
181
  if (!options.isTeammate) throw new Error("report_and_exit is only available to spawned agents.");
182
+ const reported = normalizeReportedTaskDetails(params);
150
183
  if (process.env.PI_EXTENDED_TEAMS_HERDR_RESUME === "1" && !params.content.trim()) throw new Error("Final report content must not be empty.");
151
184
 
152
185
  const config = await teams.readConfig(targetTeamName);
@@ -158,6 +191,7 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
158
191
  throw new Error(`Refusing stale report run ${processRunId} for ${options.agentName}; current roster run is ${runId}.`);
159
192
  }
160
193
  member.lifecycleRunId = runId;
194
+ const result = createReportResult(targetTeamName, options.agentName, runId, reported);
161
195
  let runtimeStatus = await runtime.readRuntimeStatus(targetTeamName, options.agentName).catch(() => null);
162
196
  if (runtimeStatus?.lifecycleRunId && runtimeStatus.lifecycleRunId !== runId) {
163
197
  throw new Error(`Refusing to report from stale run ${runId}; runtime status belongs to ${runtimeStatus.lifecycleRunId}.`);
@@ -165,17 +199,40 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
165
199
  if (runtimeStatus && !runtimeStatus.lifecycleRunId) {
166
200
  runtimeStatus = await runtime.writeRuntimeStatus(targetTeamName, options.agentName, runId, {});
167
201
  }
202
+ const checks = normalizeCheckPolicy(member.assignedChecks);
203
+ if (repairCancellation) await repairCancellation;
204
+ if (blockedReports.has(result.reportId)) throw new Error("Final report verification is blocked by uncertain persistence for this run.");
205
+ if (verifyingReports.has(result.reportId)) throw new Error("Final report verification is already in progress for this run.");
206
+ const previous = pendingRepairs.get(result.reportId)?.result;
207
+ if (previous) Object.assign(result, { verification: previous.verification, repair: previous.repair });
208
+ const controller = new VerificationController({ teamName: targetTeamName, result, cwd: member.cwd, checks, repair: member.repairPolicy });
209
+ verifyingReports.add(result.reportId);
210
+ try {
211
+ const decision = await controller.verify(`tool:${_toolCallId}`, {
212
+ loadOperations: options.loadCheckOperations ?? loadNativeCheckOperations, signal: _signal,
213
+ }).catch(error => { blockedReports.add(result.reportId); throw error; });
214
+ Object.assign(result, decision.result);
215
+ if (decision.request) pendingRepairs.set(result.reportId, { controller, result: structuredClone(result) });
216
+ else pendingRepairs.delete(result.reportId);
217
+ if (checks?.length && _signal?.aborted) {
218
+ await controller.cancel().catch(error => { blockedReports.add(result.reportId); throw error; });
219
+ pendingRepairs.delete(result.reportId);
220
+ throw new Error("Assigned check verification was cancelled.");
221
+ }
222
+ if (result.verification.state === "pending") throw new Error("Assigned checks have an unresolved execution claim; inspect the durable journal before finalizing this run.");
223
+ if (decision.request) return {
224
+ content: [{ type: "text", text: formatRepairRequest(decision.request) }],
225
+ details: { accepted: false, session: targetTeamName, repairRequest: decision.request, result },
226
+ };
227
+ } finally {
228
+ verifyingReports.delete(result.reportId);
229
+ }
168
230
  await closePersistedRecipient(targetTeamName, options.agentName, runId, {
169
231
  removeOnFailure: true,
170
232
  role: (member.role ?? "write") === "read" ? "read" : "write",
171
233
  reason: "quit",
172
234
  extensionInstanceId,
173
235
  });
174
- pendingWriterFinalization.set(`${targetTeamName}:${options.agentName}`, {
175
- teamName: targetTeamName,
176
- agentName: options.agentName,
177
- runId,
178
- });
179
236
  const sessionUsage = summarizeSessionUsage(ctx);
180
237
  const tokensUsed = typeof sessionUsage.tokensUsed === "number" ? sessionUsage.tokensUsed : runtimeStatus?.tokensUsed;
181
238
  const costUsd = sessionUsage.costUsd;
@@ -198,8 +255,11 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
198
255
  try {
199
256
  const persistedReport = await reportEvents.appendTeamReportEvent(targetTeamName, {
200
257
  agentName: options.agentName,
258
+ checkpoint: checkpointReference(targetTeamName, member),
259
+ completionGroup: member.completionGroup,
201
260
  role: member?.role || "write",
202
261
  status: "completed",
262
+ result,
203
263
  report: params.content,
204
264
  summary: params.summary || "Final report",
205
265
  startedAt: runtimeStatus?.startedAt,
@@ -215,8 +275,18 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
215
275
  });
216
276
  reportPath = persistedReport.reportPath || "";
217
277
  if (!reportPath) throw new Error("The persisted report did not provide its standalone file path.");
218
- await messaging.sendPlainMessage(targetTeamName, options.agentName, "team-lead", params.content, params.summary || "Final report", undefined, { metadata: reportMetadata });
278
+ await saveReportCheckpoint(targetTeamName, member, persistedReport, _signal);
279
+ if (persistedReport.checkpoint) result.checkpointId = persistedReport.checkpoint.id;
280
+ const leadReport = (checks?.length
281
+ ? `${params.content}\n\nHarness verification: ${result.verification.state}; lead acceptance: ${result.acceptance.state}${result.repair ? `; repair: ${result.repair.state}; effective task: ${effectiveTaskOutcome(result) ?? "unspecified"}` : ""}. Evidence: ${result.reportId}.`
282
+ : params.content) + (result.checkpointId ? `\n\nCheckpoint reference: ${result.checkpointId}.` : "");
283
+ const grouped = await deliverCompletionGroupReport(persistedReport);
284
+ if (member.completionGroup && !grouped) throw new Error("Grouped report provenance is unavailable.");
285
+ if (!grouped) {
286
+ await messaging.sendPlainMessage(targetTeamName, options.agentName, "team-lead", leadReport, params.summary || "Final report", undefined, { metadata: reportMetadata });
287
+ }
219
288
  releasedClaims = await options.releaseAllClaimsForAgent(targetTeamName, options.agentName);
289
+ pendingWriterFinalization.set(`${targetTeamName}:${options.agentName}`, { teamName: targetTeamName, agentName: options.agentName, runId, checkpointId: result.checkpointId });
220
290
  } catch (error) {
221
291
  pendingWriterFinalization.delete(`${targetTeamName}:${options.agentName}`);
222
292
  await withLifecycleTombstoneLock(targetTeamName, options.agentName, async lifecycleLock => {
@@ -232,7 +302,7 @@ export function registerCoordinationTools(pi: any, options: CoordinationToolsOpt
232
302
  try { ctx.shutdown(); } catch { process.exit(0); }
233
303
  }, 250);
234
304
 
235
- return { content: [{ type: "text", text: `Final report sent. Released ${releasedClaims.length} file claim(s). Exiting.` }], details: { session: targetTeamName, releasedClaims, reportPath } };
305
+ return { content: [{ type: "text", text: `Final report ${member.completionGroup ? "saved to its completion group" : "sent"}. Released ${releasedClaims.length} file claim(s). Exiting.` }], details: { accepted: true, session: targetTeamName, releasedClaims, reportPath, result } };
236
306
  },
237
307
  });
238
308
 
@@ -11,6 +11,7 @@ import type { ReadAgentTeardownResult } from "../agents/read-agent-session-lifec
11
11
  import type { ShutdownTeammateOptions } from "../team/lifecycle";
12
12
  import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone";
13
13
  import type { TeammateInterruptResult } from "../runtime/teammate-interrupt";
14
+ import { effectiveTaskOutcome } from "../../src/results/report-result";
14
15
 
15
16
  export interface TaskRuntimeToolsOptions {
16
17
  isTeammate: boolean;
@@ -18,7 +19,7 @@ export interface TaskRuntimeToolsOptions {
18
19
  runningReadAgents: Map<string, RunningReadAgent>;
19
20
  readAgentKey(teamName: string, agentName: string): string;
20
21
  interruptTeammate?(agentName: string): Promise<TeammateInterruptResult>;
21
- cancelQueuedAgent?(teamName: string, agentName: string): boolean;
22
+ cancelQueuedAgent?(teamName: string, agentName: string): boolean | Promise<boolean>;
22
23
  shutdownTeammate(teamName: string, member: Member, options?: ShutdownTeammateOptions): Promise<ReadAgentTeardownResult>;
23
24
  getTeamName(): string | null | undefined;
24
25
  }
@@ -56,7 +57,7 @@ export function registerTaskRuntimeTools(pi: any, options: TaskRuntimeToolsOptio
56
57
  const teamName = options.getTeamName();
57
58
  if (!teamName) throw new Error("No active agent session. Spawn an agent first.");
58
59
 
59
- if (options.cancelQueuedAgent?.(teamName, params.agent_name)) {
60
+ if (await options.cancelQueuedAgent?.(teamName, params.agent_name)) {
60
61
  return {
61
62
  content: [{ type: "text", text: `Cancelled queued agent ${params.agent_name}.` }],
62
63
  details: { session: teamName, agentName: params.agent_name, stopped: true, queued: true, reason: params.reason },
@@ -147,6 +148,10 @@ export function registerTaskRuntimeTools(pi: any, options: TaskRuntimeToolsOptio
147
148
  if (!member) {
148
149
  const fence = await readLifecycleTombstone(teamName, params.agent_name);
149
150
  if (fence.status !== "absent") {
151
+ const completedReport = !options.isTeammate && fence.status === "occupied"
152
+ ? (await reportEvents.listTeamReportEvents(teamName, { agentName: params.agent_name }))
153
+ .filter(report => report.result?.runId === fence.tombstone.runId && report.result.repair).at(-1)
154
+ : undefined;
150
155
  const details = {
151
156
  agentName: params.agent_name,
152
157
  alive: false,
@@ -157,12 +162,17 @@ export function registerTaskRuntimeTools(pi: any, options: TaskRuntimeToolsOptio
157
162
  startupStalled: false,
158
163
  runtime: await runtime.readRuntimeStatus(teamName, params.agent_name).catch(() => null),
159
164
  teardownState: "quarantined",
165
+ ...(completedReport ? { completedReport } : {}),
160
166
  removedMember: false,
161
167
  releasedClaims: [],
162
168
  tombstone: fence.status === "occupied" ? fence.tombstone : undefined,
163
169
  error: fence.status === "corrupt" ? fence.error : undefined,
164
170
  };
165
- return { content: [{ type: "text", text: formatTeammateStatusForModel(params.agent_name, details) }], details };
171
+ const text = [formatTeammateStatusForModel(params.agent_name, details),
172
+ completedReport ? `Full report: ${completedReport.reportPath}; repair ledger: ${completedReport.result!.repair!.journalPath}` : undefined,
173
+ ...(completedReport?.checks?.map(check => `Check ${check.checkId}: ${check.state}; full log: ${check.logPath}`) ?? []),
174
+ ].filter(Boolean).join("\n\n");
175
+ return { content: [{ type: "text", text }], details };
166
176
  }
167
177
  if (options.isTeammate) throw new Error(`Agent ${params.agent_name} not found`);
168
178
  const latestReport = (await reportEvents.listTeamReportEvents(teamName, {
@@ -177,6 +187,14 @@ export function registerTaskRuntimeTools(pi: any, options: TaskRuntimeToolsOptio
177
187
  : undefined;
178
188
  const text = [
179
189
  `Agent ${params.agent_name} is no longer active. Recovered its latest persisted ${latestReport.status} report from ${reportPath}.`,
190
+ latestReport.result
191
+ ? `Task: ${latestReport.result.outcome ?? "unspecified"}; verification: ${latestReport.result.verification.state}; acceptance: ${latestReport.result.acceptance.state}. Report ID: ${latestReport.result.reportId}.`
192
+ : undefined,
193
+ latestReport.result?.repair
194
+ ? `Repair: ${latestReport.result.repair.state}; effective task: ${effectiveTaskOutcome(latestReport.result) ?? "unspecified"}; ledger: ${latestReport.result.repair.journalPath}.`
195
+ : undefined,
196
+ latestReport.checks?.map(check => `Check ${check.checkId}: ${check.state}; exit ${check.exitCode ?? "unknown"}; full log: ${check.logPath}`).join("\n"),
197
+ latestReport.result?.verification.error,
180
198
  recoverySessionFile
181
199
  ? `Its durable child transcript is available at ${recoverySessionFile}. Use the read tool on that path if the report is incomplete.`
182
200
  : undefined,
@@ -246,7 +264,8 @@ export function registerTaskRuntimeTools(pi: any, options: TaskRuntimeToolsOptio
246
264
  removedMember: teardown?.removedMember ?? false,
247
265
  error: teardown?.error ?? lifecycleError,
248
266
  };
249
- return { content: [{ type: "text", text: formatTeammateStatusForModel(params.agent_name, details) }], details };
267
+ const text = [formatTeammateStatusForModel(params.agent_name, details), details.error].filter(Boolean).join("\n\n");
268
+ return { content: [{ type: "text", text }], details };
250
269
  },
251
270
  renderResult(result: any, { expanded }: any, theme: any) {
252
271
  return renderTeammateStatus(result, expanded, theme);