diff --git a/packages/voice-assistant/src/server/agent/llm-openai.ts b/packages/voice-assistant/src/server/agent/llm-openai.ts index 012aa69ab..4d11e7370 100644 --- a/packages/voice-assistant/src/server/agent/llm-openai.ts +++ b/packages/voice-assistant/src/server/agent/llm-openai.ts @@ -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) */ diff --git a/packages/voice-assistant/src/terminal-mcp/REFERENCES.md b/packages/voice-assistant/src/terminal-mcp/REFERENCES.md new file mode 100644 index 000000000..19045bdea --- /dev/null +++ b/packages/voice-assistant/src/terminal-mcp/REFERENCES.md @@ -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(); + }); +}); + +``` diff --git a/packages/voice-assistant/src/terminal-mcp/index.ts b/packages/voice-assistant/src/terminal-mcp/index.ts new file mode 100644 index 000000000..bcecac6f3 --- /dev/null +++ b/packages/voice-assistant/src/terminal-mcp/index.ts @@ -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"; diff --git a/packages/voice-assistant/src/terminal-mcp/server.ts b/packages/voice-assistant/src/terminal-mcp/server.ts new file mode 100644 index 000000000..294600dd6 --- /dev/null +++ b/packages/voice-assistant/src/terminal-mcp/server.ts @@ -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 { + 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; +} diff --git a/packages/voice-assistant/src/terminal-mcp/terminal-manager.ts b/packages/voice-assistant/src/terminal-mcp/terminal-manager.ts new file mode 100644 index 000000000..1f313309d --- /dev/null +++ b/packages/voice-assistant/src/terminal-mcp/terminal-manager.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/packages/voice-assistant/src/terminal-mcp/tmux.ts b/packages/voice-assistant/src/terminal-mcp/tmux.ts new file mode 100644 index 000000000..d23495e05 --- /dev/null +++ b/packages/voice-assistant/src/terminal-mcp/tmux.ts @@ -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 { + 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 { + try { + await executeTmux("list-sessions -F '#{session_name}'"); + return true; + } catch (error) { + return false; + } +} + +/** + * List all tmux sessions + */ +export async function listSessions(): Promise { + 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 { + 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 { + 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 { + const window = await findWindowByName(sessionId, name); + return window === null; +} + +/** + * List windows in a session + */ +export async function listWindows(sessionId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await executeTmux(`kill-session -t '${sessionId}'`); +} + +/** + * Kill a tmux window by ID + */ +export async function killWindow(windowId: string): Promise { + await executeTmux(`kill-window -t '${windowId}'`); +} + +/** + * Kill a tmux pane by ID + */ +export async function killPane(paneId: string): Promise { + 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 { + // 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 { + // 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(); + +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 { + // 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + // 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); + } + } +}