@arearseth/tmux-mcp 0.3.2 → 0.3.4

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
@@ -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
- - `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 with optional slicing (supports start/end offsets to walk full scrollback history)
66
+ - `find-session` - Find tmux sessions by exact name, substring, or regex pattern
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 (line count accepts a number or numeric string)
76
+ - `split-pane` - Split a tmux pane horizontally or vertically
74
77
  - `kill-pane` - Kill a tmux pane by ID
75
- - `set-shell-type` - Configure the shell used for command execution (supports bash, zsh, fish, tclsh). Provide a paneId to override a single pane, or omit to adjust the default.
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
@@ -4,10 +4,11 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
6
  import * as tmux from "./tmux.js";
7
+ import pkg from "../package.json" with { type: "json" };
7
8
  // Create MCP server
8
9
  const server = new McpServer({
9
10
  name: "tmux-mcp",
10
- version: "0.3.1" // Keep in sync with package.json
11
+ version: pkg.version
11
12
  }, {
12
13
  capabilities: {
13
14
  resources: {
@@ -21,6 +22,23 @@ const server = new McpServer({
21
22
  }
22
23
  });
23
24
  const shellTypeSchema = z.enum(tmux.supportedShellTypes);
25
+ /**
26
+ * Calculate the end index for display purposes when output is truncated.
27
+ * Returns the actual end index minus 1, or calculates it from start index and returned lines,
28
+ * or returns 'unknown' if not enough information is available.
29
+ */
30
+ function calculateEndIndexDisplay(lineEndIndex, lineStartIndex, returnedLines) {
31
+ if (lineEndIndex !== undefined) {
32
+ return lineEndIndex - 1;
33
+ }
34
+ if (returnedLines !== undefined && lineStartIndex !== undefined) {
35
+ return lineStartIndex + returnedLines - 1;
36
+ }
37
+ if (returnedLines !== undefined) {
38
+ return returnedLines - 1;
39
+ }
40
+ return 'unknown';
41
+ }
24
42
  // List all tmux sessions - Tool
25
43
  server.tool("list-sessions", "List all active tmux sessions", {}, async () => {
26
44
  try {
@@ -43,15 +61,17 @@ server.tool("list-sessions", "List all active tmux sessions", {}, async () => {
43
61
  }
44
62
  });
45
63
  // Find session by name - Tool
46
- server.tool("find-session", "Find a tmux session by name", {
47
- name: z.string().describe("Name of the tmux session to find")
64
+ server.tool("find-session", "Find tmux sessions by name. Tries an exact name match first, then a case-insensitive substring match, then a regular-expression match, so you can pass a partial name or pattern. Returns all matches.", {
65
+ name: z.string().describe("Exact name, substring, or regular expression to match against session names")
48
66
  }, async ({ name }) => {
49
67
  try {
50
- const session = await tmux.findSessionByName(name);
68
+ const sessions = await tmux.findSessions(name);
51
69
  return {
52
70
  content: [{
53
71
  type: "text",
54
- text: session ? JSON.stringify(session, null, 2) : `Session not found: ${name}`
72
+ text: sessions.length > 0
73
+ ? JSON.stringify(sessions.length === 1 ? sessions[0] : sessions, null, 2)
74
+ : `Session not found: ${name}`
55
75
  }]
56
76
  };
57
77
  }
@@ -114,19 +134,19 @@ server.tool("list-panes", "List panes in a tmux window", {
114
134
  // Capture pane content - Tool
115
135
  server.tool("capture-pane", "Capture content from a tmux pane. Defaults to the last N lines, but you can provide tmux-style start/end offsets (like 0 and -) to walk the full scrollback.", {
116
136
  paneId: z.string().describe("ID of the tmux pane"),
117
- lines: z.string().optional().describe("Number of trailing lines to capture when start/end offsets are omitted (defaults to 200)"),
118
- start: z.string().optional().describe("tmux -S offset; use 0 for the oldest line or a negative value to offset from the bottom"),
119
- end: z.string().optional().describe("tmux -E offset; use - for the newest line or 0 for the active cursor line"),
137
+ lines: z.union([z.string(), z.number()]).optional().describe("Number of trailing lines to capture when start/end offsets are omitted (defaults to 200). Accepts a number or numeric string."),
138
+ start: z.union([z.string(), z.number()]).optional().describe("tmux -S offset; use 0 for the oldest line or a negative value to offset from the bottom. Accepts a number or string."),
139
+ end: z.union([z.string(), z.number()]).optional().describe("tmux -E offset; use - for the newest line or 0 for the active cursor line. Accepts a number or string."),
120
140
  colors: z.boolean().optional().describe("Include color/escape sequences for text and background attributes in output")
121
141
  }, async ({ paneId, lines, start, end, colors }) => {
122
142
  try {
123
- // Parse lines parameter if provided
124
- const parsedLines = lines !== undefined ? parseInt(lines, 10) : undefined;
143
+ // Accept lines as a number or numeric string and ignore non-positive values.
144
+ const parsedLines = tmux.coerceLineCount(lines, 0);
125
145
  const includeColors = colors ?? false;
126
146
  const options = {
127
147
  includeColors
128
148
  };
129
- if (parsedLines !== undefined && !Number.isNaN(parsedLines) && parsedLines > 0) {
149
+ if (parsedLines > 0) {
130
150
  options.lines = parsedLines;
131
151
  }
132
152
  if (start !== undefined && start !== '') {
@@ -156,7 +176,7 @@ server.tool("capture-pane", "Capture content from a tmux pane. Defaults to the l
156
176
  // Create new session - Tool
157
177
  server.tool("create-session", "Create a new tmux session (optionally minimal to skip startup scripts)", {
158
178
  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."),
179
+ minimal: z.boolean().optional().describe("Launch with a minimal shell (bash --noprofile --norc) to skip startup scripts for speed. If shellCommand is provided, it overrides the minimal shell setting."),
160
180
  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
181
  }, async ({ name, minimal, shellCommand }) => {
162
182
  try {
@@ -328,10 +348,10 @@ server.tool("set-shell-type", "Configure the shell for command execution (bash,
328
348
  }
329
349
  });
330
350
  // Execute command in pane - Tool
331
- server.tool("execute-command", "Execute a command in a tmux pane and get results. For interactive applications (REPLs, editors), use `rawMode=true`. IMPORTANT: When `rawMode=false` (default), avoid heredoc syntax (cat << EOF) and other multi-line constructs as they conflict with command wrapping. For file writing, prefer: printf 'content\\n' > file, echo statements, or write to temp files instead", {
351
+ server.tool("execute-command", "Execute a command in a tmux pane and get results. Tcl REPLs (fc_shell, dc_shell, pt_shell, icc2_shell, tclsh) do NOT need rawMode: tracked mode auto-probes for Tcl, injects the tracking namespace, and pairs with wait-command-completion for immediate completion. Use rawMode=true only for true interactive edge cases (vim, less, btop); it disables status tracking and forces manual capture-pane polling. IMPORTANT: When rawMode=false (default), avoid heredoc syntax (cat << EOF) and other multi-line constructs as they conflict with command wrapping. For file writing, prefer: printf 'content\\n' > file, echo statements, or write to temp files instead. For tracked Tcl REPLs the command is wrapped in braces ({command}); multi-word/multi-arg commands work fine, but the command must contain balanced braces (almost all Tcl does). For the rare command with unbalanced braces, use rawMode and verify with capture-pane.", {
332
352
  paneId: z.string().describe("ID of the tmux pane"),
333
353
  command: z.string().describe("Command to execute"),
334
- rawMode: z.boolean().optional().describe("Execute command without wrapper markers for REPL/interactive compatibility. Disables get-command-result status tracking. Use capture-pane after execution to verify command outcome."),
354
+ rawMode: z.boolean().optional().describe("Skip wrapper markers and status tracking. Do not use for Tcl REPLs (fc_shell/tclsh): tracked mode auto-detects Tcl and wait-command-completion returns as soon as the command finishes. Use only for non-Tcl interactive apps (vim, less) where wrapping breaks input; then verify with capture-pane."),
335
355
  noEnter: z.boolean().optional().describe("Send keystrokes without pressing Enter. For TUI navigation in apps like btop, vim, less. Supports special keys (Up, Down, Escape, Tab, etc.) and strings (sent char-by-char for proper filtering). Automatically applies rawMode. Use capture-pane after to see results.")
336
356
  }, async ({ paneId, command, rawMode, noEnter }) => {
337
357
  try {
@@ -402,7 +422,7 @@ server.tool("get-command-result", "Get the result of an executed command", {
402
422
  `Command: ${command.command}`
403
423
  ];
404
424
  if (command.truncated) {
405
- const endIdxDisplay = command.lineEndIndex !== undefined ? command.lineEndIndex - 1 : (command.returnedLines ? (command.lineStartIndex ?? 0) + (command.returnedLines - 1) : 'unknown');
425
+ const endIdxDisplay = calculateEndIndexDisplay(command.lineEndIndex, command.lineStartIndex, command.returnedLines);
406
426
  metaLines.push(`Output truncated: showing ${command.returnedLines} of ${command.totalLines} lines (slice ${command.lineStartIndex}..${endIdxDisplay})`);
407
427
  }
408
428
  else if (command.outputLines) {
@@ -428,7 +448,7 @@ server.tool("get-command-result", "Get the result of an executed command", {
428
448
  }
429
449
  });
430
450
  // 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.", {
451
+ server.tool("wait-command-completion", "Wait until a tracked command completes or timeout expires. Returns as soon as completion markers are detected (not a fixed sleep); polls at intervalMs only while still pending. Pair with execute-command (rawMode=false) for Tcl and shell commands.", {
432
452
  commandId: z.string().describe("ID of the executed command"),
433
453
  timeoutMs: z.number().int().positive().optional().describe("Maximum milliseconds to wait (default 10000)"),
434
454
  intervalMs: z.number().int().positive().optional().describe("Polling interval milliseconds (default 150)"),
@@ -461,7 +481,7 @@ server.tool("wait-command-completion", "Poll until a command completes or timeou
461
481
  `Command: ${status.command}`
462
482
  ];
463
483
  if (status.truncated) {
464
- const endIdxDisplay = status.lineEndIndex !== undefined ? status.lineEndIndex - 1 : 'unknown';
484
+ const endIdxDisplay = calculateEndIndexDisplay(status.lineEndIndex, status.lineStartIndex, status.returnedLines);
465
485
  meta.push(`Output truncated: showing ${status.returnedLines} of ${status.totalLines} lines (slice ${status.lineStartIndex}..${endIdxDisplay})`);
466
486
  }
467
487
  else if (status.outputLines) {
package/build/tmux.js CHANGED
@@ -10,6 +10,35 @@ function debug(...args) {
10
10
  console.error('[tmux-mcp-debug]', ...args);
11
11
  }
12
12
  }
13
+ /**
14
+ * Coerce a line-count input (accepted as either a number or a numeric string)
15
+ * into a number. Returns the provided fallback when the value is missing or
16
+ * not a positive integer, so callers can pass "40" or 40 interchangeably.
17
+ */
18
+ export function coerceLineCount(value, fallback) {
19
+ if (value === undefined || value === '') {
20
+ return fallback;
21
+ }
22
+ const numeric = typeof value === 'number' ? value : Number.parseInt(value, 10);
23
+ return Number.isNaN(numeric) ? fallback : numeric;
24
+ }
25
+ function interpretCaptureIndex(value) {
26
+ if (value === undefined) {
27
+ return { kind: 'none' };
28
+ }
29
+ if (value === '-') {
30
+ return { kind: 'dash' };
31
+ }
32
+ const numeric = typeof value === 'number' ? value : Number(value);
33
+ if (Number.isNaN(numeric)) {
34
+ return { kind: 'none' };
35
+ }
36
+ const normalized = Math.trunc(numeric);
37
+ if (normalized >= 0) {
38
+ return { kind: 'absolute', value: normalized };
39
+ }
40
+ return { kind: 'relative', value: normalized };
41
+ }
13
42
  export const supportedShellTypes = ['bash', 'zsh', 'fish', 'tclsh'];
14
43
  const shellConfig = {
15
44
  defaultType: 'bash',
@@ -26,9 +55,11 @@ export function setShellConfig(config) {
26
55
  shellConfig.paneOverrides.set(config.paneId, normalized);
27
56
  // Reset cached initialization so the helper can be installed on demand
28
57
  tclshInitializedPanes.delete(config.paneId);
58
+ paneDetectedShell.delete(config.paneId);
29
59
  return;
30
60
  }
31
61
  shellConfig.defaultType = normalized;
62
+ paneDetectedShell.clear();
32
63
  if (normalized !== 'tclsh') {
33
64
  tclshInitializedPanes.clear();
34
65
  }
@@ -36,6 +67,9 @@ export function setShellConfig(config) {
36
67
  function resolveShellType(paneId) {
37
68
  return shellConfig.paneOverrides.get(paneId) ?? shellConfig.defaultType;
38
69
  }
70
+ function escapeForSingleQuotes(value) {
71
+ return value.replace(/'/g, "'\\''");
72
+ }
39
73
  /**
40
74
  * Execute a tmux command and return the result
41
75
  */
@@ -90,6 +124,39 @@ export async function findSessionByName(name) {
90
124
  return null;
91
125
  }
92
126
  }
127
+ /**
128
+ * Find sessions matching a query, trying progressively looser strategies so a
129
+ * caller does not need to know the exact session name:
130
+ * 1. exact name match
131
+ * 2. case-insensitive substring match
132
+ * 3. regular-expression match (when the query is a valid regex)
133
+ * Returns all matches for the first strategy that yields a result.
134
+ */
135
+ export async function findSessions(query) {
136
+ let sessions;
137
+ try {
138
+ sessions = await listSessions();
139
+ }
140
+ catch (error) {
141
+ return [];
142
+ }
143
+ const exact = sessions.filter(session => session.name === query);
144
+ if (exact.length > 0) {
145
+ return exact;
146
+ }
147
+ const lowered = query.toLowerCase();
148
+ const substring = sessions.filter(session => session.name.toLowerCase().includes(lowered));
149
+ if (substring.length > 0) {
150
+ return substring;
151
+ }
152
+ try {
153
+ const pattern = new RegExp(query);
154
+ return sessions.filter(session => pattern.test(session.name));
155
+ }
156
+ catch {
157
+ return [];
158
+ }
159
+ }
93
160
  /**
94
161
  * List windows in a session
95
162
  */
@@ -128,11 +195,15 @@ export async function listPanes(windowId) {
128
195
  }
129
196
  /**
130
197
  * 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.
198
+ * Note: tmux's -S and -E flags are unreliable due to cursor position.
199
+ * We treat tmux start/end offsets as hints, rely on JavaScript slicing for
200
+ * relative (negative) offsets, and avoid re-applying positive offsets that
201
+ * tmux already honored to prevent double trimming.
133
202
  */
134
203
  export async function capturePaneContent(paneId, options = {}) {
135
- const { lines = 200, start, end, includeColors = false } = options;
204
+ const { start, end, includeColors = false } = options;
205
+ // Accept either a number or a numeric string (e.g. "40") for the line count.
206
+ const lines = coerceLineCount(options.lines, 200);
136
207
  // Determine start value for tmux capture
137
208
  // We'll use this to capture enough data, then slice accurately
138
209
  let tmuxStart;
@@ -156,24 +227,50 @@ export async function capturePaneContent(paneId, options = {}) {
156
227
  const capturedLines = await executeTmux(commandParts.join(' '));
157
228
  // Now slice the output in JavaScript for accurate results
158
229
  const linesArray = capturedLines.split('\n');
230
+ const startInfo = interpretCaptureIndex(start);
231
+ const endInfo = interpretCaptureIndex(end);
159
232
  // Calculate actual slice indices
160
233
  let sliceStart = 0;
161
234
  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;
235
+ const absoluteStartBase = startInfo.kind === 'absolute'
236
+ ? startInfo.value
237
+ : startInfo.kind === 'dash'
238
+ ? 0
239
+ : undefined;
240
+ if (startInfo.kind === 'relative') {
241
+ sliceStart = Math.max(0, linesArray.length + startInfo.value);
242
+ }
243
+ else if (startInfo.kind === 'absolute' || startInfo.kind === 'dash') {
244
+ sliceStart = 0;
167
245
  }
168
246
  else if (lines !== undefined && lines > 0) {
169
- // If no start specified but lines is, take last N lines
170
247
  sliceStart = Math.max(0, linesArray.length - lines);
171
248
  }
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;
249
+ if (endInfo.kind === 'dash') {
250
+ sliceEnd = linesArray.length;
251
+ }
252
+ else if (endInfo.kind === 'relative') {
253
+ sliceEnd = Math.max(0, linesArray.length + endInfo.value + 1);
254
+ }
255
+ else if (endInfo.kind === 'absolute') {
256
+ if (absoluteStartBase !== undefined) {
257
+ const desiredLength = endInfo.value - absoluteStartBase + 1;
258
+ if (desiredLength <= 0) {
259
+ sliceEnd = sliceStart;
260
+ }
261
+ else {
262
+ sliceEnd = Math.min(linesArray.length, sliceStart + desiredLength);
263
+ }
264
+ }
265
+ else {
266
+ sliceEnd = Math.min(linesArray.length, endInfo.value + 1);
267
+ }
268
+ }
269
+ if (end === undefined && lines !== undefined && lines > 0) {
270
+ sliceEnd = Math.min(sliceEnd, sliceStart + lines);
271
+ }
272
+ if (sliceEnd < sliceStart) {
273
+ sliceEnd = sliceStart;
177
274
  }
178
275
  return linesArray.slice(sliceStart, sliceEnd).join('\n');
179
276
  }
@@ -182,7 +279,8 @@ export async function capturePaneContent(paneId, options = {}) {
182
279
  */
183
280
  export async function createSession(name, options) {
184
281
  // Allow launching with a minimal shell to skip startup scripts.
185
- let launchCmd = `new-session -d -s "${name}"`;
282
+ const safeName = escapeForSingleQuotes(name);
283
+ let launchCmd = `new-session -d -s '${safeName}'`;
186
284
  if (options?.minimal) {
187
285
  const shell = options.shellCommand || 'bash --noprofile --norc';
188
286
  // Quote shell command separately so user shell isn't expanded prematurely.
@@ -198,7 +296,8 @@ export async function createSession(name, options) {
198
296
  * Create a new window in a session
199
297
  */
200
298
  export async function createWindow(sessionId, name) {
201
- const output = await executeTmux(`new-window -t '${sessionId}' -n '${name}'`);
299
+ const safeName = escapeForSingleQuotes(name);
300
+ const output = await executeTmux(`new-window -t '${sessionId}' -n '${safeName}'`);
202
301
  const windows = await listWindows(sessionId);
203
302
  return windows.find(window => window.name === name) || null;
204
303
  }
@@ -252,16 +351,145 @@ export async function splitPane(targetPaneId, direction = 'vertical', size) {
252
351
  const activeCommands = new Map();
253
352
  const startMarkerBase = 'TMUX_MCP_START';
254
353
  const endMarkerBase = 'TMUX_MCP_DONE';
354
+ /**
355
+ * Per-process session nonce embedded in completion markers.
356
+ *
357
+ * tmux panes (e.g. a long-lived fc_shell) outlive the MCP server process and
358
+ * retain old TMUX_MCP_START/DONE markers in their scrollback. The sequence
359
+ * counter resets to 0 on every server restart, so a fresh command can reuse a
360
+ * sequence number whose DONE marker is still sitting in the pane history. Since
361
+ * completion is inferred by scanning the entire scrollback, the stale DONE
362
+ * marker would falsely complete the new command (often with empty output,
363
+ * because the stale DONE precedes the fresh START). Tagging markers with a
364
+ * nonce that is unique to this server process makes a previous run's markers
365
+ * impossible to match, so only this process's own markers can complete a wait.
366
+ *
367
+ * Disabled (empty) under Vitest so existing fixtures keep the legacy
368
+ * TMUX_MCP_START_<seq> / TMUX_MCP_DONE_<exit>_<seq> format; tests that exercise
369
+ * the nonce set it explicitly via setSessionNonce.
370
+ */
371
+ function generateSessionNonce() {
372
+ return `s${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36).slice(-4)}`;
373
+ }
374
+ let sessionNonce = process.env.TMUX_MCP_SESSION_NONCE
375
+ ?? (process.env.VITEST ? '' : generateSessionNonce());
376
+ export function getSessionNonce() {
377
+ return sessionNonce;
378
+ }
379
+ export function setSessionNonce(nonce) {
380
+ sessionNonce = nonce;
381
+ }
382
+ // Marker infix that carries the nonce, e.g. "s12ab_" (empty when disabled).
383
+ function markerNonceInfix() {
384
+ return sessionNonce ? `${sessionNonce}_` : '';
385
+ }
255
386
  const DEFAULT_RESULT_LINES = 100; // default number of lines returned when output is large
387
+ function hasSliceOptions(options) {
388
+ return Boolean(options && (options.lines !== undefined || options.start !== undefined || options.end !== undefined));
389
+ }
390
+ function computeSliceBounds(totalLines, options, defaultLimit) {
391
+ let sliceStart = 0;
392
+ let sliceEnd = totalLines;
393
+ if (options?.start !== undefined || options?.end !== undefined) {
394
+ if (options.start !== undefined) {
395
+ sliceStart = Math.max(0, options.start);
396
+ }
397
+ if (options.end !== undefined) {
398
+ sliceEnd = Math.min(totalLines, options.end + 1);
399
+ }
400
+ }
401
+ else if (options?.lines !== undefined) {
402
+ sliceStart = Math.max(0, totalLines - options.lines);
403
+ }
404
+ else if (defaultLimit !== undefined && totalLines > defaultLimit) {
405
+ sliceStart = Math.max(0, totalLines - defaultLimit);
406
+ }
407
+ if (sliceEnd < sliceStart) {
408
+ sliceEnd = sliceStart;
409
+ }
410
+ return { start: sliceStart, end: sliceEnd };
411
+ }
412
+ function applyOutputSlicing(command, options) {
413
+ if (!command.outputLines) {
414
+ return;
415
+ }
416
+ const defaultLimit = hasSliceOptions(options) ? undefined : DEFAULT_RESULT_LINES;
417
+ const { start: sliceStart, end: sliceEnd } = computeSliceBounds(command.outputLines.length, options, defaultLimit);
418
+ const finalLines = command.outputLines.slice(sliceStart, sliceEnd);
419
+ command.result = finalLines.join('\n').trim();
420
+ command.returnedLines = finalLines.length;
421
+ command.lineStartIndex = sliceStart;
422
+ command.lineEndIndex = sliceEnd;
423
+ command.totalLines = command.outputLines.length;
424
+ command.truncated = command.outputLines.length > finalLines.length || Boolean(command.markerStartLost);
425
+ }
256
426
  // Track tclsh initialization per pane to keep terminal output minimal
257
427
  const tclshInitializedPanes = new Set();
428
+ // Track in-flight initialization so concurrent first-time commands share a
429
+ // single helper definition instead of each queuing their own behind a busy shell.
430
+ const tclshInitInFlight = new Map();
431
+ const paneDetectedShell = new Map();
258
432
  let wrappedCommandSequenceCounter = 0; // incremented for each non-raw wrapped command (sequence numbers)
433
+ const PROBE_POLL_INTERVAL_MS = process.env.VITEST ? 0 : 100;
434
+ const PROBE_MAX_ATTEMPTS = process.env.VITEST ? 1 : 15;
435
+ function escapeRegExp(value) {
436
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
437
+ }
438
+ /**
439
+ * Probe whether the pane is in a Tcl REPL by sending a harmless Tcl command.
440
+ * pane_current_command is unreliable (often qrsh/make); this uses interpreter behavior.
441
+ */
442
+ async function detectPaneShellType(paneId) {
443
+ const marker = `TMUX_MCP_PROBE_${Date.now()}`;
444
+ const probeCommand = `puts ${marker}_[info tclversion]`;
445
+ const escapedProbe = probeCommand.replace(/'/g, "'\\''");
446
+ await executeTmux(`send-keys -t '${paneId}' '${escapedProbe}' Enter`);
447
+ const markerPattern = new RegExp(`^${escapeRegExp(marker)}_\\d`);
448
+ for (let attempt = 0; attempt < PROBE_MAX_ATTEMPTS; attempt++) {
449
+ if (PROBE_POLL_INTERVAL_MS > 0) {
450
+ await new Promise(r => setTimeout(r, PROBE_POLL_INTERVAL_MS));
451
+ }
452
+ const content = await capturePaneContent(paneId, { lines: 50 });
453
+ for (const line of content.split('\n')) {
454
+ if (markerPattern.test(line.trim())) {
455
+ debug('detectPaneShellType: Tcl REPL detected', { paneId, marker, line: line.trim() });
456
+ return 'tclsh';
457
+ }
458
+ }
459
+ }
460
+ debug('detectPaneShellType: inconclusive', { paneId, marker });
461
+ return null;
462
+ }
463
+ async function resolveShellTypeWithDetection(paneId) {
464
+ const override = shellConfig.paneOverrides.get(paneId);
465
+ if (override) {
466
+ return override;
467
+ }
468
+ const cached = paneDetectedShell.get(paneId);
469
+ if (cached) {
470
+ return cached;
471
+ }
472
+ const detected = await detectPaneShellType(paneId);
473
+ if (detected) {
474
+ paneDetectedShell.set(paneId, detected);
475
+ return detected;
476
+ }
477
+ return shellConfig.defaultType;
478
+ }
259
479
  // Execute a command in a tmux pane and track its execution
260
480
  export async function executeCommand(paneId, command, rawMode, noEnter) {
261
481
  // Generate unique ID for this command execution
262
482
  const commandId = uuidv4();
263
- const shellType = resolveShellType(paneId);
264
- const sequenceNumber = (!rawMode && !noEnter) ? (wrappedCommandSequenceCounter + 1) : undefined;
483
+ // Reserve a sequence number synchronously, before any await. Concurrent
484
+ // executeCommand calls (e.g. probes fired while a long create_placement runs)
485
+ // otherwise interleave at the awaits below, read the same counter value, and
486
+ // receive duplicate sequence numbers. Since completion tracking keys on the
487
+ // sequence number, duplicates make two commands match the same DONE marker,
488
+ // causing false completions and cross-command output attribution.
489
+ const sequenceNumber = (!rawMode && !noEnter) ? ++wrappedCommandSequenceCounter : undefined;
490
+ const shellType = (rawMode || noEnter)
491
+ ? resolveShellType(paneId)
492
+ : await resolveShellTypeWithDetection(paneId);
265
493
  debug('executeCommand: preparing', { paneId, command, rawMode, noEnter, shellType, sequenceNumber });
266
494
  let fullCommand;
267
495
  if (rawMode || noEnter) {
@@ -278,9 +506,6 @@ export async function executeCommand(paneId, command, rawMode, noEnter) {
278
506
  debug('executeCommand: wrapped command', fullCommand);
279
507
  }
280
508
  // Store command in tracking map
281
- if (sequenceNumber) {
282
- wrappedCommandSequenceCounter = sequenceNumber; // commit increment
283
- }
284
509
  debug('executeCommand: sending keys', { paneId, fullCommand, noEnter });
285
510
  activeCommands.set(commandId, {
286
511
  id: commandId,
@@ -319,8 +544,13 @@ export async function checkCommandStatus(commandId, options) {
319
544
  const command = activeCommands.get(commandId);
320
545
  if (!command)
321
546
  return null;
322
- if (command.status !== 'pending')
547
+ if (command.status !== 'pending') {
548
+ if (command.outputLines && (hasSliceOptions(options) || command.result === undefined)) {
549
+ applyOutputSlicing(command, options);
550
+ activeCommands.set(commandId, command);
551
+ }
323
552
  return command;
553
+ }
324
554
  const content = await capturePaneContent(command.paneId, { lines: 0 }); // capture entire scrollback to avoid missing markers
325
555
  debug('checkCommandStatus: captured content length', content.length, 'lines approx', content.split('\n').length);
326
556
  if (command.rawMode) {
@@ -328,13 +558,20 @@ export async function checkCommandStatus(commandId, options) {
328
558
  return command;
329
559
  }
330
560
  // Build marker blocks keyed by sequence number.
561
+ // Markers are matched against the current process's session nonce only, so a
562
+ // previous server run's stale markers (which may reuse the same sequence
563
+ // number) can never complete this command. When the nonce is disabled the
564
+ // patterns fall back to the legacy nonce-less marker format.
565
+ const nonceInfixPattern = sessionNonce ? `${escapeRegExp(sessionNonce)}_` : '';
566
+ const startMarkerRegex = new RegExp(`^${startMarkerBase}_${nonceInfixPattern}(\\d+)$`);
567
+ const endMarkerRegex = new RegExp(`^${endMarkerBase}_(\\d+)_${nonceInfixPattern}(\\d+)$`);
331
568
  const linesArr = content.split('\n');
332
569
  const blocksBySeq = new Map();
333
570
  let lastEndLine = -1;
334
571
  for (let i = 0; i < linesArr.length; i++) {
335
572
  const line = linesArr[i].trim();
336
- // Start marker pattern: TMUX_MCP_START_<seq>
337
- const startMatch = line.match(new RegExp(`^${startMarkerBase}_(\\d+)$`));
573
+ // Start marker pattern: TMUX_MCP_START_<nonce?>_<seq>
574
+ const startMatch = line.match(startMarkerRegex);
338
575
  if (startMatch) {
339
576
  const seq = parseInt(startMatch[1], 10);
340
577
  const existing = blocksBySeq.get(seq) || { endLine: -1, exitCode: -1, seq };
@@ -343,8 +580,8 @@ export async function checkCommandStatus(commandId, options) {
343
580
  debug('checkCommandStatus: start marker found', { seq, lineIndex: i, line });
344
581
  continue;
345
582
  }
346
- // End marker pattern: TMUX_MCP_DONE_<exit>_<seq>
347
- const endMatch = line.match(new RegExp(`^${endMarkerBase}_(\\d+)_([0-9]+)$`));
583
+ // End marker pattern: TMUX_MCP_DONE_<exit>_<nonce?>_<seq>
584
+ const endMatch = line.match(endMarkerRegex);
348
585
  if (endMatch) {
349
586
  const exitCode = parseInt(endMatch[1], 10);
350
587
  const seq = parseInt(endMatch[2], 10);
@@ -384,6 +621,17 @@ export async function checkCommandStatus(commandId, options) {
384
621
  debug('checkCommandStatus: end marker missing, still pending', { sequenceNumber, tailPreview: command.result });
385
622
  return command;
386
623
  }
624
+ // Defense-in-depth: a DONE marker that appears BEFORE this command's START
625
+ // marker is stale (left over from a prior run that reused the sequence
626
+ // number). The fresh command has started but not finished, so stay pending.
627
+ // (When the start marker is genuinely scrolled out, startLine is undefined
628
+ // and we fall through to normal completion handling.)
629
+ if (block.startLine !== undefined && block.endLine < block.startLine) {
630
+ const tail = linesArr.slice(-10).join('\n').trim();
631
+ command.result = tail ? tail : '(no recent output)';
632
+ debug('checkCommandStatus: stale end marker precedes fresh start, still pending', { sequenceNumber, startLine: block.startLine, endLine: block.endLine });
633
+ return command;
634
+ }
387
635
  // Mark completion
388
636
  command.status = block.exitCode === 0 ? 'completed' : 'error';
389
637
  command.exitCode = block.exitCode;
@@ -399,28 +647,7 @@ export async function checkCommandStatus(commandId, options) {
399
647
  }
400
648
  debug('checkCommandStatus: output lines after echo removal', { total: outputLines.length });
401
649
  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;
650
+ applyOutputSlicing(command, options);
424
651
  debug('checkCommandStatus: final slicing applied', { returned: command.returnedLines, total: command.totalLines, truncated: command.truncated, sliceStart: command.lineStartIndex, sliceEndExclusive: command.lineEndIndex });
425
652
  activeCommands.set(commandId, command);
426
653
  return command;
@@ -455,8 +682,13 @@ export function cleanupOldCommands(maxAgeMinutes = 30) {
455
682
  }
456
683
  function buildWrappedCommand(command, shellType, seq) {
457
684
  // End marker uses shell-specific exit variable but includes sequence
685
+ // For fish, use braces to prevent variable name ambiguity (e.g., $status_1 would be interpreted as variable 'status_1')
458
686
  const exitVar = shellType === 'fish' ? '$status' : '$?';
459
- const wrapped = `echo "${startMarkerBase}_${seq}"; ${command}; echo "${endMarkerBase}_${exitVar}_${seq}"`;
687
+ const fishExitWrapped = `{${exitVar}}`;
688
+ const nonceInfix = markerNonceInfix();
689
+ const wrapped = shellType === 'fish'
690
+ ? `echo "${startMarkerBase}_${nonceInfix}${seq}"; ${command}; echo "${endMarkerBase}_"${fishExitWrapped}"_${nonceInfix}${seq}"`
691
+ : `echo "${startMarkerBase}_${nonceInfix}${seq}"; ${command}; echo "${endMarkerBase}_${exitVar}_${nonceInfix}${seq}"`;
460
692
  debug('buildWrappedCommand', { shellType, seq, wrapped });
461
693
  return wrapped;
462
694
  }
@@ -469,45 +701,47 @@ async function ensureTclshInitialized(paneId) {
469
701
  if (tclshInitializedPanes.has(paneId)) {
470
702
  return;
471
703
  }
704
+ const pending = tclshInitInFlight.get(paneId);
705
+ if (pending) {
706
+ return pending;
707
+ }
708
+ const initPromise = sendTclshHelperDefinition(paneId);
709
+ tclshInitInFlight.set(paneId, initPromise);
710
+ try {
711
+ await initPromise;
712
+ tclshInitializedPanes.add(paneId);
713
+ }
714
+ finally {
715
+ tclshInitInFlight.delete(paneId);
716
+ }
717
+ }
718
+ async function sendTclshHelperDefinition(paneId) {
719
+ const nonceInfix = markerNonceInfix();
472
720
  const definitionCommand = [
473
721
  'namespace eval ::tmux_mcp {',
474
722
  'proc run {seq cmd} {',
475
- 'puts "' + startMarkerBase + '_${seq}"; flush stdout;',
723
+ 'puts "' + startMarkerBase + '_' + nonceInfix + '${seq}"; flush stdout;',
476
724
  'set status [catch {uplevel #0 $cmd} result opts];',
477
725
  'if {$status == 0} {',
478
726
  'if {[info exists result] && $result ne ""} { puts $result; flush stdout }',
479
727
  '} else {',
480
728
  'if {[info exists opts(-errorinfo)]} { puts $opts(-errorinfo); flush stdout } else { puts $result; flush stdout }',
481
729
  '};',
482
- 'puts "' + endMarkerBase + '_${status}_${seq}"; flush stdout',
730
+ 'puts "' + endMarkerBase + '_${status}_' + nonceInfix + '${seq}"; flush stdout',
483
731
  '}',
484
732
  '}'
485
733
  ].join(' ');
486
734
  debug('ensureTclshInitialized: sending helper definition');
487
735
  const escapedCommand = definitionCommand.replace(/'/g, "'\\''");
488
736
  await executeTmux(`send-keys -t '${paneId}' '${escapedCommand}' Enter`);
489
- tclshInitializedPanes.add(paneId);
490
737
  }
491
738
  // Retrieve sliced command output after completion without re-parsing markers
492
739
  export function getCommandOutput(commandId, options) {
493
740
  const command = activeCommands.get(commandId);
494
741
  if (!command || !command.outputLines)
495
742
  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');
743
+ const { start, end } = computeSliceBounds(command.outputLines.length, options, undefined);
744
+ return command.outputLines.slice(start, end).join('\n');
511
745
  }
512
746
  // Grep command output lines with a regular expression; returns matching lines
513
747
  export function grepCommandOutput(commandId, pattern, flags) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arearseth/tmux-mcp",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "MCP Server for interfacing with tmux sessions",
5
5
  "type": "module",
6
6
  "main": "build/index.js",