pi-extended-teams 2.1.16

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 (130) hide show
  1. package/README.md +105 -0
  2. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  3. package/assets/pi-extended-teams-in-action.png +0 -0
  4. package/extensions/agents/read-agent-report.ts +181 -0
  5. package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
  6. package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
  7. package/extensions/agents/read-agent.test.ts +3077 -0
  8. package/extensions/agents/read-agent.ts +1119 -0
  9. package/extensions/agents/write-agent.test.ts +513 -0
  10. package/extensions/agents/write-agent.ts +392 -0
  11. package/extensions/events/register-events.test.ts +465 -0
  12. package/extensions/events/register-events.ts +409 -0
  13. package/extensions/index.test.ts +1659 -0
  14. package/extensions/index.ts +1228 -0
  15. package/extensions/internal/agent-session-files.test.ts +164 -0
  16. package/extensions/internal/agent-session-files.ts +320 -0
  17. package/extensions/internal/debug.ts +44 -0
  18. package/extensions/internal/model-selection.ts +82 -0
  19. package/extensions/internal/pi-command.test.ts +191 -0
  20. package/extensions/internal/pi-command.ts +224 -0
  21. package/extensions/internal/pi-runtime-api.test.ts +43 -0
  22. package/extensions/internal/pi-runtime-api.ts +78 -0
  23. package/extensions/internal/schema.ts +19 -0
  24. package/extensions/internal/session-context-reference.test.ts +290 -0
  25. package/extensions/internal/session-context-reference.ts +430 -0
  26. package/extensions/internal/session-files.test.ts +225 -0
  27. package/extensions/internal/session-files.ts +273 -0
  28. package/extensions/internal/session-usage.ts +59 -0
  29. package/extensions/resources/spawn-resource-plan.test.ts +233 -0
  30. package/extensions/resources/spawn-resource-plan.ts +247 -0
  31. package/extensions/runtime/active-agent-sleep.test.ts +157 -0
  32. package/extensions/runtime/active-agent-sleep.ts +117 -0
  33. package/extensions/runtime/nested-read-agents.ts +22 -0
  34. package/extensions/runtime/pending-child-controller.test.ts +169 -0
  35. package/extensions/runtime/pending-child-controller.ts +280 -0
  36. package/extensions/runtime/types.ts +68 -0
  37. package/extensions/team/contracts.test.ts +111 -0
  38. package/extensions/team/lifecycle.test.ts +1095 -0
  39. package/extensions/team/lifecycle.ts +502 -0
  40. package/extensions/team/recipient-closure.test.ts +97 -0
  41. package/extensions/team/recipient-closure.ts +120 -0
  42. package/extensions/team/roster.test.ts +129 -0
  43. package/extensions/team/roster.ts +165 -0
  44. package/extensions/team/team-contracts.json +43 -0
  45. package/extensions/team/writer-screens.test.ts +50 -0
  46. package/extensions/team/writer-screens.ts +156 -0
  47. package/extensions/tools/agent-communication-tools.test.ts +306 -0
  48. package/extensions/tools/agent-communication-tools.ts +183 -0
  49. package/extensions/tools/coordination-tools.test.ts +670 -0
  50. package/extensions/tools/coordination-tools.ts +304 -0
  51. package/extensions/tools/delegation-guard.test.ts +95 -0
  52. package/extensions/tools/delegation-guard.ts +65 -0
  53. package/extensions/tools/file-claim-tools.test.ts +104 -0
  54. package/extensions/tools/file-claim-tools.ts +68 -0
  55. package/extensions/tools/model-tools.ts +53 -0
  56. package/extensions/tools/predefined-tools.test.ts +647 -0
  57. package/extensions/tools/predefined-tools.ts +331 -0
  58. package/extensions/tools/read-helper.test.ts +35 -0
  59. package/extensions/tools/task-runtime-tools.test.ts +334 -0
  60. package/extensions/tools/task-runtime-tools.ts +227 -0
  61. package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
  62. package/extensions/tools/team-tools.ts +1270 -0
  63. package/extensions/ui/agent-follow-view.test.ts +520 -0
  64. package/extensions/ui/agent-follow-view.ts +779 -0
  65. package/extensions/ui/agent-navigation.test.ts +52 -0
  66. package/extensions/ui/agent-navigation.ts +55 -0
  67. package/extensions/ui/ansi.ts +16 -0
  68. package/extensions/ui/extensions-command.test.ts +336 -0
  69. package/extensions/ui/extensions-command.ts +282 -0
  70. package/extensions/ui/favorite-models-command.test.ts +280 -0
  71. package/extensions/ui/favorite-models-command.ts +423 -0
  72. package/extensions/ui/frame.ts +77 -0
  73. package/extensions/ui/input.ts +17 -0
  74. package/extensions/ui/read-agent-status.test.ts +117 -0
  75. package/extensions/ui/read-agent-status.ts +125 -0
  76. package/extensions/ui/renderers.ts +243 -0
  77. package/extensions/ui/status-widget.test.ts +143 -0
  78. package/extensions/ui/status-widget.ts +440 -0
  79. package/extensions/ui-frame.test.ts +69 -0
  80. package/package.json +75 -0
  81. package/skills/teams.md +256 -0
  82. package/src/adapters/terminal-registry.ts +78 -0
  83. package/src/adapters/tmux-adapter.test.ts +276 -0
  84. package/src/adapters/tmux-adapter.ts +200 -0
  85. package/src/orchestration/index.ts +437 -0
  86. package/src/orchestration/orchestrator.test.ts +396 -0
  87. package/src/orchestration/types.ts +125 -0
  88. package/src/utils/atomic-json.ts +21 -0
  89. package/src/utils/claims.test.ts +137 -0
  90. package/src/utils/claims.ts +169 -0
  91. package/src/utils/hooks.test.ts +75 -0
  92. package/src/utils/hooks.ts +35 -0
  93. package/src/utils/lifecycle-tombstone.test.ts +105 -0
  94. package/src/utils/lifecycle-tombstone.ts +268 -0
  95. package/src/utils/lock.race.child.ts +44 -0
  96. package/src/utils/lock.race.test.ts +198 -0
  97. package/src/utils/lock.test.ts +90 -0
  98. package/src/utils/lock.ts +186 -0
  99. package/src/utils/messaging.test.ts +337 -0
  100. package/src/utils/messaging.ts +441 -0
  101. package/src/utils/model-resolution.test.ts +231 -0
  102. package/src/utils/model-resolution.ts +322 -0
  103. package/src/utils/models.test.ts +8 -0
  104. package/src/utils/models.ts +115 -0
  105. package/src/utils/paths.ts +81 -0
  106. package/src/utils/predefined-teams/types.ts +48 -0
  107. package/src/utils/predefined-teams.save-template.test.ts +74 -0
  108. package/src/utils/predefined-teams.test.ts +441 -0
  109. package/src/utils/predefined-teams.ts +471 -0
  110. package/src/utils/read-helper-queue.ts +99 -0
  111. package/src/utils/report-events.test.ts +154 -0
  112. package/src/utils/report-events.ts +222 -0
  113. package/src/utils/runtime.test.ts +314 -0
  114. package/src/utils/runtime.ts +261 -0
  115. package/src/utils/security.test.ts +43 -0
  116. package/src/utils/settings.test.ts +480 -0
  117. package/src/utils/settings.ts +645 -0
  118. package/src/utils/shared-memory.test.ts +80 -0
  119. package/src/utils/shared-memory.ts +81 -0
  120. package/src/utils/tasks.race.test.ts +44 -0
  121. package/src/utils/tasks.test.ts +229 -0
  122. package/src/utils/tasks.ts +396 -0
  123. package/src/utils/teams.ts +231 -0
  124. package/src/utils/terminal-adapter.ts +103 -0
  125. package/src/utils/thinking-levels.test.ts +56 -0
  126. package/src/utils/thinking-levels.ts +47 -0
  127. package/src/utils/workflow-metadata.test.ts +11 -0
  128. package/src/utils/workflow-metadata.ts +88 -0
  129. package/src/utils/write-queue.test.ts +251 -0
  130. package/src/utils/write-queue.ts +202 -0
@@ -0,0 +1,670 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import { syncBuiltinESMExports } from "node:module";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { registerCoordinationTools } from "./coordination-tools.js";
7
+ import { registerExtensionEvents } from "../events/register-events.js";
8
+ import * as paths from "../../src/utils/paths.js";
9
+ import * as runtime from "../../src/utils/runtime.js";
10
+ import * as messaging from "../../src/utils/messaging.js";
11
+ import * as reportEvents from "../../src/utils/report-events.js";
12
+ import type { Member, TeamConfig } from "../../src/utils/models.js";
13
+ import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone.js";
14
+
15
+ let root: string;
16
+ let teamsRoot: string;
17
+
18
+ function installPathSpies() {
19
+ vi.spyOn(paths, "teamDir").mockImplementation((teamName: string) => path.join(teamsRoot, paths.sanitizeName(String(teamName))));
20
+ vi.spyOn(paths, "configPath").mockImplementation((teamName: string) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "config.json"));
21
+ vi.spyOn(paths, "inboxPath").mockImplementation((teamName: string, agentName: string) => {
22
+ return path.join(teamsRoot, paths.sanitizeName(String(teamName)), "inboxes", `${paths.sanitizeName(String(agentName))}.json`);
23
+ });
24
+ vi.spyOn(paths, "runtimeStatusPath").mockImplementation((teamName: string, agentName: string) => {
25
+ return path.join(teamsRoot, paths.sanitizeName(String(teamName)), "runtime", `${paths.sanitizeName(String(agentName))}.json`);
26
+ });
27
+ vi.spyOn(paths, "reportEventsPath").mockImplementation((teamName: string) => {
28
+ return path.join(teamsRoot, paths.sanitizeName(String(teamName)), "reports.json");
29
+ });
30
+ if (typeof (paths as any).reportFilesDir === "function") {
31
+ vi.spyOn(paths as any, "reportFilesDir").mockReturnValue(path.join(root, "agent", "reports"));
32
+ }
33
+ vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation((teamName: string, agentName: string) => {
34
+ return path.join(teamsRoot, paths.sanitizeName(String(teamName)), "lifecycle", "quarantine", `${paths.sanitizeName(String(agentName))}.json`);
35
+ });
36
+ }
37
+
38
+ function privateSessionDirectory(teamName: string, agentName: string, runId: string): string {
39
+ return path.join(paths.teamDir(teamName), "agent-sessions", agentName, runId);
40
+ }
41
+
42
+ function member(name: string, overrides: Partial<Member> = {}): Member {
43
+ return {
44
+ agentId: `${name}@exit`,
45
+ name,
46
+ agentType: name === "team-lead" ? "lead" : "teammate",
47
+ role: name === "team-lead" ? undefined : "write",
48
+ joinedAt: Date.now(),
49
+ tmuxPaneId: name === "team-lead" ? "" : `%${name}`,
50
+ cwd: root,
51
+ subscriptions: [],
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ function writeConfig(config: TeamConfig) {
57
+ fs.mkdirSync(path.dirname(paths.configPath(config.name)), { recursive: true });
58
+ fs.writeFileSync(paths.configPath(config.name), JSON.stringify(config, null, 2));
59
+ }
60
+
61
+ describe("coordination tools", () => {
62
+ beforeEach(() => {
63
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-coordination-"));
64
+ teamsRoot = path.join(root, "teams");
65
+ fs.mkdirSync(teamsRoot, { recursive: true });
66
+ installPathSpies();
67
+ });
68
+
69
+ afterEach(() => {
70
+ vi.useRealTimers();
71
+ vi.unstubAllEnvs();
72
+ vi.restoreAllMocks();
73
+ syncBuiltinESMExports();
74
+ if (root && fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
75
+ });
76
+
77
+ it("read_inbox defaults to returning only unread messages", async () => {
78
+ const teamName = "inbox-team";
79
+ await messaging.sendPlainMessage(teamName, "reader", "team-lead", "Already handled", "old");
80
+ await messaging.readInbox(teamName, "team-lead", true, true);
81
+ await messaging.sendPlainMessage(teamName, "writer", "team-lead", "New report", "new");
82
+ const tools = new Map<string, any>();
83
+ const renderLeadInboxStatus = vi.fn(async () => {});
84
+ const resetLeadWakeNotifiedCount = vi.fn();
85
+
86
+ registerCoordinationTools({ registerTool: (tool: any) => tools.set(tool.name, tool) }, {
87
+ agentName: "team-lead",
88
+ isTeammate: false,
89
+ terminal: undefined,
90
+ getTeamName: () => teamName,
91
+ requireWriteAgentTeam: async () => teamName,
92
+ requireTeamContext: (explicitTeamName?: string) => explicitTeamName || teamName,
93
+ releaseAllClaimsForAgent: vi.fn(async () => []),
94
+ drainWriteQueue: vi.fn(async () => {}),
95
+ resolveSkillFile: vi.fn(),
96
+ adoptTeamAsLead: vi.fn(),
97
+ renderLeadInboxStatus,
98
+ resetLeadWakeNotifiedCount,
99
+ });
100
+
101
+ const result = await tools.get("read_inbox").execute(
102
+ "read",
103
+ {},
104
+ new AbortController().signal,
105
+ vi.fn(),
106
+ {}
107
+ );
108
+
109
+ expect(result.details.messages).toHaveLength(1);
110
+ expect(result.details.messages[0]).toMatchObject({ text: "New report", read: true });
111
+ expect(result.content[0].text).not.toContain("Already handled");
112
+ expect(resetLeadWakeNotifiedCount).toHaveBeenCalledOnce();
113
+ expect(renderLeadInboxStatus).toHaveBeenCalledOnce();
114
+ });
115
+
116
+ it("send_message fails when the recipient subagent is not running", async () => {
117
+ const teamName = "message-team";
118
+ writeConfig({
119
+ name: teamName,
120
+ description: "",
121
+ createdAt: Date.now(),
122
+ leadAgentId: "lead",
123
+ leadSessionId: "session",
124
+ members: [member("team-lead")],
125
+ });
126
+ const tools = new Map<string, any>();
127
+
128
+ registerCoordinationTools({ registerTool: (tool: any) => tools.set(tool.name, tool) }, {
129
+ agentName: "team-lead",
130
+ isTeammate: false,
131
+ terminal: undefined,
132
+ getTeamName: () => teamName,
133
+ requireWriteAgentTeam: async () => teamName,
134
+ requireTeamContext: (explicitTeamName?: string) => explicitTeamName || teamName,
135
+ releaseAllClaimsForAgent: vi.fn(async () => []),
136
+ drainWriteQueue: vi.fn(async () => {}),
137
+ resolveSkillFile: vi.fn(),
138
+ adoptTeamAsLead: vi.fn(),
139
+ renderLeadInboxStatus: vi.fn(async () => {}),
140
+ resetLeadWakeNotifiedCount: vi.fn(),
141
+ });
142
+
143
+ await expect(tools.get("send_message").execute("send", {
144
+ recipient: "staged-browser-designer",
145
+ content: "Continue the design.",
146
+ })).rejects.toThrow("Cannot send message to staged-browser-designer: agent is not running.");
147
+
148
+ expect(await messaging.readInbox(teamName, "staged-browser-designer", false, false)).toEqual([]);
149
+ });
150
+
151
+ it("delivers lead scope updates directly to an active in-process agent", async () => {
152
+ const teamName = "message-team";
153
+ writeConfig({
154
+ name: teamName,
155
+ description: "",
156
+ createdAt: Date.now(),
157
+ leadAgentId: "lead",
158
+ leadSessionId: "session",
159
+ members: [
160
+ member("team-lead"),
161
+ member("reader", { role: "read", tmuxPaneId: "" }),
162
+ ],
163
+ });
164
+ const tools = new Map<string, any>();
165
+ const deliverMessageToActiveAgent = vi.fn(async () => true);
166
+
167
+ registerCoordinationTools({ registerTool: (tool: any) => tools.set(tool.name, tool) }, {
168
+ agentName: "team-lead",
169
+ isTeammate: false,
170
+ terminal: undefined,
171
+ getTeamName: () => teamName,
172
+ requireWriteAgentTeam: async () => teamName,
173
+ requireTeamContext: (explicitTeamName?: string) => explicitTeamName || teamName,
174
+ releaseAllClaimsForAgent: vi.fn(async () => []),
175
+ drainWriteQueue: vi.fn(async () => {}),
176
+ resolveSkillFile: vi.fn(),
177
+ adoptTeamAsLead: vi.fn(),
178
+ renderLeadInboxStatus: vi.fn(async () => {}),
179
+ resetLeadWakeNotifiedCount: vi.fn(),
180
+ deliverMessageToActiveAgent,
181
+ });
182
+
183
+ const result = await tools.get("send_message").execute("send", {
184
+ recipient: "reader",
185
+ content: "Include the new screenshot in your audit.",
186
+ });
187
+
188
+ expect(deliverMessageToActiveAgent).toHaveBeenCalledWith(
189
+ teamName,
190
+ "reader",
191
+ "Include the new screenshot in your audit."
192
+ );
193
+ expect(result.details.delivery).toBe("active-session");
194
+ expect(await messaging.readInbox(teamName, "reader", false, false)).toEqual([]);
195
+ });
196
+
197
+ it("keeps trailing report events fenced, then verifies matching cleanup and clears the tombstone last on shutdown", async () => {
198
+ vi.useFakeTimers();
199
+ const teamName = "exit-team";
200
+ const agentName = "writer";
201
+ const runId = "writer-run";
202
+ vi.stubEnv("PI_LIFECYCLE_RUN_ID", runId);
203
+ writeConfig({
204
+ name: teamName,
205
+ description: "",
206
+ createdAt: Date.now(),
207
+ leadAgentId: "lead",
208
+ leadSessionId: "session",
209
+ members: [
210
+ member("team-lead"),
211
+ member(agentName, { lifecycleRunId: runId, tmuxPaneId: "%writer", model: "provider/model", thinking: "high", modelSlot: "writing-hard" }),
212
+ ],
213
+ });
214
+ const pidFile = path.join(paths.teamDir(teamName), `${agentName}.pid`);
215
+ fs.writeFileSync(pidFile, String(process.pid));
216
+ const privateSessionDir = privateSessionDirectory(teamName, agentName, runId);
217
+ fs.mkdirSync(privateSessionDir, { recursive: true });
218
+ fs.writeFileSync(path.join(privateSessionDir, "session.jsonl"), "private transcript\n");
219
+ await runtime.writeRuntimeStatus(teamName, agentName, runId, {
220
+ pid: process.pid,
221
+ startedAt: Date.now(),
222
+ lastHeartbeatAt: Date.now(),
223
+ ready: true,
224
+ });
225
+
226
+ const sendSpy = vi.spyOn(messaging, "sendPlainMessage").mockResolvedValue(undefined as any);
227
+ const reportEventSpy = vi.spyOn(reportEvents, "appendTeamReportEvent");
228
+ let notifyClaimReleaseStarted!: () => void;
229
+ const claimReleaseStarted = new Promise<void>((resolve) => { notifyClaimReleaseStarted = resolve; });
230
+ let finishClaimRelease!: () => void;
231
+ const releaseAllClaimsForAgent = vi.fn(() => {
232
+ notifyClaimReleaseStarted();
233
+ return new Promise<string[]>((resolve) => { finishClaimRelease = () => resolve([]); });
234
+ });
235
+ const drainWriteQueue = vi.fn(async () => {});
236
+ const terminal = { kill: vi.fn(), setTitle: vi.fn() };
237
+ const ctx = {
238
+ cwd: root,
239
+ shutdown: vi.fn(),
240
+ isIdle: () => true,
241
+ ui: { notify: vi.fn(), setTitle: vi.fn() },
242
+ sessionManager: { getBranch: vi.fn(() => []) },
243
+ };
244
+ const tools = new Map<string, any>();
245
+ const handlers = new Map<string, Function[]>();
246
+ const pi = {
247
+ registerTool: (tool: any) => tools.set(tool.name, tool),
248
+ registerMessageRenderer: vi.fn(),
249
+ on: vi.fn((eventName: string, handler: Function) => {
250
+ handlers.set(eventName, [...(handlers.get(eventName) || []), handler]);
251
+ }),
252
+ };
253
+ registerExtensionEvents(pi, {
254
+ isTeammate: true,
255
+ agentName,
256
+ getTeamName: () => teamName,
257
+ setSessionCtx: vi.fn(),
258
+ terminal,
259
+ quietTrigger: vi.fn(),
260
+ startLeadInboxPolling: vi.fn(),
261
+ startLeadWatchdog: vi.fn(),
262
+ buildRoster: vi.fn(async () => ({ teamName, members: [] })),
263
+ formatRosterForPrompt: vi.fn(() => "Roster: empty"),
264
+ });
265
+
266
+ registerCoordinationTools(pi, {
267
+ agentName,
268
+ isTeammate: true,
269
+ terminal,
270
+ getTeamName: () => teamName,
271
+ requireWriteAgentTeam: async () => teamName,
272
+ requireTeamContext: (explicitTeamName?: string) => explicitTeamName || teamName,
273
+ releaseAllClaimsForAgent,
274
+ drainWriteQueue,
275
+ resolveSkillFile: vi.fn(),
276
+ adoptTeamAsLead: vi.fn(),
277
+ renderLeadInboxStatus: vi.fn(async () => {}),
278
+ resetLeadWakeNotifiedCount: vi.fn(),
279
+ });
280
+
281
+ const report = tools.get("report_and_exit").execute(
282
+ "report",
283
+ { content: "done", summary: "Done" },
284
+ new AbortController().signal,
285
+ vi.fn(),
286
+ ctx
287
+ );
288
+ await claimReleaseStarted;
289
+ const stoppingMember = JSON.parse(fs.readFileSync(paths.configPath(teamName), "utf-8")).members
290
+ .find((item: Member) => item.name === agentName);
291
+ expect(stoppingMember?.isActive).toBe(false);
292
+
293
+ const leadTools = new Map<string, any>();
294
+ registerCoordinationTools({ registerTool: (tool: any) => leadTools.set(tool.name, tool) }, {
295
+ agentName: "team-lead",
296
+ isTeammate: false,
297
+ terminal: undefined,
298
+ getTeamName: () => teamName,
299
+ requireWriteAgentTeam: async () => teamName,
300
+ requireTeamContext: (explicitTeamName?: string) => explicitTeamName || teamName,
301
+ releaseAllClaimsForAgent: vi.fn(async () => []),
302
+ drainWriteQueue: vi.fn(async () => {}),
303
+ resolveSkillFile: vi.fn(),
304
+ adoptTeamAsLead: vi.fn(),
305
+ renderLeadInboxStatus: vi.fn(async () => {}),
306
+ resetLeadWakeNotifiedCount: vi.fn(),
307
+ });
308
+ await expect(leadTools.get("send_message").execute("send", {
309
+ recipient: agentName,
310
+ content: "One more requirement.",
311
+ })).rejects.toThrow("lifecycle-quarantined");
312
+ expect(sendSpy).toHaveBeenCalledTimes(1);
313
+
314
+ finishClaimRelease();
315
+ const result = await report;
316
+
317
+ expect(sendSpy).toHaveBeenCalledWith(
318
+ teamName,
319
+ agentName,
320
+ "team-lead",
321
+ "done",
322
+ "Done",
323
+ undefined,
324
+ { metadata: expect.objectContaining({ modelSlot: "write-system" }) }
325
+ );
326
+ expect(reportEventSpy).toHaveBeenCalledWith(teamName, expect.objectContaining({
327
+ modelSlot: "write-system",
328
+ metadata: { modelSlot: "write-system" },
329
+ }));
330
+ expect(reportEventSpy.mock.invocationCallOrder[0]).toBeLessThan(sendSpy.mock.invocationCallOrder[0]);
331
+ const [persistedReport] = await reportEvents.listTeamReportEvents(teamName, { agentName, limit: 1 });
332
+ expect(result.details.reportPath).toBe(persistedReport.reportPath);
333
+ expect(path.dirname(result.details.reportPath)).toBe(path.join(root, "agent", "reports", teamName));
334
+ expect(path.basename(result.details.reportPath)).toBe("writer.md");
335
+ expect(fs.readFileSync(result.details.reportPath, "utf-8")).toBe("done");
336
+ expect(JSON.stringify({ inbox: sendSpy.mock.calls, events: reportEventSpy.mock.calls })).not.toContain("writing-hard");
337
+ expect(fs.existsSync(pidFile)).toBe(true);
338
+ expect(fs.existsSync(privateSessionDir)).toBe(true);
339
+ expect(await runtime.readRuntimeStatus(teamName, agentName)).toMatchObject({ lifecycleRunId: runId, ready: true });
340
+ expect(releaseAllClaimsForAgent).toHaveBeenCalledWith(teamName, agentName);
341
+ expect(drainWriteQueue).not.toHaveBeenCalled();
342
+ expect(JSON.parse(fs.readFileSync(paths.configPath(teamName), "utf-8")).members.map((item: Member) => item.name)).toEqual(["team-lead", agentName]);
343
+ expect(result.content[0].text).toContain("Final report sent.");
344
+
345
+ for (const handler of handlers.get("tool_execution_end") || []) await handler({ toolName: "report_and_exit" }, ctx);
346
+ for (const handler of handlers.get("message_end") || []) await handler({ message: { role: "assistant", content: [] } }, ctx);
347
+ for (const handler of handlers.get("turn_end") || []) await handler({}, ctx);
348
+ expect(await runtime.readRuntimeStatus(teamName, agentName)).toMatchObject({ lifecycleRunId: runId, ready: true });
349
+
350
+ for (const handler of handlers.get("session_shutdown") || []) await handler({}, ctx);
351
+ expect(fs.existsSync(pidFile)).toBe(false);
352
+ expect(fs.existsSync(path.join(privateSessionDir, "session.jsonl"))).toBe(true);
353
+ expect(await runtime.readRuntimeStatus(teamName, agentName)).toBeNull();
354
+ expect(JSON.parse(fs.readFileSync(paths.configPath(teamName), "utf-8")).members.map((item: Member) => item.name)).toEqual(["team-lead"]);
355
+ await expect(readLifecycleTombstone(teamName, agentName)).resolves.toEqual({ status: "absent" });
356
+ expect(drainWriteQueue).toHaveBeenCalledWith(teamName);
357
+
358
+ await vi.runOnlyPendingTimersAsync();
359
+ expect(terminal.kill).not.toHaveBeenCalled();
360
+ expect(ctx.shutdown).toHaveBeenCalled();
361
+ });
362
+
363
+ it("retains the exact private run and does not send or release claims when report persistence fails", async () => {
364
+ const teamName = "report-persistence-failure-team";
365
+ const agentName = "writer";
366
+ const runId = "writer-run";
367
+ vi.stubEnv("PI_LIFECYCLE_RUN_ID", runId);
368
+ writeConfig({
369
+ name: teamName,
370
+ description: "",
371
+ createdAt: Date.now(),
372
+ leadAgentId: "lead",
373
+ leadSessionId: "session",
374
+ members: [
375
+ member("team-lead"),
376
+ member(agentName, { lifecycleRunId: runId, tmuxPaneId: "%writer", modelSlot: "write-critical" }),
377
+ ],
378
+ });
379
+ const privateSessionDir = privateSessionDirectory(teamName, agentName, runId);
380
+ fs.mkdirSync(privateSessionDir, { recursive: true });
381
+ fs.writeFileSync(path.join(privateSessionDir, "session.jsonl"), "recoverable writer transcript\n");
382
+ await runtime.writeRuntimeStatus(teamName, agentName, runId, {
383
+ startedAt: Date.now(),
384
+ lastHeartbeatAt: Date.now(),
385
+ ready: true,
386
+ });
387
+
388
+ const appendError = new Error("report store unavailable");
389
+ vi.spyOn(reportEvents, "appendTeamReportEvent").mockRejectedValue(appendError);
390
+ const sendPlainMessage = vi.spyOn(messaging, "sendPlainMessage").mockResolvedValue(undefined as any);
391
+ const releaseAllClaimsForAgent = vi.fn(async () => []);
392
+ const drainWriteQueue = vi.fn(async () => {});
393
+ const tools = new Map<string, any>();
394
+ const handlers = new Map<string, Function[]>();
395
+ registerCoordinationTools({
396
+ registerTool: (tool: any) => tools.set(tool.name, tool),
397
+ on: (eventName: string, handler: Function) => {
398
+ handlers.set(eventName, [...(handlers.get(eventName) || []), handler]);
399
+ },
400
+ }, {
401
+ agentName,
402
+ isTeammate: true,
403
+ terminal: undefined,
404
+ getTeamName: () => teamName,
405
+ requireWriteAgentTeam: async () => teamName,
406
+ requireTeamContext: (explicitTeamName?: string) => explicitTeamName || teamName,
407
+ releaseAllClaimsForAgent,
408
+ drainWriteQueue,
409
+ resolveSkillFile: vi.fn(),
410
+ adoptTeamAsLead: vi.fn(),
411
+ renderLeadInboxStatus: vi.fn(async () => {}),
412
+ resetLeadWakeNotifiedCount: vi.fn(),
413
+ });
414
+
415
+ await expect(tools.get("report_and_exit").execute(
416
+ "report",
417
+ { content: "must persist", summary: "Done" },
418
+ new AbortController().signal,
419
+ vi.fn(),
420
+ { cwd: root, shutdown: vi.fn() },
421
+ )).rejects.toBe(appendError);
422
+
423
+ expect(sendPlainMessage).not.toHaveBeenCalled();
424
+ expect(releaseAllClaimsForAgent).not.toHaveBeenCalled();
425
+ expect(fs.existsSync(privateSessionDir)).toBe(true);
426
+ expect(await runtime.readRuntimeStatus(teamName, agentName)).toMatchObject({ lifecycleRunId: runId, ready: true });
427
+ expect((JSON.parse(fs.readFileSync(paths.configPath(teamName), "utf-8")) as TeamConfig).members[1]).toMatchObject({
428
+ lifecycleRunId: runId,
429
+ isActive: false,
430
+ });
431
+ await expect(readLifecycleTombstone(teamName, agentName)).resolves.toMatchObject({
432
+ status: "occupied",
433
+ tombstone: {
434
+ runId,
435
+ phase: "cleanup_failed",
436
+ error: "report store unavailable",
437
+ },
438
+ });
439
+
440
+ for (const handler of handlers.get("session_shutdown") || []) await handler({}, {});
441
+ expect(fs.existsSync(privateSessionDir)).toBe(true);
442
+ expect(await runtime.readRuntimeStatus(teamName, agentName)).toMatchObject({ lifecycleRunId: runId });
443
+ await expect(readLifecycleTombstone(teamName, agentName)).resolves.toMatchObject({
444
+ status: "occupied",
445
+ tombstone: { runId, phase: "cleanup_failed" },
446
+ });
447
+ expect(drainWriteQueue).not.toHaveBeenCalled();
448
+ });
449
+
450
+ it("rejects an R1 report when the roster already belongs to R2 without fencing or changing R2", async () => {
451
+ const teamName = "stale-report-team";
452
+ const agentName = "writer";
453
+ vi.stubEnv("PI_LIFECYCLE_RUN_ID", "run-r1");
454
+ writeConfig({
455
+ name: teamName,
456
+ description: "",
457
+ createdAt: Date.now(),
458
+ leadAgentId: "lead",
459
+ leadSessionId: "session",
460
+ members: [
461
+ member("team-lead"),
462
+ member(agentName, { lifecycleRunId: "run-r2", tmuxPaneId: "%r2", isActive: true }),
463
+ ],
464
+ });
465
+ const tools = new Map<string, any>();
466
+ registerCoordinationTools({ registerTool: (tool: any) => tools.set(tool.name, tool) }, {
467
+ agentName,
468
+ isTeammate: true,
469
+ terminal: undefined,
470
+ getTeamName: () => teamName,
471
+ requireWriteAgentTeam: async () => teamName,
472
+ requireTeamContext: (explicitTeamName?: string) => explicitTeamName || teamName,
473
+ releaseAllClaimsForAgent: vi.fn(async () => []),
474
+ drainWriteQueue: vi.fn(async () => {}),
475
+ resolveSkillFile: vi.fn(),
476
+ adoptTeamAsLead: vi.fn(),
477
+ renderLeadInboxStatus: vi.fn(async () => {}),
478
+ resetLeadWakeNotifiedCount: vi.fn(),
479
+ });
480
+
481
+ await expect(tools.get("report_and_exit").execute(
482
+ "report",
483
+ { content: "stale R1 report" },
484
+ new AbortController().signal,
485
+ vi.fn(),
486
+ { cwd: root, shutdown: vi.fn() },
487
+ )).rejects.toThrow("Refusing stale report run run-r1");
488
+ expect((await runtime.readRuntimeStatus(teamName, agentName))).toBeNull();
489
+ expect((JSON.parse(fs.readFileSync(paths.configPath(teamName), "utf-8")) as TeamConfig).members[1]).toMatchObject({
490
+ lifecycleRunId: "run-r2",
491
+ isActive: true,
492
+ });
493
+ await expect(readLifecycleTombstone(teamName, agentName)).resolves.toEqual({ status: "absent" });
494
+ });
495
+
496
+ it("retains a cleanup_failed fence and never removes an R2 replacement during R1 shutdown", async () => {
497
+ vi.useFakeTimers();
498
+ const teamName = "replacement-exit-team";
499
+ const agentName = "writer";
500
+ const runId = "run-r1";
501
+ vi.stubEnv("PI_LIFECYCLE_RUN_ID", runId);
502
+ writeConfig({
503
+ name: teamName,
504
+ description: "",
505
+ createdAt: Date.now(),
506
+ leadAgentId: "lead",
507
+ leadSessionId: "session",
508
+ members: [
509
+ member("team-lead"),
510
+ member(agentName, { lifecycleRunId: runId, tmuxPaneId: "%r1" }),
511
+ ],
512
+ });
513
+ const privateSessionDir = privateSessionDirectory(teamName, agentName, runId);
514
+ fs.mkdirSync(privateSessionDir, { recursive: true });
515
+ fs.writeFileSync(path.join(privateSessionDir, "session.jsonl"), "retain on cleanup failure\n");
516
+ await runtime.writeRuntimeStatus(teamName, agentName, runId, {
517
+ startedAt: Date.now(),
518
+ lastHeartbeatAt: Date.now(),
519
+ ready: true,
520
+ });
521
+ const tools = new Map<string, any>();
522
+ const handlers = new Map<string, Function[]>();
523
+ registerCoordinationTools({
524
+ registerTool: (tool: any) => tools.set(tool.name, tool),
525
+ on: (eventName: string, handler: Function) => {
526
+ handlers.set(eventName, [...(handlers.get(eventName) || []), handler]);
527
+ },
528
+ }, {
529
+ agentName,
530
+ isTeammate: true,
531
+ terminal: undefined,
532
+ getTeamName: () => teamName,
533
+ requireWriteAgentTeam: async () => teamName,
534
+ requireTeamContext: (explicitTeamName?: string) => explicitTeamName || teamName,
535
+ releaseAllClaimsForAgent: vi.fn(async () => []),
536
+ drainWriteQueue: vi.fn(async () => {}),
537
+ resolveSkillFile: vi.fn(),
538
+ adoptTeamAsLead: vi.fn(),
539
+ renderLeadInboxStatus: vi.fn(async () => {}),
540
+ resetLeadWakeNotifiedCount: vi.fn(),
541
+ });
542
+
543
+ await tools.get("report_and_exit").execute(
544
+ "report",
545
+ { content: "R1 done", summary: "Done" },
546
+ new AbortController().signal,
547
+ vi.fn(),
548
+ { cwd: root, shutdown: vi.fn() },
549
+ );
550
+
551
+ const replacement = member(agentName, {
552
+ lifecycleRunId: "run-r2",
553
+ tmuxPaneId: "%r2",
554
+ joinedAt: Date.now() + 1,
555
+ isActive: true,
556
+ });
557
+ const config = JSON.parse(fs.readFileSync(paths.configPath(teamName), "utf-8")) as TeamConfig;
558
+ config.members = [config.members[0], replacement];
559
+ fs.writeFileSync(paths.configPath(teamName), JSON.stringify(config, null, 2));
560
+
561
+ for (const handler of handlers.get("session_shutdown") || []) await handler({}, {});
562
+ await expect(readLifecycleTombstone(teamName, agentName)).resolves.toMatchObject({
563
+ status: "occupied",
564
+ tombstone: {
565
+ runId,
566
+ phase: "cleanup_failed",
567
+ error: expect.stringContaining("replacement run run-r2"),
568
+ },
569
+ });
570
+ expect((await runtime.readRuntimeStatus(teamName, agentName))?.lifecycleRunId).toBe(runId);
571
+ expect(fs.existsSync(privateSessionDir)).toBe(true);
572
+ expect((JSON.parse(fs.readFileSync(paths.configPath(teamName), "utf-8")) as TeamConfig).members[1]).toMatchObject({
573
+ lifecycleRunId: "run-r2",
574
+ tmuxPaneId: "%r2",
575
+ isActive: true,
576
+ });
577
+
578
+ for (const handler of handlers.get("session_shutdown") || []) await handler({}, {});
579
+ await expect(readLifecycleTombstone(teamName, agentName)).resolves.toMatchObject({
580
+ status: "occupied",
581
+ tombstone: { runId, phase: "cleanup_failed" },
582
+ });
583
+ });
584
+
585
+ it("retains the R1 fence, runtime, and member when PID cleanup is denied during shutdown", async () => {
586
+ const teamName = "pid-cleanup-failure-team";
587
+ const agentName = "writer";
588
+ const runId = "run-r1";
589
+ vi.stubEnv("PI_LIFECYCLE_RUN_ID", runId);
590
+ writeConfig({
591
+ name: teamName,
592
+ description: "",
593
+ createdAt: Date.now(),
594
+ leadAgentId: "lead",
595
+ leadSessionId: "session",
596
+ members: [
597
+ member("team-lead"),
598
+ member(agentName, { lifecycleRunId: runId, tmuxPaneId: "%r1" }),
599
+ ],
600
+ });
601
+ const pidFile = path.join(paths.teamDir(teamName), `${agentName}.pid`);
602
+ fs.writeFileSync(pidFile, String(process.pid));
603
+ await runtime.writeRuntimeStatus(teamName, agentName, runId, {
604
+ pid: process.pid,
605
+ startedAt: Date.now(),
606
+ lastHeartbeatAt: Date.now(),
607
+ ready: true,
608
+ });
609
+
610
+ const tools = new Map<string, any>();
611
+ const handlers = new Map<string, Function[]>();
612
+ const drainWriteQueue = vi.fn(async () => {});
613
+ registerCoordinationTools({
614
+ registerTool: (tool: any) => tools.set(tool.name, tool),
615
+ on: (eventName: string, handler: Function) => {
616
+ handlers.set(eventName, [...(handlers.get(eventName) || []), handler]);
617
+ },
618
+ }, {
619
+ agentName,
620
+ isTeammate: true,
621
+ terminal: undefined,
622
+ getTeamName: () => teamName,
623
+ requireWriteAgentTeam: async () => teamName,
624
+ requireTeamContext: (explicitTeamName?: string) => explicitTeamName || teamName,
625
+ releaseAllClaimsForAgent: vi.fn(async () => []),
626
+ drainWriteQueue,
627
+ resolveSkillFile: vi.fn(),
628
+ adoptTeamAsLead: vi.fn(),
629
+ renderLeadInboxStatus: vi.fn(async () => {}),
630
+ resetLeadWakeNotifiedCount: vi.fn(),
631
+ });
632
+
633
+ await tools.get("report_and_exit").execute(
634
+ "report",
635
+ { content: "R1 done", summary: "Done" },
636
+ new AbortController().signal,
637
+ vi.fn(),
638
+ { cwd: root, shutdown: vi.fn() },
639
+ );
640
+
641
+ const unlinkSync = fs.unlinkSync;
642
+ vi.spyOn(fs, "unlinkSync").mockImplementation(filePath => {
643
+ if (String(filePath) === pidFile) {
644
+ const error = Object.assign(new Error("permission denied"), { code: "EACCES" });
645
+ throw error;
646
+ }
647
+ return unlinkSync(filePath);
648
+ });
649
+ syncBuiltinESMExports();
650
+ for (const handler of handlers.get("session_shutdown") || []) await handler({}, {});
651
+
652
+ expect(fs.existsSync(pidFile)).toBe(true);
653
+ await expect(runtime.readRuntimeStatus(teamName, agentName)).resolves.toMatchObject({ lifecycleRunId: runId, ready: true });
654
+ expect((JSON.parse(fs.readFileSync(paths.configPath(teamName), "utf-8")) as TeamConfig).members[1]).toMatchObject({
655
+ lifecycleRunId: runId,
656
+ isActive: false,
657
+ });
658
+ await expect(readLifecycleTombstone(teamName, agentName)).resolves.toEqual({
659
+ status: "occupied",
660
+ tombstone: expect.objectContaining({
661
+ runId,
662
+ phase: "cleanup_failed",
663
+ error: `Could not remove PID file for ${agentName} run ${runId}.`,
664
+ }),
665
+ });
666
+ expect(drainWriteQueue).not.toHaveBeenCalled();
667
+ await expect(runtime.writeRuntimeStatus(teamName, agentName, runId, { ready: false }))
668
+ .rejects.toThrow(`lifecycle run ${runId} is cleanup_failed`);
669
+ });
670
+ });