Files
paseo/packages/mcp-server/src/tmux.ts
T

706 lines
17 KiB
TypeScript

import { exec as execCallback } from "child_process";
import { promisify } from "util";
import { v4 as uuidv4 } from "uuid";
const exec = promisify(execCallback);
// Basic interfaces for tmux objects
export interface TmuxSession {
id: string;
name: string;
attached: boolean;
windows: number;
}
export interface TmuxWindow {
id: string;
name: string;
active: boolean;
sessionId: string;
}
export interface TmuxPane {
id: string;
windowId: string;
active: boolean;
title: string;
}
interface CommandExecution {
id: string;
paneId: string;
command: string;
status: "pending" | "completed" | "error";
startTime: Date;
result?: string;
exitCode?: number;
rawMode?: boolean;
}
export type ShellType = "bash" | "zsh" | "fish";
let shellConfig: { type: ShellType } = { type: "bash" };
export function setShellConfig(config: { type: string }): void {
// Validate shell type
const validShells: ShellType[] = ["bash", "zsh", "fish"];
if (validShells.includes(config.type as ShellType)) {
shellConfig = { type: config.type as ShellType };
} else {
shellConfig = { type: "bash" };
}
}
/**
* Execute a tmux command and return the result
*/
export async function executeTmux(tmuxCommand: string): Promise<string> {
try {
const { stdout } = await exec(`tmux ${tmuxCommand}`);
return stdout.trim();
} catch (error: any) {
throw new Error(`Failed to execute tmux command: ${error.message}`);
}
}
/**
* Check if tmux server is running
*/
export async function isTmuxRunning(): Promise<boolean> {
try {
await executeTmux("list-sessions -F '#{session_name}'");
return true;
} catch (error) {
return false;
}
}
/**
* List all tmux sessions
*/
export async function listSessions(): Promise<TmuxSession[]> {
const format =
"#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
const output = await executeTmux(`list-sessions -F '${format}'`);
if (!output) return [];
return output.split("\n").map((line) => {
const [id, name, attached, windows] = line.split(":");
return {
id,
name,
attached: attached === "1",
windows: parseInt(windows, 10),
};
});
}
/**
* Find a session by name
*/
export async function findSessionByName(
name: string
): Promise<TmuxSession | null> {
try {
const sessions = await listSessions();
return sessions.find((session) => session.name === name) || null;
} catch (error) {
return null;
}
}
/**
* List windows in a session
*/
export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
const output = await executeTmux(
`list-windows -t '${sessionId}' -F '${format}'`
);
if (!output) return [];
return output.split("\n").map((line) => {
const [id, name, active] = line.split(":");
return {
id,
name,
active: active === "1",
sessionId,
};
});
}
/**
* List panes in a window
*/
export async function listPanes(windowId: string): Promise<TmuxPane[]> {
const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
const output = await executeTmux(
`list-panes -t '${windowId}' -F '${format}'`
);
if (!output) return [];
return output.split("\n").map((line) => {
const [id, title, active] = line.split(":");
return {
id,
windowId,
title: title,
active: active === "1",
};
});
}
/**
* Capture content from a specific pane, by default the latest 200 lines.
*/
export async function capturePaneContent(
paneId: string,
lines: number = 200,
includeColors: boolean = false
): Promise<string> {
const colorFlag = includeColors ? "-e" : "";
return executeTmux(
`capture-pane -p ${colorFlag} -t '${paneId}' -S -${lines} -E -`
);
}
/**
* Create a new tmux session
*/
export async function createSession(name: string): Promise<TmuxSession | null> {
await executeTmux(`new-session -d -s "${name}"`);
return findSessionByName(name);
}
/**
* Create a new window in a session
*/
export async function createWindow(
sessionId: string,
name: string,
command?: string
): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> {
const output = await executeTmux(`new-window -t '${sessionId}' -n '${name}'`);
const windows = await listWindows(sessionId);
const window = windows.find((window) => window.name === name);
if (!window) return null;
// Get the default pane created with the window
const panes = await listPanes(window.id);
const defaultPane = panes[0];
let commandOutput: string | undefined;
// If command is provided, execute it in the new pane
if (command && defaultPane) {
await sendText({
paneId: defaultPane.id,
text: command,
pressEnter: true,
});
// Sleep for 1 second to allow command to execute
await new Promise((resolve) => setTimeout(resolve, 1000));
// Capture the pane content
commandOutput = await capturePaneContent(defaultPane.id, 200, false);
}
return {
...window,
paneId: defaultPane?.id || "",
output: commandOutput,
};
}
/**
* Kill a tmux session by ID
*/
export async function killSession(sessionId: string): Promise<void> {
await executeTmux(`kill-session -t '${sessionId}'`);
}
/**
* Kill a tmux window by ID
*/
export async function killWindow(windowId: string): Promise<void> {
await executeTmux(`kill-window -t '${windowId}'`);
}
/**
* Kill a tmux pane by ID
*/
export async function killPane(paneId: string): Promise<void> {
await executeTmux(`kill-pane -t '${paneId}'`);
}
/**
* Rename a tmux window by ID
*/
export async function renameWindow(
windowId: string,
name: string
): Promise<void> {
await executeTmux(`rename-window -t '${windowId}' '${name}'`);
}
/**
* Split a tmux pane horizontally or vertically
*/
export async function splitPane(
targetPaneId: string,
direction: "horizontal" | "vertical" = "vertical",
size?: number
): Promise<TmuxPane | null> {
// Build the split-window command
let splitCommand = "split-window";
// Add direction flag (-h for horizontal, -v for vertical)
if (direction === "horizontal") {
splitCommand += " -h";
} else {
splitCommand += " -v";
}
// Add target pane
splitCommand += ` -t '${targetPaneId}'`;
// Add size if specified (as percentage)
if (size !== undefined && size > 0 && size < 100) {
splitCommand += ` -p ${size}`;
}
// Execute the split command
await executeTmux(splitCommand);
// Get the window ID from the target pane to list all panes
const windowInfo = await executeTmux(
`display-message -p -t '${targetPaneId}' '#{window_id}'`
);
// List all panes in the window to find the newly created one
const panes = await listPanes(windowInfo);
// The newest pane is typically the last one in the list
return panes.length > 0 ? panes[panes.length - 1] : null;
}
// Map to track ongoing command executions
const activeCommands = new Map<string, CommandExecution>();
const startMarkerText = "TMUX_MCP_START";
const endMarkerPrefix = "TMUX_MCP_DONE_";
// Execute a command in a tmux pane and track its execution
export async function executeCommand(
paneId: string,
command: string,
rawMode?: boolean,
noEnter?: boolean
): Promise<string> {
// Generate unique ID for this command execution
const commandId = uuidv4();
let fullCommand: string;
if (rawMode || noEnter) {
fullCommand = command;
} else {
const endMarkerText = getEndMarkerText();
fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
}
// Store command in tracking map
activeCommands.set(commandId, {
id: commandId,
paneId,
command,
status: "pending",
startTime: new Date(),
rawMode: rawMode || noEnter,
});
// Send the command to the tmux pane
if (noEnter) {
// Check if this is a special key or key combination
// Special keys in tmux are typically capitalized or have special names
const specialKeys = [
"Up",
"Down",
"Left",
"Right",
"Escape",
"Tab",
"Enter",
"Space",
"BSpace",
"Delete",
"Home",
"End",
"PageUp",
"PageDown",
"F1",
"F2",
"F3",
"F4",
"F5",
"F6",
"F7",
"F8",
"F9",
"F10",
"F11",
"F12",
"BTab",
];
// Split the command into parts to handle combinations like "C-b" or "M-x"
const parts = fullCommand.split("-");
const isSpecialKey = parts.length === 1 && specialKeys.includes(parts[0]);
const isKeyCombo =
parts.length > 1 &&
(parts[0] === "C" || // Control
parts[0] === "M" || // Meta/Alt
parts[0] === "S"); // Shift
if (isSpecialKey || isKeyCombo) {
// Send special key or key combination as-is
await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`);
} else {
// For regular text, send each character individually to ensure proper processing
// This handles both single characters (like 'q', 'f') and strings (like 'beam')
for (const char of fullCommand) {
await executeTmux(
`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
);
}
}
} else {
await executeTmux(
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
);
}
return commandId;
}
export async function checkCommandStatus(
commandId: string
): Promise<CommandExecution | null> {
const command = activeCommands.get(commandId);
if (!command) return null;
if (command.status !== "pending") return command;
const content = await capturePaneContent(command.paneId, 1000);
if (command.rawMode) {
command.result =
"Status tracking unavailable for rawMode commands. Use capture-pane to monitor interactive apps instead.";
return command;
}
// Find the last occurrence of the markers
const startIndex = content.lastIndexOf(startMarkerText);
const endIndex = content.lastIndexOf(endMarkerPrefix);
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
command.result = "Command output could not be captured properly";
return command;
}
// Extract exit code from the end marker line
const endLine = content.substring(endIndex).split("\n")[0];
const endMarkerRegex = new RegExp(`${endMarkerPrefix}(\\d+)`);
const exitCodeMatch = endLine.match(endMarkerRegex);
if (exitCodeMatch) {
const exitCode = parseInt(exitCodeMatch[1], 10);
command.status = exitCode === 0 ? "completed" : "error";
command.exitCode = exitCode;
// Extract output between the start and end markers
const outputStart = startIndex + startMarkerText.length;
const outputContent = content.substring(outputStart, endIndex).trim();
command.result = outputContent
.substring(outputContent.indexOf("\n") + 1)
.trim();
// Update in map
activeCommands.set(commandId, command);
}
return command;
}
// Get command by ID
export function getCommand(commandId: string): CommandExecution | null {
return activeCommands.get(commandId) || null;
}
// Get all active command IDs
export function getActiveCommandIds(): string[] {
return Array.from(activeCommands.keys());
}
// Clean up completed commands older than a certain time
export function cleanupOldCommands(maxAgeMinutes: number = 60): void {
const now = new Date();
for (const [id, command] of activeCommands.entries()) {
const ageMinutes =
(now.getTime() - command.startTime.getTime()) / (1000 * 60);
if (command.status !== "pending" && ageMinutes > maxAgeMinutes) {
activeCommands.delete(id);
}
}
}
function getEndMarkerText(): string {
return shellConfig.type === "fish"
? `${endMarkerPrefix}$status`
: `${endMarkerPrefix}$?`;
}
// New consolidated API functions
export type ListScope = "all" | "sessions" | "session" | "window" | "pane";
interface SessionWithWindows extends TmuxSession {
windowDetails?: WindowWithPanes[];
}
interface WindowWithPanes extends TmuxWindow {
paneDetails?: TmuxPane[];
}
export async function list({
scope,
target,
}: {
scope: ListScope;
target?: string;
}): Promise<
| SessionWithWindows[]
| TmuxSession[]
| TmuxWindow[]
| TmuxPane[]
| TmuxSession
| TmuxWindow
| TmuxPane
> {
if (scope === "all") {
const sessions = await listSessions();
const sessionsWithDetails: SessionWithWindows[] = [];
for (const session of sessions) {
const windows = await listWindows(session.id);
const windowsWithPanes: WindowWithPanes[] = [];
for (const window of windows) {
const panes = await listPanes(window.id);
windowsWithPanes.push({
...window,
paneDetails: panes,
});
}
sessionsWithDetails.push({
...session,
windowDetails: windowsWithPanes,
});
}
return sessionsWithDetails;
}
if (scope === "sessions") {
return listSessions();
}
if (scope === "session") {
if (!target) {
throw new Error("target is required for scope 'session'");
}
return listWindows(target);
}
if (scope === "window") {
if (!target) {
throw new Error("target is required for scope 'window'");
}
return listPanes(target);
}
if (scope === "pane") {
if (!target) {
throw new Error("target is required for scope 'pane'");
}
const windowId = await executeTmux(
`display-message -p -t '${target}' '#{window_id}'`
);
const panes = await listPanes(windowId);
const pane = panes.find((p) => p.id === target);
if (!pane) {
throw new Error(`Pane not found: ${target}`);
}
return pane;
}
throw new Error(`Invalid scope: ${scope}`);
}
export type KillScope = "session" | "window" | "pane";
export async function kill({
scope,
target,
}: {
scope: KillScope;
target: string;
}): Promise<void> {
if (scope === "session") {
return killSession(target);
}
if (scope === "window") {
return killWindow(target);
}
if (scope === "pane") {
return killPane(target);
}
throw new Error(`Invalid scope: ${scope}`);
}
export interface ShellCommandResult {
command: string;
status: "completed" | "error";
exitCode: number;
output: string;
}
export async function executeShellCommand({
paneId,
command,
timeout = 30000,
}: {
paneId: string;
command: string;
timeout?: number;
}): Promise<ShellCommandResult> {
const commandId = uuidv4();
const endMarkerText = getEndMarkerText();
const fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
activeCommands.set(commandId, {
id: commandId,
paneId,
command,
status: "pending",
startTime: new Date(),
rawMode: false,
});
await executeTmux(
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
);
// Poll for completion
const startTime = Date.now();
const pollInterval = 100;
while (Date.now() - startTime < timeout) {
await new Promise((resolve) => setTimeout(resolve, pollInterval));
const result = await checkCommandStatus(commandId);
if (result && result.status !== "pending") {
// Cleanup
activeCommands.delete(commandId);
return {
command: result.command,
status: result.status,
exitCode: result.exitCode!,
output: result.result || "",
};
}
}
// Timeout
activeCommands.delete(commandId);
throw new Error(
`Command timed out after ${timeout}ms. Use capture-pane to check pane state.`
);
}
export async function sendKeys({
paneId,
keys,
repeat = 1,
return_output,
}: {
paneId: string;
keys: string;
repeat?: number;
return_output?: { lines?: number; wait?: number };
}): Promise<string | void> {
// Repeat the key press the specified number of times
for (let i = 0; i < repeat; i++) {
// Raw pass-through, no validation or processing
await executeTmux(`send-keys -t '${paneId}' ${keys}`);
}
// If return_output is requested, wait and capture pane content
if (return_output) {
await new Promise((resolve) =>
setTimeout(resolve, return_output.wait ?? 500)
);
const lines = return_output.lines || 200;
return capturePaneContent(paneId, lines, false);
}
}
export async function sendText({
paneId,
text,
pressEnter = false,
return_output,
}: {
paneId: string;
text: string;
pressEnter?: boolean;
return_output?: { lines?: number; wait?: number };
}): Promise<string | void> {
// Send each character with -l flag for literal interpretation
for (const char of text) {
await executeTmux(
`send-keys -l -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
);
}
if (pressEnter) {
await new Promise((resolve) => setTimeout(resolve, 300));
await executeTmux(`send-keys -t '${paneId}' Enter`);
}
// If return_output is requested, wait and capture pane content
if (return_output) {
if (return_output.wait) {
await new Promise((resolve) => setTimeout(resolve, return_output.wait));
}
const lines = return_output.lines || 200;
return capturePaneContent(paneId, lines, false);
}
}