pi-teams 0.9.11 → 0.9.14
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 +77 -46
- package/package.json +1 -1
- package/src/adapters/cmux-adapter.test.ts +70 -35
- package/src/adapters/cmux-adapter.ts +70 -15
- package/src/adapters/iterm2-adapter.ts +70 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +5 -1
- package/src/utils/predefined-teams.test.ts +123 -1
- package/src/utils/predefined-teams.ts +22 -14
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 },
|
|
@@ -526,7 +554,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
526
554
|
prompt: Type.String(),
|
|
527
555
|
cwd: Type.String(),
|
|
528
556
|
model: Type.Optional(Type.String()),
|
|
529
|
-
thinking: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high"])),
|
|
557
|
+
thinking: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"])),
|
|
530
558
|
plan_mode_required: Type.Optional(Type.Boolean({ default: false })),
|
|
531
559
|
separate_window: Type.Optional(Type.Boolean({ default: false })),
|
|
532
560
|
}),
|
|
@@ -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
|
@@ -62,12 +62,16 @@ describe("CmuxAdapter", () => {
|
|
|
62
62
|
process.env.CMUX_SOCKET_PATH = "/tmp/cmux.sock";
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
it("should spawn
|
|
66
|
-
mockExecCommand
|
|
67
|
-
|
|
68
|
-
stderr: "",
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
it("should spawn via new-split + poll + respawn-pane and return the new surface", () => {
|
|
66
|
+
mockExecCommand
|
|
67
|
+
// 1. listSurfaceRefs (before snapshot) — realistic cmux output
|
|
68
|
+
.mockReturnValueOnce({ stdout: "* surface:1 Terminal [selected]\n surface:2 Terminal\n", stderr: "", status: 0 })
|
|
69
|
+
// 2. new-split right
|
|
70
|
+
.mockReturnValueOnce({ stdout: "OK", stderr: "", status: 0 })
|
|
71
|
+
// 3. listSurfaceRefs (poll — finds new surface:42)
|
|
72
|
+
.mockReturnValueOnce({ stdout: "* surface:1 Terminal [selected]\n surface:2 Terminal\n surface:42 Terminal\n", stderr: "", status: 0 })
|
|
73
|
+
// 4. respawn-pane
|
|
74
|
+
.mockReturnValueOnce({ stdout: "OK", stderr: "", status: 0 });
|
|
71
75
|
|
|
72
76
|
const result = adapter.spawn({
|
|
73
77
|
name: "test-agent",
|
|
@@ -76,19 +80,25 @@ describe("CmuxAdapter", () => {
|
|
|
76
80
|
env: { PI_AGENT_ID: "test-123" },
|
|
77
81
|
});
|
|
78
82
|
|
|
79
|
-
expect(result).toBe("surface
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
expect(result).toBe("surface:42");
|
|
84
|
+
|
|
85
|
+
// Verify new-split was called without --command
|
|
86
|
+
expect(mockExecCommand).toHaveBeenCalledWith("cmux", ["new-split", "right"]);
|
|
87
|
+
|
|
88
|
+
// Verify respawn-pane was called with the new surface and full command
|
|
89
|
+
expect(mockExecCommand).toHaveBeenCalledWith("cmux", [
|
|
90
|
+
"respawn-pane",
|
|
91
|
+
"--surface", "surface:42",
|
|
92
|
+
"--command", "env PI_AGENT_ID=test-123 pi --agent test",
|
|
93
|
+
]);
|
|
84
94
|
});
|
|
85
95
|
|
|
86
96
|
it("should spawn without env prefix when no PI_ vars", () => {
|
|
87
|
-
mockExecCommand
|
|
88
|
-
stdout: "
|
|
89
|
-
stderr: "",
|
|
90
|
-
status: 0
|
|
91
|
-
|
|
97
|
+
mockExecCommand
|
|
98
|
+
.mockReturnValueOnce({ stdout: " surface:1 Terminal\n", stderr: "", status: 0 })
|
|
99
|
+
.mockReturnValueOnce({ stdout: "OK", stderr: "", status: 0 })
|
|
100
|
+
.mockReturnValueOnce({ stdout: " surface:1 Terminal\n surface:99 Terminal\n", stderr: "", status: 0 })
|
|
101
|
+
.mockReturnValueOnce({ stdout: "OK", stderr: "", status: 0 });
|
|
92
102
|
|
|
93
103
|
const result = adapter.spawn({
|
|
94
104
|
name: "test-agent",
|
|
@@ -97,19 +107,20 @@ describe("CmuxAdapter", () => {
|
|
|
97
107
|
env: { OTHER: "ignored" },
|
|
98
108
|
});
|
|
99
109
|
|
|
100
|
-
expect(result).toBe("surface
|
|
101
|
-
expect(mockExecCommand).toHaveBeenCalledWith(
|
|
102
|
-
"
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
expect(result).toBe("surface:99");
|
|
111
|
+
expect(mockExecCommand).toHaveBeenCalledWith("cmux", [
|
|
112
|
+
"respawn-pane",
|
|
113
|
+
"--surface", "surface:99",
|
|
114
|
+
"--command", "pi",
|
|
115
|
+
]);
|
|
105
116
|
});
|
|
106
117
|
|
|
107
|
-
it("should throw on
|
|
108
|
-
mockExecCommand
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
it("should throw on new-split failure", () => {
|
|
119
|
+
mockExecCommand
|
|
120
|
+
// listSurfaceRefs (before)
|
|
121
|
+
.mockReturnValueOnce({ stdout: " surface:1 Terminal\n", stderr: "", status: 0 })
|
|
122
|
+
// new-split fails
|
|
123
|
+
.mockReturnValueOnce({ stdout: "", stderr: "cmux not found", status: 1 });
|
|
113
124
|
|
|
114
125
|
expect(() => adapter.spawn({
|
|
115
126
|
name: "test-agent",
|
|
@@ -119,19 +130,43 @@ describe("CmuxAdapter", () => {
|
|
|
119
130
|
})).toThrow("cmux new-split failed with status 1");
|
|
120
131
|
});
|
|
121
132
|
|
|
122
|
-
it("should throw
|
|
123
|
-
mockExecCommand
|
|
124
|
-
|
|
125
|
-
stderr: "",
|
|
126
|
-
|
|
127
|
-
|
|
133
|
+
it("should throw when new surface is not found after polling", () => {
|
|
134
|
+
mockExecCommand
|
|
135
|
+
// listSurfaceRefs (before)
|
|
136
|
+
.mockReturnValueOnce({ stdout: " surface:1 Terminal\n", stderr: "", status: 0 })
|
|
137
|
+
// new-split OK
|
|
138
|
+
.mockReturnValueOnce({ stdout: "OK", stderr: "", status: 0 });
|
|
139
|
+
|
|
140
|
+
// All subsequent polls return the same surfaces (no new one appears)
|
|
141
|
+
// Each poll cycle = listSurfaceRefs + sleep
|
|
142
|
+
for (let i = 0; i < 20; i++) {
|
|
143
|
+
mockExecCommand
|
|
144
|
+
.mockReturnValueOnce({ stdout: " surface:1 Terminal\n", stderr: "", status: 0 }) // listSurfaceRefs
|
|
145
|
+
.mockReturnValueOnce({ stdout: "", stderr: "", status: 0 }); // sleep
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
expect(() => adapter.spawn({
|
|
149
|
+
name: "test-agent",
|
|
150
|
+
cwd: "/home/user/project",
|
|
151
|
+
command: "pi",
|
|
152
|
+
env: {},
|
|
153
|
+
})).toThrow("new surface was not found");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should throw when respawn-pane fails", () => {
|
|
157
|
+
mockExecCommand
|
|
158
|
+
.mockReturnValueOnce({ stdout: " surface:1 Terminal\n", stderr: "", status: 0 })
|
|
159
|
+
.mockReturnValueOnce({ stdout: "OK", stderr: "", status: 0 })
|
|
160
|
+
.mockReturnValueOnce({ stdout: " surface:1 Terminal\n surface:50 Terminal\n", stderr: "", status: 0 })
|
|
161
|
+
// respawn-pane fails
|
|
162
|
+
.mockReturnValueOnce({ stdout: "", stderr: "respawn error", status: 1 });
|
|
128
163
|
|
|
129
164
|
expect(() => adapter.spawn({
|
|
130
165
|
name: "test-agent",
|
|
131
166
|
cwd: "/home/user/project",
|
|
132
167
|
command: "pi",
|
|
133
168
|
env: {},
|
|
134
|
-
})).toThrow("cmux
|
|
169
|
+
})).toThrow("cmux respawn-pane failed with status 1");
|
|
135
170
|
});
|
|
136
171
|
});
|
|
137
172
|
|
|
@@ -306,4 +341,4 @@ describe("CmuxAdapter", () => {
|
|
|
306
341
|
expect(adapter.isWindowAlive(undefined as unknown as string)).toBe(false);
|
|
307
342
|
});
|
|
308
343
|
});
|
|
309
|
-
});
|
|
344
|
+
});
|
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
* CMUX Terminal Adapter
|
|
3
3
|
*
|
|
4
4
|
* Implements the TerminalAdapter interface for CMUX (cmux.dev).
|
|
5
|
+
*
|
|
6
|
+
* Spawn strategy: cmux's `new-split` does not support a `--command` flag.
|
|
7
|
+
* We follow the proven pattern from pi-cmux (npm:pi-cmux):
|
|
8
|
+
* 1. Snapshot existing surfaces
|
|
9
|
+
* 2. `cmux new-split <direction>`
|
|
10
|
+
* 3. Poll `cmux list-pane-surfaces` to find the newly created surface
|
|
11
|
+
* 4. `cmux respawn-pane --surface <id> --command <cmd>` to run the command
|
|
5
12
|
*/
|
|
6
13
|
|
|
7
14
|
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
|
|
8
15
|
|
|
16
|
+
const SURFACE_POLL_ATTEMPTS = 20;
|
|
17
|
+
const SURFACE_POLL_DELAY_MS = 150;
|
|
18
|
+
|
|
9
19
|
export class CmuxAdapter implements TerminalAdapter {
|
|
10
20
|
readonly name = "cmux";
|
|
11
21
|
|
|
@@ -18,34 +28,79 @@ export class CmuxAdapter implements TerminalAdapter {
|
|
|
18
28
|
return !!process.env.CMUX_SOCKET_PATH || !!process.env.CMUX_WORKSPACE_ID;
|
|
19
29
|
}
|
|
20
30
|
|
|
31
|
+
/**
|
|
32
|
+
* List all surface refs currently visible in the workspace.
|
|
33
|
+
*/
|
|
34
|
+
private listSurfaceRefs(): Set<string> {
|
|
35
|
+
const refs = new Set<string>();
|
|
36
|
+
try {
|
|
37
|
+
const result = execCommand("cmux", ["list-pane-surfaces"]);
|
|
38
|
+
if (result.status === 0) {
|
|
39
|
+
for (const line of result.stdout.split("\n")) {
|
|
40
|
+
// Output lines look like: "* surface:5 ⠹ π · ziahmco [selected]"
|
|
41
|
+
// Extract the surface:N ref from each line.
|
|
42
|
+
const match = line.match(/\b(surface:\d+)\b/);
|
|
43
|
+
if (match) refs.add(match[1]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Ignore
|
|
48
|
+
}
|
|
49
|
+
return refs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Block until a new surface appears that was not in `before`, or give up.
|
|
54
|
+
*/
|
|
55
|
+
private waitForNewSurface(before: Set<string>): string | null {
|
|
56
|
+
for (let i = 0; i < SURFACE_POLL_ATTEMPTS; i++) {
|
|
57
|
+
const current = this.listSurfaceRefs();
|
|
58
|
+
for (const ref of current) {
|
|
59
|
+
if (!before.has(ref)) return ref;
|
|
60
|
+
}
|
|
61
|
+
// spawnSync-based sleep — keeps the adapter synchronous
|
|
62
|
+
execCommand("sleep", [String(SURFACE_POLL_DELAY_MS / 1000)]);
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
21
67
|
spawn(options: SpawnOptions): string {
|
|
22
|
-
//
|
|
23
|
-
// CMUX doesn't have a direct 'spawn' that returns a pane ID and runs a command
|
|
24
|
-
// in one go while also returning the ID in a way we can easily capture for 'isAlive'.
|
|
25
|
-
// However, 'new-split' returns the new surface ID.
|
|
26
|
-
|
|
27
|
-
// Construct the command with environment variables
|
|
68
|
+
// Construct the full command with PI_ environment variables
|
|
28
69
|
const envPrefix = Object.entries(options.env)
|
|
29
70
|
.filter(([k]) => k.startsWith("PI_"))
|
|
30
71
|
.map(([k, v]) => `${k}=${v}`)
|
|
31
72
|
.join(" ");
|
|
32
|
-
|
|
73
|
+
|
|
33
74
|
const fullCommand = envPrefix ? `env ${envPrefix} ${options.command}` : options.command;
|
|
34
75
|
|
|
35
|
-
//
|
|
36
|
-
const
|
|
37
|
-
|
|
76
|
+
// 1. Snapshot existing surfaces before the split
|
|
77
|
+
const before = this.listSurfaceRefs();
|
|
78
|
+
|
|
79
|
+
// 2. Create the split (without --command, which is not supported)
|
|
80
|
+
const splitResult = execCommand("cmux", ["new-split", "right"]);
|
|
81
|
+
|
|
38
82
|
if (splitResult.status !== 0) {
|
|
39
83
|
throw new Error(`cmux new-split failed with status ${splitResult.status}: ${splitResult.stderr}`);
|
|
40
84
|
}
|
|
41
85
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
86
|
+
// 3. Poll for the newly created surface
|
|
87
|
+
const newSurface = this.waitForNewSurface(before);
|
|
88
|
+
if (!newSurface) {
|
|
89
|
+
throw new Error("cmux new-split succeeded but new surface was not found");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 4. Use respawn-pane to run the command in the new surface
|
|
93
|
+
const respawnResult = execCommand("cmux", [
|
|
94
|
+
"respawn-pane",
|
|
95
|
+
"--surface", newSurface,
|
|
96
|
+
"--command", fullCommand,
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
if (respawnResult.status !== 0) {
|
|
100
|
+
throw new Error(`cmux respawn-pane failed with status ${respawnResult.status}: ${respawnResult.stderr}`);
|
|
46
101
|
}
|
|
47
102
|
|
|
48
|
-
|
|
103
|
+
return newSurface;
|
|
49
104
|
}
|
|
50
105
|
|
|
51
106
|
kill(paneId: string): void {
|
|
@@ -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
|
*/
|
package/src/utils/models.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
2
|
+
|
|
3
|
+
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
4
|
+
|
|
1
5
|
export interface Member {
|
|
2
6
|
agentId: string;
|
|
3
7
|
name: string;
|
|
@@ -10,7 +14,7 @@ export interface Member {
|
|
|
10
14
|
subscriptions: any[];
|
|
11
15
|
prompt?: string;
|
|
12
16
|
color?: string;
|
|
13
|
-
thinking?:
|
|
17
|
+
thinking?: ThinkingLevel;
|
|
14
18
|
planModeRequired?: boolean;
|
|
15
19
|
backendType?: string;
|
|
16
20
|
isActive?: boolean;
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getAllPredefinedTeams,
|
|
12
12
|
getAgentDefinition,
|
|
13
13
|
getPredefinedTeam,
|
|
14
|
+
saveTeamTemplate,
|
|
14
15
|
} from "./predefined-teams";
|
|
15
16
|
|
|
16
17
|
describe("parseAgentFrontmatter", () => {
|
|
@@ -256,7 +257,8 @@ full:
|
|
|
256
257
|
|
|
257
258
|
describe("getAllAgentDefinitions and getAllPredefinedTeams", () => {
|
|
258
259
|
const globalDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
|
259
|
-
const globalTeamsDir = path.join(os.homedir(), ".pi"
|
|
260
|
+
const globalTeamsDir = path.join(os.homedir(), ".pi");
|
|
261
|
+
const legacyGlobalTeamsDir = path.join(os.homedir(), ".pi", "agent");
|
|
260
262
|
const projectDir = path.join(os.tmpdir(), "pi-teams-test-project-" + Date.now());
|
|
261
263
|
const projectAgentsDir = path.join(projectDir, ".pi", "agents");
|
|
262
264
|
const projectTeamsDir = path.join(projectDir, ".pi");
|
|
@@ -264,6 +266,7 @@ describe("getAllAgentDefinitions and getAllPredefinedTeams", () => {
|
|
|
264
266
|
// Store original files to restore later
|
|
265
267
|
let originalGlobalAgents: string[] = [];
|
|
266
268
|
let originalGlobalTeams: string | null = null;
|
|
269
|
+
let originalLegacyGlobalTeams: string | null = null;
|
|
267
270
|
|
|
268
271
|
beforeEach(() => {
|
|
269
272
|
// Create project directory
|
|
@@ -279,12 +282,31 @@ describe("getAllAgentDefinitions and getAllPredefinedTeams", () => {
|
|
|
279
282
|
if (fs.existsSync(path.join(globalTeamsDir, "teams.yaml"))) {
|
|
280
283
|
originalGlobalTeams = fs.readFileSync(path.join(globalTeamsDir, "teams.yaml"), "utf-8");
|
|
281
284
|
}
|
|
285
|
+
if (fs.existsSync(path.join(legacyGlobalTeamsDir, "teams.yaml"))) {
|
|
286
|
+
originalLegacyGlobalTeams = fs.readFileSync(path.join(legacyGlobalTeamsDir, "teams.yaml"), "utf-8");
|
|
287
|
+
}
|
|
282
288
|
});
|
|
283
289
|
|
|
284
290
|
afterEach(() => {
|
|
285
291
|
if (fs.existsSync(projectDir)) {
|
|
286
292
|
fs.rmSync(projectDir, { recursive: true });
|
|
287
293
|
}
|
|
294
|
+
|
|
295
|
+
const globalTeamsPath = path.join(globalTeamsDir, "teams.yaml");
|
|
296
|
+
if (originalGlobalTeams === null) {
|
|
297
|
+
if (fs.existsSync(globalTeamsPath)) fs.rmSync(globalTeamsPath);
|
|
298
|
+
} else {
|
|
299
|
+
fs.mkdirSync(globalTeamsDir, { recursive: true });
|
|
300
|
+
fs.writeFileSync(globalTeamsPath, originalGlobalTeams);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const legacyGlobalTeamsPath = path.join(legacyGlobalTeamsDir, "teams.yaml");
|
|
304
|
+
if (originalLegacyGlobalTeams === null) {
|
|
305
|
+
if (fs.existsSync(legacyGlobalTeamsPath)) fs.rmSync(legacyGlobalTeamsPath);
|
|
306
|
+
} else {
|
|
307
|
+
fs.mkdirSync(legacyGlobalTeamsDir, { recursive: true });
|
|
308
|
+
fs.writeFileSync(legacyGlobalTeamsPath, originalLegacyGlobalTeams);
|
|
309
|
+
}
|
|
288
310
|
});
|
|
289
311
|
|
|
290
312
|
it("combines global and project-local agents", () => {
|
|
@@ -330,6 +352,37 @@ custom:
|
|
|
330
352
|
expect(result.find(t => t.name === "custom")).toBeDefined();
|
|
331
353
|
expect(result.find(t => t.name === "custom")?.agents).toEqual(["agent1", "agent2"]);
|
|
332
354
|
});
|
|
355
|
+
|
|
356
|
+
it("reads global predefined teams from ~/.pi/teams.yaml", () => {
|
|
357
|
+
fs.mkdirSync(globalTeamsDir, { recursive: true });
|
|
358
|
+
fs.writeFileSync(path.join(globalTeamsDir, "teams.yaml"), `
|
|
359
|
+
root-global:
|
|
360
|
+
- scout
|
|
361
|
+
`);
|
|
362
|
+
|
|
363
|
+
const result = getAllPredefinedTeams();
|
|
364
|
+
|
|
365
|
+
expect(result.find(t => t.name === "root-global")).toBeDefined();
|
|
366
|
+
expect(result.find(t => t.name === "root-global")?.agents).toEqual(["scout"]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("falls back to legacy ~/.pi/agent/teams.yaml when needed", () => {
|
|
370
|
+
const globalTeamsPath = path.join(globalTeamsDir, "teams.yaml");
|
|
371
|
+
if (fs.existsSync(globalTeamsPath)) {
|
|
372
|
+
fs.rmSync(globalTeamsPath);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
fs.mkdirSync(legacyGlobalTeamsDir, { recursive: true });
|
|
376
|
+
fs.writeFileSync(path.join(legacyGlobalTeamsDir, "teams.yaml"), `
|
|
377
|
+
legacy-global:
|
|
378
|
+
- scout
|
|
379
|
+
`);
|
|
380
|
+
|
|
381
|
+
const result = getAllPredefinedTeams();
|
|
382
|
+
|
|
383
|
+
expect(result.find(t => t.name === "legacy-global")).toBeDefined();
|
|
384
|
+
expect(result.find(t => t.name === "legacy-global")?.agents).toEqual(["scout"]);
|
|
385
|
+
});
|
|
333
386
|
});
|
|
334
387
|
|
|
335
388
|
describe("getAgentDefinition and getPredefinedTeam", () => {
|
|
@@ -386,4 +439,73 @@ test-team:
|
|
|
386
439
|
const result = getPredefinedTeam("non-existent", projectDir);
|
|
387
440
|
expect(result).toBeUndefined();
|
|
388
441
|
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe("saveTeamTemplate", () => {
|
|
445
|
+
const rootPiDir = path.join(os.homedir(), ".pi");
|
|
446
|
+
const globalAgentsDir = path.join(rootPiDir, "agent", "agents");
|
|
447
|
+
const globalTeamsPath = path.join(rootPiDir, "teams.yaml");
|
|
448
|
+
const projectDir = path.join(os.tmpdir(), "pi-teams-test-save-" + Date.now());
|
|
449
|
+
|
|
450
|
+
let originalGlobalTeams: string | null = null;
|
|
451
|
+
let originalAgentFiles = new Set<string>();
|
|
452
|
+
|
|
453
|
+
beforeEach(() => {
|
|
454
|
+
if (fs.existsSync(projectDir)) {
|
|
455
|
+
fs.rmSync(projectDir, { recursive: true });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (fs.existsSync(globalTeamsPath)) {
|
|
459
|
+
originalGlobalTeams = fs.readFileSync(globalTeamsPath, "utf-8");
|
|
460
|
+
}
|
|
461
|
+
if (fs.existsSync(globalAgentsDir)) {
|
|
462
|
+
originalAgentFiles = new Set(fs.readdirSync(globalAgentsDir));
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
afterEach(() => {
|
|
467
|
+
if (fs.existsSync(projectDir)) {
|
|
468
|
+
fs.rmSync(projectDir, { recursive: true });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (originalGlobalTeams === null) {
|
|
472
|
+
if (fs.existsSync(globalTeamsPath)) fs.rmSync(globalTeamsPath);
|
|
473
|
+
} else {
|
|
474
|
+
fs.mkdirSync(path.dirname(globalTeamsPath), { recursive: true });
|
|
475
|
+
fs.writeFileSync(globalTeamsPath, originalGlobalTeams);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (fs.existsSync(globalAgentsDir)) {
|
|
479
|
+
for (const file of fs.readdirSync(globalAgentsDir)) {
|
|
480
|
+
if (!originalAgentFiles.has(file)) {
|
|
481
|
+
fs.rmSync(path.join(globalAgentsDir, file));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("writes user-scoped teams to ~/.pi/teams.yaml and agents to ~/.pi/agent/agents", () => {
|
|
488
|
+
const result = saveTeamTemplate(
|
|
489
|
+
{
|
|
490
|
+
name: "audit-team",
|
|
491
|
+
members: [
|
|
492
|
+
{
|
|
493
|
+
name: "security-worker",
|
|
494
|
+
agentType: "teammate",
|
|
495
|
+
prompt: "Audit security issues",
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
templateName: "audit-team",
|
|
501
|
+
scope: "user",
|
|
502
|
+
}
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
expect(result.teamsYamlPath).toBe(globalTeamsPath);
|
|
506
|
+
expect(result.agentsDir).toBe(globalAgentsDir);
|
|
507
|
+
expect(fs.existsSync(globalTeamsPath)).toBe(true);
|
|
508
|
+
expect(fs.readFileSync(globalTeamsPath, "utf-8")).toContain("audit-team:");
|
|
509
|
+
expect(fs.existsSync(path.join(globalAgentsDir, "security-worker.md"))).toBe(true);
|
|
510
|
+
});
|
|
389
511
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import { ThinkingLevel } from "./models";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Represents an agent definition from a .md file
|
|
@@ -10,7 +11,7 @@ export interface AgentDefinition {
|
|
|
10
11
|
description: string;
|
|
11
12
|
tools?: string[];
|
|
12
13
|
model?: string;
|
|
13
|
-
thinking?:
|
|
14
|
+
thinking?: ThinkingLevel;
|
|
14
15
|
prompt: string;
|
|
15
16
|
filePath: string;
|
|
16
17
|
}
|
|
@@ -215,12 +216,18 @@ export function getAllPredefinedTeams(projectDir?: string): PredefinedTeam[] {
|
|
|
215
216
|
const teams: PredefinedTeam[] = [];
|
|
216
217
|
const seenNames = new Set<string>();
|
|
217
218
|
|
|
218
|
-
// Global teams
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
219
|
+
// Global teams: prefer the documented ~/.pi/teams.yaml location,
|
|
220
|
+
// but still fall back to the legacy ~/.pi/agent/teams.yaml path.
|
|
221
|
+
const globalDirs = [
|
|
222
|
+
path.join(os.homedir(), ".pi"),
|
|
223
|
+
path.join(os.homedir(), ".pi", "agent"),
|
|
224
|
+
];
|
|
225
|
+
for (const globalDir of globalDirs) {
|
|
226
|
+
for (const team of discoverTeams(globalDir)) {
|
|
227
|
+
if (!seenNames.has(team.name)) {
|
|
228
|
+
seenNames.add(team.name);
|
|
229
|
+
teams.push(team);
|
|
230
|
+
}
|
|
224
231
|
}
|
|
225
232
|
}
|
|
226
233
|
|
|
@@ -293,7 +300,7 @@ export function generateAgentMarkdown(agent: {
|
|
|
293
300
|
description?: string;
|
|
294
301
|
tools?: string[];
|
|
295
302
|
model?: string;
|
|
296
|
-
thinking?:
|
|
303
|
+
thinking?: ThinkingLevel;
|
|
297
304
|
prompt?: string;
|
|
298
305
|
}): string {
|
|
299
306
|
const lines: string[] = ["---"];
|
|
@@ -387,7 +394,7 @@ export function saveTeamTemplate(
|
|
|
387
394
|
name: string;
|
|
388
395
|
agentType: string;
|
|
389
396
|
model?: string;
|
|
390
|
-
thinking?:
|
|
397
|
+
thinking?: ThinkingLevel;
|
|
391
398
|
prompt?: string;
|
|
392
399
|
}>;
|
|
393
400
|
defaultModel?: string;
|
|
@@ -395,12 +402,13 @@ export function saveTeamTemplate(
|
|
|
395
402
|
options: SaveTeamTemplateOptions
|
|
396
403
|
): SaveTeamTemplateResult {
|
|
397
404
|
// Determine output paths based on scope
|
|
398
|
-
const
|
|
399
|
-
? path.join(options.projectDir || process.cwd(), ".pi")
|
|
400
|
-
: path.join(os.homedir(), ".pi", "agent");
|
|
405
|
+
const agentsDir = options.scope === "project"
|
|
406
|
+
? path.join(options.projectDir || process.cwd(), ".pi", "agents")
|
|
407
|
+
: path.join(os.homedir(), ".pi", "agent", "agents");
|
|
401
408
|
|
|
402
|
-
const
|
|
403
|
-
|
|
409
|
+
const teamsYamlPath = options.scope === "project"
|
|
410
|
+
? path.join(options.projectDir || process.cwd(), ".pi", "teams.yaml")
|
|
411
|
+
: path.join(os.homedir(), ".pi", "teams.yaml");
|
|
404
412
|
|
|
405
413
|
// Ensure agents directory exists
|
|
406
414
|
if (!fs.existsSync(agentsDir)) {
|