pi-teams 0.7.2 → 0.7.3

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
@@ -1,6 +1,6 @@
1
1
  # pi-teams 🚀
2
2
 
3
- **pi-teams** turns your single Pi agent into a coordinated software engineering team. It allows you to spawn multiple "Teammate" agents in separate terminal panes that work autonomously, communicate with each other, and manage a shared task board—all mediated through **tmux**.
3
+ **pi-teams** turns your single Pi agent into a coordinated software engineering team. It allows you to spawn multiple "Teammate" agents in separate terminal panes that work autonomously, communicate with each other, and manage a shared task board—all mediated through tmux, iTerm2, or Zellij.
4
4
 
5
5
  ### 🖥️ pi-teams in Action
6
6
 
@@ -16,137 +16,76 @@ Open your Pi terminal and type:
16
16
  pi install npm:pi-teams
17
17
  ```
18
18
 
19
- ---
19
+ ## 🚀 Quick Start
20
+
21
+ ```bash
22
+ # 1. Start a team (inside tmux, Zellij, or iTerm2)
23
+ "Create a team named 'my-team' using 'gpt-4o'"
24
+
25
+ # 2. Spawn teammates
26
+ "Spawn 'security-bot' to scan for vulnerabilities"
27
+ "Spawn 'frontend-dev' using 'haiku' for quick iterations"
28
+
29
+ # 3. Create and assign tasks
30
+ "Create a task for security-bot: 'Audit auth endpoints'"
31
+
32
+ # 4. Review and approve work
33
+ "List all tasks and approve any pending plans"
34
+ ```
20
35
 
21
36
  ## 🌟 What can it do?
22
37
 
38
+ ### Core Features
23
39
  - **Spawn Specialists**: Create agents like "Security Expert" or "Frontend Pro" to handle sub-tasks in parallel.
24
40
  - **Shared Task Board**: Keep everyone on the same page with a persistent list of tasks and their status.
25
41
  - **Agent Messaging**: Agents can send direct messages to each other and to you (the Team Lead) to report progress.
26
- - **Broadcast Messaging**: Send a message to the entire team at once for global coordination.
27
- - **Plan Approval Mode**: Require teammates to submit their implementation plans for lead approval before they touch any code.
28
- - **Quality Gate Hooks**: Automated shell scripts can run when tasks are completed (e.g., to run tests or linting).
29
42
  - **Autonomous Work**: Teammates automatically "wake up," read their instructions, and poll their inboxes for new work while idle.
30
43
  - **Beautiful UI**: Optimized vertical splits in `tmux` with clear labels so you always know who is doing what.
31
44
 
32
- ---
33
-
34
- ## 💬 How to use it (Examples)
45
+ ### Advanced Features
46
+ - **Plan Approval Mode**: Require teammates to submit their implementation plans for your approval before they touch any code.
47
+ - **Broadcast Messaging**: Send a message to the entire team at once for global coordination and announcements.
48
+ - **Quality Gate Hooks**: Automated shell scripts run when tasks are completed (e.g., to run tests or linting).
49
+ - **Thinking Level Control**: Set per-teammate thinking levels (`off`, `minimal`, `low`, `medium`, `high`) to balance speed vs. reasoning depth.
35
50
 
36
- You don't need to learn complex code commands. Just talk to Pi in plain English!
51
+ ## 💬 Key Examples
37
52
 
38
53
  ### 1. Start a Team
39
54
  > **You:** "Create a team named 'my-app-audit' for reviewing the codebase."
40
55
 
41
- **Pro Tip:** You can also set a default model for the whole team!
56
+ **Set a default model for the whole team:**
42
57
  > **You:** "Create a team named 'Research' and use 'gpt-4o' for everyone."
43
58
 
44
- ### 2. Spawn a Teammate
59
+ ### 2. Spawn Teammate with Custom Settings
45
60
  > **You:** "Spawn a teammate named 'security-bot' in the current folder. Tell them to scan for hardcoded API keys."
46
61
 
47
- **Pro Tip:** You can specify a different model for a specific teammate!
62
+ **Use a different model:**
48
63
  > **You:** "Spawn a teammate named 'speed-bot' using 'haiku' to quickly run some benchmarks."
49
64
 
50
- **New: Plan Approval Mode**
65
+ **Require plan approval:**
51
66
  > **You:** "Spawn a teammate named 'refactor-bot' and require plan approval before they make any changes."
52
67
 
53
- **Customize Teammate Model & Thinking**
68
+ **Customize model and thinking level:**
54
69
  > **You:** "Spawn a teammate named 'architect-bot' using 'gpt-4o' with 'high' thinking level for deep reasoning."
55
- > **You:** "Spawn a teammate named 'frontend-dev' using 'haiku' with 'low' thinking level for quick iterations."
56
- > **You:** "Spawn a teammate named 'code-reviewer' using 'gpt-4o' with 'medium' thinking level."
57
70
 
58
- You can customize **both** the model and thinking level for each teammate independently:
59
- - **Model**: Override team's default model for a specific teammate (e.g., `gpt-4o`, `haiku`, `glm-4.7`)
60
- - **Thinking Level**: Balance speed vs. depth per teammate:
61
- - `off`: No thinking blocks (fastest)
62
- - `minimal`: Minimal reasoning overhead
63
- - `low`: Light reasoning for quick decisions
64
- - `medium`: Balanced reasoning (default for most work)
65
- - `high`: Extended reasoning for complex problems
66
-
67
- This lets you build teams with varied capabilities—fast, lightweight teammates for simple tasks, and powerful, thoughtful teammates for complex work.
68
-
69
- ### 3. Assign a Task
71
+ ### 3. Assign Task & Get Approval
70
72
  > **You:** "Create a task for security-bot: 'Check the .env.example file for sensitive defaults' and set it to in_progress."
71
73
 
72
- ### 4. Submit and Evaluate a Plan
73
- Teammates in `planning` mode will use `task_submit_plan`. As the lead, you can then:
74
+ Teammates in `planning` mode will use `task_submit_plan`. As the lead, review their work:
74
75
  > **You:** "Review refactor-bot's plan for task 5. If it looks good, approve it. If not, reject it with feedback on the test coverage."
75
76
 
76
- ### 5. Broadcast a Message
77
+ ### 4. Broadcast to Team
77
78
  > **You:** "Broadcast to the entire team: 'The API endpoint has changed to /v2. Please update your work accordingly.'"
78
79
 
79
- ### 6. Automated Hooks
80
- Add a script at `.pi/team-hooks/task_completed.sh` to run automated checks when any task is finished.
81
- ```bash
82
- #!/bin/bash
83
- # Example: Run tests when a task is completed
84
- npm test
85
- ```
86
-
87
- ### 7. Inter-Agent Communication
88
- > Teammates can also talk to each other! For example, a `frontend-bot` can message a `backend-bot` to coordinate on an API schema without your intervention.
89
-
90
- ### 6. Check on Progress
91
- > **You:** "How is the team doing? List all tasks and check my inbox for any messages."
92
-
93
- ### 7. Shut Down the Team
80
+ ### 5. Shut Down Team
94
81
  > **You:** "We're done. Shut down the team and close the panes."
95
82
 
96
83
  ---
97
84
 
98
- ## 🛠 Available Tools
99
-
100
- Pi automatically uses these tools when you give instructions like the examples above.
101
-
102
- ### Team Management
103
- - `team_create`: Start a new team. (Optional: `default_model`)
104
- - `team_delete`: Delete a team and its data.
105
- - `read_config`: Get details about the team and its members.
106
-
107
- ### Teammates
108
- - `spawn_teammate`: Launch a new agent into a `tmux` pane with a role and instructions. (Optional: `model`, `thinking`, `plan_mode_required`)
109
- - **`model`**: Specify which AI model this teammate should use (e.g., `gpt-4o`, `haiku`, `glm-4.7`, `glm-5`). If not specified, uses the team's default model. You can mix different models across teammates for cost/performance optimization.
110
- - **`thinking`**: Set the agent's thinking level (`off`, `minimal`, `low`, `medium`, `high`). This controls how much time the agent spends reasoning before responding. If not specified, inherited from team/global settings. Different teammates can have different thinking levels.
111
- - **`plan_mode_required`**: If true, teammate must submit plans for lead approval before making code changes
112
- - `check_teammate`: See if a teammate is still running or has unread messages.
113
- - `force_kill_teammate`: Stop a teammate and remove them from the team.
114
- - `process_shutdown_approved`: Orderly shutdown for a finished teammate.
115
-
116
- ### Task Management
117
- - `task_create`: Create a new task.
118
- - `task_list`: List all tasks and their current status.
119
- - `task_get`: Get full details of a specific task.
120
- - `task_update`: Update a task's status (pending, planning, in_progress, etc.) or owner.
121
-
122
- ### Messaging
123
- - `send_message`: Send a message to a teammate or lead.
124
- - `broadcast_message`: Send a message to the entire team.
125
- - `read_inbox`: Read incoming messages for an agent.
126
-
127
- ### Task Planning & Approval
128
- - `task_submit_plan`: For teammates to submit their implementation plans.
129
- - `task_evaluate_plan`: For the lead to approve or reject a plan (with feedback).
130
-
131
- ---
132
-
133
- ## 🤖 Automated Behavior
85
+ ## 📚 Learn More
134
86
 
135
- - **Initial Greeting**: When a teammate is spawned, they will automatically send a message saying they've started and are checking their inbox.
136
- - **Idle Polling**: Teammates check for new messages every 30 seconds if they are idle.
137
- - **Automated Hooks**: If `.pi/team-hooks/task_completed.sh` exists, it will automatically execute whenever a task status is changed to `completed`.
138
- - **Context Injection**: Each teammate is given a custom system prompt that defines their role and instructions for the team environment.
139
-
140
- ---
141
-
142
- ## 📂 Configuration & Data
143
-
144
- All team and task data is stored in your home directory:
145
- `~/.pi/teams/` and `~/.pi/tasks/`
146
-
147
- You can manually inspect these JSON files if you ever need to debug your team's configuration or message history.
148
-
149
- ---
87
+ - **[Full Usage Guide](docs/guide.md)** - Detailed examples, hook system, best practices, and troubleshooting
88
+ - **[Tool Reference](docs/reference.md)** - Complete documentation of all tools and parameters
150
89
 
151
90
  ## 🪟 Terminal Requirements: tmux, Zellij, or iTerm2
152
91
 
@@ -154,32 +93,29 @@ To show multiple agents on one screen, **pi-teams** requires a way to manage ter
154
93
 
155
94
  ### Option 1: tmux (Recommended)
156
95
 
157
- #### 1. Install tmux
96
+ Install tmux:
158
97
  - **macOS**: `brew install tmux`
159
98
  - **Linux**: `sudo apt install tmux`
160
99
 
161
- #### 2. How to run it
162
- Before you start a team, you **must** be inside a tmux session. Simply type:
100
+ How to run:
163
101
  ```bash
164
- tmux
102
+ tmux # Start tmux session
103
+ pi # Start pi inside tmux
165
104
  ```
166
- Then start `pi` inside that window.
167
105
 
168
106
  ### Option 2: Zellij
169
107
 
170
- If you prefer **Zellij**, simply start `pi` inside a Zellij session. **pi-teams** will detect it via the `ZELLIJ` environment variable and use `zellij run` to spawn teammates in new panes.
108
+ Simply start `pi` inside a Zellij session. **pi-teams** will detect it via the `ZELLIJ` environment variable and use `zellij run` to spawn teammates in new panes.
171
109
 
172
110
  ### Option 3: iTerm2 (macOS)
173
111
 
174
112
  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.
175
113
 
176
- ---
177
-
178
114
  ## 📜 Credits & Attribution
179
115
 
180
- This project is a port of the excellent [claude-code-teams-mcp](https://github.com/cs50victor/claude-code-teams-mcp) by [cs50victor](https://github.com/cs50victor).
116
+ This project is a port of the excellent [claude-code-teams-mcp](https://github.com/cs50victor/claude-code-teams-mcp) by [cs50victor](https://github.com/cs50victor).
181
117
 
182
- We have adapted the original MCP coordination protocol to work natively as a **Pi Package**, adding features like auto-starting teammates, balanced vertical UI layouts, and automatic inbox polling to make the experience seamless for Pi users.
118
+ We have adapted the original MCP coordination protocol to work natively as a **Pi Package**, adding features like auto-starting teammates, balanced vertical UI layouts, automatic inbox polling, plan approval mode, broadcast messaging, and quality gate hooks.
183
119
 
184
120
  ## 📄 License
185
121
  MIT
@@ -6,7 +6,8 @@ import * as teams from "../src/utils/teams";
6
6
  import * as tasks from "../src/utils/tasks";
7
7
  import * as messaging from "../src/utils/messaging";
8
8
  import { Member } from "../src/utils/models";
9
- import { execSync, spawnSync } from "node:child_process";
9
+ import { getTerminalAdapter } from "../src/adapters/terminal-registry";
10
+ import { Iterm2Adapter } from "../src/adapters/iterm2-adapter";
10
11
  import path from "node:path";
11
12
  import fs from "node:fs";
12
13
 
@@ -15,6 +16,9 @@ export default function (pi: ExtensionAPI) {
15
16
  const agentName = process.env.PI_AGENT_NAME || "team-lead";
16
17
  const teamName = process.env.PI_TEAM_NAME;
17
18
 
19
+ // Get the terminal adapter once at startup
20
+ const terminal = getTerminalAdapter();
21
+
18
22
  pi.on("session_start", async (_event, ctx) => {
19
23
  paths.ensureDirs();
20
24
  if (isTeammate) {
@@ -26,15 +30,9 @@ export default function (pi: ExtensionAPI) {
26
30
  // Use a shorter, more prominent status at the beginning if possible
27
31
  ctx.ui.setStatus("00-pi-teams", `[${agentName.toUpperCase()}]`);
28
32
 
29
- // Also set the tmux pane title for better visibility
30
- try {
31
- if (process.env.TMUX) {
32
- spawnSync("tmux", ["select-pane", "-T", agentName]);
33
- } else if (process.env.TERM_PROGRAM === "iTerm.app") {
34
- spawnSync("osascript", ["-e", `tell application "iTerm2" to tell current session of current window to set name to "${agentName}"`]);
35
- }
36
- } catch (e) {
37
- // ignore
33
+ // Set the terminal pane title for better visibility
34
+ if (terminal) {
35
+ terminal.setTitle(agentName);
38
36
  }
39
37
 
40
38
  // Auto-trigger the first turn for teammates
@@ -99,32 +97,8 @@ export default function (pi: ExtensionAPI) {
99
97
  }
100
98
  }
101
99
 
102
- if (member.tmuxPaneId) {
103
- try {
104
- if (member.tmuxPaneId.startsWith("iterm_")) {
105
- const itermId = member.tmuxPaneId.replace("iterm_", "");
106
- const script = `tell application "iTerm2"
107
- repeat with aWindow in windows
108
- repeat with aTab in tabs of aWindow
109
- repeat with aSession in sessions of aTab
110
- if id of aSession is "${itermId}" then
111
- close aSession
112
- return "Closed"
113
- end if
114
- end repeat
115
- end repeat
116
- end repeat
117
- end tell`;
118
- spawnSync("osascript", ["-e", script]);
119
- } else if (member.tmuxPaneId.startsWith("zellij_")) {
120
- // Zellij is expected to close on process exit (using --close-on-exit)
121
- } else {
122
- // Use -t with the pane_id
123
- spawnSync("tmux", ["kill-pane", "-t", member.tmuxPaneId.trim()]);
124
- }
125
- } catch (e) {
126
- // ignore
127
- }
100
+ if (member.tmuxPaneId && terminal) {
101
+ terminal.kill(member.tmuxPaneId);
128
102
  }
129
103
  }
130
104
 
@@ -150,7 +124,7 @@ end tell`;
150
124
  pi.registerTool({
151
125
  name: "spawn_teammate",
152
126
  label: "Spawn Teammate",
153
- description: "Spawn a new teammate in a tmux pane.",
127
+ description: "Spawn a new teammate in a terminal pane.",
154
128
  parameters: Type.Object({
155
129
  team_name: Type.String(),
156
130
  name: Type.String(),
@@ -168,8 +142,28 @@ end tell`;
168
142
  throw new Error(`Team ${params.team_name} does not exist`);
169
143
  }
170
144
 
145
+ if (!terminal) {
146
+ throw new Error("No terminal adapter detected. Ensure you're running in tmux, iTerm2, or Zellij.");
147
+ }
148
+
171
149
  const teamConfig = await teams.readConfig(safeTeamName);
172
- const chosenModel = params.model || teamConfig.defaultModel;
150
+ let chosenModel = params.model || teamConfig.defaultModel;
151
+
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}`;
164
+ }
165
+ }
166
+ }
173
167
 
174
168
  const member: Member = {
175
169
  agentId: `${safeName}@${safeTeamName}`,
@@ -194,25 +188,13 @@ end tell`;
194
188
 
195
189
  // Build model command with thinking level if specified
196
190
  if (chosenModel) {
197
- // If model doesn't include provider prefix (provider/model), use the team's defaultModel or fallback
198
- let modelWithProvider = chosenModel;
199
- if (!chosenModel.includes('/')) {
200
- // Check if team has a defaultModel with a provider prefix
201
- if (teamConfig.defaultModel && teamConfig.defaultModel.includes('/')) {
202
- const [provider] = teamConfig.defaultModel.split('/');
203
- modelWithProvider = `${provider}/${chosenModel}`;
204
- } else {
205
- // Use zai as default provider for glm models (matching user's pi settings)
206
- if (chosenModel.startsWith('glm-')) {
207
- modelWithProvider = `zai/${chosenModel}`;
208
- }
209
- }
210
- }
191
+ const [provider, ...modelParts] = chosenModel.split('/');
192
+ const modelName = modelParts.join('/');
211
193
 
212
194
  if (params.thinking) {
213
- piCmd = `${piBinary} --model ${modelWithProvider}:${params.thinking}`;
195
+ piCmd = `${piBinary} --provider ${provider} --model ${modelName}:${params.thinking}`;
214
196
  } else {
215
- piCmd = `${piBinary} --model ${modelWithProvider}`;
197
+ piCmd = `${piBinary} --provider ${provider} --model ${modelName}`;
216
198
  }
217
199
  } else if (params.thinking) {
218
200
  piCmd = `${piBinary} --thinking ${params.thinking}`;
@@ -224,87 +206,28 @@ end tell`;
224
206
  PI_AGENT_NAME: safeName,
225
207
  };
226
208
 
227
- let paneId = "";
228
- try {
229
- if (process.env.ZELLIJ && !process.env.TMUX) {
230
- const zellijArgs = [
231
- "run",
232
- "--name", safeName,
233
- "--cwd", params.cwd,
234
- "--close-on-exit",
235
- "--",
236
- "env", ...Object.entries(env).filter(([k]) => k.startsWith("PI_")).map(([k, v]) => `${k}=${v}`),
237
- "sh", "-c", piCmd
238
- ];
239
- spawnSync("zellij", zellijArgs);
240
- paneId = `zellij_${safeName}`;
241
- } else if (process.env.TERM_PROGRAM === "iTerm.app" && !process.env.TMUX && !process.env.ZELLIJ) {
242
- const envStr = Object.entries(env)
243
- .filter(([k]) => k.startsWith("PI_"))
244
- .map(([k, v]) => `${k}=${v}`)
245
- .join(" ");
246
- const itermCmd = `cd '${params.cwd}' && ${envStr} ${piCmd}`;
247
- const teammates = teamConfig.members.filter(m => m.agentType === "teammate" && m.tmuxPaneId.startsWith("iterm_"));
248
- const lastTeammate = teammates.length > 0 ? teammates[teammates.length - 1] : null;
249
-
250
- let script = "";
251
- if (!lastTeammate) {
252
- // First teammate: split current session vertically (side-by-side)
253
- script = `tell application "iTerm2"
254
- tell current session of current window
255
- set newSession to split vertically with default profile
256
- tell newSession
257
- write text "${itermCmd.replace(/"/g, '\\"')}"
258
- return id
259
- end tell
260
- end tell
261
- end tell`;
262
- } else {
263
- // Subsequent teammate: split the last teammate's session horizontally (stacking them)
264
- const lastSessionId = lastTeammate.tmuxPaneId.replace("iterm_", "");
265
- script = `tell application "iTerm2"
266
- repeat with aWindow in windows
267
- repeat with aTab in tabs of aWindow
268
- repeat with aSession in sessions of aTab
269
- if id of aSession is "${lastSessionId}" then
270
- tell aSession
271
- set newSession to split horizontally with default profile
272
- tell newSession
273
- write text "${itermCmd.replace(/"/g, '\\"')}"
274
- return id
275
- end tell
276
- end tell
277
- end if
278
- end repeat
279
- end repeat
280
- end repeat
281
- end tell`;
282
- }
283
- const result = spawnSync("osascript", ["-e", script]);
284
- if (result.status !== 0) throw new Error(`osascript failed with status ${result.status}: ${result.stderr.toString()}`);
285
- paneId = `iterm_${result.stdout.toString().trim()}`;
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_", "") });
286
215
  } else {
287
- const envArgs = Object.entries(env)
288
- .filter(([k]) => k.startsWith("PI_"))
289
- .map(([k, v]) => `${k}=${v}`);
290
- const tmuxArgs = [
291
- "split-window",
292
- "-h", "-dP",
293
- "-F", "#{pane_id}",
294
- "-c", params.cwd,
295
- "env", ...envArgs,
296
- "sh", "-c", piCmd
297
- ];
298
- const result = spawnSync("tmux", tmuxArgs);
299
- if (result.status !== 0) throw new Error(`tmux failed with status ${result.status}: ${result.stderr.toString()}`);
300
- paneId = result.stdout.toString().trim();
301
- spawnSync("tmux", ["set-window-option", "main-pane-width", "60%"]);
302
- spawnSync("tmux", ["select-layout", "main-vertical"]);
216
+ terminal.setSpawnContext({});
303
217
  }
304
- } catch (e) {
305
- throw new Error(`Failed to spawn ${process.env.ZELLIJ && !process.env.TMUX ? "zellij" : (process.env.TERM_PROGRAM === "iTerm.app" ? "iTerm2" : "tmux")} pane: ${e}`);
306
218
  }
307
219
 
220
+ let paneId = "";
221
+ try {
222
+ paneId = terminal.spawn({
223
+ name: safeName,
224
+ cwd: params.cwd,
225
+ command: piCmd,
226
+ env: env,
227
+ });
228
+ } catch (e) {
229
+ throw new Error(`Failed to spawn ${terminal.name} pane: ${e}`);
230
+ }
308
231
 
309
232
  // Update member with paneId
310
233
  await teams.updateMember(params.team_name, params.name, { tmuxPaneId: paneId });
@@ -533,7 +456,7 @@ end tell`;
533
456
  pi.registerTool({
534
457
  name: "force_kill_teammate",
535
458
  label: "Force Kill Teammate",
536
- description: "Forcibly kill a teammate's tmux target.",
459
+ description: "Forcibly kill a teammate's terminal pane.",
537
460
  parameters: Type.Object({
538
461
  team_name: Type.String(),
539
462
  agent_name: Type.String(),
@@ -567,33 +490,8 @@ end tell`;
567
490
  if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
568
491
 
569
492
  let alive = false;
570
- if (member.tmuxPaneId) {
571
- try {
572
- if (member.tmuxPaneId.startsWith("zellij_")) {
573
- // Assume alive if it's zellij for now
574
- alive = true;
575
- } else if (member.tmuxPaneId.startsWith("iterm_")) {
576
- const itermId = member.tmuxPaneId.replace("iterm_", "");
577
- const script = `tell application "iTerm2"
578
- repeat with aWindow in windows
579
- repeat with aTab in tabs of aWindow
580
- repeat with aSession in sessions of aTab
581
- if id of aSession is "${itermId}" then
582
- return "Alive"
583
- end if
584
- end repeat
585
- end repeat
586
- end repeat
587
- end tell`;
588
- const result = spawnSync("osascript", ["-e", script]);
589
- alive = result.stdout.toString().includes("Alive");
590
- } else {
591
- execSync(`tmux has-session -t ${member.tmuxPaneId}`);
592
- alive = true;
593
- }
594
- } catch (e) {
595
- alive = false;
596
- }
493
+ if (member.tmuxPaneId && terminal) {
494
+ alive = terminal.isAlive(member.tmuxPaneId);
597
495
  }
598
496
 
599
497
  const unreadCount = (await messaging.readInbox(params.team_name, params.agent_name, true, false)).length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": "github:burggraf/pi-teams",
6
6
  "author": "Mark Burggraf",
@@ -0,0 +1,158 @@
1
+ /**
2
+ * iTerm2 Terminal Adapter
3
+ *
4
+ * Implements the TerminalAdapter interface for iTerm2 terminal emulator.
5
+ * Uses AppleScript for all operations.
6
+ */
7
+
8
+ import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
9
+
10
+ /**
11
+ * Context needed for iTerm2 spawning (tracks last pane for layout)
12
+ */
13
+ export interface Iterm2SpawnContext {
14
+ /** ID of the last spawned session, used for layout decisions */
15
+ lastSessionId?: string;
16
+ }
17
+
18
+ export class Iterm2Adapter implements TerminalAdapter {
19
+ readonly name = "iTerm2";
20
+ private spawnContext: Iterm2SpawnContext = {};
21
+
22
+ detect(): boolean {
23
+ // iTerm2 is available if TERM_PROGRAM is iTerm.app and not in tmux/zellij
24
+ return process.env.TERM_PROGRAM === "iTerm.app" && !process.env.TMUX && !process.env.ZELLIJ;
25
+ }
26
+
27
+ spawn(options: SpawnOptions): string {
28
+ const envStr = Object.entries(options.env)
29
+ .filter(([k]) => k.startsWith("PI_"))
30
+ .map(([k, v]) => `${k}=${v}`)
31
+ .join(" ");
32
+
33
+ const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
34
+ const escapedCmd = itermCmd.replace(/"/g, '\\"');
35
+
36
+ let script: string;
37
+
38
+ if (!this.spawnContext.lastSessionId) {
39
+ // First teammate: split current session vertically (side-by-side)
40
+ script = `tell application "iTerm2"
41
+ tell current session of current window
42
+ set newSession to split vertically with default profile
43
+ tell newSession
44
+ write text "${escapedCmd}"
45
+ return id
46
+ end tell
47
+ end tell
48
+ end tell`;
49
+ } else {
50
+ // Subsequent teammate: split the last teammate's session horizontally (stacking)
51
+ script = `tell application "iTerm2"
52
+ repeat with aWindow in windows
53
+ repeat with aTab in tabs of aWindow
54
+ repeat with aSession in sessions of aTab
55
+ if id of aSession is "${this.spawnContext.lastSessionId}" then
56
+ tell aSession
57
+ set newSession to split horizontally with default profile
58
+ tell newSession
59
+ write text "${escapedCmd}"
60
+ return id
61
+ end tell
62
+ end tell
63
+ end if
64
+ end repeat
65
+ end repeat
66
+ end repeat
67
+ end tell`;
68
+ }
69
+
70
+ const result = execCommand("osascript", ["-e", script]);
71
+
72
+ if (result.status !== 0) {
73
+ throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`);
74
+ }
75
+
76
+ const sessionId = result.stdout.toString().trim();
77
+ this.spawnContext.lastSessionId = sessionId;
78
+
79
+ return `iterm_${sessionId}`;
80
+ }
81
+
82
+ kill(paneId: string): void {
83
+ if (!paneId || !paneId.startsWith("iterm_")) {
84
+ return; // Not an iTerm2 pane
85
+ }
86
+
87
+ const itermId = paneId.replace("iterm_", "");
88
+ const script = `tell application "iTerm2"
89
+ repeat with aWindow in windows
90
+ repeat with aTab in tabs of aWindow
91
+ repeat with aSession in sessions of aTab
92
+ if id of aSession is "${itermId}" then
93
+ close aSession
94
+ return "Closed"
95
+ end if
96
+ end repeat
97
+ end repeat
98
+ end repeat
99
+ end tell`;
100
+
101
+ try {
102
+ execCommand("osascript", ["-e", script]);
103
+ } catch {
104
+ // Ignore errors - session may already be closed
105
+ }
106
+ }
107
+
108
+ isAlive(paneId: string): boolean {
109
+ if (!paneId || !paneId.startsWith("iterm_")) {
110
+ return false; // Not an iTerm2 pane
111
+ }
112
+
113
+ const itermId = paneId.replace("iterm_", "");
114
+ const script = `tell application "iTerm2"
115
+ repeat with aWindow in windows
116
+ repeat with aTab in tabs of aWindow
117
+ repeat with aSession in sessions of aTab
118
+ if id of aSession is "${itermId}" then
119
+ return "Alive"
120
+ end if
121
+ end repeat
122
+ end repeat
123
+ end repeat
124
+ end tell`;
125
+
126
+ try {
127
+ const result = execCommand("osascript", ["-e", script]);
128
+ return result.stdout.includes("Alive");
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ setTitle(title: string): void {
135
+ try {
136
+ execCommand("osascript", [
137
+ "-e",
138
+ `tell application "iTerm2" to tell current session of current window to set name to "${title}"`
139
+ ]);
140
+ } catch {
141
+ // Ignore errors
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Set the spawn context (used to restore state when needed)
147
+ */
148
+ setSpawnContext(context: Iterm2SpawnContext): void {
149
+ this.spawnContext = context;
150
+ }
151
+
152
+ /**
153
+ * Get current spawn context (useful for persisting state)
154
+ */
155
+ getSpawnContext(): Iterm2SpawnContext {
156
+ return { ...this.spawnContext };
157
+ }
158
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Terminal Registry
3
+ *
4
+ * Manages terminal adapters and provides automatic selection based on
5
+ * the current environment.
6
+ */
7
+
8
+ import { TerminalAdapter } from "../utils/terminal-adapter";
9
+ import { TmuxAdapter } from "./tmux-adapter";
10
+ import { Iterm2Adapter } from "./iterm2-adapter";
11
+ import { ZellijAdapter } from "./zellij-adapter";
12
+
13
+ /**
14
+ * Available terminal adapters, ordered by priority
15
+ */
16
+ const adapters: TerminalAdapter[] = [
17
+ new TmuxAdapter(),
18
+ new Iterm2Adapter(),
19
+ new ZellijAdapter(),
20
+ ];
21
+
22
+ /**
23
+ * Cached detected adapter
24
+ */
25
+ let cachedAdapter: TerminalAdapter | null = null;
26
+
27
+ /**
28
+ * Detect and return the appropriate terminal adapter for the current environment.
29
+ *
30
+ * Detection order (first match wins):
31
+ * 1. tmux - if TMUX env is set
32
+ * 2. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
33
+ * 3. Zellij - if ZELLIJ env is set and not in tmux
34
+ *
35
+ * @returns The detected terminal adapter, or null if none detected
36
+ */
37
+ export function getTerminalAdapter(): TerminalAdapter | null {
38
+ if (cachedAdapter) {
39
+ return cachedAdapter;
40
+ }
41
+
42
+ for (const adapter of adapters) {
43
+ if (adapter.detect()) {
44
+ cachedAdapter = adapter;
45
+ return adapter;
46
+ }
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Get a specific terminal adapter by name.
54
+ *
55
+ * @param name - The adapter name (e.g., "tmux", "iTerm2", "zellij")
56
+ * @returns The adapter instance, or undefined if not found
57
+ */
58
+ export function getAdapterByName(name: string): TerminalAdapter | undefined {
59
+ return adapters.find(a => a.name === name);
60
+ }
61
+
62
+ /**
63
+ * Get all available adapters.
64
+ *
65
+ * @returns Array of all registered adapters
66
+ */
67
+ export function getAllAdapters(): TerminalAdapter[] {
68
+ return [...adapters];
69
+ }
70
+
71
+ /**
72
+ * Clear the cached adapter (useful for testing or environment changes)
73
+ */
74
+ export function clearAdapterCache(): void {
75
+ cachedAdapter = null;
76
+ }
77
+
78
+ /**
79
+ * Set a specific adapter (useful for testing or forced selection)
80
+ */
81
+ export function setAdapter(adapter: TerminalAdapter): void {
82
+ cachedAdapter = adapter;
83
+ }
84
+
85
+ /**
86
+ * Check if any terminal adapter is available.
87
+ *
88
+ * @returns true if a terminal adapter was detected
89
+ */
90
+ export function hasTerminalAdapter(): boolean {
91
+ return getTerminalAdapter() !== null;
92
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Tmux Terminal Adapter
3
+ *
4
+ * Implements the TerminalAdapter interface for tmux terminal multiplexer.
5
+ */
6
+
7
+ import { execSync } from "node:child_process";
8
+ import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
9
+
10
+ export class TmuxAdapter implements TerminalAdapter {
11
+ readonly name = "tmux";
12
+
13
+ detect(): boolean {
14
+ // tmux is available if TMUX environment variable is set
15
+ return !!process.env.TMUX;
16
+ }
17
+
18
+ spawn(options: SpawnOptions): string {
19
+ const envArgs = Object.entries(options.env)
20
+ .filter(([k]) => k.startsWith("PI_"))
21
+ .map(([k, v]) => `${k}=${v}`);
22
+
23
+ const tmuxArgs = [
24
+ "split-window",
25
+ "-h", "-dP",
26
+ "-F", "#{pane_id}",
27
+ "-c", options.cwd,
28
+ "env", ...envArgs,
29
+ "sh", "-c", options.command
30
+ ];
31
+
32
+ const result = execCommand("tmux", tmuxArgs);
33
+
34
+ if (result.status !== 0) {
35
+ throw new Error(`tmux spawn failed with status ${result.status}: ${result.stderr}`);
36
+ }
37
+
38
+ // Apply layout after spawning
39
+ execCommand("tmux", ["set-window-option", "main-pane-width", "60%"]);
40
+ execCommand("tmux", ["select-layout", "main-vertical"]);
41
+
42
+ return result.stdout.trim();
43
+ }
44
+
45
+ kill(paneId: string): void {
46
+ if (!paneId || paneId.startsWith("iterm_") || paneId.startsWith("zellij_")) {
47
+ return; // Not a tmux pane
48
+ }
49
+
50
+ try {
51
+ execCommand("tmux", ["kill-pane", "-t", paneId.trim()]);
52
+ } catch {
53
+ // Ignore errors - pane may already be dead
54
+ }
55
+ }
56
+
57
+ isAlive(paneId: string): boolean {
58
+ if (!paneId || paneId.startsWith("iterm_") || paneId.startsWith("zellij_")) {
59
+ return false; // Not a tmux pane
60
+ }
61
+
62
+ try {
63
+ execSync(`tmux has-session -t ${paneId}`);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ setTitle(title: string): void {
71
+ try {
72
+ execCommand("tmux", ["select-pane", "-T", title]);
73
+ } catch {
74
+ // Ignore errors
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Zellij Terminal Adapter
3
+ *
4
+ * Implements the TerminalAdapter interface for Zellij terminal multiplexer.
5
+ * Note: Zellij uses --close-on-exit, so explicit kill is not needed.
6
+ */
7
+
8
+ import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
9
+
10
+ export class ZellijAdapter implements TerminalAdapter {
11
+ readonly name = "zellij";
12
+
13
+ detect(): boolean {
14
+ // Zellij is available if ZELLIJ env is set and not in tmux
15
+ return !!process.env.ZELLIJ && !process.env.TMUX;
16
+ }
17
+
18
+ spawn(options: SpawnOptions): string {
19
+ const zellijArgs = [
20
+ "run",
21
+ "--name", options.name,
22
+ "--cwd", options.cwd,
23
+ "--close-on-exit",
24
+ "--",
25
+ "env",
26
+ ...Object.entries(options.env)
27
+ .filter(([k]) => k.startsWith("PI_"))
28
+ .map(([k, v]) => `${k}=${v}`),
29
+ "sh", "-c", options.command
30
+ ];
31
+
32
+ const result = execCommand("zellij", zellijArgs);
33
+
34
+ if (result.status !== 0) {
35
+ throw new Error(`zellij spawn failed with status ${result.status}: ${result.stderr}`);
36
+ }
37
+
38
+ // Zellij doesn't return a pane ID, so we create a synthetic one
39
+ return `zellij_${options.name}`;
40
+ }
41
+
42
+ kill(_paneId: string): void {
43
+ // Zellij uses --close-on-exit, so panes close automatically
44
+ // when the process exits. No explicit kill needed.
45
+ }
46
+
47
+ isAlive(paneId: string): boolean {
48
+ // Zellij doesn't have a straightforward way to check if a pane is alive
49
+ // For now, we assume alive if it's a zellij pane ID
50
+ if (!paneId || !paneId.startsWith("zellij_")) {
51
+ return false;
52
+ }
53
+
54
+ // Could potentially use `zellij list-sessions` or similar in the future
55
+ return true;
56
+ }
57
+
58
+ setTitle(_title: string): void {
59
+ // Zellij pane titles are set via --name at spawn time
60
+ // No runtime title changing supported
61
+ }
62
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Terminal Adapter Interface
3
+ *
4
+ * Abstracts terminal multiplexer operations (tmux, iTerm2, Zellij)
5
+ * to provide a unified API for spawning, managing, and terminating panes.
6
+ */
7
+
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ /**
11
+ * Options for spawning a new terminal pane
12
+ */
13
+ export interface SpawnOptions {
14
+ /** Name/identifier for the pane */
15
+ name: string;
16
+ /** Working directory for the new pane */
17
+ cwd: string;
18
+ /** Command to execute in the pane */
19
+ command: string;
20
+ /** Environment variables to set (key-value pairs) */
21
+ env: Record<string, string>;
22
+ }
23
+
24
+ /**
25
+ * Terminal Adapter Interface
26
+ *
27
+ * Implementations provide terminal-specific logic for pane management.
28
+ */
29
+ export interface TerminalAdapter {
30
+ /** Unique name identifier for this terminal type */
31
+ readonly name: string;
32
+
33
+ /**
34
+ * Detect if this terminal is currently available/active.
35
+ * Should check for terminal-specific environment variables or processes.
36
+ *
37
+ * @returns true if this terminal should be used
38
+ */
39
+ detect(): boolean;
40
+
41
+ /**
42
+ * Spawn a new terminal pane with the given options.
43
+ *
44
+ * @param options - Spawn configuration
45
+ * @returns Pane ID that can be used for subsequent operations
46
+ * @throws Error if spawn fails
47
+ */
48
+ spawn(options: SpawnOptions): string;
49
+
50
+ /**
51
+ * Kill/terminate a terminal pane.
52
+ * Should be idempotent - no error if pane doesn't exist.
53
+ *
54
+ * @param paneId - The pane ID returned from spawn()
55
+ */
56
+ kill(paneId: string): void;
57
+
58
+ /**
59
+ * Check if a terminal pane is still alive/active.
60
+ *
61
+ * @param paneId - The pane ID returned from spawn()
62
+ * @returns true if pane exists and is active
63
+ */
64
+ isAlive(paneId: string): boolean;
65
+
66
+ /**
67
+ * Set the title of the current terminal pane/window.
68
+ * Used for identifying panes in the terminal UI.
69
+ *
70
+ * @param title - The title to set
71
+ */
72
+ setTitle(title: string): void;
73
+ }
74
+
75
+ /**
76
+ * Base helper for adapters to execute commands synchronously.
77
+ */
78
+ export function execCommand(command: string, args: string[]): { stdout: string; stderr: string; status: number | null } {
79
+ const result = spawnSync(command, args, { encoding: "utf-8" });
80
+ return {
81
+ stdout: result.stdout?.toString() ?? "",
82
+ stderr: result.stderr?.toString() ?? "",
83
+ status: result.status,
84
+ };
85
+ }