From 30fc716b02b2a9e99656c9d1eee9c221308d03ba Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 20 Oct 2025 14:23:54 +0200 Subject: [PATCH] refactor: migrate terminal management to MCP architecture - Remove legacy terminal management code and integrate a new Terminal MCP server. - Implement TerminalManager for handling terminal operations within a specific tmux session. - Introduce tools for creating, listing, renaming, and killing terminals with improved error handling. - Update references and documentation to reflect the new architecture. - Ensure compatibility with existing features while enhancing maintainability and scalability. This refactor streamlines terminal operations and prepares the codebase for future enhancements. --- .../src/server/agent/llm-openai.ts | 367 +++---- .../src/server/agent/playwright-mcp.ts | 55 -- .../src/server/daemon/terminal-manager.ts | 294 ------ packages/voice-assistant/src/server/index.ts | 101 -- .../{ => server}/terminal-mcp/REFERENCES.md | 0 .../src/{ => server}/terminal-mcp/index.ts | 0 .../src/{ => server}/terminal-mcp/server.ts | 0 .../terminal-mcp/terminal-manager.ts | 0 .../server/{daemon => terminal-mcp}/tmux.ts | 0 .../voice-assistant/src/terminal-mcp/tmux.ts | 923 ------------------ 10 files changed, 128 insertions(+), 1612 deletions(-) delete mode 100644 packages/voice-assistant/src/server/agent/playwright-mcp.ts delete mode 100644 packages/voice-assistant/src/server/daemon/terminal-manager.ts rename packages/voice-assistant/src/{ => server}/terminal-mcp/REFERENCES.md (100%) rename packages/voice-assistant/src/{ => server}/terminal-mcp/index.ts (100%) rename packages/voice-assistant/src/{ => server}/terminal-mcp/server.ts (100%) rename packages/voice-assistant/src/{ => server}/terminal-mcp/terminal-manager.ts (100%) rename packages/voice-assistant/src/server/{daemon => terminal-mcp}/tmux.ts (100%) delete mode 100644 packages/voice-assistant/src/terminal-mcp/tmux.ts diff --git a/packages/voice-assistant/src/server/agent/llm-openai.ts b/packages/voice-assistant/src/server/agent/llm-openai.ts index 4d11e7370..4090020f6 100644 --- a/packages/voice-assistant/src/server/agent/llm-openai.ts +++ b/packages/voice-assistant/src/server/agent/llm-openai.ts @@ -1,216 +1,120 @@ -import { stepCountIs, streamText, tool, zodSchema } from "ai"; +import { stepCountIs, streamText, tool } from "ai"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { z } from "zod"; -import { - listTerminals, - createTerminal, - captureTerminal, - sendText, - sendKeys, - renameTerminal, - killTerminal, -} from "../daemon/terminal-manager.js"; import invariant from "tiny-invariant"; -import { getPlaywrightTools } from "./playwright-mcp.js"; -import { lazySchema } from "@ai-sdk/provider-utils"; +import { experimental_createMCPClient } from "ai"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { createTerminalMcpServer } from "../terminal-mcp/index.js"; /** - * Terminal tools using Vercel AI SDK tool() function - * These are automatically executed when the LLM calls them + * Singleton MCP clients */ -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: zodSchema(z.object({})), - execute: async () => { - const terminals = await listTerminals(); - return { terminals }; - }, - }), +let terminalMcpClient: Awaited< + ReturnType +> | null = null; - create_terminal: tool({ - 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: z.object({ - 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." - ), - }), - execute: async ({ name, workingDirectory, initialCommand }) => { - const terminal = await createTerminal({ - name, - workingDirectory, - initialCommand, - }); - return { terminal }; - }, - }), +let playwrightMcpClient: Awaited< + ReturnType +> | null = null; - capture_terminal: tool({ - 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: z.object({ - 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." - ), - }), - execute: async ({ terminalName, lines, maxWait }) => { - const output = await captureTerminal(terminalName, lines, maxWait); - return { output }; - }, - }), +/** + * Get or create Terminal MCP client (singleton) + */ +async function getTerminalMcpClient() { + if (terminalMcpClient) { + return terminalMcpClient; + } - send_text: tool({ - 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: z.object({ - 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." - ), - }), - execute: async ({ terminalName, text, pressEnter, return_output }) => { - const output = await sendText( - terminalName, - text, - pressEnter, - return_output - ); - return { output }; - }, - }), + // Create Terminal MCP server + const server = await createTerminalMcpServer({ sessionName: "voice-dev" }); - send_keys: tool({ - 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: z.object({ - 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." - ), - }), - execute: async ({ terminalName, keys, repeat, return_output }) => { - const output = await sendKeys(terminalName, keys, repeat, return_output); - return { output }; - }, - }), + // Create linked transport pair + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); - rename_terminal: tool({ - description: - "Rename a terminal to a more descriptive name. The new name must be unique among all terminals.", - inputSchema: z.object({ - 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." - ), - }), - execute: async ({ terminalName, newName }) => { - await renameTerminal(terminalName, newName); - return { success: true }; - }, - }), + // Connect server to its transport + await server.connect(serverTransport); - kill_terminal: tool({ - description: - "Close and destroy a terminal. This will terminate any running processes in the terminal. Use with caution.", - inputSchema: z.object({ - terminalName: z - .string() - .describe( - "Name of the terminal to kill. Get this from list_terminals." - ), - }), - execute: async ({ terminalName }) => { - await killTerminal(terminalName); - return { success: true }; - }, - }), + // Create client connected to the other side + terminalMcpClient = await experimental_createMCPClient({ + transport: clientTransport, + }); + console.log("Terminal MCP client initialized"); + + return terminalMcpClient; +} + +/** + * Get or create Playwright MCP client (singleton) + */ +async function getPlaywrightMcpClient() { + if (playwrightMcpClient) { + return playwrightMcpClient; + } + + const transport = new StdioClientTransport({ + command: "npx", + args: ["@playwright/mcp", "--image-responses", "omit"], + }); + + playwrightMcpClient = await experimental_createMCPClient({ + transport, + }); + + console.log("Playwright MCP client initialized"); + + return playwrightMcpClient; +} + +/** + * Cache for merged MCP tools + */ +let mcpToolsCache: Record | null = null; +let mcpToolsPromise: Promise> | null = null; + +async function getMcpTools(): Promise> { + if (mcpToolsCache) { + return mcpToolsCache; + } + + if (mcpToolsPromise) { + return mcpToolsPromise; + } + + mcpToolsPromise = (async () => { + const [terminalClient, playwrightClient] = await Promise.all([ + getTerminalMcpClient(), + getPlaywrightMcpClient().catch((error) => { + console.error("Failed to initialize Playwright MCP:", error); + return null; + }), + ]); + + const terminalTools = await terminalClient.tools(); + const playwrightTools = playwrightClient + ? await playwrightClient.tools() + : {}; + + const mergedTools = { + ...terminalTools, + ...playwrightTools, + }; + + mcpToolsCache = mergedTools; + console.log(`Loaded ${Object.keys(mergedTools).length} MCP tools`); + + return mergedTools; + })(); + + return mcpToolsPromise; +} + +/** + * Manual tools that aren't MCP-based + */ +const manualTools = { present_artifact: tool({ description: "Present an artifact (plan, diff, screenshot, etc.) to the user for review. Use this when you need to show information that's hard to convey via TTS, such as markdown plans, code diffs, or visual content", @@ -249,41 +153,6 @@ 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) - */ -let playwrightToolsCache: Record | null = null; -let playwrightToolsPromise: Promise> | null = null; - -async function getCachedPlaywrightTools(): Promise> { - if (playwrightToolsCache) { - return playwrightToolsCache; - } - - if (playwrightToolsPromise) { - return playwrightToolsPromise; - } - - playwrightToolsPromise = getPlaywrightTools().then((tools) => { - playwrightToolsCache = tools; - return tools; - }); - - return playwrightToolsPromise; -} - /** * Message interface for conversation */ @@ -326,11 +195,11 @@ export async function streamLLM(params: StreamLLMParams): Promise { apiKey: process.env.OPENROUTER_API_KEY, }); - // Merge terminal tools with Playwright MCP tools - const pwTools = await getCachedPlaywrightTools(); + // Get all MCP tools and merge with manual tools + const mcpTools = await getMcpTools(); const allTools = { - ...terminalTools, - ...pwTools, + ...mcpTools, + ...manualTools, }; const result = await streamText({ @@ -405,3 +274,23 @@ export async function streamLLM(params: StreamLLMParams): Promise { return fullText; } + +/** + * Cleanup function to close MCP clients + */ +export async function closeMcpClients() { + if (terminalMcpClient) { + await terminalMcpClient.close(); + terminalMcpClient = null; + console.log("Terminal MCP client closed"); + } + + if (playwrightMcpClient) { + await playwrightMcpClient.close(); + playwrightMcpClient = null; + console.log("Playwright MCP client closed"); + } + + mcpToolsCache = null; + mcpToolsPromise = null; +} diff --git a/packages/voice-assistant/src/server/agent/playwright-mcp.ts b/packages/voice-assistant/src/server/agent/playwright-mcp.ts deleted file mode 100644 index 83a5e06e7..000000000 --- a/packages/voice-assistant/src/server/agent/playwright-mcp.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { experimental_createMCPClient } from "ai"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; - -let playwrightMCPClient: Awaited< - ReturnType -> | null = null; - -/** - * Get or create the Playwright MCP client (singleton) - */ -export async function getPlaywrightMCPClient() { - if (playwrightMCPClient) { - return playwrightMCPClient; - } - - const transport = new StdioClientTransport({ - command: "npx", - args: ["@playwright/mcp", "--image-responses", "omit"], - }); - - playwrightMCPClient = await experimental_createMCPClient({ - transport, - }); - - console.log("Playwright MCP client initialized with Firefox"); - - return playwrightMCPClient; -} - -/** - * Get Playwright MCP tools in AI SDK format - */ -export async function getPlaywrightTools() { - try { - const client = await getPlaywrightMCPClient(); - const tools = await client.tools(); - console.log(`Loaded ${Object.keys(tools).length} Playwright MCP tools`); - return tools; - } catch (error) { - console.error("Failed to initialize Playwright MCP tools:", error); - // Return empty object to allow app to continue without Playwright - return {}; - } -} - -/** - * Cleanup function to close the MCP client - */ -export async function closePlaywrightMCP() { - if (playwrightMCPClient) { - await playwrightMCPClient.close(); - playwrightMCPClient = null; - console.log("Playwright MCP client closed"); - } -} diff --git a/packages/voice-assistant/src/server/daemon/terminal-manager.ts b/packages/voice-assistant/src/server/daemon/terminal-manager.ts deleted file mode 100644 index 45bcfc12b..000000000 --- a/packages/voice-assistant/src/server/daemon/terminal-manager.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { - findSessionByName, - createSession, - listWindows, - createWindow, - listPanes, - findWindowByName, - capturePaneContent, - sendText as tmuxSendText, - sendKeys as tmuxSendKeys, - renameWindow, - killWindow, - isWindowNameUnique, - getCurrentWorkingDirectory, - getCurrentCommand, - waitForPaneActivityToSettle, -} from "./tmux.js"; - -const DEFAULT_SESSION = "voice-dev"; - -// 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; -} - -/** - * Initialize the default "voice-dev" tmux session - * Creates it if it doesn't exist - */ -export async function initializeDefaultSession(): Promise { - const session = await findSessionByName(DEFAULT_SESSION); - - if (!session) { - await createSession(DEFAULT_SESSION); - } -} - -/** - * List all terminals in the voice-dev session - * Returns terminal info including name, active status, working directory, and current command - */ -export async function listTerminals(): Promise { - const session = await findSessionByName(DEFAULT_SESSION); - - if (!session) { - throw new Error( - `Session '${DEFAULT_SESSION}' not found. Call initializeDefaultSession() 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 - */ -export async function createTerminal( - params: CreateTerminalParams -): Promise { - const session = await findSessionByName(DEFAULT_SESSION); - - if (!session) { - throw new Error( - `Session '${DEFAULT_SESSION}' not found. Call initializeDefaultSession() 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 - */ -export async function captureTerminal( - terminalName: string, - lines: number = 200, - maxWait?: number -): Promise { - const session = await findSessionByName(DEFAULT_SESSION); - if (!session) { - throw new Error(`Session '${DEFAULT_SESSION}' 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 - */ -export async function sendText( - terminalName: string, - text: string, - pressEnter: boolean = false, - return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number } -): Promise { - const session = await findSessionByName(DEFAULT_SESSION); - if (!session) { - throw new Error(`Session '${DEFAULT_SESSION}' 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 - */ -export async function sendKeys( - terminalName: string, - keys: string, - repeat: number = 1, - return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number } -): Promise { - const session = await findSessionByName(DEFAULT_SESSION); - if (!session) { - throw new Error(`Session '${DEFAULT_SESSION}' 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 - */ -export async function renameTerminal( - terminalName: string, - newName: string -): Promise { - const session = await findSessionByName(DEFAULT_SESSION); - - if (!session) { - throw new Error(`Session '${DEFAULT_SESSION}' not found.`); - } - - // renameWindow handles uniqueness validation and name resolution internally - await renameWindow(session.id, terminalName, newName); -} - -/** - * Kill (close/destroy) a terminal by name - */ -export async function killTerminal(terminalName: string): Promise { - const session = await findSessionByName(DEFAULT_SESSION); - - if (!session) { - throw new Error(`Session '${DEFAULT_SESSION}' 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/server/index.ts b/packages/voice-assistant/src/server/index.ts index 6b4ac01b9..93747dd2c 100644 --- a/packages/voice-assistant/src/server/index.ts +++ b/packages/voice-assistant/src/server/index.ts @@ -9,14 +9,6 @@ import { createServer as createViteServer } from "vite"; import type { ViteDevServer } from "vite"; import type { ServerConfig } from "./types.js"; import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; -import { - initializeDefaultSession, - listTerminals, - createTerminal, - sendText, - captureTerminal, - killTerminal, -} from "./daemon/terminal-manager.js"; import { initializeSTT, transcribeAudio } from "./agent/stt-openai.js"; import { initializeTTS } from "./agent/tts-openai.js"; import { @@ -38,91 +30,6 @@ async function createServer(httpServer: HttpServer, config: ServerConfig) { res.json({ status: "ok", timestamp: new Date().toISOString() }); }); - // Test endpoint for LLM integration (removed - using orchestrator now) - - // Test endpoint for terminal operations - app.get("/api/test-terminal", async (_req, res) => { - try { - const testResults: string[] = []; - - // 1. Initialize default session - testResults.push("1. Initializing default session..."); - await initializeDefaultSession(); - testResults.push(" ✓ Session initialized"); - - // 2. List existing terminals - testResults.push("2. Listing existing terminals..."); - const beforeTerminals = await listTerminals(); - testResults.push( - ` ✓ Found ${beforeTerminals.length} existing terminals` - ); - - // 3. Create a test terminal - testResults.push("3. Creating test terminal..."); - const terminal = await createTerminal({ - name: "test-terminal", - workingDirectory: "~", - initialCommand: undefined, - }); - testResults.push( - ` ✓ Created terminal: ${terminal.name}` - ); - - // 4. Send a command to the terminal - testResults.push('4. Sending command "echo hello world"...'); - await sendText(terminal.name, 'echo "hello world"', true, { - lines: 20, - }); - testResults.push(" ✓ Command sent"); - - // 5. Capture output - testResults.push("5. Capturing terminal output..."); - const output = await captureTerminal(terminal.name, 20); - testResults.push(` ✓ Captured ${output.split("\n").length} lines`); - testResults.push(` Output preview: ${output.substring(0, 100)}...`); - - // 6. List terminals again - testResults.push("6. Listing terminals again..."); - const afterTerminals = await listTerminals(); - testResults.push(` ✓ Found ${afterTerminals.length} terminals`); - const testTerminal = afterTerminals.find( - (t) => t.name === "test-terminal" - ); - if (testTerminal) { - testResults.push(` ✓ Found test-terminal`); - } - - // 7. Kill the test terminal - testResults.push("7. Killing test terminal..."); - await killTerminal(terminal.name); - testResults.push(" ✓ Terminal killed"); - - // 8. Verify it's gone - testResults.push("8. Verifying terminal was removed..."); - const finalTerminals = await listTerminals(); - const stillExists = finalTerminals.find( - (t) => t.name === "test-terminal" - ); - if (!stillExists) { - testResults.push(" ✓ Terminal successfully removed"); - } else { - testResults.push(" ✗ Terminal still exists!"); - } - - res.json({ - success: true, - message: "All terminal operations completed successfully", - results: testResults, - }); - } catch (error: any) { - res.status(500).json({ - success: false, - error: error.message, - stack: error.stack, - }); - } - }); - let vite: ViteDevServer | undefined; if (config.isDev) { @@ -320,14 +227,6 @@ async function main() { cleanupConversations(60); // Clean up conversations older than 60 minutes }, 10 * 60 * 1000); - // Initialize default tmux session for terminal control - try { - await initializeDefaultSession(); - console.log("✓ Default tmux session initialized"); - } catch (error) { - console.error("Failed to initialize tmux session:", error); - } - httpServer.listen(port, () => { console.log( `\n✓ Voice Assistant server running on http://localhost:${port}` diff --git a/packages/voice-assistant/src/terminal-mcp/REFERENCES.md b/packages/voice-assistant/src/server/terminal-mcp/REFERENCES.md similarity index 100% rename from packages/voice-assistant/src/terminal-mcp/REFERENCES.md rename to packages/voice-assistant/src/server/terminal-mcp/REFERENCES.md diff --git a/packages/voice-assistant/src/terminal-mcp/index.ts b/packages/voice-assistant/src/server/terminal-mcp/index.ts similarity index 100% rename from packages/voice-assistant/src/terminal-mcp/index.ts rename to packages/voice-assistant/src/server/terminal-mcp/index.ts diff --git a/packages/voice-assistant/src/terminal-mcp/server.ts b/packages/voice-assistant/src/server/terminal-mcp/server.ts similarity index 100% rename from packages/voice-assistant/src/terminal-mcp/server.ts rename to packages/voice-assistant/src/server/terminal-mcp/server.ts diff --git a/packages/voice-assistant/src/terminal-mcp/terminal-manager.ts b/packages/voice-assistant/src/server/terminal-mcp/terminal-manager.ts similarity index 100% rename from packages/voice-assistant/src/terminal-mcp/terminal-manager.ts rename to packages/voice-assistant/src/server/terminal-mcp/terminal-manager.ts diff --git a/packages/voice-assistant/src/server/daemon/tmux.ts b/packages/voice-assistant/src/server/terminal-mcp/tmux.ts similarity index 100% rename from packages/voice-assistant/src/server/daemon/tmux.ts rename to packages/voice-assistant/src/server/terminal-mcp/tmux.ts diff --git a/packages/voice-assistant/src/terminal-mcp/tmux.ts b/packages/voice-assistant/src/terminal-mcp/tmux.ts deleted file mode 100644 index d23495e05..000000000 --- a/packages/voice-assistant/src/terminal-mcp/tmux.ts +++ /dev/null @@ -1,923 +0,0 @@ -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); - } - } -}