@arearseth/tmux-mcp 0.3.1 → 0.3.3
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/README.md +13 -6
- package/build/index.js +112 -9
- package/build/tmux.js +256 -59
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -61,17 +61,24 @@ The CLI flag only sets the server-wide default. You can still override individua
|
|
|
61
61
|
|
|
62
62
|
## Available Tools
|
|
63
63
|
|
|
64
|
+
### Session & Window Management
|
|
64
65
|
- `list-sessions` - List all active tmux sessions
|
|
65
66
|
- `find-session` - Find a tmux session by name
|
|
66
|
-
- `list-windows` - List windows in a tmux session
|
|
67
|
-
- `list-panes` - List panes in a tmux window
|
|
68
|
-
- `capture-pane` - Capture content from a tmux pane
|
|
69
67
|
- `create-session` - Create a new tmux session
|
|
70
|
-
- `create-window` - Create a new window in a tmux session
|
|
71
|
-
- `split-pane` - Split a tmux pane horizontally or vertically with optional size
|
|
72
68
|
- `kill-session` - Kill a tmux session by ID
|
|
69
|
+
- `list-windows` - List windows in a tmux session
|
|
70
|
+
- `create-window` - Create a new window in a tmux session
|
|
73
71
|
- `kill-window` - Kill a tmux window by ID
|
|
72
|
+
|
|
73
|
+
### Pane Management
|
|
74
|
+
- `list-panes` - List panes in a tmux window
|
|
75
|
+
- `capture-pane` - Capture content from a tmux pane
|
|
76
|
+
- `split-pane` - Split a tmux pane horizontally or vertically
|
|
74
77
|
- `kill-pane` - Kill a tmux pane by ID
|
|
75
|
-
|
|
78
|
+
|
|
79
|
+
### Command Execution
|
|
80
|
+
- `set-shell-type` - Configure the shell for command execution (bash, zsh, fish, tclsh)
|
|
76
81
|
- `execute-command` - Execute a command in a tmux pane
|
|
77
82
|
- `get-command-result` - Get the result of an executed command
|
|
83
|
+
- `wait-command-completion` - Poll until a command completes or timeout expires
|
|
84
|
+
- `grep-command-output` - Search completed command output using regex
|
package/build/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import * as tmux from "./tmux.js";
|
|
|
7
7
|
// Create MCP server
|
|
8
8
|
const server = new McpServer({
|
|
9
9
|
name: "tmux-mcp",
|
|
10
|
-
version: "0.
|
|
10
|
+
version: "0.3.3" // Keep in sync with package.json
|
|
11
11
|
}, {
|
|
12
12
|
capabilities: {
|
|
13
13
|
resources: {
|
|
@@ -154,11 +154,13 @@ server.tool("capture-pane", "Capture content from a tmux pane. Defaults to the l
|
|
|
154
154
|
}
|
|
155
155
|
});
|
|
156
156
|
// Create new session - Tool
|
|
157
|
-
server.tool("create-session", "Create a new tmux session", {
|
|
158
|
-
name: z.string().describe("Name for the new tmux session")
|
|
159
|
-
|
|
157
|
+
server.tool("create-session", "Create a new tmux session (optionally minimal to skip startup scripts)", {
|
|
158
|
+
name: z.string().describe("Name for the new tmux session"),
|
|
159
|
+
minimal: z.boolean().optional().describe("Launch with a minimal shell (bash --noprofile --norc) to skip startup scripts for speed."),
|
|
160
|
+
shellCommand: z.string().optional().describe("Custom shell command in the new session. If minimal=true and shellCommand provided, it overrides the default minimal bash. Examples: 'bash --noprofile --norc', 'zsh -f'"),
|
|
161
|
+
}, async ({ name, minimal, shellCommand }) => {
|
|
160
162
|
try {
|
|
161
|
-
const session = await tmux.createSession(name);
|
|
163
|
+
const session = await tmux.createSession(name, { minimal: minimal === true, shellCommand });
|
|
162
164
|
return {
|
|
163
165
|
content: [{
|
|
164
166
|
type: "text",
|
|
@@ -366,11 +368,14 @@ server.tool("execute-command", "Execute a command in a tmux pane and get results
|
|
|
366
368
|
});
|
|
367
369
|
// Get command result - Tool
|
|
368
370
|
server.tool("get-command-result", "Get the result of an executed command", {
|
|
369
|
-
commandId: z.string().describe("ID of the executed command")
|
|
370
|
-
|
|
371
|
+
commandId: z.string().describe("ID of the executed command"),
|
|
372
|
+
lines: z.number().int().positive().optional().describe("Return only the last N lines of output"),
|
|
373
|
+
start: z.number().int().min(0).optional().describe("Start line index (0-based) of slice to return"),
|
|
374
|
+
end: z.number().int().min(0).optional().describe("End line index (0-based, inclusive) of slice to return")
|
|
375
|
+
}, async ({ commandId, lines, start, end }) => {
|
|
371
376
|
try {
|
|
372
377
|
// Check and update command status
|
|
373
|
-
const command = await tmux.checkCommandStatus(commandId);
|
|
378
|
+
const command = await tmux.checkCommandStatus(commandId, { lines, start, end });
|
|
374
379
|
if (!command) {
|
|
375
380
|
return {
|
|
376
381
|
content: [{
|
|
@@ -391,7 +396,19 @@ server.tool("get-command-result", "Get the result of an executed command", {
|
|
|
391
396
|
}
|
|
392
397
|
}
|
|
393
398
|
else {
|
|
394
|
-
|
|
399
|
+
const metaLines = [
|
|
400
|
+
`Status: ${command.status}`,
|
|
401
|
+
`Exit code: ${command.exitCode}`,
|
|
402
|
+
`Command: ${command.command}`
|
|
403
|
+
];
|
|
404
|
+
if (command.truncated) {
|
|
405
|
+
const endIdxDisplay = command.lineEndIndex !== undefined ? command.lineEndIndex - 1 : (command.returnedLines ? (command.lineStartIndex ?? 0) + (command.returnedLines - 1) : 'unknown');
|
|
406
|
+
metaLines.push(`Output truncated: showing ${command.returnedLines} of ${command.totalLines} lines (slice ${command.lineStartIndex}..${endIdxDisplay})`);
|
|
407
|
+
}
|
|
408
|
+
else if (command.outputLines) {
|
|
409
|
+
metaLines.push(`Lines returned: ${command.returnedLines ?? command.outputLines.length}`);
|
|
410
|
+
}
|
|
411
|
+
resultText = metaLines.join("\n") + `\n\n--- Output ---\n${command.result}`;
|
|
395
412
|
}
|
|
396
413
|
return {
|
|
397
414
|
content: [{
|
|
@@ -410,6 +427,92 @@ server.tool("get-command-result", "Get the result of an executed command", {
|
|
|
410
427
|
};
|
|
411
428
|
}
|
|
412
429
|
});
|
|
430
|
+
// Wait for command completion - Tool
|
|
431
|
+
server.tool("wait-command-completion", "Poll until a command completes or timeout expires. Returns final or intermediate status with sliced output.", {
|
|
432
|
+
commandId: z.string().describe("ID of the executed command"),
|
|
433
|
+
timeoutMs: z.number().int().positive().optional().describe("Maximum milliseconds to wait (default 10000)"),
|
|
434
|
+
intervalMs: z.number().int().positive().optional().describe("Polling interval milliseconds (default 150)"),
|
|
435
|
+
lines: z.number().int().positive().optional().describe("Return only the last N lines of output when completed"),
|
|
436
|
+
start: z.number().int().min(0).optional().describe("Start line index (0-based) slice"),
|
|
437
|
+
end: z.number().int().min(0).optional().describe("End line index (0-based, inclusive) slice")
|
|
438
|
+
}, async ({ commandId, timeoutMs, intervalMs, lines, start, end }) => {
|
|
439
|
+
try {
|
|
440
|
+
const status = await tmux.waitForCompletion(commandId, timeoutMs ?? 10000, intervalMs ?? 150);
|
|
441
|
+
if (!status) {
|
|
442
|
+
return { content: [{ type: 'text', text: `Command not found: ${commandId}` }], isError: true };
|
|
443
|
+
}
|
|
444
|
+
// If completed we may want a sliced result
|
|
445
|
+
if (status.status !== 'pending' && (lines !== undefined || start !== undefined || end !== undefined)) {
|
|
446
|
+
const refreshed = await tmux.checkCommandStatus(commandId, { lines, start, end });
|
|
447
|
+
if (refreshed) {
|
|
448
|
+
// Adopt sliced result and metadata for consistency with get-command-result
|
|
449
|
+
status.result = refreshed.result;
|
|
450
|
+
status.returnedLines = refreshed.returnedLines;
|
|
451
|
+
status.lineStartIndex = refreshed.lineStartIndex;
|
|
452
|
+
status.lineEndIndex = refreshed.lineEndIndex;
|
|
453
|
+
status.truncated = refreshed.truncated;
|
|
454
|
+
status.totalLines = refreshed.totalLines;
|
|
455
|
+
status.outputLines = refreshed.outputLines;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const meta = [
|
|
459
|
+
`Status: ${status.status}`,
|
|
460
|
+
`Exit code: ${status.exitCode ?? 'n/a'}`,
|
|
461
|
+
`Command: ${status.command}`
|
|
462
|
+
];
|
|
463
|
+
if (status.truncated) {
|
|
464
|
+
const endIdxDisplay = status.lineEndIndex !== undefined ? status.lineEndIndex - 1 : 'unknown';
|
|
465
|
+
meta.push(`Output truncated: showing ${status.returnedLines} of ${status.totalLines} lines (slice ${status.lineStartIndex}..${endIdxDisplay})`);
|
|
466
|
+
}
|
|
467
|
+
else if (status.outputLines) {
|
|
468
|
+
meta.push(`Lines returned: ${status.returnedLines ?? status.outputLines.length}`);
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
content: [{
|
|
472
|
+
type: 'text',
|
|
473
|
+
text: meta.join('\n') + `\n\n--- Output ---\n${status.result || ''}`
|
|
474
|
+
}]
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
return { content: [{ type: 'text', text: `Error waiting for command: ${error}` }], isError: true };
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
// Grep command output - Tool
|
|
482
|
+
server.tool("grep-command-output", "Search completed command output lines using a regular expression. Requires the command to have completed (non-pending). Returns matching lines.", {
|
|
483
|
+
commandId: z.string().describe("ID of the executed command"),
|
|
484
|
+
pattern: z.string().describe("Regular expression pattern (ECMAScript syntax)"),
|
|
485
|
+
flags: z.string().optional().describe("Regex flags (e.g. i, m, g). 'g' is ignored for matching lines but allowed."),
|
|
486
|
+
limit: z.number().int().positive().optional().describe("Maximum number of matching lines to return (from first match onward)")
|
|
487
|
+
}, async ({ commandId, pattern, flags, limit }) => {
|
|
488
|
+
try {
|
|
489
|
+
const command = tmux.getCommand(commandId);
|
|
490
|
+
if (!command) {
|
|
491
|
+
return { content: [{ type: 'text', text: `Command not found: ${commandId}` }], isError: true };
|
|
492
|
+
}
|
|
493
|
+
if (command.status === 'pending') {
|
|
494
|
+
return { content: [{ type: 'text', text: `Command still pending: ${commandId}` }], isError: true };
|
|
495
|
+
}
|
|
496
|
+
const lines = tmux.grepCommandOutput(commandId, pattern, flags);
|
|
497
|
+
const limited = limit ? lines.slice(0, limit) : lines;
|
|
498
|
+
return {
|
|
499
|
+
content: [{
|
|
500
|
+
type: 'text',
|
|
501
|
+
text: JSON.stringify({
|
|
502
|
+
commandId,
|
|
503
|
+
pattern,
|
|
504
|
+
flags: flags || '',
|
|
505
|
+
totalMatches: lines.length,
|
|
506
|
+
returned: limited.length,
|
|
507
|
+
matches: limited
|
|
508
|
+
}, null, 2)
|
|
509
|
+
}]
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
return { content: [{ type: 'text', text: `Error during grep: ${error}` }], isError: true };
|
|
514
|
+
}
|
|
515
|
+
});
|
|
413
516
|
// Expose tmux session list as a resource
|
|
414
517
|
server.resource("Tmux Sessions", "tmux://sessions", async () => {
|
|
415
518
|
try {
|
package/build/tmux.js
CHANGED
|
@@ -2,6 +2,14 @@ import { exec as execCallback } from "child_process";
|
|
|
2
2
|
import { promisify } from "util";
|
|
3
3
|
import { v4 as uuidv4 } from 'uuid';
|
|
4
4
|
const exec = promisify(execCallback);
|
|
5
|
+
// Debug helper (enable by setting env TMUX_MCP_DEBUG=1 when launching server)
|
|
6
|
+
const DEBUG_ENABLED = process.env.TMUX_MCP_DEBUG === '1';
|
|
7
|
+
function debug(...args) {
|
|
8
|
+
if (DEBUG_ENABLED) {
|
|
9
|
+
// stderr to avoid interfering with captured pane content
|
|
10
|
+
console.error('[tmux-mcp-debug]', ...args);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
5
13
|
export const supportedShellTypes = ['bash', 'zsh', 'fish', 'tclsh'];
|
|
6
14
|
const shellConfig = {
|
|
7
15
|
defaultType: 'bash',
|
|
@@ -120,23 +128,70 @@ export async function listPanes(windowId) {
|
|
|
120
128
|
}
|
|
121
129
|
/**
|
|
122
130
|
* Capture content from a specific pane, by default the latest 200 lines.
|
|
131
|
+
* Note: tmux's -S and -E flags are unreliable due to cursor position,
|
|
132
|
+
* so we capture a range and slice in JavaScript.
|
|
123
133
|
*/
|
|
124
134
|
export async function capturePaneContent(paneId, options = {}) {
|
|
125
135
|
const { lines = 200, start, end, includeColors = false } = options;
|
|
126
|
-
|
|
127
|
-
|
|
136
|
+
// Determine start value for tmux capture
|
|
137
|
+
// We'll use this to capture enough data, then slice accurately
|
|
138
|
+
let tmuxStart;
|
|
139
|
+
if (start !== undefined) {
|
|
140
|
+
tmuxStart = String(start);
|
|
141
|
+
}
|
|
142
|
+
else if (lines === 0) {
|
|
143
|
+
// Capture all available lines
|
|
144
|
+
tmuxStart = '-'; // start from the beginning of history
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
// Default: capture the last N lines from history
|
|
148
|
+
tmuxStart = `-${lines}`;
|
|
149
|
+
}
|
|
128
150
|
const commandParts = ['capture-pane', '-p'];
|
|
129
151
|
if (includeColors) {
|
|
130
152
|
commandParts.push('-e');
|
|
131
153
|
}
|
|
132
|
-
commandParts.push('-t', `'${paneId}'`, '-S',
|
|
133
|
-
|
|
154
|
+
commandParts.push('-t', `'${paneId}'`, '-S', tmuxStart, '-E', '-' // Always use '-' for end because specific line numbers are unreliable
|
|
155
|
+
);
|
|
156
|
+
const capturedLines = await executeTmux(commandParts.join(' '));
|
|
157
|
+
// Now slice the output in JavaScript for accurate results
|
|
158
|
+
const linesArray = capturedLines.split('\n');
|
|
159
|
+
// Calculate actual slice indices
|
|
160
|
+
let sliceStart = 0;
|
|
161
|
+
let sliceEnd = linesArray.length;
|
|
162
|
+
// Handle start parameter
|
|
163
|
+
if (start !== undefined) {
|
|
164
|
+
const startNum = typeof start === 'number' ? start : parseInt(start, 10);
|
|
165
|
+
// Negative values count from end
|
|
166
|
+
sliceStart = startNum < 0 ? Math.max(0, linesArray.length + startNum) : 0;
|
|
167
|
+
}
|
|
168
|
+
else if (lines !== undefined && lines > 0) {
|
|
169
|
+
// If no start specified but lines is, take last N lines
|
|
170
|
+
sliceStart = Math.max(0, linesArray.length - lines);
|
|
171
|
+
}
|
|
172
|
+
// Handle end parameter
|
|
173
|
+
if (end !== undefined) {
|
|
174
|
+
const endNum = typeof end === 'number' ? end : parseInt(end, 10);
|
|
175
|
+
// Negative values count from end
|
|
176
|
+
sliceEnd = endNum < 0 ? linesArray.length + endNum + 1 : endNum + 1;
|
|
177
|
+
}
|
|
178
|
+
return linesArray.slice(sliceStart, sliceEnd).join('\n');
|
|
134
179
|
}
|
|
135
180
|
/**
|
|
136
181
|
* Create a new tmux session
|
|
137
182
|
*/
|
|
138
|
-
export async function createSession(name) {
|
|
139
|
-
|
|
183
|
+
export async function createSession(name, options) {
|
|
184
|
+
// Allow launching with a minimal shell to skip startup scripts.
|
|
185
|
+
let launchCmd = `new-session -d -s "${name}"`;
|
|
186
|
+
if (options?.minimal) {
|
|
187
|
+
const shell = options.shellCommand || 'bash --noprofile --norc';
|
|
188
|
+
// Quote shell command separately so user shell isn't expanded prematurely.
|
|
189
|
+
launchCmd += ` '${shell.replace(/'/g, "'\\''")}'`;
|
|
190
|
+
}
|
|
191
|
+
else if (options?.shellCommand) {
|
|
192
|
+
launchCmd += ` '${options.shellCommand.replace(/'/g, "'\\''")}'`;
|
|
193
|
+
}
|
|
194
|
+
await executeTmux(launchCmd);
|
|
140
195
|
return findSessionByName(name);
|
|
141
196
|
}
|
|
142
197
|
/**
|
|
@@ -195,15 +250,19 @@ export async function splitPane(targetPaneId, direction = 'vertical', size) {
|
|
|
195
250
|
}
|
|
196
251
|
// Map to track ongoing command executions
|
|
197
252
|
const activeCommands = new Map();
|
|
198
|
-
const
|
|
199
|
-
const
|
|
253
|
+
const startMarkerBase = 'TMUX_MCP_START';
|
|
254
|
+
const endMarkerBase = 'TMUX_MCP_DONE';
|
|
255
|
+
const DEFAULT_RESULT_LINES = 100; // default number of lines returned when output is large
|
|
200
256
|
// Track tclsh initialization per pane to keep terminal output minimal
|
|
201
257
|
const tclshInitializedPanes = new Set();
|
|
258
|
+
let wrappedCommandSequenceCounter = 0; // incremented for each non-raw wrapped command (sequence numbers)
|
|
202
259
|
// Execute a command in a tmux pane and track its execution
|
|
203
260
|
export async function executeCommand(paneId, command, rawMode, noEnter) {
|
|
204
261
|
// Generate unique ID for this command execution
|
|
205
262
|
const commandId = uuidv4();
|
|
206
263
|
const shellType = resolveShellType(paneId);
|
|
264
|
+
const sequenceNumber = (!rawMode && !noEnter) ? (wrappedCommandSequenceCounter + 1) : undefined;
|
|
265
|
+
debug('executeCommand: preparing', { paneId, command, rawMode, noEnter, shellType, sequenceNumber });
|
|
207
266
|
let fullCommand;
|
|
208
267
|
if (rawMode || noEnter) {
|
|
209
268
|
fullCommand = command;
|
|
@@ -211,20 +270,26 @@ export async function executeCommand(paneId, command, rawMode, noEnter) {
|
|
|
211
270
|
else {
|
|
212
271
|
if (shellType === 'tclsh') {
|
|
213
272
|
await ensureTclshInitialized(paneId);
|
|
214
|
-
fullCommand = buildTclshCommand(command);
|
|
273
|
+
fullCommand = buildTclshCommand(command, sequenceNumber);
|
|
215
274
|
}
|
|
216
275
|
else {
|
|
217
|
-
fullCommand = buildWrappedCommand(command, shellType);
|
|
276
|
+
fullCommand = buildWrappedCommand(command, shellType, sequenceNumber);
|
|
218
277
|
}
|
|
278
|
+
debug('executeCommand: wrapped command', fullCommand);
|
|
219
279
|
}
|
|
220
280
|
// Store command in tracking map
|
|
281
|
+
if (sequenceNumber) {
|
|
282
|
+
wrappedCommandSequenceCounter = sequenceNumber; // commit increment
|
|
283
|
+
}
|
|
284
|
+
debug('executeCommand: sending keys', { paneId, fullCommand, noEnter });
|
|
221
285
|
activeCommands.set(commandId, {
|
|
222
286
|
id: commandId,
|
|
223
287
|
paneId,
|
|
224
288
|
command,
|
|
225
289
|
status: 'pending',
|
|
226
290
|
startTime: new Date(),
|
|
227
|
-
rawMode: rawMode || noEnter
|
|
291
|
+
rawMode: rawMode || noEnter,
|
|
292
|
+
sequenceNumber
|
|
228
293
|
});
|
|
229
294
|
// Send the command to the tmux pane
|
|
230
295
|
if (noEnter) {
|
|
@@ -250,48 +315,126 @@ export async function executeCommand(paneId, command, rawMode, noEnter) {
|
|
|
250
315
|
}
|
|
251
316
|
return commandId;
|
|
252
317
|
}
|
|
253
|
-
export async function checkCommandStatus(commandId) {
|
|
318
|
+
export async function checkCommandStatus(commandId, options) {
|
|
254
319
|
const command = activeCommands.get(commandId);
|
|
255
320
|
if (!command)
|
|
256
321
|
return null;
|
|
257
322
|
if (command.status !== 'pending')
|
|
258
323
|
return command;
|
|
259
|
-
const content = await capturePaneContent(command.paneId, { lines:
|
|
324
|
+
const content = await capturePaneContent(command.paneId, { lines: 0 }); // capture entire scrollback to avoid missing markers
|
|
325
|
+
debug('checkCommandStatus: captured content length', content.length, 'lines approx', content.split('\n').length);
|
|
260
326
|
if (command.rawMode) {
|
|
261
327
|
command.result = 'Status tracking unavailable for rawMode commands. Use capture-pane to monitor interactive apps instead.';
|
|
262
328
|
return command;
|
|
263
329
|
}
|
|
264
|
-
//
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
330
|
+
// Build marker blocks keyed by sequence number.
|
|
331
|
+
const linesArr = content.split('\n');
|
|
332
|
+
const blocksBySeq = new Map();
|
|
333
|
+
let lastEndLine = -1;
|
|
334
|
+
for (let i = 0; i < linesArr.length; i++) {
|
|
335
|
+
const line = linesArr[i].trim();
|
|
336
|
+
// Start marker pattern: TMUX_MCP_START_<seq>
|
|
337
|
+
const startMatch = line.match(new RegExp(`^${startMarkerBase}_(\\d+)$`));
|
|
338
|
+
if (startMatch) {
|
|
339
|
+
const seq = parseInt(startMatch[1], 10);
|
|
340
|
+
const existing = blocksBySeq.get(seq) || { endLine: -1, exitCode: -1, seq };
|
|
341
|
+
existing.startLine = i;
|
|
342
|
+
blocksBySeq.set(seq, existing);
|
|
343
|
+
debug('checkCommandStatus: start marker found', { seq, lineIndex: i, line });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// End marker pattern: TMUX_MCP_DONE_<exit>_<seq>
|
|
347
|
+
const endMatch = line.match(new RegExp(`^${endMarkerBase}_(\\d+)_([0-9]+)$`));
|
|
348
|
+
if (endMatch) {
|
|
349
|
+
const exitCode = parseInt(endMatch[1], 10);
|
|
350
|
+
const seq = parseInt(endMatch[2], 10);
|
|
351
|
+
const existing = blocksBySeq.get(seq) || { startLine: undefined, endLine: i, exitCode, seq };
|
|
352
|
+
existing.endLine = i;
|
|
353
|
+
existing.exitCode = exitCode;
|
|
354
|
+
// If startLine missing (scrolled out), approximate start as previous end + 1
|
|
355
|
+
if (existing.startLine === undefined && lastEndLine >= 0) {
|
|
356
|
+
existing.startLine = lastEndLine + 1;
|
|
287
357
|
}
|
|
358
|
+
blocksBySeq.set(seq, existing);
|
|
359
|
+
lastEndLine = i;
|
|
360
|
+
debug('checkCommandStatus: end marker found', { seq, exitCode, lineIndex: i, line });
|
|
288
361
|
}
|
|
289
|
-
command.result = outputLines.join('\n').trim();
|
|
290
|
-
// Update in map
|
|
291
|
-
activeCommands.set(commandId, command);
|
|
292
362
|
}
|
|
363
|
+
debug('checkCommandStatus: blocks summary', Array.from(blocksBySeq.values()));
|
|
364
|
+
// Determine this command's block by sequenceNumber ordering
|
|
365
|
+
const sequenceNumber = command.sequenceNumber;
|
|
366
|
+
if (sequenceNumber === undefined) {
|
|
367
|
+
// Raw mode: keep showing tail
|
|
368
|
+
const tail = linesArr.slice(-10).join('\n').trim();
|
|
369
|
+
command.result = tail ? tail : '(no recent output)';
|
|
370
|
+
return command;
|
|
371
|
+
}
|
|
372
|
+
const block = sequenceNumber !== undefined ? blocksBySeq.get(sequenceNumber) : undefined;
|
|
373
|
+
if (!block) {
|
|
374
|
+
// Not completed yet; show tail snapshot
|
|
375
|
+
const tail = linesArr.slice(-10).join('\n').trim();
|
|
376
|
+
command.result = tail ? tail : '(no recent output)';
|
|
377
|
+
debug('checkCommandStatus: block not yet complete', { sequenceNumber, tailPreview: command.result });
|
|
378
|
+
return command;
|
|
379
|
+
}
|
|
380
|
+
// If end marker not yet observed (endLine < 0), keep pending and show tail preview
|
|
381
|
+
if (block.endLine < 0) {
|
|
382
|
+
const tail = linesArr.slice(-10).join('\n').trim();
|
|
383
|
+
command.result = tail ? tail : '(no recent output)';
|
|
384
|
+
debug('checkCommandStatus: end marker missing, still pending', { sequenceNumber, tailPreview: command.result });
|
|
385
|
+
return command;
|
|
386
|
+
}
|
|
387
|
+
// Mark completion
|
|
388
|
+
command.status = block.exitCode === 0 ? 'completed' : 'error';
|
|
389
|
+
command.exitCode = block.exitCode;
|
|
390
|
+
command.markerStartLost = block.startLine === undefined;
|
|
391
|
+
debug('checkCommandStatus: block completed', { sequenceNumber, exitCode: command.exitCode, markerStartLost: command.markerStartLost, startLine: block.startLine, endLine: block.endLine });
|
|
392
|
+
// Extract lines between markers (exclusive of marker lines)
|
|
393
|
+
const sliceStartLine = (block.startLine !== undefined ? block.startLine + 1 : 0);
|
|
394
|
+
const sliceEndLine = block.endLine; // exclude end marker line
|
|
395
|
+
let outputLines = linesArr.slice(sliceStartLine, sliceEndLine);
|
|
396
|
+
// Remove echoed command if present at first line
|
|
397
|
+
if (outputLines.length && outputLines[0].trim() === command.command.trim()) {
|
|
398
|
+
outputLines.shift();
|
|
399
|
+
}
|
|
400
|
+
debug('checkCommandStatus: output lines after echo removal', { total: outputLines.length });
|
|
401
|
+
command.outputLines = outputLines.map(l => l);
|
|
402
|
+
command.totalLines = outputLines.length;
|
|
403
|
+
const { lines, start, end } = options || {};
|
|
404
|
+
let sliceIdxStart = 0;
|
|
405
|
+
let sliceIdxEnd = outputLines.length;
|
|
406
|
+
if (start !== undefined || end !== undefined) {
|
|
407
|
+
if (start !== undefined)
|
|
408
|
+
sliceIdxStart = Math.max(0, start);
|
|
409
|
+
if (end !== undefined)
|
|
410
|
+
sliceIdxEnd = Math.min(outputLines.length, end + 1);
|
|
411
|
+
}
|
|
412
|
+
else if (lines !== undefined) {
|
|
413
|
+
sliceIdxStart = Math.max(0, outputLines.length - lines);
|
|
414
|
+
}
|
|
415
|
+
else if (outputLines.length > DEFAULT_RESULT_LINES) {
|
|
416
|
+
sliceIdxStart = Math.max(0, outputLines.length - DEFAULT_RESULT_LINES);
|
|
417
|
+
}
|
|
418
|
+
const finalLines = outputLines.slice(sliceIdxStart, sliceIdxEnd);
|
|
419
|
+
command.result = finalLines.join('\n').trim();
|
|
420
|
+
command.returnedLines = finalLines.length;
|
|
421
|
+
command.lineStartIndex = sliceIdxStart;
|
|
422
|
+
command.lineEndIndex = sliceIdxEnd;
|
|
423
|
+
command.truncated = outputLines.length > finalLines.length || command.markerStartLost;
|
|
424
|
+
debug('checkCommandStatus: final slicing applied', { returned: command.returnedLines, total: command.totalLines, truncated: command.truncated, sliceStart: command.lineStartIndex, sliceEndExclusive: command.lineEndIndex });
|
|
425
|
+
activeCommands.set(commandId, command);
|
|
293
426
|
return command;
|
|
294
427
|
}
|
|
428
|
+
// Poll until a command finishes or timeout expires
|
|
429
|
+
export async function waitForCompletion(commandId, timeoutMs = 10000, intervalMs = 150) {
|
|
430
|
+
const start = Date.now();
|
|
431
|
+
let status = await checkCommandStatus(commandId);
|
|
432
|
+
while (status && status.status === 'pending' && (Date.now() - start) < timeoutMs) {
|
|
433
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
434
|
+
status = await checkCommandStatus(commandId);
|
|
435
|
+
}
|
|
436
|
+
return status;
|
|
437
|
+
}
|
|
295
438
|
// Get command by ID
|
|
296
439
|
export function getCommand(commandId) {
|
|
297
440
|
return activeCommands.get(commandId) || null;
|
|
@@ -301,7 +444,7 @@ export function getActiveCommandIds() {
|
|
|
301
444
|
return Array.from(activeCommands.keys());
|
|
302
445
|
}
|
|
303
446
|
// Clean up completed commands older than a certain time
|
|
304
|
-
export function cleanupOldCommands(maxAgeMinutes =
|
|
447
|
+
export function cleanupOldCommands(maxAgeMinutes = 30) {
|
|
305
448
|
const now = new Date();
|
|
306
449
|
for (const [id, command] of activeCommands.entries()) {
|
|
307
450
|
const ageMinutes = (now.getTime() - command.startTime.getTime()) / (1000 * 60);
|
|
@@ -310,21 +453,17 @@ export function cleanupOldCommands(maxAgeMinutes = 60) {
|
|
|
310
453
|
}
|
|
311
454
|
}
|
|
312
455
|
}
|
|
313
|
-
function
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
return `${endMarkerPrefix}$?`;
|
|
321
|
-
}
|
|
322
|
-
function buildWrappedCommand(command, shellType) {
|
|
323
|
-
const endMarkerText = getEndMarkerText(shellType);
|
|
324
|
-
return `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
|
|
456
|
+
function buildWrappedCommand(command, shellType, seq) {
|
|
457
|
+
// End marker uses shell-specific exit variable but includes sequence
|
|
458
|
+
const exitVar = shellType === 'fish' ? '$status' : '$?';
|
|
459
|
+
const wrapped = `echo "${startMarkerBase}_${seq}"; ${command}; echo "${endMarkerBase}_${exitVar}_${seq}"`;
|
|
460
|
+
debug('buildWrappedCommand', { shellType, seq, wrapped });
|
|
461
|
+
return wrapped;
|
|
325
462
|
}
|
|
326
|
-
function buildTclshCommand(command) {
|
|
327
|
-
|
|
463
|
+
function buildTclshCommand(command, seq) {
|
|
464
|
+
const wrapped = `::tmux_mcp::run ${seq} {${command}}`;
|
|
465
|
+
debug('buildTclshCommand', { seq, wrapped });
|
|
466
|
+
return wrapped;
|
|
328
467
|
}
|
|
329
468
|
async function ensureTclshInitialized(paneId) {
|
|
330
469
|
if (tclshInitializedPanes.has(paneId)) {
|
|
@@ -332,19 +471,77 @@ async function ensureTclshInitialized(paneId) {
|
|
|
332
471
|
}
|
|
333
472
|
const definitionCommand = [
|
|
334
473
|
'namespace eval ::tmux_mcp {',
|
|
335
|
-
'proc run {cmd} {',
|
|
336
|
-
|
|
474
|
+
'proc run {seq cmd} {',
|
|
475
|
+
'puts "' + startMarkerBase + '_${seq}"; flush stdout;',
|
|
337
476
|
'set status [catch {uplevel #0 $cmd} result opts];',
|
|
338
477
|
'if {$status == 0} {',
|
|
339
|
-
'if {[info exists result] && $result ne ""} { puts $result }',
|
|
478
|
+
'if {[info exists result] && $result ne ""} { puts $result; flush stdout }',
|
|
340
479
|
'} else {',
|
|
341
|
-
'if {[info exists opts(-errorinfo)]} { puts $opts(-errorinfo) } else { puts $result }',
|
|
480
|
+
'if {[info exists opts(-errorinfo)]} { puts $opts(-errorinfo); flush stdout } else { puts $result; flush stdout }',
|
|
342
481
|
'};',
|
|
343
|
-
|
|
482
|
+
'puts "' + endMarkerBase + '_${status}_${seq}"; flush stdout',
|
|
344
483
|
'}',
|
|
345
484
|
'}'
|
|
346
485
|
].join(' ');
|
|
486
|
+
debug('ensureTclshInitialized: sending helper definition');
|
|
347
487
|
const escapedCommand = definitionCommand.replace(/'/g, "'\\''");
|
|
348
488
|
await executeTmux(`send-keys -t '${paneId}' '${escapedCommand}' Enter`);
|
|
349
489
|
tclshInitializedPanes.add(paneId);
|
|
350
490
|
}
|
|
491
|
+
// Retrieve sliced command output after completion without re-parsing markers
|
|
492
|
+
export function getCommandOutput(commandId, options) {
|
|
493
|
+
const command = activeCommands.get(commandId);
|
|
494
|
+
if (!command || !command.outputLines)
|
|
495
|
+
return null;
|
|
496
|
+
const { lines, start, end } = options || {};
|
|
497
|
+
let sliceStart = 0;
|
|
498
|
+
let sliceEnd = command.outputLines.length; // exclusive
|
|
499
|
+
if (start !== undefined || end !== undefined) {
|
|
500
|
+
if (start !== undefined) {
|
|
501
|
+
sliceStart = Math.max(0, start);
|
|
502
|
+
}
|
|
503
|
+
if (end !== undefined) {
|
|
504
|
+
sliceEnd = Math.min(command.outputLines.length, end + 1); // inclusive external end
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
else if (lines !== undefined) {
|
|
508
|
+
sliceStart = Math.max(0, command.outputLines.length - lines);
|
|
509
|
+
}
|
|
510
|
+
return command.outputLines.slice(sliceStart, sliceEnd).join('\n');
|
|
511
|
+
}
|
|
512
|
+
// Grep command output lines with a regular expression; returns matching lines
|
|
513
|
+
export function grepCommandOutput(commandId, pattern, flags) {
|
|
514
|
+
const command = activeCommands.get(commandId);
|
|
515
|
+
if (!command || !command.outputLines)
|
|
516
|
+
return [];
|
|
517
|
+
let regex;
|
|
518
|
+
try {
|
|
519
|
+
regex = new RegExp(pattern, flags);
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
return command.outputLines.filter(line => regex.test(line));
|
|
525
|
+
}
|
|
526
|
+
// Switch pane to a minimal shell variant (bash only for now) to skip heavy startup scripts.
|
|
527
|
+
export async function switchPaneToMinimalShell(paneId) {
|
|
528
|
+
const shellType = resolveShellType(paneId);
|
|
529
|
+
if (shellType !== 'bash') {
|
|
530
|
+
return false; // Only implemented for bash currently
|
|
531
|
+
}
|
|
532
|
+
// Use exec to replace current shell, suppress profile and rc loading.
|
|
533
|
+
await executeTmux(`send-keys -t '${paneId}' 'exec bash --noprofile --norc' Enter`);
|
|
534
|
+
// Emit a readiness marker after replacement
|
|
535
|
+
await executeTmux(`send-keys -t '${paneId}' 'echo MINIMAL_READY' Enter`);
|
|
536
|
+
// Poll for readiness marker appearing in last captured lines
|
|
537
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
538
|
+
const tail = await capturePaneContent(paneId, { lines: 50 });
|
|
539
|
+
if (tail.split('\n').some(l => l.includes('MINIMAL_READY'))) {
|
|
540
|
+
debug('switchPaneToMinimalShell: minimal shell ready');
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
await new Promise(r => setTimeout(r, 100));
|
|
544
|
+
}
|
|
545
|
+
debug('switchPaneToMinimalShell: timeout waiting for readiness');
|
|
546
|
+
return false;
|
|
547
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arearseth/tmux-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "MCP Server for interfacing with tmux sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -36,7 +36,10 @@
|
|
|
36
36
|
"vitest": "^1.6.0",
|
|
37
37
|
"typescript": "^5.3.3"
|
|
38
38
|
},
|
|
39
|
-
"repository":
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/AreAArseth/tmux-mcp.git"
|
|
42
|
+
},
|
|
40
43
|
"bugs": {
|
|
41
44
|
"url": "https://github.com/AreAArseth/tmux-mcp/issues"
|
|
42
45
|
},
|