@arearseth/tmux-mcp 0.3.0 → 0.3.2

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 CHANGED
@@ -65,7 +65,7 @@ The CLI flag only sets the server-wide default. You can still override individua
65
65
  - `find-session` - Find a tmux session by name
66
66
  - `list-windows` - List windows in a tmux session
67
67
  - `list-panes` - List panes in a tmux window
68
- - `capture-pane` - Capture content from a tmux pane
68
+ - `capture-pane` - Capture content from a tmux pane with optional slicing (supports start/end offsets to walk full scrollback history)
69
69
  - `create-session` - Create a new tmux session
70
70
  - `create-window` - Create a new window in a tmux session
71
71
  - `split-pane` - Split a tmux pane horizontally or vertically with optional size
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.2.2"
10
+ version: "0.3.1" // 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
- }, async ({ name }) => {
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
- }, async ({ commandId }) => {
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
- resultText = `Status: ${command.status}\nExit code: ${command.exitCode}\nCommand: ${command.command}\n\n--- Output ---\n${command.result}`;
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
- const startValue = start !== undefined ? String(start) : `-${lines}`;
127
- const endValue = end !== undefined ? String(end) : '-';
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', startValue, '-E', endValue);
133
- return executeTmux(commandParts.join(' '));
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
- await executeTmux(`new-session -d -s "${name}"`);
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 startMarkerText = 'TMUX_MCP_START';
199
- const endMarkerPrefix = "TMUX_MCP_DONE_";
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: 1000 });
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
- // Find the last occurrence of the markers
265
- const startIndex = content.lastIndexOf(startMarkerText);
266
- const endIndex = content.lastIndexOf(endMarkerPrefix);
267
- if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
268
- command.result = "Command output could not be captured properly";
269
- return command;
270
- }
271
- // Extract exit code from the end marker line
272
- const endLine = content.substring(endIndex).split('\n')[0];
273
- const endMarkerRegex = new RegExp(`${endMarkerPrefix}(\\d+)`);
274
- const exitCodeMatch = endLine.match(endMarkerRegex);
275
- if (exitCodeMatch) {
276
- const exitCode = parseInt(exitCodeMatch[1], 10);
277
- command.status = exitCode === 0 ? 'completed' : 'error';
278
- command.exitCode = exitCode;
279
- // Extract output between the start and end markers
280
- const outputStart = startIndex + startMarkerText.length;
281
- const outputContent = content.substring(outputStart, endIndex).trim();
282
- const outputLines = outputContent ? outputContent.split('\n') : [];
283
- if (outputLines.length > 0) {
284
- const firstLine = outputLines[0].trim();
285
- if (firstLine === command.command.trim()) {
286
- outputLines.shift();
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 = 60) {
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,30 +453,17 @@ export function cleanupOldCommands(maxAgeMinutes = 60) {
310
453
  }
311
454
  }
312
455
  }
313
- function getEndMarkerText(shellType) {
314
- if (shellType === 'fish') {
315
- return `${endMarkerPrefix}$status`;
316
- }
317
- if (shellType === 'tclsh') {
318
- return `${endMarkerPrefix}$::tmux_mcp_status`;
319
- }
320
- return `${endMarkerPrefix}$?`;
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;
321
462
  }
322
- function buildWrappedCommand(command, shellType) {
323
- const endMarkerText = getEndMarkerText(shellType);
324
- return `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
325
- }
326
- function buildTclshCommand(command) {
327
- const escaped = escapeForTcl(command);
328
- return `::tmux_mcp::run {${escaped}}`;
329
- }
330
- function escapeForTcl(command) {
331
- return command
332
- .replace(/\\/g, '\\\\')
333
- .replace(/\r/g, '\\r')
334
- .replace(/\n/g, '\\n')
335
- .replace(/\{/g, '\\{')
336
- .replace(/\}/g, '\\}');
463
+ function buildTclshCommand(command, seq) {
464
+ const wrapped = `::tmux_mcp::run ${seq} {${command}}`;
465
+ debug('buildTclshCommand', { seq, wrapped });
466
+ return wrapped;
337
467
  }
338
468
  async function ensureTclshInitialized(paneId) {
339
469
  if (tclshInitializedPanes.has(paneId)) {
@@ -341,19 +471,77 @@ async function ensureTclshInitialized(paneId) {
341
471
  }
342
472
  const definitionCommand = [
343
473
  'namespace eval ::tmux_mcp {',
344
- 'proc run {cmd} {',
345
- `puts "${startMarkerText}";`,
474
+ 'proc run {seq cmd} {',
475
+ 'puts "' + startMarkerBase + '_${seq}"; flush stdout;',
346
476
  'set status [catch {uplevel #0 $cmd} result opts];',
347
477
  'if {$status == 0} {',
348
- 'if {[info exists result] && $result ne ""} { puts $result }',
478
+ 'if {[info exists result] && $result ne ""} { puts $result; flush stdout }',
349
479
  '} else {',
350
- '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 }',
351
481
  '};',
352
- `puts "${endMarkerPrefix}$status"`,
482
+ 'puts "' + endMarkerBase + '_${status}_${seq}"; flush stdout',
353
483
  '}',
354
484
  '}'
355
485
  ].join(' ');
486
+ debug('ensureTclshInitialized: sending helper definition');
356
487
  const escapedCommand = definitionCommand.replace(/'/g, "'\\''");
357
488
  await executeTmux(`send-keys -t '${paneId}' '${escapedCommand}' Enter`);
358
489
  tclshInitializedPanes.add(paneId);
359
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.0",
3
+ "version": "0.3.2",
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": "github:AreAArseth/tmux-mcp",
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
  },