pi-teams 0.9.2 → 0.9.5

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.
@@ -142,10 +142,134 @@ function resolveModelWithProvider(modelName: string): string | null {
142
142
  return null;
143
143
  }
144
144
 
145
+ /**
146
+ * Find the team this session is the lead for (if any).
147
+ * Checks the lead-session.json file to match PID.
148
+ */
149
+ function findLeadTeamForSession(): string | null {
150
+ try {
151
+ const teamsDir = paths.TEAMS_DIR;
152
+ if (!fs.existsSync(teamsDir)) return null;
153
+
154
+ for (const teamDir of fs.readdirSync(teamsDir)) {
155
+ const sessionFile = paths.leadSessionPath(teamDir);
156
+ if (fs.existsSync(sessionFile)) {
157
+ try {
158
+ const session = JSON.parse(fs.readFileSync(sessionFile, "utf-8"));
159
+ if (session.pid === process.pid) {
160
+ return teamDir;
161
+ }
162
+ } catch {
163
+ // Ignore corrupted session files
164
+ }
165
+ }
166
+ }
167
+ return null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Register this session as the lead for a team.
175
+ */
176
+ function registerLeadSession(teamName: string) {
177
+ const sessionFile = paths.leadSessionPath(teamName);
178
+ const dir = path.dirname(sessionFile);
179
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
180
+ fs.writeFileSync(sessionFile, JSON.stringify({
181
+ pid: process.pid,
182
+ startedAt: Date.now(),
183
+ }));
184
+ }
185
+
186
+ /**
187
+ * Check if a process with the given PID is still alive.
188
+ */
189
+ function isPidAlive(pid: number): boolean {
190
+ try {
191
+ process.kill(pid, 0); // Signal 0 = check if process exists
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Clean up a stale team if the lead process is dead.
200
+ * Kills all teammate panes/windows and removes all state files.
201
+ * Returns true if cleanup was performed, false otherwise.
202
+ */
203
+ function cleanupStaleTeam(teamName: string, terminal: any): boolean {
204
+ const sessionFile = paths.leadSessionPath(teamName);
205
+ const configFile = paths.configPath(teamName);
206
+
207
+ if (!fs.existsSync(sessionFile) || !fs.existsSync(configFile)) {
208
+ return false;
209
+ }
210
+
211
+ try {
212
+ const session = JSON.parse(fs.readFileSync(sessionFile, "utf-8"));
213
+
214
+ // Only cleanup if the lead PID is actually dead
215
+ if (session.pid && !isPidAlive(session.pid)) {
216
+ // Read config to get member info for cleanup
217
+ try {
218
+ const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
219
+
220
+ // Kill all teammate panes/windows
221
+ for (const member of config.members || []) {
222
+ if (member.name === "team-lead") continue;
223
+
224
+ // Kill via PID file
225
+ const pidFile = path.join(paths.teamDir(teamName), `${member.name}.pid`);
226
+ if (fs.existsSync(pidFile)) {
227
+ try {
228
+ const pid = fs.readFileSync(pidFile, "utf-8").trim();
229
+ process.kill(parseInt(pid), "SIGKILL");
230
+ fs.unlinkSync(pidFile);
231
+ } catch {}
232
+ }
233
+
234
+ // Kill via terminal adapter
235
+ if (terminal) {
236
+ if (member.windowId) {
237
+ try { terminal.killWindow(member.windowId); } catch {}
238
+ }
239
+ if (member.tmuxPaneId) {
240
+ try { terminal.kill(member.tmuxPaneId); } catch {}
241
+ }
242
+ }
243
+ }
244
+ } catch {}
245
+
246
+ // Delete entire team directory
247
+ const teamDirectory = paths.teamDir(teamName);
248
+ if (fs.existsSync(teamDirectory)) {
249
+ fs.rmSync(teamDirectory, { recursive: true });
250
+ }
251
+
252
+ // Delete tasks directory
253
+ const tasksDirectory = paths.taskDir(teamName);
254
+ if (fs.existsSync(tasksDirectory)) {
255
+ fs.rmSync(tasksDirectory, { recursive: true });
256
+ }
257
+
258
+ return true;
259
+ }
260
+ } catch {}
261
+
262
+ return false;
263
+ }
264
+
145
265
  export default function (pi: ExtensionAPI) {
146
266
  const isTeammate = !!process.env.PI_AGENT_NAME;
147
267
  const agentName = process.env.PI_AGENT_NAME || "team-lead";
148
- const teamName = process.env.PI_TEAM_NAME;
268
+ const envTeamName = process.env.PI_TEAM_NAME;
269
+
270
+ // For leads without PI_TEAM_NAME, check if we're registered as lead for a team
271
+ const detectedTeamName = envTeamName || findLeadTeamForSession();
272
+ const teamName = detectedTeamName;
149
273
 
150
274
  const terminal = getTerminalAdapter();
151
275
 
@@ -181,27 +305,34 @@ export default function (pi: ExtensionAPI) {
181
305
  setTimeout(() => {
182
306
  pi.sendUserMessage(`I am starting my work as '${agentName}' on team '${teamName}'. Checking my inbox for instructions...`);
183
307
  }, 1000);
308
+ } else if (teamName) {
309
+ ctx.ui.setStatus("pi-teams", `Lead @ ${teamName}`);
310
+ }
184
311
 
312
+ // Inbox polling for BOTH teammates AND team-leads (anyone with teamName)
313
+ if (teamName) {
185
314
  setInterval(async () => {
186
- if (ctx.isIdle() && teamName) {
315
+ if (ctx.isIdle()) {
187
316
  try {
188
317
  const unread = await messaging.readInbox(teamName, agentName, true, false);
189
- await runtime.writeRuntimeStatus(teamName, agentName, {
190
- lastHeartbeatAt: Date.now(),
191
- });
318
+ if (isTeammate) {
319
+ await runtime.writeRuntimeStatus(teamName, agentName, {
320
+ lastHeartbeatAt: Date.now(),
321
+ });
322
+ }
192
323
  if (unread.length > 0) {
193
324
  pi.sendUserMessage(`I have ${unread.length} new message(s) in my inbox. Reading them now...`);
194
325
  }
195
326
  } catch (e) {
196
- await runtime.writeRuntimeStatus(teamName, agentName, {
197
- lastHeartbeatAt: Date.now(),
198
- lastError: runtime.createRuntimeError(e),
199
- });
327
+ if (isTeammate) {
328
+ await runtime.writeRuntimeStatus(teamName, agentName, {
329
+ lastHeartbeatAt: Date.now(),
330
+ lastError: runtime.createRuntimeError(e),
331
+ });
332
+ }
200
333
  }
201
334
  }
202
335
  }, 30000);
203
- } else if (teamName) {
204
- ctx.ui.setStatus("pi-teams", `Lead @ ${teamName}`);
205
336
  }
206
337
  });
207
338
 
@@ -289,7 +420,15 @@ export default function (pi: ExtensionAPI) {
289
420
  separate_windows: Type.Optional(Type.Boolean({ default: false, description: "Open teammates in separate OS windows instead of panes" })),
290
421
  }),
291
422
  async execute(toolCallId, params: any, signal, onUpdate, ctx) {
423
+ // Auto-cleanup stale team if the previous lead process is dead
424
+ // This handles the case where a session was aborted and restarted
425
+ if (teams.teamExists(params.team_name)) {
426
+ cleanupStaleTeam(params.team_name, terminal);
427
+ }
428
+
292
429
  const config = teams.createTeam(params.team_name, "local-session", "lead-agent", params.description, params.default_model, params.separate_windows);
430
+ // Register this session as the lead so it can receive inbox messages
431
+ registerLeadSession(params.team_name);
293
432
  return {
294
433
  content: [{ type: "text", text: `Team ${params.team_name} created.` }],
295
434
  details: { config },
@@ -324,6 +463,15 @@ export default function (pi: ExtensionAPI) {
324
463
  }
325
464
 
326
465
  const teamConfig = await teams.readConfig(safeTeamName);
466
+
467
+ // Check if a teammate with this name already exists - kill them first
468
+ // This handles the case where the user aborts mid-execution and restarts
469
+ const existingMember = teamConfig.members.find(m => m.name === safeName && m.agentType === "teammate");
470
+ if (existingMember) {
471
+ await killTeammate(safeTeamName, existingMember);
472
+ await teams.removeMember(safeTeamName, safeName);
473
+ }
474
+
327
475
  let chosenModel = params.model || teamConfig.defaultModel;
328
476
 
329
477
  // Resolve model to provider/model format
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.9.2",
3
+ "version": "0.9.5",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,3 +39,7 @@ export function runtimeStatusPath(teamName: string, agentName: string) {
39
39
  export function configPath(teamName: string) {
40
40
  return path.join(teamDir(teamName), "config.json");
41
41
  }
42
+
43
+ export function leadSessionPath(teamName: string) {
44
+ return path.join(teamDir(teamName), "lead-session.json");
45
+ }