pi-teams 0.9.0 → 0.9.5
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/extensions/index.ts +165 -11
- package/package.json +1 -1
- package/src/adapters/cmux-adapter.test.ts +309 -0
- package/src/adapters/cmux-adapter.ts +5 -1
- package/src/adapters/terminal-registry.ts +13 -9
- package/src/adapters/tmux-adapter.test.ts +231 -0
- package/src/adapters/tmux-adapter.ts +70 -6
- package/src/utils/paths.ts +4 -0
- package/src/utils/terminal-adapter.ts +2 -0
package/extensions/index.ts
CHANGED
|
@@ -142,10 +142,134 @@ function resolveModelWithProvider(modelName: string): string | null {
|
|
|
142
142
|
return null;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Find the team this session is the lead for (if any).
|
|
147
|
+
* Checks the lead-session.json file to match PID.
|
|
148
|
+
*/
|
|
149
|
+
function findLeadTeamForSession(): string | null {
|
|
150
|
+
try {
|
|
151
|
+
const teamsDir = paths.TEAMS_DIR;
|
|
152
|
+
if (!fs.existsSync(teamsDir)) return null;
|
|
153
|
+
|
|
154
|
+
for (const teamDir of fs.readdirSync(teamsDir)) {
|
|
155
|
+
const sessionFile = paths.leadSessionPath(teamDir);
|
|
156
|
+
if (fs.existsSync(sessionFile)) {
|
|
157
|
+
try {
|
|
158
|
+
const session = JSON.parse(fs.readFileSync(sessionFile, "utf-8"));
|
|
159
|
+
if (session.pid === process.pid) {
|
|
160
|
+
return teamDir;
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
// Ignore corrupted session files
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Register this session as the lead for a team.
|
|
175
|
+
*/
|
|
176
|
+
function registerLeadSession(teamName: string) {
|
|
177
|
+
const sessionFile = paths.leadSessionPath(teamName);
|
|
178
|
+
const dir = path.dirname(sessionFile);
|
|
179
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
180
|
+
fs.writeFileSync(sessionFile, JSON.stringify({
|
|
181
|
+
pid: process.pid,
|
|
182
|
+
startedAt: Date.now(),
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check if a process with the given PID is still alive.
|
|
188
|
+
*/
|
|
189
|
+
function isPidAlive(pid: number): boolean {
|
|
190
|
+
try {
|
|
191
|
+
process.kill(pid, 0); // Signal 0 = check if process exists
|
|
192
|
+
return true;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Clean up a stale team if the lead process is dead.
|
|
200
|
+
* Kills all teammate panes/windows and removes all state files.
|
|
201
|
+
* Returns true if cleanup was performed, false otherwise.
|
|
202
|
+
*/
|
|
203
|
+
function cleanupStaleTeam(teamName: string, terminal: any): boolean {
|
|
204
|
+
const sessionFile = paths.leadSessionPath(teamName);
|
|
205
|
+
const configFile = paths.configPath(teamName);
|
|
206
|
+
|
|
207
|
+
if (!fs.existsSync(sessionFile) || !fs.existsSync(configFile)) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const session = JSON.parse(fs.readFileSync(sessionFile, "utf-8"));
|
|
213
|
+
|
|
214
|
+
// Only cleanup if the lead PID is actually dead
|
|
215
|
+
if (session.pid && !isPidAlive(session.pid)) {
|
|
216
|
+
// Read config to get member info for cleanup
|
|
217
|
+
try {
|
|
218
|
+
const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
219
|
+
|
|
220
|
+
// Kill all teammate panes/windows
|
|
221
|
+
for (const member of config.members || []) {
|
|
222
|
+
if (member.name === "team-lead") continue;
|
|
223
|
+
|
|
224
|
+
// Kill via PID file
|
|
225
|
+
const pidFile = path.join(paths.teamDir(teamName), `${member.name}.pid`);
|
|
226
|
+
if (fs.existsSync(pidFile)) {
|
|
227
|
+
try {
|
|
228
|
+
const pid = fs.readFileSync(pidFile, "utf-8").trim();
|
|
229
|
+
process.kill(parseInt(pid), "SIGKILL");
|
|
230
|
+
fs.unlinkSync(pidFile);
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Kill via terminal adapter
|
|
235
|
+
if (terminal) {
|
|
236
|
+
if (member.windowId) {
|
|
237
|
+
try { terminal.killWindow(member.windowId); } catch {}
|
|
238
|
+
}
|
|
239
|
+
if (member.tmuxPaneId) {
|
|
240
|
+
try { terminal.kill(member.tmuxPaneId); } catch {}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch {}
|
|
245
|
+
|
|
246
|
+
// Delete entire team directory
|
|
247
|
+
const teamDirectory = paths.teamDir(teamName);
|
|
248
|
+
if (fs.existsSync(teamDirectory)) {
|
|
249
|
+
fs.rmSync(teamDirectory, { recursive: true });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Delete tasks directory
|
|
253
|
+
const tasksDirectory = paths.taskDir(teamName);
|
|
254
|
+
if (fs.existsSync(tasksDirectory)) {
|
|
255
|
+
fs.rmSync(tasksDirectory, { recursive: true });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
} catch {}
|
|
261
|
+
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
145
265
|
export default function (pi: ExtensionAPI) {
|
|
146
266
|
const isTeammate = !!process.env.PI_AGENT_NAME;
|
|
147
267
|
const agentName = process.env.PI_AGENT_NAME || "team-lead";
|
|
148
|
-
const
|
|
268
|
+
const envTeamName = process.env.PI_TEAM_NAME;
|
|
269
|
+
|
|
270
|
+
// For leads without PI_TEAM_NAME, check if we're registered as lead for a team
|
|
271
|
+
const detectedTeamName = envTeamName || findLeadTeamForSession();
|
|
272
|
+
const teamName = detectedTeamName;
|
|
149
273
|
|
|
150
274
|
const terminal = getTerminalAdapter();
|
|
151
275
|
|
|
@@ -181,27 +305,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
181
305
|
setTimeout(() => {
|
|
182
306
|
pi.sendUserMessage(`I am starting my work as '${agentName}' on team '${teamName}'. Checking my inbox for instructions...`);
|
|
183
307
|
}, 1000);
|
|
308
|
+
} else if (teamName) {
|
|
309
|
+
ctx.ui.setStatus("pi-teams", `Lead @ ${teamName}`);
|
|
310
|
+
}
|
|
184
311
|
|
|
312
|
+
// Inbox polling for BOTH teammates AND team-leads (anyone with teamName)
|
|
313
|
+
if (teamName) {
|
|
185
314
|
setInterval(async () => {
|
|
186
|
-
if (ctx.isIdle()
|
|
315
|
+
if (ctx.isIdle()) {
|
|
187
316
|
try {
|
|
188
317
|
const unread = await messaging.readInbox(teamName, agentName, true, false);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
318
|
+
if (isTeammate) {
|
|
319
|
+
await runtime.writeRuntimeStatus(teamName, agentName, {
|
|
320
|
+
lastHeartbeatAt: Date.now(),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
192
323
|
if (unread.length > 0) {
|
|
193
324
|
pi.sendUserMessage(`I have ${unread.length} new message(s) in my inbox. Reading them now...`);
|
|
194
325
|
}
|
|
195
326
|
} catch (e) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
327
|
+
if (isTeammate) {
|
|
328
|
+
await runtime.writeRuntimeStatus(teamName, agentName, {
|
|
329
|
+
lastHeartbeatAt: Date.now(),
|
|
330
|
+
lastError: runtime.createRuntimeError(e),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
200
333
|
}
|
|
201
334
|
}
|
|
202
335
|
}, 30000);
|
|
203
|
-
} else if (teamName) {
|
|
204
|
-
ctx.ui.setStatus("pi-teams", `Lead @ ${teamName}`);
|
|
205
336
|
}
|
|
206
337
|
});
|
|
207
338
|
|
|
@@ -289,7 +420,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
289
420
|
separate_windows: Type.Optional(Type.Boolean({ default: false, description: "Open teammates in separate OS windows instead of panes" })),
|
|
290
421
|
}),
|
|
291
422
|
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
|
|
423
|
+
// Auto-cleanup stale team if the previous lead process is dead
|
|
424
|
+
// This handles the case where a session was aborted and restarted
|
|
425
|
+
if (teams.teamExists(params.team_name)) {
|
|
426
|
+
cleanupStaleTeam(params.team_name, terminal);
|
|
427
|
+
}
|
|
428
|
+
|
|
292
429
|
const config = teams.createTeam(params.team_name, "local-session", "lead-agent", params.description, params.default_model, params.separate_windows);
|
|
430
|
+
// Register this session as the lead so it can receive inbox messages
|
|
431
|
+
registerLeadSession(params.team_name);
|
|
293
432
|
return {
|
|
294
433
|
content: [{ type: "text", text: `Team ${params.team_name} created.` }],
|
|
295
434
|
details: { config },
|
|
@@ -324,6 +463,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
324
463
|
}
|
|
325
464
|
|
|
326
465
|
const teamConfig = await teams.readConfig(safeTeamName);
|
|
466
|
+
|
|
467
|
+
// Check if a teammate with this name already exists - kill them first
|
|
468
|
+
// This handles the case where the user aborts mid-execution and restarts
|
|
469
|
+
const existingMember = teamConfig.members.find(m => m.name === safeName && m.agentType === "teammate");
|
|
470
|
+
if (existingMember) {
|
|
471
|
+
await killTeammate(safeTeamName, existingMember);
|
|
472
|
+
await teams.removeMember(safeTeamName, safeName);
|
|
473
|
+
}
|
|
474
|
+
|
|
327
475
|
let chosenModel = params.model || teamConfig.defaultModel;
|
|
328
476
|
|
|
329
477
|
// Resolve model to provider/model format
|
|
@@ -409,11 +557,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
409
557
|
}
|
|
410
558
|
}
|
|
411
559
|
|
|
560
|
+
const leadMember = teamConfig.members.find(m => m.name === "team-lead");
|
|
561
|
+
const anchorPaneId = terminal.name === "tmux"
|
|
562
|
+
? leadMember?.tmuxPaneId || process.env.TMUX_PANE || undefined
|
|
563
|
+
: undefined;
|
|
564
|
+
|
|
412
565
|
terminalId = terminal.spawn({
|
|
413
566
|
name: safeName,
|
|
414
567
|
cwd: params.cwd,
|
|
415
568
|
command: piCmd,
|
|
416
569
|
env: env,
|
|
570
|
+
anchorPaneId,
|
|
417
571
|
});
|
|
418
572
|
await teams.updateMember(safeTeamName, safeName, { tmuxPaneId: terminalId });
|
|
419
573
|
}
|
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
22
|
-
* 4.
|
|
23
|
-
* 5.
|
|
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.
|
|
45
|
-
* 4.
|
|
46
|
-
* 5.
|
|
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", "
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/paths.ts
CHANGED
|
@@ -39,3 +39,7 @@ export function runtimeStatusPath(teamName: string, agentName: string) {
|
|
|
39
39
|
export function configPath(teamName: string) {
|
|
40
40
|
return path.join(teamDir(teamName), "config.json");
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
export function leadSessionPath(teamName: string) {
|
|
44
|
+
return path.join(teamDir(teamName), "lead-session.json");
|
|
45
|
+
}
|
|
@@ -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
|
/**
|