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.
@@ -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 },
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.9.11",
3
+ "version": "0.9.14",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": {
6
6
  "type": "git",
@@ -62,12 +62,16 @@ describe("CmuxAdapter", () => {
62
62
  process.env.CMUX_SOCKET_PATH = "/tmp/cmux.sock";
63
63
  });
64
64
 
65
- it("should spawn a new pane and return the surface ID", () => {
66
- mockExecCommand.mockReturnValue({
67
- stdout: "OK surface-42",
68
- stderr: "",
69
- status: 0
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-42");
80
- expect(mockExecCommand).toHaveBeenCalledWith(
81
- "cmux",
82
- ["new-split", "right", "--command", "env PI_AGENT_ID=test-123 pi --agent test"]
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.mockReturnValue({
88
- stdout: "OK surface-99",
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-99");
101
- expect(mockExecCommand).toHaveBeenCalledWith(
102
- "cmux",
103
- ["new-split", "right", "--command", "pi"]
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 spawn failure", () => {
108
- mockExecCommand.mockReturnValue({
109
- stdout: "",
110
- stderr: "cmux not found",
111
- status: 1
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 on unexpected output format", () => {
123
- mockExecCommand.mockReturnValue({
124
- stdout: "ERROR something went wrong",
125
- stderr: "",
126
- status: 0
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 new-split returned unexpected output");
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
- // We use new-split to create a new pane in CMUX.
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
- // CMUX new-split returns "OK <UUID>"
36
- const splitResult = execCommand("cmux", ["new-split", "right", "--command", fullCommand]);
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
- const output = splitResult.stdout.trim();
43
- if (output.startsWith("OK ")) {
44
- const surfaceId = output.substring(3).trim();
45
- return surfaceId;
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
- throw new Error(`cmux new-split returned unexpected output: ${output}`);
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
  */
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { THINKING_LEVELS } from "./models";
3
+
4
+ describe("thinking levels", () => {
5
+ it("includes xhigh for teammate configuration", () => {
6
+ expect(THINKING_LEVELS).toContain("xhigh");
7
+ });
8
+ });
@@ -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?: "off" | "minimal" | "low" | "medium" | "high";
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", "agent");
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?: "off" | "minimal" | "low" | "medium" | "high";
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
- const globalDir = path.join(os.homedir(), ".pi", "agent");
220
- for (const team of discoverTeams(globalDir)) {
221
- if (!seenNames.has(team.name)) {
222
- seenNames.add(team.name);
223
- teams.push(team);
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?: "off" | "minimal" | "low" | "medium" | "high";
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?: "off" | "minimal" | "low" | "medium" | "high";
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 baseDir = options.scope === "project"
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 agentsDir = path.join(baseDir, "agents");
403
- const teamsYamlPath = path.join(baseDir, "teams.yaml");
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)) {