@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,839 @@
1
+ /**
2
+ * tmux-based Local Terminal Sessions
3
+ * Provides persistent terminal sessions using local tmux for SSH connections
4
+ * SSH connections stay active within tmux sessions on the local machine
5
+ */
6
+
7
+ import { exec } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+
10
+ const execAsync = promisify(exec);
11
+
12
+ /**
13
+ * Local tmux session configuration
14
+ */
15
+ interface LocalTmuxConfig {
16
+ /** Session name prefix for local MCP SSH sessions */
17
+ sessionPrefix: string;
18
+ /** Default shell to use in tmux */
19
+ defaultShell: string;
20
+ /** Terminal type */
21
+ term: string;
22
+ /** Timeout for local commands (seconds) */
23
+ timeout: number;
24
+ /** Scrollback limit (lines) */
25
+ historyLimit: number;
26
+ /** Session age limit for cleanup (milliseconds) */
27
+ sessionAgeLimit: number;
28
+ }
29
+
30
+ const DEFAULT_CONFIG: LocalTmuxConfig = {
31
+ sessionPrefix: "mcp-ssh",
32
+ defaultShell: "/bin/bash",
33
+ term: "xterm-256color",
34
+ timeout: 30,
35
+ historyLimit: 10000, // 10k lines ~ 1-2MB per session
36
+ sessionAgeLimit: 30 * 24 * 60 * 60 * 1000, // 30 days
37
+ };
38
+
39
+ /**
40
+ * Options for creating a local tmux SSH session
41
+ */
42
+ export interface LocalTmuxSessionOptions {
43
+ /** Initial command to run after SSH connection */
44
+ initialCommand?: string;
45
+ /** Custom session name (auto-generated if not provided) */
46
+ sessionName?: string;
47
+ /** Window name */
48
+ windowName?: string;
49
+ }
50
+
51
+ /**
52
+ * Result of creating a local tmux session
53
+ */
54
+ export interface LocalTmuxSessionResult {
55
+ /** Session name */
56
+ sessionName: string;
57
+ /** Whether the session was newly created */
58
+ newlyCreated: boolean;
59
+ /** Full tmux command used to create the session */
60
+ command: string;
61
+ }
62
+
63
+ /**
64
+ * Check if tmux is installed locally
65
+ */
66
+ export async function isLocalTmuxInstalled(): Promise<boolean> {
67
+ try {
68
+ const { stdout } = await execAsync("type tmux", { timeout: 5000 });
69
+ return stdout.trim() !== "tmux not found" && stdout.trim() !== "";
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Generate a local tmux session name for a host
77
+ * @param host - Remote host IP or hostname
78
+ * @param user - SSH user (default: "root")
79
+ * @returns Session name (e.g., "mcp-ssh-192-168-1-1")
80
+ */
81
+ export function generateLocalSessionName(host: string, user: string = "root"): string {
82
+ const sanitizedHost = host.replace(/[.]/g, "-");
83
+ return `${DEFAULT_CONFIG.sessionPrefix}-${sanitizedHost}`;
84
+ }
85
+
86
+ /**
87
+ * List all local tmux sessions
88
+ * @returns Array of session names
89
+ */
90
+ export async function listLocalSessions(): Promise<string[]> {
91
+ try {
92
+ const { stdout } = await execAsync(
93
+ 'tmux list-sessions -F "#{session_name}" 2>/dev/null || echo ""',
94
+ { timeout: 5000 }
95
+ );
96
+
97
+ if (!stdout || stdout.trim() === "") {
98
+ return [];
99
+ }
100
+
101
+ return stdout.trim().split("\n");
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Check if a specific local tmux session exists
109
+ * @param sessionName - Session name to check
110
+ * @returns True if session exists
111
+ */
112
+ export async function hasLocalSession(sessionName: string): Promise<boolean> {
113
+ const sessions = await listLocalSessions();
114
+ return sessions.includes(sessionName);
115
+ }
116
+
117
+ /**
118
+ * Create a local tmux session with an active SSH connection
119
+ *
120
+ * This function creates a tmux session on the local machine that maintains
121
+ * an active SSH connection to the remote host. The connection stays alive
122
+ * within the tmux session, allowing for persistent interactions.
123
+ *
124
+ * @param host - Remote host IP or hostname
125
+ * @param user - SSH user (default: "root")
126
+ * @param keyPath - Path to SSH private key (for key-based auth)
127
+ * @param password - SSH password (for password-based auth)
128
+ * @param options - Additional options for session creation
129
+ * @returns Session creation result
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * // Key-based authentication
134
+ * const result = await createLocalTmuxSSHSession(
135
+ * "192.168.1.100",
136
+ * "root",
137
+ * "/path/to/key"
138
+ * );
139
+ *
140
+ * // Password-based authentication (requires sshpass)
141
+ * const result = await createLocalTmuxSSHSession(
142
+ * "192.168.1.100",
143
+ * "root",
144
+ * undefined,
145
+ * "mypassword"
146
+ * );
147
+ * ```
148
+ */
149
+ export async function createLocalTmuxSSHSession(
150
+ host: string,
151
+ user: string = "root",
152
+ keyPath?: string,
153
+ password?: string,
154
+ options: LocalTmuxSessionOptions = {}
155
+ ): Promise<LocalTmuxSessionResult> {
156
+ // Check if tmux is installed
157
+ const tmuxInstalled = await isLocalTmuxInstalled();
158
+ if (!tmuxInstalled) {
159
+ throw new Error(
160
+ "tmux is not installed on the local machine. Please install it using: brew install tmux (macOS) or apt install tmux (Linux)"
161
+ );
162
+ }
163
+
164
+ const sessionName = options.sessionName || generateLocalSessionName(host, user);
165
+ const windowName = options.windowName || "ssh";
166
+
167
+ // Check if session already exists
168
+ const sessionExists = await hasLocalSession(sessionName);
169
+
170
+ if (sessionExists) {
171
+ // Session already exists, return info
172
+ return {
173
+ sessionName,
174
+ newlyCreated: false,
175
+ command: `tmux attach-session -t ${sessionName}`,
176
+ };
177
+ }
178
+
179
+ // Build SSH command based on auth method
180
+ let sshCommand: string;
181
+
182
+ if (keyPath) {
183
+ // Key-based authentication
184
+ sshCommand = `ssh -i ${keyPath} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${user}@${host}`;
185
+ } else if (password) {
186
+ // Password-based authentication using sshpass
187
+ // Check if sshpass is available
188
+ try {
189
+ await execAsync("type sshpass", { timeout: 5000 });
190
+ sshCommand = `sshpass -p '${password}' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${user}@${host}`;
191
+ } catch {
192
+ throw new Error(
193
+ "Password authentication requires 'sshpass' to be installed. Please install it using: brew install sshpass (macOS) or apt install sshpass (Linux). Alternatively, use key-based authentication."
194
+ );
195
+ }
196
+ } else {
197
+ // No auth method provided, try default SSH agent
198
+ sshCommand = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${user}@${host}`;
199
+ }
200
+
201
+ // Create new tmux session with SSH connection
202
+ // We use new-session -d to create in detached mode
203
+ const createCmd = [
204
+ "tmux",
205
+ "new-session",
206
+ "-d",
207
+ "-s", sessionName,
208
+ "-n", windowName,
209
+ sshCommand,
210
+ ].join(" ");
211
+
212
+ try {
213
+ // Create the session
214
+ await execAsync(createCmd, { timeout: DEFAULT_CONFIG.timeout * 1000 });
215
+
216
+ // Configure the session
217
+ try {
218
+ await execAsync(
219
+ `tmux set-option -t "${sessionName}" history-limit ${DEFAULT_CONFIG.historyLimit}`,
220
+ { timeout: 5000 }
221
+ );
222
+ } catch {
223
+ // Ignore config errors
224
+ }
225
+
226
+ // If initial command is provided, send it after SSH connects
227
+ if (options.initialCommand) {
228
+ // Wait a moment for SSH to connect
229
+ await new Promise(resolve => setTimeout(resolve, 2000));
230
+ await sendCommandToLocalSession(sessionName, options.initialCommand);
231
+ }
232
+
233
+ return {
234
+ sessionName,
235
+ newlyCreated: true,
236
+ command: createCmd,
237
+ };
238
+ } catch (error) {
239
+ throw new Error(
240
+ `Failed to create local tmux session: ${error instanceof Error ? error.message : String(error)}`
241
+ );
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Send a command to a local tmux pane (already SSH'd into the remote host)
247
+ *
248
+ * This function sends a command to the tmux pane, which will be executed
249
+ * on the remote host since the SSH connection is already active.
250
+ *
251
+ * @param sessionName - Local tmux session name
252
+ * @param command - Command to send to the remote host
253
+ * @param paneIndex - Pane index (default: "0")
254
+ * @param windowName - Window name (default: auto-detects first window)
255
+ * @returns True if command was sent successfully
256
+ */
257
+ export async function sendCommandToLocalSession(
258
+ sessionName: string,
259
+ command: string,
260
+ paneIndex: string = "0",
261
+ windowName?: string
262
+ ): Promise<boolean> {
263
+ try {
264
+ // Escape command for shell - need to escape backslashes and quotes
265
+ const escapedCmd = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
266
+
267
+ // If no window name provided, try to detect the first window
268
+ let targetWindow = windowName;
269
+ if (!targetWindow) {
270
+ try {
271
+ const windows = await listLocalSessionWindows(sessionName);
272
+ if (windows.length > 0) {
273
+ targetWindow = windows[0].name;
274
+ }
275
+ } catch {
276
+ // Fallback to default
277
+ targetWindow = "ssh";
278
+ }
279
+ }
280
+
281
+ // Use send-keys to send command as keystrokes, then Enter
282
+ // Format: {session}:{window} or {session}:{window}.{pane}
283
+ // Tmux uses 1-based indexing for panes. We target window by name
284
+ // If paneIndex is specified (not "0"), convert to 1-based and target the specific pane
285
+ // Otherwise, target just the window (tmux defaults to the active/first pane)
286
+ const target = paneIndex === "0"
287
+ ? `${sessionName}:${targetWindow}`
288
+ : `${sessionName}:${targetWindow}.${parseInt(paneIndex, 10)}`;
289
+ const sendCmd = `tmux send-keys -t "${target}" "${escapedCmd}" Enter`;
290
+
291
+ await execAsync(sendCmd, { timeout: 5000 });
292
+ return true;
293
+ } catch (error) {
294
+ console.error(
295
+ `[LocalTmux] Failed to send command to ${sessionName}:${paneIndex}:`,
296
+ error
297
+ );
298
+ return false;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Capture output from a local tmux pane
304
+ *
305
+ * This captures the current visible content of the pane, including
306
+ * the scrollback history.
307
+ *
308
+ * @param sessionName - Local tmux session name
309
+ * @param paneIndex - Pane index (default: "0")
310
+ * @param windowName - Window name (default: auto-detects first window)
311
+ * @returns Captured output or null if failed
312
+ */
313
+ export async function captureLocalPane(
314
+ sessionName: string,
315
+ paneIndex: string = "0",
316
+ windowName?: string
317
+ ): Promise<string | null> {
318
+ try {
319
+ // If no window name provided, try to detect the first window
320
+ let targetWindow = windowName;
321
+ if (!targetWindow) {
322
+ try {
323
+ const windows = await listLocalSessionWindows(sessionName);
324
+ if (windows.length > 0) {
325
+ targetWindow = windows[0].name;
326
+ }
327
+ } catch {
328
+ targetWindow = "ssh";
329
+ }
330
+ }
331
+
332
+ // Format: {session}:{window} or {session}:{window}.{pane}
333
+ // Tmux uses 1-based indexing for panes
334
+ const target = paneIndex === "0"
335
+ ? `${sessionName}:${targetWindow}`
336
+ : `${sessionName}:${targetWindow}.${parseInt(paneIndex, 10)}`;
337
+ const { stdout } = await execAsync(
338
+ `tmux capture-pane -t "${target}" -p`,
339
+ { timeout: 5000 }
340
+ );
341
+
342
+ return stdout || null;
343
+ } catch (error) {
344
+ console.error(
345
+ `[LocalTmux] Failed to capture pane ${sessionName}:${paneIndex}:`,
346
+ error
347
+ );
348
+ return null;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Get scrollback/history from a local tmux pane
354
+ *
355
+ * @param sessionName - Local tmux session name
356
+ * @param paneIndex - Pane index (default: "0")
357
+ * @param lines - Number of lines to retrieve (default: -1 for all)
358
+ * @param windowName - Window name (default: auto-detects first window)
359
+ * @returns History content or null if failed
360
+ */
361
+ export async function getLocalPaneHistory(
362
+ sessionName: string,
363
+ paneIndex: string = "0",
364
+ lines: number = -1,
365
+ windowName?: string
366
+ ): Promise<string | null> {
367
+ try {
368
+ // If no window name provided, try to detect the first window
369
+ let targetWindow = windowName;
370
+ if (!targetWindow) {
371
+ try {
372
+ const windows = await listLocalSessionWindows(sessionName);
373
+ if (windows.length > 0) {
374
+ targetWindow = windows[0].name;
375
+ }
376
+ } catch {
377
+ targetWindow = "ssh";
378
+ }
379
+ }
380
+
381
+ // Format: {session}:{window} or {session}:{window}.{pane}
382
+ // Tmux uses 1-based indexing for panes
383
+ const target = paneIndex === "0"
384
+ ? `${sessionName}:${targetWindow}`
385
+ : `${sessionName}:${targetWindow}.${parseInt(paneIndex, 10)}`;
386
+ const linesArg = lines > 0 ? `-S -${lines}` : "-S -";
387
+ const { stdout } = await execAsync(
388
+ `tmux capture-pane ${linesArg} -t "${target}" -p`,
389
+ { timeout: 10000 }
390
+ );
391
+
392
+ return stdout || null;
393
+ } catch (error) {
394
+ console.error(
395
+ `[LocalTmux] Failed to get history for ${sessionName}:${paneIndex}:`,
396
+ error
397
+ );
398
+ return null;
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Kill a local tmux session
404
+ *
405
+ * @param sessionName - Session name to kill
406
+ * @returns True if session was killed successfully
407
+ */
408
+ export async function killLocalSession(sessionName: string): Promise<boolean> {
409
+ try {
410
+ await execAsync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, {
411
+ timeout: 5000,
412
+ });
413
+ return true;
414
+ } catch {
415
+ return false;
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Get information about a local tmux session
421
+ *
422
+ * @param sessionName - Session name to query
423
+ * @returns Session information or null if session doesn't exist
424
+ */
425
+ export async function getLocalSessionInfo(
426
+ sessionName: string
427
+ ): Promise<{
428
+ exists: boolean;
429
+ windows?: number;
430
+ panes?: number;
431
+ } | null> {
432
+ try {
433
+ const { stdout } = await execAsync(
434
+ `tmux display-message -t "${sessionName}" -p '#{session_windows} #{window_panes}' 2>/dev/null || echo ""`,
435
+ { timeout: 5000 }
436
+ );
437
+
438
+ if (!stdout || stdout.trim() === "") {
439
+ return { exists: false };
440
+ }
441
+
442
+ const [windows, panes] = stdout.trim().split(" ").map(Number);
443
+ return { exists: true, windows, panes };
444
+ } catch {
445
+ return { exists: false };
446
+ }
447
+ }
448
+
449
+ /**
450
+ * List all windows in a local tmux session
451
+ *
452
+ * @param sessionName - Local tmux session name
453
+ * @returns Array of window information
454
+ */
455
+ export async function listLocalSessionWindows(
456
+ sessionName: string
457
+ ): Promise<Array<{ index: string; name: string; active: boolean }>> {
458
+ try {
459
+ const { stdout } = await execAsync(
460
+ `tmux list-windows -t "${sessionName}" -F '#{window_index} #{window_name} #{window_active}' 2>/dev/null || echo ''`,
461
+ { timeout: 5000 }
462
+ );
463
+
464
+ if (!stdout || stdout.trim() === "") {
465
+ return [];
466
+ }
467
+
468
+ return stdout.trim().split("\n").map(line => {
469
+ const [index, name, active] = line.split(" ");
470
+ return { index, name, active: active === "1" };
471
+ });
472
+ } catch {
473
+ return [];
474
+ }
475
+ }
476
+
477
+ /**
478
+ * List all panes in a local tmux session window
479
+ *
480
+ * @param sessionName - Local tmux session name
481
+ * @param windowIndex - Window index (default: "0")
482
+ * @returns Array of pane information
483
+ */
484
+ export async function listLocalWindowPanes(
485
+ sessionName: string,
486
+ windowIndex: string = "0"
487
+ ): Promise<
488
+ Array<{ index: string; currentPath: string; pid: string; active: boolean }>
489
+ > {
490
+ try {
491
+ const { stdout } = await execAsync(
492
+ `tmux list-panes -t "${sessionName}:${windowIndex}" -F '#{pane_index} #{pane_current_path} #{pane_pid} #{pane_active}' 2>/dev/null || echo ''`,
493
+ { timeout: 5000 }
494
+ );
495
+
496
+ if (!stdout || stdout.trim() === "") {
497
+ return [];
498
+ }
499
+
500
+ return stdout.trim().split("\n").map(line => {
501
+ const [index, currentPath, pid, active] = line.split(" ");
502
+ return { index, currentPath, pid, active: active === "1" };
503
+ });
504
+ } catch {
505
+ return [];
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Split a pane horizontally or vertically in a local tmux session
511
+ *
512
+ * @param sessionName - Local tmux session name
513
+ * @param direction - Split direction: "h" (horizontal) or "v" (vertical)
514
+ * @param command - Optional command to run in the new pane
515
+ * @param windowName - Window name (default: auto-detects first window)
516
+ * @returns The new pane index or null if failed
517
+ */
518
+ export async function splitLocalPane(
519
+ sessionName: string,
520
+ direction: "h" | "v" = "v",
521
+ command?: string,
522
+ windowName?: string
523
+ ): Promise<string | null> {
524
+ try {
525
+ // If no window name provided, try to detect the first window
526
+ let targetWindow = windowName;
527
+ if (!targetWindow) {
528
+ try {
529
+ const windows = await listLocalSessionWindows(sessionName);
530
+ if (windows.length > 0) {
531
+ targetWindow = windows[0].name;
532
+ }
533
+ } catch {
534
+ targetWindow = "ssh";
535
+ }
536
+ }
537
+
538
+ const target = `${sessionName}:${targetWindow}`;
539
+ const splitCmd = command
540
+ ? `tmux split-window -${direction} -t "${target}" -c "#{pane_current_path}" "${command}"`
541
+ : `tmux split-window -${direction} -t "${target}"`;
542
+
543
+ const { stdout } = await execAsync(splitCmd, { timeout: 10000 });
544
+
545
+ return stdout?.trim() || null;
546
+ } catch (error) {
547
+ console.error(`[LocalTmux] Failed to split pane in ${sessionName}:`, error);
548
+ return null;
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Cleanup old local tmux sessions
554
+ *
555
+ * @param config - Optional configuration (uses default age limit if not provided)
556
+ * @returns Object with cleaned count and errors
557
+ */
558
+ export async function cleanupOldLocalSessions(
559
+ config: Partial<LocalTmuxConfig> = {}
560
+ ): Promise<{ cleaned: number; errors: string[] }> {
561
+ const fullConfig = { ...DEFAULT_CONFIG, ...config };
562
+ const errors: string[] = [];
563
+ let cleaned = 0;
564
+
565
+ try {
566
+ // Get all tmux sessions
567
+ const sessions = await listLocalSessions();
568
+
569
+ // Filter for MCP SSH sessions
570
+ const mcpSessions = sessions.filter(s => s.startsWith(fullConfig.sessionPrefix));
571
+
572
+ // Check age of each session by looking at socket file modification time
573
+ for (const sessionName of mcpSessions) {
574
+ try {
575
+ // Check session age by examining tmux socket file
576
+ // Tmux sockets are in /tmp/tmux-*/default
577
+ const ageCheckCmd = `
578
+ find /tmp -type s -name "*${sessionName}*" 2>/dev/null | head -1 | while read socket; do
579
+ if [ -n "$socket" ]; then
580
+ mtime=$(stat -f %m "$socket" 2>/dev/null || stat -c %Y "$socket" 2>/dev/null)
581
+ if [ -n "$mtime" ]; then
582
+ now=$(date +%s)
583
+ age=$((now - mtime))
584
+ age_ms=$((age * 1000))
585
+ echo "$age_ms"
586
+ fi
587
+ fi
588
+ done
589
+ `;
590
+
591
+ const { stdout } = await execAsync(ageCheckCmd, { timeout: 10000 });
592
+ const ageMs = parseInt(stdout.trim());
593
+
594
+ if (!isNaN(ageMs) && ageMs > fullConfig.sessionAgeLimit) {
595
+ console.log(
596
+ `[LocalTmux] Cleaning up old session "${sessionName}" (age: ${Math.round(ageMs / 86400000)} days)`
597
+ );
598
+ await killLocalSession(sessionName);
599
+ cleaned++;
600
+ }
601
+ } catch (err) {
602
+ errors.push(
603
+ `${sessionName}: ${err instanceof Error ? err.message : String(err)}`
604
+ );
605
+ }
606
+ }
607
+
608
+ return { cleaned, errors };
609
+ } catch (err) {
610
+ errors.push(
611
+ `Cleanup failed: ${err instanceof Error ? err.message : String(err)}`
612
+ );
613
+ return { cleaned, errors };
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Get resource usage information for local tmux sessions
619
+ *
620
+ * @returns Resource usage summary
621
+ */
622
+ export async function getLocalTmuxResourceUsage(): Promise<{
623
+ totalSessions: number;
624
+ mcpSessions: number;
625
+ estimatedMemoryMB: number;
626
+ } | null> {
627
+ try {
628
+ // Get count of tmux sessions
629
+ const sessions = await listLocalSessions();
630
+ const mcpSessions = sessions.filter(s =>
631
+ s.startsWith(DEFAULT_CONFIG.sessionPrefix)
632
+ );
633
+
634
+ // Estimate memory: each session ~10MB base + scrollback buffer
635
+ const estimatedMemoryMB = mcpSessions.length * 11;
636
+
637
+ return {
638
+ totalSessions: sessions.length,
639
+ mcpSessions: mcpSessions.length,
640
+ estimatedMemoryMB,
641
+ };
642
+ } catch {
643
+ return null;
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Wait for a specific text to appear in the pane output
649
+ *
650
+ * @param sessionName - Local tmux session name
651
+ * @param text - Text to wait for
652
+ * @param timeoutMs - Maximum time to wait in milliseconds (default: 30000)
653
+ * @param paneIndex - Pane index (default: "0")
654
+ * @param windowName - Window name (default: auto-detects first window)
655
+ * @returns True if text appeared, false if timeout
656
+ */
657
+ export async function waitForTextInPane(
658
+ sessionName: string,
659
+ text: string,
660
+ timeoutMs: number = 30000,
661
+ paneIndex: string = "0",
662
+ windowName?: string
663
+ ): Promise<boolean> {
664
+ const startTime = Date.now();
665
+ const checkInterval = 500;
666
+
667
+ while (Date.now() - startTime < timeoutMs) {
668
+ const output = await captureLocalPane(sessionName, paneIndex, windowName);
669
+ if (output && output.includes(text)) {
670
+ return true;
671
+ }
672
+ await new Promise(resolve => setTimeout(resolve, checkInterval));
673
+ }
674
+
675
+ return false;
676
+ }
677
+
678
+ /**
679
+ * Switch to a specific window in a local tmux session
680
+ *
681
+ * @param sessionName - Local tmux session name
682
+ * @param windowIndex - Target window index
683
+ * @returns True if successful
684
+ */
685
+ export async function switchLocalWindow(
686
+ sessionName: string,
687
+ windowIndex: string
688
+ ): Promise<boolean> {
689
+ try {
690
+ await execAsync(`tmux select-window -t "${sessionName}:${windowIndex}"`, {
691
+ timeout: 5000,
692
+ });
693
+ return true;
694
+ } catch {
695
+ return false;
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Switch to a specific pane in a local tmux session window
701
+ *
702
+ * @param sessionName - Local tmux session name
703
+ * @param paneIndex - Target pane index (e.g., "0", "1", "0.1" for window.pane)
704
+ * @returns True if successful
705
+ */
706
+ export async function switchLocalPane(
707
+ sessionName: string,
708
+ paneIndex: string
709
+ ): Promise<boolean> {
710
+ try {
711
+ await execAsync(`tmux select-pane -t "${sessionName}:${paneIndex}"`, {
712
+ timeout: 5000,
713
+ });
714
+ return true;
715
+ } catch {
716
+ return false;
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Rename a window in a local tmux session
722
+ *
723
+ * @param sessionName - Local tmux session name
724
+ * @param windowIndex - Window index (default: "0")
725
+ * @param newName - New window name
726
+ * @returns True if successful
727
+ */
728
+ export async function renameLocalWindow(
729
+ sessionName: string,
730
+ windowIndex: string,
731
+ newName: string
732
+ ): Promise<boolean> {
733
+ try {
734
+ await execAsync(
735
+ `tmux rename-window -t "${sessionName}:${windowIndex}" "${newName}"`,
736
+ { timeout: 5000 }
737
+ );
738
+ return true;
739
+ } catch {
740
+ return false;
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Kill a specific pane in a local tmux session
746
+ *
747
+ * @param sessionName - Local tmux session name
748
+ * @param paneIndex - Pane index to kill
749
+ * @param windowName - Window name (default: auto-detects first window)
750
+ * @returns True if successful
751
+ */
752
+ export async function killLocalPane(
753
+ sessionName: string,
754
+ paneIndex: string,
755
+ windowName?: string
756
+ ): Promise<boolean> {
757
+ try {
758
+ // If no window name provided, try to detect the first window
759
+ let targetWindow = windowName;
760
+ if (!targetWindow) {
761
+ try {
762
+ const windows = await listLocalSessionWindows(sessionName);
763
+ if (windows.length > 0) {
764
+ targetWindow = windows[0].name;
765
+ }
766
+ } catch {
767
+ targetWindow = "ssh";
768
+ }
769
+ }
770
+
771
+ // Format: {session}:{window} or {session}:{window}.{pane}
772
+ // Tmux uses 1-based indexing for panes
773
+ const target = paneIndex === "0"
774
+ ? `${sessionName}:${targetWindow}`
775
+ : `${sessionName}:${targetWindow}.${parseInt(paneIndex, 10)}`;
776
+ await execAsync(`tmux kill-pane -t "${target}"`, {
777
+ timeout: 5000,
778
+ });
779
+ return true;
780
+ } catch {
781
+ return false;
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Get detailed information about all panes in a local session
787
+ *
788
+ * @param sessionName - Local tmux session name
789
+ * @returns Detailed session information or null
790
+ */
791
+ export async function getDetailedLocalSessionInfo(
792
+ sessionName: string
793
+ ): Promise<{
794
+ exists: boolean;
795
+ windows: Array<{
796
+ index: string;
797
+ name: string;
798
+ active: boolean;
799
+ panes: Array<{
800
+ index: string;
801
+ currentPath: string;
802
+ pid: string;
803
+ active: boolean;
804
+ }>;
805
+ }>;
806
+ } | null> {
807
+ try {
808
+ // First check if session exists
809
+ const exists = await hasLocalSession(sessionName);
810
+ if (!exists) {
811
+ return { exists: false, windows: [] };
812
+ }
813
+
814
+ // Get all windows
815
+ const windows = await listLocalSessionWindows(sessionName);
816
+
817
+ // Get panes for each window
818
+ const windowsWithPanes = await Promise.all(
819
+ windows.map(async window => {
820
+ const panes = await listLocalWindowPanes(sessionName, window.index);
821
+ return {
822
+ ...window,
823
+ panes,
824
+ };
825
+ })
826
+ );
827
+
828
+ return {
829
+ exists: true,
830
+ windows: windowsWithPanes,
831
+ };
832
+ } catch (error) {
833
+ console.error(
834
+ `[LocalTmux] Failed to get detailed info for ${sessionName}:`,
835
+ error
836
+ );
837
+ return null;
838
+ }
839
+ }