pi-teams 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -409,11 +409,17 @@ export default function (pi: ExtensionAPI) {
409
409
  }
410
410
  }
411
411
 
412
+ const leadMember = teamConfig.members.find(m => m.name === "team-lead");
413
+ const anchorPaneId = terminal.name === "tmux"
414
+ ? leadMember?.tmuxPaneId || process.env.TMUX_PANE || undefined
415
+ : undefined;
416
+
412
417
  terminalId = terminal.spawn({
413
418
  name: safeName,
414
419
  cwd: params.cwd,
415
420
  command: piCmd,
416
421
  env: env,
422
+ anchorPaneId,
417
423
  });
418
424
  await teams.updateMember(safeTeamName, safeName, { tmuxPaneId: terminalId });
419
425
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,309 @@
1
+ /**
2
+ * CmuxAdapter Tests
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import { CmuxAdapter } from "./cmux-adapter";
7
+ import * as terminalAdapter from "../utils/terminal-adapter";
8
+
9
+ describe("CmuxAdapter", () => {
10
+ let adapter: CmuxAdapter;
11
+ let mockExecCommand: ReturnType<typeof vi.spyOn>;
12
+
13
+ beforeEach(() => {
14
+ adapter = new CmuxAdapter();
15
+ mockExecCommand = vi.spyOn(terminalAdapter, "execCommand");
16
+ delete process.env.CMUX_SOCKET_PATH;
17
+ delete process.env.CMUX_WORKSPACE_ID;
18
+ delete process.env.TMUX;
19
+ delete process.env.ZELLIJ;
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ describe("name", () => {
27
+ it("should have the correct name", () => {
28
+ expect(adapter.name).toBe("cmux");
29
+ });
30
+ });
31
+
32
+ describe("detect", () => {
33
+ it("should detect when CMUX_SOCKET_PATH is set", () => {
34
+ process.env.CMUX_SOCKET_PATH = "/tmp/cmux.sock";
35
+ expect(adapter.detect()).toBe(true);
36
+ });
37
+
38
+ it("should detect when CMUX_WORKSPACE_ID is set", () => {
39
+ process.env.CMUX_WORKSPACE_ID = "workspace-123";
40
+ expect(adapter.detect()).toBe(true);
41
+ });
42
+
43
+ it("should not detect when neither env var is set", () => {
44
+ expect(adapter.detect()).toBe(false);
45
+ });
46
+
47
+ it("should not detect when TMUX is set (defensive - nested)", () => {
48
+ process.env.CMUX_SOCKET_PATH = "/tmp/cmux.sock";
49
+ process.env.TMUX = "/tmp/tmux-1000/default,123,0";
50
+ expect(adapter.detect()).toBe(false);
51
+ });
52
+
53
+ it("should not detect when ZELLIJ is set (defensive - nested)", () => {
54
+ process.env.CMUX_WORKSPACE_ID = "workspace-123";
55
+ process.env.ZELLIJ = "1";
56
+ expect(adapter.detect()).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe("spawn", () => {
61
+ beforeEach(() => {
62
+ process.env.CMUX_SOCKET_PATH = "/tmp/cmux.sock";
63
+ });
64
+
65
+ it("should spawn a new pane and return the surface ID", () => {
66
+ mockExecCommand.mockReturnValue({
67
+ stdout: "OK surface-42",
68
+ stderr: "",
69
+ status: 0
70
+ });
71
+
72
+ const result = adapter.spawn({
73
+ name: "test-agent",
74
+ cwd: "/home/user/project",
75
+ command: "pi --agent test",
76
+ env: { PI_AGENT_ID: "test-123" },
77
+ });
78
+
79
+ expect(result).toBe("surface-42");
80
+ expect(mockExecCommand).toHaveBeenCalledWith(
81
+ "cmux",
82
+ ["new-split", "right", "--command", "env PI_AGENT_ID=test-123 pi --agent test"]
83
+ );
84
+ });
85
+
86
+ it("should spawn without env prefix when no PI_ vars", () => {
87
+ mockExecCommand.mockReturnValue({
88
+ stdout: "OK surface-99",
89
+ stderr: "",
90
+ status: 0
91
+ });
92
+
93
+ const result = adapter.spawn({
94
+ name: "test-agent",
95
+ cwd: "/home/user/project",
96
+ command: "pi",
97
+ env: { OTHER: "ignored" },
98
+ });
99
+
100
+ expect(result).toBe("surface-99");
101
+ expect(mockExecCommand).toHaveBeenCalledWith(
102
+ "cmux",
103
+ ["new-split", "right", "--command", "pi"]
104
+ );
105
+ });
106
+
107
+ it("should throw on spawn failure", () => {
108
+ mockExecCommand.mockReturnValue({
109
+ stdout: "",
110
+ stderr: "cmux not found",
111
+ status: 1
112
+ });
113
+
114
+ expect(() => adapter.spawn({
115
+ name: "test-agent",
116
+ cwd: "/home/user/project",
117
+ command: "pi",
118
+ env: {},
119
+ })).toThrow("cmux new-split failed with status 1");
120
+ });
121
+
122
+ it("should throw on unexpected output format", () => {
123
+ mockExecCommand.mockReturnValue({
124
+ stdout: "ERROR something went wrong",
125
+ stderr: "",
126
+ status: 0
127
+ });
128
+
129
+ expect(() => adapter.spawn({
130
+ name: "test-agent",
131
+ cwd: "/home/user/project",
132
+ command: "pi",
133
+ env: {},
134
+ })).toThrow("cmux new-split returned unexpected output");
135
+ });
136
+ });
137
+
138
+ describe("kill", () => {
139
+ it("should kill a pane by surface ID", () => {
140
+ mockExecCommand.mockReturnValue({ stdout: "", stderr: "", status: 0 });
141
+
142
+ adapter.kill("surface-42");
143
+
144
+ expect(mockExecCommand).toHaveBeenCalledWith(
145
+ "cmux",
146
+ ["close-surface", "--surface", "surface-42"]
147
+ );
148
+ });
149
+
150
+ it("should be idempotent - no error on empty pane ID", () => {
151
+ adapter.kill("");
152
+ adapter.kill(undefined as unknown as string);
153
+ expect(mockExecCommand).not.toHaveBeenCalled();
154
+ });
155
+ });
156
+
157
+ describe("isAlive", () => {
158
+ it("should return true if pane exists", () => {
159
+ mockExecCommand.mockReturnValue({
160
+ stdout: "surface-1\nsurface-42\nsurface-99",
161
+ stderr: "",
162
+ status: 0
163
+ });
164
+
165
+ expect(adapter.isAlive("surface-42")).toBe(true);
166
+ });
167
+
168
+ it("should return false if pane does not exist", () => {
169
+ mockExecCommand.mockReturnValue({
170
+ stdout: "surface-1\nsurface-99",
171
+ stderr: "",
172
+ status: 0
173
+ });
174
+
175
+ expect(adapter.isAlive("surface-42")).toBe(false);
176
+ });
177
+
178
+ it("should return false on error", () => {
179
+ mockExecCommand.mockImplementation(() => {
180
+ throw new Error("cmux error");
181
+ });
182
+
183
+ expect(adapter.isAlive("surface-42")).toBe(false);
184
+ });
185
+
186
+ it("should return false for empty pane ID", () => {
187
+ expect(adapter.isAlive("")).toBe(false);
188
+ expect(adapter.isAlive(undefined as unknown as string)).toBe(false);
189
+ });
190
+ });
191
+
192
+ describe("setTitle", () => {
193
+ it("should set the tab title", () => {
194
+ mockExecCommand.mockReturnValue({ stdout: "", stderr: "", status: 0 });
195
+
196
+ adapter.setTitle("My Team");
197
+
198
+ expect(mockExecCommand).toHaveBeenCalledWith(
199
+ "cmux",
200
+ ["rename-tab", "My Team"]
201
+ );
202
+ });
203
+
204
+ it("should silently ignore errors", () => {
205
+ mockExecCommand.mockImplementation(() => {
206
+ throw new Error("cmux error");
207
+ });
208
+
209
+ // Should not throw
210
+ expect(() => adapter.setTitle("My Team")).not.toThrow();
211
+ });
212
+ });
213
+
214
+ describe("supportsWindows", () => {
215
+ it("should return true", () => {
216
+ expect(adapter.supportsWindows()).toBe(true);
217
+ });
218
+ });
219
+
220
+ describe("spawnWindow", () => {
221
+ it("should spawn a new window with command", () => {
222
+ mockExecCommand
223
+ .mockReturnValueOnce({ stdout: "OK window-1", stderr: "", status: 0 })
224
+ .mockReturnValueOnce({ stdout: "", stderr: "", status: 0 })
225
+ .mockReturnValueOnce({ stdout: "", stderr: "", status: 0 });
226
+
227
+ const result = adapter.spawnWindow({
228
+ name: "test-agent",
229
+ cwd: "/home/user/project",
230
+ command: "pi",
231
+ env: { PI_TEAM: "myteam" },
232
+ teamName: "Team Alpha",
233
+ });
234
+
235
+ expect(result).toBe("window-1");
236
+ expect(mockExecCommand).toHaveBeenCalledWith(
237
+ "cmux",
238
+ ["new-window"]
239
+ );
240
+ expect(mockExecCommand).toHaveBeenCalledWith(
241
+ "cmux",
242
+ ["new-workspace", "--window", "window-1", "--command", "env PI_TEAM=myteam pi"]
243
+ );
244
+ expect(mockExecCommand).toHaveBeenCalledWith(
245
+ "cmux",
246
+ ["rename-window", "--window", "window-1", "Team Alpha"]
247
+ );
248
+ });
249
+
250
+ it("should throw on new-window failure", () => {
251
+ mockExecCommand.mockReturnValue({
252
+ stdout: "",
253
+ stderr: "error",
254
+ status: 1
255
+ });
256
+
257
+ expect(() => adapter.spawnWindow({
258
+ name: "test",
259
+ cwd: "/home/user",
260
+ command: "pi",
261
+ env: {},
262
+ })).toThrow("cmux new-window failed with status 1");
263
+ });
264
+ });
265
+
266
+ describe("window operations", () => {
267
+ it("should set window title", () => {
268
+ mockExecCommand.mockReturnValue({ stdout: "", stderr: "", status: 0 });
269
+
270
+ adapter.setWindowTitle("window-1", "New Title");
271
+
272
+ expect(mockExecCommand).toHaveBeenCalledWith(
273
+ "cmux",
274
+ ["rename-window", "--window", "window-1", "New Title"]
275
+ );
276
+ });
277
+
278
+ it("should kill a window", () => {
279
+ mockExecCommand.mockReturnValue({ stdout: "", stderr: "", status: 0 });
280
+
281
+ adapter.killWindow("window-1");
282
+
283
+ expect(mockExecCommand).toHaveBeenCalledWith(
284
+ "cmux",
285
+ ["close-window", "--window", "window-1"]
286
+ );
287
+ });
288
+
289
+ it("should check if window is alive", () => {
290
+ mockExecCommand.mockReturnValue({
291
+ stdout: "window-1\nwindow-2",
292
+ stderr: "",
293
+ status: 0
294
+ });
295
+
296
+ expect(adapter.isWindowAlive("window-1")).toBe(true);
297
+ expect(adapter.isWindowAlive("window-99")).toBe(false);
298
+ });
299
+
300
+ it("should handle empty window IDs gracefully", () => {
301
+ adapter.killWindow("");
302
+ adapter.killWindow(undefined as unknown as string);
303
+ expect(mockExecCommand).not.toHaveBeenCalled();
304
+
305
+ expect(adapter.isWindowAlive("")).toBe(false);
306
+ expect(adapter.isWindowAlive(undefined as unknown as string)).toBe(false);
307
+ });
308
+ });
309
+ });
@@ -10,7 +10,11 @@ export class CmuxAdapter implements TerminalAdapter {
10
10
  readonly name = "cmux";
11
11
 
12
12
  detect(): boolean {
13
- // Check for CMUX specific environment variables
13
+ // Defensive: Don't detect cmux if we're inside tmux or Zellij
14
+ // This prevents false positives in nested terminal scenarios
15
+ if (process.env.TMUX || process.env.ZELLIJ) {
16
+ return false;
17
+ }
14
18
  return !!process.env.CMUX_SOCKET_PATH || !!process.env.CMUX_WORKSPACE_ID;
15
19
  }
16
20
 
@@ -7,8 +7,9 @@
7
7
 
8
8
  import { TerminalAdapter } from "../utils/terminal-adapter";
9
9
  import { TmuxAdapter } from "./tmux-adapter";
10
- import { Iterm2Adapter } from "./iterm2-adapter";
11
10
  import { ZellijAdapter } from "./zellij-adapter";
11
+ import { CmuxAdapter } from "./cmux-adapter";
12
+ import { Iterm2Adapter } from "./iterm2-adapter";
12
13
  import { WezTermAdapter } from "./wezterm-adapter";
13
14
  import { WindowsAdapter } from "./windows-adapter";
14
15
 
@@ -18,13 +19,15 @@ import { WindowsAdapter } from "./windows-adapter";
18
19
  * Detection order (first match wins):
19
20
  * 1. tmux - if TMUX env is set
20
21
  * 2. Zellij - if ZELLIJ env is set and not in tmux
21
- * 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
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
+ * 3. cmux - if CMUX_SOCKET_PATH or CMUX_WORKSPACE_ID env is set
23
+ * 4. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij/cmux
24
+ * 5. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij/cmux
25
+ * 6. Windows - if platform is win32 and not in tmux/zellij/cmux/iTerm2/WezTerm
24
26
  */
25
27
  const adapters: TerminalAdapter[] = [
26
28
  new TmuxAdapter(),
27
29
  new ZellijAdapter(),
30
+ new CmuxAdapter(),
28
31
  new Iterm2Adapter(),
29
32
  new WezTermAdapter(),
30
33
  new WindowsAdapter(),
@@ -41,9 +44,10 @@ let cachedAdapter: TerminalAdapter | null = null;
41
44
  * Detection order (first match wins):
42
45
  * 1. tmux - if TMUX env is set
43
46
  * 2. Zellij - if ZELLIJ env is set and not in tmux
44
- * 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
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
47
+ * 3. cmux - if CMUX_SOCKET_PATH or CMUX_WORKSPACE_ID env is set
48
+ * 4. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij/cmux
49
+ * 5. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij/cmux
50
+ * 6. Windows - if platform is win32 and not in tmux/zellij/cmux/iTerm2/WezTerm
47
51
  *
48
52
  * @returns The detected terminal adapter, or null if none detected
49
53
  */
@@ -65,7 +69,7 @@ export function getTerminalAdapter(): TerminalAdapter | null {
65
69
  /**
66
70
  * Get a specific terminal adapter by name.
67
71
  *
68
- * @param name - The adapter name (e.g., "tmux", "iTerm2", "zellij", "WezTerm", "Windows")
72
+ * @param name - The adapter name (e.g., "tmux", "zellij", "cmux", "iTerm2", "WezTerm", "Windows")
69
73
  * @returns The adapter instance, or undefined if not found
70
74
  */
71
75
  export function getAdapterByName(name: string): TerminalAdapter | undefined {
@@ -107,7 +111,7 @@ export function hasTerminalAdapter(): boolean {
107
111
  /**
108
112
  * Check if the current terminal supports spawning separate OS windows.
109
113
  *
110
- * @returns true if the detected terminal supports windows (iTerm2, WezTerm, Windows)
114
+ * @returns true if the detected terminal supports windows (iTerm2, WezTerm, Windows, cmux)
111
115
  */
112
116
  export function supportsWindows(): boolean {
113
117
  const adapter = getTerminalAdapter();
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Tmux Adapter Tests
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import { TmuxAdapter } from "./tmux-adapter";
7
+ import * as terminalAdapter from "../utils/terminal-adapter";
8
+
9
+ describe("TmuxAdapter", () => {
10
+ let adapter: TmuxAdapter;
11
+ let mockExecCommand: ReturnType<typeof vi.spyOn>;
12
+ const originalTmux = process.env.TMUX;
13
+ const originalTmuxPane = process.env.TMUX_PANE;
14
+
15
+ beforeEach(() => {
16
+ adapter = new TmuxAdapter();
17
+ mockExecCommand = vi.spyOn(terminalAdapter, "execCommand");
18
+ process.env.TMUX = "/tmp/tmux-1000/default,123,0";
19
+ process.env.TMUX_PANE = "%16";
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+
25
+ if (originalTmux === undefined) delete process.env.TMUX;
26
+ else process.env.TMUX = originalTmux;
27
+
28
+ if (originalTmuxPane === undefined) delete process.env.TMUX_PANE;
29
+ else process.env.TMUX_PANE = originalTmuxPane;
30
+ });
31
+
32
+ it("should have the correct name", () => {
33
+ expect(adapter.name).toBe("tmux");
34
+ });
35
+
36
+ it("should detect tmux when TMUX is set", () => {
37
+ expect(adapter.detect()).toBe(true);
38
+ });
39
+
40
+ it("should target the originating pane and its window when spawning", () => {
41
+ mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
42
+ if (
43
+ args[0] === "display-message" &&
44
+ args[1] === "-p" &&
45
+ args[2] === "-t" &&
46
+ args[3] === "%16" &&
47
+ args[4] === "#{pane_id}"
48
+ ) {
49
+ return { stdout: "%16", stderr: "", status: 0 };
50
+ }
51
+
52
+ if (args[0] === "split-window") {
53
+ return { stdout: "%42", stderr: "", status: 0 };
54
+ }
55
+
56
+ if (
57
+ args[0] === "display-message" &&
58
+ args[1] === "-p" &&
59
+ args[2] === "-t" &&
60
+ args[3] === "%16" &&
61
+ args[4] === "#{window_id}"
62
+ ) {
63
+ return { stdout: "@7", stderr: "", status: 0 };
64
+ }
65
+
66
+ return { stdout: "", stderr: "", status: 0 };
67
+ });
68
+
69
+ const paneId = adapter.spawn({
70
+ name: "agent-1",
71
+ cwd: "/tmp/project",
72
+ command: "pi",
73
+ env: { PI_TEAM_NAME: "team-1", PI_AGENT_NAME: "agent-1", OTHER: "ignored" },
74
+ });
75
+
76
+ expect(paneId).toBe("%42");
77
+ expect(mockExecCommand).toHaveBeenCalledWith(
78
+ "tmux",
79
+ [
80
+ "split-window",
81
+ "-h", "-dP",
82
+ "-F", "#{pane_id}",
83
+ "-t", "%16",
84
+ "-c", "/tmp/project",
85
+ "env", "PI_TEAM_NAME=team-1", "PI_AGENT_NAME=agent-1",
86
+ "sh", "-c", "pi",
87
+ ]
88
+ );
89
+ expect(mockExecCommand).toHaveBeenCalledWith(
90
+ "tmux",
91
+ ["set-window-option", "-t", "@7", "main-pane-width", "60%"]
92
+ );
93
+ expect(mockExecCommand).toHaveBeenCalledWith(
94
+ "tmux",
95
+ ["select-layout", "-t", "@7", "main-vertical"]
96
+ );
97
+ });
98
+
99
+ it("should prefer an explicit anchor pane when spawning", () => {
100
+ mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
101
+ if (
102
+ args[0] === "display-message" &&
103
+ args[1] === "-p" &&
104
+ args[2] === "-t" &&
105
+ args[3] === "%3" &&
106
+ args[4] === "#{pane_id}"
107
+ ) {
108
+ return { stdout: "%3", stderr: "", status: 0 };
109
+ }
110
+
111
+ if (args[0] === "split-window") {
112
+ return { stdout: "%42", stderr: "", status: 0 };
113
+ }
114
+
115
+ if (
116
+ args[0] === "display-message" &&
117
+ args[1] === "-p" &&
118
+ args[2] === "-t" &&
119
+ args[3] === "%3" &&
120
+ args[4] === "#{window_id}"
121
+ ) {
122
+ return { stdout: "@9", stderr: "", status: 0 };
123
+ }
124
+
125
+ return { stdout: "", stderr: "", status: 0 };
126
+ });
127
+
128
+ const paneId = adapter.spawn({
129
+ name: "agent-1",
130
+ cwd: "/tmp/project",
131
+ command: "pi",
132
+ env: { PI_TEAM_NAME: "team-1", PI_AGENT_NAME: "agent-1" },
133
+ anchorPaneId: "%3",
134
+ });
135
+
136
+ expect(paneId).toBe("%42");
137
+ expect(mockExecCommand).toHaveBeenCalledWith(
138
+ "tmux",
139
+ [
140
+ "split-window",
141
+ "-h", "-dP",
142
+ "-F", "#{pane_id}",
143
+ "-t", "%3",
144
+ "-c", "/tmp/project",
145
+ "env", "PI_TEAM_NAME=team-1", "PI_AGENT_NAME=agent-1",
146
+ "sh", "-c", "pi",
147
+ ]
148
+ );
149
+ expect(mockExecCommand).toHaveBeenCalledWith(
150
+ "tmux",
151
+ ["set-window-option", "-t", "@9", "main-pane-width", "60%"]
152
+ );
153
+ expect(mockExecCommand).toHaveBeenCalledWith(
154
+ "tmux",
155
+ ["select-layout", "-t", "@9", "main-vertical"]
156
+ );
157
+ });
158
+
159
+ it("should fall back to the current pane when the explicit anchor is stale", () => {
160
+ mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
161
+ if (
162
+ args[0] === "display-message" &&
163
+ args[1] === "-p" &&
164
+ args[2] === "-t" &&
165
+ args[3] === "%3" &&
166
+ args[4] === "#{pane_id}"
167
+ ) {
168
+ return { stdout: "", stderr: "no such pane", status: 1 };
169
+ }
170
+
171
+ if (
172
+ args[0] === "display-message" &&
173
+ args[1] === "-p" &&
174
+ args[2] === "-t" &&
175
+ args[3] === "%16" &&
176
+ args[4] === "#{pane_id}"
177
+ ) {
178
+ return { stdout: "%16", stderr: "", status: 0 };
179
+ }
180
+
181
+ if (args[0] === "split-window") {
182
+ return { stdout: "%42", stderr: "", status: 0 };
183
+ }
184
+
185
+ if (
186
+ args[0] === "display-message" &&
187
+ args[1] === "-p" &&
188
+ args[2] === "-t" &&
189
+ args[3] === "%16" &&
190
+ args[4] === "#{window_id}"
191
+ ) {
192
+ return { stdout: "@7", stderr: "", status: 0 };
193
+ }
194
+
195
+ return { stdout: "", stderr: "", status: 0 };
196
+ });
197
+
198
+ const paneId = adapter.spawn({
199
+ name: "agent-1",
200
+ cwd: "/tmp/project",
201
+ command: "pi",
202
+ env: { PI_TEAM_NAME: "team-1", PI_AGENT_NAME: "agent-1" },
203
+ anchorPaneId: "%3",
204
+ });
205
+
206
+ expect(paneId).toBe("%42");
207
+ expect(mockExecCommand).toHaveBeenCalledWith(
208
+ "tmux",
209
+ [
210
+ "split-window",
211
+ "-h", "-dP",
212
+ "-F", "#{pane_id}",
213
+ "-t", "%16",
214
+ "-c", "/tmp/project",
215
+ "env", "PI_TEAM_NAME=team-1", "PI_AGENT_NAME=agent-1",
216
+ "sh", "-c", "pi",
217
+ ]
218
+ );
219
+ });
220
+
221
+ it("should target the current pane when setting the title", () => {
222
+ mockExecCommand.mockReturnValue({ stdout: "", stderr: "", status: 0 });
223
+
224
+ adapter.setTitle("team: agent-1");
225
+
226
+ expect(mockExecCommand).toHaveBeenCalledWith(
227
+ "tmux",
228
+ ["select-pane", "-t", "%16", "-T", "team: agent-1"]
229
+ );
230
+ });
231
+ });
@@ -15,19 +15,70 @@ export class TmuxAdapter implements TerminalAdapter {
15
15
  return !!process.env.TMUX;
16
16
  }
17
17
 
18
+ private getCurrentPaneId(): string | null {
19
+ const paneId = process.env.TMUX_PANE?.trim();
20
+ return paneId ? paneId : null;
21
+ }
22
+
23
+ private getWindowIdForPane(paneId: string | null | undefined): string | null {
24
+ if (!paneId) return null;
25
+
26
+ try {
27
+ const result = execCommand("tmux", ["display-message", "-p", "-t", paneId, "#{window_id}"]);
28
+ if (result.status !== 0) return null;
29
+
30
+ const windowId = result.stdout.trim();
31
+ return windowId || null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ private isPaneUsable(paneId: string | null | undefined): paneId is string {
38
+ if (!paneId) return false;
39
+
40
+ try {
41
+ const result = execCommand("tmux", ["display-message", "-p", "-t", paneId, "#{pane_id}"]);
42
+ return result.status === 0 && result.stdout.trim() === paneId;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ private getOriginPaneId(preferredPaneId?: string | null): string | null {
49
+ if (this.isPaneUsable(preferredPaneId)) {
50
+ return preferredPaneId;
51
+ }
52
+
53
+ const currentPaneId = this.getCurrentPaneId();
54
+ if (this.isPaneUsable(currentPaneId)) {
55
+ return currentPaneId;
56
+ }
57
+
58
+ return null;
59
+ }
60
+
18
61
  spawn(options: SpawnOptions): string {
19
62
  const envArgs = Object.entries(options.env)
20
63
  .filter(([k]) => k.startsWith("PI_"))
21
64
  .map(([k, v]) => `${k}=${v}`);
22
65
 
66
+ const originPaneId = this.getOriginPaneId(options.anchorPaneId);
23
67
  const tmuxArgs = [
24
68
  "split-window",
25
69
  "-h", "-dP",
26
70
  "-F", "#{pane_id}",
71
+ ];
72
+
73
+ if (originPaneId) {
74
+ tmuxArgs.push("-t", originPaneId);
75
+ }
76
+
77
+ tmuxArgs.push(
27
78
  "-c", options.cwd,
28
79
  "env", ...envArgs,
29
80
  "sh", "-c", options.command
30
- ];
81
+ );
31
82
 
32
83
  const result = execCommand("tmux", tmuxArgs);
33
84
 
@@ -35,11 +86,20 @@ export class TmuxAdapter implements TerminalAdapter {
35
86
  throw new Error(`tmux spawn failed with status ${result.status}: ${result.stderr}`);
36
87
  }
37
88
 
38
- // Apply layout after spawning
39
- execCommand("tmux", ["set-window-option", "main-pane-width", "60%"]);
40
- execCommand("tmux", ["select-layout", "main-vertical"]);
89
+ const newPaneId = result.stdout.trim();
90
+ const layoutTarget = this.getWindowIdForPane(originPaneId) ?? this.getWindowIdForPane(newPaneId);
91
+
92
+ // Apply layout to the exact window that contains the spawned pane so the
93
+ // split always stays anchored to the intended tmux window.
94
+ if (layoutTarget) {
95
+ execCommand("tmux", ["set-window-option", "-t", layoutTarget, "main-pane-width", "60%"]);
96
+ execCommand("tmux", ["select-layout", "-t", layoutTarget, "main-vertical"]);
97
+ } else {
98
+ execCommand("tmux", ["set-window-option", "main-pane-width", "60%"]);
99
+ execCommand("tmux", ["select-layout", "main-vertical"]);
100
+ }
41
101
 
42
- return result.stdout.trim();
102
+ return newPaneId;
43
103
  }
44
104
 
45
105
  kill(paneId: string): void {
@@ -69,7 +129,11 @@ export class TmuxAdapter implements TerminalAdapter {
69
129
 
70
130
  setTitle(title: string): void {
71
131
  try {
72
- execCommand("tmux", ["select-pane", "-T", title]);
132
+ const paneId = this.getCurrentPaneId();
133
+ const args = paneId
134
+ ? ["select-pane", "-t", paneId, "-T", title]
135
+ : ["select-pane", "-T", title];
136
+ execCommand("tmux", args);
73
137
  } catch {
74
138
  // Ignore errors
75
139
  }
@@ -21,6 +21,8 @@ export interface SpawnOptions {
21
21
  env: Record<string, string>;
22
22
  /** Team name for window title formatting (e.g., "team: agent") */
23
23
  teamName?: string;
24
+ /** Optional pane ID to anchor pane-based layouts to a specific origin pane */
25
+ anchorPaneId?: string;
24
26
  }
25
27
 
26
28
  /**