fix(ssh): stabilize and complete SSH remote execution support across wizard, IPC, and agent detection

- 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
This commit is contained in:
chr1syy
2026-01-12 07:33:05 +00:00
parent 1f938021fb
commit ab7bf27d03
7 changed files with 550 additions and 335 deletions

View File

@@ -305,17 +305,117 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
}) })
); );
// Get a specific agent by ID // Get a specific agent by ID (supports SSH remote detection via optional sshRemoteId)
ipcMain.handle( ipcMain.handle(
'agents:get', 'agents:get',
withIpcErrorLogging(handlerOpts('get'), async (agentId: string) => { withIpcErrorLogging(handlerOpts('get'), async (agentId: string, sshRemoteId?: string) => {
const agentDetector = requireDependency(getAgentDetector, 'Agent detector'); logger.debug(`Getting agent: ${agentId}`, LOG_CONTEXT, { sshRemoteId });
logger.debug(`Getting agent: ${agentId}`, LOG_CONTEXT);
const agent = await agentDetector.getAgent(agentId); // If SSH remote ID provided, detect agent on remote host
// Strip argBuilder functions before sending over IPC if (sshRemoteId) {
return stripAgentFunctions(agent); 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 // Get capabilities for a specific agent
ipcMain.handle( ipcMain.handle(

View File

@@ -20,6 +20,7 @@ import { buildSshCommand } from '../../utils/ssh-command-builder';
import type { SshRemoteConfig } from '../../../shared/types'; import type { SshRemoteConfig } from '../../../shared/types';
import { powerManager } from '../../power-manager'; import { powerManager } from '../../power-manager';
import { MaestroSettings } from './persistence'; import { MaestroSettings } from './persistence';
import { getAgentCapabilities } from '../../agent-capabilities';
const LOG_CONTEXT = '[ProcessManager]'; const LOG_CONTEXT = '[ProcessManager]';
@@ -108,42 +109,46 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
const processManager = requireProcessManager(getProcessManager); const processManager = requireProcessManager(getProcessManager);
const agentDetector = requireDependency(getAgentDetector, 'Agent detector'); const agentDetector = requireDependency(getAgentDetector, 'Agent detector');
// Get agent definition to access config options and argument builders // Get agent definition to access config options and argument builders
const agent = await agentDetector.getAgent(config.toolType); const agent = await agentDetector.getAgent(config.toolType);
// Use INFO level on Windows for better visibility in logs // Use INFO level on Windows for better visibility in logs
const isWindows = process.platform === 'win32'; const isWindows = process.platform === 'win32';
const logFn = isWindows ? logger.info.bind(logger) : logger.debug.bind(logger); const logFn = isWindows ? logger.info.bind(logger) : logger.debug.bind(logger);
logFn(`Spawn config received`, LOG_CONTEXT, { logFn(`Spawn config received`, LOG_CONTEXT, {
platform: process.platform, platform: process.platform,
configToolType: config.toolType, configToolType: config.toolType,
configCommand: config.command, configCommand: config.command,
agentId: agent?.id, agentId: agent?.id,
agentCommand: agent?.command, agentCommand: agent?.command,
agentPath: agent?.path, agentPath: agent?.path,
agentPathExtension: agent?.path ? require('path').extname(agent.path) : 'none', agentPathExtension: agent?.path ? require('path').extname(agent.path) : 'none',
hasAgentSessionId: !!config.agentSessionId, hasAgentSessionId: !!config.agentSessionId,
hasPrompt: !!config.prompt, hasPrompt: !!config.prompt,
promptLength: config.prompt?.length, promptLength: config.prompt?.length,
// On Windows, show prompt preview to help debug truncation issues // On Windows, show prompt preview to help debug truncation issues
promptPreview: promptPreview: config.prompt && isWindows ? {
config.prompt && isWindows first50: config.prompt.substring(0, 50),
? { last50: config.prompt.substring(Math.max(0, config.prompt.length - 50)),
first50: config.prompt.substring(0, 50), containsHash: config.prompt.includes('#'),
last50: config.prompt.substring(Math.max(0, config.prompt.length - 50)), containsNewline: config.prompt.includes('\n'),
containsHash: config.prompt.includes('#'), } : undefined,
containsNewline: config.prompt.includes('\n'), // SSH remote config logging
} hasSessionSshRemoteConfig: !!config.sessionSshRemoteConfig,
: undefined, sessionSshRemoteConfig: config.sessionSshRemoteConfig ? {
}); enabled: config.sessionSshRemoteConfig.enabled,
let finalArgs = buildAgentArgs(agent, { remoteId: config.sessionSshRemoteConfig.remoteId,
baseArgs: config.args, hasWorkingDirOverride: !!config.sessionSshRemoteConfig.workingDirOverride,
prompt: config.prompt, } : null,
cwd: config.cwd, });
readOnlyMode: config.readOnlyMode, let finalArgs = buildAgentArgs(agent, {
modelId: config.modelId, baseArgs: config.args,
yoloMode: config.yoloMode, prompt: config.prompt,
agentSessionId: config.agentSessionId, cwd: config.cwd,
}); readOnlyMode: config.readOnlyMode,
modelId: config.modelId,
yoloMode: config.yoloMode,
agentSessionId: config.agentSessionId,
});
// ======================================================================== // ========================================================================
// Apply agent config options and session overrides // 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 // SSH Remote Execution: Detect and wrap command for remote execution
// Terminal sessions are always local (they need PTY for shell interaction) // Terminal sessions are always local (they need PTY for shell interaction)
// ======================================================================== // ========================================================================
let sshRemoteUsed: SshRemoteConfig | null = null; let sshRemoteUsed: SshRemoteConfig | null = null;
let shouldSendPromptViaStdin = false;
// Only consider SSH remote for non-terminal AI agent sessions // Only consider SSH remote for non-terminal AI agent sessions
// SSH is session-level ONLY - no agent-level or global defaults // SSH is session-level ONLY - no agent-level or global defaults
if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig) { // Log SSH evaluation on Windows for debugging
// Session-level SSH config provided - resolve and use it if (isWindows) {
logger.debug(`Using session-level SSH config`, LOG_CONTEXT, { logger.info(`Evaluating SSH remote config`, LOG_CONTEXT, {
sessionId: config.sessionId, toolType: config.toolType,
enabled: config.sessionSshRemoteConfig.enabled, isTerminal: config.toolType === 'terminal',
remoteId: config.sessionSshRemoteConfig.remoteId, 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 // Resolve effective SSH remote configuration
const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore); const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore);
@@ -278,19 +293,31 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
// SSH remote is configured - wrap the command for remote execution // SSH remote is configured - wrap the command for remote execution
sshRemoteUsed = sshResult.config; sshRemoteUsed = sshResult.config;
// For SSH execution, we need to include the prompt in the args here // For SSH execution with stream-json capable agents (like Claude Code),
// because ProcessManager.spawn() won't add it (we pass prompt: undefined for SSH) // we send the prompt via stdin instead of as a CLI arg to avoid command
// Use promptArgs if available (e.g., OpenCode -p), otherwise use positional arg // line length limits and escaping issues. For other agents, we include
let sshArgs = finalArgs; // the prompt in the args as before.
if (config.prompt) { const capabilities = getAgentCapabilities(config.toolType);
if (agent?.promptArgs) { let sshArgs = finalArgs;
sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)];
} else if (agent?.noPromptSeparator) { if (config.prompt) {
sshArgs = [...finalArgs, config.prompt]; // If agent supports stream-json input, send prompt via stdin
} else { // ProcessManager will detect this and send the prompt as a JSON message
sshArgs = [...finalArgs, '--', config.prompt]; 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 // Build the SSH command that wraps the agent execution
// //
@@ -312,51 +339,53 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
commandToSpawn = sshCommand.command; commandToSpawn = sshCommand.command;
argsToSpawn = sshCommand.args; argsToSpawn = sshCommand.args;
logger.info(`SSH remote execution configured`, LOG_CONTEXT, { logger.info(`SSH remote execution configured`, LOG_CONTEXT, {
sessionId: config.sessionId, sessionId: config.sessionId,
toolType: config.toolType, toolType: config.toolType,
remoteName: sshResult.config.name, remoteName: sshResult.config.name,
remoteHost: sshResult.config.host, remoteHost: sshResult.config.host,
source: sshResult.source, source: sshResult.source,
localCommand: config.command, localCommand: config.command,
remoteCommand: remoteCommand, remoteCommand: remoteCommand,
customPath: config.sessionCustomPath || null, customPath: config.sessionCustomPath || null,
hasCustomEnvVars: hasCustomEnvVars: !!effectiveCustomEnvVars && Object.keys(effectiveCustomEnvVars).length > 0,
!!effectiveCustomEnvVars && Object.keys(effectiveCustomEnvVars).length > 0, sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`,
sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`, promptViaStdin: shouldSendPromptViaStdin,
}); });
} }
} }
const result = processManager.spawn({ const result = processManager.spawn({
...config, ...config,
command: commandToSpawn, command: commandToSpawn,
args: argsToSpawn, args: argsToSpawn,
// When using SSH, use user's home directory as local cwd // When using SSH, use user's home directory as local cwd
// The remote working directory is embedded in the SSH command itself // The remote working directory is embedded in the SSH command itself
// This fixes ENOENT errors when session.cwd is a remote-only path // This fixes ENOENT errors when session.cwd is a remote-only path
cwd: sshRemoteUsed ? os.homedir() : config.cwd, cwd: sshRemoteUsed ? os.homedir() : config.cwd,
// When using SSH, disable PTY (SSH provides its own terminal handling) // When using SSH, disable PTY (SSH provides its own terminal handling)
// and env vars are passed via the remote command string // and env vars are passed via the remote command string
requiresPty: sshRemoteUsed ? false : agent?.requiresPty, requiresPty: sshRemoteUsed ? false : agent?.requiresPty,
// When using SSH, the prompt was already added to sshArgs above before // When using SSH with stream-json capable agents, pass the prompt so
// building the SSH command, so don't let ProcessManager add it again // ProcessManager can send it via stdin. For other SSH cases, prompt was
prompt: sshRemoteUsed ? undefined : config.prompt, // already added to sshArgs, so pass undefined to prevent double-adding.
shell: shellToUse, prompt: sshRemoteUsed && shouldSendPromptViaStdin ? config.prompt : sshRemoteUsed ? undefined : config.prompt,
shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions) shell: shellToUse,
shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions) shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions)
contextWindow, // Pass configured context window to process manager shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions)
// When using SSH, env vars are passed in the remote command string, not locally contextWindow, // Pass configured context window to process manager
customEnvVars: sshRemoteUsed ? undefined : effectiveCustomEnvVars, // When using SSH, env vars are passed in the remote command string, not locally
imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode) customEnvVars: sshRemoteUsed ? undefined : effectiveCustomEnvVars,
promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode) imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode)
noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode)
// Stats tracking: use cwd as projectPath if not explicitly provided noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt
projectPath: config.cwd, // Stats tracking: use cwd as projectPath if not explicitly provided
// SSH remote context (for SSH-specific error messages) projectPath: config.cwd,
sshRemoteId: sshRemoteUsed?.id, // SSH remote context (for SSH-specific error messages)
sshRemoteHost: sshRemoteUsed?.host, sshRemoteConfig: sshRemoteUsed,
}); sshRemoteId: sshRemoteUsed?.id,
sshRemoteHost: sshRemoteUsed?.host,
});
logger.info(`Process spawned successfully`, LOG_CONTEXT, { logger.info(`Process spawned successfully`, LOG_CONTEXT, {
sessionId: config.sessionId, sessionId: config.sessionId,

View File

@@ -237,37 +237,41 @@ export async function buildSshCommand(
env: Object.keys(mergedEnv).length > 0 ? mergedEnv : undefined, env: Object.keys(mergedEnv).length > 0 ? mergedEnv : undefined,
}); });
// Wrap the command to execute via the user's login shell. // 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. // We use bash -lc to ensure 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 (sources /etc/profile, ~/.bash_profile, etc.)
// -l loads login profile for PATH // -c executes the command
// -c executes the command //
// Using $SHELL respects the user's configured shell (bash, zsh, etc.) // IMPORTANT: We don't use -i (interactive) flag because:
// // 1. It can cause issues with shells that check if stdin is a TTY
// WHY -i IS CRITICAL: // 2. When using -tt flag, the shell already thinks it's interactive enough
// On Ubuntu (and many Linux distros), .bashrc has a guard at the top: // 3. On Ubuntu, we work around the "case $- in *i*)" guard by sourcing ~/.bashrc explicitly
// case $- in *i*) ;; *) return;; esac //
// This checks if the shell is interactive before running. Without -i, // For bash specifically, we source ~/.bashrc to ensure user PATH additions are loaded.
// .bashrc exits early and user PATH additions (like ~/.local/bin) never load. // This handles the common Ubuntu pattern where .bashrc has:
// The -i flag sets 'i' in $-, allowing .bashrc to run fully. // 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: // CRITICAL: When Node.js spawn() passes this to SSH without shell:true, SSH runs
// 1. Double quotes around the command are NOT escaped - they delimit the -c argument // the command through the remote's default shell. The key is escaping:
// 2. $ signs inside the command MUST be escaped as \$ so they defer to the login shell // 1. Double quotes around the command are NOT escaped - they delimit the -c argument
// (shellEscapeForDoubleQuotes handles this) // 2. $ signs inside the command MUST be escaped as \$ so they defer to the login shell
// 3. Single quotes inside the command pass through unchanged // (shellEscapeForDoubleQuotes handles this)
// // 3. Single quotes inside the command pass through unchanged
// Example transformation for spawn(): //
// Input: cd '/path' && MYVAR='value' claude --print // Example transformation for spawn():
// After escaping: cd '/path' && MYVAR='value' claude --print (no $ to escape here) // Input: cd '/path' && MYVAR='value' claude --print
// Wrapped: $SHELL -ilc "cd '/path' && MYVAR='value' claude --print" // After escaping: cd '/path' && MYVAR='value' claude --print (no $ to escape here)
// SSH receives this as one argument, passes to remote shell // Wrapped: bash -lc "source ~/.bashrc 2>/dev/null; cd '/path' && MYVAR='value' claude --print"
// Remote shell expands $SHELL, executes: /bin/zsh -ilc "cd '/path' ..." // SSH receives this as one argument, passes to remote shell
// The login shell runs with full PATH from ~/.zprofile AND ~/.bashrc // The login shell runs with full PATH from /etc/profile, ~/.bash_profile, AND ~/.bashrc
const escapedCommand = shellEscapeForDoubleQuotes(remoteCommand); const escapedCommand = shellEscapeForDoubleQuotes(remoteCommand);
const wrappedCommand = `$SHELL -ilc "${escapedCommand}"`; // Source login/profile files first so PATH modifications made in
args.push(wrappedCommand); // ~/.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 // Debug logging to trace the exact command being built
logger.info('Built SSH command', '[ssh-command-builder]', { logger.info('Built SSH command', '[ssh-command-builder]', {

View File

@@ -578,23 +578,34 @@ export function NewInstanceModal({
} }
}, [isOpen, sourceSession]); }, [isOpen, sourceSession]);
// Load SSH remote configurations independently of agent detection // Load SSH remote configurations independently of agent detection
// This ensures SSH remotes are available even if agent detection fails // This ensures SSH remotes are available even if agent detection fails
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
const loadSshConfigs = async () => { const loadSshConfigs = async () => {
try { try {
const sshConfigsResult = await window.maestro.sshRemote.getConfigs(); const sshConfigsResult = await window.maestro.sshRemote.getConfigs();
if (sshConfigsResult.success && sshConfigsResult.configs) { if (sshConfigsResult.success && sshConfigsResult.configs) {
setSshRemotes(sshConfigsResult.configs); setSshRemotes(sshConfigsResult.configs);
} }
} catch (sshError) { } catch (sshError) {
console.error('Failed to load SSH remote configs:', sshError); console.error('Failed to load SSH remote configs:', sshError);
} }
}; };
loadSshConfigs(); loadSshConfigs();
} }
}, [isOpen]); }, [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 // Track the current SSH remote ID for re-detection
// Uses _pending_ key when no agent is selected, which is the shared SSH config // Uses _pending_ key when no agent is selected, which is the shared SSH config

View File

@@ -680,12 +680,13 @@ export function ConversationScreen({
// Fetch existing docs if continuing from previous session // Fetch existing docs if continuing from previous session
const existingDocs = await fetchExistingDocs(); const existingDocs = await fetchExistingDocs();
await conversationManager.startConversation({ await conversationManager.startConversation({
agentType: state.selectedAgent, agentType: state.selectedAgent,
directoryPath: state.directoryPath, directoryPath: state.directoryPath,
projectName: state.agentName || 'My Project', projectName: state.agentName || 'My Project',
existingDocs: existingDocs.length > 0 ? existingDocs : undefined, existingDocs: existingDocs.length > 0 ? existingDocs : undefined,
}); sessionSshRemoteConfig: state.sessionSshRemoteConfig,
});
if (mounted) { if (mounted) {
setConversationStarted(true); setConversationStarted(true);
@@ -1061,13 +1062,14 @@ export function ConversationScreen({
} }
} }
await conversationManager.startConversation({ await conversationManager.startConversation({
agentType: state.selectedAgent, agentType: state.selectedAgent,
directoryPath: state.directoryPath, directoryPath: state.directoryPath,
projectName: state.agentName || 'My Project', projectName: state.agentName || 'My Project',
existingDocs: existingDocs.length > 0 ? existingDocs : undefined, existingDocs: existingDocs.length > 0 ? existingDocs : undefined,
}); sessionSshRemoteConfig: state.sessionSshRemoteConfig,
} });
}
// Send message and wait for response // Send message and wait for response
const result = await conversationManager.sendMessage( const result = await conversationManager.sendMessage(

View File

@@ -29,14 +29,20 @@ import { wizardDebugLogger } from './phaseGenerator';
* Configuration for starting a conversation * Configuration for starting a conversation
*/ */
export interface ConversationConfig { export interface ConversationConfig {
/** The agent type to use for the conversation */ /** The agent type to use for the conversation */
agentType: ToolType; agentType: ToolType;
/** The working directory for the agent */ /** The working directory for the agent */
directoryPath: string; directoryPath: string;
/** Project name (used in system prompt) */ /** Project name (used in system prompt) */
projectName: string; projectName: string;
/** Existing Auto Run documents (when continuing from previous session) */ /** Existing Auto Run documents (when continuing from previous session) */
existingDocs?: ExistingDocument[]; 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 * State of an active conversation session
*/ */
interface ConversationSession { interface ConversationSession {
/** Unique session ID for this wizard conversation */ /** Unique session ID for this wizard conversation */
sessionId: string; sessionId: string;
/** The agent type */ /** The agent type */
agentType: ToolType; agentType: ToolType;
/** Working directory */ /** Working directory */
directoryPath: string; directoryPath: string;
/** Project name */ /** Project name */
projectName: string; projectName: string;
/** Whether the agent process is active */ /** Whether the agent process is active */
isActive: boolean; isActive: boolean;
/** System prompt used for this session */ /** System prompt used for this session */
systemPrompt: string; systemPrompt: string;
/** Accumulated output buffer for parsing */ /** Accumulated output buffer for parsing */
outputBuffer: string; outputBuffer: string;
/** Resolve function for pending message */ /** Resolve function for pending message */
pendingResolve?: (result: SendMessageResult) => void; pendingResolve?: (result: SendMessageResult) => void;
/** Callbacks for the conversation */ /** Callbacks for the conversation */
callbacks?: ConversationCallbacks; callbacks?: ConversationCallbacks;
/** Cleanup function for data listener */ /** Cleanup function for data listener */
dataListenerCleanup?: () => void; dataListenerCleanup?: () => void;
/** Cleanup function for exit listener */ /** Cleanup function for exit listener */
exitListenerCleanup?: () => void; exitListenerCleanup?: () => void;
/** Cleanup function for thinking chunk listener */ /** Cleanup function for thinking chunk listener */
thinkingListenerCleanup?: () => void; thinkingListenerCleanup?: () => void;
/** Cleanup function for tool execution listener */ /** Cleanup function for tool execution listener */
toolExecutionListenerCleanup?: () => void; toolExecutionListenerCleanup?: () => void;
/** Timeout ID for response timeout (for cleanup) */ /** Timeout ID for response timeout (for cleanup) */
responseTimeoutId?: NodeJS.Timeout; 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, existingDocs: config.existingDocs,
}); });
this.session = { this.session = {
sessionId, sessionId,
agentType: config.agentType, agentType: config.agentType,
directoryPath: config.directoryPath, directoryPath: config.directoryPath,
projectName: config.projectName, projectName: config.projectName,
isActive: true, isActive: true,
systemPrompt, systemPrompt,
outputBuffer: '', outputBuffer: '',
}; sessionSshRemoteConfig: config.sessionSshRemoteConfig,
};
// Log conversation start // Log conversation start
wizardDebugLogger.log('info', 'Conversation started', { wizardDebugLogger.log('info', 'Conversation started', {
sessionId, sessionId,
agentType: config.agentType, agentType: config.agentType,
directoryPath: config.directoryPath, directoryPath: config.directoryPath,
projectName: config.projectName, projectName: config.projectName,
hasExistingDocs: !!config.existingDocs, hasExistingDocs: !!config.existingDocs,
existingDocsCount: config.existingDocs?.length || 0, existingDocsCount: config.existingDocs?.length || 0,
}); hasRemoteSsh: !!config.sessionSshRemoteConfig?.enabled,
remoteId: config.sessionSshRemoteConfig?.remoteId || null,
});
return sessionId; return sessionId;
} }
@@ -217,20 +232,61 @@ class ConversationManager {
// Notify sending // Notify sending
callbacks?.onSending?.(); callbacks?.onSending?.();
try { try {
// Get the agent configuration // Get the agent configuration
const agent = await window.maestro.agents.get(this.session.agentType); // Pass SSH remote ID if SSH is enabled for this session
if (!agent || !agent.available) { const sshRemoteId = this.session.sessionSshRemoteConfig?.enabled
const error = `Agent ${this.session.agentType} is not available`; ? this.session.sessionSshRemoteConfig.remoteId
wizardDebugLogger.log('error', 'Agent not available', { : undefined;
agentType: this.session.agentType,
agent: agent ? { available: agent.available } : null, wizardDebugLogger.log('info', 'Fetching agent configuration', {
}); agentType: this.session.agentType,
return { sessionId: this.session.sessionId,
success: false, hasRemoteSsh: !!this.session.sessionSshRemoteConfig?.enabled,
error, 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 // Build the full prompt with conversation context
const fullPrompt = this.buildPromptWithContext(userMessage, conversationHistory); 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 // This is critical for packaged Electron apps where PATH may not include agent locations
const commandToUse = agent.path || agent.command; const commandToUse = agent.path || agent.command;
wizardDebugLogger.log('spawn', 'Calling process.spawn', { wizardDebugLogger.log('spawn', 'Calling process.spawn', {
sessionId: this.session!.sessionId, sessionId: this.session!.sessionId,
command: commandToUse, command: commandToUse,
agentPath: agent.path, agentPath: agent.path,
agentCommand: agent.command, agentCommand: agent.command,
args: argsForSpawn, args: argsForSpawn,
cwd: this.session!.directoryPath, 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. * Build CLI args for the agent based on its type and capabilities.

View File

@@ -18,16 +18,22 @@ import {
* Configuration for document generation * Configuration for document generation
*/ */
export interface GenerationConfig { export interface GenerationConfig {
/** Agent type to use for generation */ /** Agent type to use for generation */
agentType: ToolType; agentType: ToolType;
/** Working directory for the agent */ /** Working directory for the agent */
directoryPath: string; directoryPath: string;
/** Project name from wizard */ /** Project name from wizard */
projectName: string; projectName: string;
/** Full conversation history from project discovery */ /** Full conversation history from project discovery */
conversationHistory: WizardMessage[]; conversationHistory: WizardMessage[];
/** Optional subfolder within Auto Run Docs (e.g., "Initiation") */ /** Optional subfolder within Auto Run Docs (e.g., "Initiation") */
subfolder?: string; 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 // This is critical for packaged Electron apps where PATH may not include agent locations
const commandToUse = agent.path || agent.command; const commandToUse = agent.path || agent.command;
wizardDebugLogger.log('spawn', 'Calling process.spawn', { wizardDebugLogger.log('spawn', 'Calling process.spawn', {
sessionId, sessionId,
toolType: config.agentType, toolType: config.agentType,
cwd: config.directoryPath, cwd: config.directoryPath,
command: commandToUse, command: commandToUse,
agentPath: agent.path, agentPath: agent.path,
agentCommand: agent.command, agentCommand: agent.command,
argsCount: argsForSpawn.length, argsCount: argsForSpawn.length,
promptLength: prompt.length, promptLength: prompt.length,
}); hasRemoteSsh: !!config.sessionSshRemoteConfig?.enabled,
window.maestro.process remoteId: config.sessionSshRemoteConfig?.remoteId || null,
.spawn({ });
sessionId, window.maestro.process
toolType: config.agentType, .spawn({
cwd: config.directoryPath, sessionId,
command: commandToUse, toolType: config.agentType,
args: argsForSpawn, cwd: config.directoryPath,
prompt, command: commandToUse,
}) args: argsForSpawn,
.then(() => { prompt,
console.log('[PhaseGenerator] Agent spawned successfully'); sessionSshRemoteConfig: config.sessionSshRemoteConfig,
wizardDebugLogger.log('spawn', 'Agent spawned successfully', { sessionId }); })
}) .then(() => {
.catch((error: Error) => { console.log('[PhaseGenerator] Agent spawned successfully');
console.error('[PhaseGenerator] Spawn failed:', error.message); wizardDebugLogger.log('spawn', 'Agent spawned successfully', { sessionId });
wizardDebugLogger.log('error', 'Spawn failed', { })
errorMessage: error.message, .catch((error: Error) => {
errorStack: error.stack, console.error('[PhaseGenerator] Spawn failed:', error.message);
}); wizardDebugLogger.log('error', 'Spawn failed', {
clearTimeout(timeoutId); errorMessage: error.message,
this.cleanup(); errorStack: error.stack,
if (fileWatcherCleanup) { });
fileWatcherCleanup(); clearTimeout(timeoutId);
} this.cleanup();
resolve({ if (fileWatcherCleanup) {
success: false, fileWatcherCleanup();
error: `Failed to spawn agent: ${error.message}`, }
}); resolve({
}); success: false,
}); error: `Failed to spawn agent: ${error.message}`,
} });
});
});
}
/** /**
* Read documents from the Auto Run Docs folder on disk * Read documents from the Auto Run Docs folder on disk