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 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).
@@ -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
- const unread = await messaging.readInbox(teamName, agentName, true, false);
180
- if (unread.length > 0) {
181
- pi.sendUserMessage(`I have ${unread.length} new message(s) in my inbox. Reading them now...`);
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({ alive, unreadCount }, null, 2) }],
648
- details: { alive, unreadCount },
719
+ content: [{ type: "text", text: JSON.stringify(details, null, 2) }],
720
+ details,
649
721
  };
650
722
  },
651
723
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
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) {