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 +61 -108
- package/extensions/index.ts +57 -159
- package/package.json +5 -2
- package/src/adapters/iterm2-adapter.ts +158 -0
- package/src/adapters/terminal-registry.ts +101 -0
- package/src/adapters/tmux-adapter.ts +77 -0
- package/src/adapters/wezterm-adapter.test.ts +101 -0
- package/src/adapters/wezterm-adapter.ts +166 -0
- package/src/adapters/zellij-adapter.ts +62 -0
- package/src/utils/terminal-adapter.ts +85 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
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
|
-
**
|
|
64
|
+
**Use a different model:**
|
|
48
65
|
> **You:** "Spawn a teammate named 'speed-bot' using 'haiku' to quickly run some benchmarks."
|
|
49
66
|
|
|
50
|
-
**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
##
|
|
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
|
-
|
|
117
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
Install tmux:
|
|
158
99
|
- **macOS**: `brew install tmux`
|
|
159
100
|
- **Linux**: `sudo apt install tmux`
|
|
160
101
|
|
|
161
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
package/extensions/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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 ${
|
|
195
|
+
piCmd = `${piBinary} --provider ${provider} --model ${modelName}:${params.thinking}`;
|
|
214
196
|
} else {
|
|
215
|
-
piCmd = `${piBinary} --model ${
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Agent teams for pi, ported from claude-code-teams-mcp",
|
|
5
|
-
"repository":
|
|
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
|
+
}
|