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,331 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { StringEnum } from "../internal/schema";
3
+ import { buildPiCommand, getPiLaunchCommand } from "../internal/pi-command";
4
+ import { getModelSelectionState, requireQualifiedKnownModel } from "../internal/model-selection";
5
+ import * as predefined from "../../src/utils/predefined-teams";
6
+ import * as teams from "../../src/utils/teams";
7
+ import * as messaging from "../../src/utils/messaging";
8
+ import * as runtime from "../../src/utils/runtime";
9
+ import * as paths from "../../src/utils/paths";
10
+ import { loadSettings, normalizeFavoriteModelSlot, requireFavoriteModelLevel } from "../../src/utils/settings";
11
+ import type { Member } from "../../src/utils/models";
12
+ import { requestLeadForTeammateSpawn } from "./delegation-guard";
13
+ import { createSpawnResourcePlan, parentProjectTrustForSpawn } from "../resources/spawn-resource-plan";
14
+ import { generateExtensionInstanceId, withLifecycleTombstoneLock } from "../../src/utils/lifecycle-tombstone";
15
+ import { cleanupPrivateAgentSessionDirectory, preparePrivateAgentSessionDirectory } from "../internal/agent-session-files";
16
+
17
+ function errorText(error: unknown): string {
18
+ return error instanceof Error ? error.message : String(error);
19
+ }
20
+
21
+ function stopAndVerifySpawnedPane(terminal: any, paneId: string): string | null {
22
+ let killError: unknown;
23
+ try {
24
+ terminal.kill(paneId);
25
+ } catch (error) {
26
+ killError = error;
27
+ }
28
+
29
+ if (typeof terminal.isAlive !== "function") {
30
+ return `Could not verify termination of spawned pane ${paneId}: terminal.isAlive is unavailable${killError ? ` after kill failed: ${errorText(killError)}` : ""}.`;
31
+ }
32
+
33
+ try {
34
+ const alive = terminal.isAlive(paneId);
35
+ if (alive === false) return null;
36
+ if (alive === true) {
37
+ return `Could not verify termination of spawned pane ${paneId}: the terminal still reports it alive${killError ? ` after kill failed: ${errorText(killError)}` : ""}.`;
38
+ }
39
+ return `Could not verify termination of spawned pane ${paneId}: terminal.isAlive returned a non-boolean result.`;
40
+ } catch (error) {
41
+ return `Could not verify termination of spawned pane ${paneId}: terminal.isAlive failed: ${errorText(error)}${killError ? `; kill also failed: ${errorText(killError)}` : ""}.`;
42
+ }
43
+ }
44
+
45
+ export interface PredefinedToolsOptions {
46
+ terminal: any;
47
+ adoptTeamAsLead(teamName: string, ctx?: any): void;
48
+ isTeammate: boolean;
49
+ agentName: string;
50
+ getTeamName(): string | null | undefined;
51
+ }
52
+
53
+ export function registerPredefinedTools(pi: any, options: PredefinedToolsOptions): void {
54
+ const extensionInstanceId = generateExtensionInstanceId();
55
+
56
+ pi.registerTool({
57
+ name: "list_predefined_teams",
58
+ label: "List Predefined Teams",
59
+ description: "List all available predefined team configurations from teams.yaml files. These are team templates that can be instantiated with create_predefined_team.",
60
+ parameters: Type.Object({}),
61
+ async execute(_toolCallId: string, _params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
62
+ const projectDir = ctx.cwd;
63
+ const predefinedTeams = predefined.getAllPredefinedTeams(projectDir);
64
+ const agents = predefined.getAllAgentDefinitions(projectDir);
65
+ const result = predefinedTeams.map(team => ({
66
+ name: team.name,
67
+ agents: team.agents.map(agentName => {
68
+ const agentDef = agents.find(a => a.name === agentName);
69
+ return { name: agentName, description: agentDef?.description || "(agent definition not found)", found: !!agentDef };
70
+ }),
71
+ }));
72
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: { teams: result } };
73
+ },
74
+ });
75
+
76
+ pi.registerTool({
77
+ name: "list_predefined_agents",
78
+ label: "List Predefined Agents",
79
+ description: "List all available predefined agent definitions from .md files. These can be used individually or as part of predefined teams.",
80
+ parameters: Type.Object({}),
81
+ async execute(_toolCallId: string, _params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
82
+ const projectDir = ctx.cwd;
83
+ const agents = predefined.getAllAgentDefinitions(projectDir);
84
+ const result = agents.map(agent => ({ name: agent.name, description: agent.description, tools: agent.tools, model: agent.model, thinking: agent.thinking }));
85
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: { agents: result } };
86
+ },
87
+ });
88
+
89
+ pi.registerTool({
90
+ name: "create_predefined_team",
91
+ label: "Create Predefined Team",
92
+ description: "Create a team from a predefined team configuration by configured intent tier only. model_slot selects write-agent behavior, model, and thinking; it defaults to write-critical. Canonical tiers are reported outward; minor-release compatibility aliases remain accepted. Direct model/thinking/default_model overrides are not allowed.",
93
+ parameters: Type.Object({
94
+ team_name: Type.String({ description: "Name for the new team instance" }),
95
+ predefined_team: Type.String({ description: "Name of the predefined team template from teams.yaml" }),
96
+ cwd: Type.String({ description: "Working directory for spawned agents" }),
97
+ model_slot: Type.Optional(Type.String({ description: "Configured write-* intent tier from /agents-favorite-models. Defaults to write-critical; legacy compatibility aliases are accepted." })),
98
+ }),
99
+ async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
100
+ if (options.isTeammate) {
101
+ return requestLeadForTeammateSpawn(options, {
102
+ action: "create_predefined_team",
103
+ params,
104
+ reason: "Teammate attempted to create a predefined team directly.",
105
+ });
106
+ }
107
+
108
+ const projectDir = ctx.cwd;
109
+ const predefinedTeam = predefined.getPredefinedTeam(params.predefined_team, projectDir);
110
+ if (!predefinedTeam) {
111
+ const available = predefined.getAllPredefinedTeams(projectDir).map(t => t.name);
112
+ throw new Error(`Predefined team "${params.predefined_team}" not found. Available teams: ${available.join(", ") || "none"}`);
113
+ }
114
+ if (!options.terminal) throw new Error("pi-extended-teams requires running inside tmux.");
115
+
116
+ if (params.default_model || params.model || params.thinking || params.role) {
117
+ throw new Error("create_predefined_team must use model_slot only; direct model, thinking, role, or default_model is not allowed.");
118
+ }
119
+ const settings = loadSettings({ projectDir: ctx.cwd });
120
+ const agentSettings = loadSettings({ projectDir: params.cwd });
121
+ const requestedSlot = params.model_slot ?? "write-critical";
122
+ const canonicalSlot = normalizeFavoriteModelSlot(requestedSlot);
123
+ const level = requireFavoriteModelLevel(settings, canonicalSlot ?? requestedSlot);
124
+ if (level.role !== "write") throw new Error(`create_predefined_team requires a write-* intent tier configured via /agents-favorite-models, got ${level.slot}.`);
125
+ const { availableModels } = await getModelSelectionState(ctx, ctx.cwd, [level.model]);
126
+ const defaultModel = requireQualifiedKnownModel(level.model, availableModels, "model_slot");
127
+ if (!defaultModel) throw new Error(`Intent tier ${level.slot} resolved to unavailable model ${level.model}.`);
128
+ const resourcePlan = createSpawnResourcePlan({
129
+ cwd: params.cwd,
130
+ projectTrusted: parentProjectTrustForSpawn(ctx, params.cwd),
131
+ pi,
132
+ });
133
+
134
+ let config;
135
+ try {
136
+ config = teams.createTeamIfAbsent(params.team_name, "local-session", "lead-agent", `Predefined team: ${params.predefined_team}`, defaultModel);
137
+ } catch (error) {
138
+ if (!(error instanceof teams.TeamAlreadyExistsError)) throw error;
139
+ throw new Error(`Team "${params.team_name}" already exists. Choose a different team_name, or stop that team first.`);
140
+ }
141
+ options.adoptTeamAsLead(paths.sanitizeName(params.team_name), ctx);
142
+
143
+ const agentDefinitions = predefined.getAllAgentDefinitions(projectDir);
144
+ const spawnResults: Array<{ name: string; status: string; error?: string }> = [];
145
+ for (const agentName of predefinedTeam.agents) {
146
+ const agentDef = agentDefinitions.find(a => a.name === agentName);
147
+ if (!agentDef) {
148
+ spawnResults.push({ name: agentName, status: "skipped", error: "Agent definition not found" });
149
+ continue;
150
+ }
151
+
152
+ try {
153
+ const safeName = paths.sanitizeName(agentName);
154
+ const safeTeamName = paths.sanitizeName(params.team_name);
155
+ if (agentDef.model || agentDef.thinking) {
156
+ throw new Error(`Predefined agent \"${agentName}\" must not declare direct model or thinking; choose model_slot when creating the team.`);
157
+ }
158
+ const chosenModel = defaultModel || config.defaultModel;
159
+ if (!chosenModel) throw new Error(`No configured model found for intent tier ${level.slot}.`);
160
+
161
+ const member: Member = {
162
+ agentId: `${safeName}@${safeTeamName}`,
163
+ name: safeName,
164
+ agentType: "teammate",
165
+ role: "write",
166
+ model: chosenModel,
167
+ joinedAt: Date.now(),
168
+ tmuxPaneId: "",
169
+ cwd: params.cwd,
170
+ subscriptions: [],
171
+ prompt: agentDef.prompt,
172
+ color: "blue",
173
+ thinking: level.thinking,
174
+ modelSlot: level.slot,
175
+ };
176
+
177
+ await teams.addMember(safeTeamName, member);
178
+ const runId = member.lifecycleRunId!;
179
+ const bootstrapOperationId = `bootstrap:${runId}:initial-prompt`;
180
+ let spawnedTerminalId: string | undefined;
181
+
182
+ try {
183
+ const sessionDir = agentSettings.agentSessions.showInResume
184
+ ? undefined
185
+ : preparePrivateAgentSessionDirectory(safeTeamName, safeName, runId);
186
+ await runtime.writeRuntimeStatus(safeTeamName, safeName, runId, {
187
+ startedAt: member.joinedAt,
188
+ lastHeartbeatAt: member.joinedAt,
189
+ ready: false,
190
+ currentAction: "starting",
191
+ });
192
+ await messaging.sendPlainMessageOnceIfRunning(
193
+ safeTeamName,
194
+ "team-lead",
195
+ safeName,
196
+ agentDef.prompt,
197
+ "Initial prompt from predefined team",
198
+ {
199
+ operationId: bootstrapOperationId,
200
+ expectedRecipientRunId: runId,
201
+ }
202
+ );
203
+ const piBinary = getPiLaunchCommand();
204
+ const piCmd = buildPiCommand(
205
+ piBinary,
206
+ chosenModel,
207
+ member.thinking,
208
+ resourcePlan.extensionPaths,
209
+ resourcePlan.trust.projectTrusted,
210
+ resourcePlan.selfExtensionPath,
211
+ sessionDir,
212
+ );
213
+ const env: Record<string, string> = {
214
+ ...process.env,
215
+ PI_TEAM_NAME: safeTeamName,
216
+ PI_AGENT_NAME: safeName,
217
+ PI_LIFECYCLE_RUN_ID: runId,
218
+ };
219
+
220
+ const leadMember = (await teams.readConfig(safeTeamName)).members.find(m => m.name === "team-lead");
221
+ const anchorPaneId = leadMember?.tmuxPaneId || process.env.TMUX_PANE || undefined;
222
+ spawnedTerminalId = options.terminal.spawn({ name: safeName, cwd: params.cwd, command: piCmd, env, anchorPaneId });
223
+ await teams.updateMember(safeTeamName, safeName, { tmuxPaneId: spawnedTerminalId });
224
+ spawnResults.push({ name: agentName, status: "spawned", error: undefined });
225
+ } catch (e) {
226
+ // A pane returned by spawn belongs to this run even if roster ownership
227
+ // has already moved on. Stop and synchronously verify that exact process
228
+ // before lifecycle/roster ownership checks can yield or return early.
229
+ const terminationError = spawnedTerminalId === undefined
230
+ ? null
231
+ : stopAndVerifySpawnedPane(options.terminal, spawnedTerminalId);
232
+
233
+ spawnResults.push({ name: agentName, status: "error", error: `Failed to spawn: ${e}` });
234
+ await withLifecycleTombstoneLock(safeTeamName, safeName, async lifecycleLock => {
235
+ const fence = lifecycleLock.read();
236
+ if (fence.status === "corrupt") return;
237
+ if (fence.status === "occupied" && fence.tombstone.runId !== runId) return;
238
+
239
+ const currentConfig = await teams.readConfig(safeTeamName).catch(() => null);
240
+ const currentMember = currentConfig?.members.find(item => item.name === safeName);
241
+ if (!currentMember || currentMember.lifecycleRunId !== runId) return;
242
+
243
+ if (terminationError) {
244
+ lifecycleLock.occupy({
245
+ team: safeTeamName,
246
+ agent: safeName,
247
+ runId,
248
+ role: "write",
249
+ reason: "spawn_rollback",
250
+ phase: "cleanup_failed",
251
+ extensionInstanceId,
252
+ });
253
+ lifecycleLock.updateMatching(runId, {
254
+ phase: "cleanup_failed",
255
+ error: terminationError,
256
+ });
257
+ return;
258
+ }
259
+
260
+ await messaging.removeInboxMessagesByOperationUnderLifecycleLock(safeTeamName, safeName, bootstrapOperationId);
261
+ await runtime.deleteRuntimeStatusUnderLifecycleLock(safeTeamName, safeName, runId);
262
+ await teams.removeMemberMatchingRun(safeTeamName, safeName, runId);
263
+ if (!agentSettings.agentSessions.showInResume) {
264
+ try {
265
+ cleanupPrivateAgentSessionDirectory(safeTeamName, safeName, runId);
266
+ } catch {
267
+ // A later private-session janitor can retry cleanup.
268
+ }
269
+ }
270
+ });
271
+ }
272
+ } catch (e) {
273
+ spawnResults.push({ name: agentName, status: "error", error: String(e) });
274
+ }
275
+ }
276
+
277
+ const summary = spawnResults.map(r => `${r.name}: ${r.status}${r.error ? ` (${r.error})` : ""}`).join("\n");
278
+ return {
279
+ content: [{ type: "text", text: `Team "${params.team_name}" created from predefined team "${params.predefined_team}".\n\nAgent spawn results:\n${summary}` }],
280
+ details: { teamName: params.team_name, predefinedTeam: params.predefined_team, modelSlot: level.slot, results: spawnResults },
281
+ };
282
+ },
283
+ });
284
+
285
+ pi.registerTool({
286
+ name: "save_team_as_template",
287
+ label: "Save Team as Template",
288
+ description: "Save a runtime team as a reusable predefined team template. Creates agent definition files and updates teams.yaml. Use this when you've created a team with custom prompts and want to reuse it later.",
289
+ parameters: Type.Object({
290
+ team_name: Type.String({ description: "Name of the runtime team to save" }),
291
+ template_name: Type.String({ description: "Name for the template (e.g., 'modularization', 'frontend-team')" }),
292
+ description: Type.Optional(Type.String({ description: "Description for the template" })),
293
+ scope: Type.Optional(StringEnum(["user", "project"], { description: "Where to save: 'user' for global (~/.pi), 'project' for project-local (.pi). Defaults to 'user'." })),
294
+ }),
295
+ async execute(_toolCallId: string, params: any, _signal: AbortSignal, _onUpdate: any, ctx: any) {
296
+ const teamName = params.team_name;
297
+ if (!teams.teamExists(teamName)) throw new Error(`Team "${teamName}" does not exist. Use list_runtime_teams to see available teams.`);
298
+ const config = await teams.readConfig(teamName);
299
+ const teammates = config.members.filter(m => m.agentType === "teammate");
300
+ if (teammates.length === 0) throw new Error(`Team "${teamName}" has no teammates to save. Only teams with spawned teammates can be saved as templates.`);
301
+
302
+ const result = predefined.saveTeamTemplate(config, { templateName: params.template_name, description: params.description, scope: params.scope || "user", projectDir: ctx.cwd });
303
+ const agentSummary = result.savedAgents.map(a => ` - ${a.name}: ${a.existed ? "updated" : "created"} at ${a.path}`).join("\n");
304
+ const message = `Team "${teamName}" saved as template "${params.template_name}".
305
+
306
+ Agents saved:
307
+ ${agentSummary}
308
+
309
+ Template location: ${result.teamsYamlPath}
310
+
311
+ You can now use this template with:
312
+ create_predefined_team({ team_name: "new-team", predefined_team: "${params.template_name}", cwd: "..." })`;
313
+
314
+ return { content: [{ type: "text", text: message }], details: { teamName, templateName: params.template_name, agentsDir: result.agentsDir, teamsYamlPath: result.teamsYamlPath, savedAgents: result.savedAgents, templateExisted: result.templateExisted } };
315
+ },
316
+ });
317
+
318
+ pi.registerTool({
319
+ name: "list_runtime_teams",
320
+ label: "List Runtime Teams",
321
+ description: "List all runtime team configurations that can be saved as templates. These are active or saved teams from ~/.pi/teams/.",
322
+ parameters: Type.Object({}),
323
+ async execute() {
324
+ const runtimeTeams = predefined.listRuntimeTeams();
325
+ if (runtimeTeams.length === 0) return { content: [{ type: "text", text: "No runtime teams found. Create a team with team_create first." }], details: { teams: [] } };
326
+ const result = runtimeTeams.map(team => ({ name: team.name, description: team.description, memberCount: team.memberCount, createdAt: team.createdAt ? new Date(team.createdAt).toISOString() : undefined }));
327
+ const summary = result.map(t => `- ${t.name}: ${t.memberCount} teammate(s)${t.description ? ` - ${t.description}` : ""}`).join("\n");
328
+ return { content: [{ type: "text", text: `Runtime teams:\n${summary}` }], details: { teams: result } };
329
+ },
330
+ });
331
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { registerCoordinationTools } from "./coordination-tools.js";
3
+
4
+ describe("coordination public tool surface", () => {
5
+ it("does not register helper, broadcast, shared-memory, or skill-loading tools", () => {
6
+ const tools = new Map<string, any>();
7
+
8
+ registerCoordinationTools({ registerTool: (tool: any) => tools.set(tool.name, tool) }, {
9
+ agentName: "agent",
10
+ isTeammate: true,
11
+ terminal: null,
12
+ getTeamName: () => "session",
13
+ requireWriteAgentTeam: async () => "session",
14
+ requireTeamContext: () => "session",
15
+ releaseAllClaimsForAgent: vi.fn(async () => []),
16
+ drainWriteQueue: vi.fn(async () => {}),
17
+ resolveSkillFile: vi.fn(),
18
+ adoptTeamAsLead: vi.fn(),
19
+ renderLeadInboxStatus: vi.fn(async () => {}),
20
+ resetLeadWakeNotifiedCount: vi.fn(),
21
+ });
22
+
23
+ expect(Array.from(tools.keys()).sort()).toEqual([
24
+ "claim_file",
25
+ "list_file_claims",
26
+ "read_inbox",
27
+ "release_file",
28
+ "report_and_exit",
29
+ "send_message",
30
+ ]);
31
+ expect(tools.has("request_read_helper")).toBe(false);
32
+ expect(tools.has("broadcast_message")).toBe(false);
33
+ expect(tools.has("use_skill")).toBe(false);
34
+ });
35
+ });