UNPKG

interactive-mcp-enhanced

Version:

Enhanced MCP Server for interactive communication between LLMs and users with custom sound notifications, approval workflows, clipboard support, and improved user experience.

275 lines (274 loc) 14.9 kB
import { spawn } from 'child_process'; import path from 'path'; import { fileURLToPath } from 'url'; import fsPromises from 'fs/promises'; import { watch } from 'fs'; import os from 'os'; import crypto from 'crypto'; // Updated import to use @ alias import { USER_INPUT_TIMEOUT_SECONDS } from '../../constants.js'; // Import the constant import logger from '../../utils/logger.js'; // Get the directory name of the current module const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * Bring the most recently created Terminal window to front (macOS only) */ async function bringTerminalToFront() { const platform = os.platform(); if (platform === 'darwin') { try { // Use AppleScript to activate the frontmost Terminal window specifically const { spawn } = await import('child_process'); const activateCommand = `osascript -e 'tell application "Terminal" to set frontmost of window 1 to true' -e 'tell application "Terminal" to activate'`; spawn(activateCommand, [], { stdio: ['ignore', 'ignore', 'ignore'], shell: true, detached: true, }).unref(); } catch (error) { // Silently ignore errors - window activation is a nice-to-have feature logger.error('Failed to bring Terminal to front:', error); } } // For Windows and Linux, we could potentially implement similar functionality // but it's more complex and platform-specific } // Define cleanupResources outside the promise to be accessible in the final catch async function cleanupResources(heartbeatPath, responsePath, optionsPath) { await Promise.allSettled([ fsPromises.unlink(responsePath).catch(() => { }), fsPromises.unlink(heartbeatPath).catch(() => { }), fsPromises.unlink(optionsPath).catch(() => { }), // Cleanup options file // Potentially add cleanup for other session-related files if needed ]); } /** * Display a command window with a prompt and return user input * @param projectName Name of the project requesting input (used for title) * @param promptMessage Message to display to the user * @param timeoutSeconds Timeout in seconds * @param showCountdown Whether to show a countdown timer * @param predefinedOptions Optional list of predefined options for quick selection * @returns User input or empty string if timeout */ export async function getCmdWindowInput(projectName, promptMessage, timeoutSeconds = USER_INPUT_TIMEOUT_SECONDS, // Use constant as default showCountdown = true, predefinedOptions) { // Create a temporary file for the detached process to write to const sessionId = crypto.randomBytes(8).toString('hex'); const tempDir = os.tmpdir(); const tempFilePath = path.join(tempDir, `cmd-ui-response-${sessionId}.txt`); const heartbeatFilePath = path.join(tempDir, `cmd-ui-heartbeat-${sessionId}.txt`); const optionsFilePath = path.join(tempDir, `cmd-ui-options-${sessionId}.json`); // New options file path return new Promise((resolve) => { // Wrap the async setup logic in an IIFE void (async () => { // Path to the UI script (will be in the same directory after compilation) const uiScriptPath = path.join(__dirname, 'ui.js'); // Gather options const options = { projectName, prompt: promptMessage, timeout: timeoutSeconds, showCountdown, sessionId, outputFile: tempFilePath, heartbeatFile: heartbeatFilePath, // Pass heartbeat file path too predefinedOptions, }; let ui; // Moved setup into try block try { // Write options to the file before spawning await fsPromises.writeFile(optionsFilePath, JSON.stringify(options), 'utf8'); // Platform-specific spawning const platform = os.platform(); if (platform === 'darwin') { // macOS const escapedScriptPath = uiScriptPath; const escapedSessionId = sessionId; // Only need sessionId now // Construct the command string directly for the shell. Quotes handle paths with spaces. // Pass only the sessionId const nodeCommand = `exec node "${escapedScriptPath}" "${escapedSessionId}" "${tempDir}"; exit 0`; // Escape the node command for osascript's AppleScript string: const escapedNodeCommand = nodeCommand .replace(/\\/g, '\\\\') // Escape backslashes .replace(/"/g, '\\"'); // Escape double quotes // Use do script which creates and activates the window const command = `osascript -e 'tell application "Terminal" to do script "${escapedNodeCommand}"'`; const commandArgs = []; ui = spawn(command, commandArgs, { stdio: ['ignore', 'ignore', 'ignore'], shell: true, detached: true, }); } else if (platform === 'win32') { // Windows // Pass only the sessionId ui = spawn('node', [uiScriptPath, sessionId], { stdio: ['ignore', 'ignore', 'ignore'], shell: true, detached: true, windowsHide: false, }); } else { // Linux or other // Pass only the sessionId ui = spawn('node', [uiScriptPath, sessionId], { stdio: ['ignore', 'ignore', 'ignore'], shell: true, detached: true, }); } // Add a small delay then focus just the frontmost Terminal window if (platform === 'darwin') { setTimeout(async () => { try { const { spawn } = await import('child_process'); // Focus the frontmost Terminal window without activating the entire app const focusCommand = `osascript -e 'tell application "System Events" to tell process "Terminal" to set frontmost to true'`; spawn(focusCommand, [], { stdio: ['ignore', 'ignore', 'ignore'], shell: true, detached: true, }).unref(); } catch (error) { logger.error('Failed to focus Terminal window:', error); } }, 200); } let watcher = null; let timeoutHandle = null; let heartbeatInterval = null; let heartbeatFileSeen = false; // Track if we've ever seen the heartbeat file const startTime = Date.now(); // Record start time for initial grace period // Define cleanupAndResolve inside the promise scope const cleanupAndResolve = async (response) => { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } if (watcher) { watcher.close(); watcher = null; } if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } // Pass optionsFilePath to cleanupResources await cleanupResources(heartbeatFilePath, tempFilePath, optionsFilePath); resolve(response); }; // Listen for process exit events - moved definition before IIFE start const handleExit = (code) => { // If the process exited with a non-zero code and watcher/timeout still exist if (code !== 0 && (watcher || timeoutHandle)) { void cleanupAndResolve(''); } }; const handleError = () => { if (watcher || timeoutHandle) { // Only cleanup if not already cleaned up void cleanupAndResolve(''); } }; ui.on('exit', handleExit); ui.on('error', handleError); // Unref the child process so the parent can exit independently ui.unref(); // Create an empty temp file before watching for user response await fsPromises.writeFile(tempFilePath, '', 'utf8'); // Use renamed import // Wait briefly for the heartbeat file to potentially be created await new Promise((res) => setTimeout(res, 500)); // Watch for content being written to the temp file watcher = watch(tempFilePath, (eventType) => { // Removed async if (eventType === 'change') { // Read the response and cleanup // Use an async IIFE inside the non-async callback void (async () => { try { const data = await fsPromises.readFile(tempFilePath, 'utf8'); // Use renamed import if (data) { const response = data.trim(); void cleanupAndResolve(response); // Mark promise as intentionally ignored } } catch (readError) { logger.error('Error reading response file:', readError); void cleanupAndResolve(''); // Cleanup on read error } })(); } }); // Start heartbeat check interval heartbeatInterval = setInterval(() => { // Removed async // Use an async IIFE inside the non-async callback void (async () => { try { const stats = await fsPromises.stat(heartbeatFilePath); // Use renamed import const now = Date.now(); // If file hasn't been modified in the last 3 seconds, assume dead if (now - stats.mtime.getTime() > 3000) { logger.info(`Heartbeat file ${heartbeatFilePath} hasn't been updated recently. Process likely exited.`); void cleanupAndResolve(''); // Mark promise as intentionally ignored } else { heartbeatFileSeen = true; // Mark that we've seen the file } } catch (err) { // Type err as unknown // Check if err is an error object with a code property if (err && typeof err === 'object' && 'code' in err) { const error = err; // Type assertion if (error.code === 'ENOENT') { // File not found if (heartbeatFileSeen) { // File existed before but is now gone, assume dead logger.info(`Heartbeat file ${heartbeatFilePath} not found after being seen. Process likely exited.`); void cleanupAndResolve(''); // Mark promise as intentionally ignored } else if (Date.now() - startTime > 7000) { // File never appeared and initial grace period (7s) passed, assume dead logger.info(`Heartbeat file ${heartbeatFilePath} never appeared. Process likely failed to start.`); void cleanupAndResolve(''); // Mark promise as intentionally ignored } // Otherwise, file just hasn't appeared yet, wait longer } else { // Removed check for !== 'ENOENT' as it's implied // Log other errors and resolve logger.error('Heartbeat check error:', error); void cleanupAndResolve(''); // Resolve immediately on other errors? Marked promise as intentionally ignored } } else { // Handle cases where err is not an object with a code property logger.error('Unexpected heartbeat check error:', err); void cleanupAndResolve(''); // Mark promise as intentionally ignored } } })(); }, 1500); // Check every 1.5 seconds // Timeout to stop watching if no response within limit timeoutHandle = setTimeout(() => { logger.info(`Input timeout reached after ${timeoutSeconds} seconds.`); // Added logger info void cleanupAndResolve(''); // Mark promise as intentionally ignored }, timeoutSeconds * 1000 + 5000); // Add a bit more buffer } catch (setupError) { logger.error('Error during cmd-input setup:', setupError); // Ensure cleanup happens even if setup fails // Pass optionsFilePath to cleanupResources await cleanupResources(heartbeatFilePath, tempFilePath, optionsFilePath); resolve(''); // Resolve with empty string after attempting cleanup } })(); // Execute the IIFE }); }