feat: add zodSchema wrapper for terminal tools list_terminals
```
This commit is contained in:
Mohamed Boudra
2025-10-20 14:10:38 +02:00
parent 845cfca6eb
commit e79a92813b
6 changed files with 1699 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
import { stepCountIs, streamText, tool } from "ai";
import { stepCountIs, streamText, tool, zodSchema } from "ai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { z } from "zod";
import {
@@ -12,6 +12,7 @@ import {
} from "../daemon/terminal-manager.js";
import invariant from "tiny-invariant";
import { getPlaywrightTools } from "./playwright-mcp.js";
import { lazySchema } from "@ai-sdk/provider-utils";
/**
* Terminal tools using Vercel AI SDK tool() function
@@ -21,7 +22,7 @@ export const terminalTools = {
list_terminals: tool({
description:
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, currently running command, and the last 5 lines of output for each terminal.",
inputSchema: z.object({}),
inputSchema: zodSchema(z.object({})),
execute: async () => {
const terminals = await listTerminals();
return { terminals };
@@ -248,6 +249,18 @@ export const terminalTools = {
}),
};
export const tls = {
list_terminals: tool({
description:
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, currently running command, and the last 5 lines of output for each terminal.",
inputSchema: z.object({}),
execute: async () => {
const terminals = await listTerminals();
return { terminals };
},
}),
};
/**
* Cache Playwright MCP tools (lazy loaded on first use)
*/

View File

@@ -0,0 +1,129 @@
```typescript
const server = new McpServer({
name: "my-app",
version: "1.0.0",
});
// Simple tool with parameters
server.registerTool(
"calculate-bmi",
{
title: "BMI Calculator",
description: "Calculate Body Mass Index",
inputSchema: {
weightKg: z.number(),
heightM: z.number(),
},
outputSchema: { bmi: z.number() },
},
async ({ weightKg, heightM }) => {
const output = { bmi: weightKg / (heightM * heightM) };
return {
content: [
{
type: "text",
text: JSON.stringify(output),
},
],
structuredContent: output,
};
}
);
// Async tool with external API call
server.registerTool(
"fetch-weather",
{
title: "Weather Fetcher",
description: "Get weather data for a city",
inputSchema: { city: z.string() },
outputSchema: { temperature: z.number(), conditions: z.string() },
},
async ({ city }) => {
const response = await fetch(`https://api.weather.com/${city}`);
const data = await response.json();
const output = { temperature: data.temp, conditions: data.conditions };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
}
);
// Tool that returns ResourceLinks
server.registerTool(
"list-files",
{
title: "List Files",
description: "List project files",
inputSchema: { pattern: z.string() },
outputSchema: {
count: z.number(),
files: z.array(z.object({ name: z.string(), uri: z.string() })),
},
},
async ({ pattern }) => {
const output = {
count: 2,
files: [
{ name: "README.md", uri: "file:///project/README.md" },
{ name: "index.ts", uri: "file:///project/src/index.ts" },
],
};
return {
content: [
{ type: "text", text: JSON.stringify(output) },
// ResourceLinks let tools return references without file content
{
type: "resource_link",
uri: "file:///project/README.md",
name: "README.md",
mimeType: "text/markdown",
description: "A README file",
},
{
type: "resource_link",
uri: "file:///project/src/index.ts",
name: "index.ts",
mimeType: "text/typescript",
description: "An index file",
},
],
structuredContent: output,
};
}
);
```
```typescript
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
bench('InMemoryTransport', async () => {
const client = new Client(
{
name: 'test client',
version: '1.0',
},
{
capabilities: {
tools: {},
},
},
);
const server = createServer();
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const tools = await client.listTools();
if (!tools.tools.length) throw new Error('No tools found');
await client.close();
});
});
```

View File

@@ -0,0 +1,7 @@
export { createTerminalMcpServer, type TerminalMcpServerOptions } from "./server.js";
export { TerminalManager } from "./terminal-manager.js";
export type {
TerminalInfo,
CreateTerminalParams,
CreateTerminalResult,
} from "./terminal-manager.js";

View File

@@ -0,0 +1,325 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { TerminalManager } from "./terminal-manager.js";
export interface TerminalMcpServerOptions {
sessionName: string;
}
/**
* Create and configure the Terminal MCP Server
* Multiple instances can run independently with different session names
*/
export async function createTerminalMcpServer(
options: TerminalMcpServerOptions
): Promise<McpServer> {
const { sessionName } = options;
const terminalManager = new TerminalManager(sessionName);
// Initialize the session
await terminalManager.initialize();
const server = new McpServer({
name: "terminal-mcp",
version: "1.0.0",
});
// Tool: list_terminals
server.registerTool(
"list_terminals",
{
title: "List Terminals",
description:
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, currently running command, and the last 5 lines of output for each terminal.",
inputSchema: {},
outputSchema: {
terminals: z.array(
z.object({
name: z.string(),
workingDirectory: z.string(),
currentCommand: z.string(),
lastLines: z.string().optional(),
})
),
},
},
async () => {
const terminals = await terminalManager.listTerminals();
const output = { terminals };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
}
);
// Tool: create_terminal
server.registerTool(
"create_terminal",
{
title: "Create Terminal",
description:
"Create a new terminal (isolated shell environment) at a specific working directory. Optionally execute an initial command after creation. Terminal names must be unique. Always specify workingDirectory based on context - use project paths when working on projects, or the same directory as current terminal when user says 'another terminal here'. Defaults to ~ only if no context.",
inputSchema: {
name: z
.string()
.describe(
"Unique name for the terminal. Should be descriptive of what the terminal is used for (e.g., 'web-dev', 'api-server', 'tests')."
),
workingDirectory: z
.string()
.describe(
"Absolute path to the working directory for this terminal. Can use ~ for home directory. Required parameter - set contextually based on what the user is working on. Use project paths when working on projects. Defaults to home directory (~) only if no context."
),
initialCommand: z
.string()
.optional()
.describe(
"Optional command to execute after creating the terminal (e.g., 'npm run dev', 'python -m venv venv'). The command runs after changing to the working directory."
),
},
outputSchema: {
terminal: z.object({
name: z.string(),
workingDirectory: z.string(),
currentCommand: z.string(),
commandOutput: z.string().optional(),
}),
},
},
async ({ name, workingDirectory, initialCommand }) => {
const terminal = await terminalManager.createTerminal({
name,
workingDirectory,
initialCommand,
});
const output = { terminal };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
}
);
// Tool: capture_terminal
server.registerTool(
"capture_terminal",
{
title: "Capture Terminal",
description:
"Capture and return the output from a terminal. Returns the last N lines of terminal content. Useful for checking command results, monitoring running processes, or debugging issues.",
inputSchema: {
terminalName: z.string().describe("Name of the terminal"),
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
maxWait: z
.number()
.optional()
.describe(
"Maximum milliseconds to wait for terminal activity to settle before capturing. Polls every 100ms and waits for 1s of no changes. Useful for commands with delayed output."
),
},
outputSchema: {
output: z.string(),
},
},
async ({ terminalName, lines, maxWait }) => {
const output = await terminalManager.captureTerminal(
terminalName,
lines,
maxWait
);
const result = { output };
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
};
}
);
// Tool: send_text
server.registerTool(
"send_text",
{
title: "Send Text",
description:
"Type text into a terminal. This is the PRIMARY way to execute shell commands with bash operators (&&, ||, |, ;, etc.) - set pressEnter=true to run the command. Also use for interactive applications, REPLs, forms, and text entry. For special keys or control sequences, use send_keys instead.",
inputSchema: {
terminalName: z.string().describe("Name of the terminal"),
text: z
.string()
.describe(
"Text to type into the terminal. For shell commands, can use any bash operators: && (chain), || (or), | (pipe), ; (sequential), etc."
),
pressEnter: z
.boolean()
.optional()
.describe(
"Press Enter after typing the text (default: false). Set to true to execute shell commands or submit text input."
),
return_output: z
.object({
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
waitForSettled: z
.boolean()
.optional()
.describe(
"Wait for terminal activity to settle before returning output. Polls terminal and waits 500ms after last change (default: true)"
),
maxWait: z
.number()
.optional()
.describe(
"Maximum milliseconds to wait for activity to settle (default: 120000 = 2 minutes)"
),
})
.optional()
.describe(
"Capture terminal output after sending text. By default waits for activity to settle."
),
},
outputSchema: {
output: z.string().optional(),
},
},
async ({ terminalName, text, pressEnter, return_output }) => {
const output = await terminalManager.sendText(
terminalName,
text,
pressEnter,
return_output
);
const result = { output: output || undefined };
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
};
}
);
// Tool: send_keys
server.registerTool(
"send_keys",
{
title: "Send Keys",
description:
"Send special keys or key combinations to a terminal. Use for TUI navigation and control sequences. Examples: 'Up', 'Down', 'Enter', 'Escape', 'C-c' (Ctrl+C), 'M-x' (Alt+X). For typing regular text, use send_text instead. Supports repeating key presses and optionally capturing output after sending keys.",
inputSchema: {
terminalName: z.string().describe("Name of the terminal"),
keys: z
.string()
.describe(
"Special key name or key combination: 'Up', 'Down', 'Left', 'Right', 'Enter', 'Escape', 'Tab', 'Space', 'C-c', 'M-x', etc."
),
repeat: z
.number()
.min(1)
.optional()
.describe("Number of times to repeat the key press (default: 1)"),
return_output: z
.object({
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
waitForSettled: z
.boolean()
.optional()
.describe(
"Wait for terminal activity to settle before returning output. Polls terminal and waits 500ms after last change (default: true)"
),
maxWait: z
.number()
.optional()
.describe(
"Maximum milliseconds to wait for activity to settle (default: 120000 = 2 minutes)"
),
})
.optional()
.describe(
"Capture terminal output after sending keys. By default waits for activity to settle."
),
},
outputSchema: {
output: z.string().optional(),
},
},
async ({ terminalName, keys, repeat, return_output }) => {
const output = await terminalManager.sendKeys(
terminalName,
keys,
repeat,
return_output
);
const result = { output: output || undefined };
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
};
}
);
// Tool: rename_terminal
server.registerTool(
"rename_terminal",
{
title: "Rename Terminal",
description:
"Rename a terminal to a more descriptive name. The new name must be unique among all terminals.",
inputSchema: {
terminalName: z.string().describe("Current name of the terminal"),
newName: z
.string()
.describe(
"New unique name for the terminal. Should be descriptive of the terminal's purpose."
),
},
outputSchema: {
success: z.boolean(),
},
},
async ({ terminalName, newName }) => {
await terminalManager.renameTerminal(terminalName, newName);
const output = { success: true };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
}
);
// Tool: kill_terminal
server.registerTool(
"kill_terminal",
{
title: "Kill Terminal",
description:
"Close and destroy a terminal. This will terminate any running processes in the terminal. Use with caution.",
inputSchema: {
terminalName: z
.string()
.describe(
"Name of the terminal to kill. Get this from list_terminals."
),
},
outputSchema: {
success: z.boolean(),
},
},
async ({ terminalName }) => {
await terminalManager.killTerminal(terminalName);
const output = { success: true };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
}
);
return server;
}

View File

@@ -0,0 +1,300 @@
import {
findSessionByName,
createSession,
listWindows,
createWindow,
listPanes,
findWindowByName,
capturePaneContent,
sendText as tmuxSendText,
sendKeys as tmuxSendKeys,
renameWindow,
killWindow,
isWindowNameUnique,
getCurrentWorkingDirectory,
getCurrentCommand,
waitForPaneActivityToSettle,
} from "./tmux.js";
// Terminal model: session → windows (single pane per window)
// Terminals are identified by their unique names, not IDs
export interface TerminalInfo {
name: string;
workingDirectory: string;
currentCommand: string;
lastLines?: string;
}
export interface CreateTerminalParams {
name: string;
workingDirectory: string;
initialCommand?: string;
}
export interface CreateTerminalResult extends TerminalInfo {
commandOutput?: string;
}
/**
* Terminal manager for a specific tmux session
* Multiple instances can coexist with different session names
*/
export class TerminalManager {
constructor(private sessionName: string) {}
/**
* Initialize the tmux session
* Creates it if it doesn't exist
*/
async initialize(): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
await createSession(this.sessionName);
}
}
/**
* List all terminals in the session
* Returns terminal info including name, active status, working directory, and current command
*/
async listTerminals(): Promise<TerminalInfo[]> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(
`Session '${this.sessionName}' not found. Call initialize() first.`
);
}
const windows = await listWindows(session.id);
const terminals: TerminalInfo[] = [];
for (const window of windows) {
// Get the first (and only) pane in this window
const paneId = `${window.id}.0`;
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
const lastLines = await capturePaneContent(paneId, 5, false);
terminals.push({
name: window.name,
workingDirectory,
currentCommand,
lastLines,
});
}
return terminals;
}
/**
* Create a new terminal (tmux window) with specified name and working directory
* Optionally execute an initial command
*/
async createTerminal(
params: CreateTerminalParams
): Promise<CreateTerminalResult> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(
`Session '${this.sessionName}' not found. Call initialize() first.`
);
}
// Validate name uniqueness
const isUnique = await isWindowNameUnique(session.id, params.name);
if (!isUnique) {
throw new Error(
`Terminal with name '${params.name}' already exists. Please choose a unique name.`
);
}
// Create the window
const windowResult = await createWindow(session.id, params.name, {
workingDirectory: params.workingDirectory,
command: params.initialCommand,
});
if (!windowResult) {
throw new Error(`Failed to create terminal '${params.name}'`);
}
const paneId = windowResult.paneId;
// Get terminal info
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
return {
name: windowResult.name,
workingDirectory,
currentCommand,
commandOutput: windowResult.output,
};
}
/**
* Capture output from a terminal by name
* Returns the last N lines of terminal content
* If maxWait is provided, waits for terminal activity to settle before capturing
*/
async captureTerminal(
terminalName: string,
lines: number = 200,
maxWait?: number
): Promise<string> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
// Wait for activity to settle if maxWait is provided
if (maxWait) {
return waitForPaneActivityToSettle(pane.id, maxWait, lines);
}
return capturePaneContent(pane.id, lines, false);
}
/**
* Send text to a terminal by name, optionally press Enter, optionally return output
*/
async sendText(
terminalName: string,
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
): Promise<string | void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
return tmuxSendText({
paneId: pane.id,
text,
pressEnter,
return_output,
});
}
/**
* Send special keys or key combinations to a terminal by name
* Useful for TUI navigation, control sequences, and interactive applications
*/
async sendKeys(
terminalName: string,
keys: string,
repeat: number = 1,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
): Promise<string | void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
return tmuxSendKeys({
paneId: pane.id,
keys,
repeat,
return_output,
});
}
/**
* Rename a terminal by name
* Validates that the new name is unique
*/
async renameTerminal(
terminalName: string,
newName: string
): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// renameWindow handles uniqueness validation and name resolution internally
await renameWindow(session.id, terminalName, newName);
}
/**
* Kill (close/destroy) a terminal by name
*/
async killTerminal(terminalName: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
await killWindow(window.id);
}
}

View File

@@ -0,0 +1,923 @@
import { exec as execCallback } from "child_process";
import { promisify } from "util";
import { v4 as uuidv4 } from "uuid";
import os from "node:os";
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;
}
}
/**
* Find a window by name in a session
*/
export async function findWindowByName(
sessionId: string,
name: string
): Promise<TmuxWindow | null> {
try {
const windows = await listWindows(sessionId);
return windows.find((window) => window.name === name) || null;
} catch (error) {
return null;
}
}
/**
* Check if a window name is unique in a session
*/
export async function isWindowNameUnique(
sessionId: string,
name: string
): Promise<boolean> {
const window = await findWindowByName(sessionId, name);
return window === 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" : "";
// Capture a large range to ensure we have enough content
const captureLines = Math.max(lines, 1000);
const output = await executeTmux(
`capture-pane -p ${colorFlag} -t '${paneId}' -S -${captureLines} -E -`
);
// Trim trailing whitespace, split by lines, take last N lines, rejoin
const trimmed = output.trimEnd();
const allLines = trimmed.split("\n");
const lastLines = allLines.slice(-lines);
return lastLines.join("\n");
}
/**
* Get the current working directory of a pane
*/
export async function getCurrentWorkingDirectory(
paneId: string
): Promise<string> {
try {
const tmuxPath = await executeTmux(
`display-message -p -t '${paneId}' '#{pane_current_path}'`
);
// If tmux returns a valid path, use it
if (tmuxPath && tmuxPath.trim()) {
return tmuxPath;
}
// Fallback: get the PID and use lsof to find the actual CWD
const shellPid = await executeTmux(
`display-message -p -t '${paneId}' '#{pane_pid}'`
);
const { stdout } = await exec(
`lsof -a -p ${shellPid.trim()} -d cwd -Fn | grep '^n' | cut -c2-`
);
return stdout.trim() || tmuxPath;
} catch (error) {
// If all else fails, return empty string
return "";
}
}
/**
* Get the current command running in a pane (full command line with arguments)
* Gets the immediate child process of the shell, not the shell itself
*/
export async function getCurrentCommand(paneId: string): Promise<string> {
try {
// Get the shell PID (the pane's main process)
const shellPid = await executeTmux(
`display-message -p -t '${paneId}' '#{pane_pid}'`
);
// First, check if there's a child process using comm= (works for all programs including top)
// Use 'ax' flags to see all processes
const { stdout: childPid } = await exec(
`ps ax -o pid=,ppid=,comm= | awk '$2 == ${shellPid.trim()} { print $1; exit }'`
);
if (childPid.trim()) {
// Found a child process, get its full command with args
const { stdout: fullCmd } = await exec(
`ps -p ${childPid.trim()} -o args= | sed 's/\\\\012.*//'`
);
const command = fullCmd.trim();
if (command) {
return command;
}
}
// No child process, just return the shell name
const { stdout: shellCmd } = await exec(`ps -p ${shellPid} -o comm=`);
return shellCmd.trim();
} catch (error) {
// Fallback to just the command name if ps fails
return executeTmux(
`display-message -p -t '${paneId}' '#{pane_current_command}'`
);
}
}
/**
* Create a new tmux session with a default window named "default" in home directory
*/
export async function createSession(name: string): Promise<TmuxSession | null> {
const homeDir = process.env.HOME || "~";
await executeTmux(`new-session -d -s "${name}" -n "default" -c "${homeDir}"`);
// Disable automatic window renaming for all windows in the session
await executeTmux(`set-window-option -t "${name}" automatic-rename off`);
return findSessionByName(name);
}
/**
* Expand tilde in path to home directory
*/
function expandTilde(path: string): string {
if (path.startsWith("~/")) {
const homeDir = process.env.HOME || os.homedir();
return path.replace("~", homeDir);
}
if (path === "~") {
return process.env.HOME || os.homedir();
}
return path;
}
/**
* Create a new window in a session with optional working directory and initial command
*/
export async function createWindow(
sessionId: string,
name: string,
options?: {
workingDirectory?: string;
command?: string;
}
): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> {
// Validate name uniqueness
const isUnique = await isWindowNameUnique(sessionId, name);
if (!isUnique) {
throw new Error(
`Terminal with name '${name}' already exists. Please choose a unique name.`
);
}
// Build new-window command with optional working directory
let newWindowCmd = `new-window -t '${sessionId}' -n '${name}'`;
if (options?.workingDirectory) {
// Expand tilde to home directory before passing to tmux
const expandedPath = expandTilde(options.workingDirectory);
newWindowCmd += ` -c '${expandedPath}'`;
}
await executeTmux(newWindowCmd);
const windows = await listWindows(sessionId);
const window = windows.find((window) => window.name === name);
if (!window) return null;
// Disable automatic window renaming
await executeTmux(`set-window-option -t '${window.id}' automatic-rename off`);
// 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 (options?.command && defaultPane) {
commandOutput = (await sendText({
paneId: defaultPane.id,
text: options.command,
pressEnter: true,
return_output: {
waitForSettled: true,
maxWait: 120000,
},
})) as string;
}
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 name or ID
*/
export async function renameWindow(
sessionId: string,
windowNameOrId: string,
newName: string
): Promise<void> {
// Validate new name is unique
const isUnique = await isWindowNameUnique(sessionId, newName);
if (!isUnique) {
throw new Error(
`Terminal with name '${newName}' already exists. Please choose a unique name.`
);
}
// Check if windowNameOrId is a window ID (starts with @) or a name
let windowId: string;
if (windowNameOrId.startsWith("@")) {
windowId = windowNameOrId;
} else {
// Resolve name to ID
const window = await findWindowByName(sessionId, windowNameOrId);
if (!window) {
throw new Error(`Terminal '${windowNameOrId}' not found.`);
}
windowId = window.id;
}
await executeTmux(`rename-window -t '${windowId}' '${newName}'`);
// Disable automatic renaming to preserve the manual name
await executeTmux(`set-window-option -t '${windowId}' automatic-rename off`);
}
/**
* 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;
waitForSettled?: boolean;
maxWait?: 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) {
const lines = return_output.lines || 200;
const waitForSettled = return_output.waitForSettled ?? true;
const maxWait = return_output.maxWait ?? 120000; // 2 minutes default
if (waitForSettled) {
return waitForPaneActivityToSettle(paneId, maxWait, lines);
} else {
return capturePaneContent(paneId, lines, false);
}
}
}
export async function waitForPaneActivityToSettle(
paneId: string,
maxWait: number,
lines: number
): Promise<string> {
const settleTime = 1000; // Hardcoded debounce
const pollInterval = 100; // Poll every 100ms
let lastContent = "";
let lastChangeTime = Date.now();
const startTime = Date.now();
while (true) {
const elapsed = Date.now() - startTime;
if (elapsed >= maxWait) {
// Timeout - return what we have
return lastContent;
}
const content = await capturePaneContent(paneId, lines, false);
if (content !== lastContent) {
// Activity detected - reset settle timer
lastContent = content;
lastChangeTime = Date.now();
} else if (Date.now() - lastChangeTime >= settleTime) {
// No changes for settleTime ms - settled!
return content;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
}
export async function sendText({
paneId,
text,
pressEnter = false,
return_output,
}: {
paneId: string;
text: string;
pressEnter?: boolean;
return_output?: {
lines?: number;
waitForSettled?: boolean;
maxWait?: 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) {
const lines = return_output.lines || 200;
const waitForSettled = return_output.waitForSettled ?? true;
const maxWait = return_output.maxWait ?? 120000; // 2 minutes default
if (waitForSettled) {
return waitForPaneActivityToSettle(paneId, maxWait, lines);
} else {
return capturePaneContent(paneId, lines, false);
}
}
}