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,304 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { Type } from "@sinclair/typebox";
4
+ import * as paths from "../../src/utils/paths";
5
+ import * as teams from "../../src/utils/teams";
6
+ import * as messaging from "../../src/utils/messaging";
7
+ import * as runtime from "../../src/utils/runtime";
8
+ import * as claims from "../../src/utils/claims";
9
+ import * as reportEvents from "../../src/utils/report-events";
10
+ import { canonicalPersistedModelSlot } from "../../src/utils/settings";
11
+ import { createFileClaimTools } from "./file-claim-tools";
12
+ import { formatInboxMessagesForModel, renderInboxMessages } from "../ui/renderers";
13
+ import { unlinkPidFile } from "../internal/session-files";
14
+ import { summarizeSessionUsage } from "../internal/session-usage";
15
+ import { closePersistedRecipient } from "../team/recipient-closure";
16
+ import {
17
+ generateExtensionInstanceId,
18
+ withLifecycleTombstoneLock,
19
+ } from "../../src/utils/lifecycle-tombstone";
20
+
21
+ export interface CoordinationToolsOptions {
22
+ agentName: string;
23
+ isTeammate: boolean;
24
+ terminal: any;
25
+ getTeamName(): string | null | undefined;
26
+ requireWriteAgentTeam(): Promise<string>;
27
+ requireTeamContext(explicitTeamName?: string): string;
28
+ releaseAllClaimsForAgent(teamName: string, agentName: string): Promise<string[]>;
29
+ drainWriteQueue(teamName: string): Promise<void>;
30
+ resolveSkillFile(skillName: string, cwd: string): string;
31
+ adoptTeamAsLead(teamName: string, ctx?: any): void;
32
+ renderLeadInboxStatus(): Promise<void>;
33
+ resetLeadWakeNotifiedCount(): void;
34
+ deliverMessageToActiveAgent?(teamName: string, recipient: string, content: string): Promise<boolean>;
35
+ extensionInstanceId?: string;
36
+ }
37
+
38
+ export function buildReadHelperPrompt(teamName: string, requester: string, prompt: string): string {
39
+ return [
40
+ `You are a read-only helper requested by edit agent '${requester}' in Pi session '${teamName}'.`,
41
+ "Do not edit files, claim files, install packages, start services, commit, push, deploy, or make mutating changes.",
42
+ "Investigate only what the requester asked for. Keep the final report concise and evidence-backed.",
43
+ "Mission:",
44
+ prompt,
45
+ ].join("\n\n");
46
+ }
47
+
48
+ function requireCurrentSession(options: CoordinationToolsOptions): string {
49
+ const teamName = options.getTeamName();
50
+ if (!teamName) throw new Error("No active agent session. Spawn an agent first.");
51
+ return teamName;
52
+ }
53
+
54
+ export function registerCoordinationTools(pi: any, options: CoordinationToolsOptions): void {
55
+ const extensionInstanceId = options.extensionInstanceId ?? generateExtensionInstanceId();
56
+ const pendingWriterFinalization = new Map<string, { teamName: string; agentName: string; runId: string }>();
57
+
58
+ pi.on?.("session_shutdown", async () => {
59
+ const pending = Array.from(pendingWriterFinalization.values());
60
+ for (const item of pending) {
61
+ let cleared = false;
62
+ let failed = false;
63
+ await withLifecycleTombstoneLock(item.teamName, item.agentName, async lifecycleLock => {
64
+ const fence = lifecycleLock.read();
65
+ if (fence.status !== "occupied" || fence.tombstone.runId !== item.runId) return;
66
+
67
+ lifecycleLock.updateMatching(item.runId, { phase: "finalizing", error: undefined });
68
+ try {
69
+ const config = teams.teamExists(item.teamName) ? await teams.readConfig(item.teamName) : null;
70
+ const currentMember = config?.members.find(member => member.name === item.agentName);
71
+ if (currentMember && currentMember.lifecycleRunId !== item.runId) {
72
+ throw new Error(`Refusing to remove replacement run ${currentMember.lifecycleRunId || "unknown"} of ${item.agentName}.`);
73
+ }
74
+
75
+ const currentRuntime = await runtime.readRuntimeStatus(item.teamName, item.agentName);
76
+ if (currentRuntime && currentRuntime.lifecycleRunId !== item.runId) {
77
+ throw new Error(`Refusing to remove replacement runtime run ${currentRuntime.lifecycleRunId || "unknown"} of ${item.agentName}.`);
78
+ }
79
+
80
+ const pidFile = path.join(paths.teamDir(item.teamName), `${item.agentName}.pid`);
81
+ const pidFileExisted = fs.existsSync(pidFile);
82
+ const pidFileUnlinked = unlinkPidFile(pidFile);
83
+ if (pidFileExisted && (!pidFileUnlinked || fs.existsSync(pidFile))) {
84
+ throw new Error(`Could not remove PID file for ${item.agentName} run ${item.runId}.`);
85
+ }
86
+ if (currentRuntime) {
87
+ const removedRuntime = await runtime.deleteRuntimeStatusUnderLifecycleLock(
88
+ item.teamName,
89
+ item.agentName,
90
+ item.runId,
91
+ );
92
+ if (!removedRuntime) throw new Error(`Could not remove runtime for ${item.agentName} run ${item.runId}.`);
93
+ }
94
+ if (currentMember) {
95
+ const removedMember = await teams.removeMemberMatchingRun(item.teamName, item.agentName, item.runId);
96
+ if (!removedMember) throw new Error(`Could not remove member ${item.agentName} run ${item.runId}.`);
97
+ }
98
+
99
+ const remainingMember = teams.teamExists(item.teamName)
100
+ ? (await teams.readConfig(item.teamName)).members.find(member => member.name === item.agentName)
101
+ : undefined;
102
+ if (remainingMember) {
103
+ throw new Error(`Member ${item.agentName} still exists after run ${item.runId} cleanup.`);
104
+ }
105
+ if (fs.existsSync(paths.runtimeStatusPath(item.teamName, item.agentName))) {
106
+ throw new Error(`Runtime for ${item.agentName} still exists after run ${item.runId} cleanup.`);
107
+ }
108
+
109
+ if (!lifecycleLock.clearMatching(item.runId)) {
110
+ throw new Error(`Lifecycle fence changed before final clear for ${item.agentName} run ${item.runId}.`);
111
+ }
112
+ cleared = true;
113
+ } catch (error) {
114
+ failed = true;
115
+ lifecycleLock.updateMatching(item.runId, {
116
+ phase: "cleanup_failed",
117
+ error: error instanceof Error ? error.message : String(error),
118
+ });
119
+ }
120
+ });
121
+ if (cleared || failed) {
122
+ pendingWriterFinalization.delete(`${item.teamName}:${item.agentName}`);
123
+ }
124
+ if (cleared) {
125
+ void options.drainWriteQueue(item.teamName).catch(() => {});
126
+ }
127
+ }
128
+ });
129
+
130
+ for (const tool of createFileClaimTools({
131
+ agentName: options.agentName,
132
+ getAuthorizedWriteTeam: options.requireWriteAgentTeam,
133
+ getCurrentTeam: () => requireCurrentSession(options),
134
+ claims,
135
+ })) {
136
+ pi.registerTool(tool);
137
+ }
138
+
139
+ pi.registerTool({
140
+ name: "report_and_exit",
141
+ label: "Report and Exit",
142
+ description: "Send a final report to the lead, release this agent's file claims, and shut down.",
143
+ parameters: Type.Object({
144
+ content: Type.String({ description: "Final report to send to the lead." }),
145
+ summary: Type.Optional(Type.String({ description: "Short report summary." })),
146
+ }),
147
+ async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
148
+ const targetTeamName = requireCurrentSession(options);
149
+ if (!options.isTeammate) throw new Error("report_and_exit is only available to spawned agents.");
150
+
151
+ const config = await teams.readConfig(targetTeamName);
152
+ const member = config.members.find(m => m.name === options.agentName);
153
+ if (!member) throw new Error(`Agent ${options.agentName} is not active in ${targetTeamName}.`);
154
+ const processRunId = process.env.PI_LIFECYCLE_RUN_ID;
155
+ const runId = await teams.ensureMemberLifecycleRunId(targetTeamName, options.agentName, processRunId);
156
+ if (processRunId && runId !== processRunId) {
157
+ throw new Error(`Refusing stale report run ${processRunId} for ${options.agentName}; current roster run is ${runId}.`);
158
+ }
159
+ member.lifecycleRunId = runId;
160
+ let runtimeStatus = await runtime.readRuntimeStatus(targetTeamName, options.agentName).catch(() => null);
161
+ if (runtimeStatus?.lifecycleRunId && runtimeStatus.lifecycleRunId !== runId) {
162
+ throw new Error(`Refusing to report from stale run ${runId}; runtime status belongs to ${runtimeStatus.lifecycleRunId}.`);
163
+ }
164
+ if (runtimeStatus && !runtimeStatus.lifecycleRunId) {
165
+ runtimeStatus = await runtime.writeRuntimeStatus(targetTeamName, options.agentName, runId, {});
166
+ }
167
+ await closePersistedRecipient(targetTeamName, options.agentName, runId, {
168
+ removeOnFailure: true,
169
+ role: (member.role ?? "write") === "read" ? "read" : "write",
170
+ reason: "quit",
171
+ extensionInstanceId,
172
+ });
173
+ pendingWriterFinalization.set(`${targetTeamName}:${options.agentName}`, {
174
+ teamName: targetTeamName,
175
+ agentName: options.agentName,
176
+ runId,
177
+ });
178
+ const sessionUsage = summarizeSessionUsage(ctx);
179
+ const tokensUsed = typeof sessionUsage.tokensUsed === "number" ? sessionUsage.tokensUsed : runtimeStatus?.tokensUsed;
180
+ const costUsd = sessionUsage.costUsd;
181
+ const elapsedMs = runtimeStatus?.startedAt ? Date.now() - runtimeStatus.startedAt : undefined;
182
+ const modelSlot = canonicalPersistedModelSlot(member?.modelSlot);
183
+ const reportMetadata = {
184
+ finalReport: true,
185
+ startedAt: runtimeStatus?.startedAt,
186
+ elapsedMs,
187
+ tokensUsed,
188
+ costUsd,
189
+ model: member?.model,
190
+ thinking: member?.thinking,
191
+ modelSlot,
192
+ initialPrompt: member?.prompt,
193
+ };
194
+
195
+ let reportPath = "";
196
+ let releasedClaims: string[] = [];
197
+ try {
198
+ const persistedReport = await reportEvents.appendTeamReportEvent(targetTeamName, {
199
+ agentName: options.agentName,
200
+ role: member?.role || "write",
201
+ status: "completed",
202
+ report: params.content,
203
+ summary: params.summary || "Final report",
204
+ startedAt: runtimeStatus?.startedAt,
205
+ elapsedMs,
206
+ tokensUsed,
207
+ costUsd,
208
+ source: "write-agent",
209
+ model: member?.model,
210
+ thinking: member?.thinking,
211
+ modelSlot,
212
+ color: member?.color,
213
+ metadata: { ...(member?.prompt ? { initialPrompt: member.prompt } : {}), ...(modelSlot ? { modelSlot } : {}) },
214
+ });
215
+ reportPath = persistedReport.reportPath || "";
216
+ if (!reportPath) throw new Error("The persisted report did not provide its standalone file path.");
217
+ await messaging.sendPlainMessage(targetTeamName, options.agentName, "team-lead", params.content, params.summary || "Final report", undefined, { metadata: reportMetadata });
218
+ releasedClaims = await options.releaseAllClaimsForAgent(targetTeamName, options.agentName);
219
+ } catch (error) {
220
+ pendingWriterFinalization.delete(`${targetTeamName}:${options.agentName}`);
221
+ await withLifecycleTombstoneLock(targetTeamName, options.agentName, async lifecycleLock => {
222
+ lifecycleLock.updateMatching(runId, {
223
+ phase: "cleanup_failed",
224
+ error: error instanceof Error ? error.message : String(error),
225
+ });
226
+ });
227
+ throw error;
228
+ }
229
+
230
+ setTimeout(() => {
231
+ try { ctx.shutdown(); } catch { process.exit(0); }
232
+ }, 250);
233
+
234
+ return { content: [{ type: "text", text: `Final report sent. Released ${releasedClaims.length} file claim(s). Exiting.` }], details: { session: targetTeamName, releasedClaims, reportPath } };
235
+ },
236
+ });
237
+
238
+ pi.registerTool({
239
+ name: "send_message",
240
+ label: "Send Message",
241
+ description: "Send a direct message in the current Pi session. Spawned agents default to messaging the lead.",
242
+ parameters: Type.Object({
243
+ recipient: Type.Optional(Type.String({ description: "Recipient agent name. Defaults to team-lead for spawned agents." })),
244
+ content: Type.String(),
245
+ summary: Type.Optional(Type.String()),
246
+ }),
247
+ async execute(_toolCallId: string, params: any) {
248
+ const targetTeamName = requireCurrentSession(options);
249
+ const recipient = params.recipient || (options.isTeammate ? "team-lead" : undefined);
250
+ if (!recipient) throw new Error("recipient is required when the lead sends a message.");
251
+ const deliveredDirectly = await options.deliverMessageToActiveAgent?.(targetTeamName, recipient, params.content) === true;
252
+ if (!deliveredDirectly) {
253
+ await messaging.sendPlainMessageIfRunning(targetTeamName, options.agentName, recipient, params.content, params.summary || "Message");
254
+ }
255
+ return {
256
+ content: [{ type: "text", text: `Message sent to ${recipient}.` }],
257
+ details: { session: targetTeamName, recipient, delivery: deliveredDirectly ? "active-session" : "inbox" },
258
+ };
259
+ },
260
+ });
261
+
262
+ pi.registerTool({
263
+ name: "read_inbox",
264
+ label: "Read Inbox",
265
+ description: "Read messages from the current Pi session inbox. Defaults to this agent's inbox.",
266
+ parameters: Type.Object({
267
+ agent_name: Type.Optional(Type.String({ description: "Whose inbox to read. Defaults to your own." })),
268
+ unread_only: Type.Optional(Type.Boolean({ default: true })),
269
+ mark_as_read: Type.Optional(Type.Boolean({ default: true, description: "Set false to peek without marking messages read." })),
270
+ }),
271
+ async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, _ctx: any) {
272
+ const targetTeamName = requireCurrentSession(options);
273
+ const targetAgent = params.agent_name || options.agentName;
274
+ const markAsRead = params.mark_as_read !== false;
275
+ const unreadOnly = params.unread_only !== false;
276
+ const msgs = await messaging.readInbox(targetTeamName, targetAgent, unreadOnly, markAsRead);
277
+
278
+ if (markAsRead && options.isTeammate && targetAgent === options.agentName) {
279
+ const config = await teams.readConfig(targetTeamName).catch(() => null);
280
+ const lifecycleRunId = config?.members.find(member => member.name === options.agentName)?.lifecycleRunId;
281
+ if (lifecycleRunId) {
282
+ await runtime.writeRuntimeStatus(targetTeamName, options.agentName, lifecycleRunId, {
283
+ lastHeartbeatAt: Date.now(),
284
+ lastInboxReadAt: Date.now(),
285
+ ready: true,
286
+ currentAction: "thinking",
287
+ activeToolName: undefined,
288
+ lastError: undefined,
289
+ }).catch(() => {});
290
+ }
291
+ }
292
+
293
+ if (markAsRead && !options.isTeammate && targetAgent === options.agentName) {
294
+ options.resetLeadWakeNotifiedCount();
295
+ await options.renderLeadInboxStatus();
296
+ }
297
+
298
+ return { content: [{ type: "text", text: formatInboxMessagesForModel(msgs) }], details: { session: targetTeamName, messages: msgs, targetAgent, markAsRead } };
299
+ },
300
+ renderResult(result: any, { expanded }: any, theme: any) {
301
+ return renderInboxMessages(result, expanded, theme);
302
+ },
303
+ });
304
+ }
@@ -0,0 +1,95 @@
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 { requestLeadForTeammateSpawn, resolveLeadRequestTeamName } from "./delegation-guard.js";
6
+ import * as paths from "../../src/utils/paths.js";
7
+ import { readInbox } from "../../src/utils/messaging.js";
8
+
9
+ let root: string;
10
+
11
+ function installPathSpies() {
12
+ vi.spyOn(paths, "teamDir").mockImplementation((teamName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName))));
13
+ vi.spyOn(paths, "inboxPath").mockImplementation((teamName: unknown, agentName: unknown) => {
14
+ return path.join(root, "teams", paths.sanitizeName(String(teamName)), "inboxes", `${paths.sanitizeName(String(agentName))}.json`);
15
+ });
16
+ }
17
+
18
+ describe("teammate delegation guard", () => {
19
+ beforeEach(() => {
20
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-delegation-"));
21
+ installPathSpies();
22
+ });
23
+
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ if (root && fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
27
+ });
28
+
29
+ it("resolves the active team before a requested team name", () => {
30
+ const teamName = resolveLeadRequestTeamName({
31
+ isTeammate: true,
32
+ agentName: "reader",
33
+ getTeamName: () => "active-team",
34
+ }, "requested-team");
35
+
36
+ expect(teamName).toBe("active-team");
37
+ });
38
+
39
+ it("falls back to the requested team name when no active team is available", () => {
40
+ const teamName = resolveLeadRequestTeamName({
41
+ isTeammate: true,
42
+ agentName: "reader",
43
+ getTeamName: () => undefined,
44
+ }, "requested-team");
45
+
46
+ expect(teamName).toBe("requested-team");
47
+ });
48
+
49
+ it("requires either an active team or a requested team name", () => {
50
+ expect(() => resolveLeadRequestTeamName({
51
+ isTeammate: true,
52
+ agentName: "reader",
53
+ getTeamName: () => undefined,
54
+ })).toThrow("Cannot resolve team context without a current team or team_name.");
55
+ });
56
+
57
+ it("sends lead-owned agent requests to team-lead instead of spawning directly", async () => {
58
+ const result = await requestLeadForTeammateSpawn({
59
+ isTeammate: true,
60
+ agentName: "reader",
61
+ getTeamName: () => "active-team",
62
+ }, {
63
+ action: "spawn_teammate",
64
+ params: {
65
+ team_name: "requested-team",
66
+ name: "helper",
67
+ prompt: "Investigate the failure",
68
+ role: "read",
69
+ },
70
+ reason: "Need independent coverage",
71
+ });
72
+
73
+ expect(result.content[0].text).toContain("Sent a request to team-lead for active-team");
74
+ expect(result.details).toMatchObject({
75
+ requested: true,
76
+ requestedAction: "spawn_teammate",
77
+ teamName: "active-team",
78
+ recipient: "team-lead",
79
+ from: "reader",
80
+ });
81
+
82
+ const inbox = await readInbox("active-team", "team-lead", false, false);
83
+ expect(inbox).toHaveLength(1);
84
+ expect(inbox[0]).toMatchObject({
85
+ from: "reader",
86
+ summary: "Agent spawn request from reader for helper",
87
+ color: "yellow",
88
+ read: false,
89
+ });
90
+ expect(inbox[0].text).toContain("Teammates are not allowed to spawn or promote other agents directly.");
91
+ expect(inbox[0].text).toContain("Requested action: spawn_teammate");
92
+ expect(inbox[0].text).toContain("Reason: Need independent coverage");
93
+ expect(inbox[0].text).toContain('"prompt": "Investigate the failure"');
94
+ });
95
+ });
@@ -0,0 +1,65 @@
1
+ import * as paths from "../../src/utils/paths";
2
+ import * as messaging from "../../src/utils/messaging";
3
+
4
+ export interface TeammateDelegationOptions {
5
+ isTeammate: boolean;
6
+ agentName: string;
7
+ getTeamName(): string | null | undefined;
8
+ }
9
+
10
+ export interface LeadSpawnRequest {
11
+ action: string;
12
+ params: Record<string, unknown>;
13
+ reason?: string;
14
+ }
15
+
16
+ function compactJson(value: unknown): string {
17
+ return JSON.stringify(value, null, 2);
18
+ }
19
+
20
+ export function resolveLeadRequestTeamName(options: TeammateDelegationOptions, requestedTeamName?: string): string {
21
+ const currentTeamName = options.getTeamName();
22
+ const targetTeamName = currentTeamName || requestedTeamName;
23
+ if (targetTeamName == null || String(targetTeamName).trim().length === 0) {
24
+ throw new Error("Cannot resolve team context without a current team or team_name.");
25
+ }
26
+ return paths.sanitizeName(String(targetTeamName));
27
+ }
28
+
29
+ export async function requestLeadForTeammateSpawn(
30
+ options: TeammateDelegationOptions,
31
+ request: LeadSpawnRequest,
32
+ ): Promise<{ content: any[]; details: any }> {
33
+ const requestedTeamName = typeof request.params.team_name === "string" ? request.params.team_name : undefined;
34
+ const targetTeamName = resolveLeadRequestTeamName(options, requestedTeamName);
35
+ const requestedAgentName = typeof request.params.name === "string" && request.params.name.trim().length > 0
36
+ ? ` for ${request.params.name}`
37
+ : "";
38
+ const summary = `Agent spawn request from ${options.agentName}${requestedAgentName}`;
39
+ const content = [
40
+ `${options.agentName} requested a lead-owned agent action.`,
41
+ "",
42
+ "Teammates are not allowed to spawn or promote other agents directly. The team lead must decide whether to run this request.",
43
+ "",
44
+ `Requested action: ${request.action}`,
45
+ request.reason ? `Reason: ${request.reason}` : undefined,
46
+ "Requested parameters:",
47
+ compactJson(request.params),
48
+ "",
49
+ `If appropriate, the lead should call ${request.action} with the requested parameters or adjust them before spawning.`,
50
+ ].filter((line): line is string => line != null).join("\n");
51
+
52
+ await messaging.sendPlainMessage(targetTeamName, options.agentName, "team-lead", content, summary, "yellow");
53
+
54
+ return {
55
+ content: [{ type: "text", text: `Teammates cannot spawn agents directly. Sent a request to team-lead for ${targetTeamName}.` }],
56
+ details: {
57
+ requested: true,
58
+ requestedAction: request.action,
59
+ teamName: targetTeamName,
60
+ recipient: "team-lead",
61
+ from: options.agentName,
62
+ params: request.params,
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,104 @@
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 { createFileClaimTools } from "./file-claim-tools.js";
6
+ import * as claims from "../../src/utils/claims.js";
7
+ import * as paths from "../../src/utils/paths.js";
8
+
9
+ let root: string;
10
+
11
+ type Tool = {
12
+ name: string;
13
+ execute: (toolCallId: string, params: any) => Promise<any>;
14
+ };
15
+
16
+ function makeTools(agentName: string, authorize = vi.fn(async () => "session")) {
17
+ const tools = createFileClaimTools({
18
+ agentName,
19
+ getAuthorizedWriteTeam: authorize,
20
+ getCurrentTeam: () => "session",
21
+ claims,
22
+ });
23
+ return { authorize, tools: new Map<string, Tool>(tools.map((tool: Tool) => [tool.name, tool])) };
24
+ }
25
+
26
+ describe("identity-bound file claim tools", () => {
27
+ beforeEach(() => {
28
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-file-claims-"));
29
+ vi.spyOn(paths, "claimsPath").mockImplementation((teamName: string) => path.join(root, `${teamName}.json`));
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.restoreAllMocks();
34
+ if (root && fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
35
+ });
36
+
37
+ it("authorizes the bound writer and preserves normalized atomic grants", async () => {
38
+ const { authorize, tools } = makeTools("writer");
39
+
40
+ const result = await tools.get("claim_file")!.execute("claim", {
41
+ paths: ["./src/a.ts", "src/a.ts/"],
42
+ });
43
+
44
+ expect(authorize).toHaveBeenCalledTimes(1);
45
+ expect(result.details).toMatchObject({
46
+ agent: "writer",
47
+ session: "session",
48
+ granted: ["src/a.ts"],
49
+ conflicts: [],
50
+ });
51
+ expect(await claims.listClaims("session")).toEqual([
52
+ expect.objectContaining({ agent: "writer", path: "src/a.ts" }),
53
+ ]);
54
+ });
55
+
56
+ it("returns conflict holder details without partially granting the request", async () => {
57
+ await claims.claimFiles("session", "other-writer", ["src/a.ts"]);
58
+ const { tools } = makeTools("writer");
59
+
60
+ const result = await tools.get("claim_file")!.execute("claim", {
61
+ paths: ["src/a.ts", "src/b.ts"],
62
+ });
63
+
64
+ expect(result.details).toMatchObject({
65
+ agent: "writer",
66
+ session: "session",
67
+ granted: [],
68
+ conflicts: [{ path: "src/a.ts", heldBy: "other-writer" }],
69
+ });
70
+ expect(result.content[0].text).toContain("src/a.ts held by other-writer");
71
+ expect((await claims.listClaims("session")).map(claim => claim.path)).toEqual(["src/a.ts"]);
72
+ });
73
+
74
+ it("lists active claims and releases only claims owned by the bound writer", async () => {
75
+ await claims.claimFiles("session", "writer", ["src/b.ts"]);
76
+ await claims.claimFiles("session", "other-writer", ["src/a.ts"]);
77
+ const { tools } = makeTools("writer");
78
+
79
+ const listed = await tools.get("list_file_claims")!.execute("list", {});
80
+ expect(listed.details.claims.map((claim: any) => [claim.path, claim.agent])).toEqual([
81
+ ["src/a.ts", "other-writer"],
82
+ ["src/b.ts", "writer"],
83
+ ]);
84
+
85
+ const released = await tools.get("release_file")!.execute("release", {
86
+ paths: ["src/a.ts", "./src/b.ts"],
87
+ });
88
+ expect(released.details.released).toEqual(["src/b.ts"]);
89
+ expect(await claims.listClaims("session")).toEqual([
90
+ expect.objectContaining({ agent: "other-writer", path: "src/a.ts" }),
91
+ ]);
92
+ });
93
+
94
+ it("does not touch claim storage when writer authorization fails", async () => {
95
+ const authorize = vi.fn(async () => {
96
+ throw new Error("File claim tools are only available to write agents.");
97
+ });
98
+ const { tools } = makeTools("reader", authorize);
99
+
100
+ await expect(tools.get("claim_file")!.execute("claim", { paths: ["src/a.ts"] }))
101
+ .rejects.toThrow("only available to write agents");
102
+ expect(await claims.listClaims("session")).toEqual([]);
103
+ });
104
+ });
@@ -0,0 +1,68 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type * as claimOperations from "../../src/utils/claims";
3
+
4
+ export interface FileClaimOperations {
5
+ claimFiles: typeof claimOperations.claimFiles;
6
+ releaseFiles: typeof claimOperations.releaseFiles;
7
+ listClaims: typeof claimOperations.listClaims;
8
+ }
9
+
10
+ export interface FileClaimToolsOptions {
11
+ agentName: string;
12
+ getAuthorizedWriteTeam(): Promise<string>;
13
+ getCurrentTeam(): string;
14
+ claims: FileClaimOperations;
15
+ }
16
+
17
+ export function createFileClaimTools(options: FileClaimToolsOptions): any[] {
18
+ return [
19
+ {
20
+ name: "claim_file",
21
+ label: "Claim File",
22
+ description: "Claim file paths before an edit agent changes them. Claims are scoped to the current Pi session.",
23
+ parameters: Type.Object({ paths: Type.Array(Type.String({ description: "Repository-relative file path." })) }),
24
+ async execute(_toolCallId: string, params: any) {
25
+ const targetTeamName = await options.getAuthorizedWriteTeam();
26
+ const result = await options.claims.claimFiles(targetTeamName, options.agentName, params.paths);
27
+ const text = result.conflicts.length > 0
28
+ ? [
29
+ `File claim request blocked for ${options.agentName}.`,
30
+ "Conflicts:",
31
+ ...result.conflicts.map(conflict => `- ${conflict.path} held by ${conflict.heldBy}`),
32
+ ].join("\n")
33
+ : result.granted.length > 0
34
+ ? [`Claimed ${result.granted.length} file(s) for ${options.agentName}:`, ...result.granted.map(item => `- ${item}`)].join("\n")
35
+ : `No file paths claimed for ${options.agentName}.`;
36
+ return { content: [{ type: "text", text }], details: { agent: options.agentName, session: targetTeamName, ...result } };
37
+ },
38
+ },
39
+ {
40
+ name: "release_file",
41
+ label: "Release File",
42
+ description: "Release file claims held by the current edit agent.",
43
+ parameters: Type.Object({ paths: Type.Array(Type.String({ description: "Repository-relative file path." })) }),
44
+ async execute(_toolCallId: string, params: any) {
45
+ const targetTeamName = await options.getAuthorizedWriteTeam();
46
+ const released = await options.claims.releaseFiles(targetTeamName, options.agentName, params.paths);
47
+ const text = released.length > 0
48
+ ? `Released ${released.length} file claim(s) for ${options.agentName}:\n${released.map(item => `- ${item}`).join("\n")}`
49
+ : `No matching file claims held by ${options.agentName} were released.`;
50
+ return { content: [{ type: "text", text }], details: { agent: options.agentName, session: targetTeamName, released } };
51
+ },
52
+ },
53
+ {
54
+ name: "list_file_claims",
55
+ label: "List File Claims",
56
+ description: "List file claims in the current Pi session.",
57
+ parameters: Type.Object({}),
58
+ async execute() {
59
+ const targetTeamName = options.getCurrentTeam();
60
+ const currentClaims = (await options.claims.listClaims(targetTeamName)).sort((a, b) => a.path.localeCompare(b.path));
61
+ const text = currentClaims.length > 0
62
+ ? ["Current file claims:", ...currentClaims.map(claim => `- ${claim.path} held by ${claim.agent} since ${new Date(claim.since).toISOString()}`)].join("\n")
63
+ : "No current file claims.";
64
+ return { content: [{ type: "text", text }], details: { session: targetTeamName, claims: currentClaims } };
65
+ },
66
+ },
67
+ ];
68
+ }