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,334 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import * as paths from "../../src/utils/paths.js";
6
+ import { registerTaskRuntimeTools } from "./task-runtime-tools.js";
7
+ import * as teams from "../../src/utils/teams.js";
8
+ import * as runtime from "../../src/utils/runtime.js";
9
+ import * as messaging from "../../src/utils/messaging.js";
10
+ import * as reportEvents from "../../src/utils/report-events.js";
11
+ import type { Member } from "../../src/utils/models.js";
12
+ import type { RunningReadAgent } from "../runtime/types.js";
13
+
14
+ let root = "";
15
+
16
+ function registerTools(isTeammate: boolean) {
17
+ const tools = new Map<string, any>();
18
+ registerTaskRuntimeTools({ registerTool: (tool: any) => tools.set(tool.name, tool) }, {
19
+ isTeammate,
20
+ terminal: null,
21
+ runningReadAgents: new Map(),
22
+ readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
23
+ shutdownTeammate: vi.fn(async () => ({
24
+ status: "settled" as const,
25
+ reason: "quit" as const,
26
+ extensionShutdown: "no_handlers" as const,
27
+ abort: "unavailable" as const,
28
+ delivery: "settled" as const,
29
+ dispose: "settled" as const,
30
+ cancelledDeliveries: 0,
31
+ persistenceClosed: true,
32
+ finalized: true,
33
+ removedMember: true,
34
+ releasedClaims: [],
35
+ })),
36
+ getTeamName: () => "team",
37
+ });
38
+ return tools;
39
+ }
40
+
41
+ describe("task runtime tools", () => {
42
+ beforeEach(() => {
43
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "task-runtime-tools-"));
44
+ vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation((teamName, agentName) => {
45
+ return path.join(root, String(teamName), "lifecycle", "quarantine", `${String(agentName)}.json`);
46
+ });
47
+ vi.spyOn(paths, "reportEventsPath").mockImplementation((teamName) => {
48
+ return path.join(root, String(teamName), "reports.json");
49
+ });
50
+ vi.spyOn(paths, "reportFilesDir").mockReturnValue(path.join(root, "agent", "reports"));
51
+ });
52
+
53
+ afterEach(() => {
54
+ vi.restoreAllMocks();
55
+ fs.rmSync(root, { recursive: true, force: true });
56
+ });
57
+
58
+ it("keeps stop_teammate lead-only while leaving diagnostics available", () => {
59
+ const leadTools = registerTools(false);
60
+ const teammateTools = registerTools(true);
61
+
62
+ expect(leadTools.has("stop_teammate")).toBe(true);
63
+ expect(leadTools.has("check_teammate")).toBe(true);
64
+ expect(teammateTools.has("stop_teammate")).toBe(false);
65
+ expect(teammateTools.has("check_teammate")).toBe(true);
66
+ });
67
+
68
+ it("reports timeout quarantine instead of claiming the agent stopped cleanly", async () => {
69
+ const tools = new Map<string, any>();
70
+ const teardown = {
71
+ status: "timed_out" as const,
72
+ reason: "quit" as const,
73
+ extensionShutdown: "emitted" as const,
74
+ abort: "timed_out" as const,
75
+ delivery: "timed_out" as const,
76
+ dispose: "deferred" as const,
77
+ cancelledDeliveries: 1,
78
+ persistenceClosed: true,
79
+ finalized: false,
80
+ removedMember: false,
81
+ releasedClaims: [],
82
+ };
83
+ const readConfig = vi.spyOn(teams, "readConfig").mockResolvedValue({
84
+ name: "team",
85
+ description: "",
86
+ createdAt: Date.now(),
87
+ leadAgentId: "lead",
88
+ leadSessionId: "session",
89
+ members: [{
90
+ agentId: "reader@team",
91
+ name: "reader",
92
+ agentType: "teammate",
93
+ role: "read",
94
+ joinedAt: Date.now(),
95
+ tmuxPaneId: "",
96
+ cwd: process.cwd(),
97
+ subscriptions: [],
98
+ }],
99
+ });
100
+ try {
101
+ registerTaskRuntimeTools({ registerTool: (tool: any) => tools.set(tool.name, tool) }, {
102
+ isTeammate: false,
103
+ terminal: null,
104
+ runningReadAgents: new Map(),
105
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
106
+ shutdownTeammate: vi.fn(async () => teardown),
107
+ getTeamName: () => "team",
108
+ });
109
+
110
+ const result = await tools.get("stop_teammate").execute("stop", { agent_name: "reader" });
111
+ expect(result.content[0].text).toContain("inactive but quarantined");
112
+ expect(result.content[0].text).not.toContain("Stopped agent");
113
+ expect(result.details).toMatchObject({ stopped: false, quarantined: true, teardown });
114
+ } finally {
115
+ readConfig.mockRestore();
116
+ }
117
+ });
118
+
119
+ it("limits post-roster persisted report recovery to the lead", async () => {
120
+ const leadTools = registerTools(false);
121
+ const teammateTools = registerTools(true);
122
+ vi.spyOn(teams, "readConfig").mockResolvedValue({
123
+ name: "team", description: "", createdAt: Date.now(), leadAgentId: "lead", leadSessionId: "session", members: [],
124
+ });
125
+ await reportEvents.appendTeamReportEvent("team", {
126
+ agentName: "finished-reader",
127
+ role: "read",
128
+ status: "completed",
129
+ report: "Durable completed report",
130
+ summary: "Done",
131
+ createdAt: Date.now(),
132
+ source: "read-agent",
133
+ metadata: { reportSource: "persisted-report_and_exit", recoverySessionFile: "/tmp/finished-reader.jsonl" },
134
+ });
135
+
136
+ const result = await leadTools.get("check_teammate").execute("check", { agent_name: "finished-reader" });
137
+
138
+ expect(result.content[0].text).toContain("Recovered its latest persisted completed report");
139
+ expect(result.content[0].text).toContain("Durable completed report");
140
+ expect(result.content[0].text).toContain("/tmp/finished-reader.jsonl");
141
+ expect(result.content[0].text).toContain(path.join(root, "team", "reports.json"));
142
+ expect(result.details).toMatchObject({
143
+ alive: false,
144
+ health: "completed",
145
+ removedMember: true,
146
+ completedReport: { agentName: "finished-reader", report: "Durable completed report" },
147
+ });
148
+
149
+ const listReports = vi.spyOn(reportEvents, "listTeamReportEvents");
150
+ await expect(teammateTools.get("check_teammate").execute("check", { agent_name: "finished-reader" }))
151
+ .rejects.toThrow("Agent finished-reader not found");
152
+ expect(listReports).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it("uses lifecycle cleanup proof for a settled dead teammate", async () => {
156
+ const tools = new Map<string, any>();
157
+ const member: Member = {
158
+ agentId: "dead@team",
159
+ name: "dead",
160
+ agentType: "teammate",
161
+ role: "write",
162
+ joinedAt: Date.now() - 60_000,
163
+ tmuxPaneId: "%dead",
164
+ cwd: process.cwd(),
165
+ subscriptions: [],
166
+ isActive: true,
167
+ };
168
+ vi.spyOn(teams, "readConfig").mockResolvedValue({
169
+ name: "team", description: "", createdAt: Date.now(), leadAgentId: "lead", leadSessionId: "session", members: [member],
170
+ });
171
+ vi.spyOn(runtime, "readRuntimeStatus").mockResolvedValue(null);
172
+ vi.spyOn(messaging, "readInbox").mockResolvedValue([]);
173
+ const shutdownTeammate = vi.fn(async () => ({
174
+ status: "settled" as const,
175
+ reason: "quit" as const,
176
+ extensionShutdown: "no_handlers" as const,
177
+ abort: "unavailable" as const,
178
+ delivery: "settled" as const,
179
+ dispose: "settled" as const,
180
+ cancelledDeliveries: 0,
181
+ persistenceClosed: true,
182
+ finalized: true,
183
+ removedMember: true,
184
+ releasedClaims: ["src/dead.ts"],
185
+ }));
186
+ registerTaskRuntimeTools({ registerTool: (tool: any) => tools.set(tool.name, tool) }, {
187
+ isTeammate: false,
188
+ terminal: { isAlive: vi.fn(() => false) },
189
+ runningReadAgents: new Map(),
190
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
191
+ shutdownTeammate,
192
+ getTeamName: () => "team",
193
+ });
194
+
195
+ const result = await tools.get("check_teammate").execute("check", { agent_name: "dead" });
196
+
197
+ expect(shutdownTeammate).toHaveBeenCalledOnce();
198
+ expect(result.details).toMatchObject({
199
+ alive: false,
200
+ health: "dead",
201
+ removedMember: true,
202
+ releasedClaims: ["src/dead.ts"],
203
+ });
204
+ });
205
+
206
+ it.each([
207
+ ["stopping", "stopping"],
208
+ ["quarantined", "quarantined"],
209
+ ["persistence_failed", "persistence-failed"],
210
+ ] as const)("gives teardown state %s precedence and skips duplicate cleanup", async (teardownState, expectedHealth) => {
211
+ const tools = new Map<string, any>();
212
+ const member: Member = {
213
+ agentId: "reader@team", name: "reader", agentType: "teammate", role: "read",
214
+ joinedAt: Date.now(), tmuxPaneId: "", cwd: process.cwd(), subscriptions: [], isActive: false,
215
+ };
216
+ vi.spyOn(teams, "readConfig").mockResolvedValue({
217
+ name: "team", description: "", createdAt: Date.now(), leadAgentId: "lead", leadSessionId: "session", members: [member],
218
+ });
219
+ vi.spyOn(runtime, "readRuntimeStatus").mockResolvedValue({
220
+ teamName: "team", agentName: "reader", ready: true, startedAt: Date.now(), lastHeartbeatAt: Date.now(), currentAction: "working",
221
+ });
222
+ vi.spyOn(messaging, "readInbox").mockResolvedValue([]);
223
+ const state: RunningReadAgent = {
224
+ runId: "run", name: "reader", teamName: "team", startedAt: Date.now(), tokensUsed: 10,
225
+ status: "working", recentEvents: [], lastActivityAt: Date.now(), teardownState,
226
+ };
227
+ const shutdownTeammate = vi.fn();
228
+ registerTaskRuntimeTools({ registerTool: (tool: any) => tools.set(tool.name, tool) }, {
229
+ isTeammate: false,
230
+ terminal: null,
231
+ runningReadAgents: new Map([["team:reader", state]]),
232
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
233
+ shutdownTeammate,
234
+ getTeamName: () => "team",
235
+ });
236
+
237
+ const result = await tools.get("check_teammate").execute("check", { agent_name: "reader" });
238
+
239
+ expect(result.details).toMatchObject({ alive: false, health: expectedHealth, agentLoopReady: false, removedMember: false });
240
+ expect(result.content[0].text).toContain(expectedHealth);
241
+ expect(shutdownTeammate).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it.each([
245
+ { label: "fresh runtime heartbeat", isActive: true, heartbeatAge: 0, paneAlive: false, expectedAlive: true, expectedHealth: "healthy" },
246
+ { label: "stale ready file", isActive: true, heartbeatAge: runtime.HEARTBEAT_STALE_MS + 1, paneAlive: false, expectedAlive: false, expectedHealth: "dead" },
247
+ { label: "inactive stale ready file", isActive: false, heartbeatAge: runtime.HEARTBEAT_STALE_MS + 1, paneAlive: false, expectedAlive: false, expectedHealth: "dead" },
248
+ { label: "live legacy pane", isActive: true, heartbeatAge: runtime.HEARTBEAT_STALE_MS + 1, paneAlive: true, expectedAlive: true, expectedHealth: "idle" },
249
+ { label: "inactive legacy pane", isActive: false, heartbeatAge: 0, paneAlive: true, expectedAlive: false, expectedHealth: "dead" },
250
+ ])("classifies legacy diagnostics coherently: $label", async ({ isActive, heartbeatAge, paneAlive, expectedAlive, expectedHealth }) => {
251
+ const tools = new Map<string, any>();
252
+ const now = Date.now();
253
+ const member: Member = {
254
+ agentId: "legacy@team", name: "legacy", agentType: "teammate", role: "write",
255
+ joinedAt: now - 60_000, tmuxPaneId: "%legacy", cwd: process.cwd(), subscriptions: [], isActive,
256
+ };
257
+ vi.spyOn(teams, "readConfig").mockResolvedValue({
258
+ name: "team", description: "", createdAt: now, leadAgentId: "lead", leadSessionId: "session", members: [member],
259
+ });
260
+ vi.spyOn(runtime, "readRuntimeStatus").mockResolvedValue({
261
+ teamName: "team", agentName: "legacy", ready: true, startedAt: now - 60_000, lastHeartbeatAt: now - heartbeatAge,
262
+ });
263
+ vi.spyOn(messaging, "readInbox").mockResolvedValue([]);
264
+ const shutdownTeammate = vi.fn(async () => ({
265
+ status: "settled" as const, reason: "quit" as const, extensionShutdown: "no_handlers" as const,
266
+ abort: "unavailable" as const, delivery: "settled" as const, dispose: "settled" as const,
267
+ cancelledDeliveries: 0, persistenceClosed: true, finalized: true, removedMember: true, releasedClaims: [],
268
+ }));
269
+ registerTaskRuntimeTools({ registerTool: (tool: any) => tools.set(tool.name, tool) }, {
270
+ isTeammate: false,
271
+ terminal: { isAlive: vi.fn(() => paneAlive) },
272
+ runningReadAgents: new Map(),
273
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
274
+ shutdownTeammate,
275
+ getTeamName: () => "team",
276
+ });
277
+
278
+ const result = await tools.get("check_teammate").execute("check", { agent_name: "legacy" });
279
+
280
+ expect(result.details.alive).toBe(expectedAlive);
281
+ expect(result.details.health).toBe(expectedHealth);
282
+ expect(shutdownTeammate).toHaveBeenCalledTimes(expectedAlive ? 0 : 1);
283
+ });
284
+
285
+ it.each([
286
+ { status: "timed_out" as const, expectedHealth: "quarantined" },
287
+ { status: "persistence_failed" as const, expectedHealth: "persistence-failed" },
288
+ { status: "cleanup_failed" as const, expectedHealth: "cleanup-blocked" },
289
+ ])("maps lifecycle $status without destructive fallback", async ({ status, expectedHealth }) => {
290
+ const tools = new Map<string, any>();
291
+ const member: Member = {
292
+ agentId: "dead@team", name: "dead", agentType: "teammate", role: "read",
293
+ joinedAt: Date.now(), tmuxPaneId: "", cwd: process.cwd(), subscriptions: [], isActive: false,
294
+ };
295
+ vi.spyOn(teams, "readConfig").mockResolvedValue({
296
+ name: "team", description: "", createdAt: Date.now(), leadAgentId: "lead", leadSessionId: "session", members: [member],
297
+ });
298
+ vi.spyOn(runtime, "readRuntimeStatus").mockResolvedValue(null);
299
+ vi.spyOn(messaging, "readInbox").mockResolvedValue([]);
300
+ const shutdownTeammate = vi.fn(async () => ({
301
+ status,
302
+ reason: "quit" as const,
303
+ extensionShutdown: "no_handlers" as const,
304
+ abort: "unavailable" as const,
305
+ delivery: "settled" as const,
306
+ dispose: "deferred" as const,
307
+ cancelledDeliveries: 0,
308
+ persistenceClosed: status !== "persistence_failed",
309
+ finalized: false,
310
+ removedMember: false,
311
+ releasedClaims: ["src/already-released.ts"],
312
+ error: "blocked cleanup",
313
+ }));
314
+ registerTaskRuntimeTools({ registerTool: (tool: any) => tools.set(tool.name, tool) }, {
315
+ isTeammate: false,
316
+ terminal: null,
317
+ runningReadAgents: new Map(),
318
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
319
+ shutdownTeammate,
320
+ getTeamName: () => "team",
321
+ });
322
+
323
+ const result = await tools.get("check_teammate").execute("check", { agent_name: "dead" });
324
+
325
+ expect(shutdownTeammate).toHaveBeenCalledOnce();
326
+ expect(result.details).toMatchObject({
327
+ alive: false,
328
+ health: expectedHealth,
329
+ removedMember: false,
330
+ releasedClaims: ["src/already-released.ts"],
331
+ error: "blocked cleanup",
332
+ });
333
+ });
334
+ });
@@ -0,0 +1,227 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import * as teams from "../../src/utils/teams";
3
+ import * as runtime from "../../src/utils/runtime";
4
+ import * as messaging from "../../src/utils/messaging";
5
+ import * as reportEvents from "../../src/utils/report-events";
6
+ import * as paths from "../../src/utils/paths";
7
+ import { formatTeammateStatusForModel, renderTeammateStatus } from "../ui/renderers";
8
+ import type { RunningReadAgent } from "../runtime/types";
9
+ import type { Member } from "../../src/utils/models";
10
+ import type { ReadAgentTeardownResult } from "../agents/read-agent-session-lifecycle";
11
+ import type { ShutdownTeammateOptions } from "../team/lifecycle";
12
+ import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone";
13
+
14
+ export interface TaskRuntimeToolsOptions {
15
+ isTeammate: boolean;
16
+ terminal: any;
17
+ runningReadAgents: Map<string, RunningReadAgent>;
18
+ readAgentKey(teamName: string, agentName: string): string;
19
+ shutdownTeammate(teamName: string, member: Member, options?: ShutdownTeammateOptions): Promise<ReadAgentTeardownResult>;
20
+ getTeamName(): string | null | undefined;
21
+ }
22
+
23
+ export function registerTaskRuntimeTools(pi: any, options: TaskRuntimeToolsOptions): void {
24
+ if (!options.isTeammate) {
25
+ pi.registerTool({
26
+ name: "stop_teammate",
27
+ label: "Stop Agent",
28
+ description: "Stop one active agent in the current Pi session, release its file claims, and remove it from the active roster. Use only when the user explicitly asks to cancel/stop an agent or the agent is no longer needed.",
29
+ parameters: Type.Object({
30
+ agent_name: Type.String(),
31
+ reason: Type.Optional(Type.String()),
32
+ }),
33
+ async execute(_toolCallId: string, params: any) {
34
+ const teamName = options.getTeamName();
35
+ if (!teamName) throw new Error("No active agent session. Spawn an agent first.");
36
+
37
+ const config = await teams.readConfig(teamName);
38
+ let member = config.members.find(m => m.name === params.agent_name);
39
+ const runningState = options.runningReadAgents.get(options.readAgentKey(teamName, params.agent_name));
40
+ if (!member && runningState) {
41
+ member = {
42
+ agentId: `${params.agent_name}@${teamName}`,
43
+ name: params.agent_name,
44
+ agentType: "teammate",
45
+ lifecycleRunId: runningState.runId,
46
+ role: runningState.role === "write" ? "write" : "read",
47
+ model: runningState.model,
48
+ thinking: runningState.thinking as Member["thinking"],
49
+ modelSlot: runningState.modelSlot as Member["modelSlot"],
50
+ joinedAt: runningState.startedAt,
51
+ tmuxPaneId: "",
52
+ cwd: "",
53
+ subscriptions: [],
54
+ };
55
+ }
56
+ if (!member || member.name === "team-lead") {
57
+ const fence = await readLifecycleTombstone(teamName, params.agent_name);
58
+ if (fence.status !== "absent") {
59
+ return {
60
+ content: [{ type: "text", text: `Agent ${params.agent_name} is inactive but quarantined; lifecycle cleanup is still fenced.` }],
61
+ details: {
62
+ session: teamName,
63
+ agentName: params.agent_name,
64
+ stopped: false,
65
+ quarantined: true,
66
+ blocked: true,
67
+ reason: params.reason,
68
+ tombstone: fence.status === "occupied" ? fence.tombstone : undefined,
69
+ error: fence.status === "corrupt" ? fence.error : undefined,
70
+ },
71
+ };
72
+ }
73
+ return {
74
+ content: [{ type: "text", text: `Agent ${params.agent_name} is not active in this session.` }],
75
+ details: { session: teamName, agentName: params.agent_name, stopped: false, reason: "not-active" },
76
+ };
77
+ }
78
+
79
+ const teardown = await options.shutdownTeammate(teamName, member);
80
+ const stopped = teardown.status === "settled" && teardown.finalized && teardown.removedMember;
81
+ if (!stopped) {
82
+ const blocked = teardown.status === "persistence_failed" ? "persistence-failed"
83
+ : teardown.status === "timed_out" ? "quarantined"
84
+ : "cleanup-blocked";
85
+ return {
86
+ content: [{ type: "text", text: `Agent ${params.agent_name} is inactive but ${blocked}; lifecycle cleanup did not complete${teardown.error ? `: ${teardown.error}` : "."}` }],
87
+ details: {
88
+ session: teamName,
89
+ agentName: params.agent_name,
90
+ stopped: false,
91
+ quarantined: teardown.status === "timed_out",
92
+ blocked: true,
93
+ reason: params.reason,
94
+ teardown,
95
+ },
96
+ };
97
+ }
98
+ return {
99
+ content: [{ type: "text", text: `Stopped agent ${params.agent_name}${params.reason ? `: ${params.reason}` : "."}` }],
100
+ details: { session: teamName, agentName: params.agent_name, stopped: true, reason: params.reason, teardown },
101
+ };
102
+ },
103
+ });
104
+ }
105
+
106
+ pi.registerTool({
107
+ name: "check_teammate",
108
+ label: "Check Agent",
109
+ description: options.isTeammate
110
+ ? "Check one agent's active status in the current Pi session only for targeted diagnostics after it has been quiet for several minutes or appears unhealthy. Do not use immediately after sending a message or while waiting for normal work."
111
+ : "Check one agent's status in the current Pi session only for targeted diagnostics after it has been quiet for several minutes or appears unhealthy. If the agent already left the roster before its report reached you, this recovers its latest persisted report. Do not use immediately after sending a message, while waiting for normal work, or after a report has already arrived.",
112
+ parameters: Type.Object({ agent_name: Type.String() }),
113
+ async execute(_toolCallId: string, params: any) {
114
+ const teamName = options.getTeamName();
115
+ if (!teamName) throw new Error("No active agent session. Spawn an agent first.");
116
+
117
+ const config = await teams.readConfig(teamName);
118
+ const member = config.members.find(m => m.name === params.agent_name);
119
+ if (!member) {
120
+ const fence = await readLifecycleTombstone(teamName, params.agent_name);
121
+ if (fence.status !== "absent") {
122
+ const details = {
123
+ agentName: params.agent_name,
124
+ alive: false,
125
+ unreadCount: 0,
126
+ health: "quarantined",
127
+ agentLoopReady: false,
128
+ hasRecentHeartbeat: false,
129
+ startupStalled: false,
130
+ runtime: await runtime.readRuntimeStatus(teamName, params.agent_name).catch(() => null),
131
+ teardownState: "quarantined",
132
+ removedMember: false,
133
+ releasedClaims: [],
134
+ tombstone: fence.status === "occupied" ? fence.tombstone : undefined,
135
+ error: fence.status === "corrupt" ? fence.error : undefined,
136
+ };
137
+ return { content: [{ type: "text", text: formatTeammateStatusForModel(params.agent_name, details) }], details };
138
+ }
139
+ if (options.isTeammate) throw new Error(`Agent ${params.agent_name} not found`);
140
+ const latestReport = (await reportEvents.listTeamReportEvents(teamName, {
141
+ agentName: params.agent_name,
142
+ limit: 1,
143
+ })).at(-1);
144
+ if (latestReport) {
145
+ const reportPath = paths.reportEventsPath(teamName);
146
+ const report = String(latestReport.report || "").trim();
147
+ const recoverySessionFile = typeof latestReport.metadata?.recoverySessionFile === "string"
148
+ ? latestReport.metadata.recoverySessionFile
149
+ : undefined;
150
+ const text = [
151
+ `Agent ${params.agent_name} is no longer active. Recovered its latest persisted ${latestReport.status} report from ${reportPath}.`,
152
+ recoverySessionFile
153
+ ? `Its durable child transcript is available at ${recoverySessionFile}. Use the read tool on that path if the report is incomplete.`
154
+ : undefined,
155
+ report || "The persisted report body is empty; inspect the report event file above for metadata and any recovery pointer.",
156
+ ].filter(Boolean).join("\n\n");
157
+ const details = {
158
+ agentName: params.agent_name,
159
+ alive: false,
160
+ unreadCount: 0,
161
+ health: latestReport.status,
162
+ agentLoopReady: false,
163
+ hasRecentHeartbeat: false,
164
+ startupStalled: false,
165
+ removedMember: true,
166
+ releasedClaims: [],
167
+ reportPath,
168
+ completedReport: latestReport,
169
+ };
170
+ return { content: [{ type: "text", text }], details };
171
+ }
172
+ throw new Error(`Agent ${params.agent_name} not found and no persisted report is available.`);
173
+ }
174
+
175
+ const unreadCount = (await messaging.readInbox(teamName, params.agent_name, true, false)).length;
176
+ const runtimeStatus = await runtime.readRuntimeStatus(teamName, params.agent_name).catch(() => null);
177
+ const now = Date.now();
178
+ const hasRecentHeartbeat = !!runtimeStatus?.lastHeartbeatAt && (now - runtimeStatus.lastHeartbeatAt) <= runtime.HEARTBEAT_STALE_MS;
179
+ const runningState = options.runningReadAgents.get(options.readAgentKey(teamName, member.name));
180
+ const lifecycleHealth = runningState?.teardownState === "persistence_failed" ? "persistence-failed"
181
+ : runningState?.teardownState === "quarantined" ? "quarantined"
182
+ : runningState?.teardownState === "stopping" ? "stopping"
183
+ : undefined;
184
+ const memberActive = member.isActive !== false;
185
+ const legacyPaneAlive = memberActive && !!(member.tmuxPaneId && options.terminal?.isAlive?.(member.tmuxPaneId));
186
+ const inProcessAlive = !!runningState && !lifecycleHealth && runningState.teardownState !== "finalized";
187
+ const alive = inProcessAlive || (memberActive && (hasRecentHeartbeat || legacyPaneAlive));
188
+ const startupStalled = alive && unreadCount > 0 && (now - member.joinedAt) > runtime.STARTUP_STALL_MS && !(runtimeStatus?.ready);
189
+ let health = lifecycleHealth
190
+ ?? (!alive ? "dead" : startupStalled ? "stalled" : runtimeStatus?.ready && hasRecentHeartbeat ? "healthy" : runtimeStatus?.ready || legacyPaneAlive ? "idle" : "starting");
191
+ let teardown: ReadAgentTeardownResult | undefined = runningState?.teardownResult;
192
+ let lifecycleError: string | undefined;
193
+
194
+ if (!alive && !lifecycleHealth) {
195
+ try {
196
+ teardown = await options.shutdownTeammate(teamName, member);
197
+ if (teardown.status === "timed_out") health = "quarantined";
198
+ else if (teardown.status === "persistence_failed") health = "persistence-failed";
199
+ else if (teardown.status === "cleanup_failed" || !teardown.finalized) health = "cleanup-blocked";
200
+ } catch (error) {
201
+ lifecycleError = error instanceof Error ? error.message : String(error);
202
+ health = "cleanup-blocked";
203
+ }
204
+ }
205
+
206
+ const details = {
207
+ agentName: params.agent_name,
208
+ alive: lifecycleHealth ? false : alive,
209
+ unreadCount,
210
+ health,
211
+ agentLoopReady: !lifecycleHealth && alive && !!runtimeStatus?.ready,
212
+ hasRecentHeartbeat,
213
+ startupStalled,
214
+ runtime: runtimeStatus,
215
+ teardownState: runningState?.teardownState,
216
+ teardown,
217
+ releasedClaims: teardown?.releasedClaims ?? [],
218
+ removedMember: teardown?.removedMember ?? false,
219
+ error: teardown?.error ?? lifecycleError,
220
+ };
221
+ return { content: [{ type: "text", text: formatTeammateStatusForModel(params.agent_name, details) }], details };
222
+ },
223
+ renderResult(result: any, { expanded }: any, theme: any) {
224
+ return renderTeammateStatus(result, expanded, theme);
225
+ },
226
+ });
227
+ }