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 WezTerm.
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 **WezTerm**.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- if (isFirstPane) {
93
- weztermArgs = [
94
- "cli", "split-pane", "--right", "--percent", "50",
95
- "--cwd", options.cwd, "--", "env", ...envArgs, "sh", "-c", options.command
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
- // 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
- ];
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
- // Spawn a new window
194
- const spawnArgs = [
195
- "cli", "spawn", "--new-window",
196
- "--cwd", options.cwd,
197
- "--", "env", ...envArgs, "sh", "-c", options.command
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
+ }