pi-teams 0.8.7 → 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.
- package/extensions/index.ts +83 -5
- 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/adapters/windows-adapter.test.ts +86 -155
- package/src/utils/paths.ts +4 -0
- package/src/utils/runtime.test.ts +171 -0
- package/src/utils/runtime.ts +168 -0
- package/src/utils/terminal-adapter.ts +2 -0
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
@@ -378,11 +409,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
378
409
|
}
|
|
379
410
|
}
|
|
380
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
|
+
|
|
381
417
|
terminalId = terminal.spawn({
|
|
382
418
|
name: safeName,
|
|
383
419
|
cwd: params.cwd,
|
|
384
420
|
command: piCmd,
|
|
385
421
|
env: env,
|
|
422
|
+
anchorPaneId,
|
|
386
423
|
});
|
|
387
424
|
await teams.updateMember(safeTeamName, safeName, { tmuxPaneId: terminalId });
|
|
388
425
|
}
|
|
@@ -480,6 +517,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
480
517
|
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
|
|
481
518
|
const targetAgent = params.agent_name || agentName;
|
|
482
519
|
const msgs = await messaging.readInbox(params.team_name, targetAgent, params.unread_only);
|
|
520
|
+
|
|
521
|
+
if (isTeammate && teamName && params.team_name === teamName && targetAgent === agentName) {
|
|
522
|
+
await runtime.writeRuntimeStatus(teamName, agentName, {
|
|
523
|
+
lastHeartbeatAt: Date.now(),
|
|
524
|
+
lastInboxReadAt: Date.now(),
|
|
525
|
+
ready: true,
|
|
526
|
+
lastError: undefined,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
483
530
|
return {
|
|
484
531
|
content: [{ type: "text", text: JSON.stringify(msgs, null, 2) }],
|
|
485
532
|
details: { messages: msgs },
|
|
@@ -643,9 +690,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
643
690
|
}
|
|
644
691
|
|
|
645
692
|
const unreadCount = (await messaging.readInbox(params.team_name, params.agent_name, true, false)).length;
|
|
693
|
+
const runtimeStatus = await runtime.readRuntimeStatus(params.team_name, params.agent_name);
|
|
694
|
+
const now = Date.now();
|
|
695
|
+
const hasRecentHeartbeat = !!runtimeStatus?.lastHeartbeatAt
|
|
696
|
+
&& (now - runtimeStatus.lastHeartbeatAt) <= runtime.HEARTBEAT_STALE_MS;
|
|
697
|
+
const startupStalled = alive
|
|
698
|
+
&& unreadCount > 0
|
|
699
|
+
&& (now - member.joinedAt) > runtime.STARTUP_STALL_MS
|
|
700
|
+
&& !(runtimeStatus?.ready);
|
|
701
|
+
const health = !alive
|
|
702
|
+
? "dead"
|
|
703
|
+
: startupStalled
|
|
704
|
+
? "stalled"
|
|
705
|
+
: runtimeStatus?.ready
|
|
706
|
+
? (hasRecentHeartbeat ? "healthy" : "idle")
|
|
707
|
+
: "starting";
|
|
708
|
+
|
|
709
|
+
const details = {
|
|
710
|
+
alive,
|
|
711
|
+
unreadCount,
|
|
712
|
+
health,
|
|
713
|
+
agentLoopReady: !!runtimeStatus?.ready,
|
|
714
|
+
hasRecentHeartbeat,
|
|
715
|
+
startupStalled,
|
|
716
|
+
runtime: runtimeStatus,
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// Clean up runtime status for dead teammates
|
|
720
|
+
if (!alive && runtimeStatus) {
|
|
721
|
+
await runtime.deleteRuntimeStatus(params.team_name, params.agent_name);
|
|
722
|
+
}
|
|
723
|
+
|
|
646
724
|
return {
|
|
647
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
648
|
-
details
|
|
725
|
+
content: [{ type: "text", text: JSON.stringify(details, null, 2) }],
|
|
726
|
+
details,
|
|
649
727
|
};
|
|
650
728
|
},
|
|
651
729
|
});
|
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();
|