it is allllliiiiiiive

This commit is contained in:
Pedram Amini
2025-11-26 00:45:37 -06:00
parent 2a7093c533
commit 70a7662b8c
9 changed files with 315 additions and 64 deletions

View File

@@ -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');

View File

@@ -9,6 +9,7 @@ interface ProcessConfig {
args: string[];
prompt?: string;
shell?: string;
images?: string[]; // Base64 data URLs for images
}
interface AgentConfig {

View File

@@ -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<string, ManagedProcess> = 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) {

View File

@@ -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);
}

View File

@@ -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({
<div className="flex items-center gap-2">
{viewingSession ? (
<>
<button
onClick={handleSelect}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors hover:opacity-80"
style={{
backgroundColor: theme.colors.bgActivity,
color: theme.colors.textMain,
border: `1px solid ${theme.colors.border}`,
}}
>
Select
</button>
<button
onClick={handleResume}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors hover:opacity-80"
style={{
backgroundColor: theme.colors.accent,
color: theme.colors.accentText,
}}
>
<Play className="w-4 h-4" />
Resume
</button>
</>
<button
onClick={handleResume}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors hover:opacity-80"
style={{
backgroundColor: theme.colors.accent,
color: theme.colors.accentText,
}}
>
<Play className="w-4 h-4" />
Resume
</button>
) : (
<button
onClick={onNewSession}
@@ -439,8 +428,10 @@ export function AgentSessionsBrowser({
{viewingSession ? (
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto p-6 space-y-4"
className="flex-1 overflow-y-auto p-6 space-y-4 outline-none"
onScroll={handleMessagesScroll}
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* Load more indicator */}
{hasMoreMessages && (

View File

@@ -155,7 +155,6 @@ export function MainPanel(props: MainPanelProps) {
activeSession={activeSession || undefined}
activeClaudeSessionId={activeClaudeSessionId}
onClose={() => setAgentSessionsOpen(false)}
onSelectSession={setActiveClaudeSessionId}
onResumeSession={onResumeClaudeSession}
onNewSession={onNewClaudeSession}
/>

View File

@@ -22,6 +22,7 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, defaultAgen
// Layer stack integration
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
const layerIdRef = useRef<string>();
const modalRef = useRef<HTMLDivElement>(null);
// Define handlers first before they're used in effects
const loadAgents = async () => {
@@ -100,6 +101,13 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, defaultAgen
}
}, [isOpen, onClose, updateLayerHandler]);
// Focus modal on open (only once, not on every render)
useEffect(() => {
if (isOpen && modalRef.current) {
modalRef.current.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
@@ -109,7 +117,7 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, defaultAgen
aria-modal="true"
aria-label="Create New Agent"
tabIndex={-1}
ref={(el) => el?.focus()}
ref={modalRef}
onKeyDown={(e) => {
// Handle Cmd+O for folder picker before stopping propagation
if ((e.key === 'o' || e.key === 'O') && (e.metaKey || e.ctrlKey)) {

View File

@@ -43,7 +43,7 @@ export const slashCommands: SlashCommand[] = [
command: '/jump',
description: 'Jump to CWD in file tree',
execute: (context: SlashCommandContext) => {
const { activeSessionId, sessions, setSessions, setRightPanelOpen, setActiveRightTab, setActiveFocus, setSelectedFileIndex } = context;
const { activeSessionId, sessions, setSessions, setRightPanelOpen, setActiveRightTab, setActiveFocus } = context;
// Use fallback to first session if activeSessionId is empty
const actualActiveId = activeSessionId || (sessions.length > 0 ? sessions[0].id : '');
@@ -59,14 +59,16 @@ export const slashCommands: SlashCommand[] = [
if (setRightPanelOpen) setRightPanelOpen(true);
if (setActiveRightTab) setActiveRightTab('files');
if (setActiveFocus) setActiveFocus('right');
if (setSelectedFileIndex) setSelectedFileIndex(0);
// Expand all parent folders in the path (using relative paths to match file tree)
// Calculate the relative path from session cwd to target directory
const relativePath = targetDir.replace(activeSession.cwd, '').replace(/^\//, '');
// Expand all parent folders in the path and set pendingJumpPath
setSessions(prev => prev.map(s => {
if (s.id !== actualActiveId) return s;
// Build list of relative parent paths to expand
const pathParts = targetDir.replace(s.cwd, '').split('/').filter(Boolean);
const pathParts = relativePath.split('/').filter(Boolean);
const expandPaths: string[] = [];
let currentPath = '';
@@ -80,7 +82,9 @@ export const slashCommands: SlashCommand[] = [
return {
...s,
fileExplorerExpanded: Array.from(newExpanded)
fileExplorerExpanded: Array.from(newExpanded),
// Set pending jump path - will be processed by App.tsx once flatFileList updates
pendingJumpPath: relativePath || ''
};
}));
}

View File

@@ -98,6 +98,8 @@ export interface Session {
scratchPadMode?: 'edit' | 'preview';
// Claude Code session ID for conversation continuity
claudeSessionId?: string;
// Pending jump path for /jump command (relative path within file tree)
pendingJumpPath?: string;
}
export interface Group {