pi-teams 0.9.11 → 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,39 +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
- * Handles multiple installation scenarios:
22
- *
23
- * 1. Regular Node installs: argv[1] is the real pi launcher script on disk
24
- * - execPath = /path/to/node (the interpreter)
25
- * - argv[1] = /path/to/pi (the script)
26
- * - We need to run the pi script directly (it has a shebang)
27
- *
28
- * 2. Bun-compiled binaries: argv[1] points to a virtual $bunfs path that
29
- * doesn't exist on disk. execPath is the actual executable.
30
- * - execPath = /path/to/pi-binary (the compiled executable)
31
- * - argv[1] = /$bunfs/root/pi (virtual path, doesn't exist)
32
- *
33
- * 3. Fallback: If neither works, just use "pi" and hope it's on PATH.
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`
34
26
  */
35
27
  function getPiLaunchCommand(): string {
36
28
  const argv1 = process.argv[1];
29
+ const execPath = process.execPath;
37
30
 
38
- // First, check if argv[1] is a real "pi" file on disk (regular Node install)
39
- if (argv1 && path.basename(argv1) === "pi" && fs.existsSync(argv1)) {
40
- return JSON.stringify(argv1);
41
- }
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);
42
36
 
43
- // For Bun-compiled binaries, execPath is the actual executable
44
- if (process.execPath) {
45
- return JSON.stringify(process.execPath);
37
+ if (looksLikeScript) {
38
+ return `node ${JSON.stringify(argv1)}`;
39
+ }
46
40
  }
47
41
 
48
- // Fallback: try node with argv[1]
49
- if (argv1) {
50
- return `node ${JSON.stringify(argv1)}`;
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
+ }
51
48
  }
52
49
 
53
- // Last resort: just "pi"
50
+ // Shim-based installs (like Volta) are safest to relaunch through PATH.
54
51
  return "pi";
55
52
  }
56
53
 
@@ -349,12 +346,42 @@ export default function (pi: ExtensionAPI) {
349
346
 
350
347
  // For leads without PI_TEAM_NAME, check if we're registered as lead for a team
351
348
  const detectedTeamName = envTeamName || findLeadTeamForSession();
352
- const teamName = detectedTeamName;
349
+ let teamName = detectedTeamName;
353
350
 
354
351
  const terminal = getTerminalAdapter();
355
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
+
356
381
  pi.on("session_start", async (_event, ctx) => {
357
382
  paths.ensureDirs();
383
+ sessionCtx = ctx;
384
+
358
385
  if (isTeammate) {
359
386
  if (teamName) {
360
387
  const pidFile = path.join(paths.teamDir(teamName), `${agentName}.pid`);
@@ -385,34 +412,32 @@ export default function (pi: ExtensionAPI) {
385
412
  setTimeout(() => {
386
413
  pi.sendUserMessage(`I am starting my work as '${agentName}' on team '${teamName}'. Checking my inbox for instructions...`);
387
414
  }, 1000);
388
- } else if (teamName) {
389
- ctx.ui.setStatus("pi-teams", `Lead @ ${teamName}`);
390
- }
391
415
 
392
- // Inbox polling for BOTH teammates AND team-leads (anyone with teamName)
393
- if (teamName) {
394
- setInterval(async () => {
395
- if (ctx.isIdle()) {
396
- try {
397
- const unread = await messaging.readInbox(teamName, agentName, true, false);
398
- if (isTeammate) {
399
- 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, {
400
423
  lastHeartbeatAt: Date.now(),
401
424
  });
402
- }
403
- if (unread.length > 0) {
404
- pi.sendUserMessage(`I have ${unread.length} new message(s) in my inbox. Reading them now...`);
405
- }
406
- } catch (e) {
407
- if (isTeammate) {
408
- 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, {
409
430
  lastHeartbeatAt: Date.now(),
410
431
  lastError: runtime.createRuntimeError(e),
411
432
  });
412
433
  }
413
434
  }
414
- }
415
- }, 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();
416
441
  }
417
442
  });
418
443
 
@@ -509,6 +534,9 @@ export default function (pi: ExtensionAPI) {
509
534
  const config = teams.createTeam(params.team_name, "local-session", "lead-agent", params.description, params.default_model, params.separate_windows);
510
535
  // Register this session as the lead so it can receive inbox messages
511
536
  registerLeadSession(params.team_name);
537
+ // Update teamName and start inbox polling for the lead
538
+ teamName = params.team_name;
539
+ startLeadInboxPolling();
512
540
  return {
513
541
  content: [{ type: "text", text: `Team ${params.team_name} created.` }],
514
542
  details: { config },
@@ -1093,6 +1121,9 @@ export default function (pi: ExtensionAPI) {
1093
1121
  // Create the team
1094
1122
  const config = teams.createTeam(params.team_name, "local-session", "lead-agent", `Predefined team: ${params.predefined_team}`, params.default_model, params.separate_windows);
1095
1123
  registerLeadSession(params.team_name);
1124
+ // Update teamName and start inbox polling for the lead
1125
+ teamName = params.team_name;
1126
+ startLeadInboxPolling();
1096
1127
 
1097
1128
  const agentDefinitions = predefined.getAllAgentDefinitions(projectDir);
1098
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.11",
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
  */