pi-teams 0.9.6 → 0.9.8

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.
package/README.md CHANGED
@@ -46,6 +46,7 @@ pi install npm:pi-teams
46
46
 
47
47
  ### Advanced Features
48
48
  - **Predefined Teams**: Define team templates in `teams.yaml` and spawn entire teams with a single command.
49
+ - **Save Teams as Templates**: Convert any runtime team into a reusable template with a single command.
49
50
  - **Isolated OS Windows**: Launch teammates in true separate OS windows instead of panes.
50
51
  - **Persistent Window Titles**: Windows are automatically titled `[team-name]: [agent-name]` for easy identification in your window manager.
51
52
  - **Plan Approval Mode**: Require teammates to submit their implementation plans for your approval before they touch any code.
@@ -107,6 +108,13 @@ Teammates in `planning` mode will use `task_submit_plan`. As the lead, review th
107
108
  ### 5. Shut Down Team
108
109
  > **You:** "We're done. Shut down the team and close the panes."
109
110
 
111
+ **Automatic Cleanup:**
112
+ When you shut down a team, pi-teams automatically cleans up orphaned agent session folders from `~/.pi/agent/teams/` that are older than 1 hour. This prevents accumulation of stale session data over time.
113
+
114
+ **Manual Cleanup:**
115
+ If you need to clean up agent sessions without shutting down a team, or want to use a different age threshold:
116
+ > **You:** "Clean up agent session folders older than 24 hours."
117
+
110
118
  ---
111
119
 
112
120
  ## 🏗️ Predefined Teams
@@ -198,6 +206,51 @@ This single command:
198
206
 
199
207
  ---
200
208
 
209
+ ## 💾 Save Teams as Templates
210
+
211
+ Sometimes you create a team with custom prompts and settings that you'd like to reuse later. Instead of manually creating `teams.yaml` and agent definition files, you can save any runtime team as a template.
212
+
213
+ ### The Workflow
214
+
215
+ ```
216
+ CREATE → USE → SAVE → REUSE
217
+ ```
218
+
219
+ 1. **Create** a team with custom teammates and prompts
220
+ 2. **Use** the team for your task
221
+ 3. **Save** the team as a reusable template
222
+ 4. **Reuse** the template later (even on different projects)
223
+
224
+ ### List Runtime Teams
225
+
226
+ See which teams you have that can be saved:
227
+
228
+ > **You:** "List all runtime teams."
229
+
230
+ ### Save a Team as a Template
231
+
232
+ > **You:** "Save team 'my-modularization-team' as template 'code-modularization'"
233
+
234
+ This creates:
235
+ - Agent definition files in `~/.pi/agent/agents/` for each teammate
236
+ - Updates `~/.pi/teams.yaml` with the new template
237
+
238
+ ### Save to Project-Local Scope
239
+
240
+ To save a template that's specific to the current project:
241
+
242
+ > **You:** "Save team 'my-frontend-team' as template 'frontend-sprint' with scope 'project'"
243
+
244
+ This creates files in `.pi/agents/` and `.pi/teams.yaml` in the current project directory.
245
+
246
+ ### Reuse Your Template
247
+
248
+ Once saved, use it just like any predefined team:
249
+
250
+ > **You:** "Create a team named 'auth-refactor' from the 'code-modularization' template in the current directory"
251
+
252
+ ---
253
+
201
254
  ## 📚 Learn More
202
255
 
203
256
  - **[Full Usage Guide](docs/guide.md)** - Detailed examples, hook system, best practices, and troubleshooting
@@ -12,6 +12,7 @@ import { Iterm2Adapter } from "../src/adapters/iterm2-adapter";
12
12
  import * as predefined from "../src/utils/predefined-teams";
13
13
  import * as path from "node:path";
14
14
  import * as fs from "node:fs";
15
+ import * as os from "node:os";
15
16
  import { spawnSync } from "node:child_process";
16
17
 
17
18
  // Cache for available models
@@ -263,6 +264,45 @@ function cleanupStaleTeam(teamName: string, terminal: any): boolean {
263
264
  return false;
264
265
  }
265
266
 
267
+ /**
268
+ * Clean up orphaned agent session folders from ~/.pi/agent/teams/
269
+ * These are created by the pi core system when agents are spawned.
270
+ * We remove folders that are older than 24 hours to avoid deleting active sessions.
271
+ * Returns the number of folders cleaned up.
272
+ */
273
+ function cleanupAgentSessionFolders(maxAgeMs: number = 24 * 60 * 60 * 1000): number {
274
+ const agentTeamsDir = path.join(os.homedir(), ".pi", "agent", "teams");
275
+ if (!fs.existsSync(agentTeamsDir)) return 0;
276
+
277
+ let cleaned = 0;
278
+ const now = Date.now();
279
+
280
+ for (const dir of fs.readdirSync(agentTeamsDir)) {
281
+ const sessionDir = path.join(agentTeamsDir, dir);
282
+ const configFile = path.join(sessionDir, "config.json");
283
+
284
+ try {
285
+ // Check if this is a directory with a config.json
286
+ if (!fs.statSync(sessionDir).isDirectory()) continue;
287
+ if (!fs.existsSync(configFile)) continue;
288
+
289
+ // Read the config to check the creation time
290
+ const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
291
+ const createdAt = config.createdAt ? new Date(config.createdAt).getTime() : 0;
292
+
293
+ // If the folder is older than maxAgeMs, delete it
294
+ if (createdAt > 0 && (now - createdAt) > maxAgeMs) {
295
+ fs.rmSync(sessionDir, { recursive: true });
296
+ cleaned++;
297
+ }
298
+ } catch {
299
+ // Ignore errors for individual folders
300
+ }
301
+ }
302
+
303
+ return cleaned;
304
+ }
305
+
266
306
  export default function (pi: ExtensionAPI) {
267
307
  const isTeammate = !!process.env.PI_AGENT_NAME;
268
308
  const agentName = process.env.PI_AGENT_NAME || "team-lead";
@@ -794,13 +834,44 @@ export default function (pi: ExtensionAPI) {
794
834
  const tasksDir = paths.taskDir(teamName);
795
835
  if (fs.existsSync(tasksDir)) fs.rmSync(tasksDir, { recursive: true });
796
836
  if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
797
- return { content: [{ type: "text", text: `Team ${teamName} shut down.` }], details: {} };
837
+
838
+ // Clean up orphaned agent session folders (older than 1 hour)
839
+ const cleanedSessions = cleanupAgentSessionFolders(60 * 60 * 1000);
840
+
841
+ return {
842
+ content: [{
843
+ type: "text",
844
+ text: `Team ${teamName} shut down.${cleanedSessions > 0 ? ` Cleaned up ${cleanedSessions} orphaned agent session folder(s).` : ""}`
845
+ }],
846
+ details: { cleanedSessions }
847
+ };
798
848
  } catch (e) {
799
849
  throw new Error(`Failed to shutdown team: ${e}`);
800
850
  }
801
851
  },
802
852
  });
803
853
 
854
+ pi.registerTool({
855
+ name: "cleanup_agent_sessions",
856
+ label: "Cleanup Agent Sessions",
857
+ description: "Clean up orphaned agent session folders from ~/.pi/agent/teams/ that are older than a specified age.",
858
+ parameters: Type.Object({
859
+ max_age_hours: Type.Optional(Type.Number()),
860
+ }),
861
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
862
+ const maxAgeHours = params.max_age_hours ?? 24;
863
+ const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
864
+ const cleaned = cleanupAgentSessionFolders(maxAgeMs);
865
+ return {
866
+ content: [{
867
+ type: "text",
868
+ text: `Cleaned up ${cleaned} orphaned agent session folder(s) older than ${maxAgeHours} hour(s).`
869
+ }],
870
+ details: { cleaned, maxAgeHours }
871
+ };
872
+ },
873
+ });
874
+
804
875
  pi.registerTool({
805
876
  name: "task_read",
806
877
  label: "Read Task",
@@ -1110,4 +1181,101 @@ export default function (pi: ExtensionAPI) {
1110
1181
  };
1111
1182
  },
1112
1183
  });
1184
+
1185
+ pi.registerTool({
1186
+ name: "save_team_as_template",
1187
+ label: "Save Team as Template",
1188
+ 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.",
1189
+ parameters: Type.Object({
1190
+ team_name: Type.String({ description: "Name of the runtime team to save" }),
1191
+ template_name: Type.String({ description: "Name for the template (e.g., 'modularization', 'frontend-team')" }),
1192
+ description: Type.Optional(Type.String({ description: "Description for the template" })),
1193
+ scope: Type.Optional(StringEnum(["user", "project"], { description: "Where to save: 'user' for global (~/.pi), 'project' for project-local (.pi). Defaults to 'user'." })),
1194
+ }),
1195
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
1196
+ const teamName = params.team_name;
1197
+
1198
+ // Verify the team exists
1199
+ if (!teams.teamExists(teamName)) {
1200
+ throw new Error(`Team "${teamName}" does not exist. Use list_runtime_teams to see available teams.`);
1201
+ }
1202
+
1203
+ // Read the team configuration
1204
+ const config = await teams.readConfig(teamName);
1205
+
1206
+ // Check that there are teammates to save
1207
+ const teammates = config.members.filter(m => m.agentType === "teammate");
1208
+ if (teammates.length === 0) {
1209
+ throw new Error(`Team "${teamName}" has no teammates to save. Only teams with spawned teammates can be saved as templates.`);
1210
+ }
1211
+
1212
+ // Save the team as a template
1213
+ const result = predefined.saveTeamTemplate(config, {
1214
+ templateName: params.template_name,
1215
+ description: params.description,
1216
+ scope: params.scope || "user",
1217
+ projectDir: ctx.cwd,
1218
+ });
1219
+
1220
+ // Build summary message
1221
+ const agentSummary = result.savedAgents.map(a =>
1222
+ ` - ${a.name}: ${a.existed ? "updated" : "created"} at ${a.path}`
1223
+ ).join("\n");
1224
+
1225
+ const message = `Team "${teamName}" saved as template "${params.template_name}".
1226
+
1227
+ Agents saved:
1228
+ ${agentSummary}
1229
+
1230
+ Template location: ${result.teamsYamlPath}
1231
+
1232
+ You can now use this template with:
1233
+ create_predefined_team({ team_name: "new-team", predefined_team: "${params.template_name}", cwd: "..." })`;
1234
+
1235
+ return {
1236
+ content: [{ type: "text", text: message }],
1237
+ details: {
1238
+ teamName,
1239
+ templateName: params.template_name,
1240
+ agentsDir: result.agentsDir,
1241
+ teamsYamlPath: result.teamsYamlPath,
1242
+ savedAgents: result.savedAgents,
1243
+ templateExisted: result.templateExisted,
1244
+ },
1245
+ };
1246
+ },
1247
+ });
1248
+
1249
+ pi.registerTool({
1250
+ name: "list_runtime_teams",
1251
+ label: "List Runtime Teams",
1252
+ description: "List all runtime team configurations that can be saved as templates. These are active or saved teams from ~/.pi/teams/.",
1253
+ parameters: Type.Object({}),
1254
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
1255
+ const runtimeTeams = predefined.listRuntimeTeams();
1256
+
1257
+ if (runtimeTeams.length === 0) {
1258
+ return {
1259
+ content: [{ type: "text", text: "No runtime teams found. Create a team with team_create first." }],
1260
+ details: { teams: [] },
1261
+ };
1262
+ }
1263
+
1264
+ const result = runtimeTeams.map(team => ({
1265
+ name: team.name,
1266
+ description: team.description,
1267
+ memberCount: team.memberCount,
1268
+ createdAt: team.createdAt ? new Date(team.createdAt).toISOString() : undefined,
1269
+ }));
1270
+
1271
+ const summary = result.map(t =>
1272
+ `- ${t.name}: ${t.memberCount} teammate(s)${t.description ? ` - ${t.description}` : ""}`
1273
+ ).join("\n");
1274
+
1275
+ return {
1276
+ content: [{ type: "text", text: `Runtime teams:\n${summary}` }],
1277
+ details: { teams: result },
1278
+ };
1279
+ },
1280
+ });
1113
1281
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": {
6
6
  "type": "git",
@@ -258,4 +258,252 @@ export function getAgentDefinition(name: string, projectDir?: string): AgentDefi
258
258
  export function getPredefinedTeam(name: string, projectDir?: string): PredefinedTeam | undefined {
259
259
  const teams = getAllPredefinedTeams(projectDir);
260
260
  return teams.find(t => t.name === name);
261
+ }
262
+
263
+ /**
264
+ * Options for saving a team as a template
265
+ */
266
+ export interface SaveTeamTemplateOptions {
267
+ templateName: string;
268
+ description?: string;
269
+ scope: "user" | "project";
270
+ projectDir?: string;
271
+ }
272
+
273
+ /**
274
+ * Result of saving a team as a template
275
+ */
276
+ export interface SaveTeamTemplateResult {
277
+ templateName: string;
278
+ agentsDir: string;
279
+ teamsYamlPath: string;
280
+ savedAgents: Array<{
281
+ name: string;
282
+ path: string;
283
+ existed: boolean;
284
+ }>;
285
+ templateExisted: boolean;
286
+ }
287
+
288
+ /**
289
+ * Generate markdown content for an agent definition file
290
+ */
291
+ export function generateAgentMarkdown(agent: {
292
+ name: string;
293
+ description?: string;
294
+ tools?: string[];
295
+ model?: string;
296
+ thinking?: "off" | "minimal" | "low" | "medium" | "high";
297
+ prompt?: string;
298
+ }): string {
299
+ const lines: string[] = ["---"];
300
+ lines.push(`name: ${agent.name}`);
301
+ if (agent.description) {
302
+ lines.push(`description: ${agent.description}`);
303
+ }
304
+ if (agent.tools && agent.tools.length > 0) {
305
+ lines.push(`tools: ${agent.tools.join(", ")}`);
306
+ }
307
+ if (agent.model) {
308
+ lines.push(`model: ${agent.model}`);
309
+ }
310
+ if (agent.thinking) {
311
+ lines.push(`thinking: ${agent.thinking}`);
312
+ }
313
+ lines.push("---");
314
+ lines.push("");
315
+ if (agent.prompt) {
316
+ lines.push(agent.prompt);
317
+ }
318
+ return lines.join("\n");
319
+ }
320
+
321
+ /**
322
+ * Generate teams.yaml content by adding a new team template
323
+ */
324
+ export function generateTeamsYamlWithTemplate(
325
+ existingContent: string,
326
+ templateName: string,
327
+ agentNames: string[],
328
+ description?: string
329
+ ): string {
330
+ // Check if template already exists
331
+ const lines = existingContent.split("\n");
332
+ let templateExists = false;
333
+ let templateStartLine = -1;
334
+
335
+ for (let i = 0; i < lines.length; i++) {
336
+ if (lines[i].trim() === `${templateName}:`) {
337
+ templateExists = true;
338
+ templateStartLine = i;
339
+ break;
340
+ }
341
+ }
342
+
343
+ if (templateExists) {
344
+ // Replace existing template - find where it ends
345
+ let templateEndLine = templateStartLine + 1;
346
+ while (templateEndLine < lines.length && (lines[templateEndLine].startsWith(" ") || lines[templateEndLine].startsWith("\t"))) {
347
+ templateEndLine++;
348
+ }
349
+ // Remove old template lines
350
+ lines.splice(templateStartLine, templateEndLine - templateStartLine);
351
+ }
352
+
353
+ // Build new template entry
354
+ const templateLines: string[] = [];
355
+ if (description) {
356
+ templateLines.push(`# ${description}`);
357
+ }
358
+ templateLines.push(`${templateName}:`);
359
+ for (const agentName of agentNames) {
360
+ templateLines.push(` - ${agentName}`);
361
+ }
362
+ templateLines.push("");
363
+
364
+ // Find insertion point (at the end or after existing content)
365
+ let insertIndex = lines.length;
366
+
367
+ // Remove trailing empty lines to find actual end
368
+ while (insertIndex > 0 && lines[insertIndex - 1].trim() === "") {
369
+ insertIndex--;
370
+ }
371
+
372
+ // Insert new template
373
+ lines.splice(insertIndex, 0, ...templateLines);
374
+
375
+ return lines.join("\n");
376
+ }
377
+
378
+ /**
379
+ * Save a team configuration as a reusable template.
380
+ * Creates agent definition files and updates teams.yaml.
381
+ */
382
+ export function saveTeamTemplate(
383
+ teamConfig: {
384
+ name: string;
385
+ description?: string;
386
+ members: Array<{
387
+ name: string;
388
+ agentType: string;
389
+ model?: string;
390
+ thinking?: "off" | "minimal" | "low" | "medium" | "high";
391
+ prompt?: string;
392
+ }>;
393
+ defaultModel?: string;
394
+ },
395
+ options: SaveTeamTemplateOptions
396
+ ): SaveTeamTemplateResult {
397
+ // Determine output paths based on scope
398
+ const baseDir = options.scope === "project"
399
+ ? path.join(options.projectDir || process.cwd(), ".pi")
400
+ : path.join(os.homedir(), ".pi", "agent");
401
+
402
+ const agentsDir = path.join(baseDir, "agents");
403
+ const teamsYamlPath = path.join(baseDir, "teams.yaml");
404
+
405
+ // Ensure agents directory exists
406
+ if (!fs.existsSync(agentsDir)) {
407
+ fs.mkdirSync(agentsDir, { recursive: true });
408
+ }
409
+
410
+ // Filter to only teammates (not the lead)
411
+ const teammates = teamConfig.members.filter(m => m.agentType === "teammate");
412
+ const agentNames: string[] = [];
413
+ const savedAgents: SaveTeamTemplateResult["savedAgents"] = [];
414
+
415
+ // Save each teammate as an agent definition
416
+ for (const member of teammates) {
417
+ const agentFileName = `${member.name}.md`;
418
+ const agentPath = path.join(agentsDir, agentFileName);
419
+ const existed = fs.existsSync(agentPath);
420
+
421
+ // Use the model from the member, or fall back to the team's default model
422
+ const model = member.model || teamConfig.defaultModel;
423
+
424
+ const content = generateAgentMarkdown({
425
+ name: member.name,
426
+ description: `Agent from team '${teamConfig.name}'`,
427
+ model,
428
+ thinking: member.thinking,
429
+ prompt: member.prompt,
430
+ });
431
+
432
+ fs.writeFileSync(agentPath, content);
433
+ agentNames.push(member.name);
434
+ savedAgents.push({ name: member.name, path: agentPath, existed });
435
+ }
436
+
437
+ // Update teams.yaml
438
+ let teamsContent = "";
439
+ if (fs.existsSync(teamsYamlPath)) {
440
+ teamsContent = fs.readFileSync(teamsYamlPath, "utf-8");
441
+ }
442
+
443
+ // Check if template already exists
444
+ const templateExisted = teamsContent.includes(`${options.templateName}:`);
445
+
446
+ // Generate updated teams.yaml content
447
+ const updatedContent = generateTeamsYamlWithTemplate(
448
+ teamsContent,
449
+ options.templateName,
450
+ agentNames,
451
+ options.description || teamConfig.description
452
+ );
453
+
454
+ fs.writeFileSync(teamsYamlPath, updatedContent);
455
+
456
+ return {
457
+ templateName: options.templateName,
458
+ agentsDir,
459
+ teamsYamlPath,
460
+ savedAgents,
461
+ templateExisted,
462
+ };
463
+ }
464
+
465
+ /**
466
+ * List all runtime team configurations from ~/.pi/teams/
467
+ */
468
+ export function listRuntimeTeams(): Array<{
469
+ name: string;
470
+ description?: string;
471
+ memberCount: number;
472
+ createdAt?: number;
473
+ }> {
474
+ const teamsDir = path.join(os.homedir(), ".pi", "teams");
475
+
476
+ if (!fs.existsSync(teamsDir)) {
477
+ return [];
478
+ }
479
+
480
+ const teams: Array<{
481
+ name: string;
482
+ description?: string;
483
+ memberCount: number;
484
+ createdAt?: number;
485
+ }> = [];
486
+
487
+ for (const teamDir of fs.readdirSync(teamsDir, { withFileTypes: true })) {
488
+ if (!teamDir.isDirectory()) continue;
489
+
490
+ const configFile = path.join(teamsDir.path || teamsDir.name, teamDir.name, "config.json");
491
+ const configPath = path.join(os.homedir(), ".pi", "teams", teamDir.name, "config.json");
492
+
493
+ if (fs.existsSync(configPath)) {
494
+ try {
495
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
496
+ teams.push({
497
+ name: config.name || teamDir.name,
498
+ description: config.description,
499
+ memberCount: (config.members || []).filter((m: any) => m.agentType === "teammate").length,
500
+ createdAt: config.createdAt,
501
+ });
502
+ } catch {
503
+ // Skip invalid config files
504
+ }
505
+ }
506
+ }
507
+
508
+ return teams;
261
509
  }