pi-teams 0.7.2 → 0.8.2

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, Zellij, iTerm2, or WezTerm.
4
4
 
5
5
  ### 🖥️ pi-teams in Action
6
6
 
@@ -8,6 +8,8 @@
8
8
  | :---: | :---: | :---: |
9
9
  | <a href="iTerm2.png"><img src="iTerm2.png" width="300" alt="pi-teams in iTerm2"></a> | <a href="tmux.png"><img src="tmux.png" width="300" alt="pi-teams in tmux"></a> | <a href="zellij.png"><img src="zellij.png" width="300" alt="pi-teams in Zellij"></a> |
10
10
 
11
+ *Also works with **WezTerm** (cross-platform support)*
12
+
11
13
  ## 🛠 Installation
12
14
 
13
15
  Open your Pi terminal and type:
@@ -16,170 +18,121 @@ Open your Pi terminal and type:
16
18
  pi install npm:pi-teams
17
19
  ```
18
20
 
19
- ---
21
+ ## 🚀 Quick Start
22
+
23
+ ```bash
24
+ # 1. Start a team (inside tmux, Zellij, or iTerm2)
25
+ "Create a team named 'my-team' using 'gpt-4o'"
26
+
27
+ # 2. Spawn teammates
28
+ "Spawn 'security-bot' to scan for vulnerabilities"
29
+ "Spawn 'frontend-dev' using 'haiku' for quick iterations"
30
+
31
+ # 3. Create and assign tasks
32
+ "Create a task for security-bot: 'Audit auth endpoints'"
33
+
34
+ # 4. Review and approve work
35
+ "List all tasks and approve any pending plans"
36
+ ```
20
37
 
21
38
  ## 🌟 What can it do?
22
39
 
40
+ ### Core Features
23
41
  - **Spawn Specialists**: Create agents like "Security Expert" or "Frontend Pro" to handle sub-tasks in parallel.
24
42
  - **Shared Task Board**: Keep everyone on the same page with a persistent list of tasks and their status.
25
43
  - **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
44
  - **Autonomous Work**: Teammates automatically "wake up," read their instructions, and poll their inboxes for new work while idle.
30
45
  - **Beautiful UI**: Optimized vertical splits in `tmux` with clear labels so you always know who is doing what.
31
46
 
32
- ---
33
-
34
- ## 💬 How to use it (Examples)
47
+ ### Advanced Features
48
+ - **Plan Approval Mode**: Require teammates to submit their implementation plans for your approval before they touch any code.
49
+ - **Broadcast Messaging**: Send a message to the entire team at once for global coordination and announcements.
50
+ - **Quality Gate Hooks**: Automated shell scripts run when tasks are completed (e.g., to run tests or linting).
51
+ - **Thinking Level Control**: Set per-teammate thinking levels (`off`, `minimal`, `low`, `medium`, `high`) to balance speed vs. reasoning depth.
35
52
 
36
- You don't need to learn complex code commands. Just talk to Pi in plain English!
53
+ ## 💬 Key Examples
37
54
 
38
55
  ### 1. Start a Team
39
56
  > **You:** "Create a team named 'my-app-audit' for reviewing the codebase."
40
57
 
41
- **Pro Tip:** You can also set a default model for the whole team!
58
+ **Set a default model for the whole team:**
42
59
  > **You:** "Create a team named 'Research' and use 'gpt-4o' for everyone."
43
60
 
44
- ### 2. Spawn a Teammate
61
+ ### 2. Spawn Teammate with Custom Settings
45
62
  > **You:** "Spawn a teammate named 'security-bot' in the current folder. Tell them to scan for hardcoded API keys."
46
63
 
47
- **Pro Tip:** You can specify a different model for a specific teammate!
64
+ **Use a different model:**
48
65
  > **You:** "Spawn a teammate named 'speed-bot' using 'haiku' to quickly run some benchmarks."
49
66
 
50
- **New: Plan Approval Mode**
67
+ **Require plan approval:**
51
68
  > **You:** "Spawn a teammate named 'refactor-bot' and require plan approval before they make any changes."
52
69
 
53
- **Customize Teammate Model & Thinking**
70
+ **Customize model and thinking level:**
54
71
  > **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
72
 
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
73
+ ### 3. Assign Task & Get Approval
70
74
  > **You:** "Create a task for security-bot: 'Check the .env.example file for sensitive defaults' and set it to in_progress."
71
75
 
72
- ### 4. Submit and Evaluate a Plan
73
- Teammates in `planning` mode will use `task_submit_plan`. As the lead, you can then:
76
+ Teammates in `planning` mode will use `task_submit_plan`. As the lead, review their work:
74
77
  > **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
78
 
76
- ### 5. Broadcast a Message
79
+ ### 4. Broadcast to Team
77
80
  > **You:** "Broadcast to the entire team: 'The API endpoint has changed to /v2. Please update your work accordingly.'"
78
81
 
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
82
+ ### 5. Shut Down Team
94
83
  > **You:** "We're done. Shut down the team and close the panes."
95
84
 
96
85
  ---
97
86
 
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.
87
+ ## 📚 Learn More
115
88
 
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.
89
+ - **[Full Usage Guide](docs/guide.md)** - Detailed examples, hook system, best practices, and troubleshooting
90
+ - **[Tool Reference](docs/reference.md)** - Complete documentation of all tools and parameters
121
91
 
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.
92
+ ## 🪟 Terminal Requirements
126
93
 
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
134
-
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
- ---
150
-
151
- ## 🪟 Terminal Requirements: tmux, Zellij, or iTerm2
152
-
153
- To show multiple agents on one screen, **pi-teams** requires a way to manage terminal panes. It supports **tmux**, **Zellij**, and **iTerm2** (macOS).
94
+ To show multiple agents on one screen, **pi-teams** requires a way to manage terminal panes. It supports **tmux**, **Zellij**, **iTerm2**, and **WezTerm**.
154
95
 
155
96
  ### Option 1: tmux (Recommended)
156
97
 
157
- #### 1. Install tmux
98
+ Install tmux:
158
99
  - **macOS**: `brew install tmux`
159
100
  - **Linux**: `sudo apt install tmux`
160
101
 
161
- #### 2. How to run it
162
- Before you start a team, you **must** be inside a tmux session. Simply type:
102
+ How to run:
163
103
  ```bash
164
- tmux
104
+ tmux # Start tmux session
105
+ pi # Start pi inside tmux
165
106
  ```
166
- Then start `pi` inside that window.
167
107
 
168
108
  ### Option 2: Zellij
169
109
 
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.
110
+ 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
111
 
172
112
  ### Option 3: iTerm2 (macOS)
173
113
 
174
114
  If you are using **iTerm2** on macOS and are *not* inside tmux or Zellij, **pi-teams** will use AppleScript to automatically split your current window into an optimized layout (1 large Lead pane on the left, Teammates stacked on the right). It will also name the panes with the teammate's agent name for easy identification.
175
115
 
176
- ---
116
+ ### Option 4: WezTerm (macOS, Linux, Windows)
117
+
118
+ **WezTerm** is a GPU-accelerated, cross-platform terminal emulator written in Rust. If you are using WezTerm and are *not* inside tmux or Zellij, **pi-teams** will use `wezterm cli split-pane` to spawn teammates in new panes with an optimized layout (1 large Lead pane on the left, Teammates stacked on the right).
119
+
120
+ Install WezTerm:
121
+ - **macOS**: `brew install --cask wezterm`
122
+ - **Linux**: See [wezterm.org/installation](https://wezterm.org/installation)
123
+ - **Windows**: Download from [wezterm.org](https://wezterm.org)
124
+
125
+ How to run:
126
+ ```bash
127
+ wezterm # Start WezTerm
128
+ pi # Start pi inside WezTerm
129
+ ```
177
130
 
178
131
  ## 📜 Credits & Attribution
179
132
 
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).
133
+ 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
134
 
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.
135
+ 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
136
 
184
137
  ## 📄 License
185
138
  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,8 +1,11 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.7.2",
3
+ "version": "0.8.2",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
- "repository": "github:burggraf/pi-teams",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/burggraf/pi-teams.git"
8
+ },
6
9
  "author": "Mark Burggraf",
7
10
  "license": "MIT",
8
11
  "keywords": [
@@ -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,101 @@
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
+ import { WezTermAdapter } from "./wezterm-adapter";
13
+
14
+ /**
15
+ * Available terminal adapters, ordered by priority
16
+ *
17
+ * Detection order (first match wins):
18
+ * 1. tmux - if TMUX env is set
19
+ * 2. Zellij - if ZELLIJ env is set and not in tmux
20
+ * 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
21
+ * 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij
22
+ */
23
+ const adapters: TerminalAdapter[] = [
24
+ new TmuxAdapter(),
25
+ new ZellijAdapter(),
26
+ new Iterm2Adapter(),
27
+ new WezTermAdapter(),
28
+ ];
29
+
30
+ /**
31
+ * Cached detected adapter
32
+ */
33
+ let cachedAdapter: TerminalAdapter | null = null;
34
+
35
+ /**
36
+ * Detect and return the appropriate terminal adapter for the current environment.
37
+ *
38
+ * Detection order (first match wins):
39
+ * 1. tmux - if TMUX env is set
40
+ * 2. Zellij - if ZELLIJ env is set and not in tmux
41
+ * 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
42
+ * 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij
43
+ *
44
+ * @returns The detected terminal adapter, or null if none detected
45
+ */
46
+ export function getTerminalAdapter(): TerminalAdapter | null {
47
+ if (cachedAdapter) {
48
+ return cachedAdapter;
49
+ }
50
+
51
+ for (const adapter of adapters) {
52
+ if (adapter.detect()) {
53
+ cachedAdapter = adapter;
54
+ return adapter;
55
+ }
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Get a specific terminal adapter by name.
63
+ *
64
+ * @param name - The adapter name (e.g., "tmux", "iTerm2", "zellij", "WezTerm")
65
+ * @returns The adapter instance, or undefined if not found
66
+ */
67
+ export function getAdapterByName(name: string): TerminalAdapter | undefined {
68
+ return adapters.find(a => a.name === name);
69
+ }
70
+
71
+ /**
72
+ * Get all available adapters.
73
+ *
74
+ * @returns Array of all registered adapters
75
+ */
76
+ export function getAllAdapters(): TerminalAdapter[] {
77
+ return [...adapters];
78
+ }
79
+
80
+ /**
81
+ * Clear the cached adapter (useful for testing or environment changes)
82
+ */
83
+ export function clearAdapterCache(): void {
84
+ cachedAdapter = null;
85
+ }
86
+
87
+ /**
88
+ * Set a specific adapter (useful for testing or forced selection)
89
+ */
90
+ export function setAdapter(adapter: TerminalAdapter): void {
91
+ cachedAdapter = adapter;
92
+ }
93
+
94
+ /**
95
+ * Check if any terminal adapter is available.
96
+ *
97
+ * @returns true if a terminal adapter was detected
98
+ */
99
+ export function hasTerminalAdapter(): boolean {
100
+ return getTerminalAdapter() !== null;
101
+ }
@@ -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,101 @@
1
+ /**
2
+ * WezTerm Adapter Tests
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import { WezTermAdapter } from "./wezterm-adapter";
7
+ import * as terminalAdapter from "../utils/terminal-adapter";
8
+
9
+ describe("WezTermAdapter", () => {
10
+ let adapter: WezTermAdapter;
11
+ let mockExecCommand: ReturnType<typeof vi.spyOn>;
12
+
13
+ beforeEach(() => {
14
+ adapter = new WezTermAdapter();
15
+ mockExecCommand = vi.spyOn(terminalAdapter, "execCommand");
16
+ delete process.env.WEZTERM_PANE;
17
+ delete process.env.TMUX;
18
+ delete process.env.ZELLIJ;
19
+ process.env.WEZTERM_PANE = "0";
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ describe("name", () => {
27
+ it("should have the correct name", () => {
28
+ expect(adapter.name).toBe("WezTerm");
29
+ });
30
+ });
31
+
32
+ describe("detect", () => {
33
+ it("should detect when WEZTERM_PANE is set", () => {
34
+ mockExecCommand.mockReturnValue({ stdout: "version 1.0", stderr: "", status: 0 });
35
+ expect(adapter.detect()).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("spawn", () => {
40
+ it("should spawn first pane to the right with 50%", () => {
41
+ // Mock getPanes finding only current pane
42
+ mockExecCommand.mockImplementation((bin, args) => {
43
+ if (args.includes("list")) {
44
+ return {
45
+ stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }]),
46
+ stderr: "",
47
+ status: 0
48
+ };
49
+ }
50
+ if (args.includes("split-pane")) {
51
+ return { stdout: "1", stderr: "", status: 0 };
52
+ }
53
+ return { stdout: "", stderr: "", status: 0 };
54
+ });
55
+
56
+ const result = adapter.spawn({
57
+ name: "test-agent",
58
+ cwd: "/home/user/project",
59
+ command: "pi --agent test",
60
+ env: { PI_AGENT_ID: "test-123" },
61
+ });
62
+
63
+ expect(result).toBe("wezterm_1");
64
+ expect(mockExecCommand).toHaveBeenCalledWith(
65
+ expect.stringContaining("wezterm"),
66
+ expect.arrayContaining(["cli", "split-pane", "--right", "--percent", "50"])
67
+ );
68
+ });
69
+
70
+ it("should spawn subsequent panes by splitting the sidebar", () => {
71
+ // Mock getPanes finding current pane (0) and sidebar pane (1)
72
+ mockExecCommand.mockImplementation((bin, args) => {
73
+ if (args.includes("list")) {
74
+ return {
75
+ stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }, { pane_id: 1, tab_id: 0 }]),
76
+ stderr: "",
77
+ status: 0
78
+ };
79
+ }
80
+ if (args.includes("split-pane")) {
81
+ return { stdout: "2", stderr: "", status: 0 };
82
+ }
83
+ return { stdout: "", stderr: "", status: 0 };
84
+ });
85
+
86
+ const result = adapter.spawn({
87
+ name: "agent2",
88
+ cwd: "/home/user/project",
89
+ command: "pi",
90
+ env: {},
91
+ });
92
+
93
+ expect(result).toBe("wezterm_2");
94
+ // 1 sidebar pane already exists, so percent should be floor(100/(1+1)) = 50%
95
+ expect(mockExecCommand).toHaveBeenCalledWith(
96
+ expect.stringContaining("wezterm"),
97
+ expect.arrayContaining(["cli", "split-pane", "--bottom", "--pane-id", "1", "--percent", "50"])
98
+ );
99
+ });
100
+ });
101
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * WezTerm Terminal Adapter
3
+ *
4
+ * Implements the TerminalAdapter interface for WezTerm terminal emulator.
5
+ * Uses wezterm cli split-pane for pane management.
6
+ */
7
+
8
+ import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
9
+
10
+ export class WezTermAdapter implements TerminalAdapter {
11
+ readonly name = "WezTerm";
12
+
13
+ // Common paths where wezterm CLI might be found
14
+ private possiblePaths = [
15
+ "wezterm", // In PATH
16
+ "/Applications/WezTerm.app/Contents/MacOS/wezterm", // macOS
17
+ "/usr/local/bin/wezterm", // Linux/macOS common
18
+ "/usr/bin/wezterm", // Linux system
19
+ ];
20
+
21
+ private weztermPath: string | null = null;
22
+
23
+ private findWeztermBinary(): string | null {
24
+ if (this.weztermPath !== null) {
25
+ return this.weztermPath;
26
+ }
27
+
28
+ for (const path of this.possiblePaths) {
29
+ try {
30
+ const result = execCommand(path, ["--version"]);
31
+ if (result.status === 0) {
32
+ this.weztermPath = path;
33
+ return path;
34
+ }
35
+ } catch {
36
+ // Continue to next path
37
+ }
38
+ }
39
+
40
+ this.weztermPath = null;
41
+ return null;
42
+ }
43
+
44
+ detect(): boolean {
45
+ if (!process.env.WEZTERM_PANE || process.env.TMUX || process.env.ZELLIJ) {
46
+ return false;
47
+ }
48
+ return this.findWeztermBinary() !== null;
49
+ }
50
+
51
+ /**
52
+ * Get all panes in the current tab to determine layout state.
53
+ */
54
+ private getPanes(): any[] {
55
+ const weztermBin = this.findWeztermBinary();
56
+ if (!weztermBin) return [];
57
+
58
+ const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
59
+ if (result.status !== 0) return [];
60
+
61
+ try {
62
+ const allPanes = JSON.parse(result.stdout);
63
+ const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
64
+
65
+ // Find the tab of the current pane
66
+ const currentPane = allPanes.find((p: any) => p.pane_id === currentPaneId);
67
+ if (!currentPane) return [];
68
+
69
+ // Return all panes in the same tab
70
+ return allPanes.filter((p: any) => p.tab_id === currentPane.tab_id);
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ spawn(options: SpawnOptions): string {
77
+ const weztermBin = this.findWeztermBinary();
78
+ if (!weztermBin) {
79
+ throw new Error("WezTerm CLI binary not found.");
80
+ }
81
+
82
+ const panes = this.getPanes();
83
+ const envArgs = Object.entries(options.env)
84
+ .filter(([k]) => k.startsWith("PI_"))
85
+ .map(([k, v]) => `${k}=${v}`);
86
+
87
+ let weztermArgs: string[];
88
+
89
+ // First pane: split to the right with 50% (matches iTerm2/tmux behavior)
90
+ const isFirstPane = panes.length === 1;
91
+
92
+ if (isFirstPane) {
93
+ weztermArgs = [
94
+ "cli", "split-pane", "--right", "--percent", "50",
95
+ "--cwd", options.cwd, "--", "env", ...envArgs, "sh", "-c", options.command
96
+ ];
97
+ } else {
98
+ // Subsequent teammates stack in the sidebar on the right.
99
+ // currentPaneId (id 0) is the main pane on the left.
100
+ // All other panes are in the sidebar.
101
+ const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
102
+ const sidebarPanes = panes
103
+ .filter(p => p.pane_id !== currentPaneId)
104
+ .sort((a, b) => b.cursor_y - a.cursor_y); // Sort by vertical position (bottom-most first)
105
+
106
+ // To add a new pane to the bottom of the sidebar stack:
107
+ // We always split the BOTTOM-MOST pane (sidebarPanes[0])
108
+ // and use 50% so the new pane and the previous bottom pane are equal.
109
+ // This progressively fills the sidebar from top to bottom.
110
+ const targetPane = sidebarPanes[0];
111
+
112
+ weztermArgs = [
113
+ "cli", "split-pane", "--bottom", "--pane-id", targetPane.pane_id.toString(),
114
+ "--percent", "50",
115
+ "--cwd", options.cwd, "--", "env", ...envArgs, "sh", "-c", options.command
116
+ ];
117
+ }
118
+
119
+ const result = execCommand(weztermBin, weztermArgs);
120
+ if (result.status !== 0) {
121
+ throw new Error(`wezterm spawn failed: ${result.stderr}`);
122
+ }
123
+
124
+ // New: After spawning, tell WezTerm to equalize the panes in this tab
125
+ // This ensures that regardless of the split math, they all end up the same height.
126
+ try {
127
+ execCommand(weztermBin, ["cli", "zoom-pane", "--unzoom"]); // Ensure not zoomed
128
+ // WezTerm doesn't have a single "equalize" command like tmux,
129
+ // but splitting with no percentage usually balances, or we can use
130
+ // the 'AdjustPaneSize' sequence.
131
+ // For now, let's stick to the 50/50 split of the LAST pane which is most reliable.
132
+ } catch {}
133
+
134
+ const paneId = result.stdout.trim();
135
+ return `wezterm_${paneId}`;
136
+ }
137
+
138
+ kill(paneId: string): void {
139
+ if (!paneId?.startsWith("wezterm_")) return;
140
+ const weztermBin = this.findWeztermBinary();
141
+ if (!weztermBin) return;
142
+
143
+ const weztermId = paneId.replace("wezterm_", "");
144
+ try {
145
+ execCommand(weztermBin, ["cli", "kill-pane", "--pane-id", weztermId]);
146
+ } catch {}
147
+ }
148
+
149
+ isAlive(paneId: string): boolean {
150
+ if (!paneId?.startsWith("wezterm_")) return false;
151
+ const weztermBin = this.findWeztermBinary();
152
+ if (!weztermBin) return false;
153
+
154
+ const weztermId = parseInt(paneId.replace("wezterm_", ""), 10);
155
+ const panes = this.getPanes();
156
+ return panes.some(p => p.pane_id === weztermId);
157
+ }
158
+
159
+ setTitle(title: string): void {
160
+ const weztermBin = this.findWeztermBinary();
161
+ if (!weztermBin) return;
162
+ try {
163
+ execCommand(weztermBin, ["cli", "set-tab-title", title]);
164
+ } catch {}
165
+ }
166
+ }
@@ -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
+ }