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.
- package/extensions/index.ts +76 -45
- package/package.json +1 -1
- package/src/adapters/iterm2-adapter.ts +70 -0
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
//
|
|
39
|
-
if (argv1
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
37
|
+
if (looksLikeScript) {
|
|
38
|
+
return `node ${JSON.stringify(argv1)}`;
|
|
39
|
+
}
|
|
46
40
|
}
|
|
47
41
|
|
|
48
|
-
//
|
|
49
|
-
if (
|
|
50
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
|
|
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
|
-
}
|
|
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
|
@@ -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
|
*/
|