From ab7bf27d03cf09ff5e770dd0e43e536220f7f7a7 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Mon, 12 Jan 2026 07:33:05 +0000 Subject: [PATCH] fix(ssh): stabilize and complete SSH remote execution support across wizard, IPC, and agent detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added full SSH remote support to the wizard, including propagation of SSH config through all phases - Ensured SSH config flows correctly through IPC bridge (renderer → preload → main) - Improved agent detection with optional sshRemoteId and remote `which` resolution - Fixed SSH config transfer during manual agent creation and auto-selection flows - Added extensive debugging and structured logging for SSH execution, agent availability, and wizard message flow - Improved stdin/JSON streaming for large prompts and remote execution - Added input/output JSON stream handling and enhanced logging for SSH transport - Added sourcing of bash profiles to correctly resolve claude-code binary paths on remote hosts --- src/main/ipc/handlers/agents.ts | 122 +++++++- src/main/ipc/handlers/process.ts | 243 +++++++++------- src/main/utils/ssh-command-builder.ts | 66 +++-- src/renderer/components/NewInstanceModal.tsx | 45 +-- .../Wizard/screens/ConversationScreen.tsx | 28 +- .../Wizard/services/conversationManager.ts | 270 +++++++++++------- .../Wizard/services/phaseGenerator.ts | 111 +++---- 7 files changed, 550 insertions(+), 335 deletions(-) diff --git a/src/main/ipc/handlers/agents.ts b/src/main/ipc/handlers/agents.ts index 90270abb..9ef657bc 100644 --- a/src/main/ipc/handlers/agents.ts +++ b/src/main/ipc/handlers/agents.ts @@ -305,17 +305,117 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void { }) ); - // Get a specific agent by ID - ipcMain.handle( - 'agents:get', - withIpcErrorLogging(handlerOpts('get'), async (agentId: string) => { - const agentDetector = requireDependency(getAgentDetector, 'Agent detector'); - logger.debug(`Getting agent: ${agentId}`, LOG_CONTEXT); - const agent = await agentDetector.getAgent(agentId); - // Strip argBuilder functions before sending over IPC - return stripAgentFunctions(agent); - }) - ); + // Get a specific agent by ID (supports SSH remote detection via optional sshRemoteId) + ipcMain.handle( + 'agents:get', + withIpcErrorLogging(handlerOpts('get'), async (agentId: string, sshRemoteId?: string) => { + logger.debug(`Getting agent: ${agentId}`, LOG_CONTEXT, { sshRemoteId }); + + // If SSH remote ID provided, detect agent on remote host + if (sshRemoteId) { + const sshConfig = getSshRemoteById(settingsStore, sshRemoteId); + if (!sshConfig) { + logger.warn(`SSH remote not found or disabled: ${sshRemoteId}`, LOG_CONTEXT); + // Return the agent definition with unavailable status + const agentDef = AGENT_DEFINITIONS.find((a) => a.id === agentId); + if (!agentDef) { + throw new Error(`Unknown agent: ${agentId}`); + } + return stripAgentFunctions({ + ...agentDef, + available: false, + path: undefined, + capabilities: getAgentCapabilities(agentDef.id), + error: `SSH remote configuration not found: ${sshRemoteId}`, + }); + } + + logger.info(`Getting agent ${agentId} on remote host: ${sshConfig.host}`, LOG_CONTEXT); + + // Find the agent definition + const agentDef = AGENT_DEFINITIONS.find((a) => a.id === agentId); + if (!agentDef) { + throw new Error(`Unknown agent: ${agentId}`); + } + + // Build SSH command to check for the binary using 'which' + const remoteOptions: RemoteCommandOptions = { + command: 'which', + args: [agentDef.binaryName], + }; + + try { + const sshCommand = await buildSshCommand(sshConfig, remoteOptions); + logger.info(`Executing SSH detection command for '${agentDef.binaryName}'`, LOG_CONTEXT, { + command: sshCommand.command, + args: sshCommand.args, + }); + + // Execute with timeout + const SSH_TIMEOUT_MS = 10000; + const resultPromise = execFileNoThrow(sshCommand.command, sshCommand.args); + const timeoutPromise = new Promise<{ exitCode: number; stdout: string; stderr: string }>((_, reject) => { + setTimeout(() => reject(new Error(`SSH connection timed out after ${SSH_TIMEOUT_MS / 1000}s`)), SSH_TIMEOUT_MS); + }); + + const result = await Promise.race([resultPromise, timeoutPromise]); + + logger.info(`SSH command result for '${agentDef.binaryName}'`, LOG_CONTEXT, { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }); + + // Check for SSH connection errors + let connectionError: string | undefined; + if (result.stderr && ( + result.stderr.includes('Connection refused') || + result.stderr.includes('Connection timed out') || + result.stderr.includes('No route to host') || + result.stderr.includes('Could not resolve hostname') || + result.stderr.includes('Permission denied') + )) { + connectionError = result.stderr.trim().split('\n')[0]; + logger.warn(`SSH connection error for ${sshConfig.host}: ${connectionError}`, LOG_CONTEXT); + } + + // Strip ANSI/OSC escape sequences from output + const cleanedOutput = stripAnsi(result.stdout); + const available = result.exitCode === 0 && cleanedOutput.trim().length > 0; + const path = available ? cleanedOutput.trim().split('\n')[0] : undefined; + + if (available) { + logger.info(`Agent "${agentDef.name}" found on remote at: ${path}`, LOG_CONTEXT); + } else { + logger.debug(`Agent "${agentDef.name}" not found on remote`, LOG_CONTEXT); + } + + return stripAgentFunctions({ + ...agentDef, + available, + path, + capabilities: getAgentCapabilities(agentDef.id), + error: connectionError, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn(`Failed to check agent "${agentDef.name}" on remote: ${errorMessage}`, LOG_CONTEXT); + return stripAgentFunctions({ + ...agentDef, + available: false, + capabilities: getAgentCapabilities(agentDef.id), + error: `Failed to connect: ${errorMessage}`, + }); + } + } + + // Local detection + const agentDetector = requireDependency(getAgentDetector, 'Agent detector'); + const agent = await agentDetector.getAgent(agentId); + // Strip argBuilder functions before sending over IPC + return stripAgentFunctions(agent); + }) + ); // Get capabilities for a specific agent ipcMain.handle( diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index 77aeb4be..3e6659f0 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -20,6 +20,7 @@ import { buildSshCommand } from '../../utils/ssh-command-builder'; import type { SshRemoteConfig } from '../../../shared/types'; import { powerManager } from '../../power-manager'; import { MaestroSettings } from './persistence'; +import { getAgentCapabilities } from '../../agent-capabilities'; const LOG_CONTEXT = '[ProcessManager]'; @@ -108,42 +109,46 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void const processManager = requireProcessManager(getProcessManager); const agentDetector = requireDependency(getAgentDetector, 'Agent detector'); - // Get agent definition to access config options and argument builders - const agent = await agentDetector.getAgent(config.toolType); - // Use INFO level on Windows for better visibility in logs - const isWindows = process.platform === 'win32'; - const logFn = isWindows ? logger.info.bind(logger) : logger.debug.bind(logger); - logFn(`Spawn config received`, LOG_CONTEXT, { - platform: process.platform, - configToolType: config.toolType, - configCommand: config.command, - agentId: agent?.id, - agentCommand: agent?.command, - agentPath: agent?.path, - agentPathExtension: agent?.path ? require('path').extname(agent.path) : 'none', - hasAgentSessionId: !!config.agentSessionId, - hasPrompt: !!config.prompt, - promptLength: config.prompt?.length, - // On Windows, show prompt preview to help debug truncation issues - promptPreview: - config.prompt && isWindows - ? { - first50: config.prompt.substring(0, 50), - last50: config.prompt.substring(Math.max(0, config.prompt.length - 50)), - containsHash: config.prompt.includes('#'), - containsNewline: config.prompt.includes('\n'), - } - : undefined, - }); - let finalArgs = buildAgentArgs(agent, { - baseArgs: config.args, - prompt: config.prompt, - cwd: config.cwd, - readOnlyMode: config.readOnlyMode, - modelId: config.modelId, - yoloMode: config.yoloMode, - agentSessionId: config.agentSessionId, - }); + // Get agent definition to access config options and argument builders + const agent = await agentDetector.getAgent(config.toolType); + // Use INFO level on Windows for better visibility in logs + const isWindows = process.platform === 'win32'; + const logFn = isWindows ? logger.info.bind(logger) : logger.debug.bind(logger); + logFn(`Spawn config received`, LOG_CONTEXT, { + platform: process.platform, + configToolType: config.toolType, + configCommand: config.command, + agentId: agent?.id, + agentCommand: agent?.command, + agentPath: agent?.path, + agentPathExtension: agent?.path ? require('path').extname(agent.path) : 'none', + hasAgentSessionId: !!config.agentSessionId, + hasPrompt: !!config.prompt, + promptLength: config.prompt?.length, + // On Windows, show prompt preview to help debug truncation issues + promptPreview: config.prompt && isWindows ? { + first50: config.prompt.substring(0, 50), + last50: config.prompt.substring(Math.max(0, config.prompt.length - 50)), + containsHash: config.prompt.includes('#'), + containsNewline: config.prompt.includes('\n'), + } : undefined, + // SSH remote config logging + hasSessionSshRemoteConfig: !!config.sessionSshRemoteConfig, + sessionSshRemoteConfig: config.sessionSshRemoteConfig ? { + enabled: config.sessionSshRemoteConfig.enabled, + remoteId: config.sessionSshRemoteConfig.remoteId, + hasWorkingDirOverride: !!config.sessionSshRemoteConfig.workingDirOverride, + } : null, + }); + let finalArgs = buildAgentArgs(agent, { + baseArgs: config.args, + prompt: config.prompt, + cwd: config.cwd, + readOnlyMode: config.readOnlyMode, + modelId: config.modelId, + yoloMode: config.yoloMode, + agentSessionId: config.agentSessionId, + }); // ======================================================================== // Apply agent config options and session overrides @@ -252,21 +257,31 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void }); } - // ======================================================================== - // SSH Remote Execution: Detect and wrap command for remote execution - // Terminal sessions are always local (they need PTY for shell interaction) - // ======================================================================== - let sshRemoteUsed: SshRemoteConfig | null = null; + // ======================================================================== + // SSH Remote Execution: Detect and wrap command for remote execution + // Terminal sessions are always local (they need PTY for shell interaction) + // ======================================================================== + let sshRemoteUsed: SshRemoteConfig | null = null; + let shouldSendPromptViaStdin = false; - // Only consider SSH remote for non-terminal AI agent sessions - // SSH is session-level ONLY - no agent-level or global defaults - if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig) { - // Session-level SSH config provided - resolve and use it - logger.debug(`Using session-level SSH config`, LOG_CONTEXT, { - sessionId: config.sessionId, - enabled: config.sessionSshRemoteConfig.enabled, - remoteId: config.sessionSshRemoteConfig.remoteId, - }); + // Only consider SSH remote for non-terminal AI agent sessions + // SSH is session-level ONLY - no agent-level or global defaults + // Log SSH evaluation on Windows for debugging + if (isWindows) { + logger.info(`Evaluating SSH remote config`, LOG_CONTEXT, { + toolType: config.toolType, + isTerminal: config.toolType === 'terminal', + hasSessionSshRemoteConfig: !!config.sessionSshRemoteConfig, + willUseSsh: config.toolType !== 'terminal' && !!config.sessionSshRemoteConfig, + }); + } + if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig) { + // Session-level SSH config provided - resolve and use it + logger.info(`Using session-level SSH config`, LOG_CONTEXT, { + sessionId: config.sessionId, + enabled: config.sessionSshRemoteConfig.enabled, + remoteId: config.sessionSshRemoteConfig.remoteId, + }); // Resolve effective SSH remote configuration const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore); @@ -278,19 +293,31 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // SSH remote is configured - wrap the command for remote execution sshRemoteUsed = sshResult.config; - // For SSH execution, we need to include the prompt in the args here - // because ProcessManager.spawn() won't add it (we pass prompt: undefined for SSH) - // Use promptArgs if available (e.g., OpenCode -p), otherwise use positional arg - let sshArgs = finalArgs; - if (config.prompt) { - if (agent?.promptArgs) { - sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)]; - } else if (agent?.noPromptSeparator) { - sshArgs = [...finalArgs, config.prompt]; - } else { - sshArgs = [...finalArgs, '--', config.prompt]; - } - } + // For SSH execution with stream-json capable agents (like Claude Code), + // we send the prompt via stdin instead of as a CLI arg to avoid command + // line length limits and escaping issues. For other agents, we include + // the prompt in the args as before. + const capabilities = getAgentCapabilities(config.toolType); + let sshArgs = finalArgs; + + if (config.prompt) { + // If agent supports stream-json input, send prompt via stdin + // ProcessManager will detect this and send the prompt as a JSON message + if (capabilities.supportsStreamJsonInput) { + shouldSendPromptViaStdin = true; + // Add --input-format stream-json flag so Claude knows to read from stdin + sshArgs = [...finalArgs, '--input-format', 'stream-json']; + } else { + // For agents that don't support stream-json, add prompt to args + if (agent?.promptArgs) { + sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)]; + } else if (agent?.noPromptSeparator) { + sshArgs = [...finalArgs, config.prompt]; + } else { + sshArgs = [...finalArgs, '--', config.prompt]; + } + } + } // Build the SSH command that wraps the agent execution // @@ -312,51 +339,53 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void commandToSpawn = sshCommand.command; argsToSpawn = sshCommand.args; - logger.info(`SSH remote execution configured`, LOG_CONTEXT, { - sessionId: config.sessionId, - toolType: config.toolType, - remoteName: sshResult.config.name, - remoteHost: sshResult.config.host, - source: sshResult.source, - localCommand: config.command, - remoteCommand: remoteCommand, - customPath: config.sessionCustomPath || null, - hasCustomEnvVars: - !!effectiveCustomEnvVars && Object.keys(effectiveCustomEnvVars).length > 0, - sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`, - }); - } - } + logger.info(`SSH remote execution configured`, LOG_CONTEXT, { + sessionId: config.sessionId, + toolType: config.toolType, + remoteName: sshResult.config.name, + remoteHost: sshResult.config.host, + source: sshResult.source, + localCommand: config.command, + remoteCommand: remoteCommand, + customPath: config.sessionCustomPath || null, + hasCustomEnvVars: !!effectiveCustomEnvVars && Object.keys(effectiveCustomEnvVars).length > 0, + sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`, + promptViaStdin: shouldSendPromptViaStdin, + }); + } + } - const result = processManager.spawn({ - ...config, - command: commandToSpawn, - args: argsToSpawn, - // When using SSH, use user's home directory as local cwd - // The remote working directory is embedded in the SSH command itself - // This fixes ENOENT errors when session.cwd is a remote-only path - cwd: sshRemoteUsed ? os.homedir() : config.cwd, - // When using SSH, disable PTY (SSH provides its own terminal handling) - // and env vars are passed via the remote command string - requiresPty: sshRemoteUsed ? false : agent?.requiresPty, - // When using SSH, the prompt was already added to sshArgs above before - // building the SSH command, so don't let ProcessManager add it again - prompt: sshRemoteUsed ? undefined : config.prompt, - shell: shellToUse, - shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions) - shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions) - contextWindow, // Pass configured context window to process manager - // When using SSH, env vars are passed in the remote command string, not locally - customEnvVars: sshRemoteUsed ? undefined : effectiveCustomEnvVars, - imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode) - promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode) - noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt - // Stats tracking: use cwd as projectPath if not explicitly provided - projectPath: config.cwd, - // SSH remote context (for SSH-specific error messages) - sshRemoteId: sshRemoteUsed?.id, - sshRemoteHost: sshRemoteUsed?.host, - }); + const result = processManager.spawn({ + ...config, + command: commandToSpawn, + args: argsToSpawn, + // When using SSH, use user's home directory as local cwd + // The remote working directory is embedded in the SSH command itself + // This fixes ENOENT errors when session.cwd is a remote-only path + cwd: sshRemoteUsed ? os.homedir() : config.cwd, + // When using SSH, disable PTY (SSH provides its own terminal handling) + // and env vars are passed via the remote command string + requiresPty: sshRemoteUsed ? false : agent?.requiresPty, + // When using SSH with stream-json capable agents, pass the prompt so + // ProcessManager can send it via stdin. For other SSH cases, prompt was + // already added to sshArgs, so pass undefined to prevent double-adding. + prompt: sshRemoteUsed && shouldSendPromptViaStdin ? config.prompt : sshRemoteUsed ? undefined : config.prompt, + shell: shellToUse, + shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions) + shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions) + contextWindow, // Pass configured context window to process manager + // When using SSH, env vars are passed in the remote command string, not locally + customEnvVars: sshRemoteUsed ? undefined : effectiveCustomEnvVars, + imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode) + promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode) + noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt + // Stats tracking: use cwd as projectPath if not explicitly provided + projectPath: config.cwd, + // SSH remote context (for SSH-specific error messages) + sshRemoteConfig: sshRemoteUsed, + sshRemoteId: sshRemoteUsed?.id, + sshRemoteHost: sshRemoteUsed?.host, + }); logger.info(`Process spawned successfully`, LOG_CONTEXT, { sessionId: config.sessionId, diff --git a/src/main/utils/ssh-command-builder.ts b/src/main/utils/ssh-command-builder.ts index 82463c3b..6dc2252e 100644 --- a/src/main/utils/ssh-command-builder.ts +++ b/src/main/utils/ssh-command-builder.ts @@ -237,37 +237,41 @@ export async function buildSshCommand( env: Object.keys(mergedEnv).length > 0 ? mergedEnv : undefined, }); - // Wrap the command to execute via the user's login shell. - // $SHELL -ilc ensures the user's full PATH (including homebrew, nvm, etc.) is available. - // -i forces interactive mode (critical for .bashrc to not bail out) - // -l loads login profile for PATH - // -c executes the command - // Using $SHELL respects the user's configured shell (bash, zsh, etc.) - // - // WHY -i IS CRITICAL: - // On Ubuntu (and many Linux distros), .bashrc has a guard at the top: - // case $- in *i*) ;; *) return;; esac - // This checks if the shell is interactive before running. Without -i, - // .bashrc exits early and user PATH additions (like ~/.local/bin) never load. - // The -i flag sets 'i' in $-, allowing .bashrc to run fully. - // - // CRITICAL: When Node.js spawn() passes this to SSH without shell:true, SSH runs - // the command through the remote's default shell. The key is escaping: - // 1. Double quotes around the command are NOT escaped - they delimit the -c argument - // 2. $ signs inside the command MUST be escaped as \$ so they defer to the login shell - // (shellEscapeForDoubleQuotes handles this) - // 3. Single quotes inside the command pass through unchanged - // - // Example transformation for spawn(): - // Input: cd '/path' && MYVAR='value' claude --print - // After escaping: cd '/path' && MYVAR='value' claude --print (no $ to escape here) - // Wrapped: $SHELL -ilc "cd '/path' && MYVAR='value' claude --print" - // SSH receives this as one argument, passes to remote shell - // Remote shell expands $SHELL, executes: /bin/zsh -ilc "cd '/path' ..." - // The login shell runs with full PATH from ~/.zprofile AND ~/.bashrc - const escapedCommand = shellEscapeForDoubleQuotes(remoteCommand); - const wrappedCommand = `$SHELL -ilc "${escapedCommand}"`; - args.push(wrappedCommand); + // Wrap the command to execute via the user's login shell. + // We use bash -lc to ensure the user's full PATH (including homebrew, nvm, etc.) is available. + // -l loads login profile for PATH (sources /etc/profile, ~/.bash_profile, etc.) + // -c executes the command + // + // IMPORTANT: We don't use -i (interactive) flag because: + // 1. It can cause issues with shells that check if stdin is a TTY + // 2. When using -tt flag, the shell already thinks it's interactive enough + // 3. On Ubuntu, we work around the "case $- in *i*)" guard by sourcing ~/.bashrc explicitly + // + // For bash specifically, we source ~/.bashrc to ensure user PATH additions are loaded. + // This handles the common Ubuntu pattern where .bashrc has: + // case $- in *i*) ;; *) return;; esac + // By explicitly sourcing it, we bypass this guard. + // + // CRITICAL: When Node.js spawn() passes this to SSH without shell:true, SSH runs + // the command through the remote's default shell. The key is escaping: + // 1. Double quotes around the command are NOT escaped - they delimit the -c argument + // 2. $ signs inside the command MUST be escaped as \$ so they defer to the login shell + // (shellEscapeForDoubleQuotes handles this) + // 3. Single quotes inside the command pass through unchanged + // + // Example transformation for spawn(): + // Input: cd '/path' && MYVAR='value' claude --print + // After escaping: cd '/path' && MYVAR='value' claude --print (no $ to escape here) + // Wrapped: bash -lc "source ~/.bashrc 2>/dev/null; cd '/path' && MYVAR='value' claude --print" + // SSH receives this as one argument, passes to remote shell + // The login shell runs with full PATH from /etc/profile, ~/.bash_profile, AND ~/.bashrc + const escapedCommand = shellEscapeForDoubleQuotes(remoteCommand); + // Source login/profile files first so PATH modifications made in + // ~/.bash_profile or ~/.profile are available for non-interactive + // remote commands, then source ~/.bashrc to cover interactive + // additions that might also be present. + const wrappedCommand = `bash -lc "source ~/.bash_profile 2>/dev/null || source ~/.profile 2>/dev/null; source ~/.bashrc 2>/dev/null; ${escapedCommand}"`; + args.push(wrappedCommand); // Debug logging to trace the exact command being built logger.info('Built SSH command', '[ssh-command-builder]', { diff --git a/src/renderer/components/NewInstanceModal.tsx b/src/renderer/components/NewInstanceModal.tsx index 54de2211..bfcd2143 100644 --- a/src/renderer/components/NewInstanceModal.tsx +++ b/src/renderer/components/NewInstanceModal.tsx @@ -578,23 +578,34 @@ export function NewInstanceModal({ } }, [isOpen, sourceSession]); - // Load SSH remote configurations independently of agent detection - // This ensures SSH remotes are available even if agent detection fails - useEffect(() => { - if (isOpen) { - const loadSshConfigs = async () => { - try { - const sshConfigsResult = await window.maestro.sshRemote.getConfigs(); - if (sshConfigsResult.success && sshConfigsResult.configs) { - setSshRemotes(sshConfigsResult.configs); - } - } catch (sshError) { - console.error('Failed to load SSH remote configs:', sshError); - } - }; - loadSshConfigs(); - } - }, [isOpen]); + // Load SSH remote configurations independently of agent detection + // This ensures SSH remotes are available even if agent detection fails + useEffect(() => { + if (isOpen) { + const loadSshConfigs = async () => { + try { + const sshConfigsResult = await window.maestro.sshRemote.getConfigs(); + if (sshConfigsResult.success && sshConfigsResult.configs) { + setSshRemotes(sshConfigsResult.configs); + } + } catch (sshError) { + console.error('Failed to load SSH remote configs:', sshError); + } + }; + loadSshConfigs(); + } + }, [isOpen]); + + // Transfer pending SSH config to selected agent automatically + // This ensures SSH config is preserved when agent is auto-selected or manually clicked + useEffect(() => { + if (selectedAgent && agentSshRemoteConfigs['_pending_'] && !agentSshRemoteConfigs[selectedAgent]) { + setAgentSshRemoteConfigs(prev => ({ + ...prev, + [selectedAgent]: prev['_pending_'], + })); + } + }, [selectedAgent, agentSshRemoteConfigs]); // Track the current SSH remote ID for re-detection // Uses _pending_ key when no agent is selected, which is the shared SSH config diff --git a/src/renderer/components/Wizard/screens/ConversationScreen.tsx b/src/renderer/components/Wizard/screens/ConversationScreen.tsx index 68bc5805..e015641d 100644 --- a/src/renderer/components/Wizard/screens/ConversationScreen.tsx +++ b/src/renderer/components/Wizard/screens/ConversationScreen.tsx @@ -680,12 +680,13 @@ export function ConversationScreen({ // Fetch existing docs if continuing from previous session const existingDocs = await fetchExistingDocs(); - await conversationManager.startConversation({ - agentType: state.selectedAgent, - directoryPath: state.directoryPath, - projectName: state.agentName || 'My Project', - existingDocs: existingDocs.length > 0 ? existingDocs : undefined, - }); + await conversationManager.startConversation({ + agentType: state.selectedAgent, + directoryPath: state.directoryPath, + projectName: state.agentName || 'My Project', + existingDocs: existingDocs.length > 0 ? existingDocs : undefined, + sessionSshRemoteConfig: state.sessionSshRemoteConfig, + }); if (mounted) { setConversationStarted(true); @@ -1061,13 +1062,14 @@ export function ConversationScreen({ } } - await conversationManager.startConversation({ - agentType: state.selectedAgent, - directoryPath: state.directoryPath, - projectName: state.agentName || 'My Project', - existingDocs: existingDocs.length > 0 ? existingDocs : undefined, - }); - } + await conversationManager.startConversation({ + agentType: state.selectedAgent, + directoryPath: state.directoryPath, + projectName: state.agentName || 'My Project', + existingDocs: existingDocs.length > 0 ? existingDocs : undefined, + sessionSshRemoteConfig: state.sessionSshRemoteConfig, + }); + } // Send message and wait for response const result = await conversationManager.sendMessage( diff --git a/src/renderer/components/Wizard/services/conversationManager.ts b/src/renderer/components/Wizard/services/conversationManager.ts index 1a17dfde..22324dad 100644 --- a/src/renderer/components/Wizard/services/conversationManager.ts +++ b/src/renderer/components/Wizard/services/conversationManager.ts @@ -29,14 +29,20 @@ import { wizardDebugLogger } from './phaseGenerator'; * Configuration for starting a conversation */ export interface ConversationConfig { - /** The agent type to use for the conversation */ - agentType: ToolType; - /** The working directory for the agent */ - directoryPath: string; - /** Project name (used in system prompt) */ - projectName: string; - /** Existing Auto Run documents (when continuing from previous session) */ - existingDocs?: ExistingDocument[]; + /** The agent type to use for the conversation */ + agentType: ToolType; + /** The working directory for the agent */ + directoryPath: string; + /** Project name (used in system prompt) */ + projectName: string; + /** Existing Auto Run documents (when continuing from previous session) */ + existingDocs?: ExistingDocument[]; + /** SSH remote configuration for remote agent execution */ + sessionSshRemoteConfig?: { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; + }; } /** @@ -84,34 +90,40 @@ export interface ConversationCallbacks { * State of an active conversation session */ interface ConversationSession { - /** Unique session ID for this wizard conversation */ - sessionId: string; - /** The agent type */ - agentType: ToolType; - /** Working directory */ - directoryPath: string; - /** Project name */ - projectName: string; - /** Whether the agent process is active */ - isActive: boolean; - /** System prompt used for this session */ - systemPrompt: string; - /** Accumulated output buffer for parsing */ - outputBuffer: string; - /** Resolve function for pending message */ - pendingResolve?: (result: SendMessageResult) => void; - /** Callbacks for the conversation */ - callbacks?: ConversationCallbacks; - /** Cleanup function for data listener */ - dataListenerCleanup?: () => void; - /** Cleanup function for exit listener */ - exitListenerCleanup?: () => void; - /** Cleanup function for thinking chunk listener */ - thinkingListenerCleanup?: () => void; - /** Cleanup function for tool execution listener */ - toolExecutionListenerCleanup?: () => void; - /** Timeout ID for response timeout (for cleanup) */ - responseTimeoutId?: NodeJS.Timeout; + /** Unique session ID for this wizard conversation */ + sessionId: string; + /** The agent type */ + agentType: ToolType; + /** Working directory */ + directoryPath: string; + /** Project name */ + projectName: string; + /** Whether the agent process is active */ + isActive: boolean; + /** System prompt used for this session */ + systemPrompt: string; + /** Accumulated output buffer for parsing */ + outputBuffer: string; + /** Resolve function for pending message */ + pendingResolve?: (result: SendMessageResult) => void; + /** Callbacks for the conversation */ + callbacks?: ConversationCallbacks; + /** Cleanup function for data listener */ + dataListenerCleanup?: () => void; + /** Cleanup function for exit listener */ + exitListenerCleanup?: () => void; + /** Cleanup function for thinking chunk listener */ + thinkingListenerCleanup?: () => void; + /** Cleanup function for tool execution listener */ + toolExecutionListenerCleanup?: () => void; + /** Timeout ID for response timeout (for cleanup) */ + responseTimeoutId?: NodeJS.Timeout; + /** SSH remote configuration for remote execution */ + sessionSshRemoteConfig?: { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; + }; } /** @@ -154,25 +166,28 @@ class ConversationManager { existingDocs: config.existingDocs, }); - this.session = { - sessionId, - agentType: config.agentType, - directoryPath: config.directoryPath, - projectName: config.projectName, - isActive: true, - systemPrompt, - outputBuffer: '', - }; + this.session = { + sessionId, + agentType: config.agentType, + directoryPath: config.directoryPath, + projectName: config.projectName, + isActive: true, + systemPrompt, + outputBuffer: '', + sessionSshRemoteConfig: config.sessionSshRemoteConfig, + }; - // Log conversation start - wizardDebugLogger.log('info', 'Conversation started', { - sessionId, - agentType: config.agentType, - directoryPath: config.directoryPath, - projectName: config.projectName, - hasExistingDocs: !!config.existingDocs, - existingDocsCount: config.existingDocs?.length || 0, - }); + // Log conversation start + wizardDebugLogger.log('info', 'Conversation started', { + sessionId, + agentType: config.agentType, + directoryPath: config.directoryPath, + projectName: config.projectName, + hasExistingDocs: !!config.existingDocs, + existingDocsCount: config.existingDocs?.length || 0, + hasRemoteSsh: !!config.sessionSshRemoteConfig?.enabled, + remoteId: config.sessionSshRemoteConfig?.remoteId || null, + }); return sessionId; } @@ -217,20 +232,61 @@ class ConversationManager { // Notify sending callbacks?.onSending?.(); - try { - // Get the agent configuration - const agent = await window.maestro.agents.get(this.session.agentType); - if (!agent || !agent.available) { - const error = `Agent ${this.session.agentType} is not available`; - wizardDebugLogger.log('error', 'Agent not available', { - agentType: this.session.agentType, - agent: agent ? { available: agent.available } : null, - }); - return { - success: false, - error, - }; - } + try { + // Get the agent configuration + // Pass SSH remote ID if SSH is enabled for this session + const sshRemoteId = this.session.sessionSshRemoteConfig?.enabled + ? this.session.sessionSshRemoteConfig.remoteId + : undefined; + + wizardDebugLogger.log('info', 'Fetching agent configuration', { + agentType: this.session.agentType, + sessionId: this.session.sessionId, + hasRemoteSsh: !!this.session.sessionSshRemoteConfig?.enabled, + remoteId: this.session.sessionSshRemoteConfig?.remoteId || null, + passingSshRemoteId: sshRemoteId || null, + }); + + const agent = await window.maestro.agents.get(this.session.agentType, sshRemoteId || undefined); + + // Log to main process (writes to maestro-debug.log on Windows) + console.log('[Wizard] Agent fetch result:', { + agentType: this.session.agentType, + agentExists: !!agent, + agentAvailable: agent?.available, + agentPath: agent?.path, + agentCommand: agent?.command, + hasRemoteSsh: !!this.session.sessionSshRemoteConfig?.enabled, + }); + + if (!agent || !agent.available) { + const error = `Agent ${this.session.agentType} is not available`; + + // Log detailed info about why agent is unavailable + console.error('[Wizard] Agent not available - Details:', { + agentType: this.session.agentType, + agentExists: !!agent, + agentAvailable: agent?.available, + agentPath: agent?.path, + agentError: (agent as any)?.error, + sessionSshConfig: this.session.sessionSshRemoteConfig, + }); + + wizardDebugLogger.log('error', 'Agent not available', { + agentType: this.session.agentType, + agent: agent ? { + available: agent.available, + path: agent.path, + error: (agent as any).error + } : null, + }); + return { + success: false, + error, + }; + } + + console.log('[Wizard] Agent is available, building prompt...'); // Build the full prompt with conversation context const fullPrompt = this.buildPromptWithContext(userMessage, conversationHistory); @@ -480,44 +536,48 @@ class ConversationManager { // This is critical for packaged Electron apps where PATH may not include agent locations const commandToUse = agent.path || agent.command; - wizardDebugLogger.log('spawn', 'Calling process.spawn', { - sessionId: this.session!.sessionId, - command: commandToUse, - agentPath: agent.path, - agentCommand: agent.command, - args: argsForSpawn, - cwd: this.session!.directoryPath, - }); + wizardDebugLogger.log('spawn', 'Calling process.spawn', { + sessionId: this.session!.sessionId, + command: commandToUse, + agentPath: agent.path, + agentCommand: agent.command, + args: argsForSpawn, + cwd: this.session!.directoryPath, + hasRemoteSsh: !!this.session!.sessionSshRemoteConfig?.enabled, + remoteId: this.session!.sessionSshRemoteConfig?.remoteId || null, + }); + + window.maestro.process + .spawn({ + sessionId: this.session!.sessionId, + toolType: this.session!.agentType, + cwd: this.session!.directoryPath, + command: commandToUse, + args: argsForSpawn, + prompt: prompt, + sessionSshRemoteConfig: this.session!.sessionSshRemoteConfig, + }) + .then(() => { + wizardDebugLogger.log('spawn', 'Agent process spawned successfully', { + sessionId: this.session?.sessionId, + }); + // Notify that we're receiving + this.session?.callbacks?.onReceiving?.(); + }) + .catch((error: Error) => { + wizardDebugLogger.log('error', 'Failed to spawn agent process', { + sessionId: this.session?.sessionId, + error: error.message, + }); + this.cleanupListeners(); + resolve({ + success: false, + error: `Failed to spawn agent: ${error.message}`, + }); + }); + }); + } - window.maestro.process - .spawn({ - sessionId: this.session!.sessionId, - toolType: this.session!.agentType, - cwd: this.session!.directoryPath, - command: commandToUse, - args: argsForSpawn, - prompt: prompt, - }) - .then(() => { - wizardDebugLogger.log('spawn', 'Agent process spawned successfully', { - sessionId: this.session?.sessionId, - }); - // Notify that we're receiving - this.session?.callbacks?.onReceiving?.(); - }) - .catch((error: Error) => { - wizardDebugLogger.log('error', 'Failed to spawn agent process', { - sessionId: this.session?.sessionId, - error: error.message, - }); - this.cleanupListeners(); - resolve({ - success: false, - error: `Failed to spawn agent: ${error.message}`, - }); - }); - }); - } /** * Build CLI args for the agent based on its type and capabilities. diff --git a/src/renderer/components/Wizard/services/phaseGenerator.ts b/src/renderer/components/Wizard/services/phaseGenerator.ts index 4033f11f..a0fa89ca 100644 --- a/src/renderer/components/Wizard/services/phaseGenerator.ts +++ b/src/renderer/components/Wizard/services/phaseGenerator.ts @@ -18,16 +18,22 @@ import { * Configuration for document generation */ export interface GenerationConfig { - /** Agent type to use for generation */ - agentType: ToolType; - /** Working directory for the agent */ - directoryPath: string; - /** Project name from wizard */ - projectName: string; - /** Full conversation history from project discovery */ - conversationHistory: WizardMessage[]; - /** Optional subfolder within Auto Run Docs (e.g., "Initiation") */ - subfolder?: string; + /** Agent type to use for generation */ + agentType: ToolType; + /** Working directory for the agent */ + directoryPath: string; + /** Project name from wizard */ + projectName: string; + /** Full conversation history from project discovery */ + conversationHistory: WizardMessage[]; + /** Optional subfolder within Auto Run Docs (e.g., "Initiation") */ + subfolder?: string; + /** SSH remote configuration for remote agent execution */ + sessionSshRemoteConfig?: { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; + }; } /** @@ -1054,47 +1060,50 @@ class PhaseGenerator { // This is critical for packaged Electron apps where PATH may not include agent locations const commandToUse = agent.path || agent.command; - wizardDebugLogger.log('spawn', 'Calling process.spawn', { - sessionId, - toolType: config.agentType, - cwd: config.directoryPath, - command: commandToUse, - agentPath: agent.path, - agentCommand: agent.command, - argsCount: argsForSpawn.length, - promptLength: prompt.length, - }); - window.maestro.process - .spawn({ - sessionId, - toolType: config.agentType, - cwd: config.directoryPath, - command: commandToUse, - args: argsForSpawn, - prompt, - }) - .then(() => { - console.log('[PhaseGenerator] Agent spawned successfully'); - wizardDebugLogger.log('spawn', 'Agent spawned successfully', { sessionId }); - }) - .catch((error: Error) => { - console.error('[PhaseGenerator] Spawn failed:', error.message); - wizardDebugLogger.log('error', 'Spawn failed', { - errorMessage: error.message, - errorStack: error.stack, - }); - clearTimeout(timeoutId); - this.cleanup(); - if (fileWatcherCleanup) { - fileWatcherCleanup(); - } - resolve({ - success: false, - error: `Failed to spawn agent: ${error.message}`, - }); - }); - }); - } + wizardDebugLogger.log('spawn', 'Calling process.spawn', { + sessionId, + toolType: config.agentType, + cwd: config.directoryPath, + command: commandToUse, + agentPath: agent.path, + agentCommand: agent.command, + argsCount: argsForSpawn.length, + promptLength: prompt.length, + hasRemoteSsh: !!config.sessionSshRemoteConfig?.enabled, + remoteId: config.sessionSshRemoteConfig?.remoteId || null, + }); + window.maestro.process + .spawn({ + sessionId, + toolType: config.agentType, + cwd: config.directoryPath, + command: commandToUse, + args: argsForSpawn, + prompt, + sessionSshRemoteConfig: config.sessionSshRemoteConfig, + }) + .then(() => { + console.log('[PhaseGenerator] Agent spawned successfully'); + wizardDebugLogger.log('spawn', 'Agent spawned successfully', { sessionId }); + }) + .catch((error: Error) => { + console.error('[PhaseGenerator] Spawn failed:', error.message); + wizardDebugLogger.log('error', 'Spawn failed', { + errorMessage: error.message, + errorStack: error.stack, + }); + clearTimeout(timeoutId); + this.cleanup(); + if (fileWatcherCleanup) { + fileWatcherCleanup(); + } + resolve({ + success: false, + error: `Failed to spawn agent: ${error.message}`, + }); + }); + }); + } /** * Read documents from the Auto Run Docs folder on disk