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,513 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { Member } from "../../src/utils/models";
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ addMember: vi.fn(async (_teamName: string, member: Member) => { member.lifecycleRunId = "writer-run"; }),
6
+ removeMember: vi.fn(async () => {}),
7
+ removeMemberMatchingRun: vi.fn(async () => true),
8
+ updateMember: vi.fn(async () => {}),
9
+ readConfig: vi.fn(async (): Promise<{ members: Member[] }> => ({ members: [] })),
10
+ sendPlainMessage: vi.fn(async () => {}),
11
+ sendPlainMessageOnceIfRunning: vi.fn(async () => ({ delivered: true })),
12
+ removeInboxMessagesByOperationUnderLifecycleLock: vi.fn(async () => 1),
13
+ writeRuntimeStatus: vi.fn(async () => ({})),
14
+ deleteRuntimeStatus: vi.fn(async () => true),
15
+ writeTeamsDebugEvent: vi.fn(async (..._args: any[]) => {}),
16
+ occupyLifecycleTombstone: vi.fn(),
17
+ updateLifecycleTombstone: vi.fn(),
18
+ withLifecycleTombstoneLock: vi.fn(async (_teamName: string, _agentName: string, fn: Function) => fn({
19
+ read: () => ({ status: "absent" }),
20
+ occupy: mocks.occupyLifecycleTombstone,
21
+ updateMatching: mocks.updateLifecycleTombstone,
22
+ })),
23
+ deleteRuntimeStatusUnderLifecycleLock: vi.fn(async () => true),
24
+ cleanupPrivateAgentSessionDirectory: vi.fn(),
25
+ checkModel: vi.fn((
26
+ _piBinary: string,
27
+ _model: string | undefined,
28
+ _extensions: readonly string[],
29
+ _options: { projectTrusted?: boolean; selfExtensionSource?: string },
30
+ ) => ({
31
+ status: "available",
32
+ command: "preflight",
33
+ stdout: "",
34
+ stderr: "",
35
+ exitStatus: 0,
36
+ })),
37
+ buildCommand: vi.fn((
38
+ _piBinary: string,
39
+ _model: string | undefined,
40
+ _thinking: string | undefined,
41
+ _extensions: readonly string[],
42
+ projectTrusted?: boolean,
43
+ _selfExtensionSource?: string,
44
+ sessionDir?: string,
45
+ ) => `pi ${projectTrusted ? "--approve" : "--no-approve"} --no-extensions --extension 'self.ts' --extension '/external/$safe.ts'${sessionDir ? ` --session-dir '${sessionDir}'` : ""}`),
46
+ showInResume: false,
47
+ favoriteLevel: {
48
+ slot: "writing-hard",
49
+ role: "write",
50
+ model: "provider/model",
51
+ thinking: "xhigh",
52
+ },
53
+ }));
54
+
55
+ vi.mock("../../src/utils/settings", () => ({
56
+ loadSettings: vi.fn(() => ({
57
+ debug: { enabled: false },
58
+ agentSessions: { showInResume: mocks.showInResume },
59
+ })),
60
+ requireFavoriteModelLevel: vi.fn(() => mocks.favoriteLevel),
61
+ }));
62
+ vi.mock("../../src/utils/teams", () => ({
63
+ addMember: mocks.addMember,
64
+ removeMember: mocks.removeMember,
65
+ removeMemberMatchingRun: mocks.removeMemberMatchingRun,
66
+ updateMember: mocks.updateMember,
67
+ readConfig: mocks.readConfig,
68
+ }));
69
+ vi.mock("../../src/utils/messaging", () => ({
70
+ sendPlainMessage: mocks.sendPlainMessage,
71
+ sendPlainMessageOnceIfRunning: mocks.sendPlainMessageOnceIfRunning,
72
+ removeInboxMessagesByOperationUnderLifecycleLock: mocks.removeInboxMessagesByOperationUnderLifecycleLock,
73
+ }));
74
+ vi.mock("../../src/utils/runtime", () => ({
75
+ writeRuntimeStatus: mocks.writeRuntimeStatus,
76
+ deleteRuntimeStatus: mocks.deleteRuntimeStatus,
77
+ deleteRuntimeStatusUnderLifecycleLock: mocks.deleteRuntimeStatusUnderLifecycleLock,
78
+ }));
79
+ vi.mock("../internal/debug", () => ({
80
+ isTeamsDebugEnabled: () => false,
81
+ teamDebugLogPath: () => undefined,
82
+ writeTeamsDebugEvent: mocks.writeTeamsDebugEvent,
83
+ }));
84
+ vi.mock("../internal/pi-command", () => ({
85
+ buildPiCommand: mocks.buildCommand,
86
+ checkChildPiModelAvailability: mocks.checkModel,
87
+ getPiExtendedTeamsExtensionSource: () => "self.ts",
88
+ getPiLaunchCommand: () => "pi",
89
+ }));
90
+ vi.mock("../internal/agent-session-files", () => ({
91
+ preparePrivateAgentSessionDirectory: (teamName: string, agentName: string, runId: string) => {
92
+ return `/private/${teamName}/${agentName}/${runId}`;
93
+ },
94
+ cleanupPrivateAgentSessionDirectory: vi.fn((...args: any[]) => mocks.cleanupPrivateAgentSessionDirectory(...args)),
95
+ }));
96
+ vi.mock("../team/roster", () => ({ countWriteMembers: vi.fn(async () => 0) }));
97
+ vi.mock("../../src/utils/lifecycle-tombstone", () => ({
98
+ readLifecycleTombstone: vi.fn(async () => ({ status: "absent" })),
99
+ withLifecycleTombstoneLock: mocks.withLifecycleTombstoneLock,
100
+ generateExtensionInstanceId: () => "extension-instance",
101
+ }));
102
+
103
+ import { createWriteAgentRuntime } from "./write-agent";
104
+
105
+ function writer(): Member {
106
+ return {
107
+ agentId: "writer@team",
108
+ name: "writer",
109
+ agentType: "teammate",
110
+ role: "write",
111
+ model: "provider/model",
112
+ thinking: "xhigh",
113
+ modelSlot: "writing-hard",
114
+ joinedAt: Date.now(),
115
+ tmuxPaneId: "",
116
+ cwd: "/trusted/project",
117
+ subscriptions: [],
118
+ prompt: "write",
119
+ };
120
+ }
121
+
122
+ describe("legacy tmux writer resource plan", () => {
123
+ beforeEach(() => {
124
+ vi.clearAllMocks();
125
+ mocks.readConfig.mockImplementation(async () => ({ members: [] }));
126
+ mocks.updateMember.mockImplementation(async () => {});
127
+ mocks.writeTeamsDebugEvent.mockImplementation(async () => {});
128
+ mocks.cleanupPrivateAgentSessionDirectory.mockImplementation(() => {});
129
+ mocks.showInResume = false;
130
+ mocks.favoriteLevel = {
131
+ slot: "writing-hard",
132
+ role: "write",
133
+ model: "provider/model",
134
+ thinking: "xhigh",
135
+ };
136
+ });
137
+
138
+ it("uses canonical write-* intent-tier wording for read-tier validation failures", async () => {
139
+ mocks.favoriteLevel = {
140
+ slot: "read-review",
141
+ role: "read",
142
+ model: "provider/model",
143
+ thinking: "xhigh",
144
+ };
145
+ const runtime = createWriteAgentRuntime({ terminal: { spawn: vi.fn() } });
146
+
147
+ await expect(runtime.startWriteAgent("team", writer(), "implement the task")).rejects.toThrow(
148
+ "Write agent writer must use a write-* intent tier configured via /agents-favorite-models. Spawn agents by intent tier only."
149
+ );
150
+ });
151
+
152
+ it("releases the failed writer's sleep assertion even when a replacement run is admitted", async () => {
153
+ let replacementAdmitted = false;
154
+ let notifyFailureLogged!: () => void;
155
+ const failureLogged = new Promise<void>(resolve => { notifyFailureLogged = resolve; });
156
+ let releaseFailureLog!: () => void;
157
+ const failureLogBarrier = new Promise<void>(resolve => { releaseFailureLog = resolve; });
158
+ mocks.writeTeamsDebugEvent.mockImplementation(async (_teamName: string, eventName: string) => {
159
+ if (eventName !== "write-agent.spawn.failure") return;
160
+ notifyFailureLogged();
161
+ await failureLogBarrier;
162
+ });
163
+ mocks.readConfig.mockImplementation(async () => ({
164
+ members: replacementAdmitted
165
+ ? [{ ...writer(), lifecycleRunId: "replacement-run", tmuxPaneId: "%replacement" }]
166
+ : [],
167
+ }));
168
+ const onWriterInactive = vi.fn();
169
+ const releaseSleepAssertion = vi.fn();
170
+ const sleepController = {
171
+ retain: vi.fn(() => releaseSleepAssertion),
172
+ dispose: vi.fn(),
173
+ };
174
+ const runtime = createWriteAgentRuntime({
175
+ terminal: { spawn: vi.fn(() => { throw new Error("spawn failed"); }) },
176
+ onWriterInactive,
177
+ sleepController,
178
+ createResourcePlan: async () => ({
179
+ selectionMode: "explicit",
180
+ extensionPaths: [],
181
+ selfExtensionPath: "self.ts",
182
+ extensions: [],
183
+ diagnostics: [],
184
+ skills: "all",
185
+ trust: { cwd: "/trusted/project", projectTrusted: true },
186
+ }),
187
+ });
188
+
189
+ const launch = runtime.startWriteAgent("team", writer(), "implement the task");
190
+ await failureLogged;
191
+ replacementAdmitted = true;
192
+ releaseFailureLog();
193
+
194
+ await expect(launch).rejects.toThrow("Failed to spawn background tmux screen: Error: spawn failed");
195
+ expect(mocks.withLifecycleTombstoneLock).toHaveBeenCalledWith("team", "writer", expect.any(Function));
196
+ expect(mocks.deleteRuntimeStatusUnderLifecycleLock).not.toHaveBeenCalled();
197
+ expect(mocks.removeMemberMatchingRun).not.toHaveBeenCalled();
198
+ expect(onWriterInactive).not.toHaveBeenCalled();
199
+ expect(sleepController.retain).toHaveBeenCalledOnce();
200
+ expect(releaseSleepAssertion).toHaveBeenCalledOnce();
201
+ });
202
+
203
+ it("holds a sleep assertion until the successful writer lifecycle is released", async () => {
204
+ const releaseSleepAssertion = vi.fn();
205
+ const sleepController = {
206
+ retain: vi.fn(() => releaseSleepAssertion),
207
+ dispose: vi.fn(),
208
+ };
209
+ const runtime = createWriteAgentRuntime({
210
+ terminal: {
211
+ spawn: vi.fn(() => "%writer"),
212
+ getWindowIdForPane: vi.fn(() => "@writer"),
213
+ },
214
+ sleepController,
215
+ createResourcePlan: async () => ({
216
+ selectionMode: "explicit",
217
+ extensionPaths: [],
218
+ selfExtensionPath: "self.ts",
219
+ extensions: [],
220
+ diagnostics: [],
221
+ skills: "all",
222
+ trust: { cwd: "/trusted/project", projectTrusted: true },
223
+ }),
224
+ });
225
+ const member = writer();
226
+
227
+ await runtime.startWriteAgent("team", member, "implement the task");
228
+
229
+ expect(sleepController.retain).toHaveBeenCalledOnce();
230
+ expect(releaseSleepAssertion).not.toHaveBeenCalled();
231
+
232
+ runtime.releaseWriteAgentSleepAssertion("team", member);
233
+ runtime.releaseWriteAgentSleepAssertion("team", member);
234
+ expect(releaseSleepAssertion).toHaveBeenCalledOnce();
235
+ });
236
+
237
+ it("stops and verifies a returned R1 pane before diagnostics and leaves an R2 replacement untouched", async () => {
238
+ const admitted = writer();
239
+ const replacement = { ...writer(), lifecycleRunId: "replacement-run", tmuxPaneId: "%r2" };
240
+ let replacementInstalled = false;
241
+ mocks.updateMember.mockRejectedValueOnce(new Error("update failed"));
242
+ mocks.writeTeamsDebugEvent.mockImplementation(async (_teamName: string, eventName: string) => {
243
+ if (eventName === "write-agent.spawn.failure") replacementInstalled = true;
244
+ });
245
+ mocks.readConfig.mockImplementation(async () => ({
246
+ members: replacementInstalled ? [replacement] : [admitted],
247
+ }));
248
+ mocks.withLifecycleTombstoneLock.mockImplementationOnce(async (_teamName: string, _agentName: string, fn: Function) => fn({
249
+ read: () => replacementInstalled
250
+ ? { status: "occupied", tombstone: { runId: "replacement-run" } }
251
+ : { status: "absent" },
252
+ occupy: mocks.occupyLifecycleTombstone,
253
+ updateMatching: mocks.updateLifecycleTombstone,
254
+ }));
255
+ const terminal = {
256
+ spawn: vi.fn(() => "%r1"),
257
+ kill: vi.fn(),
258
+ isAlive: vi.fn(() => false),
259
+ };
260
+ const onWriterInactive = vi.fn();
261
+ const runtime = createWriteAgentRuntime({
262
+ terminal,
263
+ onWriterInactive,
264
+ createResourcePlan: async () => ({
265
+ selectionMode: "explicit",
266
+ extensionPaths: [],
267
+ selfExtensionPath: "self.ts",
268
+ extensions: [],
269
+ diagnostics: [],
270
+ skills: "all",
271
+ trust: { cwd: "/trusted/project", projectTrusted: true },
272
+ }),
273
+ });
274
+
275
+ await expect(runtime.startWriteAgent("team", admitted, "implement the task")).rejects.toThrow(
276
+ "Failed to spawn background tmux screen: Error: update failed",
277
+ );
278
+
279
+ const failureDiagnosticIndex = mocks.writeTeamsDebugEvent.mock.calls.findIndex(call => call[1] === "write-agent.spawn.failure");
280
+ expect(terminal.kill).toHaveBeenCalledWith("%r1");
281
+ expect(terminal.isAlive).toHaveBeenCalledWith("%r1");
282
+ expect(terminal.isAlive.mock.invocationCallOrder[0]).toBeLessThan(
283
+ mocks.writeTeamsDebugEvent.mock.invocationCallOrder[failureDiagnosticIndex]!,
284
+ );
285
+ expect(onWriterInactive).not.toHaveBeenCalled();
286
+ expect(mocks.removeMemberMatchingRun).not.toHaveBeenCalled();
287
+ expect(mocks.deleteRuntimeStatusUnderLifecycleLock).not.toHaveBeenCalled();
288
+ expect(mocks.removeInboxMessagesByOperationUnderLifecycleLock).not.toHaveBeenCalled();
289
+ expect(mocks.cleanupPrivateAgentSessionDirectory).not.toHaveBeenCalled();
290
+ expect(mocks.occupyLifecycleTombstone).not.toHaveBeenCalled();
291
+ expect(mocks.updateLifecycleTombstone).not.toHaveBeenCalled();
292
+ });
293
+
294
+ it("does not fence or delete R2 state when R1 pane termination cannot be proven", async () => {
295
+ const admitted = writer();
296
+ const replacement = { ...writer(), lifecycleRunId: "replacement-run", tmuxPaneId: "%r2" };
297
+ let replacementInstalled = false;
298
+ mocks.updateMember.mockRejectedValueOnce(new Error("update failed"));
299
+ mocks.writeTeamsDebugEvent.mockImplementation(async (_teamName: string, eventName: string) => {
300
+ if (eventName === "write-agent.spawn.failure") replacementInstalled = true;
301
+ });
302
+ mocks.readConfig.mockImplementation(async () => ({
303
+ members: replacementInstalled ? [replacement] : [admitted],
304
+ }));
305
+ mocks.withLifecycleTombstoneLock.mockImplementationOnce(async (_teamName: string, _agentName: string, fn: Function) => fn({
306
+ read: () => replacementInstalled
307
+ ? { status: "occupied", tombstone: { runId: "replacement-run" } }
308
+ : { status: "absent" },
309
+ occupy: mocks.occupyLifecycleTombstone,
310
+ updateMatching: mocks.updateLifecycleTombstone,
311
+ }));
312
+ const terminal = {
313
+ spawn: vi.fn(() => "%r1"),
314
+ kill: vi.fn(),
315
+ isAlive: vi.fn(() => true),
316
+ };
317
+ const runtime = createWriteAgentRuntime({
318
+ terminal,
319
+ createResourcePlan: async () => ({
320
+ selectionMode: "explicit",
321
+ extensionPaths: [],
322
+ selfExtensionPath: "self.ts",
323
+ extensions: [],
324
+ diagnostics: [],
325
+ skills: "all",
326
+ trust: { cwd: "/trusted/project", projectTrusted: true },
327
+ }),
328
+ });
329
+
330
+ await expect(runtime.startWriteAgent("team", admitted, "implement the task")).rejects.toThrow(
331
+ "Failed to spawn background tmux screen: Error: update failed",
332
+ );
333
+
334
+ expect(terminal.kill).toHaveBeenCalledWith("%r1");
335
+ expect(terminal.isAlive).toHaveBeenCalledWith("%r1");
336
+ expect(mocks.removeMemberMatchingRun).not.toHaveBeenCalled();
337
+ expect(mocks.deleteRuntimeStatusUnderLifecycleLock).not.toHaveBeenCalled();
338
+ expect(mocks.removeInboxMessagesByOperationUnderLifecycleLock).not.toHaveBeenCalled();
339
+ expect(mocks.cleanupPrivateAgentSessionDirectory).not.toHaveBeenCalled();
340
+ expect(mocks.occupyLifecycleTombstone).not.toHaveBeenCalled();
341
+ expect(mocks.updateLifecycleTombstone).not.toHaveBeenCalled();
342
+ });
343
+
344
+ it("uses one immutable plan for preflight and spawn and propagates its trust snapshot", async () => {
345
+ const extensionPaths = Object.freeze(["/external/$safe.ts"]);
346
+ const plan = Object.freeze({
347
+ selectionMode: "explicit" as const,
348
+ extensionPaths,
349
+ selfExtensionPath: "self.ts",
350
+ extensions: Object.freeze([]),
351
+ diagnostics: Object.freeze([]),
352
+ skills: "all" as const,
353
+ trust: Object.freeze({ cwd: "/trusted/project", projectTrusted: true }),
354
+ });
355
+ const createResourcePlan = vi.fn(async () => plan);
356
+ const terminal = {
357
+ spawn: vi.fn(() => "%writer"),
358
+ getWindowIdForPane: vi.fn(() => "@writer"),
359
+ };
360
+ const runtime = createWriteAgentRuntime({
361
+ terminal,
362
+ getProjectTrusted: (cwd) => cwd === "/trusted/project",
363
+ createResourcePlan,
364
+ });
365
+
366
+ await runtime.startWriteAgent("team", writer(), "implement the task");
367
+
368
+ expect(createResourcePlan).toHaveBeenCalledOnce();
369
+ expect(createResourcePlan).toHaveBeenCalledWith({ cwd: "/trusted/project", projectTrusted: true });
370
+ expect(mocks.checkModel.mock.calls[0]?.[2]).toBe(extensionPaths);
371
+ expect(mocks.checkModel.mock.calls[0]?.[3]).toEqual({ projectTrusted: true, selfExtensionSource: "self.ts" });
372
+ expect(mocks.buildCommand.mock.calls[0]?.[3]).toBe(extensionPaths);
373
+ expect(mocks.buildCommand.mock.calls[0]?.[4]).toBe(true);
374
+ expect(mocks.buildCommand.mock.calls[0]?.[5]).toBe("self.ts");
375
+ expect(mocks.buildCommand.mock.calls[0]?.[6]).toBe("/private/team/writer/writer-run");
376
+ expect(mocks.buildCommand.mock.calls[0]).toHaveLength(7);
377
+ expect(terminal.spawn).toHaveBeenCalledWith(expect.objectContaining({
378
+ cwd: "/trusted/project",
379
+ command: expect.stringContaining("--session-dir '/private/team/writer/writer-run'"),
380
+ }));
381
+ expect(mocks.addMember).toHaveBeenCalledOnce();
382
+ expect(mocks.sendPlainMessageOnceIfRunning).toHaveBeenCalledWith(
383
+ "team",
384
+ "team-lead",
385
+ "writer",
386
+ "implement the task",
387
+ "Initial prompt",
388
+ {
389
+ operationId: "bootstrap:writer-run:initial-prompt",
390
+ expectedRecipientRunId: "writer-run",
391
+ },
392
+ );
393
+ expect(mocks.updateMember).toHaveBeenCalledWith("team", "writer", {
394
+ tmuxPaneId: "%writer",
395
+ windowId: "@writer",
396
+ });
397
+ });
398
+
399
+ it("stops and verifies the exact spawned pane before rolling back an updateMember failure", async () => {
400
+ const admitted = writer();
401
+ mocks.readConfig.mockImplementation(async () => ({ members: [admitted] }));
402
+ mocks.updateMember.mockRejectedValueOnce(new Error("update failed"));
403
+ const terminal = {
404
+ spawn: vi.fn(() => "%writer"),
405
+ kill: vi.fn(),
406
+ isAlive: vi.fn(() => false),
407
+ };
408
+ const onWriterInactive = vi.fn();
409
+ const runtime = createWriteAgentRuntime({
410
+ terminal,
411
+ onWriterInactive,
412
+ createResourcePlan: async () => ({
413
+ selectionMode: "explicit",
414
+ extensionPaths: [],
415
+ selfExtensionPath: "self.ts",
416
+ extensions: [],
417
+ diagnostics: [],
418
+ skills: "all",
419
+ trust: { cwd: "/trusted/project", projectTrusted: true },
420
+ }),
421
+ });
422
+
423
+ await expect(runtime.startWriteAgent("team", admitted, "implement the task")).rejects.toThrow(
424
+ "Failed to spawn background tmux screen: Error: update failed",
425
+ );
426
+
427
+ expect(terminal.kill).toHaveBeenCalledWith("%writer");
428
+ expect(terminal.isAlive).toHaveBeenCalledWith("%writer");
429
+ expect(onWriterInactive).toHaveBeenCalledWith("team", expect.objectContaining({ lifecycleRunId: "writer-run" }));
430
+ expect(mocks.removeInboxMessagesByOperationUnderLifecycleLock).toHaveBeenCalledWith(
431
+ "team", "writer", "bootstrap:writer-run:initial-prompt",
432
+ );
433
+ expect(mocks.deleteRuntimeStatusUnderLifecycleLock).toHaveBeenCalledWith("team", "writer", "writer-run");
434
+ expect(terminal.isAlive.mock.invocationCallOrder[0]).toBeLessThan(
435
+ mocks.deleteRuntimeStatusUnderLifecycleLock.mock.invocationCallOrder[0]!,
436
+ );
437
+ expect(mocks.removeMemberMatchingRun).toHaveBeenCalledWith("team", "writer", "writer-run");
438
+ expect(mocks.cleanupPrivateAgentSessionDirectory).toHaveBeenCalledWith("team", "writer", "writer-run");
439
+ expect(mocks.occupyLifecycleTombstone).not.toHaveBeenCalled();
440
+ });
441
+
442
+ it("fences the exact spawned pane and retains recovery artifacts when termination cannot be proven", async () => {
443
+ const admitted = writer();
444
+ mocks.readConfig.mockImplementation(async () => ({ members: [admitted] }));
445
+ mocks.updateMember.mockRejectedValueOnce(new Error("update failed"));
446
+ const terminal = {
447
+ spawn: vi.fn(() => "%writer"),
448
+ kill: vi.fn(),
449
+ isAlive: vi.fn(() => true),
450
+ };
451
+ const onWriterInactive = vi.fn();
452
+ const runtime = createWriteAgentRuntime({
453
+ terminal,
454
+ onWriterInactive,
455
+ createResourcePlan: async () => ({
456
+ selectionMode: "explicit",
457
+ extensionPaths: [],
458
+ selfExtensionPath: "self.ts",
459
+ extensions: [],
460
+ diagnostics: [],
461
+ skills: "all",
462
+ trust: { cwd: "/trusted/project", projectTrusted: true },
463
+ }),
464
+ });
465
+
466
+ await expect(runtime.startWriteAgent("team", admitted, "implement the task")).rejects.toThrow(
467
+ "Failed to spawn background tmux screen: Error: update failed",
468
+ );
469
+
470
+ expect(terminal.kill).toHaveBeenCalledWith("%writer");
471
+ expect(terminal.isAlive).toHaveBeenCalledWith("%writer");
472
+ expect(mocks.occupyLifecycleTombstone).toHaveBeenCalledWith(expect.objectContaining({
473
+ team: "team",
474
+ agent: "writer",
475
+ runId: "writer-run",
476
+ role: "write",
477
+ phase: "cleanup_failed",
478
+ extensionInstanceId: "extension-instance",
479
+ }));
480
+ expect(mocks.updateLifecycleTombstone).toHaveBeenCalledWith("writer-run", expect.objectContaining({
481
+ phase: "cleanup_failed",
482
+ error: expect.stringContaining("%writer"),
483
+ }));
484
+ expect(onWriterInactive).not.toHaveBeenCalled();
485
+ expect(mocks.removeInboxMessagesByOperationUnderLifecycleLock).not.toHaveBeenCalled();
486
+ expect(mocks.deleteRuntimeStatusUnderLifecycleLock).not.toHaveBeenCalled();
487
+ expect(mocks.removeMemberMatchingRun).not.toHaveBeenCalled();
488
+ expect(mocks.cleanupPrivateAgentSessionDirectory).not.toHaveBeenCalled();
489
+ });
490
+
491
+ it("uses Pi's resumable session store only after an explicit opt-in", async () => {
492
+ mocks.showInResume = true;
493
+ const terminal = { spawn: vi.fn(() => "%writer") };
494
+ const runtime = createWriteAgentRuntime({
495
+ terminal,
496
+ createResourcePlan: async () => ({
497
+ selectionMode: "explicit",
498
+ extensionPaths: [],
499
+ selfExtensionPath: "self.ts",
500
+ extensions: [],
501
+ diagnostics: [],
502
+ skills: "all",
503
+ trust: { cwd: "/trusted/project", projectTrusted: true },
504
+ }),
505
+ });
506
+
507
+ await runtime.startWriteAgent("team", writer(), "implement the task");
508
+
509
+ expect(mocks.buildCommand.mock.calls[0]?.[6]).toBeUndefined();
510
+ const spawn = (terminal.spawn.mock.calls as any[][])[0]?.[0];
511
+ expect(spawn.command).not.toContain("--session-dir");
512
+ });
513
+ });