pi-teams 0.8.6 → 0.9.0
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 +35 -3
- package/extensions/index.ts +77 -5
- package/package.json +1 -1
- package/src/adapters/cmux-adapter.ts +191 -0
- package/src/adapters/terminal-registry.ts +12 -8
- package/src/adapters/wezterm-adapter.ts +99 -39
- package/src/adapters/windows-adapter.test.ts +244 -0
- package/src/adapters/windows-adapter.ts +269 -0
- package/src/utils/paths.ts +4 -0
- package/src/utils/runtime.test.ts +171 -0
- package/src/utils/runtime.ts +168 -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 tmux, Zellij, iTerm2, or
|
|
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, WezTerm, or Windows Terminal.
|
|
4
4
|
|
|
5
5
|
### 🖥️ pi-teams in Action
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@
|
|
|
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)*
|
|
11
|
+
*Also works with **WezTerm** and **Windows Terminal** (cross-platform support)*
|
|
12
12
|
|
|
13
13
|
## 🛠 Installation
|
|
14
14
|
|
|
@@ -115,7 +115,7 @@ Teammates in `planning` mode will use `task_submit_plan`. As the lead, review th
|
|
|
115
115
|
|
|
116
116
|
## 🪟 Terminal Requirements
|
|
117
117
|
|
|
118
|
-
To show multiple agents on one screen, **pi-teams** requires a way to manage terminal panes. It supports **tmux**, **Zellij**, **iTerm2**, and **
|
|
118
|
+
To show multiple agents on one screen, **pi-teams** requires a way to manage terminal panes. It supports **tmux**, **Zellij**, **iTerm2**, **WezTerm**, and **Windows Terminal**.
|
|
119
119
|
|
|
120
120
|
### Option 1: tmux (Recommended)
|
|
121
121
|
|
|
@@ -156,6 +156,38 @@ wezterm # Start WezTerm
|
|
|
156
156
|
pi # Start pi inside WezTerm
|
|
157
157
|
```
|
|
158
158
|
|
|
159
|
+
### Option 5: Windows Terminal (Windows)
|
|
160
|
+
|
|
161
|
+
**Windows Terminal** is the modern, feature-rich terminal emulator for Windows 10/11. It supports both **Panes** and **Separate OS Windows**.
|
|
162
|
+
|
|
163
|
+
**Requirements:**
|
|
164
|
+
- Windows 10 (version 19041 or later) or Windows 11
|
|
165
|
+
- Windows Terminal installed (available from Microsoft Store or winget)
|
|
166
|
+
- PowerShell 5.1 or later (pwsh.exe)
|
|
167
|
+
|
|
168
|
+
Install Windows Terminal:
|
|
169
|
+
- **Microsoft Store**: Search for "Windows Terminal" and install
|
|
170
|
+
- **winget**: `winget install Microsoft.WindowsTerminal`
|
|
171
|
+
- **Scoop**: `scoop install windows-terminal`
|
|
172
|
+
|
|
173
|
+
Install PowerShell Core (optional but recommended):
|
|
174
|
+
- **winget**: `winget install Microsoft.PowerShell`
|
|
175
|
+
- **Scoop**: `scoop install powershell`
|
|
176
|
+
|
|
177
|
+
How to run:
|
|
178
|
+
```powershell
|
|
179
|
+
# Open Windows Terminal and start pi
|
|
180
|
+
wt
|
|
181
|
+
pi
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Or start pi directly from Windows Terminal with new window:
|
|
185
|
+
```powershell
|
|
186
|
+
wt -- pwsh -c "pi"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Note:** On Windows, pi-teams uses PowerShell for command execution. Make sure `pi` is in your PATH. If you installed pi via npm and Node.js, verify both are accessible from PowerShell.
|
|
190
|
+
|
|
159
191
|
## 📜 Credits & Attribution
|
|
160
192
|
|
|
161
193
|
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).
|
package/extensions/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import * as paths from "../src/utils/paths";
|
|
|
5
5
|
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
|
+
import * as runtime from "../src/utils/runtime";
|
|
8
9
|
import { Member } from "../src/utils/models";
|
|
9
10
|
import { getTerminalAdapter } from "../src/adapters/terminal-registry";
|
|
10
11
|
import { Iterm2Adapter } from "../src/adapters/iterm2-adapter";
|
|
@@ -154,6 +155,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
154
155
|
if (teamName) {
|
|
155
156
|
const pidFile = path.join(paths.teamDir(teamName), `${agentName}.pid`);
|
|
156
157
|
fs.writeFileSync(pidFile, process.pid.toString());
|
|
158
|
+
await runtime.writeRuntimeStatus(teamName, agentName, {
|
|
159
|
+
pid: process.pid,
|
|
160
|
+
startedAt: Date.now(),
|
|
161
|
+
lastHeartbeatAt: Date.now(),
|
|
162
|
+
ready: false,
|
|
163
|
+
lastError: undefined,
|
|
164
|
+
});
|
|
157
165
|
}
|
|
158
166
|
ctx.ui.notify(`Teammate: ${agentName} (Team: ${teamName})`, "info");
|
|
159
167
|
ctx.ui.setStatus("00-pi-teams", `[${agentName.toUpperCase()}]`);
|
|
@@ -176,9 +184,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
176
184
|
|
|
177
185
|
setInterval(async () => {
|
|
178
186
|
if (ctx.isIdle() && teamName) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
187
|
+
try {
|
|
188
|
+
const unread = await messaging.readInbox(teamName, agentName, true, false);
|
|
189
|
+
await runtime.writeRuntimeStatus(teamName, agentName, {
|
|
190
|
+
lastHeartbeatAt: Date.now(),
|
|
191
|
+
});
|
|
192
|
+
if (unread.length > 0) {
|
|
193
|
+
pi.sendUserMessage(`I have ${unread.length} new message(s) in my inbox. Reading them now...`);
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
await runtime.writeRuntimeStatus(teamName, agentName, {
|
|
197
|
+
lastHeartbeatAt: Date.now(),
|
|
198
|
+
lastError: runtime.createRuntimeError(e),
|
|
199
|
+
});
|
|
182
200
|
}
|
|
183
201
|
}
|
|
184
202
|
}, 30000);
|
|
@@ -192,6 +210,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
192
210
|
const fullTitle = teamName ? `${teamName}: ${agentName}` : agentName;
|
|
193
211
|
if ((ctx.ui as any).setTitle) (ctx.ui as any).setTitle(fullTitle);
|
|
194
212
|
if (terminal) terminal.setTitle(fullTitle);
|
|
213
|
+
if (teamName) {
|
|
214
|
+
await runtime.writeRuntimeStatus(teamName, agentName, {
|
|
215
|
+
lastHeartbeatAt: Date.now(),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
195
218
|
}
|
|
196
219
|
});
|
|
197
220
|
|
|
@@ -200,6 +223,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
223
|
if (isTeammate && firstTurn) {
|
|
201
224
|
firstTurn = false;
|
|
202
225
|
|
|
226
|
+
if (teamName) {
|
|
227
|
+
await runtime.writeRuntimeStatus(teamName, agentName, {
|
|
228
|
+
lastHeartbeatAt: Date.now(),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
203
232
|
let modelInfo = "";
|
|
204
233
|
if (teamName) {
|
|
205
234
|
try {
|
|
@@ -244,6 +273,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
244
273
|
if (member.tmuxPaneId && terminal) {
|
|
245
274
|
terminal.kill(member.tmuxPaneId);
|
|
246
275
|
}
|
|
276
|
+
|
|
277
|
+
await runtime.deleteRuntimeStatus(teamName, member.name);
|
|
247
278
|
}
|
|
248
279
|
|
|
249
280
|
// Tools
|
|
@@ -480,6 +511,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
480
511
|
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
|
|
481
512
|
const targetAgent = params.agent_name || agentName;
|
|
482
513
|
const msgs = await messaging.readInbox(params.team_name, targetAgent, params.unread_only);
|
|
514
|
+
|
|
515
|
+
if (isTeammate && teamName && params.team_name === teamName && targetAgent === agentName) {
|
|
516
|
+
await runtime.writeRuntimeStatus(teamName, agentName, {
|
|
517
|
+
lastHeartbeatAt: Date.now(),
|
|
518
|
+
lastInboxReadAt: Date.now(),
|
|
519
|
+
ready: true,
|
|
520
|
+
lastError: undefined,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
483
524
|
return {
|
|
484
525
|
content: [{ type: "text", text: JSON.stringify(msgs, null, 2) }],
|
|
485
526
|
details: { messages: msgs },
|
|
@@ -643,9 +684,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
643
684
|
}
|
|
644
685
|
|
|
645
686
|
const unreadCount = (await messaging.readInbox(params.team_name, params.agent_name, true, false)).length;
|
|
687
|
+
const runtimeStatus = await runtime.readRuntimeStatus(params.team_name, params.agent_name);
|
|
688
|
+
const now = Date.now();
|
|
689
|
+
const hasRecentHeartbeat = !!runtimeStatus?.lastHeartbeatAt
|
|
690
|
+
&& (now - runtimeStatus.lastHeartbeatAt) <= runtime.HEARTBEAT_STALE_MS;
|
|
691
|
+
const startupStalled = alive
|
|
692
|
+
&& unreadCount > 0
|
|
693
|
+
&& (now - member.joinedAt) > runtime.STARTUP_STALL_MS
|
|
694
|
+
&& !(runtimeStatus?.ready);
|
|
695
|
+
const health = !alive
|
|
696
|
+
? "dead"
|
|
697
|
+
: startupStalled
|
|
698
|
+
? "stalled"
|
|
699
|
+
: runtimeStatus?.ready
|
|
700
|
+
? (hasRecentHeartbeat ? "healthy" : "idle")
|
|
701
|
+
: "starting";
|
|
702
|
+
|
|
703
|
+
const details = {
|
|
704
|
+
alive,
|
|
705
|
+
unreadCount,
|
|
706
|
+
health,
|
|
707
|
+
agentLoopReady: !!runtimeStatus?.ready,
|
|
708
|
+
hasRecentHeartbeat,
|
|
709
|
+
startupStalled,
|
|
710
|
+
runtime: runtimeStatus,
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
// Clean up runtime status for dead teammates
|
|
714
|
+
if (!alive && runtimeStatus) {
|
|
715
|
+
await runtime.deleteRuntimeStatus(params.team_name, params.agent_name);
|
|
716
|
+
}
|
|
717
|
+
|
|
646
718
|
return {
|
|
647
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
648
|
-
details
|
|
719
|
+
content: [{ type: "text", text: JSON.stringify(details, null, 2) }],
|
|
720
|
+
details,
|
|
649
721
|
};
|
|
650
722
|
},
|
|
651
723
|
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMUX Terminal Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the TerminalAdapter interface for CMUX (cmux.dev).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
|
|
8
|
+
|
|
9
|
+
export class CmuxAdapter implements TerminalAdapter {
|
|
10
|
+
readonly name = "cmux";
|
|
11
|
+
|
|
12
|
+
detect(): boolean {
|
|
13
|
+
// Check for CMUX specific environment variables
|
|
14
|
+
return !!process.env.CMUX_SOCKET_PATH || !!process.env.CMUX_WORKSPACE_ID;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
spawn(options: SpawnOptions): string {
|
|
18
|
+
// We use new-split to create a new pane in CMUX.
|
|
19
|
+
// CMUX doesn't have a direct 'spawn' that returns a pane ID and runs a command
|
|
20
|
+
// in one go while also returning the ID in a way we can easily capture for 'isAlive'.
|
|
21
|
+
// However, 'new-split' returns the new surface ID.
|
|
22
|
+
|
|
23
|
+
// Construct the command with environment variables
|
|
24
|
+
const envPrefix = Object.entries(options.env)
|
|
25
|
+
.filter(([k]) => k.startsWith("PI_"))
|
|
26
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
27
|
+
.join(" ");
|
|
28
|
+
|
|
29
|
+
const fullCommand = envPrefix ? `env ${envPrefix} ${options.command}` : options.command;
|
|
30
|
+
|
|
31
|
+
// CMUX new-split returns "OK <UUID>"
|
|
32
|
+
const splitResult = execCommand("cmux", ["new-split", "right", "--command", fullCommand]);
|
|
33
|
+
|
|
34
|
+
if (splitResult.status !== 0) {
|
|
35
|
+
throw new Error(`cmux new-split failed with status ${splitResult.status}: ${splitResult.stderr}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const output = splitResult.stdout.trim();
|
|
39
|
+
if (output.startsWith("OK ")) {
|
|
40
|
+
const surfaceId = output.substring(3).trim();
|
|
41
|
+
return surfaceId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw new Error(`cmux new-split returned unexpected output: ${output}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
kill(paneId: string): void {
|
|
48
|
+
if (!paneId) return;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// CMUX calls them surfaces
|
|
52
|
+
execCommand("cmux", ["close-surface", "--surface", paneId]);
|
|
53
|
+
} catch {
|
|
54
|
+
// Ignore errors during kill
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
isAlive(paneId: string): boolean {
|
|
59
|
+
if (!paneId) return false;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// We can use list-pane-surfaces and grep for the ID
|
|
63
|
+
// Or just 'identify' if we want to be precise, but list-pane-surfaces is safer
|
|
64
|
+
const result = execCommand("cmux", ["list-pane-surfaces"]);
|
|
65
|
+
return result.stdout.includes(paneId);
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setTitle(title: string): void {
|
|
72
|
+
try {
|
|
73
|
+
// rename-tab or rename-workspace?
|
|
74
|
+
// Usually agents want to rename their current "tab" or "surface"
|
|
75
|
+
execCommand("cmux", ["rename-tab", title]);
|
|
76
|
+
} catch {
|
|
77
|
+
// Ignore errors
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* CMUX supports spawning separate OS windows
|
|
83
|
+
*/
|
|
84
|
+
supportsWindows(): boolean {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Spawn a new separate OS window.
|
|
90
|
+
*/
|
|
91
|
+
spawnWindow(options: SpawnOptions): string {
|
|
92
|
+
// CMUX new-window returns "OK <UUID>"
|
|
93
|
+
const result = execCommand("cmux", ["new-window"]);
|
|
94
|
+
|
|
95
|
+
if (result.status !== 0) {
|
|
96
|
+
throw new Error(`cmux new-window failed with status ${result.status}: ${result.stderr}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const output = result.stdout.trim();
|
|
100
|
+
if (output.startsWith("OK ")) {
|
|
101
|
+
const windowId = output.substring(3).trim();
|
|
102
|
+
|
|
103
|
+
// Now we need to run the command in this window.
|
|
104
|
+
// Usually new-window creates a default workspace/surface.
|
|
105
|
+
// We might need to find the workspace in that window.
|
|
106
|
+
|
|
107
|
+
// For now, let's just use 'new-workspace' in that window if possible,
|
|
108
|
+
// but CMUX commands usually target the current window unless specified.
|
|
109
|
+
// Wait a bit for the window to be ready?
|
|
110
|
+
|
|
111
|
+
const envPrefix = Object.entries(options.env)
|
|
112
|
+
.filter(([k]) => k.startsWith("PI_"))
|
|
113
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
114
|
+
.join(" ");
|
|
115
|
+
|
|
116
|
+
const fullCommand = envPrefix ? `env ${envPrefix} ${options.command}` : options.command;
|
|
117
|
+
|
|
118
|
+
// Target the new window
|
|
119
|
+
execCommand("cmux", ["new-workspace", "--window", windowId, "--command", fullCommand]);
|
|
120
|
+
|
|
121
|
+
if (options.teamName) {
|
|
122
|
+
this.setWindowTitle(windowId, options.teamName);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return windowId;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
throw new Error(`cmux new-window returned unexpected output: ${output}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Set the title of a specific window.
|
|
133
|
+
*/
|
|
134
|
+
setWindowTitle(windowId: string, title: string): void {
|
|
135
|
+
try {
|
|
136
|
+
execCommand("cmux", ["rename-window", "--window", windowId, title]);
|
|
137
|
+
} catch {
|
|
138
|
+
// Ignore
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Kill/terminate a window.
|
|
144
|
+
*/
|
|
145
|
+
killWindow(windowId: string): void {
|
|
146
|
+
if (!windowId) return;
|
|
147
|
+
try {
|
|
148
|
+
execCommand("cmux", ["close-window", "--window", windowId]);
|
|
149
|
+
} catch {
|
|
150
|
+
// Ignore
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if a window is still alive.
|
|
156
|
+
*/
|
|
157
|
+
isWindowAlive(windowId: string): boolean {
|
|
158
|
+
if (!windowId) return false;
|
|
159
|
+
try {
|
|
160
|
+
const result = execCommand("cmux", ["list-windows"]);
|
|
161
|
+
return result.stdout.includes(windowId);
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Custom CMUX capability: create a workspace for a problem.
|
|
169
|
+
* This isn't part of the TerminalAdapter interface but can be used via the adapter.
|
|
170
|
+
*/
|
|
171
|
+
createProblemWorkspace(title: string, command?: string): string {
|
|
172
|
+
const args = ["new-workspace"];
|
|
173
|
+
if (command) {
|
|
174
|
+
args.push("--command", command);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const result = execCommand("cmux", args);
|
|
178
|
+
if (result.status !== 0) {
|
|
179
|
+
throw new Error(`cmux new-workspace failed: ${result.stderr}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const output = result.stdout.trim();
|
|
183
|
+
if (output.startsWith("OK ")) {
|
|
184
|
+
const workspaceId = output.substring(3).trim();
|
|
185
|
+
execCommand("cmux", ["workspace-action", "--action", "rename", "--title", title, "--workspace", workspaceId]);
|
|
186
|
+
return workspaceId;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw new Error(`cmux new-workspace returned unexpected output: ${output}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Terminal Registry
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Manages terminal adapters and provides automatic selection based on
|
|
5
5
|
* the current environment.
|
|
6
6
|
*/
|
|
@@ -10,6 +10,7 @@ import { TmuxAdapter } from "./tmux-adapter";
|
|
|
10
10
|
import { Iterm2Adapter } from "./iterm2-adapter";
|
|
11
11
|
import { ZellijAdapter } from "./zellij-adapter";
|
|
12
12
|
import { WezTermAdapter } from "./wezterm-adapter";
|
|
13
|
+
import { WindowsAdapter } from "./windows-adapter";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Available terminal adapters, ordered by priority
|
|
@@ -19,12 +20,14 @@ import { WezTermAdapter } from "./wezterm-adapter";
|
|
|
19
20
|
* 2. Zellij - if ZELLIJ env is set and not in tmux
|
|
20
21
|
* 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
|
|
21
22
|
* 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij
|
|
23
|
+
* 5. Windows - if platform is win32 and not in tmux/zellij/iTerm2/WezTerm
|
|
22
24
|
*/
|
|
23
25
|
const adapters: TerminalAdapter[] = [
|
|
24
26
|
new TmuxAdapter(),
|
|
25
27
|
new ZellijAdapter(),
|
|
26
28
|
new Iterm2Adapter(),
|
|
27
29
|
new WezTermAdapter(),
|
|
30
|
+
new WindowsAdapter(),
|
|
28
31
|
];
|
|
29
32
|
|
|
30
33
|
/**
|
|
@@ -40,6 +43,7 @@ let cachedAdapter: TerminalAdapter | null = null;
|
|
|
40
43
|
* 2. Zellij - if ZELLIJ env is set and not in tmux
|
|
41
44
|
* 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
|
|
42
45
|
* 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij
|
|
46
|
+
* 5. Windows - if platform is win32 and not in tmux/zellij/iTerm2/WezTerm
|
|
43
47
|
*
|
|
44
48
|
* @returns The detected terminal adapter, or null if none detected
|
|
45
49
|
*/
|
|
@@ -61,7 +65,7 @@ export function getTerminalAdapter(): TerminalAdapter | null {
|
|
|
61
65
|
/**
|
|
62
66
|
* Get a specific terminal adapter by name.
|
|
63
67
|
*
|
|
64
|
-
* @param name - The adapter name (e.g., "tmux", "iTerm2", "zellij", "WezTerm")
|
|
68
|
+
* @param name - The adapter name (e.g., "tmux", "iTerm2", "zellij", "WezTerm", "Windows")
|
|
65
69
|
* @returns The adapter instance, or undefined if not found
|
|
66
70
|
*/
|
|
67
71
|
export function getAdapterByName(name: string): TerminalAdapter | undefined {
|
|
@@ -70,7 +74,7 @@ export function getAdapterByName(name: string): TerminalAdapter | undefined {
|
|
|
70
74
|
|
|
71
75
|
/**
|
|
72
76
|
* Get all available adapters.
|
|
73
|
-
*
|
|
77
|
+
*
|
|
74
78
|
* @returns Array of all registered adapters
|
|
75
79
|
*/
|
|
76
80
|
export function getAllAdapters(): TerminalAdapter[] {
|
|
@@ -93,7 +97,7 @@ export function setAdapter(adapter: TerminalAdapter): void {
|
|
|
93
97
|
|
|
94
98
|
/**
|
|
95
99
|
* Check if any terminal adapter is available.
|
|
96
|
-
*
|
|
100
|
+
*
|
|
97
101
|
* @returns true if a terminal adapter was detected
|
|
98
102
|
*/
|
|
99
103
|
export function hasTerminalAdapter(): boolean {
|
|
@@ -102,8 +106,8 @@ export function hasTerminalAdapter(): boolean {
|
|
|
102
106
|
|
|
103
107
|
/**
|
|
104
108
|
* Check if the current terminal supports spawning separate OS windows.
|
|
105
|
-
*
|
|
106
|
-
* @returns true if the detected terminal supports windows (iTerm2, WezTerm)
|
|
109
|
+
*
|
|
110
|
+
* @returns true if the detected terminal supports windows (iTerm2, WezTerm, Windows)
|
|
107
111
|
*/
|
|
108
112
|
export function supportsWindows(): boolean {
|
|
109
113
|
const adapter = getTerminalAdapter();
|
|
@@ -112,9 +116,9 @@ export function supportsWindows(): boolean {
|
|
|
112
116
|
|
|
113
117
|
/**
|
|
114
118
|
* Get the name of the currently detected terminal adapter.
|
|
115
|
-
*
|
|
119
|
+
*
|
|
116
120
|
* @returns The adapter name, or null if none detected
|
|
117
121
|
*/
|
|
118
122
|
export function getTerminalName(): string | null {
|
|
119
123
|
return getTerminalAdapter()?.name ?? null;
|
|
120
|
-
}
|
|
124
|
+
}
|
|
@@ -73,6 +73,22 @@ export class WezTermAdapter implements TerminalAdapter {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Build command arguments for the current platform.
|
|
78
|
+
* On Windows, uses PowerShell. On Unix, uses sh.
|
|
79
|
+
*/
|
|
80
|
+
private buildCommandArgs(options: SpawnOptions, envArgs: string[]): string[] {
|
|
81
|
+
if (process.platform === "win32") {
|
|
82
|
+
// Windows: Use PowerShell
|
|
83
|
+
// Build the command without environment variables (they'll be set via WezTerm's env)
|
|
84
|
+
const psCommand = `cd '${options.cwd}'; ${options.command}`;
|
|
85
|
+
return ["pwsh", "-NoExit", "-Command", psCommand];
|
|
86
|
+
} else {
|
|
87
|
+
// Unix: Use sh
|
|
88
|
+
return ["env", ...envArgs, "sh", "-c", options.command];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
spawn(options: SpawnOptions): string {
|
|
77
93
|
const weztermBin = this.findWeztermBinary();
|
|
78
94
|
if (!weztermBin) {
|
|
@@ -80,40 +96,66 @@ export class WezTermAdapter implements TerminalAdapter {
|
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
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
99
|
|
|
89
100
|
// First pane: split to the right with 50% (matches iTerm2/tmux behavior)
|
|
90
101
|
const isFirstPane = panes.length === 1;
|
|
91
102
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
let weztermArgs: string[];
|
|
104
|
+
|
|
105
|
+
if (process.platform === "win32") {
|
|
106
|
+
// Windows: Use PowerShell with double quotes (works when WezTerm wraps in single quotes)
|
|
107
|
+
const envVars = Object.entries(options.env)
|
|
108
|
+
.filter(([k]) => k.startsWith("PI_"))
|
|
109
|
+
.map(([k, v]) => `$env:${k}="${v}"`)
|
|
110
|
+
.join("; ");
|
|
111
|
+
|
|
112
|
+
const psCommand = `${envVars}; cd "${options.cwd}"; ${options.command}`;
|
|
113
|
+
// Use 'powershell' (built-in) instead of 'pwsh' (PowerShell Core, may not be installed)
|
|
114
|
+
const cmdArgs = ["powershell", "-NoExit", "-Command", psCommand];
|
|
115
|
+
|
|
116
|
+
if (isFirstPane) {
|
|
117
|
+
weztermArgs = [
|
|
118
|
+
"cli", "split-pane", "--right", "--percent", "50",
|
|
119
|
+
"--cwd", options.cwd, "--", ...cmdArgs
|
|
120
|
+
];
|
|
121
|
+
} else {
|
|
122
|
+
const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
|
|
123
|
+
const sidebarPanes = panes
|
|
124
|
+
.filter(p => p.pane_id !== currentPaneId)
|
|
125
|
+
.sort((a, b) => b.cursor_y - a.cursor_y);
|
|
126
|
+
const targetPane = sidebarPanes[0];
|
|
127
|
+
|
|
128
|
+
weztermArgs = [
|
|
129
|
+
"cli", "split-pane", "--bottom", "--pane-id", targetPane.pane_id.toString(),
|
|
130
|
+
"--percent", "50",
|
|
131
|
+
"--cwd", options.cwd, "--", ...cmdArgs
|
|
132
|
+
];
|
|
133
|
+
}
|
|
97
134
|
} else {
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
135
|
+
// Unix: Use sh with env command
|
|
136
|
+
const envArgs = Object.entries(options.env)
|
|
137
|
+
.filter(([k]) => k.startsWith("PI_"))
|
|
138
|
+
.map(([k, v]) => `${k}=${v}`);
|
|
139
|
+
const cmdArgs = ["env", ...envArgs, "sh", "-c", options.command];
|
|
140
|
+
|
|
141
|
+
if (isFirstPane) {
|
|
142
|
+
weztermArgs = [
|
|
143
|
+
"cli", "split-pane", "--right", "--percent", "50",
|
|
144
|
+
"--cwd", options.cwd, "--", ...cmdArgs
|
|
145
|
+
];
|
|
146
|
+
} else {
|
|
147
|
+
const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
|
|
148
|
+
const sidebarPanes = panes
|
|
149
|
+
.filter(p => p.pane_id !== currentPaneId)
|
|
150
|
+
.sort((a, b) => b.cursor_y - a.cursor_y);
|
|
151
|
+
const targetPane = sidebarPanes[0];
|
|
152
|
+
|
|
153
|
+
weztermArgs = [
|
|
154
|
+
"cli", "split-pane", "--bottom", "--pane-id", targetPane.pane_id.toString(),
|
|
155
|
+
"--percent", "50",
|
|
156
|
+
"--cwd", options.cwd, "--", ...cmdArgs
|
|
157
|
+
];
|
|
158
|
+
}
|
|
117
159
|
}
|
|
118
160
|
|
|
119
161
|
const result = execCommand(weztermBin, weztermArgs);
|
|
@@ -181,21 +223,39 @@ export class WezTermAdapter implements TerminalAdapter {
|
|
|
181
223
|
throw new Error("WezTerm CLI binary not found.");
|
|
182
224
|
}
|
|
183
225
|
|
|
184
|
-
const envArgs = Object.entries(options.env)
|
|
185
|
-
.filter(([k]) => k.startsWith("PI_"))
|
|
186
|
-
.map(([k, v]) => `${k}=${v}`);
|
|
187
|
-
|
|
188
226
|
// Format window title as "teamName: agentName" if teamName is provided
|
|
189
227
|
const windowTitle = options.teamName
|
|
190
228
|
? `${options.teamName}: ${options.name}`
|
|
191
229
|
: options.name;
|
|
192
230
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
231
|
+
let spawnArgs: string[];
|
|
232
|
+
|
|
233
|
+
if (process.platform === "win32") {
|
|
234
|
+
// Windows: Use PowerShell with double quotes (works when WezTerm wraps in single quotes)
|
|
235
|
+
const envVars = Object.entries(options.env)
|
|
236
|
+
.filter(([k]) => k.startsWith("PI_"))
|
|
237
|
+
.map(([k, v]) => `$env:${k}="${v}"`)
|
|
238
|
+
.join("; ");
|
|
239
|
+
|
|
240
|
+
const psCommand = `${envVars}; cd "${options.cwd}"; ${options.command}`;
|
|
241
|
+
|
|
242
|
+
spawnArgs = [
|
|
243
|
+
"cli", "spawn", "--new-window",
|
|
244
|
+
"--cwd", options.cwd,
|
|
245
|
+
"--", "powershell", "-NoExit", "-Command", psCommand
|
|
246
|
+
];
|
|
247
|
+
} else {
|
|
248
|
+
// Unix: Use env command
|
|
249
|
+
const envArgs = Object.entries(options.env)
|
|
250
|
+
.filter(([k]) => k.startsWith("PI_"))
|
|
251
|
+
.map(([k, v]) => `${k}=${v}`);
|
|
252
|
+
|
|
253
|
+
spawnArgs = [
|
|
254
|
+
"cli", "spawn", "--new-window",
|
|
255
|
+
"--cwd", options.cwd,
|
|
256
|
+
"--", "env", ...envArgs, "sh", "-c", options.command
|
|
257
|
+
];
|
|
258
|
+
}
|
|
199
259
|
|
|
200
260
|
const result = execCommand(weztermBin, spawnArgs);
|
|
201
261
|
if (result.status !== 0) {
|