@ebowwa/terminal 0.2.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/dist/client.d.ts +15 -0
- package/dist/client.js +45 -0
- package/dist/error.d.ts +8 -0
- package/dist/error.js +12 -0
- package/dist/exec.d.ts +47 -0
- package/dist/exec.js +107 -0
- package/dist/files.d.ts +124 -0
- package/dist/files.js +436 -0
- package/dist/fingerprint.d.ts +67 -0
- package/dist/index.d.ts +17 -0
- package/dist/pool.d.ts +143 -0
- package/dist/pool.js +554 -0
- package/dist/pty.d.ts +59 -0
- package/dist/scp.d.ts +30 -0
- package/dist/scp.js +74 -0
- package/dist/sessions.d.ts +98 -0
- package/dist/tmux-exec.d.ts +50 -0
- package/dist/tmux.d.ts +213 -0
- package/dist/tmux.js +528 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.js +5 -0
- package/ebowwa-terminal-0.2.0.tgz +0 -0
- package/mcp/README.md +181 -0
- package/mcp/package.json +34 -0
- package/mcp/test-fix.sh +273 -0
- package/package.json +118 -0
- package/src/api.ts +752 -0
- package/src/client.ts +55 -0
- package/src/config.ts +489 -0
- package/src/error.ts +13 -0
- package/src/exec.ts +128 -0
- package/src/files.ts +636 -0
- package/src/fingerprint.ts +263 -0
- package/src/index.ts +144 -0
- package/src/manager.ts +319 -0
- package/src/mcp/index.ts +467 -0
- package/src/mcp/stdio.ts +708 -0
- package/src/network-error-detector.ts +121 -0
- package/src/pool.ts +662 -0
- package/src/pty.ts +285 -0
- package/src/scp.ts +109 -0
- package/src/sessions.ts +861 -0
- package/src/tmux-exec.ts +96 -0
- package/src/tmux-local.ts +839 -0
- package/src/tmux-manager.ts +962 -0
- package/src/tmux.ts +711 -0
- package/src/types.ts +19 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Node Tmux Session Manager
|
|
3
|
+
* Provides unified management of tmux sessions across multiple VPS nodes
|
|
4
|
+
* Supports batch operations, session tracking, and parallel execution
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { SSHOptions } from "./types.js";
|
|
8
|
+
import {
|
|
9
|
+
generateSessionName,
|
|
10
|
+
listTmuxSessions,
|
|
11
|
+
hasTmuxSession,
|
|
12
|
+
getTmuxSessionInfo,
|
|
13
|
+
getDetailedSessionInfo,
|
|
14
|
+
killTmuxSession,
|
|
15
|
+
sendCommandToPane,
|
|
16
|
+
splitPane,
|
|
17
|
+
listSessionWindows,
|
|
18
|
+
listWindowPanes,
|
|
19
|
+
capturePane,
|
|
20
|
+
getPaneHistory,
|
|
21
|
+
switchWindow,
|
|
22
|
+
switchPane,
|
|
23
|
+
renameWindow,
|
|
24
|
+
killPane,
|
|
25
|
+
cleanupOldTmuxSessions,
|
|
26
|
+
getTmuxResourceUsage,
|
|
27
|
+
ensureTmux,
|
|
28
|
+
createOrAttachTmuxSession,
|
|
29
|
+
} from "./tmux.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Represents a single node (server) in the cluster
|
|
33
|
+
*/
|
|
34
|
+
export interface Node {
|
|
35
|
+
/** Unique identifier (server ID from Hetzner) */
|
|
36
|
+
id: string;
|
|
37
|
+
/** Server name */
|
|
38
|
+
name: string;
|
|
39
|
+
/** IPv4 address */
|
|
40
|
+
ip: string;
|
|
41
|
+
/** IPv6 address (optional) */
|
|
42
|
+
ipv6?: string | null;
|
|
43
|
+
/** SSH user */
|
|
44
|
+
user: string;
|
|
45
|
+
/** SSH port */
|
|
46
|
+
port: number;
|
|
47
|
+
/** SSH key path (optional) */
|
|
48
|
+
keyPath?: string;
|
|
49
|
+
/** Node status */
|
|
50
|
+
status: "running" | "stopped" | "unreachable";
|
|
51
|
+
/** Metadata tags */
|
|
52
|
+
tags?: string[];
|
|
53
|
+
/** Datacenter location */
|
|
54
|
+
location?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Represents a tmux session on a node
|
|
59
|
+
*/
|
|
60
|
+
export interface TmuxSession {
|
|
61
|
+
/** Session name */
|
|
62
|
+
name: string;
|
|
63
|
+
/** Node ID */
|
|
64
|
+
nodeId: string;
|
|
65
|
+
/** Number of windows */
|
|
66
|
+
windows?: number;
|
|
67
|
+
/** Number of panes */
|
|
68
|
+
panes?: number;
|
|
69
|
+
/** Session exists */
|
|
70
|
+
exists: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Detailed session information with windows and panes
|
|
75
|
+
*/
|
|
76
|
+
export interface DetailedTmuxSession extends TmuxSession {
|
|
77
|
+
windows: Array<{
|
|
78
|
+
index: string;
|
|
79
|
+
name: string;
|
|
80
|
+
active: boolean;
|
|
81
|
+
panes: Array<{
|
|
82
|
+
index: string;
|
|
83
|
+
currentPath: string;
|
|
84
|
+
pid: string;
|
|
85
|
+
active: boolean;
|
|
86
|
+
}>;
|
|
87
|
+
}>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Batch operation result across multiple nodes
|
|
92
|
+
*/
|
|
93
|
+
export interface BatchOperationResult<T = any> {
|
|
94
|
+
/** Total nodes in batch */
|
|
95
|
+
total: number;
|
|
96
|
+
/** Successful operations */
|
|
97
|
+
successful: number;
|
|
98
|
+
/** Failed operations */
|
|
99
|
+
failed: number;
|
|
100
|
+
/** Results per node */
|
|
101
|
+
results: Array<{
|
|
102
|
+
nodeId: string;
|
|
103
|
+
nodeName: string;
|
|
104
|
+
success: boolean;
|
|
105
|
+
data?: T;
|
|
106
|
+
error?: string;
|
|
107
|
+
}>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Options for creating a new tmux session
|
|
112
|
+
*/
|
|
113
|
+
export interface CreateSessionOptions {
|
|
114
|
+
/** Session name (auto-generated if not provided) */
|
|
115
|
+
sessionName?: string;
|
|
116
|
+
/** Initial working directory */
|
|
117
|
+
cwd?: string;
|
|
118
|
+
/** Initial command to run */
|
|
119
|
+
initialCommand?: string;
|
|
120
|
+
/** Window layout */
|
|
121
|
+
layout?: "even-horizontal" | "even-vertical" | "main-horizontal" | "main-vertical" | "tiled";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Options for batch command execution
|
|
126
|
+
*/
|
|
127
|
+
export interface BatchCommandOptions {
|
|
128
|
+
/** Command to execute */
|
|
129
|
+
command: string;
|
|
130
|
+
/** Target pane index (default: "0") */
|
|
131
|
+
paneIndex?: string;
|
|
132
|
+
/** Execute in parallel (default: true) */
|
|
133
|
+
parallel?: boolean;
|
|
134
|
+
/** Continue on error (default: true) */
|
|
135
|
+
continueOnError?: boolean;
|
|
136
|
+
/** Timeout per node (seconds) */
|
|
137
|
+
timeout?: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Options for multi-node session queries
|
|
142
|
+
*/
|
|
143
|
+
export interface SessionQueryOptions {
|
|
144
|
+
/** Filter by node IDs */
|
|
145
|
+
nodeIds?: string[];
|
|
146
|
+
/** Filter by tags */
|
|
147
|
+
tags?: string[];
|
|
148
|
+
/** Include detailed session info */
|
|
149
|
+
detailed?: boolean;
|
|
150
|
+
/** Include inactive sessions */
|
|
151
|
+
includeInactive?: boolean;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Multi-Node Tmux Session Manager
|
|
156
|
+
* Manages tmux sessions across multiple VPS nodes
|
|
157
|
+
*/
|
|
158
|
+
export class TmuxSessionManager {
|
|
159
|
+
private nodes: Map<string, Node> = new Map();
|
|
160
|
+
private sessionCache: Map<string, TmuxSession[]> = new Map();
|
|
161
|
+
private cacheTimeout: number = 30000; // 30 seconds
|
|
162
|
+
private lastCacheUpdate: Map<string, number> = new Map();
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Add or update a node in the manager
|
|
166
|
+
*/
|
|
167
|
+
addNode(node: Node): void {
|
|
168
|
+
this.nodes.set(node.id, { ...node });
|
|
169
|
+
// Invalidate cache for this node
|
|
170
|
+
this.sessionCache.delete(node.id);
|
|
171
|
+
this.lastCacheUpdate.delete(node.id);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Remove a node from the manager
|
|
176
|
+
*/
|
|
177
|
+
removeNode(nodeId: string): void {
|
|
178
|
+
this.nodes.delete(nodeId);
|
|
179
|
+
this.sessionCache.delete(nodeId);
|
|
180
|
+
this.lastCacheUpdate.delete(nodeId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get a node by ID
|
|
185
|
+
*/
|
|
186
|
+
getNode(nodeId: string): Node | undefined {
|
|
187
|
+
return this.nodes.get(nodeId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get all nodes
|
|
192
|
+
*/
|
|
193
|
+
getAllNodes(): Node[] {
|
|
194
|
+
return Array.from(this.nodes.values());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get nodes by tag
|
|
199
|
+
*/
|
|
200
|
+
getNodesByTag(tag: string): Node[] {
|
|
201
|
+
return this.getAllNodes().filter((node) => node.tags?.includes(tag));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get active nodes (running and reachable)
|
|
206
|
+
*/
|
|
207
|
+
getActiveNodes(): Node[] {
|
|
208
|
+
return this.getAllNodes().filter((node) => node.status === "running");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get SSH options for a node
|
|
213
|
+
*/
|
|
214
|
+
private getSSHOptions(node: Node): SSHOptions {
|
|
215
|
+
return {
|
|
216
|
+
host: node.ip,
|
|
217
|
+
user: node.user,
|
|
218
|
+
port: node.port,
|
|
219
|
+
keyPath: node.keyPath,
|
|
220
|
+
timeout: 30,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Invalidate session cache for a node or all nodes
|
|
226
|
+
*/
|
|
227
|
+
invalidateCache(nodeId?: string): void {
|
|
228
|
+
if (nodeId) {
|
|
229
|
+
this.sessionCache.delete(nodeId);
|
|
230
|
+
this.lastCacheUpdate.delete(nodeId);
|
|
231
|
+
} else {
|
|
232
|
+
this.sessionCache.clear();
|
|
233
|
+
this.lastCacheUpdate.clear();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if cache is valid for a node
|
|
239
|
+
*/
|
|
240
|
+
private isCacheValid(nodeId: string): boolean {
|
|
241
|
+
const lastUpdate = this.lastCacheUpdate.get(nodeId);
|
|
242
|
+
if (!lastUpdate) return false;
|
|
243
|
+
return Date.now() - lastUpdate < this.cacheTimeout;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Create a new tmux session on a node
|
|
248
|
+
*/
|
|
249
|
+
async createSession(
|
|
250
|
+
nodeId: string,
|
|
251
|
+
options: CreateSessionOptions = {}
|
|
252
|
+
): Promise<{ success: boolean; sessionName?: string; error?: string }> {
|
|
253
|
+
const node = this.getNode(nodeId);
|
|
254
|
+
if (!node) {
|
|
255
|
+
return { success: false, error: `Node ${nodeId} not found` };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (node.status !== "running") {
|
|
259
|
+
return { success: false, error: `Node ${nodeId} is not running` };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const sshOpts = this.getSSHOptions(node);
|
|
264
|
+
|
|
265
|
+
// Ensure tmux is installed
|
|
266
|
+
const tmuxCheck = await ensureTmux(sshOpts);
|
|
267
|
+
if (!tmuxCheck.success) {
|
|
268
|
+
return { success: false, error: tmuxCheck.message };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Generate session name if not provided
|
|
272
|
+
const sessionName = options.sessionName || generateSessionName(node.ip, node.user);
|
|
273
|
+
|
|
274
|
+
// Check if session already exists
|
|
275
|
+
const exists = await hasTmuxSession(sessionName, sshOpts);
|
|
276
|
+
if (exists) {
|
|
277
|
+
return { success: true, sessionName };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Create new session with tmux command
|
|
281
|
+
const createCmd = options.initialCommand
|
|
282
|
+
? `tmux new-session -d -s "${sessionName}" -n "main" "${options.initialCommand}"`
|
|
283
|
+
: `tmux new-session -d -s "${sessionName}" -n "main"`;
|
|
284
|
+
|
|
285
|
+
const { getSSHPool } = await import("./pool.js");
|
|
286
|
+
const pool = getSSHPool();
|
|
287
|
+
await pool.exec(createCmd, sshOpts);
|
|
288
|
+
|
|
289
|
+
// Set initial options
|
|
290
|
+
if (options.cwd) {
|
|
291
|
+
await pool.exec(`tmux set-option -t "${sessionName}" default-path "${options.cwd}"`, sshOpts);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (options.layout) {
|
|
295
|
+
await pool.exec(`tmux select-layout -t "${sessionName}" ${options.layout}`, sshOpts);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Invalidate cache
|
|
299
|
+
this.invalidateCache(nodeId);
|
|
300
|
+
|
|
301
|
+
return { success: true, sessionName };
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return {
|
|
304
|
+
success: false,
|
|
305
|
+
error: error instanceof Error ? error.message : String(error),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Attach to a tmux session on a node (returns SSH command args)
|
|
312
|
+
*/
|
|
313
|
+
async attachSession(
|
|
314
|
+
nodeId: string,
|
|
315
|
+
sessionName?: string
|
|
316
|
+
): Promise<{ success: true; sshArgs: string[]; sessionName: string } | { success: false; error: string }> {
|
|
317
|
+
const node = this.getNode(nodeId);
|
|
318
|
+
if (!node) {
|
|
319
|
+
return { success: false, error: `Node ${nodeId} not found` };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (node.status !== "running") {
|
|
323
|
+
return { success: false, error: `Node ${nodeId} is not running` };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const result = await createOrAttachTmuxSession(
|
|
328
|
+
node.ip,
|
|
329
|
+
node.user,
|
|
330
|
+
node.keyPath,
|
|
331
|
+
{ sessionName }
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
success: true,
|
|
336
|
+
sshArgs: result.sshArgs,
|
|
337
|
+
sessionName: result.sessionName,
|
|
338
|
+
};
|
|
339
|
+
} catch (error) {
|
|
340
|
+
return {
|
|
341
|
+
success: false,
|
|
342
|
+
error: error instanceof Error ? error.message : String(error),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* List all sessions across nodes
|
|
349
|
+
*/
|
|
350
|
+
async listSessions(options: SessionQueryOptions = {}): Promise<TmuxSession[]> {
|
|
351
|
+
const nodes = options.nodeIds
|
|
352
|
+
? options.nodeIds.map((id) => this.getNode(id)).filter(Boolean) as Node[]
|
|
353
|
+
: options.tags
|
|
354
|
+
? options.tags.flatMap((tag) => this.getNodesByTag(tag))
|
|
355
|
+
: this.getActiveNodes();
|
|
356
|
+
|
|
357
|
+
const allSessions: TmuxSession[] = [];
|
|
358
|
+
|
|
359
|
+
for (const node of nodes) {
|
|
360
|
+
if (node.status !== "running") continue;
|
|
361
|
+
|
|
362
|
+
// Use cache if valid
|
|
363
|
+
if (this.isCacheValid(node.id) && this.sessionCache.has(node.id)) {
|
|
364
|
+
allSessions.push(...this.sessionCache.get(node.id)!);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const sshOpts = this.getSSHOptions(node);
|
|
370
|
+
const sessionNames = await listTmuxSessions(sshOpts);
|
|
371
|
+
|
|
372
|
+
const sessions: TmuxSession[] = await Promise.all(
|
|
373
|
+
sessionNames.map(async (name) => {
|
|
374
|
+
const info = await getTmuxSessionInfo(name, sshOpts);
|
|
375
|
+
return {
|
|
376
|
+
name,
|
|
377
|
+
nodeId: node.id,
|
|
378
|
+
windows: info?.windows,
|
|
379
|
+
panes: info?.panes,
|
|
380
|
+
exists: info?.exists ?? true,
|
|
381
|
+
};
|
|
382
|
+
})
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// Update cache
|
|
386
|
+
this.sessionCache.set(node.id, sessions);
|
|
387
|
+
this.lastCacheUpdate.set(node.id, Date.now());
|
|
388
|
+
|
|
389
|
+
allSessions.push(...sessions);
|
|
390
|
+
} catch {
|
|
391
|
+
// Node unreachable, skip
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return allSessions;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Get detailed session information
|
|
401
|
+
*/
|
|
402
|
+
async getDetailedSession(
|
|
403
|
+
nodeId: string,
|
|
404
|
+
sessionName: string
|
|
405
|
+
): Promise<DetailedTmuxSession | null> {
|
|
406
|
+
const node = this.getNode(nodeId);
|
|
407
|
+
if (!node || node.status !== "running") {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const sshOpts = this.getSSHOptions(node);
|
|
413
|
+
const info = await getDetailedSessionInfo(sessionName, sshOpts);
|
|
414
|
+
|
|
415
|
+
if (!info || !info.exists) {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
name: sessionName,
|
|
421
|
+
nodeId: node.id,
|
|
422
|
+
exists: true,
|
|
423
|
+
windows: info.windows,
|
|
424
|
+
};
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Kill a session on a node
|
|
432
|
+
*/
|
|
433
|
+
async killSession(nodeId: string, sessionName: string): Promise<{ success: boolean; error?: string }> {
|
|
434
|
+
const node = this.getNode(nodeId);
|
|
435
|
+
if (!node) {
|
|
436
|
+
return { success: false, error: `Node ${nodeId} not found` };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (node.status !== "running") {
|
|
440
|
+
return { success: false, error: `Node ${nodeId} is not running` };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
const sshOpts = this.getSSHOptions(node);
|
|
445
|
+
const killed = await killTmuxSession(sessionName, sshOpts);
|
|
446
|
+
|
|
447
|
+
// Invalidate cache
|
|
448
|
+
this.invalidateCache(nodeId);
|
|
449
|
+
|
|
450
|
+
return { success: killed };
|
|
451
|
+
} catch (error) {
|
|
452
|
+
return {
|
|
453
|
+
success: false,
|
|
454
|
+
error: error instanceof Error ? error.message : String(error),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Send a command to a specific session on a node
|
|
461
|
+
*/
|
|
462
|
+
async sendCommand(
|
|
463
|
+
nodeId: string,
|
|
464
|
+
sessionName: string,
|
|
465
|
+
command: string,
|
|
466
|
+
paneIndex: string = "0"
|
|
467
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
468
|
+
const node = this.getNode(nodeId);
|
|
469
|
+
if (!node || node.status !== "running") {
|
|
470
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
const sshOpts = this.getSSHOptions(node);
|
|
475
|
+
const result = await sendCommandToPane(sessionName, command, paneIndex, sshOpts);
|
|
476
|
+
return { success: result };
|
|
477
|
+
} catch (error) {
|
|
478
|
+
return {
|
|
479
|
+
success: false,
|
|
480
|
+
error: error instanceof Error ? error.message : String(error),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Send command to multiple nodes (batch operation)
|
|
487
|
+
*/
|
|
488
|
+
async sendCommandToNodes(
|
|
489
|
+
nodeIds: string[],
|
|
490
|
+
sessionName: string,
|
|
491
|
+
command: string,
|
|
492
|
+
options: BatchCommandOptions = {}
|
|
493
|
+
): Promise<BatchOperationResult> {
|
|
494
|
+
const { parallel = true, continueOnError = true, paneIndex = "0", timeout = 30 } = options;
|
|
495
|
+
|
|
496
|
+
const nodes = nodeIds.map((id) => this.getNode(id)).filter(Boolean) as Node[];
|
|
497
|
+
const results: BatchOperationResult["results"] = [];
|
|
498
|
+
let successful = 0;
|
|
499
|
+
let failed = 0;
|
|
500
|
+
|
|
501
|
+
const executeCommand = async (node: Node) => {
|
|
502
|
+
if (node.status !== "running") {
|
|
503
|
+
failed++;
|
|
504
|
+
return {
|
|
505
|
+
nodeId: node.id,
|
|
506
|
+
nodeName: node.name,
|
|
507
|
+
success: false,
|
|
508
|
+
error: `Node is ${node.status}`,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const sshOpts = { ...this.getSSHOptions(node), timeout };
|
|
514
|
+
const result = await sendCommandToPane(sessionName, command, paneIndex, sshOpts);
|
|
515
|
+
|
|
516
|
+
if (result) {
|
|
517
|
+
successful++;
|
|
518
|
+
return {
|
|
519
|
+
nodeId: node.id,
|
|
520
|
+
nodeName: node.name,
|
|
521
|
+
success: true,
|
|
522
|
+
};
|
|
523
|
+
} else {
|
|
524
|
+
failed++;
|
|
525
|
+
return {
|
|
526
|
+
nodeId: node.id,
|
|
527
|
+
nodeName: node.name,
|
|
528
|
+
success: false,
|
|
529
|
+
error: "Failed to send command",
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
} catch (error) {
|
|
533
|
+
failed++;
|
|
534
|
+
return {
|
|
535
|
+
nodeId: node.id,
|
|
536
|
+
nodeName: node.name,
|
|
537
|
+
success: false,
|
|
538
|
+
error: error instanceof Error ? error.message : String(error),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
if (parallel) {
|
|
544
|
+
const resultsArray = await Promise.allSettled(nodes.map(executeCommand));
|
|
545
|
+
resultsArray.forEach((result) => {
|
|
546
|
+
if (result.status === "fulfilled") {
|
|
547
|
+
results.push(result.value);
|
|
548
|
+
} else {
|
|
549
|
+
results.push({
|
|
550
|
+
nodeId: "unknown",
|
|
551
|
+
nodeName: "unknown",
|
|
552
|
+
success: false,
|
|
553
|
+
error: String(result.reason),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
} else {
|
|
558
|
+
for (const node of nodes) {
|
|
559
|
+
try {
|
|
560
|
+
const result = await executeCommand(node);
|
|
561
|
+
results.push(result);
|
|
562
|
+
if (!result.success && !continueOnError) {
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
} catch (error) {
|
|
566
|
+
failed++;
|
|
567
|
+
results.push({
|
|
568
|
+
nodeId: node.id,
|
|
569
|
+
nodeName: node.name,
|
|
570
|
+
success: false,
|
|
571
|
+
error: error instanceof Error ? error.message : String(error),
|
|
572
|
+
});
|
|
573
|
+
if (!continueOnError) {
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
total: nodes.length,
|
|
582
|
+
successful,
|
|
583
|
+
failed,
|
|
584
|
+
results,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Split a pane in a session
|
|
590
|
+
*/
|
|
591
|
+
async splitPaneOnNode(
|
|
592
|
+
nodeId: string,
|
|
593
|
+
sessionName: string,
|
|
594
|
+
direction: "h" | "v" = "v",
|
|
595
|
+
command: string | null = null,
|
|
596
|
+
windowIndex: string = "0"
|
|
597
|
+
): Promise<{ success: boolean; newPaneIndex?: string; error?: string }> {
|
|
598
|
+
const node = this.getNode(nodeId);
|
|
599
|
+
if (!node || node.status !== "running") {
|
|
600
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
const sshOpts = this.getSSHOptions(node);
|
|
605
|
+
const result = await splitPane(sessionName, direction, command, sshOpts);
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
success: result !== null,
|
|
609
|
+
newPaneIndex: result || undefined,
|
|
610
|
+
};
|
|
611
|
+
} catch (error) {
|
|
612
|
+
return {
|
|
613
|
+
success: false,
|
|
614
|
+
error: error instanceof Error ? error.message : String(error),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Capture pane output from a session
|
|
621
|
+
*/
|
|
622
|
+
async capturePaneOutput(
|
|
623
|
+
nodeId: string,
|
|
624
|
+
sessionName: string,
|
|
625
|
+
paneIndex: string = "0"
|
|
626
|
+
): Promise<{ success: boolean; output?: string; error?: string }> {
|
|
627
|
+
const node = this.getNode(nodeId);
|
|
628
|
+
if (!node || node.status !== "running") {
|
|
629
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
const sshOpts = this.getSSHOptions(node);
|
|
634
|
+
const output = await capturePane(sessionName, paneIndex, sshOpts);
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
success: output !== null,
|
|
638
|
+
output: output || undefined,
|
|
639
|
+
};
|
|
640
|
+
} catch (error) {
|
|
641
|
+
return {
|
|
642
|
+
success: false,
|
|
643
|
+
error: error instanceof Error ? error.message : String(error),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Get pane history (scrollback buffer)
|
|
650
|
+
*/
|
|
651
|
+
async getPaneHistory(
|
|
652
|
+
nodeId: string,
|
|
653
|
+
sessionName: string,
|
|
654
|
+
paneIndex: string = "0",
|
|
655
|
+
lines: number = -1
|
|
656
|
+
): Promise<{ success: boolean; history?: string; error?: string }> {
|
|
657
|
+
const node = this.getNode(nodeId);
|
|
658
|
+
if (!node || node.status !== "running") {
|
|
659
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
const sshOpts = this.getSSHOptions(node);
|
|
664
|
+
const history = await getPaneHistory(sessionName, paneIndex, lines, sshOpts);
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
success: history !== null,
|
|
668
|
+
history: history || undefined,
|
|
669
|
+
};
|
|
670
|
+
} catch (error) {
|
|
671
|
+
return {
|
|
672
|
+
success: false,
|
|
673
|
+
error: error instanceof Error ? error.message : String(error),
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* List windows in a session
|
|
680
|
+
*/
|
|
681
|
+
async listWindows(
|
|
682
|
+
nodeId: string,
|
|
683
|
+
sessionName: string
|
|
684
|
+
): Promise<{ success: boolean; windows?: Array<{ index: string; name: string; active: boolean }>; error?: string }> {
|
|
685
|
+
const node = this.getNode(nodeId);
|
|
686
|
+
if (!node || node.status !== "running") {
|
|
687
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
const sshOpts = this.getSSHOptions(node);
|
|
692
|
+
const windows = await listSessionWindows(sessionName, sshOpts);
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
success: true,
|
|
696
|
+
windows,
|
|
697
|
+
};
|
|
698
|
+
} catch (error) {
|
|
699
|
+
return {
|
|
700
|
+
success: false,
|
|
701
|
+
error: error instanceof Error ? error.message : String(error),
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* List panes in a window
|
|
708
|
+
*/
|
|
709
|
+
async listPanes(
|
|
710
|
+
nodeId: string,
|
|
711
|
+
sessionName: string,
|
|
712
|
+
windowIndex: string = "0"
|
|
713
|
+
): Promise<{
|
|
714
|
+
success: boolean;
|
|
715
|
+
panes?: Array<{ index: string; currentPath: string; pid: string; active: boolean }>;
|
|
716
|
+
error?: string;
|
|
717
|
+
}> {
|
|
718
|
+
const node = this.getNode(nodeId);
|
|
719
|
+
if (!node || node.status !== "running") {
|
|
720
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
const sshOpts = this.getSSHOptions(node);
|
|
725
|
+
const panes = await listWindowPanes(sessionName, windowIndex, sshOpts);
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
success: true,
|
|
729
|
+
panes,
|
|
730
|
+
};
|
|
731
|
+
} catch (error) {
|
|
732
|
+
return {
|
|
733
|
+
success: false,
|
|
734
|
+
error: error instanceof Error ? error.message : String(error),
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Switch to a different window in a session
|
|
741
|
+
*/
|
|
742
|
+
async switchToWindow(
|
|
743
|
+
nodeId: string,
|
|
744
|
+
sessionName: string,
|
|
745
|
+
windowIndex: string
|
|
746
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
747
|
+
const node = this.getNode(nodeId);
|
|
748
|
+
if (!node || node.status !== "running") {
|
|
749
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
try {
|
|
753
|
+
const sshOpts = this.getSSHOptions(node);
|
|
754
|
+
const result = await switchWindow(sessionName, windowIndex, sshOpts);
|
|
755
|
+
|
|
756
|
+
return { success: result };
|
|
757
|
+
} catch (error) {
|
|
758
|
+
return {
|
|
759
|
+
success: false,
|
|
760
|
+
error: error instanceof Error ? error.message : String(error),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Switch to a different pane in a session
|
|
767
|
+
*/
|
|
768
|
+
async switchToPane(
|
|
769
|
+
nodeId: string,
|
|
770
|
+
sessionName: string,
|
|
771
|
+
paneIndex: string
|
|
772
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
773
|
+
const node = this.getNode(nodeId);
|
|
774
|
+
if (!node || node.status !== "running") {
|
|
775
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
try {
|
|
779
|
+
const sshOpts = this.getSSHOptions(node);
|
|
780
|
+
const result = await switchPane(sessionName, paneIndex, sshOpts);
|
|
781
|
+
|
|
782
|
+
return { success: result };
|
|
783
|
+
} catch (error) {
|
|
784
|
+
return {
|
|
785
|
+
success: false,
|
|
786
|
+
error: error instanceof Error ? error.message : String(error),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Rename a window in a session
|
|
793
|
+
*/
|
|
794
|
+
async renameWindowInSession(
|
|
795
|
+
nodeId: string,
|
|
796
|
+
sessionName: string,
|
|
797
|
+
windowIndex: string,
|
|
798
|
+
newName: string
|
|
799
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
800
|
+
const node = this.getNode(nodeId);
|
|
801
|
+
if (!node || node.status !== "running") {
|
|
802
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
const sshOpts = this.getSSHOptions(node);
|
|
807
|
+
const result = await renameWindow(sessionName, windowIndex, newName, sshOpts);
|
|
808
|
+
|
|
809
|
+
return { success: result };
|
|
810
|
+
} catch (error) {
|
|
811
|
+
return {
|
|
812
|
+
success: false,
|
|
813
|
+
error: error instanceof Error ? error.message : String(error),
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Kill a pane in a session
|
|
820
|
+
*/
|
|
821
|
+
async killPaneInSession(
|
|
822
|
+
nodeId: string,
|
|
823
|
+
sessionName: string,
|
|
824
|
+
paneIndex: string
|
|
825
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
826
|
+
const node = this.getNode(nodeId);
|
|
827
|
+
if (!node || node.status !== "running") {
|
|
828
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
try {
|
|
832
|
+
const sshOpts = this.getSSHOptions(node);
|
|
833
|
+
const result = await killPane(sessionName, paneIndex, sshOpts);
|
|
834
|
+
|
|
835
|
+
return { success: result };
|
|
836
|
+
} catch (error) {
|
|
837
|
+
return {
|
|
838
|
+
success: false,
|
|
839
|
+
error: error instanceof Error ? error.message : String(error),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Cleanup old sessions on a node
|
|
846
|
+
*/
|
|
847
|
+
async cleanupOldSessions(
|
|
848
|
+
nodeId: string,
|
|
849
|
+
ageLimitMs: number = 30 * 24 * 60 * 60 * 1000
|
|
850
|
+
): Promise<{ success: boolean; cleaned?: number; errors?: string[] }> {
|
|
851
|
+
const node = this.getNode(nodeId);
|
|
852
|
+
if (!node || node.status !== "running") {
|
|
853
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
try {
|
|
857
|
+
const sshOpts = this.getSSHOptions(node);
|
|
858
|
+
const result = await cleanupOldTmuxSessions(sshOpts, { sessionAgeLimit: ageLimitMs });
|
|
859
|
+
|
|
860
|
+
// Invalidate cache
|
|
861
|
+
this.invalidateCache(nodeId);
|
|
862
|
+
|
|
863
|
+
return {
|
|
864
|
+
success: true,
|
|
865
|
+
cleaned: result.cleaned,
|
|
866
|
+
errors: result.errors,
|
|
867
|
+
};
|
|
868
|
+
} catch (error) {
|
|
869
|
+
return {
|
|
870
|
+
success: false,
|
|
871
|
+
error: error instanceof Error ? error.message : String(error),
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Get resource usage for sessions on a node
|
|
878
|
+
*/
|
|
879
|
+
async getResourceUsage(
|
|
880
|
+
nodeId: string
|
|
881
|
+
): Promise<{ success: boolean; usage?: { totalSessions: number; codespacesSessions: number; estimatedMemoryMB: number }; error?: string }> {
|
|
882
|
+
const node = this.getNode(nodeId);
|
|
883
|
+
if (!node || node.status !== "running") {
|
|
884
|
+
return { success: false, error: node ? `Node ${nodeId} is not running` : `Node ${nodeId} not found` };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
const sshOpts = this.getSSHOptions(node);
|
|
889
|
+
const usage = await getTmuxResourceUsage(sshOpts);
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
success: usage !== null,
|
|
893
|
+
usage: usage || undefined,
|
|
894
|
+
};
|
|
895
|
+
} catch (error) {
|
|
896
|
+
return {
|
|
897
|
+
success: false,
|
|
898
|
+
error: error instanceof Error ? error.message : String(error),
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Get summary statistics across all nodes
|
|
905
|
+
*/
|
|
906
|
+
async getSummary(): Promise<{
|
|
907
|
+
totalNodes: number;
|
|
908
|
+
activeNodes: number;
|
|
909
|
+
totalSessions: number;
|
|
910
|
+
totalMemoryUsageMB: number;
|
|
911
|
+
nodesWithSessions: number;
|
|
912
|
+
}> {
|
|
913
|
+
const nodes = this.getActiveNodes();
|
|
914
|
+
const sessions = await this.listSessions();
|
|
915
|
+
|
|
916
|
+
let totalMemoryUsageMB = 0;
|
|
917
|
+
const nodesWithSessions = new Set<string>();
|
|
918
|
+
|
|
919
|
+
// Group sessions by node
|
|
920
|
+
for (const session of sessions) {
|
|
921
|
+
nodesWithSessions.add(session.nodeId);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Calculate memory usage per node
|
|
925
|
+
for (const node of nodes) {
|
|
926
|
+
const usage = await this.getResourceUsage(node.id);
|
|
927
|
+
if (usage.success && usage.usage) {
|
|
928
|
+
totalMemoryUsageMB += usage.usage.estimatedMemoryMB;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return {
|
|
933
|
+
totalNodes: this.nodes.size,
|
|
934
|
+
activeNodes: nodes.length,
|
|
935
|
+
totalSessions: sessions.length,
|
|
936
|
+
totalMemoryUsageMB,
|
|
937
|
+
nodesWithSessions: nodesWithSessions.size,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Global singleton instance
|
|
944
|
+
*/
|
|
945
|
+
let globalManager: TmuxSessionManager | null = null;
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Get the global tmux session manager instance
|
|
949
|
+
*/
|
|
950
|
+
export function getTmuxManager(): TmuxSessionManager {
|
|
951
|
+
if (!globalManager) {
|
|
952
|
+
globalManager = new TmuxSessionManager();
|
|
953
|
+
}
|
|
954
|
+
return globalManager;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Reset the global manager (for testing)
|
|
959
|
+
*/
|
|
960
|
+
export function resetTmuxManager(): void {
|
|
961
|
+
globalManager = null;
|
|
962
|
+
}
|