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,465 @@
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 { isInboxFileWatchEvent, registerExtensionEvents } from "./register-events.js";
6
+ import * as paths from "../../src/utils/paths.js";
7
+ import * as runtime from "../../src/utils/runtime.js";
8
+ import * as settings from "../../src/utils/settings.js";
9
+ import { sendPlainMessage } from "../../src/utils/messaging.js";
10
+
11
+ let root: string;
12
+ let teamsRoot: string;
13
+
14
+ function installPathSpies() {
15
+ vi.spyOn(paths, "teamDir").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName))));
16
+ vi.spyOn(paths, "configPath").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "config.json"));
17
+ vi.spyOn(paths, "inboxPath").mockImplementation((teamName: unknown, agentName: unknown) => {
18
+ return path.join(teamsRoot, paths.sanitizeName(String(teamName)), "inboxes", `${paths.sanitizeName(String(agentName))}.json`);
19
+ });
20
+ vi.spyOn(paths, "runtimeStatusPath").mockImplementation((teamName: unknown, agentName: unknown) => {
21
+ return path.join(teamsRoot, paths.sanitizeName(String(teamName)), "runtime", `${paths.sanitizeName(String(agentName))}.json`);
22
+ });
23
+ vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation((teamName: unknown, agentName: unknown) => {
24
+ return path.join(teamsRoot, paths.sanitizeName(String(teamName)), "lifecycle", "quarantine", `${paths.sanitizeName(String(agentName))}.json`);
25
+ });
26
+ }
27
+
28
+ function setupEvents(
29
+ isIdle: () => boolean,
30
+ overrides: Partial<Parameters<typeof registerExtensionEvents>[1]> = {}
31
+ ) {
32
+ if (!fs.existsSync(paths.configPath("team"))) writeTeamConfig();
33
+ const handlers = new Map<string, Function[]>();
34
+ const quietTrigger = vi.fn();
35
+ const terminal = { setTitle: vi.fn() };
36
+ registerExtensionEvents({
37
+ registerMessageRenderer: vi.fn(),
38
+ on: vi.fn((eventName: string, handler: Function) => {
39
+ handlers.set(eventName, [...(handlers.get(eventName) || []), handler]);
40
+ }),
41
+ }, {
42
+ isTeammate: true,
43
+ agentName: "writer",
44
+ getTeamName: () => "team",
45
+ setSessionCtx: vi.fn(),
46
+ terminal,
47
+ quietTrigger,
48
+ startLeadInboxPolling: vi.fn(),
49
+ startLeadWatchdog: vi.fn(),
50
+ buildRoster: vi.fn(async () => ({ teamName: "team", members: [] })),
51
+ formatRosterForPrompt: vi.fn(() => "Roster: empty"),
52
+ ...overrides,
53
+ });
54
+ const ctx = {
55
+ ui: { notify: vi.fn(), setTitle: vi.fn() },
56
+ isIdle,
57
+ };
58
+ return { handlers, quietTrigger, terminal, ctx };
59
+ }
60
+
61
+ function writeTeamConfig(role: "read" | "write" = "write", metadata: Record<string, any> = {}) {
62
+ const configPath = paths.configPath("team");
63
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
64
+ fs.writeFileSync(configPath, JSON.stringify({
65
+ name: "team",
66
+ description: "test team",
67
+ createdAt: Date.now(),
68
+ leadAgentId: "lead-agent",
69
+ leadSessionId: "session",
70
+ members: [
71
+ { agentId: "lead-agent", name: "team-lead", agentType: "lead", joinedAt: Date.now(), tmuxPaneId: "", cwd: root, subscriptions: [] },
72
+ { agentId: "writer@team", name: "writer", agentType: "teammate", role, lifecycleRunId: "writer-run", joinedAt: Date.now(), tmuxPaneId: "", cwd: root, subscriptions: [], metadata },
73
+ ],
74
+ }, null, 2));
75
+ }
76
+
77
+ describe("extension teammate inbox wake", () => {
78
+ beforeEach(() => {
79
+ vi.useFakeTimers();
80
+ vi.stubEnv("PI_LIFECYCLE_RUN_ID", "writer-run");
81
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-events-"));
82
+ teamsRoot = path.join(root, "teams");
83
+ fs.mkdirSync(teamsRoot, { recursive: true });
84
+ installPathSpies();
85
+ });
86
+
87
+ afterEach(() => {
88
+ vi.useRealTimers();
89
+ vi.unstubAllEnvs();
90
+ vi.restoreAllMocks();
91
+ if (root && fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
92
+ });
93
+
94
+ it("renders agent report messages open even when tool expansion is collapsed", () => {
95
+ const registerMessageRenderer = vi.fn();
96
+ registerExtensionEvents({
97
+ registerMessageRenderer,
98
+ on: vi.fn(),
99
+ }, {
100
+ isTeammate: false,
101
+ agentName: "team-lead",
102
+ getTeamName: () => "team",
103
+ setSessionCtx: vi.fn(),
104
+ terminal: {},
105
+ quietTrigger: vi.fn(),
106
+ startLeadInboxPolling: vi.fn(),
107
+ startLeadWatchdog: vi.fn(),
108
+ buildRoster: vi.fn(async () => ({ teamName: "team", members: [] })),
109
+ formatRosterForPrompt: vi.fn(() => "Roster: empty"),
110
+ });
111
+
112
+ const renderer = registerMessageRenderer.mock.calls.find((call) => call[0] === "pi-extended-teams-report")?.[1];
113
+ const output = renderer({
114
+ content: "full report body",
115
+ details: { name: "reader", tokens: 42, elapsedMs: 1000, ok: true },
116
+ }, { expanded: false }, {
117
+ fg: (_name: string, text: string) => text,
118
+ bold: (text: string) => text,
119
+ }).render(120).join("\n");
120
+
121
+ expect(output).toContain("reader reported");
122
+ expect(output).toContain("full report body");
123
+ expect(output).not.toContain("ctrl+o");
124
+ });
125
+
126
+ it("globally cleans stale session references and private agent sessions without an adopted team", async () => {
127
+ const cleanupStaleSessionContextReferences = vi.fn(() => 2);
128
+ const cleanupStalePrivateAgentSessions = vi.fn(() => 3);
129
+ const { handlers, ctx } = setupEvents(() => true, {
130
+ isTeammate: false,
131
+ agentName: "team-lead",
132
+ getTeamName: () => null,
133
+ cleanupStaleSessionContextReferences,
134
+ cleanupStalePrivateAgentSessions,
135
+ });
136
+
137
+ for (const handler of handlers.get("session_start") || []) await handler({}, ctx);
138
+ expect(cleanupStaleSessionContextReferences).toHaveBeenCalledOnce();
139
+ expect(cleanupStaleSessionContextReferences).toHaveBeenLastCalledWith();
140
+ expect(cleanupStalePrivateAgentSessions).toHaveBeenCalledOnce();
141
+ expect(cleanupStalePrivateAgentSessions).toHaveBeenLastCalledWith();
142
+
143
+ for (const handler of handlers.get("session_shutdown") || []) await handler({}, ctx);
144
+ expect(cleanupStaleSessionContextReferences).toHaveBeenCalledTimes(2);
145
+ expect(cleanupStaleSessionContextReferences).toHaveBeenLastCalledWith();
146
+ expect(cleanupStalePrivateAgentSessions).toHaveBeenCalledOnce();
147
+ });
148
+
149
+ it("does not run the private-session janitor inside spawned teammate sessions", async () => {
150
+ const cleanupStalePrivateAgentSessions = vi.fn(() => 0);
151
+ const { handlers, ctx } = setupEvents(() => true, { cleanupStalePrivateAgentSessions });
152
+
153
+ for (const handler of handlers.get("session_start") || []) await handler({}, ctx);
154
+
155
+ expect(cleanupStalePrivateAgentSessions).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it("continues lead session_start initialization when the private-session janitor fails", async () => {
159
+ const setSessionCtx = vi.fn();
160
+ const startLeadInboxPolling = vi.fn();
161
+ const startLeadWatchdog = vi.fn();
162
+ const cleanupStalePrivateAgentSessions = vi.fn(() => {
163
+ throw new Error("simulated janitor failure");
164
+ });
165
+ const cleanupStaleSessionContextReferences = vi.fn(() => 0);
166
+ const loadSettings = vi.spyOn(settings, "loadSettings").mockReturnValue({ favoriteModels: {} } as any);
167
+ const { handlers, ctx } = setupEvents(() => true, {
168
+ isTeammate: false,
169
+ agentName: "team-lead",
170
+ setSessionCtx,
171
+ startLeadInboxPolling,
172
+ startLeadWatchdog,
173
+ cleanupStalePrivateAgentSessions,
174
+ cleanupStaleSessionContextReferences,
175
+ });
176
+ (ctx as any).cwd = root;
177
+
178
+ await expect(Promise.all((handlers.get("session_start") || []).map(handler => handler({}, ctx)))).resolves.toBeDefined();
179
+
180
+ expect(setSessionCtx).toHaveBeenCalledWith(ctx);
181
+ expect(loadSettings).toHaveBeenCalledWith({ projectDir: root });
182
+ expect(cleanupStaleSessionContextReferences).toHaveBeenCalledOnce();
183
+ expect(startLeadInboxPolling).toHaveBeenCalledOnce();
184
+ expect(startLeadWatchdog).toHaveBeenCalledOnce();
185
+ });
186
+
187
+ it("injects level selection and literal-wait rules into every lead prompt", async () => {
188
+ const handlers = new Map<string, Function[]>();
189
+ registerExtensionEvents({
190
+ registerMessageRenderer: vi.fn(),
191
+ on: vi.fn((name: string, handler: Function) => handlers.set(name, [...(handlers.get(name) || []), handler])),
192
+ }, {
193
+ isTeammate: false,
194
+ agentName: "team-lead",
195
+ getTeamName: () => "team",
196
+ setSessionCtx: vi.fn(),
197
+ terminal: {},
198
+ quietTrigger: vi.fn(),
199
+ startLeadInboxPolling: vi.fn(),
200
+ startLeadWatchdog: vi.fn(),
201
+ buildRoster: vi.fn(async () => ({ teamName: "team", members: [] })),
202
+ formatRosterForPrompt: vi.fn(() => "Roster: empty"),
203
+ });
204
+
205
+ const [handler] = handlers.get("before_agent_start") || [];
206
+ const result = await handler({ systemPrompt: "base" });
207
+
208
+ expect(result.systemPrompt).toContain("read-review is the normal default");
209
+ expect(result.systemPrompt).toContain("Use read-collect when the lane gathers bounded facts");
210
+ expect(result.systemPrompt).toContain("Use read-analyze when it must explain behavior or root cause");
211
+ expect(result.systemPrompt).toContain("Reserve read-critical for irreducible high-stakes");
212
+ expect(result.systemPrompt).toContain("write-patch for a narrow localized change");
213
+ expect(result.systemPrompt).toContain("write-feature for a bounded feature");
214
+ expect(result.systemPrompt).toContain("write-system for a cross-cutting integration/refactor");
215
+ expect(result.systemPrompt).toContain("write-critical only for high-risk");
216
+ expect(result.systemPrompt).toContain("A spawned agent owns its assigned lane");
217
+ expect(result.systemPrompt).toContain("wait literally idle");
218
+ expect(result.systemPrompt).toContain("Do not sleep, poll");
219
+ expect(result.systemPrompt).toContain("Wait for the actual report before synthesizing");
220
+ expect(result.systemPrompt).toContain("concrete, reproducible findings with file/line evidence or a focused failing regression may proceed directly to TDD repair");
221
+ expect(result.systemPrompt).toContain("Use a separate read-only confirmation only when evidence is missing or weak, the claim is disputed, or irreducible high-risk uncertainty remains");
222
+ expect(result.systemPrompt).toContain("never reconfirm an already confirmed finding");
223
+ expect(result.systemPrompt).not.toContain("Before implementing a durable bug/security/testing claim sourced from an agent report or backlog, confirm it with a separate read-only agent");
224
+ });
225
+
226
+ it("wakes teammates with implicit-session inbox instructions", async () => {
227
+ const { handlers, quietTrigger, ctx } = setupEvents(() => true);
228
+
229
+ for (const handler of handlers.get("session_start") || []) {
230
+ await handler({}, ctx);
231
+ }
232
+ await vi.advanceTimersByTimeAsync(1000);
233
+
234
+ expect(quietTrigger).toHaveBeenCalledWith("read_inbox to get your instructions, then begin your work.");
235
+ expect(quietTrigger.mock.calls.flat().join("\n")).not.toContain("team_name");
236
+ });
237
+
238
+ it("injects teammate prompts without removed public tools or legacy parameters", async () => {
239
+ writeTeamConfig("write", { workflowRunId: "run-1" });
240
+ const { handlers, ctx } = setupEvents(() => true);
241
+ for (const sessionStart of handlers.get("session_start") || []) await sessionStart({}, ctx);
242
+ const [handler] = handlers.get("before_agent_start") || [];
243
+
244
+ const result = await handler({ systemPrompt: "base" });
245
+ const prompt = result.systemPrompt;
246
+
247
+ expect(prompt).toContain("Start by calling read_inbox to get your initial instructions.");
248
+ expect(prompt).toContain("use send_message to ask team-lead");
249
+ expect(prompt).toContain("ask team-lead with send_message");
250
+ expect(prompt).toContain("Progress reporting is required, not optional UI polish");
251
+ expect(prompt).toContain("After reading your initial instructions, call report_progress before your first work tool");
252
+ expect(prompt).toContain("never make more than 3 work-tool calls without a fresh progress update");
253
+ expect(prompt).toContain("Use a new phrase describing what you are doing now");
254
+ expect(prompt).toContain("without messaging or waking the lead");
255
+ for (const staleText of [
256
+ "request_read_helper",
257
+ "request_teammate",
258
+ "broadcast_message",
259
+ "list_teammates",
260
+ "use_skill",
261
+ "read_inbox(team_name",
262
+ ]) {
263
+ expect(prompt).not.toContain(staleText);
264
+ }
265
+ });
266
+
267
+ it("remembers inbox wakeups that arrive while writer is busy and fires on turn_end", async () => {
268
+ let idle = false;
269
+ const { handlers, quietTrigger, ctx } = setupEvents(() => idle);
270
+
271
+ for (const handler of handlers.get("session_start") || []) {
272
+ await handler({}, ctx);
273
+ }
274
+ quietTrigger.mockClear();
275
+
276
+ await sendPlainMessage("team", "writer-reader", "writer", "helper report", "Read helper report");
277
+ await vi.advanceTimersByTimeAsync(30000);
278
+ expect(quietTrigger.mock.calls.flat()).not.toContain("You have 1 new inbox message(s). Read them with read_inbox and act.");
279
+ quietTrigger.mockClear();
280
+
281
+ idle = true;
282
+ for (const handler of handlers.get("turn_end") || []) {
283
+ await handler({}, ctx);
284
+ }
285
+ await vi.advanceTimersByTimeAsync(250);
286
+
287
+ expect(quietTrigger).toHaveBeenCalledWith("You have 1 new inbox message(s). Read them with read_inbox and act.");
288
+ });
289
+
290
+ it("does not reject the inbox wake when runtime status writes keep failing", async () => {
291
+ let watchCallback: ((eventType: string, filename: string | Buffer | null) => void) | undefined;
292
+ const watchInboxDirectory = vi.fn((_path: string, callback: any) => {
293
+ watchCallback = callback;
294
+ return { close: vi.fn() } as unknown as fs.FSWatcher;
295
+ });
296
+ const runtimeWrite = vi.spyOn(runtime, "writeRuntimeStatus").mockImplementation(async (_teamName: any, _agentName: any, _runId: any, updates: any) => {
297
+ const keys = Object.keys(updates ?? {});
298
+ // Fail only the two wake-path writes.
299
+ if (keys.length <= 2 && keys.includes("lastHeartbeatAt")) throw new Error("Could not acquire lock");
300
+ return {} as any;
301
+ });
302
+ const rejections: unknown[] = [];
303
+ const onRejection = (reason: unknown) => rejections.push(reason);
304
+ process.on("unhandledRejection", onRejection);
305
+
306
+ try {
307
+ const { handlers, ctx } = setupEvents(() => true, { watchInboxDirectory });
308
+ for (const handler of handlers.get("session_start") || []) await handler({}, ctx);
309
+
310
+ watchCallback?.("change", "writer.json");
311
+ await vi.advanceTimersByTimeAsync(1000);
312
+
313
+ expect(runtimeWrite).toHaveBeenCalled();
314
+ expect(rejections).toEqual([]);
315
+ } finally {
316
+ process.off("unhandledRejection", onRejection);
317
+ }
318
+ });
319
+
320
+ it("closes teammate inbox handles and prevents work after session shutdown", async () => {
321
+ const close = vi.fn();
322
+ let watchCallback: ((eventType: string, filename: string | Buffer | null) => void) | undefined;
323
+ const watchInboxDirectory = vi.fn((_path: string, callback: any) => {
324
+ watchCallback = callback;
325
+ return { close } as unknown as fs.FSWatcher;
326
+ });
327
+ const runtimeWrite = vi.spyOn(runtime, "writeRuntimeStatus");
328
+ const { handlers, quietTrigger, terminal, ctx } = setupEvents(() => true, { watchInboxDirectory });
329
+
330
+ for (const handler of handlers.get("session_start") || []) await handler({}, ctx);
331
+ quietTrigger.mockClear();
332
+ runtimeWrite.mockClear();
333
+ terminal.setTitle.mockClear();
334
+ ctx.ui.setTitle.mockClear();
335
+
336
+ for (const handler of handlers.get("session_shutdown") || []) await handler({}, ctx);
337
+ expect(close).toHaveBeenCalledTimes(1);
338
+
339
+ watchCallback?.("change", "writer.json");
340
+ await vi.advanceTimersByTimeAsync(60_000);
341
+ expect(runtimeWrite).not.toHaveBeenCalled();
342
+ expect(quietTrigger).not.toHaveBeenCalled();
343
+ expect(terminal.setTitle).not.toHaveBeenCalled();
344
+ expect(ctx.ui.setTitle).not.toHaveBeenCalled();
345
+ });
346
+
347
+ it("treats inbox lock-file writes as wake-worthy fs.watch events", () => {
348
+ const inboxFile = path.join(root, "teams", "team", "inboxes", "writer.json");
349
+
350
+ expect(isInboxFileWatchEvent(inboxFile, "writer.json.lock")).toBe(true);
351
+ expect(isInboxFileWatchEvent(inboxFile, "writer.json")).toBe(true);
352
+ expect(isInboxFileWatchEvent(inboxFile, undefined)).toBe(true);
353
+ expect(isInboxFileWatchEvent(inboxFile, "team-lead.json.lock")).toBe(false);
354
+ });
355
+
356
+ it("records writer current action in agent runtime status", async () => {
357
+ const { handlers, ctx } = setupEvents(() => true);
358
+
359
+ for (const handler of handlers.get("session_start") || []) {
360
+ await handler({}, ctx);
361
+ }
362
+ await runtime.writeRuntimeStatus("team", "writer", "writer-run", {
363
+ latestProgress: "Reviewing event lifecycle",
364
+ progressUpdatedAt: 1234,
365
+ });
366
+ for (const handler of handlers.get("turn_start") || []) {
367
+ await handler({}, ctx);
368
+ }
369
+ expect(await runtime.readRuntimeStatus("team", "writer")).toMatchObject({
370
+ currentAction: "thinking",
371
+ latestProgress: "Reviewing event lifecycle",
372
+ progressUpdatedAt: 1234,
373
+ });
374
+
375
+ for (const handler of handlers.get("tool_execution_start") || []) {
376
+ await handler({ toolName: "bash" }, ctx);
377
+ }
378
+ expect(await runtime.readRuntimeStatus("team", "writer")).toMatchObject({
379
+ currentAction: "working",
380
+ activeToolName: "bash",
381
+ latestProgress: "Reviewing event lifecycle",
382
+ });
383
+
384
+ for (const handler of handlers.get("tool_execution_end") || []) {
385
+ await handler({}, ctx);
386
+ }
387
+ const afterTool = await runtime.readRuntimeStatus("team", "writer");
388
+ expect(afterTool).toMatchObject({
389
+ currentAction: "thinking",
390
+ latestProgress: "Reviewing event lifecycle",
391
+ progressUpdatedAt: 1234,
392
+ });
393
+ expect(afterTool).not.toHaveProperty("activeToolName");
394
+ });
395
+
396
+ it("records writer token usage in agent runtime status", async () => {
397
+ const { handlers, ctx } = setupEvents(() => true);
398
+ const assistantMessage = {
399
+ role: "assistant",
400
+ provider: "provider",
401
+ model: "model",
402
+ timestamp: 123,
403
+ stopReason: "toolUse",
404
+ content: [],
405
+ usage: {
406
+ input: 100,
407
+ output: 50,
408
+ cacheRead: 20,
409
+ cacheWrite: 5,
410
+ cost: { total: 0.123 },
411
+ },
412
+ };
413
+ (ctx as any).sessionManager = { getBranch: vi.fn(() => []) };
414
+ let currentContextUsage = { tokens: 597, contextWindow: 200_000, percent: 0.3 };
415
+ (ctx as any).getContextUsage = vi.fn(() => currentContextUsage);
416
+
417
+ for (const handler of handlers.get("session_start") || []) {
418
+ await handler({}, ctx);
419
+ }
420
+ expect(await runtime.readRuntimeStatus("team", "writer")).toMatchObject({
421
+ contextUsage: { tokens: 0, percent: 0 },
422
+ });
423
+ for (const handler of handlers.get("turn_start") || []) {
424
+ await handler({}, ctx);
425
+ }
426
+ expect(await runtime.readRuntimeStatus("team", "writer")).toMatchObject({
427
+ contextUsage: { tokens: 0, percent: 0 },
428
+ });
429
+
430
+ currentContextUsage = { tokens: 46_000, contextWindow: 200_000, percent: 23 };
431
+ for (const handler of handlers.get("message_end") || []) {
432
+ await handler({ message: assistantMessage }, ctx);
433
+ }
434
+
435
+ expect(await runtime.readRuntimeStatus("team", "writer")).toMatchObject({
436
+ tokensUsed: 175,
437
+ contextUsage: { tokens: 46_000, contextWindow: 200_000, percent: 23 },
438
+ });
439
+ });
440
+
441
+ it("defers the turn_end inbox wake until the writer session becomes idle", async () => {
442
+ let idle = false;
443
+ const { handlers, quietTrigger, ctx } = setupEvents(() => idle);
444
+
445
+ for (const handler of handlers.get("session_start") || []) {
446
+ await handler({}, ctx);
447
+ }
448
+ quietTrigger.mockClear();
449
+
450
+ await sendPlainMessage("team", "writer-reader", "writer", "helper report", "Read helper report");
451
+ await vi.advanceTimersByTimeAsync(30000);
452
+ expect(quietTrigger.mock.calls.flat()).not.toContain("You have 1 new inbox message(s). Read them with read_inbox and act.");
453
+
454
+ for (const handler of handlers.get("turn_end") || []) {
455
+ await handler({}, ctx);
456
+ }
457
+ await vi.advanceTimersByTimeAsync(100);
458
+ expect(quietTrigger.mock.calls.flat()).not.toContain("You have 1 new inbox message(s). Read them with read_inbox and act.");
459
+
460
+ idle = true;
461
+ await vi.advanceTimersByTimeAsync(250);
462
+
463
+ expect(quietTrigger).toHaveBeenCalledWith("You have 1 new inbox message(s). Read them with read_inbox and act.");
464
+ });
465
+ });