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.
- package/extensions/index.ts +79 -32
- package/package.json +1 -1
- package/src/adapters/iterm2-adapter.ts +70 -0
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
//
|
|
33
|
-
if (
|
|
34
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
-
|
|
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
|
-
}
|
|
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
|
@@ -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
|
*/
|