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 +30 -2
- package/extensions/index.ts +276 -130
- package/package.json +1 -1
- package/src/adapters/iterm2-adapter.ts +162 -20
- package/src/adapters/terminal-registry.ts +19 -0
- package/src/adapters/tmux-adapter.ts +35 -0
- package/src/adapters/wezterm-adapter.ts +138 -0
- package/src/adapters/zellij-adapter.ts +35 -0
- package/src/utils/models.ts +2 -0
- package/src/utils/teams.ts +3 -1
- package/src/utils/terminal-adapter.ts +49 -4
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**
|
|
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.
|
|
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`
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
153
|
-
if (chosenModel
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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";
|
|
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
|
-
|
|
192
|
-
const modelName = modelParts.join('/');
|
|
193
|
-
|
|
340
|
+
// Use the combined --model provider/model:thinking format
|
|
194
341
|
if (params.thinking) {
|
|
195
|
-
piCmd = `${piBinary} --
|
|
342
|
+
piCmd = `${piBinary} --model ${chosenModel}:${params.thinking}`;
|
|
196
343
|
} else {
|
|
197
|
-
piCmd = `${piBinary} --
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 ${
|
|
237
|
-
details: { agentId: member.agentId,
|
|
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
|
|
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: "
|
|
394
|
-
label: "
|
|
395
|
-
description: "
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
441
|
-
label: "
|
|
442
|
-
description: "
|
|
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.
|
|
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:
|
|
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,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 =
|
|
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;
|
|
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
|
-
|
|
115
|
+
this.runAppleScript(script);
|
|
103
116
|
} catch {
|
|
104
|
-
// Ignore errors
|
|
117
|
+
// Ignore errors
|
|
105
118
|
}
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
isAlive(paneId: string): boolean {
|
|
109
|
-
if (!paneId || !paneId.startsWith("iterm_")) {
|
|
110
|
-
return false;
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/models.ts
CHANGED
|
@@ -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 {
|
package/src/utils/teams.ts
CHANGED
|
@@ -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
|
/**
|