@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/src/tmux.ts ADDED
@@ -0,0 +1,711 @@
1
+ /**
2
+ * tmux-based Terminal Sessions
3
+ * Provides persistent terminal sessions using tmux on remote servers
4
+ * Includes automatic tmux installation and session management
5
+ */
6
+
7
+ import { getSSHPool } from "./pool.js";
8
+ import type { SSHOptions } from "./types.js";
9
+ import { SSHFlags, SSHPresets, buildSSHArgs, sshConfig, type PTYOptions, type SSHConfigFlag } from "@codespaces/ssh";
10
+
11
+ /**
12
+ * tmux session configuration
13
+ */
14
+ interface TmuxConfig {
15
+ /** Session name prefix for codespaces sessions */
16
+ sessionPrefix: string;
17
+ /** Default shell to use in tmux */
18
+ defaultShell: string;
19
+ /** Terminal type */
20
+ term: string;
21
+ /** Timeout for SSH commands (seconds) */
22
+ timeout: number;
23
+ /** Scrollback limit (lines) */
24
+ historyLimit: number;
25
+ /** Session age limit for cleanup (milliseconds) */
26
+ sessionAgeLimit: number;
27
+ }
28
+
29
+ const DEFAULT_CONFIG: TmuxConfig = {
30
+ sessionPrefix: "codespaces",
31
+ defaultShell: "/bin/bash",
32
+ term: "xterm-256color",
33
+ timeout: 30,
34
+ historyLimit: 10000, // 10k lines ~ 1-2MB per session
35
+ sessionAgeLimit: 30 * 24 * 60 * 60 * 1000, // 30 days
36
+ };
37
+
38
+ /**
39
+ * Generate a tmux session name for a host
40
+ */
41
+ export function generateSessionName(host: string, user: string = "root"): string {
42
+ const sanitizedHost = host.replace(/[.]/g, "-");
43
+ return `${DEFAULT_CONFIG.sessionPrefix}-${sanitizedHost}`;
44
+ }
45
+
46
+ /**
47
+ * Check if tmux is installed on the remote server
48
+ */
49
+ export async function isTmuxInstalled(options: SSHOptions): Promise<boolean> {
50
+ const pool = getSSHPool();
51
+ try {
52
+ const result = await pool.exec("type tmux", {
53
+ ...options,
54
+ timeout: 5,
55
+ });
56
+ return result !== "0";
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Install tmux on the remote server
64
+ *
65
+ * FALLBACK MECHANISM: This should NOT be the primary installation method.
66
+ * tmux should be installed via cloud-init during initial node provisioning.
67
+ *
68
+ * This function exists for:
69
+ * - Legacy nodes provisioned before cloud-init included tmux
70
+ * - Manual node provisioning outside cheapspaces
71
+ * - Recovery scenarios where cloud-init failed
72
+ *
73
+ * @see workspace/docs/design/node-agent/TMUX-INSTALLATION.md
74
+ * @see workspace/src/lib/bootstrap/cloud-init.ts - where tmux should be added to packages
75
+ *
76
+ * Supports Debian/Ubuntu (apt) and CentOS/RHEL (yum/dnf)
77
+ */
78
+ export async function installTmux(options: SSHOptions): Promise<{ success: boolean; message: string }> {
79
+ const pool = getSSHPool();
80
+
81
+ try {
82
+ // Detect package manager and install tmux
83
+ const installCmd = `
84
+ if command -v apt-get >/dev/null 2>&1; then
85
+ # Debian/Ubuntu
86
+ export DEBIAN_FRONTEND=noninteractive
87
+ apt-get update -qq && apt-get install -y -qq tmux
88
+ elif command -v yum >/dev/null 2>&1; then
89
+ # CentOS/RHEL (older)
90
+ yum install -y -q tmux
91
+ elif command -v dnf >/dev/null 2>&1; then
92
+ # Fedora/RHEL (newer)
93
+ dnf install -y -q tmux
94
+ elif command -v apk >/dev/null 2>&1; then
95
+ # Alpine
96
+ apk add --no-cache tmux
97
+ else
98
+ echo "ERROR: No supported package manager found"
99
+ exit 1
100
+ fi
101
+ `;
102
+
103
+ await pool.exec(installCmd, {
104
+ ...options,
105
+ timeout: 120, // Installation can take time
106
+ });
107
+
108
+ // Verify installation
109
+ const installed = await isTmuxInstalled(options);
110
+ if (installed) {
111
+ return { success: true, message: "tmux installed successfully" };
112
+ } else {
113
+ return { success: false, message: "tmux installation failed" };
114
+ }
115
+ } catch (error) {
116
+ return {
117
+ success: false,
118
+ message: `tmux installation error: ${error instanceof Error ? error.message : String(error)}`,
119
+ };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Ensure tmux is available, installing if necessary
125
+ */
126
+ export async function ensureTmux(options: SSHOptions): Promise<{ success: boolean; message: string }> {
127
+ const installed = await isTmuxInstalled(options);
128
+ if (installed) {
129
+ return { success: true, message: "tmux already installed" };
130
+ }
131
+
132
+ console.log("[Tmux] Not installed, attempting installation...");
133
+ return await installTmux(options);
134
+ }
135
+
136
+ /**
137
+ * List existing tmux sessions on the remote server
138
+ */
139
+ export async function listTmuxSessions(options: SSHOptions): Promise<string[]> {
140
+ const pool = getSSHPool();
141
+
142
+ try {
143
+ const result = await pool.exec(
144
+ "tmux list-sessions -F '#{session_name}' 2>/dev/null || echo ''",
145
+ { ...options, timeout: 5 }
146
+ );
147
+
148
+ if (!result || result === "0" || result.trim() === "") {
149
+ return [];
150
+ }
151
+
152
+ return result.trim().split("\n");
153
+ } catch {
154
+ return [];
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Check if a specific tmux session exists
160
+ */
161
+ export async function hasTmuxSession(sessionName: string, options: SSHOptions): Promise<boolean> {
162
+ const sessions = await listTmuxSessions(options);
163
+ return sessions.includes(sessionName);
164
+ }
165
+
166
+ /**
167
+ * Create or attach to a tmux session
168
+ * Returns the SSH command arguments to connect to the tmux session
169
+ */
170
+ export async function createOrAttachTmuxSession(
171
+ host: string,
172
+ user: string = "root",
173
+ keyPath?: string,
174
+ config: Partial<TmuxConfig> = {},
175
+ ): Promise<{ sshArgs: string[]; sessionName: string; newlyCreated: boolean }> {
176
+ const fullConfig = { ...DEFAULT_CONFIG, ...config };
177
+ const sessionName = generateSessionName(host, user);
178
+ const pool = getSSHPool();
179
+
180
+ const sshOptions: SSHOptions = {
181
+ host,
182
+ user,
183
+ port: 22,
184
+ keyPath,
185
+ timeout: fullConfig.timeout,
186
+ };
187
+
188
+ // Ensure tmux is installed
189
+ const tmuxCheck = await ensureTmux(sshOptions);
190
+ if (!tmuxCheck.success) {
191
+ throw new Error(`Failed to setup tmux: ${tmuxCheck.message}`);
192
+ }
193
+
194
+ // Check if session already exists
195
+ const sessionExists = await hasTmuxSession(sessionName, sshOptions);
196
+
197
+ // Build SSH command with typed flags
198
+ const flags = [
199
+ ...SSHPresets.default,
200
+ SSHFlags.port(22),
201
+ SSHFlags.forceTTY(2), // -tt for forced PTY
202
+ ];
203
+
204
+ if (keyPath) {
205
+ flags.push(SSHFlags.identity(String(keyPath)) as any);
206
+ }
207
+
208
+ const sshArgs = buildSSHArgs(flags, host, user);
209
+
210
+ // Tmux commands to create or attach session
211
+ // -A: Attach to existing session or create new
212
+ // -s: Session name
213
+ // new-window: Ensure we have a window (for new sessions)
214
+ const tmuxCmd = [
215
+ "tmux",
216
+ "new-session",
217
+ "-A",
218
+ "-s", sessionName,
219
+ "-n", "codespaces",
220
+ ];
221
+
222
+ sshArgs.push(...tmuxCmd);
223
+
224
+ // If creating a new session, also configure it via separate command
225
+ if (!sessionExists) {
226
+ try {
227
+ await pool.exec(`tmux set-option -t "${sessionName}" history-limit ${fullConfig.historyLimit} 2>/dev/null`, {
228
+ ...sshOptions,
229
+ timeout: 5,
230
+ });
231
+ } catch {
232
+ // Ignore config errors
233
+ }
234
+ }
235
+
236
+ return {
237
+ sshArgs,
238
+ sessionName,
239
+ newlyCreated: !sessionExists,
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Kill a tmux session on the remote server
245
+ */
246
+ export async function killTmuxSession(sessionName: string, options: SSHOptions): Promise<boolean> {
247
+ const pool = getSSHPool();
248
+
249
+ try {
250
+ await pool.exec(`tmux kill-session -t "${sessionName}" 2>/dev/null`, {
251
+ ...options,
252
+ timeout: 5,
253
+ });
254
+ return true;
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Get tmux session information
262
+ */
263
+ export async function getTmuxSessionInfo(sessionName: string, options: SSHOptions): Promise<{
264
+ exists: boolean;
265
+ windows?: number;
266
+ panes?: number;
267
+ } | null> {
268
+ const pool = getSSHPool();
269
+
270
+ try {
271
+ const result = await pool.exec(
272
+ `tmux display-message -t "${sessionName}" -p '#{session_windows} #{window_panes}' 2>/dev/null || echo ""`,
273
+ { ...options, timeout: 5 }
274
+ );
275
+
276
+ if (!result || result.trim() === "" || result === "0") {
277
+ return { exists: false };
278
+ }
279
+
280
+ const [windows, panes] = result.trim().split(" ").map(Number);
281
+ return { exists: true, windows, panes };
282
+ } catch {
283
+ return { exists: false };
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Cleanup old tmux sessions on a remote server
289
+ * Kills sessions with matching prefix that are older than specified age limit
290
+ * @param options SSH connection options
291
+ * @param config Optional configuration (uses default age limit if not provided)
292
+ * @returns Object with cleaned count and errors
293
+ */
294
+ export async function cleanupOldTmuxSessions(
295
+ options: SSHOptions,
296
+ config: Partial<TmuxConfig> = {},
297
+ ): Promise<{ cleaned: number; errors: string[] }> {
298
+ const fullConfig = { ...DEFAULT_CONFIG, ...config };
299
+ const pool = getSSHPool();
300
+ const errors: string[] = [];
301
+ let cleaned = 0;
302
+
303
+ try {
304
+ // Get all tmux sessions
305
+ const sessions = await listTmuxSessions(options);
306
+
307
+ // Filter for codespaces sessions
308
+ const codespacesSessions = sessions.filter(s => s.startsWith(fullConfig.sessionPrefix));
309
+
310
+ // Check age of each session by looking at socket file modification time
311
+ for (const sessionName of codespacesSessions) {
312
+ try {
313
+ // Check session age by examining tmux socket file
314
+ // Tmux sockets are in /tmp/tmux-*/default or /tmp/tmux-*
315
+ const ageCheckCmd = `
316
+ find /tmp -type s -name "*${sessionName}*" 2>/dev/null | head -1 | while read socket; do
317
+ if [ -n "$socket" ]; then
318
+ # Get file modification time in seconds since epoch
319
+ mtime=$(stat -c %Y "$socket" 2>/dev/null || stat -f %m "$socket" 2>/dev/null)
320
+ if [ -n "$mtime" ]; then
321
+ now=$(date +%s)
322
+ age=$((now - mtime))
323
+ age_ms=$((age * 1000))
324
+ echo "$age_ms"
325
+ fi
326
+ fi
327
+ done
328
+ `;
329
+
330
+ const ageResult = await pool.exec(ageCheckCmd, { ...options, timeout: 10 });
331
+ const ageMs = parseInt(ageResult.trim());
332
+
333
+ if (!isNaN(ageMs) && ageMs > fullConfig.sessionAgeLimit) {
334
+ console.log(`[Tmux] Cleaning up old session "${sessionName}" (age: ${Math.round(ageMs / 86400000)} days)`);
335
+ await killTmuxSession(sessionName, options);
336
+ cleaned++;
337
+ }
338
+ } catch (err) {
339
+ errors.push(`${sessionName}: ${err instanceof Error ? err.message : String(err)}`);
340
+ }
341
+ }
342
+
343
+ return { cleaned, errors };
344
+ } catch (err) {
345
+ errors.push(`Cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
346
+ return { cleaned, errors };
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Get resource usage information for tmux sessions on a remote server
352
+ * @param options SSH connection options
353
+ * @returns Resource usage summary
354
+ */
355
+ export async function getTmuxResourceUsage(options: SSHOptions): Promise<{
356
+ totalSessions: number;
357
+ codespacesSessions: number;
358
+ estimatedMemoryMB: number;
359
+ } | null> {
360
+ const pool = getSSHPool();
361
+
362
+ try {
363
+ // Get count of tmux sessions
364
+ const sessions = await listTmuxSessions(options);
365
+ const codespacesSessions = sessions.filter(s => s.startsWith(DEFAULT_CONFIG.sessionPrefix));
366
+
367
+ // Estimate memory: each session ~10MB base + scrollback buffer
368
+ // Scrollback: 10000 lines × ~100 bytes/line = ~1MB per session
369
+ const estimatedMemoryMB = codespacesSessions.length * 11;
370
+
371
+ return {
372
+ totalSessions: sessions.length,
373
+ codespacesSessions: codespacesSessions.length,
374
+ estimatedMemoryMB,
375
+ };
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Send a command to a specific pane in a tmux session
383
+ * @param sessionName Target tmux session name
384
+ * @param paneIndex Pane index (default: 0 for first pane)
385
+ * @param command Command to execute (sent as keystrokes)
386
+ * @param options SSH connection options
387
+ */
388
+ export async function sendCommandToPane(
389
+ sessionName: string,
390
+ command: string,
391
+ paneIndex: string = "0",
392
+ options: SSHOptions
393
+ ): Promise<boolean> {
394
+ const pool = getSSHPool();
395
+
396
+ try {
397
+ // Use send-keys to send command as keystrokes, then Enter
398
+ const escapedCmd = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
399
+ await pool.exec(
400
+ `tmux send-keys -t "${sessionName}:${paneIndex}" "${escapedCmd}" Enter`,
401
+ { ...options, timeout: 5 }
402
+ );
403
+ return true;
404
+ } catch (error) {
405
+ console.error(`[Tmux] Failed to send command to ${sessionName}:${paneIndex}:`, error);
406
+ return false;
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Split a pane horizontally or vertically in a tmux session
412
+ * @param sessionName Target tmux session name
413
+ * @param windowIndex Window index (default: 0)
414
+ * @param direction Split direction: "h" (horizontal) or "v" (vertical)
415
+ * @param command Optional command to run in the new pane
416
+ * @param options SSH connection options
417
+ * @returns The new pane index
418
+ */
419
+ export async function splitPane(
420
+ sessionName: string,
421
+ direction: "h" | "v" = "v",
422
+ command: string | null = null,
423
+ options: SSHOptions
424
+ ): Promise<string | null> {
425
+ const pool = getSSHPool();
426
+
427
+ try {
428
+ // Split the pane and capture the new pane ID
429
+ const splitCmd = command
430
+ ? `tmux split-window -${direction} -t "${sessionName}" -c "#{pane_current_path}" "${command}"`
431
+ : `tmux split-window -${direction} -t "${sessionName}"`;
432
+
433
+ const result = await pool.exec(splitCmd, { ...options, timeout: 10 });
434
+
435
+ // Return the result which contains the new pane ID
436
+ return result?.trim() || null;
437
+ } catch (error) {
438
+ console.error(`[Tmux] Failed to split pane in ${sessionName}:`, error);
439
+ return null;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * List all windows in a tmux session
445
+ * @param sessionName Target tmux session name
446
+ * @param options SSH connection options
447
+ */
448
+ export async function listSessionWindows(
449
+ sessionName: string,
450
+ options: SSHOptions
451
+ ): Promise<Array<{ index: string; name: string; active: boolean }>> {
452
+ const pool = getSSHPool();
453
+
454
+ try {
455
+ const result = await pool.exec(
456
+ `tmux list-windows -t "${sessionName}" -F '#{window_index} #{window_name} #{window_active}' 2>/dev/null || echo ''`,
457
+ { ...options, timeout: 5 }
458
+ );
459
+
460
+ if (!result || result.trim() === "" || result === "0") {
461
+ return [];
462
+ }
463
+
464
+ return result.trim().split("\n").map(line => {
465
+ const [index, name, active] = line.split(" ");
466
+ return { index, name, active: active === "1" };
467
+ });
468
+ } catch {
469
+ return [];
470
+ }
471
+ }
472
+
473
+ /**
474
+ * List all panes in a tmux session window
475
+ * @param sessionName Target tmux session name
476
+ * @param windowIndex Window index (default: 0)
477
+ * @param options SSH connection options
478
+ */
479
+ export async function listWindowPanes(
480
+ sessionName: string,
481
+ windowIndex: string = "0",
482
+ options: SSHOptions
483
+ ): Promise<Array<{ index: string; currentPath: string; pid: string; active: boolean }>> {
484
+ const pool = getSSHPool();
485
+
486
+ try {
487
+ const result = await pool.exec(
488
+ `tmux list-panes -t "${sessionName}:${windowIndex}" -F '#{pane_index} #{pane_current_path} #{pane_pid} #{pane_active}' 2>/dev/null || echo ''`,
489
+ { ...options, timeout: 5 }
490
+ );
491
+
492
+ if (!result || result.trim() === "" || result === "0") {
493
+ return [];
494
+ }
495
+
496
+ return result.trim().split("\n").map(line => {
497
+ const [index, currentPath, pid, active] = line.split(" ");
498
+ return { index, currentPath, pid, active: active === "1" };
499
+ });
500
+ } catch {
501
+ return [];
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Capture the current output of a pane
507
+ * @param sessionName Target tmux session name
508
+ * @param paneIndex Pane index (default: 0)
509
+ * @param options SSH connection options
510
+ */
511
+ export async function capturePane(
512
+ sessionName: string,
513
+ paneIndex: string = "0",
514
+ options: SSHOptions
515
+ ): Promise<string | null> {
516
+ const pool = getSSHPool();
517
+
518
+ try {
519
+ const result = await pool.exec(
520
+ `tmux capture-pane -t "${sessionName}:${paneIndex}" -p`,
521
+ { ...options, timeout: 5 }
522
+ );
523
+
524
+ return result || null;
525
+ } catch (error) {
526
+ console.error(`[Tmux] Failed to capture pane ${sessionName}:${paneIndex}:`, error);
527
+ return null;
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Get scrollback/history from a pane
533
+ * @param sessionName Target tmux session name
534
+ * @param paneIndex Pane index (default: 0)
535
+ * @param lines Number of lines to retrieve (default: all)
536
+ * @param options SSH connection options
537
+ */
538
+ export async function getPaneHistory(
539
+ sessionName: string,
540
+ paneIndex: string = "0",
541
+ lines: number = -1,
542
+ options: SSHOptions
543
+ ): Promise<string | null> {
544
+ const pool = getSSHPool();
545
+
546
+ try {
547
+ const linesArg = lines > 0 ? `-S -${lines}` : "-S -";
548
+ const result = await pool.exec(
549
+ `tmux capture-pane ${linesArg} -t "${sessionName}:${paneIndex}" -p`,
550
+ { ...options, timeout: 10 }
551
+ );
552
+
553
+ return result || null;
554
+ } catch (error) {
555
+ console.error(`[Tmux] Failed to get history for ${sessionName}:${paneIndex}:`, error);
556
+ return null;
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Switch to a specific window in a tmux session
562
+ * @param sessionName Target tmux session name
563
+ * @param windowIndex Target window index
564
+ * @param options SSH connection options
565
+ */
566
+ export async function switchWindow(
567
+ sessionName: string,
568
+ windowIndex: string,
569
+ options: SSHOptions
570
+ ): Promise<boolean> {
571
+ const pool = getSSHPool();
572
+
573
+ try {
574
+ await pool.exec(
575
+ `tmux select-window -t "${sessionName}:${windowIndex}"`,
576
+ { ...options, timeout: 5 }
577
+ );
578
+ return true;
579
+ } catch {
580
+ return false;
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Switch to a specific pane in a tmux session window
586
+ * @param sessionName Target tmux session name
587
+ * @param paneIndex Target pane index (e.g., "0", "1", "0.1" for window.pane)
588
+ * @param options SSH connection options
589
+ */
590
+ export async function switchPane(
591
+ sessionName: string,
592
+ paneIndex: string,
593
+ options: SSHOptions
594
+ ): Promise<boolean> {
595
+ const pool = getSSHPool();
596
+
597
+ try {
598
+ await pool.exec(
599
+ `tmux select-pane -t "${sessionName}:${paneIndex}"`,
600
+ { ...options, timeout: 5 }
601
+ );
602
+ return true;
603
+ } catch {
604
+ return false;
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Rename a window in a tmux session
610
+ * @param sessionName Target tmux session name
611
+ * @param windowIndex Window index (default: 0)
612
+ * @param newName New window name
613
+ * @param options SSH connection options
614
+ */
615
+ export async function renameWindow(
616
+ sessionName: string,
617
+ windowIndex: string,
618
+ newName: string,
619
+ options: SSHOptions
620
+ ): Promise<boolean> {
621
+ const pool = getSSHPool();
622
+
623
+ try {
624
+ await pool.exec(
625
+ `tmux rename-window -t "${sessionName}:${windowIndex}" "${newName}"`,
626
+ { ...options, timeout: 5 }
627
+ );
628
+ return true;
629
+ } catch {
630
+ return false;
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Kill a specific pane in a tmux session
636
+ * @param sessionName Target tmux session name
637
+ * @param paneIndex Pane index to kill
638
+ * @param options SSH connection options
639
+ */
640
+ export async function killPane(
641
+ sessionName: string,
642
+ paneIndex: string,
643
+ options: SSHOptions
644
+ ): Promise<boolean> {
645
+ const pool = getSSHPool();
646
+
647
+ try {
648
+ await pool.exec(
649
+ `tmux kill-pane -t "${sessionName}:${paneIndex}"`,
650
+ { ...options, timeout: 5 }
651
+ );
652
+ return true;
653
+ } catch {
654
+ return false;
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Get detailed information about all panes in a session
660
+ * @param sessionName Target tmux session name
661
+ * @param options SSH connection options
662
+ */
663
+ export async function getDetailedSessionInfo(
664
+ sessionName: string,
665
+ options: SSHOptions
666
+ ): Promise<{
667
+ exists: boolean;
668
+ windows: Array<{
669
+ index: string;
670
+ name: string;
671
+ active: boolean;
672
+ panes: Array<{
673
+ index: string;
674
+ currentPath: string;
675
+ pid: string;
676
+ active: boolean;
677
+ }>;
678
+ }>;
679
+ } | null> {
680
+ const pool = getSSHPool();
681
+
682
+ try {
683
+ // First check if session exists
684
+ const exists = await hasTmuxSession(sessionName, options);
685
+ if (!exists) {
686
+ return { exists: false, windows: [] };
687
+ }
688
+
689
+ // Get all windows
690
+ const windows = await listSessionWindows(sessionName, options);
691
+
692
+ // Get panes for each window
693
+ const windowsWithPanes = await Promise.all(
694
+ windows.map(async (window) => {
695
+ const panes = await listWindowPanes(sessionName, window.index, options);
696
+ return {
697
+ ...window,
698
+ panes,
699
+ };
700
+ })
701
+ );
702
+
703
+ return {
704
+ exists: true,
705
+ windows: windowsWithPanes,
706
+ };
707
+ } catch (error) {
708
+ console.error(`[Tmux] Failed to get detailed info for ${sessionName}:`, error);
709
+ return null;
710
+ }
711
+ }