diff --git a/src/main/index.ts b/src/main/index.ts index c3d004a5..ea558727 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -258,6 +258,7 @@ function setupIpcHandlers() { args: string[]; prompt?: string; shell?: string; + images?: string[]; // Base64 data URLs for images }) => { if (!processManager) throw new Error('Process manager not initialized'); if (!agentDetector) throw new Error('Agent detector not initialized'); diff --git a/src/main/preload.ts b/src/main/preload.ts index 06804c31..876028bf 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -9,6 +9,7 @@ interface ProcessConfig { args: string[]; prompt?: string; shell?: string; + images?: string[]; // Base64 data URLs for images } interface AgentConfig { diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index 7ae3d0ed..cbc23e80 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -12,6 +12,7 @@ interface ProcessConfig { requiresPty?: boolean; // Whether this agent needs a pseudo-terminal prompt?: string; // For batch mode agents like Claude (passed as CLI argument) shell?: string; // Shell to use for terminal sessions (e.g., 'zsh', 'bash', 'fish') + images?: string[]; // Base64 data URLs for images (passed via stream-json input) } interface ManagedProcess { @@ -23,10 +24,68 @@ interface ManagedProcess { pid: number; isTerminal: boolean; isBatchMode?: boolean; // True for agents that run in batch mode (exit after response) + isStreamJsonMode?: boolean; // True when using stream-json input/output (for images) jsonBuffer?: string; // Buffer for accumulating JSON output in batch mode lastCommand?: string; // Last command sent to terminal (for filtering command echoes) } +/** + * Parse a data URL and extract base64 data and media type + */ +function parseDataUrl(dataUrl: string): { base64: string; mediaType: string } | null { + // Format: data:image/png;base64,iVBORw0KGgo... + const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/); + if (!match) return null; + return { + mediaType: match[1], + base64: match[2], + }; +} + +/** + * Build a stream-json message for Claude Code with images and text + */ +function buildStreamJsonMessage(prompt: string, images: string[]): string { + // Build content array with images first, then text + const content: Array<{ + type: 'image' | 'text'; + text?: string; + source?: { type: 'base64'; media_type: string; data: string }; + }> = []; + + // Add images + for (const dataUrl of images) { + const parsed = parseDataUrl(dataUrl); + if (parsed) { + content.push({ + type: 'image', + source: { + type: 'base64', + media_type: parsed.mediaType, + data: parsed.base64, + }, + }); + } + } + + // Add text prompt + content.push({ + type: 'text', + text: prompt, + }); + + // Build the stream-json message + const message = { + type: 'user', + message: { + role: 'user', + content, + }, + }; + + return JSON.stringify(message); +} + export class ProcessManager extends EventEmitter { private processes: Map = new Map(); @@ -34,16 +93,29 @@ export class ProcessManager extends EventEmitter { * Spawn a new process for a session */ spawn(config: ProcessConfig): { pid: number; success: boolean } { - const { sessionId, toolType, cwd, command, args, requiresPty, prompt, shell } = config; + const { sessionId, toolType, cwd, command, args, requiresPty, prompt, shell, images } = config; - // For batch mode with prompt, append prompt to args with -- separator - // The -- ensures prompt is treated as positional arg, not a flag (even if it starts with --) - const finalArgs = prompt ? [...args, '--', prompt] : args; + // For batch mode with images, use stream-json mode and send message via stdin + // For batch mode without images, append prompt to args with -- separator + const hasImages = images && images.length > 0; + let finalArgs: string[]; + + if (hasImages && prompt) { + // Use stream-json mode for images - prompt will be sent via stdin + finalArgs = [...args, '--input-format', 'stream-json', '--output-format', 'stream-json', '-p']; + } else if (prompt) { + // Regular batch mode - prompt as CLI arg + // The -- ensures prompt is treated as positional arg, not a flag (even if it starts with --) + finalArgs = [...args, '--', prompt]; + } else { + finalArgs = args; + } console.log('[ProcessManager] spawn() config:', { sessionId, toolType, hasPrompt: !!prompt, + hasImages, promptValue: prompt, baseArgs: args, finalArgs @@ -128,13 +200,47 @@ export class ProcessManager extends EventEmitter { return { pid: ptyProcess.pid, success: true }; } else { // Use regular child_process for AI tools (including batch mode) + + // Fix PATH for Electron environment + // Electron's main process may have a limited PATH that doesn't include + // user-installed binaries like node, which is needed for #!/usr/bin/env node scripts + const env = { ...process.env }; + const standardPaths = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'; + if (env.PATH) { + // Prepend standard paths if not already present + if (!env.PATH.includes('/opt/homebrew/bin')) { + env.PATH = `${standardPaths}:${env.PATH}`; + } + } else { + env.PATH = standardPaths; + } + + console.log('[ProcessManager] About to spawn child process:', { + command, + finalArgs, + cwd, + PATH: env.PATH?.substring(0, 150), + hasStdio: 'default (pipe)' + }); + const childProcess = spawn(command, finalArgs, { cwd, - env: process.env, + env, shell: false, // Explicitly disable shell to prevent injection + stdio: ['pipe', 'pipe', 'pipe'], // Explicitly set stdio to pipe + }); + + console.log('[ProcessManager] Child process spawned:', { + pid: childProcess.pid, + hasStdout: !!childProcess.stdout, + hasStderr: !!childProcess.stderr, + hasStdin: !!childProcess.stdin, + killed: childProcess.killed, + exitCode: childProcess.exitCode }); const isBatchMode = !!prompt; + const isStreamJsonMode = hasImages && !!prompt; const managedProcess: ManagedProcess = { sessionId, @@ -144,41 +250,111 @@ export class ProcessManager extends EventEmitter { pid: childProcess.pid || -1, isTerminal: false, isBatchMode, + isStreamJsonMode, jsonBuffer: isBatchMode ? '' : undefined, }; this.processes.set(sessionId, managedProcess); + console.log('[ProcessManager] Setting up stdout/stderr/exit handlers for session:', sessionId); + console.log('[ProcessManager] childProcess.stdout:', childProcess.stdout ? 'exists' : 'null'); + console.log('[ProcessManager] childProcess.stderr:', childProcess.stderr ? 'exists' : 'null'); + // Handle stdout - childProcess.stdout?.on('data', (data: Buffer) => { + if (childProcess.stdout) { + console.log('[ProcessManager] Attaching stdout data listener...'); + childProcess.stdout.setEncoding('utf8'); // Ensure proper encoding + childProcess.stdout.on('error', (err) => { + console.error('[ProcessManager] stdout error:', err); + }); + childProcess.stdout.on('data', (data: Buffer | string) => { + console.log('[ProcessManager] >>> STDOUT EVENT FIRED <<<'); + console.log('[ProcessManager] stdout event fired for session:', sessionId); const output = data.toString(); console.log('[ProcessManager] stdout data received:', { sessionId, isBatchMode, + isStreamJsonMode, dataLength: output.length, dataPreview: output.substring(0, 200) }); - if (isBatchMode) { - // In batch mode, accumulate JSON output + if (isStreamJsonMode) { + // In stream-json mode, each line is a JSONL message + // Accumulate and process complete lines + managedProcess.jsonBuffer = (managedProcess.jsonBuffer || '') + output; + + // Process complete lines + const lines = managedProcess.jsonBuffer.split('\n'); + // Keep the last incomplete line in the buffer + managedProcess.jsonBuffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + // Handle different message types from stream-json output + if (msg.type === 'assistant' && msg.message?.content) { + // Extract text from content blocks + const textContent = msg.message.content + .filter((block: any) => block.type === 'text') + .map((block: any) => block.text) + .join(''); + if (textContent) { + this.emit('data', sessionId, textContent); + } + } else if (msg.type === 'result' && msg.result) { + this.emit('data', sessionId, msg.result); + } + // Capture session_id from any message type + if (msg.session_id) { + this.emit('session-id', sessionId, msg.session_id); + } + } catch (e) { + // If it's not valid JSON, emit as raw text + console.log('[ProcessManager] Non-JSON line in stream-json mode:', line.substring(0, 100)); + this.emit('data', sessionId, line); + } + } + } else if (isBatchMode) { + // In regular batch mode, accumulate JSON output managedProcess.jsonBuffer = (managedProcess.jsonBuffer || '') + output; console.log('[ProcessManager] Accumulated JSON buffer length:', managedProcess.jsonBuffer.length); } else { // In interactive mode, emit data immediately this.emit('data', sessionId, output); } - }); + }); + } else { + console.log('[ProcessManager] WARNING: childProcess.stdout is null!'); + } // Handle stderr - childProcess.stderr?.on('data', (data: Buffer) => { - this.emit('data', sessionId, `[stderr] ${data.toString()}`); - }); + if (childProcess.stderr) { + console.log('[ProcessManager] Attaching stderr data listener...'); + childProcess.stderr.setEncoding('utf8'); + childProcess.stderr.on('error', (err) => { + console.error('[ProcessManager] stderr error:', err); + }); + childProcess.stderr.on('data', (data: Buffer | string) => { + console.log('[ProcessManager] >>> STDERR EVENT FIRED <<<', data.toString().substring(0, 100)); + this.emit('data', sessionId, `[stderr] ${data.toString()}`); + }); + } // Handle exit childProcess.on('exit', (code) => { - if (isBatchMode && managedProcess.jsonBuffer) { - // Parse JSON response from batch mode + console.log('[ProcessManager] Child process exit event:', { + sessionId, + code, + isBatchMode, + isStreamJsonMode, + jsonBufferLength: managedProcess.jsonBuffer?.length || 0, + jsonBufferPreview: managedProcess.jsonBuffer?.substring(0, 200) + }); + if (isBatchMode && !isStreamJsonMode && managedProcess.jsonBuffer) { + // Parse JSON response from regular batch mode (not stream-json) try { const jsonResponse = JSON.parse(managedProcess.jsonBuffer); @@ -215,6 +391,24 @@ export class ProcessManager extends EventEmitter { this.processes.delete(sessionId); }); + // Handle stdin for batch mode + if (isStreamJsonMode && prompt && images) { + // Stream-json mode with images: send the message via stdin + const streamJsonMessage = buildStreamJsonMessage(prompt, images); + console.log('[ProcessManager] Sending stream-json message with images:', { + sessionId, + messageLength: streamJsonMessage.length, + imageCount: images.length + }); + childProcess.stdin?.write(streamJsonMessage + '\n'); + childProcess.stdin?.end(); // Signal end of input + } else if (isBatchMode) { + // Regular batch mode: close stdin immediately since prompt is passed as CLI arg + // Some CLIs wait for stdin to close before processing + console.log('[ProcessManager] Closing stdin for batch mode (prompt passed as CLI arg)'); + childProcess.stdin?.end(); + } + return { pid: childProcess.pid || -1, success: true }; } } catch (error: any) { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0e1d3cc4..f0173074 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -234,21 +234,23 @@ export default function MaestroConsole() { if (!isClaudeBatchMode) { // Only spawn for non-batch-mode agents + // Use agent.path (full path) if available for better cross-environment compatibility aiSpawnResult = await window.maestro.process.spawn({ sessionId: `${correctedSession.id}-ai`, toolType: aiAgentType, cwd: correctedSession.cwd, - command: agent.command, + command: agent.path || agent.command, args: agent.args || [] }); } // 2. Spawn terminal process + // Use terminalAgent.path (full path) if available const terminalSpawnResult = await window.maestro.process.spawn({ sessionId: `${correctedSession.id}-terminal`, toolType: 'terminal', cwd: correctedSession.cwd, - command: terminalAgent.command, + command: terminalAgent.path || terminalAgent.command, args: terminalAgent.args || [] }); @@ -1145,11 +1147,12 @@ export default function MaestroConsole() { // Spawn BOTH processes - this is the dual-process architecture try { // 1. Spawn AI agent process + // Use agent.path (full path) if available for better cross-environment compatibility const aiSpawnResult = await window.maestro.process.spawn({ sessionId: `${newId}-ai`, toolType: agentId, cwd: workingDir, - command: agent.command, + command: agent.path || agent.command, args: agent.args || [] }); @@ -1158,11 +1161,12 @@ export default function MaestroConsole() { } // 2. Spawn terminal process + // Use terminalAgent.path (full path) if available const terminalSpawnResult = await window.maestro.process.spawn({ sessionId: `${newId}-terminal`, toolType: 'terminal', cwd: workingDir, - command: terminalAgent.command, + command: terminalAgent.path || terminalAgent.command, args: terminalAgent.args || [] }); @@ -1449,8 +1453,9 @@ export default function MaestroConsole() { })(); } - // Capture input value before clearing (needed for async batch mode spawn) + // Capture input value and images before clearing (needed for async batch mode spawn) const capturedInputValue = inputValue; + const capturedImages = [...stagedImages]; setInputValue(''); setStagedImages([]); @@ -1500,13 +1505,18 @@ export default function MaestroConsole() { } // Spawn Claude with prompt as argument (use captured value) + // If images are present, they will be passed via stream-json input format + // Use agent.path (full path) if available, otherwise fall back to agent.command + const commandToUse = agent.path || agent.command; + console.log('[processInput] Spawning Claude:', { command: commandToUse, path: agent.path, fallback: agent.command }); await window.maestro.process.spawn({ sessionId: targetSessionId, toolType: 'claude-code', cwd: activeSession.cwd, - command: agent.command, + command: commandToUse, args: spawnArgs, - prompt: capturedInputValue + prompt: capturedInputValue, + images: capturedImages.length > 0 ? capturedImages : undefined }); } catch (error) { console.error('Failed to spawn Claude batch process:', error); @@ -1897,6 +1907,46 @@ export default function MaestroConsole() { setFlatFileList(flattenTree(filteredFileTree, expandedSet)); }, [activeSession?.fileExplorerExpanded, filteredFileTree]); + // Handle pending jump path from /jump command + useEffect(() => { + if (!activeSession || activeSession.pendingJumpPath === undefined || flatFileList.length === 0) return; + + const jumpPath = activeSession.pendingJumpPath; + + // Find the target index + let targetIndex = 0; + + if (jumpPath === '') { + // Jump to root - select first item + targetIndex = 0; + } else { + // Find the folder in the flat list, then select its first child + const folderIndex = flatFileList.findIndex(item => item.fullPath === jumpPath && item.isFolder); + + if (folderIndex !== -1 && folderIndex + 1 < flatFileList.length) { + // Check if the next item is a child of this folder + const nextItem = flatFileList[folderIndex + 1]; + if (nextItem.fullPath.startsWith(jumpPath + '/')) { + // Select the first child + targetIndex = folderIndex + 1; + } else { + // Folder has no visible children, select the folder itself + targetIndex = folderIndex; + } + } else if (folderIndex !== -1) { + // Folder found but no children, select the folder + targetIndex = folderIndex; + } + // If folder not found, stay at 0 + } + + setSelectedFileIndex(targetIndex); + + // Clear the pending jump path + setSessions(prev => prev.map(s => + s.id === activeSession.id ? { ...s, pendingJumpPath: undefined } : s + )); + }, [activeSession?.pendingJumpPath, flatFileList, activeSession?.id]); // Scroll to selected file item when selection changes useEffect(() => { @@ -2218,9 +2268,10 @@ export default function MaestroConsole() { onResumeClaudeSession={(claudeSessionId: string, messages: LogEntry[]) => { // Update the active session with the selected Claude session ID and load messages // Also reset state to 'idle' since we're just loading historical messages + // Switch to AI mode since we're resuming an AI session if (activeSession) { setSessions(prev => prev.map(s => - s.id === activeSession.id ? { ...s, claudeSessionId, aiLogs: messages, state: 'idle' } : s + s.id === activeSession.id ? { ...s, claudeSessionId, aiLogs: messages, state: 'idle', inputMode: 'ai' } : s )); setActiveClaudeSessionId(claudeSessionId); } diff --git a/src/renderer/components/AgentSessionsBrowser.tsx b/src/renderer/components/AgentSessionsBrowser.tsx index 3c9244a4..91ce2fc1 100644 --- a/src/renderer/components/AgentSessionsBrowser.tsx +++ b/src/renderer/components/AgentSessionsBrowser.tsx @@ -35,7 +35,6 @@ interface AgentSessionsBrowserProps { activeSession: Session | undefined; activeClaudeSessionId: string | null; onClose: () => void; - onSelectSession: (claudeSessionId: string) => void; onResumeSession: (claudeSessionId: string, messages: LogEntry[]) => void; onNewSession: () => void; } @@ -45,7 +44,6 @@ export function AgentSessionsBrowser({ activeSession, activeClaudeSessionId, onClose, - onSelectSession, onResumeSession, onNewSession, }: AgentSessionsBrowserProps) { @@ -171,6 +169,13 @@ export function AgentSessionsBrowser({ if (offset === 0) { setMessages(result.messages); + // Scroll to bottom after initial load and focus the container for keyboard nav + requestAnimationFrame(() => { + if (messagesContainerRef.current) { + messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; + messagesContainerRef.current.focus(); + } + }); } else { // Prepend older messages setMessages(prev => [...result.messages, ...prev]); @@ -272,6 +277,10 @@ export function AgentSessionsBrowser({ e.preventDefault(); setViewingSession(null); setMessages([]); + } else if (e.key === 'Enter') { + // Enter in session details view resumes the session + e.preventDefault(); + handleResume(); } return; } @@ -294,14 +303,7 @@ export function AgentSessionsBrowser({ } }; - // Handle selecting/resuming a session - const handleSelect = useCallback(() => { - if (viewingSession) { - onSelectSession(viewingSession.sessionId); - onClose(); - } - }, [viewingSession, onSelectSession, onClose]); - + // Handle resuming a session const handleResume = useCallback(() => { if (viewingSession) { // Convert messages to LogEntry format for AI terminal @@ -388,30 +390,17 @@ export function AgentSessionsBrowser({
{viewingSession ? ( - <> - - - + ) : (