pi-teams 0.9.10 → 0.9.13

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.
@@ -18,23 +18,36 @@ import { spawnSync } from "node:child_process";
18
18
  /**
19
19
  * Build the command used to relaunch pi for teammate processes.
20
20
  *
21
- * In Bun-compiled pi binaries, process.argv[1] points at a virtual $bunfs path
22
- * like /$bunfs/root/pi, which is not a real file and breaks when prefixed with
23
- * `node`. process.execPath points at the actual executable in both compiled and
24
- * regular environments, so prefer that when available.
21
+ * There are three common cases:
22
+ * - npm/node install: pi runs as `node .../dist/cli.js`
23
+ * - standalone compiled binary: process.execPath is the actual `pi` executable
24
+ * - shim-based installs (e.g. Volta): process.execPath is `node` and argv[1]
25
+ * may be a shim path, so the safest relaunch command is plain `pi`
25
26
  */
26
27
  function getPiLaunchCommand(): string {
27
- // If we have an execPath, use it directly (works for both compiled binaries and node scripts)
28
- if (process.execPath) {
29
- return JSON.stringify(process.execPath);
28
+ const argv1 = process.argv[1];
29
+ const execPath = process.execPath;
30
+
31
+ // Regular Node install: relaunch the actual CLI script with node.
32
+ if (argv1) {
33
+ const ext = path.extname(argv1).toLowerCase();
34
+ const looksLikeScript = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"].includes(ext)
35
+ || /(?:^|[/\\])dist[/\\]cli\.js$/i.test(argv1);
36
+
37
+ if (looksLikeScript) {
38
+ return `node ${JSON.stringify(argv1)}`;
39
+ }
30
40
  }
31
41
 
32
- // Fallback: try argv[1] with node prefix for regular node environments
33
- if (process.argv[1]) {
34
- return `node ${JSON.stringify(process.argv[1])}`;
42
+ // Standalone binary install: execPath is the pi executable itself.
43
+ if (execPath) {
44
+ const base = path.basename(execPath).toLowerCase();
45
+ if (base !== "node" && base !== "node.exe" && base !== "bun" && base !== "bun.exe") {
46
+ return JSON.stringify(execPath);
47
+ }
35
48
  }
36
49
 
37
- // Last resort: just use "pi" and hope it's on PATH
50
+ // Shim-based installs (like Volta) are safest to relaunch through PATH.
38
51
  return "pi";
39
52
  }
40
53
 
@@ -333,12 +346,42 @@ export default function (pi: ExtensionAPI) {
333
346
 
334
347
  // For leads without PI_TEAM_NAME, check if we're registered as lead for a team
335
348
  const detectedTeamName = envTeamName || findLeadTeamForSession();
336
- const teamName = detectedTeamName;
349
+ let teamName = detectedTeamName;
337
350
 
338
351
  const terminal = getTerminalAdapter();
339
352
 
353
+ // Track whether lead inbox polling has been started (to avoid duplicates)
354
+ let leadPollingStarted = false;
355
+ let sessionCtx: any = null;
356
+
357
+ /**
358
+ * Start inbox polling for the team lead.
359
+ * Called when a team is created or when the lead reconnects to an existing team.
360
+ * Requires sessionCtx to be set (from session_start).
361
+ */
362
+ function startLeadInboxPolling() {
363
+ if (leadPollingStarted || isTeammate || !sessionCtx) return;
364
+ leadPollingStarted = true;
365
+
366
+ setInterval(async () => {
367
+ if (!teamName) return;
368
+ if (sessionCtx.isIdle()) {
369
+ try {
370
+ const unread = await messaging.readInbox(teamName, agentName, true, false);
371
+ if (unread.length > 0) {
372
+ pi.sendUserMessage(`I have ${unread.length} new message(s) in my inbox. Reading them now...`);
373
+ }
374
+ } catch {
375
+ // Ignore errors for lead polling
376
+ }
377
+ }
378
+ }, 30000);
379
+ }
380
+
340
381
  pi.on("session_start", async (_event, ctx) => {
341
382
  paths.ensureDirs();
383
+ sessionCtx = ctx;
384
+
342
385
  if (isTeammate) {
343
386
  if (teamName) {
344
387
  const pidFile = path.join(paths.teamDir(teamName), `${agentName}.pid`);
@@ -369,34 +412,32 @@ export default function (pi: ExtensionAPI) {
369
412
  setTimeout(() => {
370
413
  pi.sendUserMessage(`I am starting my work as '${agentName}' on team '${teamName}'. Checking my inbox for instructions...`);
371
414
  }, 1000);
372
- } else if (teamName) {
373
- ctx.ui.setStatus("pi-teams", `Lead @ ${teamName}`);
374
- }
375
415
 
376
- // Inbox polling for BOTH teammates AND team-leads (anyone with teamName)
377
- if (teamName) {
378
- setInterval(async () => {
379
- if (ctx.isIdle()) {
380
- try {
381
- const unread = await messaging.readInbox(teamName, agentName, true, false);
382
- if (isTeammate) {
383
- await runtime.writeRuntimeStatus(teamName, agentName, {
416
+ // Inbox polling for teammates
417
+ if (teamName) {
418
+ setInterval(async () => {
419
+ if (ctx.isIdle()) {
420
+ try {
421
+ const unread = await messaging.readInbox(teamName!, agentName, true, false);
422
+ await runtime.writeRuntimeStatus(teamName!, agentName, {
384
423
  lastHeartbeatAt: Date.now(),
385
424
  });
386
- }
387
- if (unread.length > 0) {
388
- pi.sendUserMessage(`I have ${unread.length} new message(s) in my inbox. Reading them now...`);
389
- }
390
- } catch (e) {
391
- if (isTeammate) {
392
- await runtime.writeRuntimeStatus(teamName, agentName, {
425
+ if (unread.length > 0) {
426
+ pi.sendUserMessage(`I have ${unread.length} new message(s) in my inbox. Reading them now...`);
427
+ }
428
+ } catch (e) {
429
+ await runtime.writeRuntimeStatus(teamName!, agentName, {
393
430
  lastHeartbeatAt: Date.now(),
394
431
  lastError: runtime.createRuntimeError(e),
395
432
  });
396
433
  }
397
434
  }
398
- }
399
- }, 30000);
435
+ }, 30000);
436
+ }
437
+ } else if (teamName) {
438
+ // Lead reconnecting to an existing team
439
+ ctx.ui.setStatus("pi-teams", `Lead @ ${teamName}`);
440
+ startLeadInboxPolling();
400
441
  }
401
442
  });
402
443
 
@@ -493,6 +534,9 @@ export default function (pi: ExtensionAPI) {
493
534
  const config = teams.createTeam(params.team_name, "local-session", "lead-agent", params.description, params.default_model, params.separate_windows);
494
535
  // Register this session as the lead so it can receive inbox messages
495
536
  registerLeadSession(params.team_name);
537
+ // Update teamName and start inbox polling for the lead
538
+ teamName = params.team_name;
539
+ startLeadInboxPolling();
496
540
  return {
497
541
  content: [{ type: "text", text: `Team ${params.team_name} created.` }],
498
542
  details: { config },
@@ -1077,6 +1121,9 @@ export default function (pi: ExtensionAPI) {
1077
1121
  // Create the team
1078
1122
  const config = teams.createTeam(params.team_name, "local-session", "lead-agent", `Predefined team: ${params.predefined_team}`, params.default_model, params.separate_windows);
1079
1123
  registerLeadSession(params.team_name);
1124
+ // Update teamName and start inbox polling for the lead
1125
+ teamName = params.team_name;
1126
+ startLeadInboxPolling();
1080
1127
 
1081
1128
  const agentDefinitions = predefined.getAllAgentDefinitions(projectDir);
1082
1129
  const spawnResults: Array<{ name: string; status: string; error?: string }> = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.9.10",
3
+ "version": "0.9.13",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": {
6
6
  "type": "git",
@@ -7,6 +7,8 @@
7
7
 
8
8
  import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
9
9
  import { spawnSync } from "node:child_process";
10
+ import * as fs from "node:fs";
11
+ import * as paths from "../utils/paths";
10
12
 
11
13
  /**
12
14
  * Context needed for iTerm2 spawning (tracks last pane for layout)
@@ -19,6 +21,8 @@ export interface Iterm2SpawnContext {
19
21
  export class Iterm2Adapter implements TerminalAdapter {
20
22
  readonly name = "iTerm2";
21
23
  private spawnContext: Iterm2SpawnContext = {};
24
+ /** Cached iTerm2 session ID for this process (looked up from team config) */
25
+ private cachedOwnSessionId: string | null | undefined = undefined;
22
26
 
23
27
  detect(): boolean {
24
28
  return process.env.TERM_PROGRAM === "iTerm.app" && !process.env.TMUX && !process.env.ZELLIJ;
@@ -146,6 +150,38 @@ end tell`;
146
150
 
147
151
  setTitle(title: string): void {
148
152
  const escapedTitle = title.replace(/"/g, '\\"');
153
+
154
+ // For teammate processes, find the specific session to avoid renaming
155
+ // unrelated iTerm2 tabs. The session ID is stored in the team config.
156
+ const sessionId = this.findOwnSessionId();
157
+ if (sessionId) {
158
+ const script = `tell application "iTerm2"
159
+ repeat with aWindow in windows
160
+ repeat with aTab in tabs of aWindow
161
+ repeat with aSession in sessions of aTab
162
+ if id of aSession is "${sessionId}" then
163
+ set name of aSession to "${escapedTitle}"
164
+ return "Found"
165
+ end if
166
+ end repeat
167
+ end repeat
168
+ end repeat
169
+ end tell`;
170
+ try {
171
+ this.runAppleScript(script);
172
+ } catch {
173
+ // Ignore errors
174
+ }
175
+ return;
176
+ }
177
+
178
+ // If we're a teammate but haven't found our session ID yet (race condition
179
+ // during startup), skip the rename to avoid overwriting an unrelated tab.
180
+ if (process.env.PI_AGENT_NAME) {
181
+ return;
182
+ }
183
+
184
+ // Fallback for non-teammate processes (e.g., standalone pi sessions).
149
185
  const script = `tell application "iTerm2" to tell current session of current window
150
186
  set name to "${escapedTitle}"
151
187
  end tell`;
@@ -156,6 +192,40 @@ end tell`;
156
192
  }
157
193
  }
158
194
 
195
+ /**
196
+ * Look up this process's iTerm2 session ID from the team config.
197
+ * Teammates have PI_AGENT_NAME and PI_TEAM_NAME env vars, and the
198
+ * team config stores the iTerm2 session ID in the tmuxPaneId field.
199
+ * Caches the result once found to avoid repeated file reads.
200
+ */
201
+ private findOwnSessionId(): string | null {
202
+ // Return cached value if we've already found it
203
+ if (this.cachedOwnSessionId != null) return this.cachedOwnSessionId;
204
+
205
+ const agentName = process.env.PI_AGENT_NAME;
206
+ const teamName = process.env.PI_TEAM_NAME;
207
+ if (!agentName || !teamName) return null;
208
+
209
+ try {
210
+ const configFile = paths.configPath(teamName);
211
+ const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
212
+ const member = config.members?.find((m: any) => m.name === agentName);
213
+ if (
214
+ member?.tmuxPaneId &&
215
+ member.tmuxPaneId.startsWith("iterm_") &&
216
+ !member.tmuxPaneId.startsWith("iterm_win_")
217
+ ) {
218
+ this.cachedOwnSessionId = member.tmuxPaneId.replace("iterm_", "");
219
+ return this.cachedOwnSessionId;
220
+ }
221
+ } catch {
222
+ // Config not yet available — will retry on next call
223
+ }
224
+
225
+ // Don't cache null — the config might not be written yet (timing)
226
+ return null;
227
+ }
228
+
159
229
  /**
160
230
  * iTerm2 supports spawning separate OS windows via AppleScript
161
231
  */