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