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,647 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { registerPredefinedTools } from "./predefined-tools.js";
6
+ import * as teams from "../../src/utils/teams.js";
7
+ import * as paths from "../../src/utils/paths.js";
8
+ import * as messaging from "../../src/utils/messaging.js";
9
+ import * as runtime from "../../src/utils/runtime.js";
10
+ import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone.js";
11
+ import type { Member } from "../../src/utils/models.js";
12
+
13
+ type RegisteredTool = {
14
+ name: string;
15
+ description?: string;
16
+ parameters?: any;
17
+ execute: (toolCallId: string, params: any, signal: AbortSignal, onUpdate: any, ctx: any) => Promise<any>;
18
+ };
19
+
20
+ let root: string;
21
+
22
+ function writePredefinedFixture() {
23
+ fs.mkdirSync(path.join(root, ".pi", "agents"), { recursive: true });
24
+ fs.writeFileSync(path.join(root, ".pi", "teams.yaml"), "writers:\n - writer\n");
25
+ fs.writeFileSync(path.join(root, ".pi", "agents", "writer.md"), "---\nname: writer\ndescription: Applies a change\n---\nImplement the assigned change.\n");
26
+ const settingsPath = path.join(root, ".pi", "agent", "pi-extended-teams", "settings.json");
27
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
28
+ fs.writeFileSync(settingsPath, JSON.stringify({
29
+ favoriteModels: {
30
+ "read-review": { model: "provider/model", thinking: "xhigh" },
31
+ "writing-hard": { model: "provider/model", thinking: "xhigh" },
32
+ "write-critical": { model: "provider/model", thinking: "max" },
33
+ },
34
+ }));
35
+ }
36
+
37
+ function writeProjectSessionSetting(projectDir: string, showInResume: boolean) {
38
+ const settingsPath = path.join(projectDir, ".pi", "pi-extended-teams.json");
39
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
40
+ fs.writeFileSync(settingsPath, JSON.stringify({ agentSessions: { showInResume } }));
41
+ }
42
+
43
+ function registerTools(terminal: { spawn: ReturnType<typeof vi.fn> } = { spawn: vi.fn(() => "%writer") }) {
44
+ const tools = new Map<string, RegisteredTool>();
45
+ registerPredefinedTools({
46
+ registerTool: (tool: RegisteredTool) => tools.set(tool.name, tool),
47
+ }, {
48
+ terminal,
49
+ adoptTeamAsLead: vi.fn(),
50
+ isTeammate: false,
51
+ agentName: "team-lead",
52
+ getTeamName: () => null,
53
+ });
54
+ return tools;
55
+ }
56
+
57
+ function makeCtx() {
58
+ return {
59
+ cwd: root,
60
+ model: { provider: "provider", id: "model" },
61
+ modelRegistry: {
62
+ getAvailable: vi.fn(async () => [{ provider: "provider", id: "model" }]),
63
+ find: vi.fn(() => ({ provider: "provider", id: "model" })),
64
+ },
65
+ isProjectTrusted: vi.fn(() => true),
66
+ };
67
+ }
68
+
69
+ function installReplacementArtifacts(teamName: string, replacementRunId: string) {
70
+ const configFile = paths.configPath(teamName);
71
+ const config = JSON.parse(fs.readFileSync(configFile, "utf8"));
72
+ const currentMember = config.members.find((member: Member) => member.name === "writer");
73
+ const member = { ...currentMember, lifecycleRunId: replacementRunId, tmuxPaneId: "%r2" };
74
+ config.members = config.members.map((item: Member) => item.name === "writer" ? member : item);
75
+ fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
76
+
77
+ const runtimeStatus = {
78
+ teamName,
79
+ agentName: "writer",
80
+ lifecycleRunId: replacementRunId,
81
+ currentAction: "working",
82
+ };
83
+ fs.writeFileSync(paths.runtimeStatusPath(teamName, "writer"), JSON.stringify(runtimeStatus, null, 2));
84
+
85
+ const inbox = [{
86
+ from: "team-lead",
87
+ text: "R2 prompt",
88
+ summary: "R2 bootstrap",
89
+ timestamp: new Date(0).toISOString(),
90
+ read: false,
91
+ operationId: `bootstrap:${replacementRunId}:initial-prompt`,
92
+ }];
93
+ fs.writeFileSync(paths.inboxPath(teamName, "writer"), JSON.stringify(inbox, null, 2));
94
+
95
+ const sessionMarker = path.join(
96
+ paths.teamDir(teamName),
97
+ "agent-sessions",
98
+ "writer",
99
+ replacementRunId,
100
+ "r2-session",
101
+ );
102
+ fs.mkdirSync(path.dirname(sessionMarker), { recursive: true, mode: 0o700 });
103
+ fs.writeFileSync(sessionMarker, "R2");
104
+
105
+ const tombstone = {
106
+ version: 1,
107
+ team: teamName,
108
+ agent: "writer",
109
+ runId: replacementRunId,
110
+ role: "write",
111
+ reason: "replacement",
112
+ phase: "closing",
113
+ ownerPid: process.pid,
114
+ extensionInstanceId: "replacement-extension",
115
+ timestamps: { createdAt: 1, updatedAt: 1 },
116
+ };
117
+ const tombstoneFile = paths.lifecycleTombstonePath(teamName, "writer");
118
+ fs.mkdirSync(path.dirname(tombstoneFile), { recursive: true });
119
+ fs.writeFileSync(tombstoneFile, JSON.stringify(tombstone, null, 2));
120
+
121
+ return { member, runtimeStatus, inbox, sessionMarker, tombstone };
122
+ }
123
+
124
+ describe("create_predefined_team tiers", () => {
125
+ beforeEach(() => {
126
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-predefined-"));
127
+ vi.spyOn(os, "homedir").mockReturnValue(root);
128
+ vi.spyOn(paths, "teamDir").mockImplementation(teamName => path.join(root, ".pi", "teams", String(teamName)));
129
+ vi.spyOn(paths, "configPath").mockImplementation(teamName => path.join(root, ".pi", "teams", String(teamName), "config.json"));
130
+ vi.spyOn(paths, "inboxPath").mockImplementation((teamName, agentName) => path.join(root, ".pi", "teams", String(teamName), "inboxes", `${String(agentName)}.json`));
131
+ vi.spyOn(paths, "runtimeStatusPath").mockImplementation((teamName, agentName) => path.join(root, ".pi", "teams", String(teamName), "runtime", `${String(agentName)}.json`));
132
+ vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation((teamName, agentName) => path.join(root, ".pi", "teams", String(teamName), "lifecycle", "quarantine", `${String(agentName)}.json`));
133
+ writePredefinedFixture();
134
+ });
135
+
136
+ afterEach(() => {
137
+ vi.restoreAllMocks();
138
+ fs.rmSync(root, { recursive: true, force: true });
139
+ });
140
+
141
+ it("defaults to write-critical and canonicalizes legacy model_slot input in outward results", async () => {
142
+ const terminal = { spawn: vi.fn(() => "%writer") };
143
+ const tools = registerTools(terminal);
144
+ const create = tools.get("create_predefined_team")!;
145
+ const ctx = makeCtx();
146
+
147
+ expect(create.description).toContain("write-critical");
148
+ expect(create.parameters.properties.model_slot.description).toContain("write-critical");
149
+
150
+ const defaultResult = await create.execute("default", {
151
+ team_name: "default-writers",
152
+ predefined_team: "writers",
153
+ cwd: root,
154
+ }, new AbortController().signal, undefined, ctx);
155
+ const legacyResult = await create.execute("legacy", {
156
+ team_name: "legacy-writers",
157
+ predefined_team: "writers",
158
+ cwd: root,
159
+ model_slot: "writing-hard",
160
+ }, new AbortController().signal, undefined, ctx);
161
+
162
+ expect(defaultResult.details).toMatchObject({ modelSlot: "write-critical" });
163
+ expect(legacyResult.details).toMatchObject({ modelSlot: "write-system" });
164
+ const defaultWriter = (await teams.readConfig("default-writers")).members.find(member => member.name === "writer")!;
165
+ expect(defaultWriter).toMatchObject({
166
+ modelSlot: "write-critical",
167
+ role: "write",
168
+ thinking: "max",
169
+ });
170
+ const defaultSpawn = (terminal.spawn.mock.calls as any[][])[0][0];
171
+ expect(defaultSpawn.command).toContain("--model 'provider/model:max'");
172
+ expect(defaultSpawn.command).toContain(
173
+ `--session-dir '${path.join(paths.teamDir("default-writers"), "agent-sessions", "writer", defaultWriter.lifecycleRunId!)}'`,
174
+ );
175
+ expect(defaultSpawn.env).toMatchObject({
176
+ PI_TEAM_NAME: "default-writers",
177
+ PI_AGENT_NAME: "writer",
178
+ PI_LIFECYCLE_RUN_ID: defaultWriter.lifecycleRunId,
179
+ });
180
+ expect(await runtime.readRuntimeStatus("default-writers", "writer")).toMatchObject({
181
+ lifecycleRunId: defaultWriter.lifecycleRunId,
182
+ currentAction: "starting",
183
+ });
184
+ expect(await messaging.peekInbox("default-writers", "writer", false)).toEqual([
185
+ expect.objectContaining({
186
+ text: "Implement the assigned change.",
187
+ operationId: `bootstrap:${defaultWriter.lifecycleRunId}:initial-prompt`,
188
+ }),
189
+ ]);
190
+ expect((await teams.readConfig("legacy-writers")).members.find(member => member.name === "writer")).toMatchObject({
191
+ modelSlot: "write-system",
192
+ role: "write",
193
+ });
194
+ });
195
+
196
+ it("refuses to recreate a team that already has members", async () => {
197
+ const create = registerTools().get("create_predefined_team")!;
198
+ const ctx = makeCtx();
199
+
200
+ await create.execute("first", {
201
+ team_name: "live-writers",
202
+ predefined_team: "writers",
203
+ cwd: root,
204
+ }, new AbortController().signal, undefined, ctx);
205
+
206
+ // An agent spawned separately into the same team, e.g. via spawn_agent.
207
+ await teams.addMember("live-writers", {
208
+ agentId: "reviewer@live-writers",
209
+ name: "reviewer",
210
+ agentType: "teammate",
211
+ role: "read",
212
+ model: "provider/model",
213
+ thinking: "high",
214
+ joinedAt: Date.now(),
215
+ tmuxPaneId: "",
216
+ cwd: root,
217
+ subscriptions: [],
218
+ } as Member);
219
+
220
+ const before = (await teams.readConfig("live-writers")).members.map(member => member.name);
221
+ expect(before).toContain("writer");
222
+ expect(before).toContain("reviewer");
223
+
224
+ await expect(create.execute("second", {
225
+ team_name: "live-writers",
226
+ predefined_team: "writers",
227
+ cwd: root,
228
+ }, new AbortController().signal, undefined, ctx)).rejects.toThrow(/already exists/);
229
+
230
+ const after = (await teams.readConfig("live-writers")).members.map(member => member.name);
231
+ expect(after).toEqual(before);
232
+ });
233
+
234
+ it("preserves a concurrently created team when exclusive creation loses", async () => {
235
+ const terminal = { spawn: vi.fn(() => "%writer") };
236
+ const create = registerTools(terminal).get("create_predefined_team")!;
237
+ const configFile = paths.configPath("raced-writers");
238
+ const winnerBytes = JSON.stringify({
239
+ name: "raced-writers",
240
+ description: "Concurrent winner",
241
+ createdAt: 1,
242
+ leadAgentId: "winner-lead",
243
+ leadSessionId: "winner-session",
244
+ members: [
245
+ {
246
+ agentId: "winner-lead",
247
+ name: "team-lead",
248
+ agentType: "lead",
249
+ joinedAt: 1,
250
+ tmuxPaneId: "",
251
+ cwd: root,
252
+ subscriptions: [],
253
+ },
254
+ {
255
+ agentId: "winner@raced-writers",
256
+ name: "winner",
257
+ agentType: "teammate",
258
+ joinedAt: 1,
259
+ tmuxPaneId: "",
260
+ cwd: root,
261
+ subscriptions: [],
262
+ },
263
+ ],
264
+ }, null, 2);
265
+ const writeFileSync = fs.writeFileSync.bind(fs) as any;
266
+ let winnerCreated = false;
267
+ vi.spyOn(fs, "writeFileSync").mockImplementation(((file: any, data: any, options?: any) => {
268
+ if (!winnerCreated && String(file) === configFile) {
269
+ winnerCreated = true;
270
+ writeFileSync(configFile, winnerBytes);
271
+ }
272
+ return writeFileSync(file, data, options);
273
+ }) as any);
274
+
275
+ await expect(create.execute("loser", {
276
+ team_name: "raced-writers",
277
+ predefined_team: "writers",
278
+ cwd: root,
279
+ }, new AbortController().signal, undefined, makeCtx())).rejects.toThrow(
280
+ 'Team "raced-writers" already exists. Choose a different team_name, or stop that team first.'
281
+ );
282
+
283
+ expect(winnerCreated).toBe(true);
284
+ expect(fs.readFileSync(configFile, "utf-8")).toBe(winnerBytes);
285
+ expect(terminal.spawn).not.toHaveBeenCalled();
286
+ });
287
+
288
+ it("retains createTeam's replacement semantics for existing callers", async () => {
289
+ teams.createTeam("replaceable", "first-session", "first-lead");
290
+ await teams.addMember("replaceable", {
291
+ agentId: "writer@replaceable",
292
+ name: "writer",
293
+ agentType: "teammate",
294
+ joinedAt: 1,
295
+ tmuxPaneId: "",
296
+ cwd: root,
297
+ subscriptions: [],
298
+ } as Member);
299
+
300
+ teams.createTeam("replaceable", "replacement-session", "replacement-lead");
301
+
302
+ expect(await teams.readConfig("replaceable")).toMatchObject({
303
+ leadAgentId: "replacement-lead",
304
+ leadSessionId: "replacement-session",
305
+ members: [{ agentId: "replacement-lead", name: "team-lead" }],
306
+ });
307
+ });
308
+
309
+ it("preserves unexpected config creation failures", async () => {
310
+ const create = registerTools().get("create_predefined_team")!;
311
+ const configFile = paths.configPath("unwritable-writers");
312
+ const failure = Object.assign(new Error("filesystem unavailable"), { code: "EACCES" });
313
+ const writeFileSync = fs.writeFileSync.bind(fs) as any;
314
+ vi.spyOn(fs, "writeFileSync").mockImplementation(((file: any, data: any, options?: any) => {
315
+ if (String(file) === configFile) throw failure;
316
+ return writeFileSync(file, data, options);
317
+ }) as any);
318
+
319
+ await expect(create.execute("io-failure", {
320
+ team_name: "unwritable-writers",
321
+ predefined_team: "writers",
322
+ cwd: root,
323
+ }, new AbortController().signal, undefined, makeCtx())).rejects.toBe(failure);
324
+ });
325
+
326
+ it("uses Pi's resumable session store only after an explicit opt-in", async () => {
327
+ const settingsPath = path.join(root, ".pi", "agent", "pi-extended-teams", "settings.json");
328
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
329
+ settings.agentSessions = { showInResume: true };
330
+ fs.writeFileSync(settingsPath, JSON.stringify(settings));
331
+ const terminal = { spawn: vi.fn(() => "%writer") };
332
+ const create = registerTools(terminal).get("create_predefined_team")!;
333
+
334
+ await create.execute("resumable", {
335
+ team_name: "resumable-writers",
336
+ predefined_team: "writers",
337
+ cwd: root,
338
+ }, new AbortController().signal, undefined, makeCtx());
339
+
340
+ const spawn = (terminal.spawn.mock.calls as any[][])[0]?.[0];
341
+ expect(spawn.command).not.toContain("--session-dir");
342
+ });
343
+
344
+ it("resolves session visibility from the spawned target project, not the lead project", async () => {
345
+ const privateTarget = path.join(root, "private-target");
346
+ const resumableTarget = path.join(root, "resumable-target");
347
+ fs.mkdirSync(privateTarget, { recursive: true });
348
+ fs.mkdirSync(resumableTarget, { recursive: true });
349
+ writeProjectSessionSetting(root, true);
350
+ writeProjectSessionSetting(privateTarget, false);
351
+ writeProjectSessionSetting(resumableTarget, true);
352
+
353
+ const terminal = { spawn: vi.fn(() => "%writer") };
354
+ const create = registerTools(terminal).get("create_predefined_team")!;
355
+ const leadCtx = makeCtx();
356
+
357
+ await create.execute("private-target", {
358
+ team_name: "private-target-writers",
359
+ predefined_team: "writers",
360
+ cwd: privateTarget,
361
+ }, new AbortController().signal, undefined, leadCtx);
362
+ const privateWriter = (await teams.readConfig("private-target-writers")).members.find(member => member.name === "writer")!;
363
+ const privateSpawn = (terminal.spawn.mock.calls as any[][])[0][0];
364
+ expect(privateSpawn.command).toContain(
365
+ `--session-dir '${path.join(paths.teamDir("private-target-writers"), "agent-sessions", "writer", privateWriter.lifecycleRunId!)}'`,
366
+ );
367
+
368
+ writeProjectSessionSetting(root, false);
369
+ await create.execute("resumable-target", {
370
+ team_name: "resumable-target-writers",
371
+ predefined_team: "writers",
372
+ cwd: resumableTarget,
373
+ }, new AbortController().signal, undefined, leadCtx);
374
+ const resumableSpawn = (terminal.spawn.mock.calls as any[][])[1][0];
375
+ expect(resumableSpawn.command).not.toContain("--session-dir");
376
+ });
377
+
378
+ it("rejects direct thinking from a predefined agent definition", async () => {
379
+ fs.writeFileSync(
380
+ path.join(root, ".pi", "agents", "writer.md"),
381
+ "---\nname: writer\ndescription: Applies a change\nthinking: high\n---\nImplement the assigned change.\n",
382
+ );
383
+ const terminal = { spawn: vi.fn(() => "%writer") };
384
+ const create = registerTools(terminal).get("create_predefined_team")!;
385
+
386
+ const result = await create.execute("direct-thinking", {
387
+ team_name: "direct-thinking-writers",
388
+ predefined_team: "writers",
389
+ cwd: root,
390
+ }, new AbortController().signal, undefined, makeCtx());
391
+
392
+ expect(result.details.results).toEqual([
393
+ expect.objectContaining({ status: "error", error: expect.stringContaining("must not declare direct model or thinking") }),
394
+ ]);
395
+ expect(terminal.spawn).not.toHaveBeenCalled();
396
+ });
397
+
398
+ it.runIf(process.platform !== "win32")("rolls back admission when private session storage is untrusted", async () => {
399
+ const teamDirectory = paths.teamDir("unsafe-session-writers");
400
+ const external = path.join(root, "external-sessions");
401
+ fs.mkdirSync(teamDirectory, { recursive: true });
402
+ fs.mkdirSync(external);
403
+ fs.symlinkSync(external, path.join(teamDirectory, "agent-sessions"), "dir");
404
+ const terminal = { spawn: vi.fn(() => "%writer") };
405
+ const create = registerTools(terminal).get("create_predefined_team")!;
406
+
407
+ const result = await create.execute("unsafe-session", {
408
+ team_name: "unsafe-session-writers",
409
+ predefined_team: "writers",
410
+ cwd: root,
411
+ }, new AbortController().signal, undefined, makeCtx());
412
+
413
+ expect(result.details.results).toEqual([
414
+ expect.objectContaining({ status: "error", error: expect.stringMatching(/private agent session directory/i) }),
415
+ ]);
416
+ expect((await teams.readConfig("unsafe-session-writers")).members.find(member => member.name === "writer")).toBeUndefined();
417
+ expect(await runtime.readRuntimeStatus("unsafe-session-writers", "writer")).toBeNull();
418
+ expect(terminal.spawn).not.toHaveBeenCalled();
419
+ });
420
+
421
+ it("stops and verifies the exact spawned pane before rolling back an updateMember failure", async () => {
422
+ vi.spyOn(teams, "updateMember").mockRejectedValueOnce(new Error("update failed"));
423
+ const deleteRuntimeStatus = vi.spyOn(runtime, "deleteRuntimeStatusUnderLifecycleLock");
424
+ const terminal = {
425
+ spawn: vi.fn(() => "%writer"),
426
+ kill: vi.fn(),
427
+ isAlive: vi.fn(() => false),
428
+ };
429
+ const create = registerTools(terminal).get("create_predefined_team")!;
430
+
431
+ const result = await create.execute("update-failure", {
432
+ team_name: "update-failed-writers",
433
+ predefined_team: "writers",
434
+ cwd: root,
435
+ }, new AbortController().signal, undefined, makeCtx());
436
+
437
+ const runId = (terminal.spawn.mock.calls as any[][])[0][0].env.PI_LIFECYCLE_RUN_ID;
438
+ expect(result.details.results).toEqual([
439
+ expect.objectContaining({ status: "error", error: expect.stringContaining("Failed to spawn: Error: update failed") }),
440
+ ]);
441
+ expect(terminal.kill).toHaveBeenCalledWith("%writer");
442
+ expect(terminal.isAlive).toHaveBeenCalledWith("%writer");
443
+ expect((await teams.readConfig("update-failed-writers")).members.find(member => member.name === "writer")).toBeUndefined();
444
+ expect(await runtime.readRuntimeStatus("update-failed-writers", "writer")).toBeNull();
445
+ expect(deleteRuntimeStatus).toHaveBeenCalledWith("update-failed-writers", "writer", runId);
446
+ expect(terminal.isAlive.mock.invocationCallOrder[0]).toBeLessThan(deleteRuntimeStatus.mock.invocationCallOrder[0]!);
447
+ expect(await messaging.readInbox("update-failed-writers", "writer", false, false)).toEqual([]);
448
+ expect(fs.existsSync(path.join(paths.teamDir("update-failed-writers"), "agent-sessions", "writer", runId))).toBe(false);
449
+ expect(await readLifecycleTombstone("update-failed-writers", "writer")).toEqual({ status: "absent" });
450
+ });
451
+
452
+ it("stops and verifies a returned R1 pane before rollback reads and leaves all R2 artifacts untouched", async () => {
453
+ const configReads = vi.spyOn(teams, "readConfig");
454
+ vi.spyOn(teams, "updateMember").mockRejectedValueOnce(new Error("update failed"));
455
+ const replacementRunId = "replacement-run";
456
+ let artifacts!: ReturnType<typeof installReplacementArtifacts>;
457
+ const terminal = {
458
+ spawn: vi.fn(() => "%r1"),
459
+ kill: vi.fn(() => {
460
+ artifacts = installReplacementArtifacts("replacement-writers", replacementRunId);
461
+ }),
462
+ isAlive: vi.fn(() => false),
463
+ };
464
+ const create = registerTools(terminal).get("create_predefined_team")!;
465
+
466
+ const result = await create.execute("replacement-race", {
467
+ team_name: "replacement-writers",
468
+ predefined_team: "writers",
469
+ cwd: root,
470
+ }, new AbortController().signal, undefined, makeCtx());
471
+
472
+ expect(result.details.results).toEqual([
473
+ expect.objectContaining({ status: "error", error: expect.stringContaining("Failed to spawn: Error: update failed") }),
474
+ ]);
475
+ expect(terminal.kill).toHaveBeenCalledWith("%r1");
476
+ expect(terminal.isAlive).toHaveBeenCalledWith("%r1");
477
+ expect(configReads).toHaveBeenCalledTimes(1);
478
+ expect((await teams.readConfig("replacement-writers")).members.find(member => member.name === "writer")).toEqual(artifacts.member);
479
+ expect(await runtime.readRuntimeStatus("replacement-writers", "writer")).toEqual(artifacts.runtimeStatus);
480
+ expect(await messaging.peekInbox("replacement-writers", "writer", false)).toEqual(artifacts.inbox);
481
+ expect(fs.readFileSync(artifacts.sessionMarker, "utf8")).toBe("R2");
482
+ expect(await readLifecycleTombstone("replacement-writers", "writer")).toEqual({
483
+ status: "occupied",
484
+ tombstone: artifacts.tombstone,
485
+ });
486
+ });
487
+
488
+ it("does not fence or delete R2 artifacts when R1 pane termination cannot be proven", async () => {
489
+ const configReads = vi.spyOn(teams, "readConfig");
490
+ vi.spyOn(teams, "updateMember").mockRejectedValueOnce(new Error("update failed"));
491
+ const replacementRunId = "replacement-run";
492
+ let artifacts!: ReturnType<typeof installReplacementArtifacts>;
493
+ const terminal = {
494
+ spawn: vi.fn(() => "%r1"),
495
+ kill: vi.fn(() => {
496
+ artifacts = installReplacementArtifacts("unproven-replacement-writers", replacementRunId);
497
+ }),
498
+ isAlive: vi.fn(() => true),
499
+ };
500
+ const create = registerTools(terminal).get("create_predefined_team")!;
501
+
502
+ const result = await create.execute("unproven-replacement-race", {
503
+ team_name: "unproven-replacement-writers",
504
+ predefined_team: "writers",
505
+ cwd: root,
506
+ }, new AbortController().signal, undefined, makeCtx());
507
+
508
+ expect(result.details.results).toEqual([
509
+ expect.objectContaining({ status: "error", error: expect.stringContaining("Failed to spawn: Error: update failed") }),
510
+ ]);
511
+ expect(terminal.kill).toHaveBeenCalledWith("%r1");
512
+ expect(terminal.isAlive).toHaveBeenCalledWith("%r1");
513
+ expect(configReads).toHaveBeenCalledTimes(1);
514
+ expect((await teams.readConfig("unproven-replacement-writers")).members.find(member => member.name === "writer")).toEqual(artifacts.member);
515
+ expect(await runtime.readRuntimeStatus("unproven-replacement-writers", "writer")).toEqual(artifacts.runtimeStatus);
516
+ expect(await messaging.peekInbox("unproven-replacement-writers", "writer", false)).toEqual(artifacts.inbox);
517
+ expect(fs.readFileSync(artifacts.sessionMarker, "utf8")).toBe("R2");
518
+ expect(await readLifecycleTombstone("unproven-replacement-writers", "writer")).toEqual({
519
+ status: "occupied",
520
+ tombstone: artifacts.tombstone,
521
+ });
522
+ });
523
+
524
+ it("fences the exact spawned pane and retains recovery artifacts when termination cannot be proven", async () => {
525
+ vi.spyOn(teams, "updateMember").mockRejectedValueOnce(new Error("update failed"));
526
+ const terminal = {
527
+ spawn: vi.fn(() => "%writer"),
528
+ kill: vi.fn(),
529
+ isAlive: vi.fn(() => true),
530
+ };
531
+ const create = registerTools(terminal).get("create_predefined_team")!;
532
+
533
+ const result = await create.execute("termination-failure", {
534
+ team_name: "retained-writers",
535
+ predefined_team: "writers",
536
+ cwd: root,
537
+ }, new AbortController().signal, undefined, makeCtx());
538
+
539
+ const runId = (terminal.spawn.mock.calls as any[][])[0][0].env.PI_LIFECYCLE_RUN_ID;
540
+ expect(result.details.results).toEqual([
541
+ expect.objectContaining({ status: "error", error: expect.stringContaining("Failed to spawn: Error: update failed") }),
542
+ ]);
543
+ expect(terminal.kill).toHaveBeenCalledWith("%writer");
544
+ expect(terminal.isAlive).toHaveBeenCalledWith("%writer");
545
+ expect((await teams.readConfig("retained-writers")).members.find(member => member.name === "writer")).toMatchObject({
546
+ lifecycleRunId: runId,
547
+ });
548
+ expect(await runtime.readRuntimeStatus("retained-writers", "writer")).toMatchObject({ lifecycleRunId: runId });
549
+ expect(await messaging.peekInbox("retained-writers", "writer", false)).toEqual([
550
+ expect.objectContaining({ operationId: `bootstrap:${runId}:initial-prompt` }),
551
+ ]);
552
+ expect(fs.existsSync(path.join(paths.teamDir("retained-writers"), "agent-sessions", "writer", runId))).toBe(true);
553
+ expect(await readLifecycleTombstone("retained-writers", "writer")).toMatchObject({
554
+ status: "occupied",
555
+ tombstone: {
556
+ runId,
557
+ phase: "cleanup_failed",
558
+ error: expect.stringContaining("%writer"),
559
+ },
560
+ });
561
+ });
562
+
563
+ it("rolls back only the admitted run when terminal spawn fails and permits same-name readmission", async () => {
564
+ const terminal = { spawn: vi.fn(() => { throw new Error("spawn failed"); }) };
565
+ const create = registerTools(terminal).get("create_predefined_team")!;
566
+
567
+ const result = await create.execute("spawn-failure", {
568
+ team_name: "failed-writers",
569
+ predefined_team: "writers",
570
+ cwd: root,
571
+ }, new AbortController().signal, undefined, makeCtx());
572
+
573
+ const failedRunId = (terminal.spawn.mock.calls as any[][])[0][0].env.PI_LIFECYCLE_RUN_ID;
574
+ expect(result.details.results).toEqual([
575
+ expect.objectContaining({ status: "error", error: expect.stringContaining("Failed to spawn: Error: spawn failed") }),
576
+ ]);
577
+ expect((await teams.readConfig("failed-writers")).members.find(member => member.name === "writer")).toBeUndefined();
578
+ expect(await runtime.readRuntimeStatus("failed-writers", "writer")).toBeNull();
579
+ expect(await messaging.readInbox("failed-writers", "writer", false, false)).toEqual([]);
580
+
581
+ const replacement: Member = {
582
+ agentId: "writer@failed-writers",
583
+ name: "writer",
584
+ agentType: "teammate",
585
+ role: "write" as const,
586
+ model: "provider/model",
587
+ joinedAt: Date.now(),
588
+ tmuxPaneId: "",
589
+ cwd: root,
590
+ subscriptions: [],
591
+ thinking: "max" as const,
592
+ modelSlot: "write-critical",
593
+ };
594
+ await expect(teams.addMember("failed-writers", replacement)).resolves.toBeUndefined();
595
+ expect(replacement.lifecycleRunId).toBeTruthy();
596
+ expect(replacement.lifecycleRunId).not.toBe(failedRunId);
597
+ });
598
+
599
+ it("preserves a replacement run admitted before spawn-failure rollback acquires the lifecycle lock", async () => {
600
+ const replacementRunId = "replacement-run";
601
+ const terminal = { spawn: vi.fn(() => {
602
+ const configPath = paths.configPath("raced-writers");
603
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
604
+ const writer = config.members.find((member: any) => member.name === "writer");
605
+ writer.lifecycleRunId = replacementRunId;
606
+ writer.tmuxPaneId = "%replacement";
607
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
608
+ fs.writeFileSync(paths.runtimeStatusPath("raced-writers", "writer"), JSON.stringify({
609
+ teamName: "raced-writers",
610
+ agentName: "writer",
611
+ lifecycleRunId: replacementRunId,
612
+ currentAction: "working",
613
+ }, null, 2));
614
+ throw new Error("spawn failed after replacement");
615
+ }) };
616
+ const create = registerTools(terminal).get("create_predefined_team")!;
617
+
618
+ const result = await create.execute("raced-failure", {
619
+ team_name: "raced-writers",
620
+ predefined_team: "writers",
621
+ cwd: root,
622
+ }, new AbortController().signal, undefined, makeCtx());
623
+
624
+ expect(result.details.results[0]).toMatchObject({ status: "error" });
625
+ expect((await teams.readConfig("raced-writers")).members.find(member => member.name === "writer")).toMatchObject({
626
+ lifecycleRunId: replacementRunId,
627
+ tmuxPaneId: "%replacement",
628
+ });
629
+ expect(await runtime.readRuntimeStatus("raced-writers", "writer")).toMatchObject({
630
+ lifecycleRunId: replacementRunId,
631
+ currentAction: "working",
632
+ });
633
+ });
634
+
635
+ it("uses canonical write-* intent-tier wording for read-tier validation failures", async () => {
636
+ const create = registerTools().get("create_predefined_team")!;
637
+
638
+ await expect(create.execute("wrong-tier", {
639
+ team_name: "readers",
640
+ predefined_team: "writers",
641
+ cwd: root,
642
+ model_slot: "read-review",
643
+ }, new AbortController().signal, undefined, makeCtx())).rejects.toThrow(
644
+ "create_predefined_team requires a write-* intent tier configured via /agents-favorite-models, got read-review."
645
+ );
646
+ });
647
+ });