pi-teams 0.8.2 → 0.8.6

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/README.md CHANGED
@@ -45,6 +45,8 @@ pi install npm:pi-teams
45
45
  - **Beautiful UI**: Optimized vertical splits in `tmux` with clear labels so you always know who is doing what.
46
46
 
47
47
  ### Advanced Features
48
+ - **Isolated OS Windows**: Launch teammates in true separate OS windows instead of panes.
49
+ - **Persistent Window Titles**: Windows are automatically titled `[team-name]: [agent-name]` for easy identification in your window manager.
48
50
  - **Plan Approval Mode**: Require teammates to submit their implementation plans for your approval before they touch any code.
49
51
  - **Broadcast Messaging**: Send a message to the entire team at once for global coordination and announcements.
50
52
  - **Quality Gate Hooks**: Automated shell scripts run when tasks are completed (e.g., to run tests or linting).
@@ -58,9 +60,20 @@ pi install npm:pi-teams
58
60
  **Set a default model for the whole team:**
59
61
  > **You:** "Create a team named 'Research' and use 'gpt-4o' for everyone."
60
62
 
63
+ **Start a team in "Separate Windows" mode:**
64
+ > **You:** "Create a team named 'Dev' and open everyone in separate windows."
65
+ *(Supported in iTerm2 and WezTerm only)*
66
+
61
67
  ### 2. Spawn Teammate with Custom Settings
62
68
  > **You:** "Spawn a teammate named 'security-bot' in the current folder. Tell them to scan for hardcoded API keys."
63
69
 
70
+ **Spawn a specific teammate in a separate window:**
71
+ > **You:** "Spawn 'researcher' in a separate window."
72
+
73
+ **Move the Team Lead to a separate window:**
74
+ > **You:** "Open the team lead in its own window."
75
+ *(Requires separate_windows mode enabled or iTerm2/WezTerm)*
76
+
64
77
  **Use a different model:**
65
78
  > **You:** "Spawn a teammate named 'speed-bot' using 'haiku' to quickly run some benchmarks."
66
79
 
@@ -70,6 +83,17 @@ pi install npm:pi-teams
70
83
  **Customize model and thinking level:**
71
84
  > **You:** "Spawn a teammate named 'architect-bot' using 'gpt-4o' with 'high' thinking level for deep reasoning."
72
85
 
86
+ **Smart Model Resolution:**
87
+ When you specify a model name without a provider (e.g., `gemini-2.5-flash`), pi-teams automatically:
88
+ - Queries available models from `pi --list-models`
89
+ - Prioritizes **OAuth/subscription providers** (cheaper/free) over API-key providers:
90
+ - `google-gemini-cli` (OAuth) is preferred over `google` (API key)
91
+ - `github-copilot`, `kimi-sub` are preferred over their API-key equivalents
92
+ - Falls back to API-key providers if OAuth providers aren't available
93
+ - Constructs the correct `--model provider/model:thinking` command
94
+
95
+ > **Example:** Specifying `gemini-2.5-flash` will automatically use `google-gemini-cli/gemini-2.5-flash` if available, saving API costs.
96
+
73
97
  ### 3. Assign Task & Get Approval
74
98
  > **You:** "Create a task for security-bot: 'Check the .env.example file for sensitive defaults' and set it to in_progress."
75
99
 
@@ -111,11 +135,15 @@ Simply start `pi` inside a Zellij session. **pi-teams** will detect it via the `
111
135
 
112
136
  ### Option 3: iTerm2 (macOS)
113
137
 
114
- If you are using **iTerm2** on macOS and are *not* inside tmux or Zellij, **pi-teams** will use AppleScript to automatically split your current window into an optimized layout (1 large Lead pane on the left, Teammates stacked on the right). It will also name the panes with the teammate's agent name for easy identification.
138
+ If you are using **iTerm2** on macOS and are *not* inside tmux or Zellij, **pi-teams** can manage your team in two ways:
139
+ 1. **Panes (Default)**: Automatically split your current window into an optimized layout.
140
+ 2. **Windows**: Create true separate OS windows for each agent.
141
+
142
+ It will name the panes or windows with the teammate's agent name for easy identification.
115
143
 
116
144
  ### Option 4: WezTerm (macOS, Linux, Windows)
117
145
 
118
- **WezTerm** is a GPU-accelerated, cross-platform terminal emulator written in Rust. If you are using WezTerm and are *not* inside tmux or Zellij, **pi-teams** will use `wezterm cli split-pane` to spawn teammates in new panes with an optimized layout (1 large Lead pane on the left, Teammates stacked on the right).
146
+ **WezTerm** is a GPU-accelerated, cross-platform terminal emulator written in Rust. Like iTerm2, it supports both **Panes** and **Separate OS Windows**.
119
147
 
120
148
  Install WezTerm:
121
149
  - **macOS**: `brew install --cask wezterm`
@@ -8,15 +8,144 @@ import * as messaging from "../src/utils/messaging";
8
8
  import { Member } from "../src/utils/models";
9
9
  import { getTerminalAdapter } from "../src/adapters/terminal-registry";
10
10
  import { Iterm2Adapter } from "../src/adapters/iterm2-adapter";
11
- import path from "node:path";
12
- import fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import * as fs from "node:fs";
13
+ import { spawnSync } from "node:child_process";
14
+
15
+ // Cache for available models
16
+ let availableModelsCache: Array<{ provider: string; model: string }> | null = null;
17
+ let modelsCacheTime = 0;
18
+ const MODELS_CACHE_TTL = 60000; // 1 minute
19
+
20
+ /**
21
+ * Query available models from pi --list-models
22
+ */
23
+ function getAvailableModels(): Array<{ provider: string; model: string }> {
24
+ const now = Date.now();
25
+ if (availableModelsCache && now - modelsCacheTime < MODELS_CACHE_TTL) {
26
+ return availableModelsCache;
27
+ }
28
+
29
+ try {
30
+ const result = spawnSync("pi", ["--list-models"], {
31
+ encoding: "utf-8",
32
+ timeout: 10000,
33
+ });
34
+
35
+ if (result.status !== 0 || !result.stdout) {
36
+ return [];
37
+ }
38
+
39
+ const models: Array<{ provider: string; model: string }> = [];
40
+ const lines = result.stdout.split("\n");
41
+
42
+ for (const line of lines) {
43
+ // Skip header line and empty lines
44
+ if (!line.trim() || line.startsWith("provider")) continue;
45
+
46
+ // Parse: provider model context max-out thinking images
47
+ const parts = line.trim().split(/\s+/);
48
+ if (parts.length >= 2) {
49
+ const provider = parts[0];
50
+ const model = parts[1];
51
+ if (provider && model) {
52
+ models.push({ provider, model });
53
+ }
54
+ }
55
+ }
56
+
57
+ availableModelsCache = models;
58
+ modelsCacheTime = now;
59
+ return models;
60
+ } catch (e) {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Provider priority list - OAuth/subscription providers first (cheaper), then API-key providers
67
+ */
68
+ const PROVIDER_PRIORITY = [
69
+ // OAuth / Subscription providers (typically free/cheaper)
70
+ "google-gemini-cli", // Google Gemini CLI - OAuth, free tier
71
+ "github-copilot", // GitHub Copilot - subscription
72
+ "kimi-sub", // Kimi subscription
73
+ // API key providers
74
+ "anthropic",
75
+ "openai",
76
+ "google",
77
+ "zai",
78
+ "openrouter",
79
+ "azure-openai",
80
+ "amazon-bedrock",
81
+ "mistral",
82
+ "groq",
83
+ "cerebras",
84
+ "xai",
85
+ "vercel-ai-gateway",
86
+ ];
87
+
88
+ /**
89
+ * Find the best matching provider for a given model name.
90
+ * Returns the full provider/model string or null if not found.
91
+ */
92
+ function resolveModelWithProvider(modelName: string): string | null {
93
+ // If already has provider prefix, return as-is
94
+ if (modelName.includes("/")) {
95
+ return modelName;
96
+ }
97
+
98
+ const availableModels = getAvailableModels();
99
+ if (availableModels.length === 0) {
100
+ return null;
101
+ }
102
+
103
+ const lowerModelName = modelName.toLowerCase();
104
+
105
+ // Find all exact matches (case-insensitive) and sort by provider priority
106
+ const exactMatches = availableModels.filter(
107
+ (m) => m.model.toLowerCase() === lowerModelName
108
+ );
109
+
110
+ if (exactMatches.length > 0) {
111
+ // Sort by provider priority (lower index = higher priority)
112
+ exactMatches.sort((a, b) => {
113
+ const aIndex = PROVIDER_PRIORITY.indexOf(a.provider);
114
+ const bIndex = PROVIDER_PRIORITY.indexOf(b.provider);
115
+ // If provider not in priority list, put it at the end
116
+ const aPriority = aIndex === -1 ? 999 : aIndex;
117
+ const bPriority = bIndex === -1 ? 999 : bIndex;
118
+ return aPriority - bPriority;
119
+ });
120
+ return `${exactMatches[0].provider}/${exactMatches[0].model}`;
121
+ }
122
+
123
+ // Try partial match (model name contains the search term)
124
+ const partialMatches = availableModels.filter((m) =>
125
+ m.model.toLowerCase().includes(lowerModelName)
126
+ );
127
+
128
+ if (partialMatches.length > 0) {
129
+ for (const preferredProvider of PROVIDER_PRIORITY) {
130
+ const match = partialMatches.find(
131
+ (m) => m.provider === preferredProvider
132
+ );
133
+ if (match) {
134
+ return `${match.provider}/${match.model}`;
135
+ }
136
+ }
137
+ // Return first match if no preferred provider found
138
+ return `${partialMatches[0].provider}/${partialMatches[0].model}`;
139
+ }
140
+
141
+ return null;
142
+ }
13
143
 
14
144
  export default function (pi: ExtensionAPI) {
15
145
  const isTeammate = !!process.env.PI_AGENT_NAME;
16
146
  const agentName = process.env.PI_AGENT_NAME || "team-lead";
17
147
  const teamName = process.env.PI_TEAM_NAME;
18
148
 
19
- // Get the terminal adapter once at startup
20
149
  const terminal = getTerminalAdapter();
21
150
 
22
151
  pi.on("session_start", async (_event, ctx) => {
@@ -27,20 +156,24 @@ export default function (pi: ExtensionAPI) {
27
156
  fs.writeFileSync(pidFile, process.pid.toString());
28
157
  }
29
158
  ctx.ui.notify(`Teammate: ${agentName} (Team: ${teamName})`, "info");
30
- // Use a shorter, more prominent status at the beginning if possible
31
- ctx.ui.setStatus("00-pi-teams", `[${agentName.toUpperCase()}]`);
32
-
33
- // Set the terminal pane title for better visibility
159
+ ctx.ui.setStatus("00-pi-teams", `[${agentName.toUpperCase()}]`);
160
+
34
161
  if (terminal) {
35
- terminal.setTitle(agentName);
162
+ const fullTitle = teamName ? `${teamName}: ${agentName}` : agentName;
163
+ const setIt = () => {
164
+ if ((ctx.ui as any).setTitle) (ctx.ui as any).setTitle(fullTitle);
165
+ terminal.setTitle(fullTitle);
166
+ };
167
+ setIt();
168
+ setTimeout(setIt, 500);
169
+ setTimeout(setIt, 2000);
170
+ setTimeout(setIt, 5000);
36
171
  }
37
-
38
- // Auto-trigger the first turn for teammates
172
+
39
173
  setTimeout(() => {
40
174
  pi.sendUserMessage(`I am starting my work as '${agentName}' on team '${teamName}'. Checking my inbox for instructions...`);
41
175
  }, 1000);
42
176
 
43
- // Periodically check for new messages when idle
44
177
  setInterval(async () => {
45
178
  if (ctx.isIdle() && teamName) {
46
179
  const unread = await messaging.readInbox(teamName, agentName, true, false);
@@ -54,12 +187,19 @@ export default function (pi: ExtensionAPI) {
54
187
  }
55
188
  });
56
189
 
190
+ pi.on("turn_start", async (_event, ctx) => {
191
+ if (isTeammate) {
192
+ const fullTitle = teamName ? `${teamName}: ${agentName}` : agentName;
193
+ if ((ctx.ui as any).setTitle) (ctx.ui as any).setTitle(fullTitle);
194
+ if (terminal) terminal.setTitle(fullTitle);
195
+ }
196
+ });
197
+
57
198
  let firstTurn = true;
58
199
  pi.on("before_agent_start", async (event, ctx) => {
59
200
  if (isTeammate && firstTurn) {
60
201
  firstTurn = false;
61
202
 
62
- // Get the teammate's model and thinking level from team config for accurate reporting
63
203
  let modelInfo = "";
64
204
  if (teamName) {
65
205
  try {
@@ -73,7 +213,7 @@ export default function (pi: ExtensionAPI) {
73
213
  modelInfo += `. When reporting your model or thinking level, use these exact values.`;
74
214
  }
75
215
  } catch (e) {
76
- // If config can't be read, that's okay - proceed without model info
216
+ // Ignore
77
217
  }
78
218
  }
79
219
 
@@ -93,10 +233,14 @@ export default function (pi: ExtensionAPI) {
93
233
  process.kill(parseInt(pid), "SIGKILL");
94
234
  fs.unlinkSync(pidFile);
95
235
  } catch (e) {
96
- // ignore if process already dead
236
+ // ignore
97
237
  }
98
238
  }
99
239
 
240
+ if (member.windowId && terminal) {
241
+ terminal.killWindow(member.windowId);
242
+ }
243
+
100
244
  if (member.tmuxPaneId && terminal) {
101
245
  terminal.kill(member.tmuxPaneId);
102
246
  }
@@ -111,9 +255,10 @@ export default function (pi: ExtensionAPI) {
111
255
  team_name: Type.String(),
112
256
  description: Type.Optional(Type.String()),
113
257
  default_model: Type.Optional(Type.String()),
258
+ separate_windows: Type.Optional(Type.Boolean({ default: false, description: "Open teammates in separate OS windows instead of panes" })),
114
259
  }),
115
260
  async execute(toolCallId, params: any, signal, onUpdate, ctx) {
116
- const config = teams.createTeam(params.team_name, "local-session", "lead-agent", params.description, params.default_model);
261
+ const config = teams.createTeam(params.team_name, "local-session", "lead-agent", params.description, params.default_model, params.separate_windows);
117
262
  return {
118
263
  content: [{ type: "text", text: `Team ${params.team_name} created.` }],
119
264
  details: { config },
@@ -124,7 +269,7 @@ export default function (pi: ExtensionAPI) {
124
269
  pi.registerTool({
125
270
  name: "spawn_teammate",
126
271
  label: "Spawn Teammate",
127
- description: "Spawn a new teammate in a terminal pane.",
272
+ description: "Spawn a new teammate in a terminal pane or separate window.",
128
273
  parameters: Type.Object({
129
274
  team_name: Type.String(),
130
275
  name: Type.String(),
@@ -133,6 +278,7 @@ export default function (pi: ExtensionAPI) {
133
278
  model: Type.Optional(Type.String()),
134
279
  thinking: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high"])),
135
280
  plan_mode_required: Type.Optional(Type.Boolean({ default: false })),
281
+ separate_window: Type.Optional(Type.Boolean({ default: false })),
136
282
  }),
137
283
  async execute(toolCallId, params: any, signal, onUpdate, ctx) {
138
284
  const safeName = paths.sanitizeName(params.name);
@@ -143,28 +289,32 @@ export default function (pi: ExtensionAPI) {
143
289
  }
144
290
 
145
291
  if (!terminal) {
146
- throw new Error("No terminal adapter detected. Ensure you're running in tmux, iTerm2, or Zellij.");
292
+ throw new Error("No terminal adapter detected.");
147
293
  }
148
294
 
149
295
  const teamConfig = await teams.readConfig(safeTeamName);
150
296
  let chosenModel = params.model || teamConfig.defaultModel;
151
297
 
152
- // If model doesn't include provider prefix (provider/model), use the team's defaultModel or fallback
153
- if (chosenModel && !chosenModel.includes('/')) {
154
- // Check if team has a defaultModel with a provider prefix
155
- if (teamConfig.defaultModel && teamConfig.defaultModel.includes('/')) {
156
- const [provider] = teamConfig.defaultModel.split('/');
157
- chosenModel = `${provider}/${chosenModel}`;
158
- } else {
159
- // Infer provider from model name
160
- if (chosenModel.startsWith('glm-')) {
161
- chosenModel = `zai/${chosenModel}`;
162
- } else if (chosenModel.startsWith('claude-')) {
163
- chosenModel = `anthropic/${chosenModel}`;
298
+ // Resolve model to provider/model format
299
+ if (chosenModel) {
300
+ if (!chosenModel.includes('/')) {
301
+ // Try to resolve using available models from pi --list-models
302
+ const resolved = resolveModelWithProvider(chosenModel);
303
+ if (resolved) {
304
+ chosenModel = resolved;
305
+ } else if (teamConfig.defaultModel && teamConfig.defaultModel.includes('/')) {
306
+ // Fall back to team default provider
307
+ const [provider] = teamConfig.defaultModel.split('/');
308
+ chosenModel = `${provider}/${chosenModel}`;
164
309
  }
165
310
  }
166
311
  }
167
312
 
313
+ const useSeparateWindow = params.separate_window ?? teamConfig.separateWindows ?? false;
314
+ if (useSeparateWindow && !terminal.supportsWindows()) {
315
+ throw new Error(`Separate windows mode is not supported in ${terminal.name}.`);
316
+ }
317
+
168
318
  const member: Member = {
169
319
  agentId: `${safeName}@${safeTeamName}`,
170
320
  name: safeName,
@@ -183,18 +333,15 @@ export default function (pi: ExtensionAPI) {
183
333
  await teams.addMember(safeTeamName, member);
184
334
  await messaging.sendPlainMessage(safeTeamName, "team-lead", safeName, params.prompt, "Initial prompt");
185
335
 
186
- const piBinary = process.argv[1] ? `node ${process.argv[1]}` : "pi"; // Assumed on path
336
+ const piBinary = process.argv[1] ? `node ${process.argv[1]}` : "pi";
187
337
  let piCmd = piBinary;
188
338
 
189
- // Build model command with thinking level if specified
190
339
  if (chosenModel) {
191
- const [provider, ...modelParts] = chosenModel.split('/');
192
- const modelName = modelParts.join('/');
193
-
340
+ // Use the combined --model provider/model:thinking format
194
341
  if (params.thinking) {
195
- piCmd = `${piBinary} --provider ${provider} --model ${modelName}:${params.thinking}`;
342
+ piCmd = `${piBinary} --model ${chosenModel}:${params.thinking}`;
196
343
  } else {
197
- piCmd = `${piBinary} --provider ${provider} --model ${modelName}`;
344
+ piCmd = `${piBinary} --model ${chosenModel}`;
198
345
  }
199
346
  } else if (params.thinking) {
200
347
  piCmd = `${piBinary} --thinking ${params.thinking}`;
@@ -206,39 +353,83 @@ export default function (pi: ExtensionAPI) {
206
353
  PI_AGENT_NAME: safeName,
207
354
  };
208
355
 
209
- // For iTerm2, we need to handle the spawn context for proper layout
210
- if (terminal instanceof Iterm2Adapter) {
211
- const teammates = teamConfig.members.filter(m => m.agentType === "teammate" && m.tmuxPaneId.startsWith("iterm_"));
212
- const lastTeammate = teammates.length > 0 ? teammates[teammates.length - 1] : null;
213
- if (lastTeammate?.tmuxPaneId) {
214
- terminal.setSpawnContext({ lastSessionId: lastTeammate.tmuxPaneId.replace("iterm_", "") });
215
- } else {
216
- terminal.setSpawnContext({});
217
- }
218
- }
356
+ let terminalId = "";
357
+ let isWindow = false;
219
358
 
220
- let paneId = "";
221
359
  try {
222
- paneId = terminal.spawn({
223
- name: safeName,
224
- cwd: params.cwd,
225
- command: piCmd,
226
- env: env,
227
- });
360
+ if (useSeparateWindow) {
361
+ isWindow = true;
362
+ terminalId = terminal.spawnWindow({
363
+ name: safeName,
364
+ cwd: params.cwd,
365
+ command: piCmd,
366
+ env: env,
367
+ teamName: safeTeamName,
368
+ });
369
+ await teams.updateMember(safeTeamName, safeName, { windowId: terminalId });
370
+ } else {
371
+ if (terminal instanceof Iterm2Adapter) {
372
+ const teammates = teamConfig.members.filter(m => m.agentType === "teammate" && m.tmuxPaneId.startsWith("iterm_"));
373
+ const lastTeammate = teammates.length > 0 ? teammates[teammates.length - 1] : null;
374
+ if (lastTeammate?.tmuxPaneId) {
375
+ terminal.setSpawnContext({ lastSessionId: lastTeammate.tmuxPaneId.replace("iterm_", "") });
376
+ } else {
377
+ terminal.setSpawnContext({});
378
+ }
379
+ }
380
+
381
+ terminalId = terminal.spawn({
382
+ name: safeName,
383
+ cwd: params.cwd,
384
+ command: piCmd,
385
+ env: env,
386
+ });
387
+ await teams.updateMember(safeTeamName, safeName, { tmuxPaneId: terminalId });
388
+ }
228
389
  } catch (e) {
229
- throw new Error(`Failed to spawn ${terminal.name} pane: ${e}`);
390
+ throw new Error(`Failed to spawn ${terminal.name} ${isWindow ? 'window' : 'pane'}: ${e}`);
230
391
  }
231
392
 
232
- // Update member with paneId
233
- await teams.updateMember(params.team_name, params.name, { tmuxPaneId: paneId });
234
-
235
393
  return {
236
- content: [{ type: "text", text: `Teammate ${params.name} spawned in pane ${paneId}.` }],
237
- details: { agentId: member.agentId, paneId },
394
+ content: [{ type: "text", text: `Teammate ${params.name} spawned in ${isWindow ? 'window' : 'pane'} ${terminalId}.` }],
395
+ details: { agentId: member.agentId, terminalId, isWindow },
238
396
  };
239
397
  },
240
398
  });
241
399
 
400
+ pi.registerTool({
401
+ name: "spawn_lead_window",
402
+ label: "Spawn Lead Window",
403
+ description: "Open the team lead in a separate OS window.",
404
+ parameters: Type.Object({
405
+ team_name: Type.String(),
406
+ cwd: Type.Optional(Type.String()),
407
+ }),
408
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
409
+ const safeTeamName = paths.sanitizeName(params.team_name);
410
+ if (!teams.teamExists(safeTeamName)) throw new Error(`Team ${params.team_name} does not exist`);
411
+ if (!terminal || !terminal.supportsWindows()) throw new Error("Windows mode not supported.");
412
+
413
+ const teamConfig = await teams.readConfig(safeTeamName);
414
+ const cwd = params.cwd || process.cwd();
415
+ const piBinary = process.argv[1] ? `node ${process.argv[1]}` : "pi";
416
+ let piCmd = piBinary;
417
+ if (teamConfig.defaultModel) {
418
+ // Use the combined --model provider/model format
419
+ piCmd = `${piBinary} --model ${teamConfig.defaultModel}`;
420
+ }
421
+
422
+ const env = { ...process.env, PI_TEAM_NAME: safeTeamName, PI_AGENT_NAME: "team-lead" };
423
+ try {
424
+ const windowId = terminal.spawnWindow({ name: "team-lead", cwd, command: piCmd, env, teamName: safeTeamName });
425
+ await teams.updateMember(safeTeamName, "team-lead", { windowId });
426
+ return { content: [{ type: "text", text: `Lead window spawned: ${windowId}` }], details: { windowId } };
427
+ } catch (e) {
428
+ throw new Error(`Failed: ${e}`);
429
+ }
430
+ }
431
+ });
432
+
242
433
  pi.registerTool({
243
434
  name: "send_message",
244
435
  label: "Send Message",
@@ -286,7 +477,7 @@ export default function (pi: ExtensionAPI) {
286
477
  agent_name: Type.Optional(Type.String({ description: "Whose inbox to read. Defaults to your own." })),
287
478
  unread_only: Type.Optional(Type.Boolean({ default: true })),
288
479
  }),
289
- async execute(toolCallId, params, signal, onUpdate, ctx) {
480
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
290
481
  const targetAgent = params.agent_name || agentName;
291
482
  const msgs = await messaging.readInbox(params.team_name, targetAgent, params.unread_only);
292
483
  return {
@@ -305,7 +496,7 @@ export default function (pi: ExtensionAPI) {
305
496
  subject: Type.String(),
306
497
  description: Type.String(),
307
498
  }),
308
- async execute(toolCallId, params, signal, onUpdate, ctx) {
499
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
309
500
  const task = await tasks.createTask(params.team_name, params.subject, params.description);
310
501
  return {
311
502
  content: [{ type: "text", text: `Task ${task.id} created.` }],
@@ -354,11 +545,11 @@ export default function (pi: ExtensionAPI) {
354
545
  pi.registerTool({
355
546
  name: "task_list",
356
547
  label: "List Tasks",
357
- description: "List all team tasks.",
548
+ description: "List all tasks for a team.",
358
549
  parameters: Type.Object({
359
550
  team_name: Type.String(),
360
551
  }),
361
- async execute(toolCallId, params, signal, onUpdate, ctx) {
552
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
362
553
  const taskList = await tasks.listTasks(params.team_name);
363
554
  return {
364
555
  content: [{ type: "text", text: JSON.stringify(taskList, null, 2) }],
@@ -390,61 +581,39 @@ export default function (pi: ExtensionAPI) {
390
581
  });
391
582
 
392
583
  pi.registerTool({
393
- name: "team_delete",
394
- label: "Delete Team",
395
- description: "Delete a team and all its data.",
584
+ name: "team_shutdown",
585
+ label: "Shutdown Team",
586
+ description: "Shutdown the entire team and close all panes/windows.",
396
587
  parameters: Type.Object({
397
588
  team_name: Type.String(),
398
589
  }),
399
- async execute(toolCallId, params, signal, onUpdate, ctx) {
590
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
400
591
  const teamName = params.team_name;
401
592
  try {
402
593
  const config = await teams.readConfig(teamName);
403
594
  for (const member of config.members) {
404
- if (member.name !== "team-lead") {
405
- ctx.ui.notify(`Stopping teammate: ${member.name}`, "info");
406
- await killTeammate(teamName, member);
407
- }
595
+ await killTeammate(teamName, member);
408
596
  }
597
+ const dir = paths.teamDir(teamName);
598
+ const tasksDir = paths.taskDir(teamName);
599
+ if (fs.existsSync(tasksDir)) fs.rmSync(tasksDir, { recursive: true });
600
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
601
+ return { content: [{ type: "text", text: `Team ${teamName} shut down.` }], details: {} };
409
602
  } catch (e) {
410
- // config might not exist, ignore
603
+ throw new Error(`Failed to shutdown team: ${e}`);
411
604
  }
412
-
413
- const dir = paths.teamDir(teamName);
414
- const tasksDir = paths.taskDir(teamName);
415
- if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
416
- if (fs.existsSync(tasksDir)) fs.rmSync(tasksDir, { recursive: true });
417
- return {
418
- content: [{ type: "text", text: `Team ${teamName} deleted.` }],
419
- };
420
- },
421
- });
422
-
423
- pi.registerTool({
424
- name: "read_config",
425
- label: "Read Config",
426
- description: "Read the current team configuration.",
427
- parameters: Type.Object({
428
- team_name: Type.String(),
429
- }),
430
- async execute(toolCallId, params, signal, onUpdate, ctx) {
431
- const config = await teams.readConfig(params.team_name);
432
- return {
433
- content: [{ type: "text", text: JSON.stringify(config, null, 2) }],
434
- details: { config },
435
- };
436
605
  },
437
606
  });
438
607
 
439
608
  pi.registerTool({
440
- name: "task_get",
441
- label: "Get Task",
442
- description: "Get full details of a specific task by ID.",
609
+ name: "task_read",
610
+ label: "Read Task",
611
+ description: "Read details of a specific task.",
443
612
  parameters: Type.Object({
444
613
  team_name: Type.String(),
445
614
  task_id: Type.String(),
446
615
  }),
447
- async execute(toolCallId, params, signal, onUpdate, ctx) {
616
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
448
617
  const task = await tasks.readTask(params.team_name, params.task_id);
449
618
  return {
450
619
  content: [{ type: "text", text: JSON.stringify(task, null, 2) }],
@@ -453,29 +622,6 @@ export default function (pi: ExtensionAPI) {
453
622
  },
454
623
  });
455
624
 
456
- pi.registerTool({
457
- name: "force_kill_teammate",
458
- label: "Force Kill Teammate",
459
- description: "Forcibly kill a teammate's terminal pane.",
460
- parameters: Type.Object({
461
- team_name: Type.String(),
462
- agent_name: Type.String(),
463
- }),
464
- async execute(toolCallId, params, signal, onUpdate, ctx) {
465
- const config = await teams.readConfig(params.team_name);
466
- const member = config.members.find(m => m.name === params.agent_name);
467
- if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
468
-
469
- await killTeammate(params.team_name, member);
470
-
471
- await teams.removeMember(params.team_name, params.agent_name);
472
- await tasks.resetOwnerTasks(params.team_name, params.agent_name);
473
- return {
474
- content: [{ type: "text", text: `${params.agent_name} has been stopped.` }],
475
- };
476
- },
477
- });
478
-
479
625
  pi.registerTool({
480
626
  name: "check_teammate",
481
627
  label: "Check Teammate",
@@ -484,18 +630,19 @@ export default function (pi: ExtensionAPI) {
484
630
  team_name: Type.String(),
485
631
  agent_name: Type.String(),
486
632
  }),
487
- async execute(toolCallId, params, signal, onUpdate, ctx) {
633
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
488
634
  const config = await teams.readConfig(params.team_name);
489
635
  const member = config.members.find(m => m.name === params.agent_name);
490
636
  if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
491
637
 
492
638
  let alive = false;
493
- if (member.tmuxPaneId && terminal) {
639
+ if (member.windowId && terminal) {
640
+ alive = terminal.isWindowAlive(member.windowId);
641
+ } else if (member.tmuxPaneId && terminal) {
494
642
  alive = terminal.isAlive(member.tmuxPaneId);
495
643
  }
496
644
 
497
645
  const unreadCount = (await messaging.readInbox(params.team_name, params.agent_name, true, false)).length;
498
-
499
646
  return {
500
647
  content: [{ type: "text", text: JSON.stringify({ alive, unreadCount }, null, 2) }],
501
648
  details: { alive, unreadCount },
@@ -511,17 +658,16 @@ export default function (pi: ExtensionAPI) {
511
658
  team_name: Type.String(),
512
659
  agent_name: Type.String(),
513
660
  }),
514
- async execute(toolCallId, params, signal, onUpdate, ctx) {
661
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
515
662
  const config = await teams.readConfig(params.team_name);
516
663
  const member = config.members.find(m => m.name === params.agent_name);
517
664
  if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
518
665
 
519
666
  await killTeammate(params.team_name, member);
520
-
521
667
  await teams.removeMember(params.team_name, params.agent_name);
522
- await tasks.resetOwnerTasks(params.team_name, params.agent_name);
523
668
  return {
524
- content: [{ type: "text", text: `${params.agent_name} removed from team.` }],
669
+ content: [{ type: "text", text: `Teammate ${params.agent_name} has been shut down.` }],
670
+ details: {},
525
671
  };
526
672
  },
527
673
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.8.2",
3
+ "version": "0.8.6",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * iTerm2 Terminal Adapter
3
- *
3
+ *
4
4
  * Implements the TerminalAdapter interface for iTerm2 terminal emulator.
5
5
  * Uses AppleScript for all operations.
6
6
  */
7
7
 
8
8
  import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
9
+ import { spawnSync } from "node:child_process";
9
10
 
10
11
  /**
11
12
  * Context needed for iTerm2 spawning (tracks last pane for layout)
@@ -20,23 +21,36 @@ export class Iterm2Adapter implements TerminalAdapter {
20
21
  private spawnContext: Iterm2SpawnContext = {};
21
22
 
22
23
  detect(): boolean {
23
- // iTerm2 is available if TERM_PROGRAM is iTerm.app and not in tmux/zellij
24
24
  return process.env.TERM_PROGRAM === "iTerm.app" && !process.env.TMUX && !process.env.ZELLIJ;
25
25
  }
26
26
 
27
+ /**
28
+ * Helper to execute AppleScript via stdin to avoid escaping issues with -e
29
+ */
30
+ private runAppleScript(script: string): { stdout: string; stderr: string; status: number | null } {
31
+ const result = spawnSync("osascript", ["-"], {
32
+ input: script,
33
+ encoding: "utf-8",
34
+ });
35
+ return {
36
+ stdout: result.stdout?.toString() ?? "",
37
+ stderr: result.stderr?.toString() ?? "",
38
+ status: result.status,
39
+ };
40
+ }
41
+
27
42
  spawn(options: SpawnOptions): string {
28
43
  const envStr = Object.entries(options.env)
29
44
  .filter(([k]) => k.startsWith("PI_"))
30
45
  .map(([k, v]) => `${k}=${v}`)
31
46
  .join(" ");
32
-
47
+
33
48
  const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
34
49
  const escapedCmd = itermCmd.replace(/"/g, '\\"');
35
50
 
36
51
  let script: string;
37
-
52
+
38
53
  if (!this.spawnContext.lastSessionId) {
39
- // First teammate: split current session vertically (side-by-side)
40
54
  script = `tell application "iTerm2"
41
55
  tell current session of current window
42
56
  set newSession to split vertically with default profile
@@ -47,7 +61,6 @@ export class Iterm2Adapter implements TerminalAdapter {
47
61
  end tell
48
62
  end tell`;
49
63
  } else {
50
- // Subsequent teammate: split the last teammate's session horizontally (stacking)
51
64
  script = `tell application "iTerm2"
52
65
  repeat with aWindow in windows
53
66
  repeat with aTab in tabs of aWindow
@@ -67,21 +80,21 @@ end tell`;
67
80
  end tell`;
68
81
  }
69
82
 
70
- const result = execCommand("osascript", ["-e", script]);
71
-
83
+ const result = this.runAppleScript(script);
84
+
72
85
  if (result.status !== 0) {
73
86
  throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`);
74
87
  }
75
88
 
76
89
  const sessionId = result.stdout.toString().trim();
77
90
  this.spawnContext.lastSessionId = sessionId;
78
-
91
+
79
92
  return `iterm_${sessionId}`;
80
93
  }
81
94
 
82
95
  kill(paneId: string): void {
83
- if (!paneId || !paneId.startsWith("iterm_")) {
84
- return; // Not an iTerm2 pane
96
+ if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) {
97
+ return;
85
98
  }
86
99
 
87
100
  const itermId = paneId.replace("iterm_", "");
@@ -99,15 +112,15 @@ end tell`;
99
112
  end tell`;
100
113
 
101
114
  try {
102
- execCommand("osascript", ["-e", script]);
115
+ this.runAppleScript(script);
103
116
  } catch {
104
- // Ignore errors - session may already be closed
117
+ // Ignore errors
105
118
  }
106
119
  }
107
120
 
108
121
  isAlive(paneId: string): boolean {
109
- if (!paneId || !paneId.startsWith("iterm_")) {
110
- return false; // Not an iTerm2 pane
122
+ if (!paneId || !paneId.startsWith("iterm_") || paneId.startsWith("iterm_win_")) {
123
+ return false;
111
124
  }
112
125
 
113
126
  const itermId = paneId.replace("iterm_", "");
@@ -124,7 +137,7 @@ end tell`;
124
137
  end tell`;
125
138
 
126
139
  try {
127
- const result = execCommand("osascript", ["-e", script]);
140
+ const result = this.runAppleScript(script);
128
141
  return result.stdout.includes("Alive");
129
142
  } catch {
130
143
  return false;
@@ -132,16 +145,145 @@ end tell`;
132
145
  }
133
146
 
134
147
  setTitle(title: string): void {
148
+ const escapedTitle = title.replace(/"/g, '\\"');
149
+ const script = `tell application "iTerm2" to tell current session of current window
150
+ set name to "${escapedTitle}"
151
+ end tell`;
135
152
  try {
136
- execCommand("osascript", [
137
- "-e",
138
- `tell application "iTerm2" to tell current session of current window to set name to "${title}"`
139
- ]);
153
+ this.runAppleScript(script);
140
154
  } catch {
141
155
  // Ignore errors
142
156
  }
143
157
  }
144
158
 
159
+ /**
160
+ * iTerm2 supports spawning separate OS windows via AppleScript
161
+ */
162
+ supportsWindows(): boolean {
163
+ return true;
164
+ }
165
+
166
+ /**
167
+ * Spawn a new separate OS window with the given options.
168
+ */
169
+ spawnWindow(options: SpawnOptions): string {
170
+ const envStr = Object.entries(options.env)
171
+ .filter(([k]) => k.startsWith("PI_"))
172
+ .map(([k, v]) => `${k}=${v}`)
173
+ .join(" ");
174
+
175
+ const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
176
+ const escapedCmd = itermCmd.replace(/"/g, '\\"');
177
+
178
+ const windowTitle = options.teamName
179
+ ? `${options.teamName}: ${options.name}`
180
+ : options.name;
181
+
182
+ const escapedTitle = windowTitle.replace(/"/g, '\\"');
183
+
184
+ const script = `tell application "iTerm2"
185
+ set newWindow to (create window with default profile)
186
+ tell current session of newWindow
187
+ -- Set the session name (tab title)
188
+ set name to "${escapedTitle}"
189
+ -- Set window title via escape sequence (OSC 2)
190
+ -- We use double backslashes for AppleScript to emit a single backslash to the shell
191
+ write text "printf '\\\\033]2;${escapedTitle}\\\\007'"
192
+ -- Execute the command
193
+ write text "cd '${options.cwd}' && ${escapedCmd}"
194
+ return id of newWindow
195
+ end tell
196
+ end tell`;
197
+
198
+ const result = this.runAppleScript(script);
199
+
200
+ if (result.status !== 0) {
201
+ throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`);
202
+ }
203
+
204
+ const windowId = result.stdout.toString().trim();
205
+ return `iterm_win_${windowId}`;
206
+ }
207
+
208
+ /**
209
+ * Set the title of a specific window.
210
+ */
211
+ setWindowTitle(windowId: string, title: string): void {
212
+ if (!windowId || !windowId.startsWith("iterm_win_")) {
213
+ return;
214
+ }
215
+
216
+ const itermId = windowId.replace("iterm_win_", "");
217
+ const escapedTitle = title.replace(/"/g, '\\"');
218
+
219
+ const script = `tell application "iTerm2"
220
+ repeat with aWindow in windows
221
+ if id of aWindow is "${itermId}" then
222
+ tell current session of aWindow
223
+ write text "printf '\\\\033]2;${escapedTitle}\\\\007'"
224
+ end tell
225
+ exit repeat
226
+ end if
227
+ end repeat
228
+ end tell`;
229
+
230
+ try {
231
+ this.runAppleScript(script);
232
+ } catch {
233
+ // Silently fail
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Kill/terminate a window.
239
+ */
240
+ killWindow(windowId: string): void {
241
+ if (!windowId || !windowId.startsWith("iterm_win_")) {
242
+ return;
243
+ }
244
+
245
+ const itermId = windowId.replace("iterm_win_", "");
246
+ const script = `tell application "iTerm2"
247
+ repeat with aWindow in windows
248
+ if id of aWindow is "${itermId}" then
249
+ close aWindow
250
+ return "Closed"
251
+ end if
252
+ end repeat
253
+ end tell`;
254
+
255
+ try {
256
+ this.runAppleScript(script);
257
+ } catch {
258
+ // Silently fail
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Check if a window is still alive/active.
264
+ */
265
+ isWindowAlive(windowId: string): boolean {
266
+ if (!windowId || !windowId.startsWith("iterm_win_")) {
267
+ return false;
268
+ }
269
+
270
+ const itermId = windowId.replace("iterm_win_", "");
271
+ const script = `tell application "iTerm2"
272
+ repeat with aWindow in windows
273
+ if id of aWindow is "${itermId}" then
274
+ return "Alive"
275
+ end if
276
+ end repeat
277
+ end tell`;
278
+
279
+ try {
280
+ const result = this.runAppleScript(script);
281
+ return result.stdout.includes("Alive");
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
145
287
  /**
146
288
  * Set the spawn context (used to restore state when needed)
147
289
  */
@@ -99,3 +99,22 @@ export function setAdapter(adapter: TerminalAdapter): void {
99
99
  export function hasTerminalAdapter(): boolean {
100
100
  return getTerminalAdapter() !== null;
101
101
  }
102
+
103
+ /**
104
+ * Check if the current terminal supports spawning separate OS windows.
105
+ *
106
+ * @returns true if the detected terminal supports windows (iTerm2, WezTerm)
107
+ */
108
+ export function supportsWindows(): boolean {
109
+ const adapter = getTerminalAdapter();
110
+ return adapter?.supportsWindows() ?? false;
111
+ }
112
+
113
+ /**
114
+ * Get the name of the currently detected terminal adapter.
115
+ *
116
+ * @returns The adapter name, or null if none detected
117
+ */
118
+ export function getTerminalName(): string | null {
119
+ return getTerminalAdapter()?.name ?? null;
120
+ }
@@ -74,4 +74,39 @@ export class TmuxAdapter implements TerminalAdapter {
74
74
  // Ignore errors
75
75
  }
76
76
  }
77
+
78
+ /**
79
+ * tmux does not support spawning separate OS windows
80
+ */
81
+ supportsWindows(): boolean {
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Not supported - throws error
87
+ */
88
+ spawnWindow(_options: SpawnOptions): string {
89
+ throw new Error("tmux does not support spawning separate OS windows. Use iTerm2 or WezTerm instead.");
90
+ }
91
+
92
+ /**
93
+ * Not supported - no-op
94
+ */
95
+ setWindowTitle(_windowId: string, _title: string): void {
96
+ // Not supported
97
+ }
98
+
99
+ /**
100
+ * Not supported - no-op
101
+ */
102
+ killWindow(_windowId: string): void {
103
+ // Not supported
104
+ }
105
+
106
+ /**
107
+ * Not supported - always returns false
108
+ */
109
+ isWindowAlive(_windowId: string): boolean {
110
+ return false;
111
+ }
77
112
  }
@@ -163,4 +163,142 @@ export class WezTermAdapter implements TerminalAdapter {
163
163
  execCommand(weztermBin, ["cli", "set-tab-title", title]);
164
164
  } catch {}
165
165
  }
166
+
167
+ /**
168
+ * WezTerm supports spawning separate OS windows via CLI
169
+ */
170
+ supportsWindows(): boolean {
171
+ return this.findWeztermBinary() !== null;
172
+ }
173
+
174
+ /**
175
+ * Spawn a new separate OS window with the given options.
176
+ * Uses `wezterm cli spawn --new-window` and sets the window title.
177
+ */
178
+ spawnWindow(options: SpawnOptions): string {
179
+ const weztermBin = this.findWeztermBinary();
180
+ if (!weztermBin) {
181
+ throw new Error("WezTerm CLI binary not found.");
182
+ }
183
+
184
+ const envArgs = Object.entries(options.env)
185
+ .filter(([k]) => k.startsWith("PI_"))
186
+ .map(([k, v]) => `${k}=${v}`);
187
+
188
+ // Format window title as "teamName: agentName" if teamName is provided
189
+ const windowTitle = options.teamName
190
+ ? `${options.teamName}: ${options.name}`
191
+ : options.name;
192
+
193
+ // Spawn a new window
194
+ const spawnArgs = [
195
+ "cli", "spawn", "--new-window",
196
+ "--cwd", options.cwd,
197
+ "--", "env", ...envArgs, "sh", "-c", options.command
198
+ ];
199
+
200
+ const result = execCommand(weztermBin, spawnArgs);
201
+ if (result.status !== 0) {
202
+ throw new Error(`wezterm spawn-window failed: ${result.stderr}`);
203
+ }
204
+
205
+ // The output is the pane ID, we need to find the window ID
206
+ const paneId = result.stdout.trim();
207
+
208
+ // Query to get window ID from pane ID
209
+ const windowId = this.getWindowIdFromPaneId(parseInt(paneId, 10));
210
+
211
+ // Set the window title if we found the window
212
+ if (windowId !== null) {
213
+ this.setWindowTitle(`wezterm_win_${windowId}`, windowTitle);
214
+ }
215
+
216
+ return `wezterm_win_${windowId || paneId}`;
217
+ }
218
+
219
+ /**
220
+ * Get window ID from a pane ID by querying WezTerm
221
+ */
222
+ private getWindowIdFromPaneId(paneId: number): number | null {
223
+ const weztermBin = this.findWeztermBinary();
224
+ if (!weztermBin) return null;
225
+
226
+ const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
227
+ if (result.status !== 0) return null;
228
+
229
+ try {
230
+ const allPanes = JSON.parse(result.stdout);
231
+ const pane = allPanes.find((p: any) => p.pane_id === paneId);
232
+ return pane?.window_id ?? null;
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Set the title of a specific window.
240
+ */
241
+ setWindowTitle(windowId: string, title: string): void {
242
+ if (!windowId?.startsWith("wezterm_win_")) return;
243
+
244
+ const weztermBin = this.findWeztermBinary();
245
+ if (!weztermBin) return;
246
+
247
+ const weztermWindowId = windowId.replace("wezterm_win_", "");
248
+
249
+ try {
250
+ execCommand(weztermBin, ["cli", "set-window-title", "--window-id", weztermWindowId, title]);
251
+ } catch {
252
+ // Silently fail
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Kill/terminate a window.
258
+ */
259
+ killWindow(windowId: string): void {
260
+ if (!windowId?.startsWith("wezterm_win_")) return;
261
+
262
+ const weztermBin = this.findWeztermBinary();
263
+ if (!weztermBin) return;
264
+
265
+ const weztermWindowId = windowId.replace("wezterm_win_", "");
266
+
267
+ try {
268
+ // WezTerm doesn't have a direct kill-window command, so we kill all panes in the window
269
+ const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
270
+ if (result.status !== 0) return;
271
+
272
+ const allPanes = JSON.parse(result.stdout);
273
+ const windowPanes = allPanes.filter((p: any) => p.window_id.toString() === weztermWindowId);
274
+
275
+ for (const pane of windowPanes) {
276
+ execCommand(weztermBin, ["cli", "kill-pane", "--pane-id", pane.pane_id.toString()]);
277
+ }
278
+ } catch {
279
+ // Silently fail
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Check if a window is still alive/active.
285
+ */
286
+ isWindowAlive(windowId: string): boolean {
287
+ if (!windowId?.startsWith("wezterm_win_")) return false;
288
+
289
+ const weztermBin = this.findWeztermBinary();
290
+ if (!weztermBin) return false;
291
+
292
+ const weztermWindowId = windowId.replace("wezterm_win_", "");
293
+
294
+ try {
295
+ const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
296
+ if (result.status !== 0) return false;
297
+
298
+ const allPanes = JSON.parse(result.stdout);
299
+ return allPanes.some((p: any) => p.window_id.toString() === weztermWindowId);
300
+ } catch {
301
+ return false;
302
+ }
303
+ }
166
304
  }
@@ -59,4 +59,39 @@ export class ZellijAdapter implements TerminalAdapter {
59
59
  // Zellij pane titles are set via --name at spawn time
60
60
  // No runtime title changing supported
61
61
  }
62
+
63
+ /**
64
+ * Zellij does not support spawning separate OS windows
65
+ */
66
+ supportsWindows(): boolean {
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Not supported - throws error
72
+ */
73
+ spawnWindow(_options: SpawnOptions): string {
74
+ throw new Error("Zellij does not support spawning separate OS windows. Use iTerm2 or WezTerm instead.");
75
+ }
76
+
77
+ /**
78
+ * Not supported - no-op
79
+ */
80
+ setWindowTitle(_windowId: string, _title: string): void {
81
+ // Not supported
82
+ }
83
+
84
+ /**
85
+ * Not supported - no-op
86
+ */
87
+ killWindow(_windowId: string): void {
88
+ // Not supported
89
+ }
90
+
91
+ /**
92
+ * Not supported - always returns false
93
+ */
94
+ isWindowAlive(_windowId: string): boolean {
95
+ return false;
96
+ }
62
97
  }
@@ -5,6 +5,7 @@ export interface Member {
5
5
  model?: string;
6
6
  joinedAt: number;
7
7
  tmuxPaneId: string;
8
+ windowId?: string;
8
9
  cwd: string;
9
10
  subscriptions: any[];
10
11
  prompt?: string;
@@ -23,6 +24,7 @@ export interface TeamConfig {
23
24
  leadSessionId: string;
24
25
  members: Member[];
25
26
  defaultModel?: string;
27
+ separateWindows?: boolean;
26
28
  }
27
29
 
28
30
  export interface TaskFile {
@@ -13,7 +13,8 @@ export function createTeam(
13
13
  sessionId: string,
14
14
  leadAgentId: string,
15
15
  description = "",
16
- defaultModel?: string
16
+ defaultModel?: string,
17
+ separateWindows?: boolean
17
18
  ): TeamConfig {
18
19
  const dir = teamDir(name);
19
20
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@@ -39,6 +40,7 @@ export function createTeam(
39
40
  leadSessionId: sessionId,
40
41
  members: [leadMember],
41
42
  defaultModel,
43
+ separateWindows,
42
44
  };
43
45
 
44
46
  fs.writeFileSync(configPath(name), JSON.stringify(config, null, 2));
@@ -8,17 +8,19 @@
8
8
  import { spawnSync } from "node:child_process";
9
9
 
10
10
  /**
11
- * Options for spawning a new terminal pane
11
+ * Options for spawning a new terminal pane or window
12
12
  */
13
13
  export interface SpawnOptions {
14
- /** Name/identifier for the pane */
14
+ /** Name/identifier for the pane/window */
15
15
  name: string;
16
- /** Working directory for the new pane */
16
+ /** Working directory for the new pane/window */
17
17
  cwd: string;
18
- /** Command to execute in the pane */
18
+ /** Command to execute in the pane/window */
19
19
  command: string;
20
20
  /** Environment variables to set (key-value pairs) */
21
21
  env: Record<string, string>;
22
+ /** Team name for window title formatting (e.g., "team: agent") */
23
+ teamName?: string;
22
24
  }
23
25
 
24
26
  /**
@@ -70,6 +72,49 @@ export interface TerminalAdapter {
70
72
  * @param title - The title to set
71
73
  */
72
74
  setTitle(title: string): void;
75
+
76
+ /**
77
+ * Check if this terminal supports spawning separate OS windows.
78
+ * Terminals like tmux and Zellij only support panes/tabs within a session.
79
+ *
80
+ * @returns true if spawnWindow() is supported
81
+ */
82
+ supportsWindows(): boolean;
83
+
84
+ /**
85
+ * Spawn a new separate OS window with the given options.
86
+ * Only available if supportsWindows() returns true.
87
+ *
88
+ * @param options - Spawn configuration
89
+ * @returns Window ID that can be used for subsequent operations
90
+ * @throws Error if spawn fails or not supported
91
+ */
92
+ spawnWindow(options: SpawnOptions): string;
93
+
94
+ /**
95
+ * Set the title of a specific window.
96
+ * Used for identifying windows in the OS window manager.
97
+ *
98
+ * @param windowId - The window ID returned from spawnWindow()
99
+ * @param title - The title to set
100
+ */
101
+ setWindowTitle(windowId: string, title: string): void;
102
+
103
+ /**
104
+ * Kill/terminate a window.
105
+ * Should be idempotent - no error if window doesn't exist.
106
+ *
107
+ * @param windowId - The window ID returned from spawnWindow()
108
+ */
109
+ killWindow(windowId: string): void;
110
+
111
+ /**
112
+ * Check if a window is still alive/active.
113
+ *
114
+ * @param windowId - The window ID returned from spawnWindow()
115
+ * @returns true if window exists and is active
116
+ */
117
+ isWindowAlive(windowId: string): boolean;
73
118
  }
74
119
 
75
120
  /**