@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.
@@ -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
+ }