pi-teams 0.8.6 → 0.8.7
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, 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/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) {
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows Adapter Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
6
|
+
import { WindowsAdapter } from "./windows-adapter";
|
|
7
|
+
|
|
8
|
+
// Mock process.platform for Windows tests
|
|
9
|
+
const originalPlatform = process.platform;
|
|
10
|
+
|
|
11
|
+
describe("WindowsAdapter", () => {
|
|
12
|
+
let adapter: WindowsAdapter;
|
|
13
|
+
let mockExecCommand: ReturnType<typeof vi.fn>;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
adapter = new WindowsAdapter();
|
|
17
|
+
vi.resetAllMocks();
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
|
|
20
|
+
// Save original process.platform
|
|
21
|
+
Object.defineProperty(process, "platform", {
|
|
22
|
+
value: originalPlatform,
|
|
23
|
+
writable: true,
|
|
24
|
+
configurable: true,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("basics", () => {
|
|
29
|
+
it("should have the correct name", () => {
|
|
30
|
+
expect(adapter.name).toBe("Windows");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("detect()", () => {
|
|
35
|
+
it("should detect when on Windows and wt is available", () => {
|
|
36
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
37
|
+
delete process.env.TMUX;
|
|
38
|
+
delete process.env.ZELLIJ;
|
|
39
|
+
delete process.env.WEZTERM_PANE;
|
|
40
|
+
|
|
41
|
+
// Mock successful wt --version
|
|
42
|
+
vi.mock("../utils/terminal-adapter", async () => {
|
|
43
|
+
const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
|
|
44
|
+
return {
|
|
45
|
+
...actual,
|
|
46
|
+
execCommand: vi.fn().mockReturnValue({ stdout: "Windows Terminal", status: 0 }),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const { execCommand } = require("../utils/terminal-adapter");
|
|
51
|
+
// Create new adapter to use mocked execCommand
|
|
52
|
+
adapter = new WindowsAdapter();
|
|
53
|
+
|
|
54
|
+
expect(adapter.detect()).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should not detect when not on Windows", () => {
|
|
58
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
59
|
+
|
|
60
|
+
expect(adapter.detect()).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should not detect when TMUX is set", () => {
|
|
64
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
65
|
+
process.env.TMUX = "/tmp/tmux";
|
|
66
|
+
|
|
67
|
+
expect(adapter.detect()).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should not detect when ZELLIJ is set", () => {
|
|
71
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
72
|
+
process.env.ZELLIJ = "true";
|
|
73
|
+
|
|
74
|
+
expect(adapter.detect()).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should not detect when WEZTERM_PANE is set", () => {
|
|
78
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
79
|
+
process.env.WEZTERM_PANE = "123";
|
|
80
|
+
|
|
81
|
+
expect(adapter.detect()).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("spawn()", () => {
|
|
86
|
+
it("should spawn first pane on Windows", () => {
|
|
87
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
88
|
+
|
|
89
|
+
vi.mock("../utils/terminal-adapter", async () => {
|
|
90
|
+
const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
|
|
91
|
+
return {
|
|
92
|
+
...actual,
|
|
93
|
+
execCommand: vi.fn().mockReturnValue({ stdout: "pane-id", status: 0 }),
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const { execCommand } = require("../utils/terminal-adapter");
|
|
98
|
+
adapter = new WindowsAdapter();
|
|
99
|
+
|
|
100
|
+
const paneId = adapter.spawn({
|
|
101
|
+
name: "test-agent",
|
|
102
|
+
cwd: "/test/path",
|
|
103
|
+
command: "pi --model gpt-4",
|
|
104
|
+
env: { PI_TEAM_NAME: "team1", PI_AGENT_NAME: "agent1" },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(paneId).toMatch(/^windows_\d+_test-agent$/);
|
|
108
|
+
expect(execCommand).toHaveBeenCalledWith(
|
|
109
|
+
"wt",
|
|
110
|
+
expect.arrayContaining([
|
|
111
|
+
"split-pane",
|
|
112
|
+
"--vertical",
|
|
113
|
+
"--size", "50%",
|
|
114
|
+
"--", "pwsh", "-NoExit", "-Command",
|
|
115
|
+
])
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should spawn subsequent pane on Windows", () => {
|
|
120
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
121
|
+
|
|
122
|
+
vi.mock("../utils/terminal-adapter", async () => {
|
|
123
|
+
const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
|
|
124
|
+
return {
|
|
125
|
+
...actual,
|
|
126
|
+
execCommand: vi.fn()
|
|
127
|
+
.mockReturnValueOnce({ stdout: "pane-id", status: 0 }) // wt --version
|
|
128
|
+
.mockReturnValueOnce({ stdout: '[{"window":1,"pane":1},{"window":1,"pane":2}]', status: 0 }) // wt list
|
|
129
|
+
.mockReturnValue({ stdout: "pane-id", status: 0 }), // split-pane
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const { execCommand } = require("../utils/terminal-adapter");
|
|
134
|
+
adapter = new WindowsAdapter();
|
|
135
|
+
|
|
136
|
+
const paneId = adapter.spawn({
|
|
137
|
+
name: "test-agent",
|
|
138
|
+
cwd: "/test/path",
|
|
139
|
+
command: "pi --model gpt-4",
|
|
140
|
+
env: { PI_TEAM_NAME: "team1", PI_AGENT_NAME: "agent1" },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(paneId).toMatch(/^windows_\d+_test-agent$/);
|
|
144
|
+
expect(execCommand).toHaveBeenCalledWith(
|
|
145
|
+
"wt",
|
|
146
|
+
expect.arrayContaining(["split-pane", "--horizontal"])
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should throw error when wt binary not found", () => {
|
|
151
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
152
|
+
|
|
153
|
+
vi.mock("../utils/terminal-adapter", async () => {
|
|
154
|
+
const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
|
|
155
|
+
return {
|
|
156
|
+
...actual,
|
|
157
|
+
execCommand: vi.fn().mockReturnValue({ stdout: "", status: 1 }),
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const { execCommand } = require("../utils/terminal-adapter");
|
|
162
|
+
adapter = new WindowsAdapter();
|
|
163
|
+
|
|
164
|
+
expect(() => adapter.spawn({
|
|
165
|
+
name: "test-agent",
|
|
166
|
+
cwd: "/test/path",
|
|
167
|
+
command: "pi",
|
|
168
|
+
env: {},
|
|
169
|
+
})).toThrow("Windows Terminal (wt) CLI binary not found");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("supportsWindows()", () => {
|
|
174
|
+
it("should return true when wt is available", () => {
|
|
175
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
176
|
+
|
|
177
|
+
vi.mock("../utils/terminal-adapter", async () => {
|
|
178
|
+
const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
|
|
179
|
+
return {
|
|
180
|
+
...actual,
|
|
181
|
+
execCommand: vi.fn().mockReturnValue({ stdout: "version", status: 0 }),
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const { execCommand } = require("../utils/terminal-adapter");
|
|
186
|
+
adapter = new WindowsAdapter();
|
|
187
|
+
|
|
188
|
+
expect(adapter.supportsWindows()).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should return false when wt not available", () => {
|
|
192
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
193
|
+
|
|
194
|
+
vi.mock("../utils/terminal-adapter", async () => {
|
|
195
|
+
const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
|
|
196
|
+
return {
|
|
197
|
+
...actual,
|
|
198
|
+
execCommand: vi.fn().mockReturnValue({ stdout: "", status: 1 }),
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const { execCommand } = require("../utils/terminal-adapter");
|
|
203
|
+
adapter = new WindowsAdapter();
|
|
204
|
+
|
|
205
|
+
expect(adapter.supportsWindows()).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("spawnWindow()", () => {
|
|
210
|
+
it("should spawn a new window", () => {
|
|
211
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
212
|
+
|
|
213
|
+
vi.mock("../utils/terminal-adapter", async () => {
|
|
214
|
+
const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
|
|
215
|
+
return {
|
|
216
|
+
...actual,
|
|
217
|
+
execCommand: vi.fn().mockReturnValue({ stdout: "window-id", status: 0 }),
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const { execCommand } = require("../utils/terminal-adapter");
|
|
222
|
+
adapter = new WindowsAdapter();
|
|
223
|
+
|
|
224
|
+
const windowId = adapter.spawnWindow({
|
|
225
|
+
name: "agent",
|
|
226
|
+
cwd: "/test",
|
|
227
|
+
command: "pi",
|
|
228
|
+
env: {},
|
|
229
|
+
teamName: "team1",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(windowId).toMatch(/^windows_win_\d+_agent$/);
|
|
233
|
+
expect(execCommand).toHaveBeenCalledWith(
|
|
234
|
+
"wt",
|
|
235
|
+
expect.arrayContaining([
|
|
236
|
+
"new-window",
|
|
237
|
+
"--title", "team1: agent",
|
|
238
|
+
])
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("kill()", () => {
|
|
244
|
+
it("should handle kill gracefully for windows pane", () => {
|
|
245
|
+
adapter.kill("windows_123_agent");
|
|
246
|
+
// Should not throw, just silently do nothing
|
|
247
|
+
expect(true).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("should ignore non-windows pane IDs", () => {
|
|
251
|
+
adapter.kill("tmux_123");
|
|
252
|
+
// Should not throw, just silently do nothing
|
|
253
|
+
expect(true).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("killWindow()", () => {
|
|
258
|
+
it("should handle killWindow gracefully", () => {
|
|
259
|
+
adapter.killWindow("windows_win_123_agent");
|
|
260
|
+
// Should not throw, just silently do nothing
|
|
261
|
+
expect(true).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("isAlive()", () => {
|
|
266
|
+
it("should return true for windows pane ID", () => {
|
|
267
|
+
expect(adapter.isAlive("windows_123_agent")).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("should return false for non-windows pane ID", () => {
|
|
271
|
+
expect(adapter.isAlive("tmux_123")).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("isWindowAlive()", () => {
|
|
276
|
+
it("should return true for windows window ID", () => {
|
|
277
|
+
expect(adapter.isWindowAlive("windows_win_123_agent")).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("should return false for non-windows window ID", () => {
|
|
281
|
+
expect(adapter.isWindowAlive("other_123")).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("setTitle()", () => {
|
|
286
|
+
it("should set tab title gracefully", () => {
|
|
287
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
288
|
+
|
|
289
|
+
vi.mock("../utils/terminal-adapter", async () => {
|
|
290
|
+
const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
|
|
291
|
+
return {
|
|
292
|
+
...actual,
|
|
293
|
+
execCommand: vi.fn().mockReturnValue({ stdout: "", status: 0 }),
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const { execCommand } = require("../utils/terminal-adapter");
|
|
298
|
+
adapter = new WindowsAdapter();
|
|
299
|
+
|
|
300
|
+
adapter.setTitle("Test Title");
|
|
301
|
+
expect(execCommand).toHaveBeenCalledWith("wt", ["set-tab-title", "Test Title"]);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("setWindowTitle()", () => {
|
|
306
|
+
it("should gracefully handle setWindowTitle limitation", () => {
|
|
307
|
+
adapter.setWindowTitle("windows_win_123", "Test Title");
|
|
308
|
+
// Windows Terminal limitation - titles are set at spawn time
|
|
309
|
+
// Should silently do nothing without throwing
|
|
310
|
+
expect(true).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows Terminal/PowerShell Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the TerminalAdapter interface for Windows with PowerShell.
|
|
5
|
+
* Uses wt (Windows Terminal) CLI for pane management and PowerShell for command execution.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
|
|
9
|
+
|
|
10
|
+
export class WindowsAdapter implements TerminalAdapter {
|
|
11
|
+
readonly name = "Windows";
|
|
12
|
+
|
|
13
|
+
// Common paths where wt CLI might be found on Windows
|
|
14
|
+
private possiblePaths = [
|
|
15
|
+
"wt", // In PATH
|
|
16
|
+
"C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminal_8wekyb3d8bbwe\\wt.exe", // WindowsApps
|
|
17
|
+
"C:\\Users\\${process.env.USERNAME}\\AppData\\Local\\Microsoft\\WindowsApps\\wt.exe", // User Local
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
private wtPath: string | null = null;
|
|
21
|
+
|
|
22
|
+
private findWtBinary(): string | null {
|
|
23
|
+
if (this.wtPath !== null) {
|
|
24
|
+
return this.wtPath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// On Windows, wt.exe is usually available via WindowsApps
|
|
28
|
+
// Try different methods to detect it
|
|
29
|
+
try {
|
|
30
|
+
// Method 1: Try running wt directly (works in Windows Terminal)
|
|
31
|
+
const result = execCommand("wt", ["--version"]);
|
|
32
|
+
// wt doesn't have a proper --version, but if it exists, it will fail with a specific error
|
|
33
|
+
// If it doesn't exist, spawnSync will throw
|
|
34
|
+
this.wtPath = "wt";
|
|
35
|
+
return "wt";
|
|
36
|
+
} catch {
|
|
37
|
+
// Method 2: Check common paths
|
|
38
|
+
const fs = require("fs");
|
|
39
|
+
const possiblePaths = [
|
|
40
|
+
`C:\\Users\\${process.env.USERNAME}\\AppData\\Local\\Microsoft\\WindowsApps\\wt.exe`,
|
|
41
|
+
"C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminal_8wekyb3d8bbwe\\wt.exe",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
for (const p of possiblePaths) {
|
|
45
|
+
try {
|
|
46
|
+
if (fs.existsSync(p)) {
|
|
47
|
+
this.wtPath = p;
|
|
48
|
+
return p;
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Method 3: Just assume wt is available on Windows and let spawn fail if not
|
|
55
|
+
// This is a reasonable fallback for most Windows 10/11 systems
|
|
56
|
+
if (process.platform === "win32") {
|
|
57
|
+
this.wtPath = "wt";
|
|
58
|
+
return "wt";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.wtPath = null;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
detect(): boolean {
|
|
66
|
+
// Windows only - check platform
|
|
67
|
+
if (process.platform !== "win32") {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Don't use if inside tmux, Zellij, or WezTerm
|
|
72
|
+
// Note: we DO detect in mintty/Git Bash because we can still use Windows Terminal
|
|
73
|
+
if (process.env.TMUX || process.env.ZELLIJ || process.env.WEZTERM_PANE) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// On Windows, always try to use Windows Terminal
|
|
78
|
+
// findWtBinary() will return "wt" as fallback
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get all panes in the current window to determine layout state.
|
|
84
|
+
* wt cli list returns JSON with pane information.
|
|
85
|
+
*/
|
|
86
|
+
private getPanes(): any[] {
|
|
87
|
+
const wtBin = this.findWtBinary();
|
|
88
|
+
if (!wtBin) return [];
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const result = execCommand(wtBin, ["list", "--format", "json"]);
|
|
92
|
+
if (result.status !== 0) return [];
|
|
93
|
+
|
|
94
|
+
const allPanes = JSON.parse(result.stdout);
|
|
95
|
+
|
|
96
|
+
// Filter to get panes from current window only
|
|
97
|
+
// We can't easily get the current pane ID on Windows, so we assume
|
|
98
|
+
// the first window in the list is the current one
|
|
99
|
+
if (allPanes.length === 0) return [];
|
|
100
|
+
|
|
101
|
+
const currentWindowId = allPanes[0].window;
|
|
102
|
+
return allPanes.filter((p: any) => p.window === currentWindowId);
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
spawn(options: SpawnOptions): string {
|
|
109
|
+
const wtBin = this.findWtBinary();
|
|
110
|
+
if (!wtBin) {
|
|
111
|
+
throw new Error("Windows Terminal (wt) CLI binary not found.");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const panes = this.getPanes();
|
|
115
|
+
|
|
116
|
+
// Build environment variables for PowerShell
|
|
117
|
+
const envVars = Object.entries(options.env)
|
|
118
|
+
.filter(([k]) => k.startsWith("PI_"))
|
|
119
|
+
.map(([k, v]) => `$env:${k}='${v}'`)
|
|
120
|
+
.join(" ");
|
|
121
|
+
|
|
122
|
+
// Build the PowerShell command
|
|
123
|
+
// Use icm (Invoke-Command) to run in a specific directory with env vars
|
|
124
|
+
const psCommand = `cd '${options.cwd}'; ${envVars}; ${options.command}`;
|
|
125
|
+
|
|
126
|
+
// Use wt split-pane command
|
|
127
|
+
// First pane splits vertically (right), subsequent panes stack
|
|
128
|
+
const isFirstPane = panes.length <= 1;
|
|
129
|
+
|
|
130
|
+
let wtArgs: string[];
|
|
131
|
+
|
|
132
|
+
if (isFirstPane) {
|
|
133
|
+
wtArgs = [
|
|
134
|
+
"split-pane",
|
|
135
|
+
"--vertical",
|
|
136
|
+
"--size", "50%",
|
|
137
|
+
"--", "pwsh", "-NoExit", "-Command", psCommand
|
|
138
|
+
];
|
|
139
|
+
} else {
|
|
140
|
+
// Create a new tab for subsequent panes (Windows Terminal limitation)
|
|
141
|
+
// Alternatively split horizontally at the bottom
|
|
142
|
+
wtArgs = [
|
|
143
|
+
"split-pane",
|
|
144
|
+
"--horizontal",
|
|
145
|
+
"--size", "50%",
|
|
146
|
+
"--", "pwsh", "-NoExit", "-Command", psCommand
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result = execCommand(wtBin, wtArgs);
|
|
151
|
+
if (result.status !== 0) {
|
|
152
|
+
throw new Error(`Windows Terminal spawn failed: ${result.stderr}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// wt doesn't return a pane ID, so we create a synthetic one
|
|
156
|
+
// We'll use a timestamp + name to make it unique
|
|
157
|
+
const syntheticId = `windows_${Date.now()}_${options.name}`;
|
|
158
|
+
return syntheticId;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
kill(paneId: string): void {
|
|
162
|
+
if (!paneId?.startsWith("windows_")) return;
|
|
163
|
+
|
|
164
|
+
// Windows Terminal doesn't have a direct kill-pane command via CLI
|
|
165
|
+
// The pane will close when the PowerShell process exits
|
|
166
|
+
// We could potentially kill the process if we tracked PIDs, but for now
|
|
167
|
+
// we'll just let the user close it manually or the process ends naturally
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
isAlive(paneId: string): boolean {
|
|
171
|
+
if (!paneId?.startsWith("windows_")) return false;
|
|
172
|
+
|
|
173
|
+
// Windows Terminal doesn't provide an easy way to check pane status via CLI
|
|
174
|
+
// We assume the pane is alive for simplicity
|
|
175
|
+
// In production, you might want to track PIDs and check process status
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
setTitle(title: string): void {
|
|
180
|
+
const wtBin = this.findWtBinary();
|
|
181
|
+
if (!wtBin) return;
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
// Set tab title (Windows Terminal uses tab titles, not pane titles)
|
|
185
|
+
execCommand(wtBin, ["set-tab-title", title]);
|
|
186
|
+
} catch {
|
|
187
|
+
// Silently fail
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Windows Terminal supports spawning separate OS windows via New Tab or New Window
|
|
193
|
+
*/
|
|
194
|
+
supportsWindows(): boolean {
|
|
195
|
+
return this.findWtBinary() !== null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Spawn a new separate OS window with the given options.
|
|
200
|
+
* Uses `wt new-window` or starts a new wt instance.
|
|
201
|
+
*/
|
|
202
|
+
spawnWindow(options: SpawnOptions): string {
|
|
203
|
+
const wtBin = this.findWtBinary();
|
|
204
|
+
if (!wtBin) {
|
|
205
|
+
throw new Error("Windows Terminal (wt) CLI binary not found.");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Build environment variables for PowerShell
|
|
209
|
+
const envVars = Object.entries(options.env)
|
|
210
|
+
.filter(([k]) => k.startsWith("PI_"))
|
|
211
|
+
.map(([k, v]) => `$env:${k}='${v}'`)
|
|
212
|
+
.join(" ");
|
|
213
|
+
|
|
214
|
+
// Build the PowerShell command
|
|
215
|
+
const psCommand = `cd '${options.cwd}'; ${envVars}; ${options.command}`;
|
|
216
|
+
|
|
217
|
+
// Format window title as "teamName: agentName" if teamName is provided
|
|
218
|
+
const windowTitle = options.teamName
|
|
219
|
+
? `${options.teamName}: ${options.name}`
|
|
220
|
+
: options.name;
|
|
221
|
+
|
|
222
|
+
// Use wt new-window
|
|
223
|
+
const spawnArgs = [
|
|
224
|
+
"new-window",
|
|
225
|
+
"--title", windowTitle,
|
|
226
|
+
"--", "pwsh", "-NoExit", "-Command", psCommand
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
const result = execCommand(wtBin, spawnArgs);
|
|
230
|
+
if (result.status !== 0) {
|
|
231
|
+
throw new Error(`Windows Terminal spawn-window failed: ${result.stderr}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Create a synthetic window ID
|
|
235
|
+
const syntheticId = `windows_win_${Date.now()}_${options.name}`;
|
|
236
|
+
return syntheticId;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Set the title of a specific window.
|
|
241
|
+
*/
|
|
242
|
+
setWindowTitle(windowId: string, title: string): void {
|
|
243
|
+
// Windows Terminal CLI doesn't support setting window titles post-creation
|
|
244
|
+
// Titles are set at spawn time via --title flag
|
|
245
|
+
// This is a limitation of the wt CLI
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Kill/terminate a window.
|
|
250
|
+
*/
|
|
251
|
+
killWindow(windowId: string): void {
|
|
252
|
+
if (!windowId?.startsWith("windows_win_")) return;
|
|
253
|
+
|
|
254
|
+
// Windows Terminal doesn't provide a direct way to kill windows via CLI
|
|
255
|
+
// This is a limitation of the wt CLI
|
|
256
|
+
// Users would need to close the window manually
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Check if a window is still alive/active.
|
|
261
|
+
*/
|
|
262
|
+
isWindowAlive(windowId: string): boolean {
|
|
263
|
+
if (!windowId?.startsWith("windows_win_")) return false;
|
|
264
|
+
|
|
265
|
+
// Windows Terminal doesn't provide an easy way to check window status via CLI
|
|
266
|
+
// We assume the window is alive for simplicity
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
}
|