mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
fix(ssh): stabilize SSH remote execution across wizard, file browser, and process manager
- Added extensive DEBUG-level logging for SSH command execution, spawn details, exit codes, and configuration flow - Improved Wizard SSH remote support: - Debounced remote directory validation to reduce excessive SSH calls - Fixed git.isRepo() to correctly pass remoteCwd for remote checks - Persisted SSH config in SerializableWizardState and validated directories over SSH - Ensured ConversationScreen and ConversationSession consistently pass SSH config for remote agent execution - Fixed "agent not available" errors by forwarding stdin via exec and enabling stream-json mode for large prompts - Enhanced remote agent execution logic in ProcessManager with stdin streaming, exec-based forwarding, and useStdin flag - Improved SSH file browser behavior: - Added resolveSshPath() to locate SSH binaries on Windows (Electron spawn PATH issue) - Corrected getSshContext() handling of enabled/remoteId states - Ensured synopsis background tasks run via SSH instead of local paths - Added Windows development improvements: dev:win script and PowerShell launcher for separate renderer/main terminals - Added additional SSH directory debugging logs for remote-fs and wizard flows Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@
|
|||||||
"dev:main:prod-data": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .",
|
"dev:main:prod-data": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .",
|
||||||
"dev:renderer": "vite",
|
"dev:renderer": "vite",
|
||||||
"dev:web": "vite --config vite.config.web.mts",
|
"dev:web": "vite --config vite.config.web.mts",
|
||||||
|
"dev:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ./scripts/start-dev.ps1",
|
||||||
"build": "npm run build:prompts && npm run build:main && npm run build:preload && npm run build:renderer && npm run build:web && npm run build:cli",
|
"build": "npm run build:prompts && npm run build:main && npm run build:preload && npm run build:renderer && npm run build:web && npm run build:cli",
|
||||||
"build:prompts": "node scripts/generate-prompts.mjs",
|
"build:prompts": "node scripts/generate-prompts.mjs",
|
||||||
"build:main": "tsc -p tsconfig.main.json",
|
"build:main": "tsc -p tsconfig.main.json",
|
||||||
|
|||||||
16
scripts/start-dev.ps1
Normal file
16
scripts/start-dev.ps1
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Opens two PowerShell windows: one for renderer dev, one for building and running Electron
|
||||||
|
# Usage: powershell -NoProfile -ExecutionPolicy Bypass -File ./scripts/start-dev.ps1
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path -Path (Join-Path $PSScriptRoot '..')
|
||||||
|
$repoRoot = $repoRoot.Path
|
||||||
|
|
||||||
|
# escape single quotes for embedding in command strings
|
||||||
|
$repoRootEscaped = $repoRoot -replace "'","''"
|
||||||
|
|
||||||
|
$cmdRenderer = "Set-Location -LiteralPath '$repoRootEscaped'; npm run dev:renderer"
|
||||||
|
Start-Process powershell -ArgumentList '-NoExit', '-Command', $cmdRenderer
|
||||||
|
|
||||||
|
$cmdBuild = "Set-Location -LiteralPath '$repoRootEscaped'; npm run build:prompts; npx tsc -p tsconfig.main.json; `$env:NODE_ENV='development'; npx electron ."
|
||||||
|
Start-Process powershell -ArgumentList '-NoExit', '-Command', $cmdBuild
|
||||||
|
|
||||||
|
Write-Host "Launched renderer and main developer windows." -ForegroundColor Green
|
||||||
@@ -412,6 +412,17 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
|
|||||||
// Local detection
|
// Local detection
|
||||||
const agentDetector = requireDependency(getAgentDetector, 'Agent detector');
|
const agentDetector = requireDependency(getAgentDetector, 'Agent detector');
|
||||||
const agent = await agentDetector.getAgent(agentId);
|
const agent = await agentDetector.getAgent(agentId);
|
||||||
|
|
||||||
|
// Debug logging for agent availability
|
||||||
|
logger.debug(`Agent retrieved: ${agentId}`, LOG_CONTEXT, {
|
||||||
|
available: agent?.available,
|
||||||
|
hasPath: !!agent?.path,
|
||||||
|
path: agent?.path,
|
||||||
|
command: agent?.command,
|
||||||
|
hasCustomPath: !!agent?.customPath,
|
||||||
|
customPath: agent?.customPath,
|
||||||
|
});
|
||||||
|
|
||||||
// Strip argBuilder functions before sending over IPC
|
// Strip argBuilder functions before sending over IPC
|
||||||
return stripAgentFunctions(agent);
|
return stripAgentFunctions(agent);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -293,30 +293,33 @@ 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 with stream-json capable agents (like Claude Code),
|
// For SSH execution, we need to include the prompt in the args here
|
||||||
// we send the prompt via stdin instead of as a CLI arg to avoid command
|
// because ProcessManager.spawn() won't add it (we pass prompt: undefined for SSH)
|
||||||
// line length limits and escaping issues. For other agents, we include
|
// Use promptArgs if available (e.g., OpenCode -p), otherwise use positional arg
|
||||||
// the prompt in the args as before.
|
//
|
||||||
const capabilities = getAgentCapabilities(config.toolType);
|
// IMPORTANT: For large prompts (>4000 chars), don't embed in command line to avoid
|
||||||
|
// Windows command line length limits (~8191 chars). SSH wrapping adds significant overhead.
|
||||||
|
// Instead, add --input-format stream-json and let ProcessManager send via stdin.
|
||||||
|
const isLargePrompt = config.prompt && config.prompt.length > 4000;
|
||||||
let sshArgs = finalArgs;
|
let sshArgs = finalArgs;
|
||||||
|
if (config.prompt && !isLargePrompt) {
|
||||||
if (config.prompt) {
|
// Small prompt - embed in command line as usual
|
||||||
// If agent supports stream-json input, send prompt via stdin
|
if (agent?.promptArgs) {
|
||||||
// ProcessManager will detect this and send the prompt as a JSON message
|
sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)];
|
||||||
if (capabilities.supportsStreamJsonInput) {
|
} else if (agent?.noPromptSeparator) {
|
||||||
shouldSendPromptViaStdin = true;
|
sshArgs = [...finalArgs, config.prompt];
|
||||||
// Add --input-format stream-json flag so Claude knows to read from stdin
|
|
||||||
sshArgs = [...finalArgs, '--input-format', 'stream-json'];
|
|
||||||
} else {
|
} else {
|
||||||
// For agents that don't support stream-json, add prompt to args
|
sshArgs = [...finalArgs, '--', config.prompt];
|
||||||
if (agent?.promptArgs) {
|
|
||||||
sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)];
|
|
||||||
} else if (agent?.noPromptSeparator) {
|
|
||||||
sshArgs = [...finalArgs, config.prompt];
|
|
||||||
} else {
|
|
||||||
sshArgs = [...finalArgs, '--', config.prompt];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if (config.prompt && isLargePrompt) {
|
||||||
|
// Large prompt - use stdin mode
|
||||||
|
// Add --input-format stream-json flag so agent reads from stdin
|
||||||
|
sshArgs = [...finalArgs, '--input-format', 'stream-json'];
|
||||||
|
logger.info(`Using stdin for large prompt in SSH remote execution`, LOG_CONTEXT, {
|
||||||
|
sessionId: config.sessionId,
|
||||||
|
promptLength: config.prompt.length,
|
||||||
|
reason: 'avoid-command-line-length-limit',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the SSH command that wraps the agent execution
|
// Build the SSH command that wraps the agent execution
|
||||||
@@ -327,6 +330,9 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
|||||||
// the remote shell's PATH resolve it. This avoids using local paths like
|
// the remote shell's PATH resolve it. This avoids using local paths like
|
||||||
// '/opt/homebrew/bin/codex' which don't exist on the remote host.
|
// '/opt/homebrew/bin/codex' which don't exist on the remote host.
|
||||||
const remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command;
|
const remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command;
|
||||||
|
// Decide whether we'll send input via stdin to the remote command
|
||||||
|
const useStdin = sshArgs.includes('--input-format') && sshArgs.includes('stream-json');
|
||||||
|
|
||||||
const sshCommand = await buildSshCommand(sshResult.config, {
|
const sshCommand = await buildSshCommand(sshResult.config, {
|
||||||
command: remoteCommand,
|
command: remoteCommand,
|
||||||
args: sshArgs,
|
args: sshArgs,
|
||||||
@@ -334,6 +340,9 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
|||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
// Pass custom environment variables to the remote command
|
// Pass custom environment variables to the remote command
|
||||||
env: effectiveCustomEnvVars,
|
env: effectiveCustomEnvVars,
|
||||||
|
// Explicitly indicate whether stdin will be used so ssh-command-builder
|
||||||
|
// can avoid forcing a TTY for stream-json modes.
|
||||||
|
useStdin,
|
||||||
});
|
});
|
||||||
|
|
||||||
commandToSpawn = sshCommand.command;
|
commandToSpawn = sshCommand.command;
|
||||||
@@ -352,6 +361,44 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
|||||||
sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`,
|
sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`,
|
||||||
promptViaStdin: shouldSendPromptViaStdin,
|
promptViaStdin: shouldSendPromptViaStdin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Detailed debug logging to diagnose SSH command execution issues
|
||||||
|
logger.debug(`SSH command details for debugging`, LOG_CONTEXT, {
|
||||||
|
sessionId: config.sessionId,
|
||||||
|
toolType: config.toolType,
|
||||||
|
sshBinary: sshCommand.command,
|
||||||
|
sshArgsCount: sshCommand.args.length,
|
||||||
|
sshArgsArray: sshCommand.args,
|
||||||
|
// Show the last arg which contains the wrapped remote command
|
||||||
|
remoteCommandString: sshCommand.args[sshCommand.args.length - 1],
|
||||||
|
// Show the agent command that will execute remotely
|
||||||
|
agentBinary: remoteCommand,
|
||||||
|
agentArgs: sshArgs,
|
||||||
|
agentCwd: config.cwd,
|
||||||
|
// Full invocation for copy-paste debugging
|
||||||
|
fullSshInvocation: `${sshCommand.command} ${sshCommand.args.map(arg =>
|
||||||
|
arg.includes(' ') ? `'${arg}'` : arg
|
||||||
|
).join(' ')}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detailed debug logging to diagnose SSH command execution issues
|
||||||
|
logger.debug(`SSH command details for debugging`, LOG_CONTEXT, {
|
||||||
|
sessionId: config.sessionId,
|
||||||
|
toolType: config.toolType,
|
||||||
|
sshBinary: sshCommand.command,
|
||||||
|
sshArgsCount: sshCommand.args.length,
|
||||||
|
sshArgsArray: sshCommand.args,
|
||||||
|
// Show the last arg which contains the wrapped remote command
|
||||||
|
remoteCommandString: sshCommand.args[sshCommand.args.length - 1],
|
||||||
|
// Show the agent command that will execute remotely
|
||||||
|
agentBinary: remoteCommand,
|
||||||
|
agentArgs: sshArgs,
|
||||||
|
agentCwd: config.cwd,
|
||||||
|
// Full invocation for copy-paste debugging
|
||||||
|
fullSshInvocation: `${sshCommand.command} ${sshCommand.args.map(arg =>
|
||||||
|
arg.includes(' ') ? `'${arg}'` : arg
|
||||||
|
).join(' ')}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,10 +413,9 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
|||||||
// 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 with stream-json capable agents, pass the prompt so
|
// When using SSH with small prompts, the prompt was already added to sshArgs above
|
||||||
// ProcessManager can send it via stdin. For other SSH cases, prompt was
|
// For large prompts, pass it to ProcessManager so it can send via stdin
|
||||||
// already added to sshArgs, so pass undefined to prevent double-adding.
|
prompt: (sshRemoteUsed && config.prompt && config.prompt.length > 4000) ? config.prompt : (sshRemoteUsed ? undefined : config.prompt),
|
||||||
prompt: sshRemoteUsed && shouldSendPromptViaStdin ? config.prompt : sshRemoteUsed ? undefined : config.prompt,
|
|
||||||
shell: shellToUse,
|
shell: shellToUse,
|
||||||
shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions)
|
shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions)
|
||||||
shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions)
|
shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function getExpandedEnv(): NodeJS.ProcessEnv {
|
|||||||
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
||||||
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||||
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
||||||
|
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
|
||||||
|
|
||||||
additionalPaths = [
|
additionalPaths = [
|
||||||
path.join(appData, 'npm'),
|
path.join(appData, 'npm'),
|
||||||
@@ -34,7 +35,9 @@ export function getExpandedEnv(): NodeJS.ProcessEnv {
|
|||||||
path.join(programFiles, 'cloudflared'),
|
path.join(programFiles, 'cloudflared'),
|
||||||
path.join(home, 'scoop', 'shims'),
|
path.join(home, 'scoop', 'shims'),
|
||||||
path.join(process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', 'bin'),
|
path.join(process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', 'bin'),
|
||||||
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32'),
|
path.join(systemRoot, 'System32'),
|
||||||
|
// Windows OpenSSH (placed last so it's checked first due to unshift loop)
|
||||||
|
path.join(systemRoot, 'System32', 'OpenSSH'),
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
additionalPaths = [
|
additionalPaths = [
|
||||||
@@ -207,6 +210,20 @@ export async function detectSshPath(): Promise<string | null> {
|
|||||||
|
|
||||||
if (result.exitCode === 0 && result.stdout.trim()) {
|
if (result.exitCode === 0 && result.stdout.trim()) {
|
||||||
sshPathCache = result.stdout.trim().split('\n')[0];
|
sshPathCache = result.stdout.trim().split('\n')[0];
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
// Fallback for Windows: Check the built-in OpenSSH location directly
|
||||||
|
// This is the standard location for Windows 10/11 OpenSSH
|
||||||
|
const fs = await import('fs');
|
||||||
|
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
|
||||||
|
const opensshPath = path.join(systemRoot, 'System32', 'OpenSSH', 'ssh.exe');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(opensshPath)) {
|
||||||
|
sshPathCache = opensshPath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If check fails, leave sshPathCache as null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sshDetectionDone = true;
|
sshDetectionDone = true;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { execFileNoThrow, ExecResult } from './execFile';
|
|||||||
import { shellEscape } from './shell-escape';
|
import { shellEscape } from './shell-escape';
|
||||||
import { sshRemoteManager } from '../ssh-remote-manager';
|
import { sshRemoteManager } from '../ssh-remote-manager';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { resolveSshPath } from './cliDetection';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File or directory entry returned from readDir operations.
|
* File or directory entry returned from readDir operations.
|
||||||
@@ -147,6 +148,9 @@ async function execRemoteCommand(
|
|||||||
const { maxRetries, baseDelayMs, maxDelayMs } = DEFAULT_RETRY_CONFIG;
|
const { maxRetries, baseDelayMs, maxDelayMs } = DEFAULT_RETRY_CONFIG;
|
||||||
let lastResult: ExecResult | null = null;
|
let lastResult: ExecResult | null = null;
|
||||||
|
|
||||||
|
// Resolve SSH binary path (critical for Windows where spawn() doesn't search PATH)
|
||||||
|
const sshPath = await resolveSshPath();
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
const sshArgs = deps.buildSshArgs(config);
|
const sshArgs = deps.buildSshArgs(config);
|
||||||
sshArgs.push(remoteCommand);
|
sshArgs.push(remoteCommand);
|
||||||
@@ -154,11 +158,11 @@ async function execRemoteCommand(
|
|||||||
// Log SSH command for debugging connection issues
|
// Log SSH command for debugging connection issues
|
||||||
if (attempt === 0) {
|
if (attempt === 0) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[remote-fs] SSH to ${config.host}: ssh ${sshArgs.slice(0, -1).join(' ')} "<command>"`
|
`[remote-fs] SSH to ${config.host}: ${sshPath} ${sshArgs.slice(0, -1).join(' ')} "<command>"`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await deps.execSsh('ssh', sshArgs);
|
const result = await deps.execSsh(sshPath, sshArgs);
|
||||||
lastResult = result;
|
lastResult = result;
|
||||||
|
|
||||||
// Success - return immediately
|
// Success - return immediately
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface RemoteCommandOptions {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
/** Environment variables to set on the remote (optional) */
|
/** Environment variables to set on the remote (optional) */
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
|
/** Indicates the caller will send input via stdin to the remote command (optional) */
|
||||||
|
useStdin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +49,7 @@ const DEFAULT_SSH_OPTIONS: Record<string, string> = {
|
|||||||
StrictHostKeyChecking: 'accept-new', // Auto-accept new host keys
|
StrictHostKeyChecking: 'accept-new', // Auto-accept new host keys
|
||||||
ConnectTimeout: '10', // Connection timeout in seconds
|
ConnectTimeout: '10', // Connection timeout in seconds
|
||||||
ClearAllForwardings: 'yes', // Disable port forwarding from SSH config (avoids "Address already in use" errors)
|
ClearAllForwardings: 'yes', // Disable port forwarding from SSH config (avoids "Address already in use" errors)
|
||||||
RequestTTY: 'force', // Force TTY allocation - required for Claude Code's --print mode to produce output
|
RequestTTY: 'no', // Default: do NOT request a TTY. We only force a TTY for specific remote modes (e.g., --print)
|
||||||
LogLevel: 'ERROR', // Suppress SSH warnings like "Pseudo-terminal will not be allocated..."
|
LogLevel: 'ERROR', // Suppress SSH warnings like "Pseudo-terminal will not be allocated..."
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,13 +101,19 @@ export function buildRemoteCommand(options: RemoteCommandOptions): string {
|
|||||||
// Build the command with arguments
|
// Build the command with arguments
|
||||||
const commandWithArgs = buildShellCommand(command, args);
|
const commandWithArgs = buildShellCommand(command, args);
|
||||||
|
|
||||||
|
// If command expects JSON via stdin (stream-json), use exec to replace the
|
||||||
|
// shell process so stdin is delivered directly to the agent binary and no
|
||||||
|
// intermediate shell produces control sequences that could corrupt the stream.
|
||||||
|
const hasStreamJsonInput = options.useStdin ? true : (Array.isArray(args) && args.includes('--input-format') && args.includes('stream-json'));
|
||||||
|
const finalCommandWithArgs = hasStreamJsonInput ? `exec ${commandWithArgs}` : commandWithArgs;
|
||||||
|
|
||||||
// Combine env exports with command
|
// Combine env exports with command
|
||||||
let fullCommand: string;
|
let fullCommand: string;
|
||||||
if (envExports.length > 0) {
|
if (envExports.length > 0) {
|
||||||
// Prepend env vars inline: VAR1='val1' VAR2='val2' command args
|
// Prepend env vars inline: VAR1='val1' VAR2='val2' command args
|
||||||
fullCommand = `${envExports.join(' ')} ${commandWithArgs}`;
|
fullCommand = `${envExports.join(' ')} ${finalCommandWithArgs}`;
|
||||||
} else {
|
} else {
|
||||||
fullCommand = commandWithArgs;
|
fullCommand = finalCommandWithArgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(fullCommand);
|
parts.push(fullCommand);
|
||||||
@@ -175,9 +183,29 @@ export async function buildSshCommand(
|
|||||||
// Resolve the SSH binary path (handles packaged Electron apps where PATH is limited)
|
// Resolve the SSH binary path (handles packaged Electron apps where PATH is limited)
|
||||||
const sshPath = await resolveSshPath();
|
const sshPath = await resolveSshPath();
|
||||||
|
|
||||||
// Force TTY allocation - required for Claude Code's --print mode to produce output
|
// Decide whether we need to force a TTY for the remote command.
|
||||||
// Without a TTY, Claude Code with --print hangs indefinitely
|
// Historically we forced a TTY for Claude Code when running with `--print`.
|
||||||
args.push('-tt');
|
// However, for stream-json input (sending JSON via stdin) a TTY injects terminal
|
||||||
|
// control sequences that corrupt the stream. Only enable forced TTY for cases
|
||||||
|
// that explicitly require it (e.g., `--print` without `--input-format stream-json`).
|
||||||
|
const remoteArgs = remoteOptions.args || [];
|
||||||
|
const hasPrintFlag = remoteArgs.includes('--print');
|
||||||
|
const hasStreamJsonInput = remoteOptions.useStdin ? true : (remoteArgs.includes('--input-format') && remoteArgs.includes('stream-json'));
|
||||||
|
const forceTty = Boolean(hasPrintFlag && !hasStreamJsonInput);
|
||||||
|
|
||||||
|
// Log the decision so callers can debug why a TTY was or was not forced
|
||||||
|
logger.debug('SSH TTY decision', '[ssh-command-builder]', {
|
||||||
|
host: config.host,
|
||||||
|
useStdinFlag: !!remoteOptions.useStdin,
|
||||||
|
hasPrintFlag,
|
||||||
|
hasStreamJsonInput,
|
||||||
|
forceTty,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (forceTty) {
|
||||||
|
// -tt must come first for reliable forced allocation in some SSH implementations
|
||||||
|
args.push('-tt');
|
||||||
|
}
|
||||||
|
|
||||||
// When using SSH config, we let SSH handle authentication settings
|
// When using SSH config, we let SSH handle authentication settings
|
||||||
// Only add explicit overrides if provided
|
// Only add explicit overrides if provided
|
||||||
@@ -192,9 +220,15 @@ export async function buildSshCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default SSH options for non-interactive operation
|
// Default SSH options for non-interactive operation
|
||||||
// These are always needed to ensure BatchMode behavior
|
// These are always needed to ensure BatchMode behavior. If `forceTty` is true,
|
||||||
|
// override RequestTTY to `force` so SSH will allocate a TTY even in non-interactive contexts.
|
||||||
for (const [key, value] of Object.entries(DEFAULT_SSH_OPTIONS)) {
|
for (const [key, value] of Object.entries(DEFAULT_SSH_OPTIONS)) {
|
||||||
args.push('-o', `${key}=${value}`);
|
// If we will force a TTY for this command, override the RequestTTY option
|
||||||
|
if (key === 'RequestTTY' && forceTty) {
|
||||||
|
args.push('-o', `${key}=force`);
|
||||||
|
} else {
|
||||||
|
args.push('-o', `${key}=${value}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port specification - only add if not default and not using SSH config
|
// Port specification - only add if not default and not using SSH config
|
||||||
@@ -238,17 +272,14 @@ export async function buildSshCommand(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wrap the command to execute via the user's login shell.
|
// 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.
|
// $SHELL -ilc ensures the user's full PATH (including homebrew, nvm, etc.) is available.
|
||||||
// -l loads login profile for PATH (sources /etc/profile, ~/.bash_profile, etc.)
|
// -i forces interactive mode (critical for .bashrc to not bail out)
|
||||||
|
// -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:
|
// WHY -i IS CRITICAL:
|
||||||
// 1. It can cause issues with shells that check if stdin is a TTY
|
// On Ubuntu (and many Linux distros), .bashrc has a guard at the top:
|
||||||
// 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
|
// case $- in *i*) ;; *) return;; esac
|
||||||
// By explicitly sourcing it, we bypass this guard.
|
// By explicitly sourcing it, we bypass this guard.
|
||||||
//
|
//
|
||||||
@@ -274,11 +305,19 @@ export async function buildSshCommand(
|
|||||||
args.push(wrappedCommand);
|
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.debug('Built SSH command', '[ssh-command-builder]', {
|
||||||
|
host: config.host,
|
||||||
|
username: config.username,
|
||||||
|
port: config.port,
|
||||||
|
useSshConfig: config.useSshConfig,
|
||||||
|
privateKeyPath: config.privateKeyPath ? '***configured***' : undefined,
|
||||||
remoteCommand,
|
remoteCommand,
|
||||||
wrappedCommand,
|
wrappedCommand,
|
||||||
sshPath,
|
sshPath,
|
||||||
|
sshArgs: args,
|
||||||
fullCommand: `${sshPath} ${args.join(' ')}`,
|
fullCommand: `${sshPath} ${args.join(' ')}`,
|
||||||
|
// Show the exact command string that will execute on the remote
|
||||||
|
remoteExecutionString: wrappedCommand,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6326,6 +6326,7 @@ You are taking over this conversation. Based on the context above, provide a bri
|
|||||||
customEnvVars: activeSession.customEnvVars,
|
customEnvVars: activeSession.customEnvVars,
|
||||||
customModel: activeSession.customModel,
|
customModel: activeSession.customModel,
|
||||||
customContextWindow: activeSession.customContextWindow,
|
customContextWindow: activeSession.customContextWindow,
|
||||||
|
sessionSshRemoteConfig: activeSession.sessionSshRemoteConfig,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -409,6 +409,12 @@ export interface SerializableWizardState {
|
|||||||
generatedDocuments: GeneratedDocument[];
|
generatedDocuments: GeneratedDocument[];
|
||||||
editedPhase1Content: string | null;
|
editedPhase1Content: string | null;
|
||||||
wantsTour: boolean;
|
wantsTour: boolean;
|
||||||
|
/** Per-session SSH remote configuration (for remote execution) */
|
||||||
|
sessionSshRemoteConfig?: {
|
||||||
|
enabled: boolean;
|
||||||
|
remoteId: string | null;
|
||||||
|
workingDirOverride?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -755,6 +761,7 @@ export function WizardProvider({ children }: WizardProviderProps) {
|
|||||||
generatedDocuments: state.generatedDocuments,
|
generatedDocuments: state.generatedDocuments,
|
||||||
editedPhase1Content: state.editedPhase1Content,
|
editedPhase1Content: state.editedPhase1Content,
|
||||||
wantsTour: state.wantsTour,
|
wantsTour: state.wantsTour,
|
||||||
|
sessionSshRemoteConfig: state.sessionSshRemoteConfig,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
state.currentStep,
|
state.currentStep,
|
||||||
@@ -768,6 +775,7 @@ export function WizardProvider({ children }: WizardProviderProps) {
|
|||||||
state.generatedDocuments,
|
state.generatedDocuments,
|
||||||
state.editedPhase1Content,
|
state.editedPhase1Content,
|
||||||
state.wantsTour,
|
state.wantsTour,
|
||||||
|
state.sessionSshRemoteConfig,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const saveStateForResume = useCallback(() => {
|
const saveStateForResume = useCallback(() => {
|
||||||
|
|||||||
@@ -74,11 +74,21 @@ export function WizardResumeModal({
|
|||||||
let dirValid = true;
|
let dirValid = true;
|
||||||
if (resumeState.directoryPath) {
|
if (resumeState.directoryPath) {
|
||||||
try {
|
try {
|
||||||
|
// Get SSH remote ID from resume state (for remote execution)
|
||||||
|
const sshRemoteId = resumeState.sessionSshRemoteConfig?.enabled
|
||||||
|
? resumeState.sessionSshRemoteConfig.remoteId ?? undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Use git.isRepo which will fail if directory doesn't exist
|
// Use git.isRepo which will fail if directory doesn't exist
|
||||||
await window.maestro.git.isRepo(resumeState.directoryPath);
|
// For SSH remotes, pass the path as remoteCwd so git can operate in the correct directory
|
||||||
} catch {
|
await window.maestro.git.isRepo(
|
||||||
|
resumeState.directoryPath,
|
||||||
|
sshRemoteId,
|
||||||
|
sshRemoteId ? resumeState.directoryPath : undefined
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
// Directory doesn't exist or is inaccessible
|
// Directory doesn't exist or is inaccessible
|
||||||
dirValid = false;
|
dirValid = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ export function ConversationScreen({
|
|||||||
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,
|
sshRemoteConfig: state.sessionSshRemoteConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -813,6 +813,7 @@ export function ConversationScreen({
|
|||||||
agentType: state.selectedAgent,
|
agentType: state.selectedAgent,
|
||||||
directoryPath: state.directoryPath,
|
directoryPath: state.directoryPath,
|
||||||
projectName: state.agentName || 'My Project',
|
projectName: state.agentName || 'My Project',
|
||||||
|
sshRemoteConfig: state.sessionSshRemoteConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1067,7 +1068,7 @@ export function ConversationScreen({
|
|||||||
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,
|
sshRemoteConfig: state.sessionSshRemoteConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
|
|||||||
const continueButtonRef = useRef<HTMLButtonElement>(null);
|
const continueButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Ref for debouncing validation
|
||||||
|
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch agent config when selected agent changes
|
* Fetch agent config when selected agent changes
|
||||||
*/
|
*/
|
||||||
@@ -125,6 +128,17 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
|
|||||||
setIsDetecting(false);
|
setIsDetecting(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup validation timeout on unmount
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (validationTimeoutRef.current) {
|
||||||
|
clearTimeout(validationTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load SSH remote host name when remote is configured
|
* Load SSH remote host name when remote is configured
|
||||||
*/
|
*/
|
||||||
@@ -226,7 +240,8 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Directory exists, now check if it's a git repo
|
// Directory exists, now check if it's a git repo
|
||||||
const isRepo = await window.maestro.git.isRepo(path, sshRemoteId);
|
// For SSH remotes, pass the path as remoteCwd so git can operate in the correct directory
|
||||||
|
const isRepo = await window.maestro.git.isRepo(path, sshRemoteId, sshRemoteId ? path : undefined);
|
||||||
setIsGitRepo(isRepo);
|
setIsGitRepo(isRepo);
|
||||||
setDirectoryError(null);
|
setDirectoryError(null);
|
||||||
|
|
||||||
@@ -288,19 +303,24 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
|
|||||||
const newPath = e.target.value;
|
const newPath = e.target.value;
|
||||||
setDirectoryPath(newPath);
|
setDirectoryPath(newPath);
|
||||||
|
|
||||||
// Debounce validation to avoid excessive API calls while typing
|
// Clear any pending validation
|
||||||
if (newPath.trim()) {
|
if (validationTimeoutRef.current) {
|
||||||
const timeoutId = setTimeout(() => {
|
clearTimeout(validationTimeoutRef.current);
|
||||||
validateDirectory(newPath);
|
validationTimeoutRef.current = null;
|
||||||
}, 500);
|
}
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
} else {
|
// Debounce validation to avoid excessive API calls while typing (especially over SSH)
|
||||||
setDirectoryError(null);
|
if (newPath.trim()) {
|
||||||
setIsGitRepo(false);
|
validationTimeoutRef.current = setTimeout(() => {
|
||||||
}
|
validateDirectory(newPath);
|
||||||
},
|
validationTimeoutRef.current = null;
|
||||||
[setDirectoryPath, setDirectoryError, setIsGitRepo, validateDirectory]
|
}, 800); // 800ms debounce for SSH remote checks
|
||||||
);
|
} else {
|
||||||
|
setDirectoryError(null);
|
||||||
|
setIsGitRepo(false);
|
||||||
|
setHasExistingAutoRunDocs(false, 0);
|
||||||
|
}
|
||||||
|
}, [setDirectoryPath, setDirectoryError, setIsGitRepo, setHasExistingAutoRunDocs, validateDirectory]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle browse button click - open native folder picker
|
* Handle browse button click - open native folder picker
|
||||||
|
|||||||
@@ -726,6 +726,7 @@ export function PreparingPlanScreen({ theme }: PreparingPlanScreenProps): JSX.El
|
|||||||
projectName: state.agentName || 'My Project',
|
projectName: state.agentName || 'My Project',
|
||||||
conversationHistory: state.conversationHistory,
|
conversationHistory: state.conversationHistory,
|
||||||
subfolder: 'Initiation',
|
subfolder: 'Initiation',
|
||||||
|
sshRemoteConfig: state.sessionSshRemoteConfig,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onStart: () => {
|
onStart: () => {
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export interface ConversationConfig {
|
|||||||
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 */
|
/** SSH remote configuration (for remote execution) */
|
||||||
sessionSshRemoteConfig?: {
|
sshRemoteConfig?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
remoteId: string | null;
|
remoteId: string | null;
|
||||||
workingDirOverride?: string;
|
workingDirOverride?: string;
|
||||||
@@ -118,8 +118,8 @@ interface ConversationSession {
|
|||||||
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 */
|
/** SSH remote configuration (for remote execution) */
|
||||||
sessionSshRemoteConfig?: {
|
sshRemoteConfig?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
remoteId: string | null;
|
remoteId: string | null;
|
||||||
workingDirOverride?: string;
|
workingDirOverride?: string;
|
||||||
@@ -174,7 +174,7 @@ class ConversationManager {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
outputBuffer: '',
|
outputBuffer: '',
|
||||||
sessionSshRemoteConfig: config.sessionSshRemoteConfig,
|
sshRemoteConfig: config.sshRemoteConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log conversation start
|
// Log conversation start
|
||||||
@@ -185,8 +185,8 @@ class ConversationManager {
|
|||||||
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,
|
hasRemoteSsh: !!config.sshRemoteConfig?.enabled,
|
||||||
remoteId: config.sessionSshRemoteConfig?.remoteId || null,
|
remoteId: config.sshRemoteConfig?.remoteId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return sessionId;
|
return sessionId;
|
||||||
@@ -234,51 +234,16 @@ class ConversationManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the agent configuration
|
// Get the agent configuration
|
||||||
// Pass SSH remote ID if SSH is enabled for this session
|
const agent = await window.maestro.agents.get(this.session.agentType);
|
||||||
const sshRemoteId = this.session.sessionSshRemoteConfig?.enabled
|
|
||||||
? this.session.sessionSshRemoteConfig.remoteId
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
wizardDebugLogger.log('info', 'Fetching agent configuration', {
|
// For SSH remote sessions, skip the availability check since we're executing remotely
|
||||||
agentType: this.session.agentType,
|
// The agent detector checks for binaries locally, but we need to execute on the remote host
|
||||||
sessionId: this.session.sessionId,
|
const isRemoteSession = this.session.sshRemoteConfig?.enabled && this.session.sshRemoteConfig?.remoteId;
|
||||||
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);
|
if (!agent) {
|
||||||
|
const error = `Agent ${this.session.agentType} configuration not found`;
|
||||||
// Log to main process (writes to maestro-debug.log on Windows)
|
wizardDebugLogger.log('error', 'Agent config not found', {
|
||||||
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,
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -286,7 +251,71 @@ class ConversationManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Wizard] Agent is available, building prompt...');
|
// Only check availability for local sessions
|
||||||
|
if (!isRemoteSession && !agent.available) {
|
||||||
|
const error = `Agent ${this.session.agentType} is not available locally`;
|
||||||
|
wizardDebugLogger.log('error', 'Agent not available locally', {
|
||||||
|
agentType: this.session.agentType,
|
||||||
|
agent: {
|
||||||
|
available: agent.available,
|
||||||
|
path: agent.path,
|
||||||
|
command: agent.command,
|
||||||
|
customPath: (agent as any).customPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.error('[Wizard] Agent not available locally:', {
|
||||||
|
agentType: this.session.agentType,
|
||||||
|
agentAvailable: agent.available,
|
||||||
|
agentPath: agent.path,
|
||||||
|
agentCommand: agent.command,
|
||||||
|
agentCustomPath: (agent as any)?.customPath,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check availability for local sessions
|
||||||
|
if (!isRemoteSession && !agent.available) {
|
||||||
|
const error = `Agent ${this.session.agentType} is not available locally`;
|
||||||
|
wizardDebugLogger.log('error', 'Agent not available locally', {
|
||||||
|
agentType: this.session.agentType,
|
||||||
|
agent: {
|
||||||
|
available: agent.available,
|
||||||
|
path: agent.path,
|
||||||
|
command: agent.command,
|
||||||
|
customPath: (agent as any).customPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.error('[Wizard] Agent not available locally:', {
|
||||||
|
agentType: this.session.agentType,
|
||||||
|
agentAvailable: agent.available,
|
||||||
|
agentPath: agent.path,
|
||||||
|
agentCommand: agent.command,
|
||||||
|
agentCustomPath: (agent as any)?.customPath,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For remote sessions, log that we're skipping the availability check
|
||||||
|
if (isRemoteSession) {
|
||||||
|
wizardDebugLogger.log('info', 'Executing agent on SSH remote (skipping local availability check)', {
|
||||||
|
agentType: this.session.agentType,
|
||||||
|
remoteId: this.session.sshRemoteConfig?.remoteId,
|
||||||
|
agentCommand: agent.command,
|
||||||
|
agentPath: agent.path,
|
||||||
|
agentCustomPath: (agent as any).customPath,
|
||||||
|
});
|
||||||
|
console.log('[Wizard] Executing agent on SSH remote:', {
|
||||||
|
agentType: this.session.agentType,
|
||||||
|
remoteId: this.session.sshRemoteConfig?.remoteId,
|
||||||
|
agentCommand: agent.command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -532,9 +561,23 @@ class ConversationManager {
|
|||||||
// Each agent has different CLI structure for batch mode
|
// Each agent has different CLI structure for batch mode
|
||||||
const argsForSpawn = this.buildArgsForAgent(agent);
|
const argsForSpawn = this.buildArgsForAgent(agent);
|
||||||
|
|
||||||
// Use the agent's resolved path if available, falling back to command name
|
// Use the agent's resolved path if available, falling back to command name
|
||||||
// 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;
|
||||||
|
|
||||||
|
// Log spawn details to main process
|
||||||
|
console.log('[Wizard] Preparing to spawn agent process:', {
|
||||||
|
sessionId: this.session!.sessionId,
|
||||||
|
toolType: this.session!.agentType,
|
||||||
|
command: commandToUse,
|
||||||
|
agentPath: agent.path,
|
||||||
|
agentCommand: agent.command,
|
||||||
|
argsCount: argsForSpawn.length,
|
||||||
|
cwd: this.session!.directoryPath,
|
||||||
|
hasRemoteSsh: !!this.session!.sshRemoteConfig?.enabled,
|
||||||
|
remoteId: this.session!.sshRemoteConfig?.remoteId || null,
|
||||||
|
sshConfig: this.session!.sshRemoteConfig,
|
||||||
|
});
|
||||||
|
|
||||||
wizardDebugLogger.log('spawn', 'Calling process.spawn', {
|
wizardDebugLogger.log('spawn', 'Calling process.spawn', {
|
||||||
sessionId: this.session!.sessionId,
|
sessionId: this.session!.sessionId,
|
||||||
@@ -543,8 +586,8 @@ class ConversationManager {
|
|||||||
agentCommand: agent.command,
|
agentCommand: agent.command,
|
||||||
args: argsForSpawn,
|
args: argsForSpawn,
|
||||||
cwd: this.session!.directoryPath,
|
cwd: this.session!.directoryPath,
|
||||||
hasRemoteSsh: !!this.session!.sessionSshRemoteConfig?.enabled,
|
hasRemoteSsh: !!this.session!.sshRemoteConfig?.enabled,
|
||||||
remoteId: this.session!.sessionSshRemoteConfig?.remoteId || null,
|
remoteId: this.session!.sshRemoteConfig?.remoteId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
window.maestro.process
|
window.maestro.process
|
||||||
@@ -555,7 +598,8 @@ class ConversationManager {
|
|||||||
command: commandToUse,
|
command: commandToUse,
|
||||||
args: argsForSpawn,
|
args: argsForSpawn,
|
||||||
prompt: prompt,
|
prompt: prompt,
|
||||||
sessionSshRemoteConfig: this.session!.sessionSshRemoteConfig,
|
// Pass SSH configuration for remote execution
|
||||||
|
sessionSshRemoteConfig: this.session!.sshRemoteConfig,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
wizardDebugLogger.log('spawn', 'Agent process spawned successfully', {
|
wizardDebugLogger.log('spawn', 'Agent process spawned successfully', {
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export interface GenerationConfig {
|
|||||||
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 */
|
/** SSH remote configuration (for remote execution) */
|
||||||
sessionSshRemoteConfig?: {
|
sshRemoteConfig?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
remoteId: string | null;
|
remoteId: string | null;
|
||||||
workingDirOverride?: string;
|
workingDirOverride?: string;
|
||||||
@@ -576,23 +576,52 @@ class PhaseGenerator {
|
|||||||
callbacks?.onStart?.();
|
callbacks?.onStart?.();
|
||||||
callbacks?.onProgress?.('Preparing to generate your Playbook...');
|
callbacks?.onProgress?.('Preparing to generate your Playbook...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the agent configuration
|
// Get the agent configuration
|
||||||
wizardDebugLogger.log('info', 'Fetching agent configuration', {
|
wizardDebugLogger.log('info', 'Fetching agent configuration', { agentType: config.agentType });
|
||||||
agentType: config.agentType,
|
const agent = await window.maestro.agents.get(config.agentType);
|
||||||
});
|
|
||||||
const agent = await window.maestro.agents.get(config.agentType);
|
// For SSH remote sessions, skip the availability check since we're executing remotely
|
||||||
if (!agent || !agent.available) {
|
// The agent detector checks for binaries locally, but we need to execute on the remote host
|
||||||
wizardDebugLogger.log('error', 'Agent not available', {
|
const isRemoteSession = config.sshRemoteConfig?.enabled && config.sshRemoteConfig?.remoteId;
|
||||||
agentType: config.agentType,
|
|
||||||
agent,
|
if (!agent) {
|
||||||
});
|
wizardDebugLogger.log('error', 'Agent configuration not found', { agentType: config.agentType });
|
||||||
throw new Error(`Agent ${config.agentType} is not available`);
|
throw new Error(`Agent ${config.agentType} configuration not found`);
|
||||||
}
|
}
|
||||||
wizardDebugLogger.log('info', 'Agent configuration retrieved', {
|
|
||||||
command: agent.command,
|
// Only check availability for local sessions
|
||||||
argsCount: agent.args?.length || 0,
|
if (!isRemoteSession && !agent.available) {
|
||||||
});
|
wizardDebugLogger.log('error', 'Agent not available locally', { agentType: config.agentType, agent });
|
||||||
|
|
||||||
|
// Provide helpful error message with guidance
|
||||||
|
let errorMsg = `The ${config.agentType} agent is not available locally.`;
|
||||||
|
|
||||||
|
if (agent?.customPath) {
|
||||||
|
errorMsg += `\n\nThe custom path "${agent.customPath}" is not valid. The file may not exist or may not be executable.`;
|
||||||
|
errorMsg += `\n\nTo fix this:\n1. Click "Go Back" to return to agent selection\n2. Click the settings icon on the agent tile\n3. Update the custom path or clear it to use the system PATH\n4. Click "Refresh" to re-detect the agent`;
|
||||||
|
} else {
|
||||||
|
errorMsg += `\n\nThe agent was not found in your system PATH.`;
|
||||||
|
errorMsg += `\n\nTo fix this:\n1. Install ${config.agentType} on your system\n2. Or click "Go Back" and configure a custom path in the agent settings`;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For remote sessions, log that we're skipping the availability check
|
||||||
|
if (isRemoteSession) {
|
||||||
|
wizardDebugLogger.log('info', 'Executing agent on SSH remote (skipping local availability check)', {
|
||||||
|
agentType: config.agentType,
|
||||||
|
remoteId: config.sshRemoteConfig?.remoteId,
|
||||||
|
agentCommand: agent.command,
|
||||||
|
agentPath: agent.path,
|
||||||
|
agentCustomPath: (agent as any).customPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wizardDebugLogger.log('info', 'Agent configuration retrieved', {
|
||||||
|
command: agent.command,
|
||||||
|
argsCount: agent.args?.length || 0,
|
||||||
|
});
|
||||||
|
|
||||||
// Generate the prompt
|
// Generate the prompt
|
||||||
const prompt = generateDocumentGenerationPrompt(config);
|
const prompt = generateDocumentGenerationPrompt(config);
|
||||||
@@ -1080,7 +1109,8 @@ class PhaseGenerator {
|
|||||||
command: commandToUse,
|
command: commandToUse,
|
||||||
args: argsForSpawn,
|
args: argsForSpawn,
|
||||||
prompt,
|
prompt,
|
||||||
sessionSshRemoteConfig: config.sessionSshRemoteConfig,
|
// Pass SSH configuration for remote execution
|
||||||
|
sessionSshRemoteConfig: config.sshRemoteConfig,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('[PhaseGenerator] Agent spawned successfully');
|
console.log('[PhaseGenerator] Agent spawned successfully');
|
||||||
|
|||||||
@@ -26,14 +26,30 @@ const FILE_TREE_RETRY_DELAY_MS = 20000;
|
|||||||
* we must fall back to sessionSshRemoteConfig.remoteId. See CLAUDE.md "SSH Remote Sessions".
|
* we must fall back to sessionSshRemoteConfig.remoteId. See CLAUDE.md "SSH Remote Sessions".
|
||||||
*/
|
*/
|
||||||
function getSshContext(session: Session): SshContext | undefined {
|
function getSshContext(session: Session): SshContext | undefined {
|
||||||
const sshRemoteId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined;
|
// First check if there's a spawned sshRemoteId (set by agent spawn)
|
||||||
if (!sshRemoteId) {
|
let sshRemoteId: string | undefined = session.sshRemoteId;
|
||||||
return undefined;
|
|
||||||
}
|
// Fall back to sessionSshRemoteConfig if enabled and has a valid remoteId
|
||||||
return {
|
// Note: remoteId can be `null` per the type definition, so we explicitly check for truthiness
|
||||||
sshRemoteId,
|
if (!sshRemoteId && session.sessionSshRemoteConfig?.enabled && session.sessionSshRemoteConfig?.remoteId) {
|
||||||
remoteCwd: session.remoteCwd || session.sessionSshRemoteConfig?.workingDirOverride,
|
sshRemoteId = session.sessionSshRemoteConfig.remoteId;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
console.log('[getSshContext] session.sshRemoteId:', session.sshRemoteId);
|
||||||
|
console.log('[getSshContext] session.sessionSshRemoteConfig:', session.sessionSshRemoteConfig);
|
||||||
|
console.log('[getSshContext] resolved sshRemoteId:', sshRemoteId);
|
||||||
|
|
||||||
|
if (!sshRemoteId) {
|
||||||
|
console.log('[getSshContext] No SSH remote ID found, returning undefined');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
sshRemoteId,
|
||||||
|
remoteCwd: session.remoteCwd || session.sessionSshRemoteConfig?.workingDirOverride,
|
||||||
|
};
|
||||||
|
console.log('[getSshContext] Returning context:', context);
|
||||||
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { RightPanelHandle } from '../../components/RightPanel';
|
export type { RightPanelHandle } from '../../components/RightPanel';
|
||||||
|
|||||||
Reference in New Issue
Block a user