diff --git a/src/__tests__/main/agent-detector.test.ts b/src/__tests__/main/agent-detector.test.ts index 8cd74813..58f8cddf 100644 --- a/src/__tests__/main/agent-detector.test.ts +++ b/src/__tests__/main/agent-detector.test.ts @@ -392,11 +392,10 @@ describe('agent-detector', () => { expect.stringContaining('Agent detection starting'), 'AgentDetector' ); - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('Agent detection complete'), - 'AgentDetector', - expect.any(Object) - ); + const calls = logger.info.mock.calls; + const completeCall = calls.find((call) => call[0].includes('Agent detection complete')); + expect(completeCall).toBeDefined(); + expect(completeCall[1]).toBe('AgentDetector'); }); it('should log when agents are found', async () => { diff --git a/src/__tests__/main/autorun-folder-validation.test.ts b/src/__tests__/main/autorun-folder-validation.test.ts index 63ee6509..a3d47b89 100644 --- a/src/__tests__/main/autorun-folder-validation.test.ts +++ b/src/__tests__/main/autorun-folder-validation.test.ts @@ -376,7 +376,7 @@ describe('Auto Run Folder Validation', () => { expect(validatePathWithinFolder(joined, folderPath)).toBe(true); // It's still within the folder } else { // On Unix, path.join with absolute second arg gives the absolute path - expect(joined).toBe('/etc/passwd'); + expect(joined).toBe('/test/autorun/etc/passwd'); expect(validatePathWithinFolder(joined, folderPath)).toBe(false); } diff --git a/src/__tests__/main/ipc/handlers/process.test.ts b/src/__tests__/main/ipc/handlers/process.test.ts index 86b7fb71..439f1561 100644 --- a/src/__tests__/main/ipc/handlers/process.test.ts +++ b/src/__tests__/main/ipc/handlers/process.test.ts @@ -240,6 +240,9 @@ describe('process IPC handlers', () => { name: 'Claude Code', requiresPty: true, path: '/usr/local/bin/claude', + capabilities: { + supportsStreamJsonInput: true, + }, }; mockAgentDetector.getAgent.mockResolvedValue(mockAgent); @@ -866,6 +869,9 @@ describe('process IPC handlers', () => { const mockAgent = { id: 'claude-code', requiresPty: true, // Note: should be disabled when using SSH + capabilities: { + supportsStreamJsonInput: true, + }, }; mockAgentDetector.getAgent.mockResolvedValue(mockAgent); @@ -946,6 +952,9 @@ describe('process IPC handlers', () => { const mockAgent = { id: 'claude-code', requiresPty: false, + capabilities: { + supportsStreamJsonInput: true, + }, }; // Mock applyAgentConfigOverrides to return custom env vars @@ -1073,6 +1082,9 @@ describe('process IPC handlers', () => { const mockAgent = { id: 'claude-code', requiresPty: false, + capabilities: { + supportsStreamJsonInput: true, + }, }; mockAgentDetector.getAgent.mockResolvedValue(mockAgent); @@ -1120,6 +1132,9 @@ describe('process IPC handlers', () => { binaryName: 'codex', // Just the binary name, without path path: '/opt/homebrew/bin/codex', // Local macOS path requiresPty: false, + capabilities: { + supportsStreamJsonInput: false, + }, }; mockAgentDetector.getAgent.mockResolvedValue(mockAgent); @@ -1161,6 +1176,9 @@ describe('process IPC handlers', () => { binaryName: 'codex', path: '/opt/homebrew/bin/codex', // Local path requiresPty: false, + capabilities: { + supportsStreamJsonInput: false, + }, }; mockAgentDetector.getAgent.mockResolvedValue(mockAgent); diff --git a/src/__tests__/renderer/hooks/useInputProcessing.test.ts b/src/__tests__/renderer/hooks/useInputProcessing.test.ts index cdcc5b9d..1e11099d 100644 --- a/src/__tests__/renderer/hooks/useInputProcessing.test.ts +++ b/src/__tests__/renderer/hooks/useInputProcessing.test.ts @@ -547,10 +547,10 @@ describe('useInputProcessing', () => { isBuiltIn: true, }, { - id: 'speckit-specify', - command: '/commit', - description: 'Create a feature spec', - prompt: 'Create a spec for: $ARGUMENTS', + id: 'test-command', + command: '/testcommand', + description: 'Test command', + prompt: 'Test: $ARGUMENTS', isBuiltIn: true, }, ]; @@ -559,7 +559,7 @@ describe('useInputProcessing', () => { vi.useFakeTimers(); const deps = createDeps({ - inputValue: '/speckit.constitution Blah blah blah', + inputValue: '/testcommand Blah blah blah', customAICommands: speckitCommandsWithArgs, }); const { result } = renderHook(() => useInputProcessing(deps)); @@ -583,7 +583,7 @@ describe('useInputProcessing', () => { const queuedItem = callArgs[1] as QueuedItem; expect(queuedItem.type).toBe('command'); - expect(queuedItem.command).toBe('/speckit.constitution'); + expect(queuedItem.command).toBe('/testcommand'); expect(queuedItem.commandArgs).toBe('Blah blah blah'); vi.useRealTimers(); @@ -619,7 +619,7 @@ describe('useInputProcessing', () => { vi.useFakeTimers(); const deps = createDeps({ - inputValue: '/commit Add user authentication with OAuth 2.0 support', + inputValue: '/testcommand Add user authentication with OAuth 2.0 support', customAICommands: speckitCommandsWithArgs, }); const { result } = renderHook(() => useInputProcessing(deps)); @@ -633,7 +633,7 @@ describe('useInputProcessing', () => { }); const queuedItem = mockProcessQueuedItemRef.current.mock.calls[0][1] as QueuedItem; - expect(queuedItem.command).toBe('/commit'); + expect(queuedItem.command).toBe('/testcommand'); expect(queuedItem.commandArgs).toBe('Add user authentication with OAuth 2.0 support'); vi.useRealTimers(); diff --git a/src/main/agent-capabilities.ts b/src/main/agent-capabilities.ts index 30790b4f..2aeb56c9 100644 --- a/src/main/agent-capabilities.ts +++ b/src/main/agent-capabilities.ts @@ -269,7 +269,7 @@ export const AGENT_CAPABILITIES: Record = { supportsStreaming: true, // Streams JSONL events - Verified supportsResultMessages: true, // step_finish with part.reason:"stop" - Verified supportsModelSelection: true, // --model provider/model (e.g., 'ollama/qwen3:8b') - Verified - supportsStreamJsonInput: false, // Uses -f, --file flag instead + supportsStreamJsonInput: false, // Uses positional arguments for prompt supportsThinkingDisplay: true, // Emits streaming text chunks supportsContextMerge: true, // Can receive merged context via prompts supportsContextExport: true, // Session storage supports context export diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index 14eda661..569ea5f9 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -18,6 +18,7 @@ import { } from '../../utils/ipcHandler'; import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver'; import { buildSshCommand } from '../../utils/ssh-command-builder'; +import { buildExpandedEnv } from '../../../shared/pathUtils'; import type { SshRemoteConfig } from '../../../shared/types'; import { powerManager } from '../../power-manager'; import { MaestroSettings } from './persistence'; @@ -262,6 +263,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void let useShell = false; let sshRemoteUsed: SshRemoteConfig | null = null; let customEnvVarsToPass: Record | undefined = effectiveCustomEnvVars; + let useHereDocForOpenCode = false; if (config.sessionCustomPath) { logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, { @@ -273,13 +275,11 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // On Windows (except SSH), always use shell execution for agents if (isWindows && !config.sessionSshRemoteConfig?.enabled) { useShell = true; - // Merge process.env with custom env vars, to ensure PATH is present - // Only keep string values (filter out undefined) + // Use expanded environment with custom env vars to ensure PATH includes all binary locations + const expandedEnv = buildExpandedEnv(customEnvVarsToPass); + // Filter out undefined values to match Record type customEnvVarsToPass = Object.fromEntries( - Object.entries({ - ...process.env, - ...(customEnvVarsToPass || {}), - }).filter(([_, v]) => typeof v === 'string') + Object.entries(expandedEnv).filter(([_, value]) => value !== undefined) ) as Record; // Determine an explicit shell to use when forcing shell execution on Windows. @@ -320,6 +320,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void }); } let shouldSendPromptViaStdin = false; + let shouldSendPromptViaStdinRaw = false; if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig?.enabled) { // Session-level SSH config provided - resolve and use it logger.info(`Using session-level SSH config`, LOG_CONTEXT, { @@ -351,6 +352,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void const isLargePrompt = config.prompt && config.prompt.length > 4000; const hasStreamJsonInput = finalArgs.includes('--input-format') && finalArgs.includes('stream-json'); + const agentSupportsStreamJson = agent?.capabilities.supportsStreamJsonInput ?? false; let sshArgs = finalArgs; if (config.prompt && !isLargePrompt && !hasStreamJsonInput) { // Small prompt - embed in command line as usual (only if not using stream-json input) @@ -361,8 +363,12 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void } else { sshArgs = [...finalArgs, '--', config.prompt]; } - } else if (config.prompt && (isLargePrompt || hasStreamJsonInput)) { - // Large prompt or stream-json input - ensure --input-format stream-json is present + } else if ( + config.prompt && + (isLargePrompt || hasStreamJsonInput) && + agentSupportsStreamJson + ) { + // Large prompt or stream-json input, and agent supports it - use stdin if (!hasStreamJsonInput) { sshArgs = [...finalArgs, '--input-format', 'stream-json']; } @@ -375,16 +381,43 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void : 'stream-json-input-mode', hasStreamJsonInput, }); + } else if (config.prompt && isLargePrompt && !agentSupportsStreamJson) { + // Large prompt but agent doesn't support stream-json + if (config.toolType === 'opencode') { + // OpenCode: mark for here document processing (will be handled after remoteCommand is set) + useHereDocForOpenCode = true; + } else { + // Other agents: send via stdin as raw text + shouldSendPromptViaStdinRaw = true; + } } - - // Build the SSH command that wraps the agent execution // // Determine the command to run on the remote host: // 1. If user set a session-specific custom path, use that (they configured it for the remote) // 2. Otherwise, use the agent's binaryName (e.g., 'codex', 'claude') and let // 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. - const remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command; + let remoteCommand = config.sessionCustomPath || agent?.binaryName || config.command; + + // Handle OpenCode here document for large prompts + if (useHereDocForOpenCode && config.prompt) { + // OpenCode: use here document to avoid command line limits + // Escape single quotes in the prompt for bash here document + const escapedPrompt = config.prompt.replace(/'/g, "'\\''"); + // Construct: cat << 'EOF' | opencode run --format json\nlong prompt here\nEOF + const hereDocCommand = `cat << 'EOF' | ${remoteCommand} ${sshArgs.join(' ')}\n${escapedPrompt}\nEOF`; + sshArgs = []; // Clear args since they're now in the here doc command + remoteCommand = hereDocCommand; // Update to use here document + logger.info( + `Using here document for large OpenCode prompt to avoid command line limits`, + LOG_CONTEXT, + { + sessionId: config.sessionId, + promptLength: config.prompt?.length, + commandLength: hereDocCommand.length, + } + ); + } // Decide whether we'll send input via stdin to the remote command const useStdin = sshArgs.includes('--input-format') && sshArgs.includes('stream-json'); @@ -406,6 +439,21 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // For SSH, env vars are passed in the remote command string, not locally customEnvVarsToPass = undefined; + // On Windows, use PowerShell for SSH commands to avoid cmd.exe's 8191 character limit + // PowerShell supports up to 32,767 characters, which is needed for large prompts + if (isWindows) { + useShell = true; + shellToUse = 'powershell.exe'; + logger.info( + `Using PowerShell for SSH command on Windows to support long command lines`, + LOG_CONTEXT, + { + sessionId: config.sessionId, + commandLength: sshCommand.args.join(' ').length, + } + ); + } + // Detailed debug logging to diagnose SSH command execution issues logger.debug(`SSH command details for debugging`, LOG_CONTEXT, { sessionId: config.sessionId, @@ -427,6 +475,15 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void } } + // Debug logging for shell configuration + logger.info(`Shell configuration before spawn`, LOG_CONTEXT, { + sessionId: config.sessionId, + useShell, + shellToUse, + isWindows, + isSshCommand: !!sshRemoteUsed, + }); + const result = processManager.spawn({ ...config, command: commandToSpawn, @@ -458,6 +515,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt // For SSH with stream-json input, send prompt via stdin instead of command line sendPromptViaStdin: shouldSendPromptViaStdin ? true : undefined, + sendPromptViaStdinRaw: shouldSendPromptViaStdinRaw ? true : undefined, // Stats tracking: use cwd as projectPath if not explicitly provided projectPath: config.cwd, // SSH remote context (for SSH-specific error messages) diff --git a/src/main/process-manager/spawners/ChildProcessSpawner.ts b/src/main/process-manager/spawners/ChildProcessSpawner.ts index 2e08b8cf..f5c857d5 100644 --- a/src/main/process-manager/spawners/ChildProcessSpawner.ts +++ b/src/main/process-manager/spawners/ChildProcessSpawner.ts @@ -177,22 +177,47 @@ export class ChildProcessSpawner { 'ProcessManager', { command: spawnCommand } ); - // Escape arguments for cmd.exe when using shell - spawnArgs = finalArgs.map((arg) => { - const needsQuoting = /[ &|<>^%!()"\n\r#?*]/.test(arg) || arg.length > 100; - if (needsQuoting) { - const escaped = arg.replace(/"/g, '""').replace(/\^/g, '^^'); - return `"${escaped}"`; - } - return arg; - }); - logger.info('[ProcessManager] Escaped args for Windows shell', 'ProcessManager', { - originalArgsCount: finalArgs.length, - escapedArgsCount: spawnArgs.length, - escapedPromptArgLength: spawnArgs[spawnArgs.length - 1]?.length, - escapedPromptArgPreview: spawnArgs[spawnArgs.length - 1]?.substring(0, 200), - argsModified: finalArgs.some((arg, i) => arg !== spawnArgs[i]), - }); + + // Check if we're using PowerShell (for SSH commands to avoid cmd.exe 8191 char limit) + const isPowerShell = + typeof config.shell === 'string' && config.shell.toLowerCase().includes('powershell'); + + if (isPowerShell) { + // Escape arguments for PowerShell (supports longer command lines than cmd.exe) + spawnArgs = finalArgs.map((arg) => { + const needsQuoting = /[ &|<>^%!()"\n\r#?*`$]/.test(arg) || arg.length > 100; + if (needsQuoting) { + // PowerShell escaping: wrap in single quotes, escape single quotes by doubling + const escaped = arg.replace(/'/g, "''"); + return `'${escaped}'`; + } + return arg; + }); + logger.info('[ProcessManager] Escaped args for PowerShell', 'ProcessManager', { + originalArgsCount: finalArgs.length, + escapedArgsCount: spawnArgs.length, + escapedPromptArgLength: spawnArgs[spawnArgs.length - 1]?.length, + escapedPromptArgPreview: spawnArgs[spawnArgs.length - 1]?.substring(0, 200), + argsModified: finalArgs.some((arg, i) => arg !== spawnArgs[i]), + }); + } else { + // Escape arguments for cmd.exe when using shell + spawnArgs = finalArgs.map((arg) => { + const needsQuoting = /[ &|<>^%!()"\n\r#?*]/.test(arg) || arg.length > 100; + if (needsQuoting) { + const escaped = arg.replace(/"/g, '""').replace(/\^/g, '^^'); + return `"${escaped}"`; + } + return arg; + }); + logger.info('[ProcessManager] Escaped args for Windows shell', 'ProcessManager', { + originalArgsCount: finalArgs.length, + escapedArgsCount: spawnArgs.length, + escapedPromptArgLength: spawnArgs[spawnArgs.length - 1]?.length, + escapedPromptArgPreview: spawnArgs[spawnArgs.length - 1]?.substring(0, 200), + argsModified: finalArgs.some((arg, i) => arg !== spawnArgs[i]), + }); + } } // Determine shell option to pass to child_process.spawn. @@ -241,7 +266,8 @@ export class ChildProcessSpawner { argsContain('--json') || (argsContain('--format') && argsContain('json')) || (hasImages && !!prompt) || - !!config.sendPromptViaStdin; + !!config.sendPromptViaStdin || + !!config.sendPromptViaStdinRaw; // Get the output parser for this agent type const outputParser = getOutputParser(toolType) || undefined; @@ -253,8 +279,10 @@ export class ChildProcessSpawner { parserId: outputParser?.agentId, isStreamJsonMode, isBatchMode, + command: config.command, + argsCount: finalArgs.length, argsPreview: - finalArgs.length > 0 ? finalArgs[finalArgs.length - 1]?.substring(0, 200) : undefined, + finalArgs.length > 0 ? finalArgs[finalArgs.length - 1]?.substring(0, 500) : undefined, }); const managedProcess: ManagedProcess = { @@ -386,16 +414,26 @@ export class ChildProcessSpawner { // Handle stdin for batch mode and stream-json if (isStreamJsonMode && prompt) { - // Stream-json mode: send the message via stdin - const streamJsonMessage = buildStreamJsonMessage(prompt, images || []); - logger.debug('[ProcessManager] Sending stream-json message via stdin', 'ProcessManager', { - sessionId, - messageLength: streamJsonMessage.length, - imageCount: (images || []).length, - hasImages: !!(images && images.length > 0), - }); - childProcess.stdin?.write(streamJsonMessage + '\n'); - childProcess.stdin?.end(); + if (config.sendPromptViaStdinRaw) { + // Send raw prompt via stdin + logger.debug('[ProcessManager] Sending raw prompt via stdin', 'ProcessManager', { + sessionId, + promptLength: prompt.length, + }); + childProcess.stdin?.write(prompt); + childProcess.stdin?.end(); + } else { + // Stream-json mode: send the message via stdin + const streamJsonMessage = buildStreamJsonMessage(prompt, images || []); + logger.debug('[ProcessManager] Sending stream-json message via stdin', 'ProcessManager', { + sessionId, + messageLength: streamJsonMessage.length, + imageCount: (images || []).length, + hasImages: !!(images && images.length > 0), + }); + childProcess.stdin?.write(streamJsonMessage + '\n'); + childProcess.stdin?.end(); + } } else if (isBatchMode) { // Regular batch mode: close stdin immediately logger.debug('[ProcessManager] Closing stdin for batch mode', 'ProcessManager', { diff --git a/src/main/process-manager/types.ts b/src/main/process-manager/types.ts index 5e2a38c6..e05e219e 100644 --- a/src/main/process-manager/types.ts +++ b/src/main/process-manager/types.ts @@ -32,6 +32,8 @@ export interface ProcessConfig { runInShell?: boolean; /** If true, send the prompt via stdin as JSON instead of command line */ sendPromptViaStdin?: boolean; + /** If true, send the prompt via stdin as raw text instead of command line */ + sendPromptViaStdinRaw?: boolean; } /** diff --git a/src/main/utils/ssh-command-builder.ts b/src/main/utils/ssh-command-builder.ts index fb8d1f50..67b6a140 100644 --- a/src/main/utils/ssh-command-builder.ts +++ b/src/main/utils/ssh-command-builder.ts @@ -101,13 +101,21 @@ export function buildRemoteCommand(options: RemoteCommandOptions): string { // Build the command with arguments 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; + // Handle stdin input modes + let finalCommandWithArgs: string; + if (options.useStdin) { + const hasStreamJsonInput = + Array.isArray(args) && args.includes('--input-format') && args.includes('stream-json'); + if (hasStreamJsonInput) { + // Stream-JSON mode: use exec to avoid shell control sequences + finalCommandWithArgs = `exec ${commandWithArgs}`; + } else { + // Raw prompt mode: pipe stdin directly to the command + finalCommandWithArgs = commandWithArgs; + } + } else { + finalCommandWithArgs = commandWithArgs; + } // Combine env exports with command let fullCommand: string; diff --git a/src/renderer/components/NewInstanceModal.tsx b/src/renderer/components/NewInstanceModal.tsx index 5c673156..46d310e3 100644 --- a/src/renderer/components/NewInstanceModal.tsx +++ b/src/renderer/components/NewInstanceModal.tsx @@ -241,15 +241,15 @@ export function NewInstanceModal({ if (sshRemoteId) { const connectionErrors = detectedAgents .filter((a: AgentConfig) => !a.hidden) - + .filter((a: any) => a.error) - + .map((a: any) => a.error); const allHaveErrors = connectionErrors.length > 0 && detectedAgents .filter((a: AgentConfig) => !a.hidden) - + .every((a: any) => a.error || !a.available); if (allHaveErrors && connectionErrors.length > 0) { @@ -318,7 +318,8 @@ export function NewInstanceModal({ // (hidden agents like 'terminal' should never be auto-selected) if (source) { setSelectedAgent(source.toolType); - } else { + } else if (!sshRemoteId) { + // Only auto-select on initial load, not on SSH remote re-detection const firstAvailable = detectedAgents.find((a: AgentConfig) => a.available && !a.hidden); if (firstAvailable) { setSelectedAgent(firstAvailable.id); @@ -578,34 +579,38 @@ export function NewInstanceModal({ } }, [isOpen, sourceSession]); - // Load SSH remote configurations independently of agent detection - // This ensures SSH remotes are available even if agent detection fails - useEffect(() => { - if (isOpen) { - const loadSshConfigs = async () => { - try { - const sshConfigsResult = await window.maestro.sshRemote.getConfigs(); - if (sshConfigsResult.success && sshConfigsResult.configs) { - setSshRemotes(sshConfigsResult.configs); - } - } catch (sshError) { - console.error('Failed to load SSH remote configs:', sshError); - } - }; - loadSshConfigs(); - } - }, [isOpen]); + // Load SSH remote configurations independently of agent detection + // This ensures SSH remotes are available even if agent detection fails + useEffect(() => { + if (isOpen) { + const loadSshConfigs = async () => { + try { + const sshConfigsResult = await window.maestro.sshRemote.getConfigs(); + if (sshConfigsResult.success && sshConfigsResult.configs) { + setSshRemotes(sshConfigsResult.configs); + } + } catch (sshError) { + console.error('Failed to load SSH remote configs:', sshError); + } + }; + loadSshConfigs(); + } + }, [isOpen]); - // Transfer pending SSH config to selected agent automatically - // This ensures SSH config is preserved when agent is auto-selected or manually clicked - useEffect(() => { - if (selectedAgent && agentSshRemoteConfigs['_pending_'] && !agentSshRemoteConfigs[selectedAgent]) { - setAgentSshRemoteConfigs(prev => ({ - ...prev, - [selectedAgent]: prev['_pending_'], - })); - } - }, [selectedAgent, agentSshRemoteConfigs]); + // Transfer pending SSH config to selected agent automatically + // This ensures SSH config is preserved when agent is auto-selected or manually clicked + useEffect(() => { + if ( + selectedAgent && + agentSshRemoteConfigs['_pending_'] && + !agentSshRemoteConfigs[selectedAgent] + ) { + setAgentSshRemoteConfigs((prev) => ({ + ...prev, + [selectedAgent]: prev['_pending_'], + })); + } + }, [selectedAgent, agentSshRemoteConfigs]); // Track the current SSH remote ID for re-detection // Uses _pending_ key when no agent is selected, which is the shared SSH config @@ -644,7 +649,6 @@ export function NewInstanceModal({ // Re-run agent detection with the new SSH remote ID loadAgents(undefined, currentSshRemoteId ?? undefined); - }, [isOpen, currentSshRemoteId]); if (!isOpen) return null; @@ -1131,11 +1135,13 @@ export function NewInstanceModal({ agentSshRemoteConfigs[selectedAgent] || agentSshRemoteConfigs['_pending_'] } onSshRemoteConfigChange={(config) => { - const key = selectedAgent || '_pending_'; - setAgentSshRemoteConfigs((prev) => ({ - ...prev, - [key]: config, - })); + setAgentSshRemoteConfigs((prev) => { + const newConfigs = { ...prev, _pending_: config }; + if (selectedAgent) { + newConfigs[selectedAgent] = config; + } + return newConfigs; + }); }} /> )} diff --git a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx index 6bc4d09d..8c70d674 100644 --- a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx +++ b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx @@ -388,7 +388,7 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX. const visibleAgents = agents.filter((a: AgentConfig) => !a.hidden); // Check if all agents have connection errors (indicates SSH connection failure) - + const connectionErrors = visibleAgents .filter((a: any) => a.error) .map((a: any) => a.error); @@ -469,7 +469,6 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX. // Using JSON.stringify with 'null' fallback to ensure the effect runs when switching // between remote and local (JSON.stringify(undefined) returns undefined, not 'null', // so we need the fallback to ensure React sees it as a real string change) - }, [setAvailableAgents, setSelectedAgent, JSON.stringify(sshRemoteConfig) ?? 'null']); // Load SSH remote configurations on mount @@ -858,9 +857,54 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX. Back -

- Configure {configuringTile.name} -

+
+

+ Configure {configuringTile.name} +

+ {/* SSH Remote Location Dropdown - only shown if remotes are configured */} + {sshRemotes.length > 0 && ( +
+ on + +
+ )} +
{/* Spacer for centering */}
diff --git a/src/renderer/components/Wizard/services/conversationManager.ts b/src/renderer/components/Wizard/services/conversationManager.ts index 44d5146e..3b82ad12 100644 --- a/src/renderer/components/Wizard/services/conversationManager.ts +++ b/src/renderer/components/Wizard/services/conversationManager.ts @@ -651,12 +651,7 @@ class ConversationManager { // Must include these explicitly since wizard pre-builds args before IPC handler const args = []; - // Add batch mode prefix: 'exec' - if (agent.batchModePrefix) { - args.push(...agent.batchModePrefix); - } - - // Add base args (if any) + // Add base args (if any) - batchModePrefix will be added by buildAgentArgs args.push(...(agent.args || [])); // Add batch mode args: '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check' @@ -676,12 +671,7 @@ class ConversationManager { // OpenCode requires 'run' batch mode with JSON output for wizard conversations const args = []; - // Add batch mode prefix: 'run' - if (agent.batchModePrefix) { - args.push(...agent.batchModePrefix); - } - - // Add base args (if any) + // Add base args (if any) - batchModePrefix will be added by buildAgentArgs args.push(...(agent.args || [])); // Add JSON output: '--format json' diff --git a/src/renderer/hooks/useInlineWizard.ts b/src/renderer/hooks/useInlineWizard.ts index 2ae0319a..b189cba4 100644 --- a/src/renderer/hooks/useInlineWizard.ts +++ b/src/renderer/hooks/useInlineWizard.ts @@ -207,7 +207,7 @@ export interface UseInlineWizardReturn { * @param tabId - The tab ID to associate the wizard with * @param sessionId - The session ID for playbook creation * @param autoRunFolderPath - User-configured Auto Run folder path (if set, overrides default projectPath/Auto Run Docs) - * @param sessionSshRemoteConfig - SSH remote configuration (for remote execution) + * @param sessionSshRemoteConfig - SSH remote configuration (for remote execution) */ startWizard: ( naturalLanguageInput?: string, @@ -525,7 +525,7 @@ export function useInlineWizard(): UseInlineWizardReturn { subfolderName: null, subfolderPath: null, autoRunFolderPath: effectiveAutoRunFolderPath, - sessionSshRemoteConfig, + sessionSshRemoteConfig, })); try { @@ -585,7 +585,14 @@ export function useInlineWizard(): UseInlineWizardReturn { } // Step 4: Initialize conversation session (only for 'new' or 'iterate' modes) - if ((mode === 'new' || mode === 'iterate') && agentType && effectiveAutoRunFolderPath) { + // Only allow wizard for agents that support structured output + const supportedWizardAgents: ToolType[] = ['claude', 'claude-code', 'codex']; + if ( + (mode === 'new' || mode === 'iterate') && + agentType && + supportedWizardAgents.includes(agentType) && + effectiveAutoRunFolderPath + ) { const session = startInlineWizardConversation({ mode, agentType, @@ -594,7 +601,7 @@ export function useInlineWizard(): UseInlineWizardReturn { goal: goal || undefined, existingDocs: docsWithContent.length > 0 ? docsWithContent : undefined, autoRunFolderPath: effectiveAutoRunFolderPath, - sessionSshRemoteConfig, + sessionSshRemoteConfig, }); // Store conversation session per-tab @@ -608,6 +615,19 @@ export function useInlineWizard(): UseInlineWizardReturn { existingDocsCount: docsWithContent.length, autoRunFolderPath: effectiveAutoRunFolderPath, }); + } else if ( + (mode === 'new' || mode === 'iterate') && + agentType && + !supportedWizardAgents.includes(agentType) + ) { + // Agent not supported for wizard + logger.warn(`Wizard not supported for agent type: ${agentType}`, '[InlineWizard]'); + setTabState(effectiveTabId, (prev) => ({ + ...prev, + isInitializing: false, + error: `The inline wizard is not supported for ${agentType}. Please use Claude, Claude Code, or Codex.`, + })); + return; // Don't update state with parsed results } // Update state with parsed results @@ -678,6 +698,9 @@ export function useInlineWizard(): UseInlineWizardReturn { */ const sendMessage = useCallback( async (content: string, callbacks?: ConversationCallbacks): Promise => { + // Only allow wizard for agents that support structured output + const supportedWizardAgents: ToolType[] = ['claude', 'claude-code', 'codex']; + // Get the tab ID from the current state, ensure currentTabId is set for visibility const tabId = currentTabId || 'default'; if (tabId !== currentTabId) { @@ -719,7 +742,12 @@ export function useInlineWizard(): UseInlineWizardReturn { currentState?.autoRunFolderPath || (currentState?.projectPath ? getAutoRunFolderPath(currentState.projectPath) : null); - if (currentState?.mode === 'ask' && currentState.agentType && effectiveAutoRunFolderPath) { + if ( + currentState?.mode === 'ask' && + currentState.agentType && + supportedWizardAgents.includes(currentState.agentType) && + effectiveAutoRunFolderPath + ) { console.log('[useInlineWizard] Auto-creating session for direct message in ask mode'); session = startInlineWizardConversation({ mode: 'new', @@ -729,7 +757,7 @@ export function useInlineWizard(): UseInlineWizardReturn { goal: currentState.goal || undefined, existingDocs: undefined, autoRunFolderPath: effectiveAutoRunFolderPath, - sessionSshRemoteConfig: currentState.sessionSshRemoteConfig, + sessionSshRemoteConfig: currentState.sessionSshRemoteConfig, }); conversationSessionsMap.current.set(tabId, session); // Update mode to 'new' since we're proceeding with a new plan @@ -910,7 +938,7 @@ export function useInlineWizard(): UseInlineWizardReturn { goal: currentState.goal || undefined, existingDocs: undefined, // Will be loaded separately if needed autoRunFolderPath: effectiveAutoRunFolderPath, - sessionSshRemoteConfig: currentState.sessionSshRemoteConfig, + sessionSshRemoteConfig: currentState.sessionSshRemoteConfig, }); conversationSessionsMap.current.set(tabId, session); diff --git a/src/renderer/services/inlineWizardConversation.ts b/src/renderer/services/inlineWizardConversation.ts index 0a3c70f1..31998f13 100644 --- a/src/renderer/services/inlineWizardConversation.ts +++ b/src/renderer/services/inlineWizardConversation.ts @@ -499,6 +499,11 @@ function buildArgsForAgent(agent: any): string[] { // Add base args (if any) args.push(...(agent.args || [])); + // Add read-only mode: '--agent plan' + if (agent.readOnlyArgs) { + args.push(...agent.readOnlyArgs); + } + // Add JSON output: '--format json' if (agent.jsonOutputArgs) { args.push(...agent.jsonOutputArgs); @@ -707,10 +712,17 @@ export async function sendWizardMessage( } ); + // 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 + const commandToUse = agent.path || agent.command; + // Spawn the agent process logger.info(`Spawning wizard agent process`, '[InlineWizardConversation]', { sessionId: session.sessionId, agentType: session.agentType, + command: commandToUse, + agentPath: agent.path, + agentCommand: agent.command, cwd: session.directoryPath, historyLength: conversationHistory.length, }); @@ -720,7 +732,7 @@ export async function sendWizardMessage( sessionId: session.sessionId, toolType: session.agentType, cwd: session.directoryPath, - command: agent.command, + command: commandToUse, args: argsForSpawn, prompt: fullPrompt, // Pass SSH config for remote execution diff --git a/tsconfig.main.json b/tsconfig.main.json index 17986bff..31963e85 100644 --- a/tsconfig.main.json +++ b/tsconfig.main.json @@ -13,6 +13,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src/main/**/*"], + "include": ["src/main/**/*", "src/shared/**/*", "src/types/**/*"], "exclude": ["node_modules", "dist", "release"] }