@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 +14 -7
- package/build/index.js +37 -17
- package/build/tmux.js +301 -67
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
47
|
-
name: z.string().describe("
|
|
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
|
|
68
|
+
const sessions = await tmux.findSessions(name);
|
|
51
69
|
return {
|
|
52
70
|
content: [{
|
|
53
71
|
type: "text",
|
|
54
|
-
text:
|
|
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
|
-
//
|
|
124
|
-
const parsedLines =
|
|
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
|
|
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.
|
|
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("
|
|
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
|
|
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", "
|
|
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
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
sliceEnd =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
264
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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 {
|
|
497
|
-
|
|
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) {
|