diff --git a/src/__tests__/main/performance-optimizations.test.ts b/src/__tests__/main/performance-optimizations.test.ts new file mode 100644 index 00000000..0f15ee2d --- /dev/null +++ b/src/__tests__/main/performance-optimizations.test.ts @@ -0,0 +1,514 @@ +/** + * @file performance-optimizations.test.ts + * @description Unit tests for performance optimizations in the main process. + * + * Tests cover: + * - Group chat session ID regex patterns + * - Buffer operations (O(1) append, correct join) + * - Debug logging conditional behavior + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +// ============================================================================ +// Regex Pattern Tests +// ============================================================================ +// These patterns are used in hot paths (process data handlers) and are +// pre-compiled at module level for performance. We test them here to ensure +// they match the expected session ID formats. + +describe('Group Chat Session ID Patterns', () => { + // Moderator session patterns + const REGEX_MODERATOR_SESSION = /^group-chat-(.+)-moderator-/; + const REGEX_MODERATOR_SESSION_TIMESTAMP = /^group-chat-(.+)-moderator-\d+$/; + + // Participant session patterns + const REGEX_PARTICIPANT_UUID = + /^group-chat-(.+)-participant-(.+)-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i; + const REGEX_PARTICIPANT_TIMESTAMP = /^group-chat-(.+)-participant-(.+)-(\d{13,})$/; + const REGEX_PARTICIPANT_FALLBACK = /^group-chat-(.+)-participant-([^-]+)-/; + + // Web broadcast patterns + const REGEX_AI_SUFFIX = /-ai-[^-]+$/; + const REGEX_AI_TAB_ID = /-ai-([^-]+)$/; + + describe('REGEX_MODERATOR_SESSION', () => { + it('should match moderator session IDs', () => { + const sessionId = 'group-chat-test-chat-123-moderator-1705678901234'; + const match = sessionId.match(REGEX_MODERATOR_SESSION); + expect(match).not.toBeNull(); + expect(match![1]).toBe('test-chat-123'); + }); + + it('should match moderator synthesis session IDs', () => { + const sessionId = 'group-chat-my-chat-moderator-synthesis-1705678901234'; + const match = sessionId.match(REGEX_MODERATOR_SESSION); + expect(match).not.toBeNull(); + expect(match![1]).toBe('my-chat'); + }); + + it('should not match participant session IDs', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-1705678901234'; + const match = sessionId.match(REGEX_MODERATOR_SESSION); + expect(match).toBeNull(); + }); + + it('should not match regular session IDs', () => { + const sessionId = 'session-123-ai-tab1'; + const match = sessionId.match(REGEX_MODERATOR_SESSION); + expect(match).toBeNull(); + }); + }); + + describe('REGEX_MODERATOR_SESSION_TIMESTAMP', () => { + it('should match moderator session IDs with timestamp suffix', () => { + const sessionId = 'group-chat-test-chat-moderator-1705678901234'; + const match = sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP); + expect(match).not.toBeNull(); + expect(match![1]).toBe('test-chat'); + }); + + it('should not match moderator synthesis session IDs', () => { + const sessionId = 'group-chat-my-chat-moderator-synthesis-1705678901234'; + const match = sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP); + expect(match).toBeNull(); + }); + + it('should not match session IDs without timestamp', () => { + const sessionId = 'group-chat-test-chat-moderator-'; + const match = sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP); + expect(match).toBeNull(); + }); + }); + + describe('REGEX_PARTICIPANT_UUID', () => { + it('should match participant session IDs with UUID suffix', () => { + const sessionId = + 'group-chat-test-chat-participant-Agent1-a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + const match = sessionId.match(REGEX_PARTICIPANT_UUID); + expect(match).not.toBeNull(); + expect(match![1]).toBe('test-chat'); + expect(match![2]).toBe('Agent1'); + expect(match![3]).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + }); + + it('should match UUID case-insensitively', () => { + const sessionId = + 'group-chat-test-chat-participant-Agent1-A1B2C3D4-E5F6-7890-ABCD-EF1234567890'; + const match = sessionId.match(REGEX_PARTICIPANT_UUID); + expect(match).not.toBeNull(); + }); + + it('should not match session IDs with invalid UUID', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-invalid-uuid'; + const match = sessionId.match(REGEX_PARTICIPANT_UUID); + expect(match).toBeNull(); + }); + + it('should handle participant names with hyphens', () => { + const sessionId = + 'group-chat-test-chat-participant-My-Agent-Name-a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + const match = sessionId.match(REGEX_PARTICIPANT_UUID); + expect(match).not.toBeNull(); + expect(match![2]).toBe('My-Agent-Name'); + }); + }); + + describe('REGEX_PARTICIPANT_TIMESTAMP', () => { + it('should match participant session IDs with 13-digit timestamp', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-1705678901234'; + const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP); + expect(match).not.toBeNull(); + expect(match![1]).toBe('test-chat'); + expect(match![2]).toBe('Agent1'); + expect(match![3]).toBe('1705678901234'); + }); + + it('should match timestamps with more than 13 digits', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-17056789012345'; + const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP); + expect(match).not.toBeNull(); + }); + + it('should not match timestamps with less than 13 digits', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-123456789012'; + const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP); + expect(match).toBeNull(); + }); + + it('should handle participant names with hyphens', () => { + const sessionId = 'group-chat-test-chat-participant-My-Agent-1705678901234'; + const match = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP); + expect(match).not.toBeNull(); + expect(match![2]).toBe('My-Agent'); + }); + }); + + describe('REGEX_PARTICIPANT_FALLBACK', () => { + it('should match participant session IDs with simple names', () => { + const sessionId = 'group-chat-test-chat-participant-Agent1-anything'; + const match = sessionId.match(REGEX_PARTICIPANT_FALLBACK); + expect(match).not.toBeNull(); + expect(match![1]).toBe('test-chat'); + expect(match![2]).toBe('Agent1'); + }); + + it('should stop at hyphen for participant name', () => { + const sessionId = 'group-chat-test-chat-participant-Agent-1-suffix'; + const match = sessionId.match(REGEX_PARTICIPANT_FALLBACK); + expect(match).not.toBeNull(); + expect(match![2]).toBe('Agent'); // Stops at first hyphen after participant name start + }); + }); + + describe('REGEX_AI_SUFFIX', () => { + it('should match session IDs with -ai- suffix', () => { + const sessionId = 'session-123-ai-tab1'; + expect(REGEX_AI_SUFFIX.test(sessionId)).toBe(true); + }); + + it('should remove -ai- suffix correctly', () => { + const sessionId = 'session-123-ai-tab1'; + const baseSessionId = sessionId.replace(REGEX_AI_SUFFIX, ''); + expect(baseSessionId).toBe('session-123'); + }); + + it('should not match session IDs without -ai- suffix', () => { + const sessionId = 'session-123-terminal'; + expect(REGEX_AI_SUFFIX.test(sessionId)).toBe(false); + }); + }); + + describe('REGEX_AI_TAB_ID', () => { + it('should extract tab ID from session ID', () => { + const sessionId = 'session-123-ai-tab1'; + const match = sessionId.match(REGEX_AI_TAB_ID); + expect(match).not.toBeNull(); + expect(match![1]).toBe('tab1'); + }); + + it('should handle complex tab IDs', () => { + const sessionId = 'session-123-ai-abc123xyz'; + const match = sessionId.match(REGEX_AI_TAB_ID); + expect(match).not.toBeNull(); + expect(match![1]).toBe('abc123xyz'); + }); + + it('should return null for non-AI sessions', () => { + const sessionId = 'session-123-terminal'; + const match = sessionId.match(REGEX_AI_TAB_ID); + expect(match).toBeNull(); + }); + }); +}); + +// ============================================================================ +// Buffer Operation Tests +// ============================================================================ +// These tests verify the O(1) buffer implementation works correctly. + +describe('Group Chat Output Buffer', () => { + // Simulate the buffer implementation + type BufferEntry = { chunks: string[]; totalLength: number }; + let buffers: Map; + + function appendToBuffer(sessionId: string, data: string): number { + let buffer = buffers.get(sessionId); + if (!buffer) { + buffer = { chunks: [], totalLength: 0 }; + buffers.set(sessionId, buffer); + } + buffer.chunks.push(data); + buffer.totalLength += data.length; + return buffer.totalLength; + } + + function getBufferedOutput(sessionId: string): string | undefined { + const buffer = buffers.get(sessionId); + if (!buffer || buffer.chunks.length === 0) return undefined; + return buffer.chunks.join(''); + } + + beforeEach(() => { + buffers = new Map(); + }); + + describe('appendToBuffer', () => { + it('should create new buffer entry on first append', () => { + const length = appendToBuffer('session-1', 'hello'); + expect(length).toBe(5); + expect(buffers.has('session-1')).toBe(true); + }); + + it('should append to existing buffer', () => { + appendToBuffer('session-1', 'hello'); + const length = appendToBuffer('session-1', ' world'); + expect(length).toBe(11); // 5 + 6 + }); + + it('should track total length correctly across multiple appends', () => { + appendToBuffer('session-1', 'a'); // 1 + appendToBuffer('session-1', 'bb'); // 3 + appendToBuffer('session-1', 'ccc'); // 6 + const length = appendToBuffer('session-1', 'dddd'); // 10 + expect(length).toBe(10); + }); + + it('should maintain separate buffers for different sessions', () => { + appendToBuffer('session-1', 'hello'); + appendToBuffer('session-2', 'world'); + + expect(buffers.get('session-1')?.totalLength).toBe(5); + expect(buffers.get('session-2')?.totalLength).toBe(5); + }); + + it('should handle empty strings', () => { + appendToBuffer('session-1', 'hello'); + const length = appendToBuffer('session-1', ''); + expect(length).toBe(5); + }); + + it('should handle large data chunks', () => { + const largeData = 'x'.repeat(100000); + const length = appendToBuffer('session-1', largeData); + expect(length).toBe(100000); + }); + }); + + describe('getBufferedOutput', () => { + it('should return undefined for non-existent session', () => { + const output = getBufferedOutput('non-existent'); + expect(output).toBeUndefined(); + }); + + it('should return undefined for empty buffer', () => { + buffers.set('session-1', { chunks: [], totalLength: 0 }); + const output = getBufferedOutput('session-1'); + expect(output).toBeUndefined(); + }); + + it('should join chunks correctly', () => { + appendToBuffer('session-1', 'hello'); + appendToBuffer('session-1', ' '); + appendToBuffer('session-1', 'world'); + + const output = getBufferedOutput('session-1'); + expect(output).toBe('hello world'); + }); + + it('should preserve order of chunks', () => { + appendToBuffer('session-1', '1'); + appendToBuffer('session-1', '2'); + appendToBuffer('session-1', '3'); + + const output = getBufferedOutput('session-1'); + expect(output).toBe('123'); + }); + + it('should handle special characters', () => { + appendToBuffer('session-1', '{"type": "test"}'); + appendToBuffer('session-1', '\n'); + appendToBuffer('session-1', '{"type": "test2"}'); + + const output = getBufferedOutput('session-1'); + expect(output).toBe('{"type": "test"}\n{"type": "test2"}'); + }); + }); + + describe('buffer cleanup', () => { + it('should allow deletion of buffer entries', () => { + appendToBuffer('session-1', 'data'); + buffers.delete('session-1'); + expect(buffers.has('session-1')).toBe(false); + }); + + it('should not affect other buffers on deletion', () => { + appendToBuffer('session-1', 'data1'); + appendToBuffer('session-2', 'data2'); + + buffers.delete('session-1'); + + expect(buffers.has('session-1')).toBe(false); + expect(getBufferedOutput('session-2')).toBe('data2'); + }); + }); + + describe('performance characteristics', () => { + it('should maintain O(1) append by tracking totalLength incrementally', () => { + // This test verifies the implementation doesn't use reduce() + // by checking that totalLength matches sum of chunk lengths + const chunks = ['chunk1', 'chunk2', 'chunk3', 'chunk4', 'chunk5']; + + for (const chunk of chunks) { + appendToBuffer('session-1', chunk); + } + + const buffer = buffers.get('session-1')!; + const actualSum = buffer.chunks.reduce((sum, chunk) => sum + chunk.length, 0); + + expect(buffer.totalLength).toBe(actualSum); + }); + + it('should handle many small appends efficiently', () => { + // Simulate streaming output with many small chunks + const startTime = Date.now(); + + for (let i = 0; i < 10000; i++) { + appendToBuffer('session-1', 'x'); + } + + const elapsed = Date.now() - startTime; + + // Should complete in under 100ms for 10000 appends + // (generous threshold to avoid flaky tests) + expect(elapsed).toBeLessThan(100); + expect(buffers.get('session-1')?.totalLength).toBe(10000); + }); + }); +}); + +// ============================================================================ +// Debug Logging Tests +// ============================================================================ +// These tests verify the conditional debug logging behavior. + +describe('Debug Logging', () => { + // Simulate the debugLog function + function createDebugLog(isEnabled: boolean) { + const logs: string[] = []; + + function debugLog(prefix: string, message: string, ...args: unknown[]): void { + if (isEnabled) { + logs.push(`[${prefix}] ${message}`); + } + } + + return { debugLog, logs }; + } + + it('should log when debug is enabled', () => { + const { debugLog, logs } = createDebugLog(true); + + debugLog('GroupChat:Debug', 'Test message'); + + expect(logs).toHaveLength(1); + expect(logs[0]).toBe('[GroupChat:Debug] Test message'); + }); + + it('should not log when debug is disabled', () => { + const { debugLog, logs } = createDebugLog(false); + + debugLog('GroupChat:Debug', 'Test message'); + + expect(logs).toHaveLength(0); + }); + + it('should format message with prefix correctly', () => { + const { debugLog, logs } = createDebugLog(true); + + debugLog('WebBroadcast', 'Broadcasting to session'); + + expect(logs[0]).toBe('[WebBroadcast] Broadcasting to session'); + }); + + it('should handle multiple log calls', () => { + const { debugLog, logs } = createDebugLog(true); + + debugLog('Test', 'Message 1'); + debugLog('Test', 'Message 2'); + debugLog('Test', 'Message 3'); + + expect(logs).toHaveLength(3); + }); +}); + +// ============================================================================ +// Session ID Parsing Tests +// ============================================================================ +// These tests verify the parseParticipantSessionId logic. + +describe('parseParticipantSessionId', () => { + // Simulate the parsing function + function parseParticipantSessionId( + sessionId: string + ): { groupChatId: string; participantName: string } | null { + if (!sessionId.includes('-participant-')) { + return null; + } + + const REGEX_PARTICIPANT_UUID = + /^group-chat-(.+)-participant-(.+)-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i; + const REGEX_PARTICIPANT_TIMESTAMP = /^group-chat-(.+)-participant-(.+)-(\d{13,})$/; + const REGEX_PARTICIPANT_FALLBACK = /^group-chat-(.+)-participant-([^-]+)-/; + + const uuidMatch = sessionId.match(REGEX_PARTICIPANT_UUID); + if (uuidMatch) { + return { groupChatId: uuidMatch[1], participantName: uuidMatch[2] }; + } + + const timestampMatch = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP); + if (timestampMatch) { + return { groupChatId: timestampMatch[1], participantName: timestampMatch[2] }; + } + + const fallbackMatch = sessionId.match(REGEX_PARTICIPANT_FALLBACK); + if (fallbackMatch) { + return { groupChatId: fallbackMatch[1], participantName: fallbackMatch[2] }; + } + + return null; + } + + it('should return null for non-participant session IDs', () => { + expect(parseParticipantSessionId('session-123')).toBeNull(); + expect(parseParticipantSessionId('group-chat-test-moderator-123')).toBeNull(); + }); + + it('should parse UUID-based participant session IDs', () => { + const result = parseParticipantSessionId( + 'group-chat-my-chat-participant-Claude-a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ); + expect(result).toEqual({ + groupChatId: 'my-chat', + participantName: 'Claude', + }); + }); + + it('should parse timestamp-based participant session IDs', () => { + const result = parseParticipantSessionId('group-chat-my-chat-participant-Claude-1705678901234'); + expect(result).toEqual({ + groupChatId: 'my-chat', + participantName: 'Claude', + }); + }); + + it('should handle participant names with hyphens using UUID format', () => { + const result = parseParticipantSessionId( + 'group-chat-my-chat-participant-Claude-Code-a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ); + expect(result).toEqual({ + groupChatId: 'my-chat', + participantName: 'Claude-Code', + }); + }); + + it('should handle group chat IDs with hyphens', () => { + const result = parseParticipantSessionId( + 'group-chat-my-complex-chat-id-participant-Agent-1705678901234' + ); + expect(result).toEqual({ + groupChatId: 'my-complex-chat-id', + participantName: 'Agent', + }); + }); + + it('should prefer UUID match over timestamp match', () => { + // This session ID could theoretically match both patterns + // but UUID should be tried first + const result = parseParticipantSessionId( + 'group-chat-chat-participant-Agent-a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ); + expect(result).not.toBeNull(); + expect(result?.participantName).toBe('Agent'); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index 090ae6cf..efe0ec37 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,10 +15,43 @@ import { powerManager } from './power-manager'; import { getThemeById } from './themes'; import Store from 'electron-store'; import { getHistoryManager } from './history-manager'; -import { registerGitHandlers, registerAutorunHandlers, registerPlaybooksHandlers, registerHistoryHandlers, registerAgentsHandlers, registerProcessHandlers, registerPersistenceHandlers, registerSystemHandlers, registerClaudeHandlers, registerAgentSessionsHandlers, registerGroupChatHandlers, registerDebugHandlers, registerSpeckitHandlers, registerOpenSpecHandlers, registerContextHandlers, registerMarketplaceHandlers, registerStatsHandlers, registerDocumentGraphHandlers, registerSshRemoteHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, getActiveGroomingSessionCount } from './ipc/handlers'; +import { + registerGitHandlers, + registerAutorunHandlers, + registerPlaybooksHandlers, + registerHistoryHandlers, + registerAgentsHandlers, + registerProcessHandlers, + registerPersistenceHandlers, + registerSystemHandlers, + registerClaudeHandlers, + registerAgentSessionsHandlers, + registerGroupChatHandlers, + registerDebugHandlers, + registerSpeckitHandlers, + registerOpenSpecHandlers, + registerContextHandlers, + registerMarketplaceHandlers, + registerStatsHandlers, + registerDocumentGraphHandlers, + registerSshRemoteHandlers, + setupLoggerEventForwarding, + cleanupAllGroomingSessions, + getActiveGroomingSessionCount, +} from './ipc/handlers'; import { initializeStatsDB, closeStatsDB, getStatsDB } from './stats-db'; import { groupChatEmitters } from './ipc/handlers/groupChat'; -import { routeModeratorResponse, routeAgentResponse, setGetSessionsCallback, setGetCustomEnvVarsCallback, setGetAgentConfigCallback, markParticipantResponded, spawnModeratorSynthesis, getGroupChatReadOnlyState, respawnParticipantWithRecovery } from './group-chat/group-chat-router'; +import { + routeModeratorResponse, + routeAgentResponse, + setGetSessionsCallback, + setGetCustomEnvVarsCallback, + setGetAgentConfigCallback, + markParticipantResponded, + spawnModeratorSynthesis, + getGroupChatReadOnlyState, + respawnParticipantWithRecovery, +} from './group-chat/group-chat-router'; import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage'; import { needsSessionRecovery, initiateSessionRecovery } from './group-chat/session-recovery'; import { initializeSessionStorages } from './storage'; @@ -27,17 +60,59 @@ import { calculateContextTokens } from './parsers/usage-aggregator'; import { DEMO_MODE, DEMO_DATA_PATH } from './constants'; import type { SshRemoteConfig } from '../shared/types'; import { initAutoUpdater } from './auto-updater'; -import { readDirRemote, readFileRemote, statRemote, directorySizeRemote, renameRemote, deleteRemote, countItemsRemote } from './utils/remote-fs'; +import { + readDirRemote, + readFileRemote, + statRemote, + directorySizeRemote, + renameRemote, + deleteRemote, + countItemsRemote, +} from './utils/remote-fs'; import { checkWslEnvironment } from './utils/wslDetector'; +// ============================================================================ +// Pre-compiled Regex Patterns (Performance Optimization) +// ============================================================================ +// These patterns are used in hot paths (process data handlers) that fire hundreds +// of times per second. Pre-compiling them avoids repeated regex compilation overhead. + +// Group chat session ID patterns +const REGEX_MODERATOR_SESSION = /^group-chat-(.+)-moderator-/; +const REGEX_MODERATOR_SESSION_TIMESTAMP = /^group-chat-(.+)-moderator-\d+$/; +const REGEX_PARTICIPANT_UUID = + /^group-chat-(.+)-participant-(.+)-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i; +const REGEX_PARTICIPANT_TIMESTAMP = /^group-chat-(.+)-participant-(.+)-(\d{13,})$/; +const REGEX_PARTICIPANT_FALLBACK = /^group-chat-(.+)-participant-([^-]+)-/; + +// Web broadcast session ID patterns +const REGEX_AI_SUFFIX = /-ai-[^-]+$/; +const REGEX_AI_TAB_ID = /-ai-([^-]+)$/; + +// ============================================================================ +// Debug Logging (Performance Optimization) +// ============================================================================ +// Debug logs in hot paths (data handlers) are disabled in production to avoid +// performance overhead from string interpolation and console I/O on every data chunk. +const DEBUG_GROUP_CHAT = + process.env.NODE_ENV === 'development' || process.env.DEBUG_GROUP_CHAT === '1'; + +/** Log debug message only in development mode. Avoids overhead in production. */ + +function debugLog(prefix: string, message: string, ...args: any[]): void { + if (DEBUG_GROUP_CHAT) { + console.log(`[${prefix}] ${message}`, ...args); + } +} + // ============================================================================ // Custom Storage Location Configuration // ============================================================================ // This bootstrap store is ALWAYS local - it tells us where to find the main data // Users can choose a custom folder (e.g., iCloud Drive, Dropbox, OneDrive) to sync settings interface BootstrapSettings { - customSyncPath?: string; - iCloudSyncEnabled?: boolean; // Legacy - kept for backwards compatibility during migration + customSyncPath?: string; + iCloudSyncEnabled?: boolean; // Legacy - kept for backwards compatibility during migration } // ============================================================================ @@ -51,19 +126,19 @@ const productionDataPath = app.getPath('userData'); // Demo mode: use a separate data directory for fresh demos if (DEMO_MODE) { - app.setPath('userData', DEMO_DATA_PATH); - console.log(`[DEMO MODE] Using data directory: ${DEMO_DATA_PATH}`); + app.setPath('userData', DEMO_DATA_PATH); + console.log(`[DEMO MODE] Using data directory: ${DEMO_DATA_PATH}`); } // Development mode: use a separate data directory to allow running alongside production // This prevents database lock conflicts (e.g., Service Worker storage) // Set USE_PROD_DATA=1 to use the production data directory instead (requires closing production app) if (isDevelopment && !DEMO_MODE && !process.env.USE_PROD_DATA) { - const devDataPath = path.join(app.getPath('userData'), '..', 'maestro-dev'); - app.setPath('userData', devDataPath); - console.log(`[DEV MODE] Using data directory: ${devDataPath}`); + const devDataPath = path.join(app.getPath('userData'), '..', 'maestro-dev'); + app.setPath('userData', devDataPath); + console.log(`[DEV MODE] Using data directory: ${devDataPath}`); } else if (isDevelopment && process.env.USE_PROD_DATA) { - console.log(`[DEV MODE] Using production data directory: ${app.getPath('userData')}`); + console.log(`[DEV MODE] Using production data directory: ${app.getPath('userData')}`); } // ============================================================================ @@ -71,9 +146,9 @@ if (isDevelopment && !DEMO_MODE && !process.env.USE_PROD_DATA) { // ============================================================================ const bootstrapStore = new Store({ - name: 'maestro-bootstrap', - cwd: app.getPath('userData'), - defaults: {}, + name: 'maestro-bootstrap', + cwd: app.getPath('userData'), + defaults: {}, }); /** @@ -81,23 +156,23 @@ const bootstrapStore = new Store({ * Returns undefined if using default path. */ function getSyncPath(): string | undefined { - const customPath = bootstrapStore.get('customSyncPath'); + const customPath = bootstrapStore.get('customSyncPath'); - if (customPath) { - // Ensure the directory exists - if (!fsSync.existsSync(customPath)) { - try { - fsSync.mkdirSync(customPath, { recursive: true }); - } catch { - // If we can't create the directory, fall back to default - console.error(`Failed to create custom sync path: ${customPath}, using default`); - return undefined; - } - } - return customPath; - } + if (customPath) { + // Ensure the directory exists + if (!fsSync.existsSync(customPath)) { + try { + fsSync.mkdirSync(customPath, { recursive: true }); + } catch { + // If we can't create the directory, fall back to default + console.error(`Failed to create custom sync path: ${customPath}, using default`); + return undefined; + } + } + return customPath; + } - return undefined; // Use default path + return undefined; // Use default path } // Get the sync path once at startup @@ -112,9 +187,12 @@ console.log(`[STARTUP] productionDataPath (agent configs): ${productionDataPath} // Initialize Sentry for crash reporting // Only enable in production - skip during development to avoid noise from hot-reload artifacts // Check if crash reporting is enabled (default: true for opt-out behavior) -const earlySettingsStore = new Store<{ crashReportingEnabled: boolean; disableGpuAcceleration: boolean }>({ - name: 'maestro-settings', - cwd: syncPath, // Use same path as main settings store +const earlySettingsStore = new Store<{ + crashReportingEnabled: boolean; + disableGpuAcceleration: boolean; +}>({ + name: 'maestro-settings', + cwd: syncPath, // Use same path as main settings store }); const crashReportingEnabled = earlySettingsStore.get('crashReportingEnabled', true); @@ -122,172 +200,172 @@ const crashReportingEnabled = earlySettingsStore.get('crashReportingEnabled', tr // Must be called before app.ready event const disableGpuAcceleration = earlySettingsStore.get('disableGpuAcceleration', false); if (disableGpuAcceleration) { - app.disableHardwareAcceleration(); - console.log('[STARTUP] GPU hardware acceleration disabled by user preference'); + app.disableHardwareAcceleration(); + console.log('[STARTUP] GPU hardware acceleration disabled by user preference'); } if (crashReportingEnabled && !isDevelopment) { - Sentry.init({ - dsn: 'https://2303c5f787f910863d83ed5d27ce8ed2@o4510554134740992.ingest.us.sentry.io/4510554135789568', - // Set release version for better debugging - release: app.getVersion(), - // Use Classic IPC mode to avoid "sentry-ipc:// URL scheme not supported" errors - // See: https://github.com/getsentry/sentry-electron/issues/661 - ipcMode: IPCMode.Classic, - // Only send errors, not performance data - tracesSampleRate: 0, - // Filter out sensitive data - beforeSend(event) { - // Remove any potential sensitive data from the event - if (event.user) { - delete event.user.ip_address; - delete event.user.email; - } - return event; - }, - }); + Sentry.init({ + dsn: 'https://2303c5f787f910863d83ed5d27ce8ed2@o4510554134740992.ingest.us.sentry.io/4510554135789568', + // Set release version for better debugging + release: app.getVersion(), + // Use Classic IPC mode to avoid "sentry-ipc:// URL scheme not supported" errors + // See: https://github.com/getsentry/sentry-electron/issues/661 + ipcMode: IPCMode.Classic, + // Only send errors, not performance data + tracesSampleRate: 0, + // Filter out sensitive data + beforeSend(event) { + // Remove any potential sensitive data from the event + if (event.user) { + delete event.user.ip_address; + delete event.user.email; + } + return event; + }, + }); } // Type definitions interface MaestroSettings { - activeThemeId: string; - llmProvider: string; - modelSlug: string; - apiKey: string; - shortcuts: Record; - fontSize: number; - fontFamily: string; - customFonts: string[]; - logLevel: 'debug' | 'info' | 'warn' | 'error'; - defaultShell: string; - // Web interface authentication - webAuthEnabled: boolean; - webAuthToken: string | null; - // Web interface custom port - webInterfaceUseCustomPort: boolean; - webInterfaceCustomPort: number; - // SSH remote execution - sshRemotes: SshRemoteConfig[]; - defaultSshRemoteId: string | null; - // Unique installation identifier (generated once on first run) - installationId: string | null; + activeThemeId: string; + llmProvider: string; + modelSlug: string; + apiKey: string; + shortcuts: Record; + fontSize: number; + fontFamily: string; + customFonts: string[]; + logLevel: 'debug' | 'info' | 'warn' | 'error'; + defaultShell: string; + // Web interface authentication + webAuthEnabled: boolean; + webAuthToken: string | null; + // Web interface custom port + webInterfaceUseCustomPort: boolean; + webInterfaceCustomPort: number; + // SSH remote execution + sshRemotes: SshRemoteConfig[]; + defaultSshRemoteId: string | null; + // Unique installation identifier (generated once on first run) + installationId: string | null; } const store = new Store({ - name: 'maestro-settings', - cwd: syncPath, // Use iCloud/custom sync path if configured - defaults: { - activeThemeId: 'dracula', - llmProvider: 'openrouter', - modelSlug: 'anthropic/claude-3.5-sonnet', - apiKey: '', - shortcuts: {}, - fontSize: 14, - fontFamily: 'Roboto Mono, Menlo, "Courier New", monospace', - customFonts: [], - logLevel: 'info', - defaultShell: (() => { - // Windows: $SHELL doesn't exist; default to PowerShell - if (process.platform === 'win32') { - return 'powershell'; - } - // Unix: Respect user's configured login shell from $SHELL - const shellPath = process.env.SHELL; - if (shellPath) { - const shellName = path.basename(shellPath); - // Valid Unix shell IDs from shellDetector.ts (lines 27-34) - if (['bash', 'zsh', 'fish', 'sh', 'tcsh'].includes(shellName)) { - return shellName; - } - } - // Fallback to bash (more portable than zsh on older Unix systems) - return 'bash'; - })(), - webAuthEnabled: false, - webAuthToken: null, - webInterfaceUseCustomPort: false, - webInterfaceCustomPort: 8080, - sshRemotes: [], - defaultSshRemoteId: null, - installationId: null, - }, + name: 'maestro-settings', + cwd: syncPath, // Use iCloud/custom sync path if configured + defaults: { + activeThemeId: 'dracula', + llmProvider: 'openrouter', + modelSlug: 'anthropic/claude-3.5-sonnet', + apiKey: '', + shortcuts: {}, + fontSize: 14, + fontFamily: 'Roboto Mono, Menlo, "Courier New", monospace', + customFonts: [], + logLevel: 'info', + defaultShell: (() => { + // Windows: $SHELL doesn't exist; default to PowerShell + if (process.platform === 'win32') { + return 'powershell'; + } + // Unix: Respect user's configured login shell from $SHELL + const shellPath = process.env.SHELL; + if (shellPath) { + const shellName = path.basename(shellPath); + // Valid Unix shell IDs from shellDetector.ts (lines 27-34) + if (['bash', 'zsh', 'fish', 'sh', 'tcsh'].includes(shellName)) { + return shellName; + } + } + // Fallback to bash (more portable than zsh on older Unix systems) + return 'bash'; + })(), + webAuthEnabled: false, + webAuthToken: null, + webInterfaceUseCustomPort: false, + webInterfaceCustomPort: 8080, + sshRemotes: [], + defaultSshRemoteId: null, + installationId: null, + }, }); // Generate installation ID on first run (one-time generation) // This creates a unique identifier per Maestro installation for telemetry differentiation let installationId = store.get('installationId'); if (!installationId) { - installationId = crypto.randomUUID(); - store.set('installationId', installationId); - logger.info('Generated new installation ID', 'Startup', { installationId }); + installationId = crypto.randomUUID(); + store.set('installationId', installationId); + logger.info('Generated new installation ID', 'Startup', { installationId }); } // Add installation ID to Sentry for error correlation across installations if (crashReportingEnabled && !isDevelopment) { - Sentry.setTag('installationId', installationId); + Sentry.setTag('installationId', installationId); } // Sessions store interface SessionsData { - sessions: any[]; + sessions: any[]; } const sessionsStore = new Store({ - name: 'maestro-sessions', - cwd: syncPath, // Use iCloud/custom sync path if configured - defaults: { - sessions: [], - }, + name: 'maestro-sessions', + cwd: syncPath, // Use iCloud/custom sync path if configured + defaults: { + sessions: [], + }, }); // Groups store interface GroupsData { - groups: any[]; + groups: any[]; } const groupsStore = new Store({ - name: 'maestro-groups', - cwd: syncPath, // Use iCloud/custom sync path if configured - defaults: { - groups: [], - }, + name: 'maestro-groups', + cwd: syncPath, // Use iCloud/custom sync path if configured + defaults: { + groups: [], + }, }); interface AgentConfigsData { - configs: Record>; // agentId -> config key-value pairs + configs: Record>; // agentId -> config key-value pairs } // Agent configs are ALWAYS stored in the production path, even in dev mode // This ensures agent paths, custom args, and env vars are shared between dev and prod // (They represent machine-level configuration, not session/project data) const agentConfigsStore = new Store({ - name: 'maestro-agent-configs', - cwd: productionDataPath, - defaults: { - configs: {}, - }, + name: 'maestro-agent-configs', + cwd: productionDataPath, + defaults: { + configs: {}, + }, }); // Window state store (for remembering window size/position) // NOTE: This is intentionally NOT synced - window state is per-device interface WindowState { - x?: number; - y?: number; - width: number; - height: number; - isMaximized: boolean; - isFullScreen: boolean; + x?: number; + y?: number; + width: number; + height: number; + isMaximized: boolean; + isFullScreen: boolean; } const windowStateStore = new Store({ - name: 'maestro-window-state', - // No cwd - always local, not synced (window position is device-specific) - defaults: { - width: 1400, - height: 900, - isMaximized: false, - isFullScreen: false, - }, + name: 'maestro-window-state', + // No cwd - always local, not synced (window position is device-specific) + defaults: { + width: 1400, + height: 900, + isMaximized: false, + isFullScreen: false, + }, }); // Note: History storage is now handled by HistoryManager which uses per-session files @@ -298,35 +376,41 @@ const windowStateStore = new Store({ // and their origin type (user-initiated vs auto/batch) type ClaudeSessionOrigin = 'user' | 'auto'; interface ClaudeSessionOriginInfo { - origin: ClaudeSessionOrigin; - sessionName?: string; // User-defined session name from Maestro - starred?: boolean; // Whether the session is starred - contextUsage?: number; // Last known context window usage percentage (0-100) + origin: ClaudeSessionOrigin; + sessionName?: string; // User-defined session name from Maestro + starred?: boolean; // Whether the session is starred + contextUsage?: number; // Last known context window usage percentage (0-100) } interface ClaudeSessionOriginsData { - // Map of projectPath -> { agentSessionId -> origin info } - origins: Record>; + // Map of projectPath -> { agentSessionId -> origin info } + origins: Record>; } const claudeSessionOriginsStore = new Store({ - name: 'maestro-claude-session-origins', - cwd: syncPath, // Use iCloud/custom sync path if configured - defaults: { - origins: {}, - }, + name: 'maestro-claude-session-origins', + cwd: syncPath, // Use iCloud/custom sync path if configured + defaults: { + origins: {}, + }, }); // Generic agent session origins store - supports all agents (Codex, OpenCode, etc.) // Structure: { [agentId]: { [projectPath]: { [sessionId]: { origin, sessionName, starred } } } } interface AgentSessionOriginsData { - origins: Record>>; + origins: Record< + string, + Record< + string, + Record + > + >; } const agentSessionOriginsStore = new Store({ - name: 'maestro-agent-session-origins', - cwd: syncPath, // Use iCloud/custom sync path if configured - defaults: { - origins: {}, - }, + name: 'maestro-agent-session-origins', + cwd: syncPath, // Use iCloud/custom sync path if configured + defaults: { + origins: {}, + }, }); /** @@ -334,8 +418,8 @@ const agentSessionOriginsStore = new Store({ * Returns undefined if not found. */ function getSshRemoteById(sshRemoteId: string): SshRemoteConfig | undefined { - const sshRemotes = store.get('sshRemotes', []) as SshRemoteConfig[]; - return sshRemotes.find((r) => r.id === sshRemoteId); + const sshRemotes = store.get('sshRemotes', []) as SshRemoteConfig[]; + return sshRemotes.find((r) => r.id === sshRemoteId); } let mainWindow: BrowserWindow | null = null; @@ -350,15 +434,22 @@ let cliActivityWatcher: fsSync.FSWatcher | null = null; * This prevents "Render frame was disposed before WebFrameMain could be accessed" errors. */ function safeSend(channel: string, ...args: unknown[]): void { - try { - if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents && !mainWindow.webContents.isDestroyed()) { - mainWindow.webContents.send(channel, ...args); - } - } catch (error) { - // Silently ignore - renderer is not available - // This can happen during GPU crashes, window closing, or app shutdown - logger.debug(`Failed to send IPC message to renderer: ${channel}`, 'IPC', { error: String(error) }); - } + try { + if ( + mainWindow && + !mainWindow.isDestroyed() && + mainWindow.webContents && + !mainWindow.webContents.isDestroyed() + ) { + mainWindow.webContents.send(channel, ...args); + } + } catch (error) { + // Silently ignore - renderer is not available + // This can happen during GPU crashes, window closing, or app shutdown + logger.debug(`Failed to send IPC message to renderer: ${channel}`, 'IPC', { + error: String(error), + }); + } } /** @@ -366,540 +457,570 @@ function safeSend(channel: string, ...args: unknown[]): void { * Called when user enables the web interface. */ function createWebServer(): WebServer { - // Use custom port if enabled, otherwise 0 for random port assignment - const useCustomPort = store.get('webInterfaceUseCustomPort', false); - const customPort = store.get('webInterfaceCustomPort', 8080); - const port = useCustomPort ? customPort : 0; - const server = new WebServer(port); // Custom or random port with auto-generated security token + // Use custom port if enabled, otherwise 0 for random port assignment + const useCustomPort = store.get('webInterfaceUseCustomPort', false); + const customPort = store.get('webInterfaceCustomPort', 8080); + const port = useCustomPort ? customPort : 0; + const server = new WebServer(port); // Custom or random port with auto-generated security token - // Set up callback for web server to fetch sessions list - server.setGetSessionsCallback(() => { - const sessions = sessionsStore.get('sessions', []); - const groups = groupsStore.get('groups', []); - return sessions.map((s: any) => { - // Find the group for this session - const group = s.groupId ? groups.find((g: any) => g.id === s.groupId) : null; + // Set up callback for web server to fetch sessions list + server.setGetSessionsCallback(() => { + const sessions = sessionsStore.get('sessions', []); + const groups = groupsStore.get('groups', []); + return sessions.map((s: any) => { + // Find the group for this session + const group = s.groupId ? groups.find((g: any) => g.id === s.groupId) : null; - // Extract last AI response for mobile preview (first 3 lines, max 500 chars) - // Use active tab's logs as the source of truth - let lastResponse = null; - const activeTab = s.aiTabs?.find((t: any) => t.id === s.activeTabId) || s.aiTabs?.[0]; - const tabLogs = activeTab?.logs || []; - if (tabLogs.length > 0) { - // Find the last stdout/stderr entry from the AI (not user messages) - // Note: 'thinking' logs are already excluded since they have a distinct source type - const lastAiLog = [...tabLogs].reverse().find((log: any) => - log.source === 'stdout' || log.source === 'stderr' - ); - if (lastAiLog && lastAiLog.text) { - const fullText = lastAiLog.text; - // Get first 3 lines or 500 chars, whichever is shorter - const lines = fullText.split('\n').slice(0, 3); - let previewText = lines.join('\n'); - if (previewText.length > 500) { - previewText = previewText.slice(0, 497) + '...'; - } else if (fullText.length > previewText.length) { - previewText = previewText + '...'; - } - lastResponse = { - text: previewText, - timestamp: lastAiLog.timestamp, - source: lastAiLog.source, - fullLength: fullText.length, - }; - } - } + // Extract last AI response for mobile preview (first 3 lines, max 500 chars) + // Use active tab's logs as the source of truth + let lastResponse = null; + const activeTab = s.aiTabs?.find((t: any) => t.id === s.activeTabId) || s.aiTabs?.[0]; + const tabLogs = activeTab?.logs || []; + if (tabLogs.length > 0) { + // Find the last stdout/stderr entry from the AI (not user messages) + // Note: 'thinking' logs are already excluded since they have a distinct source type + const lastAiLog = [...tabLogs] + .reverse() + .find((log: any) => log.source === 'stdout' || log.source === 'stderr'); + if (lastAiLog && lastAiLog.text) { + const fullText = lastAiLog.text; + // Get first 3 lines or 500 chars, whichever is shorter + const lines = fullText.split('\n').slice(0, 3); + let previewText = lines.join('\n'); + if (previewText.length > 500) { + previewText = previewText.slice(0, 497) + '...'; + } else if (fullText.length > previewText.length) { + previewText = previewText + '...'; + } + lastResponse = { + text: previewText, + timestamp: lastAiLog.timestamp, + source: lastAiLog.source, + fullLength: fullText.length, + }; + } + } - // Map aiTabs to web-safe format (strip logs to reduce payload) - const aiTabs = s.aiTabs?.map((tab: any) => ({ - id: tab.id, - agentSessionId: tab.agentSessionId || null, - name: tab.name || null, - starred: tab.starred || false, - inputValue: tab.inputValue || '', - usageStats: tab.usageStats || null, - createdAt: tab.createdAt, - state: tab.state || 'idle', - thinkingStartTime: tab.thinkingStartTime || null, - })) || []; + // Map aiTabs to web-safe format (strip logs to reduce payload) + const aiTabs = + s.aiTabs?.map((tab: any) => ({ + id: tab.id, + agentSessionId: tab.agentSessionId || null, + name: tab.name || null, + starred: tab.starred || false, + inputValue: tab.inputValue || '', + usageStats: tab.usageStats || null, + createdAt: tab.createdAt, + state: tab.state || 'idle', + thinkingStartTime: tab.thinkingStartTime || null, + })) || []; - return { - id: s.id, - name: s.name, - toolType: s.toolType, - state: s.state, - inputMode: s.inputMode, - cwd: s.cwd, - groupId: s.groupId || null, - groupName: group?.name || null, - groupEmoji: group?.emoji || null, - usageStats: s.usageStats || null, - lastResponse, - agentSessionId: s.agentSessionId || null, - thinkingStartTime: s.thinkingStartTime || null, - aiTabs, - activeTabId: s.activeTabId || (aiTabs.length > 0 ? aiTabs[0].id : undefined), - bookmarked: s.bookmarked || false, - // Worktree subagent support - parentSessionId: s.parentSessionId || null, - worktreeBranch: s.worktreeBranch || null, - }; - }); - }); + return { + id: s.id, + name: s.name, + toolType: s.toolType, + state: s.state, + inputMode: s.inputMode, + cwd: s.cwd, + groupId: s.groupId || null, + groupName: group?.name || null, + groupEmoji: group?.emoji || null, + usageStats: s.usageStats || null, + lastResponse, + agentSessionId: s.agentSessionId || null, + thinkingStartTime: s.thinkingStartTime || null, + aiTabs, + activeTabId: s.activeTabId || (aiTabs.length > 0 ? aiTabs[0].id : undefined), + bookmarked: s.bookmarked || false, + // Worktree subagent support + parentSessionId: s.parentSessionId || null, + worktreeBranch: s.worktreeBranch || null, + }; + }); + }); - // Set up callback for web server to fetch single session details - // Optional tabId param allows fetching logs for a specific tab (avoids race conditions) - server.setGetSessionDetailCallback((sessionId: string, tabId?: string) => { - const sessions = sessionsStore.get('sessions', []); - const session = sessions.find((s: any) => s.id === sessionId); - if (!session) return null; + // Set up callback for web server to fetch single session details + // Optional tabId param allows fetching logs for a specific tab (avoids race conditions) + server.setGetSessionDetailCallback((sessionId: string, tabId?: string) => { + const sessions = sessionsStore.get('sessions', []); + const session = sessions.find((s: any) => s.id === sessionId); + if (!session) return null; - // Get the requested tab's logs (or active tab if no tabId provided) - // Tabs are the source of truth for AI conversation history - // Filter out thinking and tool logs - these should never be shown on the web interface - let aiLogs: any[] = []; - const targetTabId = tabId || session.activeTabId; - if (session.aiTabs && session.aiTabs.length > 0) { - const targetTab = session.aiTabs.find((t: any) => t.id === targetTabId) || session.aiTabs[0]; - const rawLogs = targetTab?.logs || []; - // Web interface should never show thinking/tool logs regardless of desktop settings - aiLogs = rawLogs.filter((log: any) => log.source !== 'thinking' && log.source !== 'tool'); - } + // Get the requested tab's logs (or active tab if no tabId provided) + // Tabs are the source of truth for AI conversation history + // Filter out thinking and tool logs - these should never be shown on the web interface + let aiLogs: any[] = []; + const targetTabId = tabId || session.activeTabId; + if (session.aiTabs && session.aiTabs.length > 0) { + const targetTab = session.aiTabs.find((t: any) => t.id === targetTabId) || session.aiTabs[0]; + const rawLogs = targetTab?.logs || []; + // Web interface should never show thinking/tool logs regardless of desktop settings + aiLogs = rawLogs.filter((log: any) => log.source !== 'thinking' && log.source !== 'tool'); + } - return { - id: session.id, - name: session.name, - toolType: session.toolType, - state: session.state, - inputMode: session.inputMode, - cwd: session.cwd, - aiLogs, - shellLogs: session.shellLogs || [], - usageStats: session.usageStats, - agentSessionId: session.agentSessionId, - isGitRepo: session.isGitRepo, - activeTabId: targetTabId, - }; - }); + return { + id: session.id, + name: session.name, + toolType: session.toolType, + state: session.state, + inputMode: session.inputMode, + cwd: session.cwd, + aiLogs, + shellLogs: session.shellLogs || [], + usageStats: session.usageStats, + agentSessionId: session.agentSessionId, + isGitRepo: session.isGitRepo, + activeTabId: targetTabId, + }; + }); - // Set up callback for web server to fetch current theme - server.setGetThemeCallback(() => { - const themeId = store.get('activeThemeId', 'dracula'); - return getThemeById(themeId); - }); + // Set up callback for web server to fetch current theme + server.setGetThemeCallback(() => { + const themeId = store.get('activeThemeId', 'dracula'); + return getThemeById(themeId); + }); - // Set up callback for web server to fetch custom AI commands - server.setGetCustomCommandsCallback(() => { - const customCommands = store.get('customAICommands', []) as Array<{ - id: string; - command: string; - description: string; - prompt: string; - }>; - return customCommands; - }); + // Set up callback for web server to fetch custom AI commands + server.setGetCustomCommandsCallback(() => { + const customCommands = store.get('customAICommands', []) as Array<{ + id: string; + command: string; + description: string; + prompt: string; + }>; + return customCommands; + }); - // Set up callback for web server to fetch history entries - // Uses HistoryManager for per-session storage - server.setGetHistoryCallback((projectPath?: string, sessionId?: string) => { - const historyManager = getHistoryManager(); + // Set up callback for web server to fetch history entries + // Uses HistoryManager for per-session storage + server.setGetHistoryCallback((projectPath?: string, sessionId?: string) => { + const historyManager = getHistoryManager(); - if (sessionId) { - // Get entries for specific session - const entries = historyManager.getEntries(sessionId); - // Sort by timestamp descending - entries.sort((a, b) => b.timestamp - a.timestamp); - return entries; - } + if (sessionId) { + // Get entries for specific session + const entries = historyManager.getEntries(sessionId); + // Sort by timestamp descending + entries.sort((a, b) => b.timestamp - a.timestamp); + return entries; + } - if (projectPath) { - // Get all entries for sessions in this project - return historyManager.getEntriesByProjectPath(projectPath); - } + if (projectPath) { + // Get all entries for sessions in this project + return historyManager.getEntriesByProjectPath(projectPath); + } - // Return all entries (for global view) - return historyManager.getAllEntries(); - }); + // Return all entries (for global view) + return historyManager.getAllEntries(); + }); - // Set up callback for web server to write commands to sessions - // Note: Process IDs have -ai or -terminal suffix based on session's inputMode - server.setWriteToSessionCallback((sessionId: string, data: string) => { - if (!processManager) { - logger.warn('processManager is null for writeToSession', 'WebServer'); - return false; - } + // Set up callback for web server to write commands to sessions + // Note: Process IDs have -ai or -terminal suffix based on session's inputMode + server.setWriteToSessionCallback((sessionId: string, data: string) => { + if (!processManager) { + logger.warn('processManager is null for writeToSession', 'WebServer'); + return false; + } - // Get the session's current inputMode to determine which process to write to - const sessions = sessionsStore.get('sessions', []); - const session = sessions.find((s: any) => s.id === sessionId); - if (!session) { - logger.warn(`Session ${sessionId} not found for writeToSession`, 'WebServer'); - return false; - } + // Get the session's current inputMode to determine which process to write to + const sessions = sessionsStore.get('sessions', []); + const session = sessions.find((s: any) => s.id === sessionId); + if (!session) { + logger.warn(`Session ${sessionId} not found for writeToSession`, 'WebServer'); + return false; + } - // Append -ai or -terminal suffix based on inputMode - const targetSessionId = session.inputMode === 'ai' ? `${sessionId}-ai` : `${sessionId}-terminal`; - logger.debug(`Writing to ${targetSessionId} (inputMode=${session.inputMode})`, 'WebServer'); + // Append -ai or -terminal suffix based on inputMode + const targetSessionId = + session.inputMode === 'ai' ? `${sessionId}-ai` : `${sessionId}-terminal`; + logger.debug(`Writing to ${targetSessionId} (inputMode=${session.inputMode})`, 'WebServer'); - const result = processManager.write(targetSessionId, data); - logger.debug(`Write result: ${result}`, 'WebServer'); - return result; - }); + const result = processManager.write(targetSessionId, data); + logger.debug(`Write result: ${result}`, 'WebServer'); + return result; + }); - // Set up callback for web server to execute commands through the desktop - // This forwards AI commands to the renderer, ensuring single source of truth - // The renderer handles all spawn logic, state management, and broadcasts - server.setExecuteCommandCallback(async (sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => { - if (!mainWindow) { - logger.warn('mainWindow is null for executeCommand', 'WebServer'); - return false; - } + // Set up callback for web server to execute commands through the desktop + // This forwards AI commands to the renderer, ensuring single source of truth + // The renderer handles all spawn logic, state management, and broadcasts + server.setExecuteCommandCallback( + async (sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => { + if (!mainWindow) { + logger.warn('mainWindow is null for executeCommand', 'WebServer'); + return false; + } - // Look up the session to get Claude session ID for logging - const sessions = sessionsStore.get('sessions', []); - const session = sessions.find((s: any) => s.id === sessionId); - const agentSessionId = session?.agentSessionId || 'none'; + // Look up the session to get Claude session ID for logging + const sessions = sessionsStore.get('sessions', []); + const session = sessions.find((s: any) => s.id === sessionId); + const agentSessionId = session?.agentSessionId || 'none'; - // Forward to renderer - it will handle spawn, state, and everything else - // This ensures web commands go through exact same code path as desktop commands - // Pass inputMode so renderer uses the web's intended mode (avoids sync issues) - logger.info(`[Web → Renderer] Forwarding command | Maestro: ${sessionId} | Claude: ${agentSessionId} | Mode: ${inputMode || 'auto'} | Command: ${command.substring(0, 100)}`, 'WebServer'); - mainWindow.webContents.send('remote:executeCommand', sessionId, command, inputMode); - return true; - }); + // Forward to renderer - it will handle spawn, state, and everything else + // This ensures web commands go through exact same code path as desktop commands + // Pass inputMode so renderer uses the web's intended mode (avoids sync issues) + logger.info( + `[Web → Renderer] Forwarding command | Maestro: ${sessionId} | Claude: ${agentSessionId} | Mode: ${inputMode || 'auto'} | Command: ${command.substring(0, 100)}`, + 'WebServer' + ); + mainWindow.webContents.send('remote:executeCommand', sessionId, command, inputMode); + return true; + } + ); - // Set up callback for web server to interrupt sessions through the desktop - // This forwards to the renderer which handles state updates and broadcasts - server.setInterruptSessionCallback(async (sessionId: string) => { - if (!mainWindow) { - logger.warn('mainWindow is null for interrupt', 'WebServer'); - return false; - } + // Set up callback for web server to interrupt sessions through the desktop + // This forwards to the renderer which handles state updates and broadcasts + server.setInterruptSessionCallback(async (sessionId: string) => { + if (!mainWindow) { + logger.warn('mainWindow is null for interrupt', 'WebServer'); + return false; + } - // Forward to renderer - it will handle interrupt, state update, and broadcasts - // This ensures web interrupts go through exact same code path as desktop interrupts - logger.debug(`Forwarding interrupt to renderer for session ${sessionId}`, 'WebServer'); - mainWindow.webContents.send('remote:interrupt', sessionId); - return true; - }); + // Forward to renderer - it will handle interrupt, state update, and broadcasts + // This ensures web interrupts go through exact same code path as desktop interrupts + logger.debug(`Forwarding interrupt to renderer for session ${sessionId}`, 'WebServer'); + mainWindow.webContents.send('remote:interrupt', sessionId); + return true; + }); - // Set up callback for web server to switch session mode through the desktop - // This forwards to the renderer which handles state updates and broadcasts - server.setSwitchModeCallback(async (sessionId: string, mode: 'ai' | 'terminal') => { - logger.info(`[Web→Desktop] Mode switch callback invoked: session=${sessionId}, mode=${mode}`, 'WebServer'); - if (!mainWindow) { - logger.warn('mainWindow is null for switchMode', 'WebServer'); - return false; - } + // Set up callback for web server to switch session mode through the desktop + // This forwards to the renderer which handles state updates and broadcasts + server.setSwitchModeCallback(async (sessionId: string, mode: 'ai' | 'terminal') => { + logger.info( + `[Web→Desktop] Mode switch callback invoked: session=${sessionId}, mode=${mode}`, + 'WebServer' + ); + if (!mainWindow) { + logger.warn('mainWindow is null for switchMode', 'WebServer'); + return false; + } - // Forward to renderer - it will handle mode switch and broadcasts - // This ensures web mode switches go through exact same code path as desktop - logger.info(`[Web→Desktop] Sending IPC remote:switchMode to renderer`, 'WebServer'); - mainWindow.webContents.send('remote:switchMode', sessionId, mode); - return true; - }); + // Forward to renderer - it will handle mode switch and broadcasts + // This ensures web mode switches go through exact same code path as desktop + logger.info(`[Web→Desktop] Sending IPC remote:switchMode to renderer`, 'WebServer'); + mainWindow.webContents.send('remote:switchMode', sessionId, mode); + return true; + }); - // Set up callback for web server to select/switch to a session in the desktop - // This forwards to the renderer which handles state updates and broadcasts - // If tabId is provided, also switches to that tab within the session - server.setSelectSessionCallback(async (sessionId: string, tabId?: string) => { - logger.info(`[Web→Desktop] Session select callback invoked: session=${sessionId}, tab=${tabId || 'none'}`, 'WebServer'); - if (!mainWindow) { - logger.warn('mainWindow is null for selectSession', 'WebServer'); - return false; - } + // Set up callback for web server to select/switch to a session in the desktop + // This forwards to the renderer which handles state updates and broadcasts + // If tabId is provided, also switches to that tab within the session + server.setSelectSessionCallback(async (sessionId: string, tabId?: string) => { + logger.info( + `[Web→Desktop] Session select callback invoked: session=${sessionId}, tab=${tabId || 'none'}`, + 'WebServer' + ); + if (!mainWindow) { + logger.warn('mainWindow is null for selectSession', 'WebServer'); + return false; + } - // Forward to renderer - it will handle session selection and broadcasts - logger.info(`[Web→Desktop] Sending IPC remote:selectSession to renderer`, 'WebServer'); - mainWindow.webContents.send('remote:selectSession', sessionId, tabId); - return true; - }); + // Forward to renderer - it will handle session selection and broadcasts + logger.info(`[Web→Desktop] Sending IPC remote:selectSession to renderer`, 'WebServer'); + mainWindow.webContents.send('remote:selectSession', sessionId, tabId); + return true; + }); - // Tab operation callbacks - server.setSelectTabCallback(async (sessionId: string, tabId: string) => { - logger.info(`[Web→Desktop] Tab select callback invoked: session=${sessionId}, tab=${tabId}`, 'WebServer'); - if (!mainWindow) { - logger.warn('mainWindow is null for selectTab', 'WebServer'); - return false; - } + // Tab operation callbacks + server.setSelectTabCallback(async (sessionId: string, tabId: string) => { + logger.info( + `[Web→Desktop] Tab select callback invoked: session=${sessionId}, tab=${tabId}`, + 'WebServer' + ); + if (!mainWindow) { + logger.warn('mainWindow is null for selectTab', 'WebServer'); + return false; + } - mainWindow.webContents.send('remote:selectTab', sessionId, tabId); - return true; - }); + mainWindow.webContents.send('remote:selectTab', sessionId, tabId); + return true; + }); - server.setNewTabCallback(async (sessionId: string) => { - logger.info(`[Web→Desktop] New tab callback invoked: session=${sessionId}`, 'WebServer'); - if (!mainWindow) { - logger.warn('mainWindow is null for newTab', 'WebServer'); - return null; - } + server.setNewTabCallback(async (sessionId: string) => { + logger.info(`[Web→Desktop] New tab callback invoked: session=${sessionId}`, 'WebServer'); + if (!mainWindow) { + logger.warn('mainWindow is null for newTab', 'WebServer'); + return null; + } - // Use invoke for synchronous response with tab ID - return new Promise((resolve) => { - const responseChannel = `remote:newTab:response:${Date.now()}`; - ipcMain.once(responseChannel, (_event, result) => { - resolve(result); - }); - mainWindow!.webContents.send('remote:newTab', sessionId, responseChannel); - // Timeout after 5 seconds - setTimeout(() => resolve(null), 5000); - }); - }); + // Use invoke for synchronous response with tab ID + return new Promise((resolve) => { + const responseChannel = `remote:newTab:response:${Date.now()}`; + ipcMain.once(responseChannel, (_event, result) => { + resolve(result); + }); + mainWindow!.webContents.send('remote:newTab', sessionId, responseChannel); + // Timeout after 5 seconds + setTimeout(() => resolve(null), 5000); + }); + }); - server.setCloseTabCallback(async (sessionId: string, tabId: string) => { - logger.info(`[Web→Desktop] Close tab callback invoked: session=${sessionId}, tab=${tabId}`, 'WebServer'); - if (!mainWindow) { - logger.warn('mainWindow is null for closeTab', 'WebServer'); - return false; - } + server.setCloseTabCallback(async (sessionId: string, tabId: string) => { + logger.info( + `[Web→Desktop] Close tab callback invoked: session=${sessionId}, tab=${tabId}`, + 'WebServer' + ); + if (!mainWindow) { + logger.warn('mainWindow is null for closeTab', 'WebServer'); + return false; + } - mainWindow.webContents.send('remote:closeTab', sessionId, tabId); - return true; - }); + mainWindow.webContents.send('remote:closeTab', sessionId, tabId); + return true; + }); - server.setRenameTabCallback(async (sessionId: string, tabId: string, newName: string) => { - logger.info(`[Web→Desktop] Rename tab callback invoked: session=${sessionId}, tab=${tabId}, newName=${newName}`, 'WebServer'); - if (!mainWindow) { - logger.warn('mainWindow is null for renameTab', 'WebServer'); - return false; - } + server.setRenameTabCallback(async (sessionId: string, tabId: string, newName: string) => { + logger.info( + `[Web→Desktop] Rename tab callback invoked: session=${sessionId}, tab=${tabId}, newName=${newName}`, + 'WebServer' + ); + if (!mainWindow) { + logger.warn('mainWindow is null for renameTab', 'WebServer'); + return false; + } - mainWindow.webContents.send('remote:renameTab', sessionId, tabId, newName); - return true; - }); + mainWindow.webContents.send('remote:renameTab', sessionId, tabId, newName); + return true; + }); - return server; + return server; } function createWindow() { - // Restore saved window state - const savedState = windowStateStore.store; + // Restore saved window state + const savedState = windowStateStore.store; - mainWindow = new BrowserWindow({ - x: savedState.x, - y: savedState.y, - width: savedState.width, - height: savedState.height, - minWidth: 1000, - minHeight: 600, - backgroundColor: '#0b0b0d', - titleBarStyle: 'hiddenInset', - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - }, - }); + mainWindow = new BrowserWindow({ + x: savedState.x, + y: savedState.y, + width: savedState.width, + height: savedState.height, + minWidth: 1000, + minHeight: 600, + backgroundColor: '#0b0b0d', + titleBarStyle: 'hiddenInset', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + }, + }); - // Restore maximized/fullscreen state after window is created - if (savedState.isFullScreen) { - mainWindow.setFullScreen(true); - } else if (savedState.isMaximized) { - mainWindow.maximize(); - } + // Restore maximized/fullscreen state after window is created + if (savedState.isFullScreen) { + mainWindow.setFullScreen(true); + } else if (savedState.isMaximized) { + mainWindow.maximize(); + } - logger.info('Browser window created', 'Window', { - size: `${savedState.width}x${savedState.height}`, - maximized: savedState.isMaximized, - fullScreen: savedState.isFullScreen, - mode: process.env.NODE_ENV || 'production' - }); + logger.info('Browser window created', 'Window', { + size: `${savedState.width}x${savedState.height}`, + maximized: savedState.isMaximized, + fullScreen: savedState.isFullScreen, + mode: process.env.NODE_ENV || 'production', + }); - // Save window state before closing - const saveWindowState = () => { - if (!mainWindow) return; + // Save window state before closing + const saveWindowState = () => { + if (!mainWindow) return; - const isMaximized = mainWindow.isMaximized(); - const isFullScreen = mainWindow.isFullScreen(); - const bounds = mainWindow.getBounds(); + const isMaximized = mainWindow.isMaximized(); + const isFullScreen = mainWindow.isFullScreen(); + const bounds = mainWindow.getBounds(); - // Only save bounds if not maximized/fullscreen (to restore proper size later) - if (!isMaximized && !isFullScreen) { - windowStateStore.set('x', bounds.x); - windowStateStore.set('y', bounds.y); - windowStateStore.set('width', bounds.width); - windowStateStore.set('height', bounds.height); - } - windowStateStore.set('isMaximized', isMaximized); - windowStateStore.set('isFullScreen', isFullScreen); - }; + // Only save bounds if not maximized/fullscreen (to restore proper size later) + if (!isMaximized && !isFullScreen) { + windowStateStore.set('x', bounds.x); + windowStateStore.set('y', bounds.y); + windowStateStore.set('width', bounds.width); + windowStateStore.set('height', bounds.height); + } + windowStateStore.set('isMaximized', isMaximized); + windowStateStore.set('isFullScreen', isFullScreen); + }; - mainWindow.on('close', saveWindowState); + mainWindow.on('close', saveWindowState); - // Load the app - if (process.env.NODE_ENV === 'development') { - // Install React DevTools extension in development mode - import('electron-devtools-installer').then(({ default: installExtension, REACT_DEVELOPER_TOOLS }) => { - installExtension(REACT_DEVELOPER_TOOLS) - .then(() => logger.info('React DevTools extension installed', 'Window')) - .catch((err: Error) => logger.warn(`Failed to install React DevTools: ${err.message}`, 'Window')); - }).catch((err: Error) => logger.warn(`Failed to load electron-devtools-installer: ${err.message}`, 'Window')); + // Load the app + if (process.env.NODE_ENV === 'development') { + // Install React DevTools extension in development mode + import('electron-devtools-installer') + .then(({ default: installExtension, REACT_DEVELOPER_TOOLS }) => { + installExtension(REACT_DEVELOPER_TOOLS) + .then(() => logger.info('React DevTools extension installed', 'Window')) + .catch((err: Error) => + logger.warn(`Failed to install React DevTools: ${err.message}`, 'Window') + ); + }) + .catch((err: Error) => + logger.warn(`Failed to load electron-devtools-installer: ${err.message}`, 'Window') + ); - mainWindow.loadURL('http://localhost:5173'); - // DevTools can be opened via Command-K menu instead of automatically on startup - logger.info('Loading development server', 'Window'); - } else { - mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')); - logger.info('Loading production build', 'Window'); - // Open DevTools in production if DEBUG env var is set - if (process.env.DEBUG === 'true') { - mainWindow.webContents.openDevTools(); - } - } + mainWindow.loadURL('http://localhost:5173'); + // DevTools can be opened via Command-K menu instead of automatically on startup + logger.info('Loading development server', 'Window'); + } else { + mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')); + logger.info('Loading production build', 'Window'); + // Open DevTools in production if DEBUG env var is set + if (process.env.DEBUG === 'true') { + mainWindow.webContents.openDevTools(); + } + } - mainWindow.on('closed', () => { - logger.info('Browser window closed', 'Window'); - mainWindow = null; - }); + mainWindow.on('closed', () => { + logger.info('Browser window closed', 'Window'); + mainWindow = null; + }); - // Initialize auto-updater (only in production) - if (process.env.NODE_ENV !== 'development') { - initAutoUpdater(mainWindow); - logger.info('Auto-updater initialized', 'Window'); - } else { - // Register stub handlers in development mode so users get a helpful error - ipcMain.handle('updates:download', async () => { - return { success: false, error: 'Auto-update is disabled in development mode. Please check update first.' }; - }); - ipcMain.handle('updates:install', async () => { - logger.warn('Auto-update install called in development mode', 'AutoUpdater'); - }); - ipcMain.handle('updates:getStatus', async () => { - return { status: 'idle' as const }; - }); - ipcMain.handle('updates:checkAutoUpdater', async () => { - return { success: false, error: 'Auto-update is disabled in development mode' }; - }); - logger.info('Auto-updater disabled in development mode (stub handlers registered)', 'Window'); - } + // Initialize auto-updater (only in production) + if (process.env.NODE_ENV !== 'development') { + initAutoUpdater(mainWindow); + logger.info('Auto-updater initialized', 'Window'); + } else { + // Register stub handlers in development mode so users get a helpful error + ipcMain.handle('updates:download', async () => { + return { + success: false, + error: 'Auto-update is disabled in development mode. Please check update first.', + }; + }); + ipcMain.handle('updates:install', async () => { + logger.warn('Auto-update install called in development mode', 'AutoUpdater'); + }); + ipcMain.handle('updates:getStatus', async () => { + return { status: 'idle' as const }; + }); + ipcMain.handle('updates:checkAutoUpdater', async () => { + return { success: false, error: 'Auto-update is disabled in development mode' }; + }); + logger.info('Auto-updater disabled in development mode (stub handlers registered)', 'Window'); + } } // Set up global error handlers for uncaught exceptions process.on('uncaughtException', (error: Error) => { - logger.error( - `Uncaught Exception: ${error.message}`, - 'UncaughtException', - { - stack: error.stack, - name: error.name, - } - ); - // Don't exit the process - let it continue running + logger.error(`Uncaught Exception: ${error.message}`, 'UncaughtException', { + stack: error.stack, + name: error.name, + }); + // Don't exit the process - let it continue running }); process.on('unhandledRejection', (reason: any, promise: Promise) => { - logger.error( - `Unhandled Promise Rejection: ${reason?.message || String(reason)}`, - 'UnhandledRejection', - { - reason: reason, - stack: reason?.stack, - promise: String(promise), - } - ); + logger.error( + `Unhandled Promise Rejection: ${reason?.message || String(reason)}`, + 'UnhandledRejection', + { + reason: reason, + stack: reason?.stack, + promise: String(promise), + } + ); }); app.whenReady().then(async () => { - // Load logger settings first - const logLevel = store.get('logLevel', 'info'); - logger.setLogLevel(logLevel); - const maxLogBuffer = store.get('maxLogBuffer', 1000); - logger.setMaxLogBuffer(maxLogBuffer); + // Load logger settings first + const logLevel = store.get('logLevel', 'info'); + logger.setLogLevel(logLevel); + const maxLogBuffer = store.get('maxLogBuffer', 1000); + logger.setMaxLogBuffer(maxLogBuffer); - logger.info('Maestro application starting', 'Startup', { - version: app.getVersion(), - platform: process.platform, - logLevel - }); + logger.info('Maestro application starting', 'Startup', { + version: app.getVersion(), + platform: process.platform, + logLevel, + }); - // Check for WSL + Windows mount issues early - checkWslEnvironment(process.cwd()); + // Check for WSL + Windows mount issues early + checkWslEnvironment(process.cwd()); - // Initialize core services - logger.info('Initializing core services', 'Startup'); - processManager = new ProcessManager(); - // Note: webServer is created on-demand when user enables web interface (see setupWebServerCallbacks) - agentDetector = new AgentDetector(); + // Initialize core services + logger.info('Initializing core services', 'Startup'); + processManager = new ProcessManager(); + // Note: webServer is created on-demand when user enables web interface (see setupWebServerCallbacks) + agentDetector = new AgentDetector(); - // Load custom agent paths from settings - const allAgentConfigs = agentConfigsStore.get('configs', {}); - const customPaths: Record = {}; - for (const [agentId, config] of Object.entries(allAgentConfigs)) { - if (config && typeof config === 'object' && 'customPath' in config && config.customPath) { - customPaths[agentId] = config.customPath as string; - } - } - if (Object.keys(customPaths).length > 0) { - agentDetector.setCustomPaths(customPaths); - logger.info(`Loaded custom agent paths: ${JSON.stringify(customPaths)}`, 'Startup'); - } + // Load custom agent paths from settings + const allAgentConfigs = agentConfigsStore.get('configs', {}); + const customPaths: Record = {}; + for (const [agentId, config] of Object.entries(allAgentConfigs)) { + if (config && typeof config === 'object' && 'customPath' in config && config.customPath) { + customPaths[agentId] = config.customPath as string; + } + } + if (Object.keys(customPaths).length > 0) { + agentDetector.setCustomPaths(customPaths); + logger.info(`Loaded custom agent paths: ${JSON.stringify(customPaths)}`, 'Startup'); + } - logger.info('Core services initialized', 'Startup'); + logger.info('Core services initialized', 'Startup'); - // Initialize history manager (handles migration from legacy format if needed) - logger.info('Initializing history manager', 'Startup'); - const historyManager = getHistoryManager(); - try { - await historyManager.initialize(); - logger.info('History manager initialized', 'Startup'); - // Start watching history directory for external changes (from CLI, etc.) - historyManager.startWatching((sessionId) => { - logger.debug(`History file changed for session ${sessionId}, notifying renderer`, 'HistoryWatcher'); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('history:externalChange', sessionId); - } - }); - } catch (error) { - // Migration failed - log error but continue with app startup - // History will be unavailable but the app will still function - logger.error(`Failed to initialize history manager: ${error}`, 'Startup'); - logger.warn('Continuing without history - history features will be unavailable', 'Startup'); - } + // Initialize history manager (handles migration from legacy format if needed) + logger.info('Initializing history manager', 'Startup'); + const historyManager = getHistoryManager(); + try { + await historyManager.initialize(); + logger.info('History manager initialized', 'Startup'); + // Start watching history directory for external changes (from CLI, etc.) + historyManager.startWatching((sessionId) => { + logger.debug( + `History file changed for session ${sessionId}, notifying renderer`, + 'HistoryWatcher' + ); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('history:externalChange', sessionId); + } + }); + } catch (error) { + // Migration failed - log error but continue with app startup + // History will be unavailable but the app will still function + logger.error(`Failed to initialize history manager: ${error}`, 'Startup'); + logger.warn('Continuing without history - history features will be unavailable', 'Startup'); + } - // Initialize stats database for usage tracking - logger.info('Initializing stats database', 'Startup'); - try { - initializeStatsDB(); - logger.info('Stats database initialized', 'Startup'); - } catch (error) { - // Stats initialization failed - log error but continue with app startup - // Stats will be unavailable but the app will still function - logger.error(`Failed to initialize stats database: ${error}`, 'Startup'); - logger.warn('Continuing without stats - usage tracking will be unavailable', 'Startup'); - } + // Initialize stats database for usage tracking + logger.info('Initializing stats database', 'Startup'); + try { + initializeStatsDB(); + logger.info('Stats database initialized', 'Startup'); + } catch (error) { + // Stats initialization failed - log error but continue with app startup + // Stats will be unavailable but the app will still function + logger.error(`Failed to initialize stats database: ${error}`, 'Startup'); + logger.warn('Continuing without stats - usage tracking will be unavailable', 'Startup'); + } - // Set up IPC handlers - logger.debug('Setting up IPC handlers', 'Startup'); - setupIpcHandlers(); + // Set up IPC handlers + logger.debug('Setting up IPC handlers', 'Startup'); + setupIpcHandlers(); - // Set up process event listeners - logger.debug('Setting up process event listeners', 'Startup'); - setupProcessListeners(); + // Set up process event listeners + logger.debug('Setting up process event listeners', 'Startup'); + setupProcessListeners(); - // Create main window - logger.info('Creating main window', 'Startup'); - createWindow(); + // Create main window + logger.info('Creating main window', 'Startup'); + createWindow(); - // Note: History file watching is handled by HistoryManager.startWatching() above - // which uses the new per-session file format in the history/ directory + // Note: History file watching is handled by HistoryManager.startWatching() above + // which uses the new per-session file format in the history/ directory - // Start CLI activity watcher (polls every 2 seconds for CLI playbook runs) - startCliActivityWatcher(); + // Start CLI activity watcher (polls every 2 seconds for CLI playbook runs) + startCliActivityWatcher(); - // Note: Web server is not auto-started - it starts when user enables web interface - // via live:startServer IPC call from the renderer + // Note: Web server is not auto-started - it starts when user enables web interface + // via live:startServer IPC call from the renderer - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } - }); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); }); app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } + if (process.platform !== 'darwin') { + app.quit(); + } }); // Track if quit has been confirmed by user (or no busy agents) @@ -907,78 +1028,78 @@ let quitConfirmed = false; // Handle quit confirmation from renderer ipcMain.on('app:quitConfirmed', () => { - logger.info('Quit confirmed by renderer', 'Window'); - quitConfirmed = true; - app.quit(); + logger.info('Quit confirmed by renderer', 'Window'); + quitConfirmed = true; + app.quit(); }); // Handle quit cancellation (user declined) ipcMain.on('app:quitCancelled', () => { - logger.info('Quit cancelled by renderer', 'Window'); - // Nothing to do - app stays running + logger.info('Quit cancelled by renderer', 'Window'); + // Nothing to do - app stays running }); // IMPORTANT: This handler must be synchronous for event.preventDefault() to work! // Async handlers return a Promise immediately, which breaks preventDefault in Electron. app.on('before-quit', (event) => { - // If quit not yet confirmed, intercept and ask renderer - if (!quitConfirmed) { - event.preventDefault(); + // If quit not yet confirmed, intercept and ask renderer + if (!quitConfirmed) { + event.preventDefault(); - // Ask renderer to check for busy agents - if (mainWindow && !mainWindow.isDestroyed()) { - logger.info('Requesting quit confirmation from renderer', 'Window'); - mainWindow.webContents.send('app:requestQuitConfirmation'); - } else { - // No window, just quit - quitConfirmed = true; - app.quit(); - } - return; - } + // Ask renderer to check for busy agents + if (mainWindow && !mainWindow.isDestroyed()) { + logger.info('Requesting quit confirmation from renderer', 'Window'); + mainWindow.webContents.send('app:requestQuitConfirmation'); + } else { + // No window, just quit + quitConfirmed = true; + app.quit(); + } + return; + } - // Quit confirmed - proceed with cleanup (async operations are fire-and-forget) - logger.info('Application shutting down', 'Shutdown'); + // Quit confirmed - proceed with cleanup (async operations are fire-and-forget) + logger.info('Application shutting down', 'Shutdown'); - // Stop history manager watcher - getHistoryManager().stopWatching(); + // Stop history manager watcher + getHistoryManager().stopWatching(); - // Stop CLI activity watcher - if (cliActivityWatcher) { - cliActivityWatcher.close(); - cliActivityWatcher = null; - } + // Stop CLI activity watcher + if (cliActivityWatcher) { + cliActivityWatcher.close(); + cliActivityWatcher = null; + } - // Clean up active grooming sessions (context merge/transfer operations) - const groomingSessionCount = getActiveGroomingSessionCount(); - if (groomingSessionCount > 0 && processManager) { - logger.info(`Cleaning up ${groomingSessionCount} active grooming session(s)`, 'Shutdown'); - // Fire and forget - don't await - cleanupAllGroomingSessions(processManager).catch(err => { - logger.error(`Error cleaning up grooming sessions: ${err}`, 'Shutdown'); - }); - } + // Clean up active grooming sessions (context merge/transfer operations) + const groomingSessionCount = getActiveGroomingSessionCount(); + if (groomingSessionCount > 0 && processManager) { + logger.info(`Cleaning up ${groomingSessionCount} active grooming session(s)`, 'Shutdown'); + // Fire and forget - don't await + cleanupAllGroomingSessions(processManager).catch((err) => { + logger.error(`Error cleaning up grooming sessions: ${err}`, 'Shutdown'); + }); + } - // Clean up all running processes - logger.info('Killing all running processes', 'Shutdown'); - processManager?.killAll(); + // Clean up all running processes + logger.info('Killing all running processes', 'Shutdown'); + processManager?.killAll(); - // Stop tunnel and web server (fire and forget) - logger.info('Stopping tunnel', 'Shutdown'); - tunnelManager.stop().catch(err => { - logger.error(`Error stopping tunnel: ${err}`, 'Shutdown'); - }); + // Stop tunnel and web server (fire and forget) + logger.info('Stopping tunnel', 'Shutdown'); + tunnelManager.stop().catch((err) => { + logger.error(`Error stopping tunnel: ${err}`, 'Shutdown'); + }); - logger.info('Stopping web server', 'Shutdown'); - webServer?.stop().catch(err => { - logger.error(`Error stopping web server: ${err}`, 'Shutdown'); - }); + logger.info('Stopping web server', 'Shutdown'); + webServer?.stop().catch((err) => { + logger.error(`Error stopping web server: ${err}`, 'Shutdown'); + }); - // Close stats database - logger.info('Closing stats database', 'Shutdown'); - closeStatsDB(); + // Close stats database + logger.info('Closing stats database', 'Shutdown'); + closeStatsDB(); - logger.info('Shutdown complete', 'Shutdown'); + logger.info('Shutdown complete', 'Shutdown'); }); /** @@ -986,1644 +1107,1732 @@ app.on('before-quit', (event) => { * Uses fs.watch() for event-driven detection when CLI is running playbooks */ function startCliActivityWatcher() { - const cliActivityPath = path.join(app.getPath('userData'), 'cli-activity.json'); - const cliActivityDir = path.dirname(cliActivityPath); + const cliActivityPath = path.join(app.getPath('userData'), 'cli-activity.json'); + const cliActivityDir = path.dirname(cliActivityPath); - // Ensure directory exists for watching - if (!fsSync.existsSync(cliActivityDir)) { - fsSync.mkdirSync(cliActivityDir, { recursive: true }); - } + // Ensure directory exists for watching + if (!fsSync.existsSync(cliActivityDir)) { + fsSync.mkdirSync(cliActivityDir, { recursive: true }); + } - // Watch the directory for file changes (handles file creation/deletion) - // Using directory watch because fs.watch on non-existent file throws - try { - cliActivityWatcher = fsSync.watch(cliActivityDir, (_eventType, filename) => { - if (filename === 'cli-activity.json') { - logger.debug('CLI activity file changed, notifying renderer', 'CliActivityWatcher'); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('cli:activityChange'); - } - } - }); + // Watch the directory for file changes (handles file creation/deletion) + // Using directory watch because fs.watch on non-existent file throws + try { + cliActivityWatcher = fsSync.watch(cliActivityDir, (_eventType, filename) => { + if (filename === 'cli-activity.json') { + logger.debug('CLI activity file changed, notifying renderer', 'CliActivityWatcher'); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('cli:activityChange'); + } + } + }); - cliActivityWatcher.on('error', (error) => { - logger.error(`CLI activity watcher error: ${error.message}`, 'CliActivityWatcher'); - }); + cliActivityWatcher.on('error', (error) => { + logger.error(`CLI activity watcher error: ${error.message}`, 'CliActivityWatcher'); + }); - logger.info('CLI activity watcher started', 'Startup'); - } catch (error) { - logger.error(`Failed to start CLI activity watcher: ${error}`, 'CliActivityWatcher'); - } + logger.info('CLI activity watcher started', 'Startup'); + } catch (error) { + logger.error(`Failed to start CLI activity watcher: ${error}`, 'CliActivityWatcher'); + } } function setupIpcHandlers() { - // Settings, sessions, and groups persistence - extracted to src/main/ipc/handlers/persistence.ts - - // Broadcast user input to web clients (called when desktop sends a message) - ipcMain.handle('web:broadcastUserInput', async (_, sessionId: string, command: string, inputMode: 'ai' | 'terminal') => { - if (webServer && webServer.getWebClientCount() > 0) { - webServer.broadcastUserInput(sessionId, command, inputMode); - return true; - } - return false; - }); - - // Broadcast AutoRun state to web clients (called when batch processing state changes) - // Always store state even if no clients are connected, so new clients get initial state - ipcMain.handle('web:broadcastAutoRunState', async (_, sessionId: string, state: { - isRunning: boolean; - totalTasks: number; - completedTasks: number; - currentTaskIndex: number; - isStopping?: boolean; - // Multi-document progress fields - totalDocuments?: number; - currentDocumentIndex?: number; - totalTasksAcrossAllDocs?: number; - completedTasksAcrossAllDocs?: number; - } | null) => { - if (webServer) { - // Always call broadcastAutoRunState - it stores the state for new clients - // and broadcasts to any currently connected clients - webServer.broadcastAutoRunState(sessionId, state); - return true; - } - return false; - }); - - // Broadcast tab changes to web clients - ipcMain.handle('web:broadcastTabsChange', async (_, sessionId: string, aiTabs: any[], activeTabId: string) => { - if (webServer && webServer.getWebClientCount() > 0) { - webServer.broadcastTabsChange(sessionId, aiTabs, activeTabId); - return true; - } - return false; - }); - - // Broadcast session state change to web clients (for real-time busy/idle updates) - // This is called directly from the renderer to bypass debounced persistence - // which resets state to 'idle' before saving - ipcMain.handle('web:broadcastSessionState', async (_, sessionId: string, state: string, additionalData?: { - name?: string; - toolType?: string; - inputMode?: string; - cwd?: string; - }) => { - if (webServer && webServer.getWebClientCount() > 0) { - webServer.broadcastSessionStateChange(sessionId, state, additionalData); - return true; - } - return false; - }); - - // Git operations - extracted to src/main/ipc/handlers/git.ts - registerGitHandlers({ - settingsStore: store, - }); - - // Auto Run operations - extracted to src/main/ipc/handlers/autorun.ts - registerAutorunHandlers({ - mainWindow, - getMainWindow: () => mainWindow, - app, - settingsStore: store, - }); - - // Playbook operations - extracted to src/main/ipc/handlers/playbooks.ts - registerPlaybooksHandlers({ - mainWindow, - getMainWindow: () => mainWindow, - app, - }); - - // History operations - extracted to src/main/ipc/handlers/history.ts - // Uses HistoryManager singleton for per-session storage - registerHistoryHandlers(); - - // Agent management operations - extracted to src/main/ipc/handlers/agents.ts - registerAgentsHandlers({ - getAgentDetector: () => agentDetector, - agentConfigsStore, - settingsStore: store, - }); - - // Process management operations - extracted to src/main/ipc/handlers/process.ts - registerProcessHandlers({ - getProcessManager: () => processManager, - getAgentDetector: () => agentDetector, - agentConfigsStore, - settingsStore: store, - getMainWindow: () => mainWindow, - }); - - // Persistence operations - extracted to src/main/ipc/handlers/persistence.ts - registerPersistenceHandlers({ - settingsStore: store, - sessionsStore, - groupsStore, - getWebServer: () => webServer, - }); - - // System operations - extracted to src/main/ipc/handlers/system.ts - registerSystemHandlers({ - getMainWindow: () => mainWindow, - app, - settingsStore: store, - tunnelManager, - getWebServer: () => webServer, - bootstrapStore, // For iCloud/sync settings - }); - - // Claude Code sessions - extracted to src/main/ipc/handlers/claude.ts - registerClaudeHandlers({ - claudeSessionOriginsStore, - getMainWindow: () => mainWindow, - }); - - // Initialize output parsers for all agents (Codex, OpenCode, Claude Code) - // This must be called before any agent output is processed - initializeOutputParsers(); - - // Initialize session storages and register generic agent sessions handlers - // This provides the new window.maestro.agentSessions.* API - // Pass the shared claudeSessionOriginsStore so session names/stars are consistent - initializeSessionStorages({ claudeSessionOriginsStore }); - registerAgentSessionsHandlers({ getMainWindow: () => mainWindow, agentSessionOriginsStore }); - - // Helper to get agent config values (custom args/env vars, model, etc.) - const getAgentConfigForAgent = (agentId: string): Record => { - const allConfigs = agentConfigsStore.get('configs', {}); - return allConfigs[agentId] || {}; - }; - - // Helper to get custom env vars for an agent - const getCustomEnvVarsForAgent = (agentId: string): Record | undefined => { - return getAgentConfigForAgent(agentId).customEnvVars as Record | undefined; - }; - - // Register Group Chat handlers - registerGroupChatHandlers({ - getMainWindow: () => mainWindow, - getProcessManager: () => processManager, - getAgentDetector: () => agentDetector, - getCustomEnvVars: getCustomEnvVarsForAgent, - getAgentConfig: getAgentConfigForAgent, - }); - - // Register Debug Package handlers - registerDebugHandlers({ - getMainWindow: () => mainWindow, - getAgentDetector: () => agentDetector, - getProcessManager: () => processManager, - getWebServer: () => webServer, - settingsStore: store, - sessionsStore, - groupsStore, - bootstrapStore, - }); - - // Register Spec Kit handlers (no dependencies needed) - registerSpeckitHandlers(); - - // Register OpenSpec handlers (no dependencies needed) - registerOpenSpecHandlers(); - - // Register Context Merge handlers for session context transfer and grooming - registerContextHandlers({ - getMainWindow: () => mainWindow, - getProcessManager: () => processManager, - getAgentDetector: () => agentDetector, - }); - - // Register Marketplace handlers for fetching and importing playbooks - registerMarketplaceHandlers({ - app, - settingsStore: store, - }); - - // Register Stats handlers for usage tracking - registerStatsHandlers({ - getMainWindow: () => mainWindow, - settingsStore: store, - }); - - // Register Document Graph handlers for file watching - registerDocumentGraphHandlers({ - getMainWindow: () => mainWindow, - app, - }); - - // Register SSH Remote handlers for managing SSH configurations - registerSshRemoteHandlers({ - settingsStore: store, - }); - - // Set up callback for group chat router to lookup sessions for auto-add @mentions - setGetSessionsCallback(() => { - const sessions = sessionsStore.get('sessions', []); - return sessions.map((s: any) => { - // Resolve SSH remote name if session has SSH config - let sshRemoteName: string | undefined; - if (s.sessionSshRemoteConfig?.enabled && s.sessionSshRemoteConfig.remoteId) { - const sshConfig = getSshRemoteById(s.sessionSshRemoteConfig.remoteId); - sshRemoteName = sshConfig?.name; - } - return { - id: s.id, - name: s.name, - toolType: s.toolType, - cwd: s.cwd || s.fullPath || process.env.HOME || '/tmp', - customArgs: s.customArgs, - customEnvVars: s.customEnvVars, - customModel: s.customModel, - sshRemoteName, - }; - }); - }); - - // Set up callback for group chat router to lookup custom env vars for agents - setGetCustomEnvVarsCallback(getCustomEnvVarsForAgent); - setGetAgentConfigCallback(getAgentConfigForAgent); - - // Setup logger event forwarding to renderer - setupLoggerEventForwarding(() => mainWindow); - - // File system operations - ipcMain.handle('fs:homeDir', () => { - return os.homedir(); - }); - - ipcMain.handle('fs:readDir', async (_, dirPath: string, sshRemoteId?: string) => { - // SSH remote: dispatch to remote fs operations - if (sshRemoteId) { - const sshConfig = getSshRemoteById(sshRemoteId); - if (!sshConfig) { - throw new Error(`SSH remote not found: ${sshRemoteId}`); - } - const result = await readDirRemote(dirPath, sshConfig); - if (!result.success) { - throw new Error(result.error || 'Failed to read remote directory'); - } - // Map remote entries to match local format (isFile derived from !isDirectory && !isSymlink) - // Include full path for recursive directory scanning (e.g., document graph) - // Use POSIX path joining for remote paths (always forward slashes) - return result.data!.map((entry) => ({ - name: entry.name, - isDirectory: entry.isDirectory, - isFile: !entry.isDirectory && !entry.isSymlink, - path: dirPath.endsWith('/') ? `${dirPath}${entry.name}` : `${dirPath}/${entry.name}`, - })); - } - - // Local: use standard fs operations - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - // Convert Dirent objects to plain objects for IPC serialization - // Include full path for recursive directory scanning (e.g., document graph) - return entries.map((entry: any) => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isFile: entry.isFile(), - path: path.join(dirPath, entry.name), - })); - }); - - ipcMain.handle('fs:readFile', async (_, filePath: string, sshRemoteId?: string) => { - try { - // SSH remote: dispatch to remote fs operations - if (sshRemoteId) { - const sshConfig = getSshRemoteById(sshRemoteId); - if (!sshConfig) { - throw new Error(`SSH remote not found: ${sshRemoteId}`); - } - const result = await readFileRemote(filePath, sshConfig); - if (!result.success) { - throw new Error(result.error || 'Failed to read remote file'); - } - // For images over SSH, we'd need to base64 encode on remote and decode here - // For now, return raw content (text files work, binary images may have issues) - const ext = filePath.split('.').pop()?.toLowerCase(); - const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']; - const isImage = imageExtensions.includes(ext || ''); - if (isImage) { - // The remote readFile returns raw bytes as string - convert to base64 data URL - const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`; - const base64 = Buffer.from(result.data!, 'binary').toString('base64'); - return `data:${mimeType};base64,${base64}`; - } - return result.data!; - } - - // Local: use standard fs operations - // Check if file is an image - const ext = filePath.split('.').pop()?.toLowerCase(); - const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']; - const isImage = imageExtensions.includes(ext || ''); - - if (isImage) { - // Read image as buffer and convert to base64 data URL - const buffer = await fs.readFile(filePath); - const base64 = buffer.toString('base64'); - const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`; - return `data:${mimeType};base64,${base64}`; - } else { - // Read text files as UTF-8 - const content = await fs.readFile(filePath, 'utf-8'); - return content; - } - } catch (error) { - throw new Error(`Failed to read file: ${error}`); - } - }); - - ipcMain.handle('fs:stat', async (_, filePath: string, sshRemoteId?: string) => { - try { - // SSH remote: dispatch to remote fs operations - if (sshRemoteId) { - const sshConfig = getSshRemoteById(sshRemoteId); - if (!sshConfig) { - throw new Error(`SSH remote not found: ${sshRemoteId}`); - } - const result = await statRemote(filePath, sshConfig); - if (!result.success) { - throw new Error(result.error || 'Failed to get remote file stats'); - } - // Map remote stat result to match local format - // Note: remote stat doesn't provide createdAt (birthtime), use mtime as fallback - const mtimeDate = new Date(result.data!.mtime); - return { - size: result.data!.size, - createdAt: mtimeDate.toISOString(), // Fallback: use mtime for createdAt - modifiedAt: mtimeDate.toISOString(), - isDirectory: result.data!.isDirectory, - isFile: !result.data!.isDirectory, - }; - } - - // Local: use standard fs operations - const stats = await fs.stat(filePath); - return { - size: stats.size, - createdAt: stats.birthtime.toISOString(), - modifiedAt: stats.mtime.toISOString(), - isDirectory: stats.isDirectory(), - isFile: stats.isFile() - }; - } catch (error) { - throw new Error(`Failed to get file stats: ${error}`); - } - }); - - // Calculate total size of a directory recursively - // Respects the same ignore patterns as loadFileTree (node_modules, __pycache__) - ipcMain.handle('fs:directorySize', async (_, dirPath: string, sshRemoteId?: string) => { - // SSH remote: dispatch to remote fs operations - if (sshRemoteId) { - const sshConfig = getSshRemoteById(sshRemoteId); - if (!sshConfig) { - throw new Error(`SSH remote not found: ${sshRemoteId}`); - } - // Fetch size and counts in parallel for SSH remotes - const [sizeResult, countResult] = await Promise.all([ - directorySizeRemote(dirPath, sshConfig), - countItemsRemote(dirPath, sshConfig), - ]); - if (!sizeResult.success) { - throw new Error(sizeResult.error || 'Failed to get remote directory size'); - } - return { - totalSize: sizeResult.data!, - fileCount: countResult.success ? countResult.data!.fileCount : 0, - folderCount: countResult.success ? countResult.data!.folderCount : 0, - }; - } - - // Local: use standard fs operations - let totalSize = 0; - let fileCount = 0; - let folderCount = 0; - - const calculateSize = async (currentPath: string, depth: number = 0): Promise => { - // Limit recursion depth to match file tree loading - if (depth >= 10) return; - - try { - const entries = await fs.readdir(currentPath, { withFileTypes: true }); - - for (const entry of entries) { - // Skip common ignore patterns (same as loadFileTree) - if (entry.name === 'node_modules' || entry.name === '__pycache__') { - continue; - } - - const fullPath = path.join(currentPath, entry.name); - - if (entry.isDirectory()) { - folderCount++; - await calculateSize(fullPath, depth + 1); - } else if (entry.isFile()) { - fileCount++; - try { - const stats = await fs.stat(fullPath); - totalSize += stats.size; - } catch { - // Skip files we can't stat (permissions, etc.) - } - } - } - } catch { - // Skip directories we can't read - } - }; - - await calculateSize(dirPath); - - return { - totalSize, - fileCount, - folderCount - }; - }); - - ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string) => { - try { - await fs.writeFile(filePath, content, 'utf-8'); - return { success: true }; - } catch (error) { - throw new Error(`Failed to write file: ${error}`); - } - }); - - // Rename a file or folder (supports SSH remote) - ipcMain.handle('fs:rename', async (_, oldPath: string, newPath: string, sshRemoteId?: string) => { - try { - // SSH remote: dispatch to remote fs operations - if (sshRemoteId) { - const sshConfig = getSshRemoteById(sshRemoteId); - if (!sshConfig) { - throw new Error(`SSH remote not found: ${sshRemoteId}`); - } - const result = await renameRemote(oldPath, newPath, sshConfig); - if (!result.success) { - throw new Error(result.error || 'Failed to rename remote file'); - } - return { success: true }; - } - - // Local: standard fs rename - await fs.rename(oldPath, newPath); - return { success: true }; - } catch (error) { - throw new Error(`Failed to rename: ${error}`); - } - }); - - // Delete a file or folder (with recursive option for folders, supports SSH remote) - ipcMain.handle('fs:delete', async (_, targetPath: string, options?: { recursive?: boolean; sshRemoteId?: string }) => { - try { - const sshRemoteId = options?.sshRemoteId; - - // SSH remote: dispatch to remote fs operations - if (sshRemoteId) { - const sshConfig = getSshRemoteById(sshRemoteId); - if (!sshConfig) { - throw new Error(`SSH remote not found: ${sshRemoteId}`); - } - const result = await deleteRemote(targetPath, sshConfig, options?.recursive ?? true); - if (!result.success) { - throw new Error(result.error || 'Failed to delete remote file'); - } - return { success: true }; - } - - // Local: standard fs delete - const stat = await fs.stat(targetPath); - if (stat.isDirectory()) { - await fs.rm(targetPath, { recursive: options?.recursive ?? true, force: true }); - } else { - await fs.unlink(targetPath); - } - return { success: true }; - } catch (error) { - throw new Error(`Failed to delete: ${error}`); - } - }); - - // Count items in a directory (for delete confirmation, supports SSH remote) - ipcMain.handle('fs:countItems', async (_, dirPath: string, sshRemoteId?: string) => { - try { - // SSH remote: dispatch to remote fs operations - if (sshRemoteId) { - const sshConfig = getSshRemoteById(sshRemoteId); - if (!sshConfig) { - throw new Error(`SSH remote not found: ${sshRemoteId}`); - } - const result = await countItemsRemote(dirPath, sshConfig); - if (!result.success || !result.data) { - throw new Error(result.error || 'Failed to count remote items'); - } - return result.data; - } - - // Local: standard fs count - let fileCount = 0; - let folderCount = 0; - - const countRecursive = async (dir: string) => { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - folderCount++; - await countRecursive(path.join(dir, entry.name)); - } else { - fileCount++; - } - } - }; - - await countRecursive(dirPath); - return { fileCount, folderCount }; - } catch (error) { - throw new Error(`Failed to count items: ${error}`); - } - }); - - // Fetch image from URL and return as base64 data URL (avoids CORS issues) - ipcMain.handle('fs:fetchImageAsBase64', async (_, url: string) => { - try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - const base64 = buffer.toString('base64'); - // Determine mime type from content-type header or URL - const contentType = response.headers.get('content-type') || 'image/png'; - return `data:${contentType};base64,${base64}`; - } catch (error) { - // Return null on failure - let caller handle gracefully - logger.warn(`Failed to fetch image from ${url}: ${error}`, 'fs:fetchImageAsBase64'); - return null; - } - }); - - // Live session management - toggle sessions as live/offline in web interface - ipcMain.handle('live:toggle', async (_, sessionId: string, agentSessionId?: string) => { - if (!webServer) { - throw new Error('Web server not initialized'); - } - - // Ensure web server is running before allowing live toggle - if (!webServer.isActive()) { - logger.warn('Web server not yet started, waiting...', 'Live'); - // Wait for server to start (with timeout) - const startTime = Date.now(); - while (!webServer.isActive() && Date.now() - startTime < 5000) { - await new Promise(resolve => setTimeout(resolve, 100)); - } - if (!webServer.isActive()) { - throw new Error('Web server failed to start'); - } - } - - const isLive = webServer.isSessionLive(sessionId); - - if (isLive) { - // Turn off live mode - webServer.setSessionOffline(sessionId); - logger.info(`Session ${sessionId} is now offline`, 'Live'); - return { live: false, url: null }; - } else { - // Turn on live mode - logger.info(`Enabling live mode for session ${sessionId} (claude: ${agentSessionId || 'none'})`, 'Live'); - webServer.setSessionLive(sessionId, agentSessionId); - const url = webServer.getSessionUrl(sessionId); - logger.info(`Session ${sessionId} is now live at ${url}`, 'Live'); - return { live: true, url }; - } - }); - - ipcMain.handle('live:getStatus', async (_, sessionId: string) => { - if (!webServer) { - return { live: false, url: null }; - } - const isLive = webServer.isSessionLive(sessionId); - return { - live: isLive, - url: isLive ? webServer.getSessionUrl(sessionId) : null, - }; - }); - - ipcMain.handle('live:getDashboardUrl', async () => { - if (!webServer) { - return null; - } - return webServer.getSecureUrl(); - }); - - ipcMain.handle('live:getLiveSessions', async () => { - if (!webServer) { - return []; - } - return webServer.getLiveSessions(); - }); - - ipcMain.handle('live:broadcastActiveSession', async (_, sessionId: string) => { - if (webServer) { - webServer.broadcastActiveSessionChange(sessionId); - } - }); - - // Start web server (creates if needed, starts if not running) - ipcMain.handle('live:startServer', async () => { - try { - // Create web server if it doesn't exist - if (!webServer) { - logger.info('Creating web server', 'WebServer'); - webServer = createWebServer(); - } - - // Start if not already running - if (!webServer.isActive()) { - logger.info('Starting web server', 'WebServer'); - const { port, url } = await webServer.start(); - logger.info(`Web server running at ${url} (port ${port})`, 'WebServer'); - return { success: true, url }; - } - - // Already running - return { success: true, url: webServer.getSecureUrl() }; - } catch (error: any) { - logger.error(`Failed to start web server: ${error.message}`, 'WebServer'); - return { success: false, error: error.message }; - } - }); - - // Stop web server and clean up - ipcMain.handle('live:stopServer', async () => { - if (!webServer) { - return { success: true }; - } - - try { - logger.info('Stopping web server', 'WebServer'); - await webServer.stop(); - webServer = null; // Allow garbage collection, will recreate on next start - logger.info('Web server stopped and cleaned up', 'WebServer'); - return { success: true }; - } catch (error: any) { - logger.error(`Failed to stop web server: ${error.message}`, 'WebServer'); - return { success: false, error: error.message }; - } - }); - - // Disable all live sessions and stop the server - ipcMain.handle('live:disableAll', async () => { - if (!webServer) { - return { success: true, count: 0 }; - } - - // First mark all sessions as offline - const liveSessions = webServer.getLiveSessions(); - const count = liveSessions.length; - for (const session of liveSessions) { - webServer.setSessionOffline(session.sessionId); - } - - // Then stop the server - try { - logger.info(`Disabled ${count} live sessions, stopping server`, 'Live'); - await webServer.stop(); - webServer = null; - return { success: true, count }; - } catch (error: any) { - logger.error(`Failed to stop web server during disableAll: ${error.message}`, 'WebServer'); - return { success: false, count, error: error.message }; - } - }); - - // Web server management - ipcMain.handle('webserver:getUrl', async () => { - return webServer?.getSecureUrl(); - }); - - ipcMain.handle('webserver:getConnectedClients', async () => { - return webServer?.getWebClientCount() || 0; - }); - - // System operations (dialog, fonts, shells, tunnel, devtools, updates, logger) - // extracted to src/main/ipc/handlers/system.ts - - // Claude Code sessions - extracted to src/main/ipc/handlers/claude.ts - - // ========================================================================== - // Agent Error Handling API - // ========================================================================== - - // Clear an error state for a session (called after recovery action) - ipcMain.handle('agent:clearError', async (_event, sessionId: string) => { - logger.debug('Clearing agent error for session', 'AgentError', { sessionId }); - // Note: The actual error state is managed in the renderer. - // This handler is used to log the clear action and potentially - // perform any main process cleanup needed. - return { success: true }; - }); - - // Retry the last operation after an error (optionally with modified parameters) - ipcMain.handle('agent:retryAfterError', async (_event, sessionId: string, options?: { - prompt?: string; - newSession?: boolean; - }) => { - logger.info('Retrying after agent error', 'AgentError', { - sessionId, - hasPrompt: !!options?.prompt, - newSession: options?.newSession || false, - }); - // Note: The actual retry logic is handled in the renderer, which will: - // 1. Clear the error state - // 2. Optionally start a new session - // 3. Re-send the last command or the provided prompt - // This handler exists for logging and potential future main process coordination. - return { success: true }; - }); - - // Notification operations - ipcMain.handle('notification:show', async (_event, title: string, body: string) => { - try { - const { Notification } = await import('electron'); - if (Notification.isSupported()) { - const notification = new Notification({ - title, - body, - silent: true, // Don't play system sound - we have our own audio feedback option - }); - notification.show(); - logger.debug('Showed OS notification', 'Notification', { title, body }); - return { success: true }; - } else { - logger.warn('OS notifications not supported on this platform', 'Notification'); - return { success: false, error: 'Notifications not supported' }; - } - } catch (error) { - logger.error('Error showing notification', 'Notification', error); - return { success: false, error: String(error) }; - } - }); - - // Track active TTS processes by ID for stopping - const activeTtsProcesses = new Map; command: string }>(); - let ttsProcessIdCounter = 0; - - // TTS queue to prevent audio overlap - enforces minimum delay between TTS calls - const TTS_MIN_DELAY_MS = 15000; // 15 seconds between TTS calls - let lastTtsEndTime = 0; - const ttsQueue: Array<{ - text: string; - command?: string; - resolve: (result: { success: boolean; ttsId?: number; error?: string }) => void; - }> = []; - let isTtsProcessing = false; - - // Process the next item in the TTS queue - const processNextTts = async () => { - if (isTtsProcessing || ttsQueue.length === 0) return; - - isTtsProcessing = true; - const item = ttsQueue.shift()!; - - // Calculate delay needed to maintain minimum gap - const now = Date.now(); - const timeSinceLastTts = now - lastTtsEndTime; - const delayNeeded = Math.max(0, TTS_MIN_DELAY_MS - timeSinceLastTts); - - if (delayNeeded > 0) { - logger.debug(`TTS queue waiting ${delayNeeded}ms before next speech`, 'TTS'); - await new Promise(resolve => setTimeout(resolve, delayNeeded)); - } - - // Execute the TTS - const result = await executeTts(item.text, item.command); - item.resolve(result); - - // Record when this TTS ended - lastTtsEndTime = Date.now(); - isTtsProcessing = false; - - // Process next item in queue - processNextTts(); - }; - - // Execute TTS - the actual implementation - // Returns a Promise that resolves when the TTS process completes (not just when it starts) - const executeTts = async (text: string, command?: string): Promise<{ success: boolean; ttsId?: number; error?: string }> => { - console.log('[TTS Main] executeTts called, text length:', text?.length, 'command:', command); - - // Log the incoming request with full details for debugging - logger.info('TTS speak request received', 'TTS', { - command: command || '(default: say)', - textLength: text?.length || 0, - textPreview: text ? (text.length > 200 ? text.substring(0, 200) + '...' : text) : '(no text)', - }); - - try { - const { spawn } = await import('child_process'); - const fullCommand = command || 'say'; // Default to macOS 'say' command - console.log('[TTS Main] Using fullCommand:', fullCommand); - - // Log the full command being executed - logger.info('TTS executing command', 'TTS', { - command: fullCommand, - textLength: text?.length || 0, - }); - - // Spawn the TTS process with shell mode to support pipes and command chaining - const child = spawn(fullCommand, [], { - stdio: ['pipe', 'ignore', 'pipe'], // stdin: pipe, stdout: ignore, stderr: pipe for errors - shell: true, - }); - - // Generate a unique ID for this TTS process - const ttsId = ++ttsProcessIdCounter; - activeTtsProcesses.set(ttsId, { process: child, command: fullCommand }); - - // Return a Promise that resolves when the TTS process completes - return new Promise((resolve) => { - let resolved = false; - let stderrOutput = ''; - - // Write the text to stdin and close it - if (child.stdin) { - // Handle stdin errors (EPIPE if process terminates before write completes) - child.stdin.on('error', (err) => { - const errorCode = (err as NodeJS.ErrnoException).code; - if (errorCode === 'EPIPE') { - logger.debug('TTS stdin EPIPE - process closed before write completed', 'TTS'); - } else { - logger.error('TTS stdin error', 'TTS', { error: String(err), code: errorCode }); - } - }); - console.log('[TTS Main] Writing to stdin:', text); - child.stdin.write(text, 'utf8', (err) => { - if (err) { - console.error('[TTS Main] stdin write error:', err); - } else { - console.log('[TTS Main] stdin write completed, ending stream'); - } - child.stdin!.end(); - }); - } else { - console.error('[TTS Main] No stdin available on child process'); - } - - child.on('error', (err) => { - console.error('[TTS Main] Spawn error:', err); - logger.error('TTS spawn error', 'TTS', { - error: String(err), - command: fullCommand, - textPreview: text ? (text.length > 100 ? text.substring(0, 100) + '...' : text) : '(no text)', - }); - activeTtsProcesses.delete(ttsId); - if (!resolved) { - resolved = true; - resolve({ success: false, ttsId, error: String(err) }); - } - }); - - // Capture stderr for debugging - if (child.stderr) { - child.stderr.on('data', (data) => { - stderrOutput += data.toString(); - }); - } - - child.on('close', (code, signal) => { - console.log('[TTS Main] Process exited with code:', code, 'signal:', signal); - // Always log close event for debugging production issues - logger.info('TTS process closed', 'TTS', { - ttsId, - exitCode: code, - signal, - stderr: stderrOutput || '(none)', - command: fullCommand, - }); - if (code !== 0 && stderrOutput) { - console.error('[TTS Main] stderr:', stderrOutput); - logger.error('TTS process error output', 'TTS', { - exitCode: code, - stderr: stderrOutput, - command: fullCommand, - }); - } - activeTtsProcesses.delete(ttsId); - // Notify renderer that TTS has completed - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send('tts:completed', ttsId); - }); - - // Resolve the promise now that TTS has completed - if (!resolved) { - resolved = true; - resolve({ success: code === 0, ttsId }); - } - }); - - console.log('[TTS Main] Process spawned successfully with ID:', ttsId); - logger.info('TTS process spawned successfully', 'TTS', { - ttsId, - command: fullCommand, - textLength: text?.length || 0, - }); - }); - } catch (error) { - console.error('[TTS Main] Error starting audio feedback:', error); - logger.error('TTS error starting audio feedback', 'TTS', { - error: String(error), - command: command || '(default: say)', - textPreview: text ? (text.length > 100 ? text.substring(0, 100) + '...' : text) : '(no text)', - }); - return { success: false, error: String(error) }; - } - }; - - // Audio feedback using system TTS command - queued to prevent overlap - ipcMain.handle('notification:speak', async (_event, text: string, command?: string) => { - // Add to queue and return a promise that resolves when this TTS completes - return new Promise<{ success: boolean; ttsId?: number; error?: string }>((resolve) => { - ttsQueue.push({ text, command, resolve }); - logger.debug(`TTS queued, queue length: ${ttsQueue.length}`, 'TTS'); - processNextTts(); - }); - }); - - // Stop a running TTS process - ipcMain.handle('notification:stopSpeak', async (_event, ttsId: number) => { - console.log('[TTS Main] notification:stopSpeak called for ID:', ttsId); - - const ttsProcess = activeTtsProcesses.get(ttsId); - if (!ttsProcess) { - console.log('[TTS Main] No active TTS process found with ID:', ttsId); - return { success: false, error: 'No active TTS process with that ID' }; - } - - try { - // Kill the process and all its children - ttsProcess.process.kill('SIGTERM'); - activeTtsProcesses.delete(ttsId); - - logger.info('TTS process stopped', 'TTS', { - ttsId, - command: ttsProcess.command, - }); - - console.log('[TTS Main] TTS process killed successfully'); - return { success: true }; - } catch (error) { - console.error('[TTS Main] Error stopping TTS process:', error); - logger.error('TTS error stopping process', 'TTS', { - ttsId, - error: String(error), - }); - return { success: false, error: String(error) }; - } - }); - - // Attachments API - store images per Maestro session - // Images are stored in userData/attachments/{sessionId}/{filename} - ipcMain.handle('attachments:save', async (_event, sessionId: string, base64Data: string, filename: string) => { - try { - const userDataPath = app.getPath('userData'); - const attachmentsDir = path.join(userDataPath, 'attachments', sessionId); - - // Ensure the attachments directory exists - await fs.mkdir(attachmentsDir, { recursive: true }); - - // Extract the base64 content (remove data:image/...;base64, prefix if present) - const base64Match = base64Data.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/); - let buffer: Buffer; - let finalFilename = filename; - - if (base64Match) { - const extension = base64Match[1]; - buffer = Buffer.from(base64Match[2], 'base64'); - // Update filename with correct extension if not already present - if (!filename.includes('.')) { - finalFilename = `${filename}.${extension}`; - } - } else { - // Assume raw base64 - buffer = Buffer.from(base64Data, 'base64'); - } - - const filePath = path.join(attachmentsDir, finalFilename); - await fs.writeFile(filePath, buffer); - - logger.info(`Saved attachment: ${filePath}`, 'Attachments', { sessionId, filename: finalFilename, size: buffer.length }); - return { success: true, path: filePath, filename: finalFilename }; - } catch (error) { - logger.error('Error saving attachment', 'Attachments', error); - return { success: false, error: String(error) }; - } - }); - - ipcMain.handle('attachments:load', async (_event, sessionId: string, filename: string) => { - try { - const userDataPath = app.getPath('userData'); - const filePath = path.join(userDataPath, 'attachments', sessionId, filename); - - const buffer = await fs.readFile(filePath); - const base64 = buffer.toString('base64'); - - // Determine MIME type from extension - const ext = path.extname(filename).toLowerCase().slice(1); - const mimeTypes: Record = { - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - 'webp': 'image/webp', - 'svg': 'image/svg+xml', - }; - const mimeType = mimeTypes[ext] || 'image/png'; - - logger.debug(`Loaded attachment: ${filePath}`, 'Attachments', { sessionId, filename, size: buffer.length }); - return { success: true, dataUrl: `data:${mimeType};base64,${base64}` }; - } catch (error) { - logger.error('Error loading attachment', 'Attachments', error); - return { success: false, error: String(error) }; - } - }); - - ipcMain.handle('attachments:delete', async (_event, sessionId: string, filename: string) => { - try { - const userDataPath = app.getPath('userData'); - const filePath = path.join(userDataPath, 'attachments', sessionId, filename); - - await fs.unlink(filePath); - logger.info(`Deleted attachment: ${filePath}`, 'Attachments', { sessionId, filename }); - return { success: true }; - } catch (error) { - logger.error('Error deleting attachment', 'Attachments', error); - return { success: false, error: String(error) }; - } - }); - - ipcMain.handle('attachments:list', async (_event, sessionId: string) => { - try { - const userDataPath = app.getPath('userData'); - const attachmentsDir = path.join(userDataPath, 'attachments', sessionId); - - try { - const files = await fs.readdir(attachmentsDir); - const imageFiles = files.filter(f => /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(f)); - logger.debug(`Listed attachments for session: ${sessionId}`, 'Attachments', { count: imageFiles.length }); - return { success: true, files: imageFiles }; - } catch (err: any) { - if (err.code === 'ENOENT') { - // Directory doesn't exist yet - no attachments - return { success: true, files: [] }; - } - throw err; - } - } catch (error) { - logger.error('Error listing attachments', 'Attachments', error); - return { success: false, error: String(error), files: [] }; - } - }); - - ipcMain.handle('attachments:getPath', async (_event, sessionId: string) => { - const userDataPath = app.getPath('userData'); - const attachmentsDir = path.join(userDataPath, 'attachments', sessionId); - return { success: true, path: attachmentsDir }; - }); - - // Auto Run operations - extracted to src/main/ipc/handlers/autorun.ts - - // Playbook operations - extracted to src/main/ipc/handlers/playbooks.ts - - // ========================================================================== - // Leaderboard API - // ========================================================================== - - // Get the unique installation ID for this Maestro installation - ipcMain.handle('leaderboard:getInstallationId', async () => { - return store.get('installationId') || null; - }); - - // Submit leaderboard entry to runmaestro.ai - ipcMain.handle( - 'leaderboard:submit', - async ( - _event, - data: { - email: string; - displayName: string; - githubUsername?: string; - twitterHandle?: string; - linkedinHandle?: string; - discordUsername?: string; - blueskyHandle?: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - longestRunMs?: number; - longestRunDate?: string; - currentRunMs?: number; // Duration in milliseconds of the run that just completed - theme?: string; - clientToken?: string; // Client-generated token for polling auth status - authToken?: string; // Required for confirmed email addresses - // Delta mode for multi-device aggregation - deltaMs?: number; // Time in milliseconds to ADD to server-side cumulative total - deltaRuns?: number; // Number of runs to ADD to server-side total runs count - // Installation tracking for multi-device differentiation - installationId?: string; // Unique GUID per Maestro installation - clientTotalTimeMs?: number; // Client's self-proclaimed total time (for discrepancy detection) - } - ): Promise<{ - success: boolean; - message: string; - pendingEmailConfirmation?: boolean; - error?: string; - authTokenRequired?: boolean; // True if 401 due to missing token - ranking?: { - cumulative: { - rank: number; - total: number; - previousRank: number | null; - improved: boolean; - }; - longestRun: { - rank: number; - total: number; - previousRank: number | null; - improved: boolean; - } | null; - }; - // Server-side totals for multi-device sync - serverTotals?: { - cumulativeTimeMs: number; - totalRuns: number; - }; - }> => { - try { - // Auto-inject installation ID if not provided - const installationId = data.installationId || store.get('installationId') || undefined; - - logger.info('Submitting leaderboard entry', 'Leaderboard', { - displayName: data.displayName, - email: data.email.substring(0, 3) + '***', - badgeLevel: data.badgeLevel, - hasClientToken: !!data.clientToken, - hasAuthToken: !!data.authToken, - hasInstallationId: !!installationId, - hasClientTotalTime: !!data.clientTotalTimeMs, - }); - - // Prepare submission data with server-expected field names - // Server expects 'installId' not 'installationId' - const submissionData = { - ...data, - installId: installationId, // Map to server field name - }; - - const response = await fetch('https://runmaestro.ai/api/m4estr0/submit', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - body: JSON.stringify(submissionData), - }); - - const result = await response.json() as { - success?: boolean; - message?: string; - pendingEmailConfirmation?: boolean; - error?: string; - ranking?: { - cumulative: { - rank: number; - total: number; - previousRank: number | null; - improved: boolean; - }; - longestRun: { - rank: number; - total: number; - previousRank: number | null; - improved: boolean; - } | null; - }; - // Server-side totals for multi-device sync - serverTotals?: { - cumulativeTimeMs: number; - totalRuns: number; - }; - }; - - if (response.ok) { - logger.info('Leaderboard submission successful', 'Leaderboard', { - pendingEmailConfirmation: result.pendingEmailConfirmation, - ranking: result.ranking, - serverTotals: result.serverTotals, - }); - return { - success: true, - message: result.message || 'Submission received', - pendingEmailConfirmation: result.pendingEmailConfirmation, - ranking: result.ranking, - serverTotals: result.serverTotals, - }; - } else if (response.status === 401) { - // Auth token required or invalid - logger.warn('Leaderboard submission requires auth token', 'Leaderboard', { - error: result.error || result.message, - }); - return { - success: false, - message: result.message || 'Authentication required', - error: result.error || 'Auth token required for confirmed email addresses', - authTokenRequired: true, - }; - } else { - logger.warn('Leaderboard submission failed', 'Leaderboard', { - status: response.status, - error: result.error || result.message, - }); - return { - success: false, - message: result.message || 'Submission failed', - error: result.error || `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error submitting to leaderboard', 'Leaderboard', error); - return { - success: false, - message: 'Failed to connect to leaderboard server', - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); - - // Poll for auth token after email confirmation - ipcMain.handle( - 'leaderboard:pollAuthStatus', - async ( - _event, - clientToken: string - ): Promise<{ - status: 'pending' | 'confirmed' | 'expired' | 'error'; - authToken?: string; - message?: string; - error?: string; - }> => { - try { - logger.debug('Polling leaderboard auth status', 'Leaderboard'); - - const response = await fetch( - `https://runmaestro.ai/api/m4estr0/auth-status?clientToken=${encodeURIComponent(clientToken)}`, - { - headers: { - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - } - ); - - const result = await response.json() as { - status: 'pending' | 'confirmed' | 'expired'; - authToken?: string; - message?: string; - }; - - if (response.ok) { - if (result.status === 'confirmed' && result.authToken) { - logger.info('Leaderboard auth token received', 'Leaderboard'); - } - return { - status: result.status, - authToken: result.authToken, - message: result.message, - }; - } else { - return { - status: 'error', - error: result.message || `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error polling leaderboard auth status', 'Leaderboard', error); - return { - status: 'error', - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); - - // Resend confirmation email (self-service auth token recovery) - ipcMain.handle( - 'leaderboard:resendConfirmation', - async ( - _event, - data: { - email: string; - clientToken: string; - } - ): Promise<{ - success: boolean; - message?: string; - error?: string; - }> => { - try { - logger.info('Requesting leaderboard confirmation resend', 'Leaderboard', { - email: data.email.substring(0, 3) + '***', - }); - - const response = await fetch('https://runmaestro.ai/api/m4estr0/resend-confirmation', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - body: JSON.stringify({ - email: data.email, - clientToken: data.clientToken, - }), - }); - - const result = await response.json() as { - success?: boolean; - message?: string; - error?: string; - }; - - if (response.ok && result.success) { - logger.info('Leaderboard confirmation email resent', 'Leaderboard'); - return { - success: true, - message: result.message || 'Confirmation email sent. Please check your inbox.', - }; - } else { - return { - success: false, - error: result.error || result.message || `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error resending leaderboard confirmation', 'Leaderboard', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); - - // Get leaderboard entries - ipcMain.handle( - 'leaderboard:get', - async ( - _event, - options?: { limit?: number } - ): Promise<{ - success: boolean; - entries?: Array<{ - rank: number; - displayName: string; - githubUsername?: string; - avatarUrl?: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - }>; - error?: string; - }> => { - try { - const limit = options?.limit || 50; - const response = await fetch(`https://runmaestro.ai/api/leaderboard?limit=${limit}`, { - headers: { - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - }); - - if (response.ok) { - const data = await response.json() as { entries?: unknown[] }; - return { success: true, entries: data.entries as Array<{ - rank: number; - displayName: string; - githubUsername?: string; - avatarUrl?: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - }> }; - } else { - return { - success: false, - error: `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error fetching leaderboard', 'Leaderboard', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); - - // Get longest runs leaderboard - ipcMain.handle( - 'leaderboard:getLongestRuns', - async ( - _event, - options?: { limit?: number } - ): Promise<{ - success: boolean; - entries?: Array<{ - rank: number; - displayName: string; - githubUsername?: string; - avatarUrl?: string; - longestRunMs: number; - runDate: string; - }>; - error?: string; - }> => { - try { - const limit = options?.limit || 50; - const response = await fetch(`https://runmaestro.ai/api/longest-runs?limit=${limit}`, { - headers: { - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - }); - - if (response.ok) { - const data = await response.json() as { entries?: unknown[] }; - return { success: true, entries: data.entries as Array<{ - rank: number; - displayName: string; - githubUsername?: string; - avatarUrl?: string; - longestRunMs: number; - runDate: string; - }> }; - } else { - return { - success: false, - error: `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error fetching longest runs leaderboard', 'Leaderboard', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); - - // Sync user stats from server (for new device installations) - ipcMain.handle( - 'leaderboard:sync', - async ( - _event, - data: { - email: string; - authToken: string; - } - ): Promise<{ - success: boolean; - found: boolean; - message?: string; - error?: string; - errorCode?: 'EMAIL_NOT_CONFIRMED' | 'INVALID_TOKEN' | 'MISSING_FIELDS'; - data?: { - displayName: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - longestRunMs: number | null; - longestRunDate: string | null; - keyboardLevel: number | null; - coveragePercent: number | null; - ranking: { - cumulative: { rank: number; total: number }; - longestRun: { rank: number; total: number } | null; - }; - }; - }> => { - try { - logger.info('Syncing leaderboard stats from server', 'Leaderboard', { - email: data.email.substring(0, 3) + '***', - }); - - const response = await fetch('https://runmaestro.ai/api/m4estr0/sync', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - body: JSON.stringify({ - email: data.email, - authToken: data.authToken, - }), - }); - - const result = await response.json() as { - success: boolean; - found?: boolean; - message?: string; - error?: string; - errorCode?: string; - data?: { - displayName: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - longestRunMs: number | null; - longestRunDate: string | null; - keyboardLevel: number | null; - coveragePercent: number | null; - ranking: { - cumulative: { rank: number; total: number }; - longestRun: { rank: number; total: number } | null; - }; - }; - }; - - if (response.ok && result.success) { - if (result.found && result.data) { - logger.info('Leaderboard sync successful', 'Leaderboard', { - badgeLevel: result.data.badgeLevel, - cumulativeTimeMs: result.data.cumulativeTimeMs, - }); - return { - success: true, - found: true, - data: result.data, - }; - } else { - logger.info('Leaderboard sync: user not found', 'Leaderboard'); - return { - success: true, - found: false, - message: result.message || 'No existing registration found', - }; - } - } else if (response.status === 401) { - logger.warn('Leaderboard sync: invalid token', 'Leaderboard'); - return { - success: false, - found: false, - error: result.error || 'Invalid authentication token', - errorCode: 'INVALID_TOKEN', - }; - } else if (response.status === 403) { - logger.warn('Leaderboard sync: email not confirmed', 'Leaderboard'); - return { - success: false, - found: false, - error: result.error || 'Email not yet confirmed', - errorCode: 'EMAIL_NOT_CONFIRMED', - }; - } else if (response.status === 400) { - return { - success: false, - found: false, - error: result.error || 'Missing required fields', - errorCode: 'MISSING_FIELDS', - }; - } else { - return { - success: false, - found: false, - error: result.error || `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error syncing from leaderboard server', 'Leaderboard', error); - return { - success: false, - found: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); + // Settings, sessions, and groups persistence - extracted to src/main/ipc/handlers/persistence.ts + + // Broadcast user input to web clients (called when desktop sends a message) + ipcMain.handle( + 'web:broadcastUserInput', + async (_, sessionId: string, command: string, inputMode: 'ai' | 'terminal') => { + if (webServer && webServer.getWebClientCount() > 0) { + webServer.broadcastUserInput(sessionId, command, inputMode); + return true; + } + return false; + } + ); + + // Broadcast AutoRun state to web clients (called when batch processing state changes) + // Always store state even if no clients are connected, so new clients get initial state + ipcMain.handle( + 'web:broadcastAutoRunState', + async ( + _, + sessionId: string, + state: { + isRunning: boolean; + totalTasks: number; + completedTasks: number; + currentTaskIndex: number; + isStopping?: boolean; + // Multi-document progress fields + totalDocuments?: number; + currentDocumentIndex?: number; + totalTasksAcrossAllDocs?: number; + completedTasksAcrossAllDocs?: number; + } | null + ) => { + if (webServer) { + // Always call broadcastAutoRunState - it stores the state for new clients + // and broadcasts to any currently connected clients + webServer.broadcastAutoRunState(sessionId, state); + return true; + } + return false; + } + ); + + // Broadcast tab changes to web clients + ipcMain.handle( + 'web:broadcastTabsChange', + async (_, sessionId: string, aiTabs: any[], activeTabId: string) => { + if (webServer && webServer.getWebClientCount() > 0) { + webServer.broadcastTabsChange(sessionId, aiTabs, activeTabId); + return true; + } + return false; + } + ); + + // Broadcast session state change to web clients (for real-time busy/idle updates) + // This is called directly from the renderer to bypass debounced persistence + // which resets state to 'idle' before saving + ipcMain.handle( + 'web:broadcastSessionState', + async ( + _, + sessionId: string, + state: string, + additionalData?: { + name?: string; + toolType?: string; + inputMode?: string; + cwd?: string; + } + ) => { + if (webServer && webServer.getWebClientCount() > 0) { + webServer.broadcastSessionStateChange(sessionId, state, additionalData); + return true; + } + return false; + } + ); + + // Git operations - extracted to src/main/ipc/handlers/git.ts + registerGitHandlers({ + settingsStore: store, + }); + + // Auto Run operations - extracted to src/main/ipc/handlers/autorun.ts + registerAutorunHandlers({ + mainWindow, + getMainWindow: () => mainWindow, + app, + settingsStore: store, + }); + + // Playbook operations - extracted to src/main/ipc/handlers/playbooks.ts + registerPlaybooksHandlers({ + mainWindow, + getMainWindow: () => mainWindow, + app, + }); + + // History operations - extracted to src/main/ipc/handlers/history.ts + // Uses HistoryManager singleton for per-session storage + registerHistoryHandlers(); + + // Agent management operations - extracted to src/main/ipc/handlers/agents.ts + registerAgentsHandlers({ + getAgentDetector: () => agentDetector, + agentConfigsStore, + settingsStore: store, + }); + + // Process management operations - extracted to src/main/ipc/handlers/process.ts + registerProcessHandlers({ + getProcessManager: () => processManager, + getAgentDetector: () => agentDetector, + agentConfigsStore, + settingsStore: store, + getMainWindow: () => mainWindow, + }); + + // Persistence operations - extracted to src/main/ipc/handlers/persistence.ts + registerPersistenceHandlers({ + settingsStore: store, + sessionsStore, + groupsStore, + getWebServer: () => webServer, + }); + + // System operations - extracted to src/main/ipc/handlers/system.ts + registerSystemHandlers({ + getMainWindow: () => mainWindow, + app, + settingsStore: store, + tunnelManager, + getWebServer: () => webServer, + bootstrapStore, // For iCloud/sync settings + }); + + // Claude Code sessions - extracted to src/main/ipc/handlers/claude.ts + registerClaudeHandlers({ + claudeSessionOriginsStore, + getMainWindow: () => mainWindow, + }); + + // Initialize output parsers for all agents (Codex, OpenCode, Claude Code) + // This must be called before any agent output is processed + initializeOutputParsers(); + + // Initialize session storages and register generic agent sessions handlers + // This provides the new window.maestro.agentSessions.* API + // Pass the shared claudeSessionOriginsStore so session names/stars are consistent + initializeSessionStorages({ claudeSessionOriginsStore }); + registerAgentSessionsHandlers({ getMainWindow: () => mainWindow, agentSessionOriginsStore }); + + // Helper to get agent config values (custom args/env vars, model, etc.) + const getAgentConfigForAgent = (agentId: string): Record => { + const allConfigs = agentConfigsStore.get('configs', {}); + return allConfigs[agentId] || {}; + }; + + // Helper to get custom env vars for an agent + const getCustomEnvVarsForAgent = (agentId: string): Record | undefined => { + return getAgentConfigForAgent(agentId).customEnvVars as Record | undefined; + }; + + // Register Group Chat handlers + registerGroupChatHandlers({ + getMainWindow: () => mainWindow, + getProcessManager: () => processManager, + getAgentDetector: () => agentDetector, + getCustomEnvVars: getCustomEnvVarsForAgent, + getAgentConfig: getAgentConfigForAgent, + }); + + // Register Debug Package handlers + registerDebugHandlers({ + getMainWindow: () => mainWindow, + getAgentDetector: () => agentDetector, + getProcessManager: () => processManager, + getWebServer: () => webServer, + settingsStore: store, + sessionsStore, + groupsStore, + bootstrapStore, + }); + + // Register Spec Kit handlers (no dependencies needed) + registerSpeckitHandlers(); + + // Register OpenSpec handlers (no dependencies needed) + registerOpenSpecHandlers(); + + // Register Context Merge handlers for session context transfer and grooming + registerContextHandlers({ + getMainWindow: () => mainWindow, + getProcessManager: () => processManager, + getAgentDetector: () => agentDetector, + }); + + // Register Marketplace handlers for fetching and importing playbooks + registerMarketplaceHandlers({ + app, + settingsStore: store, + }); + + // Register Stats handlers for usage tracking + registerStatsHandlers({ + getMainWindow: () => mainWindow, + settingsStore: store, + }); + + // Register Document Graph handlers for file watching + registerDocumentGraphHandlers({ + getMainWindow: () => mainWindow, + app, + }); + + // Register SSH Remote handlers for managing SSH configurations + registerSshRemoteHandlers({ + settingsStore: store, + }); + + // Set up callback for group chat router to lookup sessions for auto-add @mentions + setGetSessionsCallback(() => { + const sessions = sessionsStore.get('sessions', []); + return sessions.map((s: any) => { + // Resolve SSH remote name if session has SSH config + let sshRemoteName: string | undefined; + if (s.sessionSshRemoteConfig?.enabled && s.sessionSshRemoteConfig.remoteId) { + const sshConfig = getSshRemoteById(s.sessionSshRemoteConfig.remoteId); + sshRemoteName = sshConfig?.name; + } + return { + id: s.id, + name: s.name, + toolType: s.toolType, + cwd: s.cwd || s.fullPath || process.env.HOME || '/tmp', + customArgs: s.customArgs, + customEnvVars: s.customEnvVars, + customModel: s.customModel, + sshRemoteName, + }; + }); + }); + + // Set up callback for group chat router to lookup custom env vars for agents + setGetCustomEnvVarsCallback(getCustomEnvVarsForAgent); + setGetAgentConfigCallback(getAgentConfigForAgent); + + // Setup logger event forwarding to renderer + setupLoggerEventForwarding(() => mainWindow); + + // File system operations + ipcMain.handle('fs:homeDir', () => { + return os.homedir(); + }); + + ipcMain.handle('fs:readDir', async (_, dirPath: string, sshRemoteId?: string) => { + // SSH remote: dispatch to remote fs operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + const result = await readDirRemote(dirPath, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Failed to read remote directory'); + } + // Map remote entries to match local format (isFile derived from !isDirectory && !isSymlink) + // Include full path for recursive directory scanning (e.g., document graph) + // Use POSIX path joining for remote paths (always forward slashes) + return result.data!.map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory, + isFile: !entry.isDirectory && !entry.isSymlink, + path: dirPath.endsWith('/') ? `${dirPath}${entry.name}` : `${dirPath}/${entry.name}`, + })); + } + + // Local: use standard fs operations + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + // Convert Dirent objects to plain objects for IPC serialization + // Include full path for recursive directory scanning (e.g., document graph) + return entries.map((entry: any) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isFile: entry.isFile(), + path: path.join(dirPath, entry.name), + })); + }); + + ipcMain.handle('fs:readFile', async (_, filePath: string, sshRemoteId?: string) => { + try { + // SSH remote: dispatch to remote fs operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + const result = await readFileRemote(filePath, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Failed to read remote file'); + } + // For images over SSH, we'd need to base64 encode on remote and decode here + // For now, return raw content (text files work, binary images may have issues) + const ext = filePath.split('.').pop()?.toLowerCase(); + const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']; + const isImage = imageExtensions.includes(ext || ''); + if (isImage) { + // The remote readFile returns raw bytes as string - convert to base64 data URL + const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`; + const base64 = Buffer.from(result.data!, 'binary').toString('base64'); + return `data:${mimeType};base64,${base64}`; + } + return result.data!; + } + + // Local: use standard fs operations + // Check if file is an image + const ext = filePath.split('.').pop()?.toLowerCase(); + const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico']; + const isImage = imageExtensions.includes(ext || ''); + + if (isImage) { + // Read image as buffer and convert to base64 data URL + const buffer = await fs.readFile(filePath); + const base64 = buffer.toString('base64'); + const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`; + return `data:${mimeType};base64,${base64}`; + } else { + // Read text files as UTF-8 + const content = await fs.readFile(filePath, 'utf-8'); + return content; + } + } catch (error) { + throw new Error(`Failed to read file: ${error}`); + } + }); + + ipcMain.handle('fs:stat', async (_, filePath: string, sshRemoteId?: string) => { + try { + // SSH remote: dispatch to remote fs operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + const result = await statRemote(filePath, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Failed to get remote file stats'); + } + // Map remote stat result to match local format + // Note: remote stat doesn't provide createdAt (birthtime), use mtime as fallback + const mtimeDate = new Date(result.data!.mtime); + return { + size: result.data!.size, + createdAt: mtimeDate.toISOString(), // Fallback: use mtime for createdAt + modifiedAt: mtimeDate.toISOString(), + isDirectory: result.data!.isDirectory, + isFile: !result.data!.isDirectory, + }; + } + + // Local: use standard fs operations + const stats = await fs.stat(filePath); + return { + size: stats.size, + createdAt: stats.birthtime.toISOString(), + modifiedAt: stats.mtime.toISOString(), + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + }; + } catch (error) { + throw new Error(`Failed to get file stats: ${error}`); + } + }); + + // Calculate total size of a directory recursively + // Respects the same ignore patterns as loadFileTree (node_modules, __pycache__) + ipcMain.handle('fs:directorySize', async (_, dirPath: string, sshRemoteId?: string) => { + // SSH remote: dispatch to remote fs operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + // Fetch size and counts in parallel for SSH remotes + const [sizeResult, countResult] = await Promise.all([ + directorySizeRemote(dirPath, sshConfig), + countItemsRemote(dirPath, sshConfig), + ]); + if (!sizeResult.success) { + throw new Error(sizeResult.error || 'Failed to get remote directory size'); + } + return { + totalSize: sizeResult.data!, + fileCount: countResult.success ? countResult.data!.fileCount : 0, + folderCount: countResult.success ? countResult.data!.folderCount : 0, + }; + } + + // Local: use standard fs operations + let totalSize = 0; + let fileCount = 0; + let folderCount = 0; + + const calculateSize = async (currentPath: string, depth: number = 0): Promise => { + // Limit recursion depth to match file tree loading + if (depth >= 10) return; + + try { + const entries = await fs.readdir(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + // Skip common ignore patterns (same as loadFileTree) + if (entry.name === 'node_modules' || entry.name === '__pycache__') { + continue; + } + + const fullPath = path.join(currentPath, entry.name); + + if (entry.isDirectory()) { + folderCount++; + await calculateSize(fullPath, depth + 1); + } else if (entry.isFile()) { + fileCount++; + try { + const stats = await fs.stat(fullPath); + totalSize += stats.size; + } catch { + // Skip files we can't stat (permissions, etc.) + } + } + } + } catch { + // Skip directories we can't read + } + }; + + await calculateSize(dirPath); + + return { + totalSize, + fileCount, + folderCount, + }; + }); + + ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string) => { + try { + await fs.writeFile(filePath, content, 'utf-8'); + return { success: true }; + } catch (error) { + throw new Error(`Failed to write file: ${error}`); + } + }); + + // Rename a file or folder (supports SSH remote) + ipcMain.handle('fs:rename', async (_, oldPath: string, newPath: string, sshRemoteId?: string) => { + try { + // SSH remote: dispatch to remote fs operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + const result = await renameRemote(oldPath, newPath, sshConfig); + if (!result.success) { + throw new Error(result.error || 'Failed to rename remote file'); + } + return { success: true }; + } + + // Local: standard fs rename + await fs.rename(oldPath, newPath); + return { success: true }; + } catch (error) { + throw new Error(`Failed to rename: ${error}`); + } + }); + + // Delete a file or folder (with recursive option for folders, supports SSH remote) + ipcMain.handle( + 'fs:delete', + async (_, targetPath: string, options?: { recursive?: boolean; sshRemoteId?: string }) => { + try { + const sshRemoteId = options?.sshRemoteId; + + // SSH remote: dispatch to remote fs operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + const result = await deleteRemote(targetPath, sshConfig, options?.recursive ?? true); + if (!result.success) { + throw new Error(result.error || 'Failed to delete remote file'); + } + return { success: true }; + } + + // Local: standard fs delete + const stat = await fs.stat(targetPath); + if (stat.isDirectory()) { + await fs.rm(targetPath, { recursive: options?.recursive ?? true, force: true }); + } else { + await fs.unlink(targetPath); + } + return { success: true }; + } catch (error) { + throw new Error(`Failed to delete: ${error}`); + } + } + ); + + // Count items in a directory (for delete confirmation, supports SSH remote) + ipcMain.handle('fs:countItems', async (_, dirPath: string, sshRemoteId?: string) => { + try { + // SSH remote: dispatch to remote fs operations + if (sshRemoteId) { + const sshConfig = getSshRemoteById(sshRemoteId); + if (!sshConfig) { + throw new Error(`SSH remote not found: ${sshRemoteId}`); + } + const result = await countItemsRemote(dirPath, sshConfig); + if (!result.success || !result.data) { + throw new Error(result.error || 'Failed to count remote items'); + } + return result.data; + } + + // Local: standard fs count + let fileCount = 0; + let folderCount = 0; + + const countRecursive = async (dir: string) => { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + folderCount++; + await countRecursive(path.join(dir, entry.name)); + } else { + fileCount++; + } + } + }; + + await countRecursive(dirPath); + return { fileCount, folderCount }; + } catch (error) { + throw new Error(`Failed to count items: ${error}`); + } + }); + + // Fetch image from URL and return as base64 data URL (avoids CORS issues) + ipcMain.handle('fs:fetchImageAsBase64', async (_, url: string) => { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const base64 = buffer.toString('base64'); + // Determine mime type from content-type header or URL + const contentType = response.headers.get('content-type') || 'image/png'; + return `data:${contentType};base64,${base64}`; + } catch (error) { + // Return null on failure - let caller handle gracefully + logger.warn(`Failed to fetch image from ${url}: ${error}`, 'fs:fetchImageAsBase64'); + return null; + } + }); + + // Live session management - toggle sessions as live/offline in web interface + ipcMain.handle('live:toggle', async (_, sessionId: string, agentSessionId?: string) => { + if (!webServer) { + throw new Error('Web server not initialized'); + } + + // Ensure web server is running before allowing live toggle + if (!webServer.isActive()) { + logger.warn('Web server not yet started, waiting...', 'Live'); + // Wait for server to start (with timeout) + const startTime = Date.now(); + while (!webServer.isActive() && Date.now() - startTime < 5000) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!webServer.isActive()) { + throw new Error('Web server failed to start'); + } + } + + const isLive = webServer.isSessionLive(sessionId); + + if (isLive) { + // Turn off live mode + webServer.setSessionOffline(sessionId); + logger.info(`Session ${sessionId} is now offline`, 'Live'); + return { live: false, url: null }; + } else { + // Turn on live mode + logger.info( + `Enabling live mode for session ${sessionId} (claude: ${agentSessionId || 'none'})`, + 'Live' + ); + webServer.setSessionLive(sessionId, agentSessionId); + const url = webServer.getSessionUrl(sessionId); + logger.info(`Session ${sessionId} is now live at ${url}`, 'Live'); + return { live: true, url }; + } + }); + + ipcMain.handle('live:getStatus', async (_, sessionId: string) => { + if (!webServer) { + return { live: false, url: null }; + } + const isLive = webServer.isSessionLive(sessionId); + return { + live: isLive, + url: isLive ? webServer.getSessionUrl(sessionId) : null, + }; + }); + + ipcMain.handle('live:getDashboardUrl', async () => { + if (!webServer) { + return null; + } + return webServer.getSecureUrl(); + }); + + ipcMain.handle('live:getLiveSessions', async () => { + if (!webServer) { + return []; + } + return webServer.getLiveSessions(); + }); + + ipcMain.handle('live:broadcastActiveSession', async (_, sessionId: string) => { + if (webServer) { + webServer.broadcastActiveSessionChange(sessionId); + } + }); + + // Start web server (creates if needed, starts if not running) + ipcMain.handle('live:startServer', async () => { + try { + // Create web server if it doesn't exist + if (!webServer) { + logger.info('Creating web server', 'WebServer'); + webServer = createWebServer(); + } + + // Start if not already running + if (!webServer.isActive()) { + logger.info('Starting web server', 'WebServer'); + const { port, url } = await webServer.start(); + logger.info(`Web server running at ${url} (port ${port})`, 'WebServer'); + return { success: true, url }; + } + + // Already running + return { success: true, url: webServer.getSecureUrl() }; + } catch (error: any) { + logger.error(`Failed to start web server: ${error.message}`, 'WebServer'); + return { success: false, error: error.message }; + } + }); + + // Stop web server and clean up + ipcMain.handle('live:stopServer', async () => { + if (!webServer) { + return { success: true }; + } + + try { + logger.info('Stopping web server', 'WebServer'); + await webServer.stop(); + webServer = null; // Allow garbage collection, will recreate on next start + logger.info('Web server stopped and cleaned up', 'WebServer'); + return { success: true }; + } catch (error: any) { + logger.error(`Failed to stop web server: ${error.message}`, 'WebServer'); + return { success: false, error: error.message }; + } + }); + + // Disable all live sessions and stop the server + ipcMain.handle('live:disableAll', async () => { + if (!webServer) { + return { success: true, count: 0 }; + } + + // First mark all sessions as offline + const liveSessions = webServer.getLiveSessions(); + const count = liveSessions.length; + for (const session of liveSessions) { + webServer.setSessionOffline(session.sessionId); + } + + // Then stop the server + try { + logger.info(`Disabled ${count} live sessions, stopping server`, 'Live'); + await webServer.stop(); + webServer = null; + return { success: true, count }; + } catch (error: any) { + logger.error(`Failed to stop web server during disableAll: ${error.message}`, 'WebServer'); + return { success: false, count, error: error.message }; + } + }); + + // Web server management + ipcMain.handle('webserver:getUrl', async () => { + return webServer?.getSecureUrl(); + }); + + ipcMain.handle('webserver:getConnectedClients', async () => { + return webServer?.getWebClientCount() || 0; + }); + + // System operations (dialog, fonts, shells, tunnel, devtools, updates, logger) + // extracted to src/main/ipc/handlers/system.ts + + // Claude Code sessions - extracted to src/main/ipc/handlers/claude.ts + + // ========================================================================== + // Agent Error Handling API + // ========================================================================== + + // Clear an error state for a session (called after recovery action) + ipcMain.handle('agent:clearError', async (_event, sessionId: string) => { + logger.debug('Clearing agent error for session', 'AgentError', { sessionId }); + // Note: The actual error state is managed in the renderer. + // This handler is used to log the clear action and potentially + // perform any main process cleanup needed. + return { success: true }; + }); + + // Retry the last operation after an error (optionally with modified parameters) + ipcMain.handle( + 'agent:retryAfterError', + async ( + _event, + sessionId: string, + options?: { + prompt?: string; + newSession?: boolean; + } + ) => { + logger.info('Retrying after agent error', 'AgentError', { + sessionId, + hasPrompt: !!options?.prompt, + newSession: options?.newSession || false, + }); + // Note: The actual retry logic is handled in the renderer, which will: + // 1. Clear the error state + // 2. Optionally start a new session + // 3. Re-send the last command or the provided prompt + // This handler exists for logging and potential future main process coordination. + return { success: true }; + } + ); + + // Notification operations + ipcMain.handle('notification:show', async (_event, title: string, body: string) => { + try { + const { Notification } = await import('electron'); + if (Notification.isSupported()) { + const notification = new Notification({ + title, + body, + silent: true, // Don't play system sound - we have our own audio feedback option + }); + notification.show(); + logger.debug('Showed OS notification', 'Notification', { title, body }); + return { success: true }; + } else { + logger.warn('OS notifications not supported on this platform', 'Notification'); + return { success: false, error: 'Notifications not supported' }; + } + } catch (error) { + logger.error('Error showing notification', 'Notification', error); + return { success: false, error: String(error) }; + } + }); + + // Track active TTS processes by ID for stopping + const activeTtsProcesses = new Map< + number, + { process: ReturnType; command: string } + >(); + let ttsProcessIdCounter = 0; + + // TTS queue to prevent audio overlap - enforces minimum delay between TTS calls + const TTS_MIN_DELAY_MS = 15000; // 15 seconds between TTS calls + let lastTtsEndTime = 0; + const ttsQueue: Array<{ + text: string; + command?: string; + resolve: (result: { success: boolean; ttsId?: number; error?: string }) => void; + }> = []; + let isTtsProcessing = false; + + // Process the next item in the TTS queue + const processNextTts = async () => { + if (isTtsProcessing || ttsQueue.length === 0) return; + + isTtsProcessing = true; + const item = ttsQueue.shift()!; + + // Calculate delay needed to maintain minimum gap + const now = Date.now(); + const timeSinceLastTts = now - lastTtsEndTime; + const delayNeeded = Math.max(0, TTS_MIN_DELAY_MS - timeSinceLastTts); + + if (delayNeeded > 0) { + logger.debug(`TTS queue waiting ${delayNeeded}ms before next speech`, 'TTS'); + await new Promise((resolve) => setTimeout(resolve, delayNeeded)); + } + + // Execute the TTS + const result = await executeTts(item.text, item.command); + item.resolve(result); + + // Record when this TTS ended + lastTtsEndTime = Date.now(); + isTtsProcessing = false; + + // Process next item in queue + processNextTts(); + }; + + // Execute TTS - the actual implementation + // Returns a Promise that resolves when the TTS process completes (not just when it starts) + const executeTts = async ( + text: string, + command?: string + ): Promise<{ success: boolean; ttsId?: number; error?: string }> => { + console.log('[TTS Main] executeTts called, text length:', text?.length, 'command:', command); + + // Log the incoming request with full details for debugging + logger.info('TTS speak request received', 'TTS', { + command: command || '(default: say)', + textLength: text?.length || 0, + textPreview: text ? (text.length > 200 ? text.substring(0, 200) + '...' : text) : '(no text)', + }); + + try { + const { spawn } = await import('child_process'); + const fullCommand = command || 'say'; // Default to macOS 'say' command + console.log('[TTS Main] Using fullCommand:', fullCommand); + + // Log the full command being executed + logger.info('TTS executing command', 'TTS', { + command: fullCommand, + textLength: text?.length || 0, + }); + + // Spawn the TTS process with shell mode to support pipes and command chaining + const child = spawn(fullCommand, [], { + stdio: ['pipe', 'ignore', 'pipe'], // stdin: pipe, stdout: ignore, stderr: pipe for errors + shell: true, + }); + + // Generate a unique ID for this TTS process + const ttsId = ++ttsProcessIdCounter; + activeTtsProcesses.set(ttsId, { process: child, command: fullCommand }); + + // Return a Promise that resolves when the TTS process completes + return new Promise((resolve) => { + let resolved = false; + let stderrOutput = ''; + + // Write the text to stdin and close it + if (child.stdin) { + // Handle stdin errors (EPIPE if process terminates before write completes) + child.stdin.on('error', (err) => { + const errorCode = (err as NodeJS.ErrnoException).code; + if (errorCode === 'EPIPE') { + logger.debug('TTS stdin EPIPE - process closed before write completed', 'TTS'); + } else { + logger.error('TTS stdin error', 'TTS', { error: String(err), code: errorCode }); + } + }); + console.log('[TTS Main] Writing to stdin:', text); + child.stdin.write(text, 'utf8', (err) => { + if (err) { + console.error('[TTS Main] stdin write error:', err); + } else { + console.log('[TTS Main] stdin write completed, ending stream'); + } + child.stdin!.end(); + }); + } else { + console.error('[TTS Main] No stdin available on child process'); + } + + child.on('error', (err) => { + console.error('[TTS Main] Spawn error:', err); + logger.error('TTS spawn error', 'TTS', { + error: String(err), + command: fullCommand, + textPreview: text + ? text.length > 100 + ? text.substring(0, 100) + '...' + : text + : '(no text)', + }); + activeTtsProcesses.delete(ttsId); + if (!resolved) { + resolved = true; + resolve({ success: false, ttsId, error: String(err) }); + } + }); + + // Capture stderr for debugging + if (child.stderr) { + child.stderr.on('data', (data) => { + stderrOutput += data.toString(); + }); + } + + child.on('close', (code, signal) => { + console.log('[TTS Main] Process exited with code:', code, 'signal:', signal); + // Always log close event for debugging production issues + logger.info('TTS process closed', 'TTS', { + ttsId, + exitCode: code, + signal, + stderr: stderrOutput || '(none)', + command: fullCommand, + }); + if (code !== 0 && stderrOutput) { + console.error('[TTS Main] stderr:', stderrOutput); + logger.error('TTS process error output', 'TTS', { + exitCode: code, + stderr: stderrOutput, + command: fullCommand, + }); + } + activeTtsProcesses.delete(ttsId); + // Notify renderer that TTS has completed + BrowserWindow.getAllWindows().forEach((win) => { + win.webContents.send('tts:completed', ttsId); + }); + + // Resolve the promise now that TTS has completed + if (!resolved) { + resolved = true; + resolve({ success: code === 0, ttsId }); + } + }); + + console.log('[TTS Main] Process spawned successfully with ID:', ttsId); + logger.info('TTS process spawned successfully', 'TTS', { + ttsId, + command: fullCommand, + textLength: text?.length || 0, + }); + }); + } catch (error) { + console.error('[TTS Main] Error starting audio feedback:', error); + logger.error('TTS error starting audio feedback', 'TTS', { + error: String(error), + command: command || '(default: say)', + textPreview: text + ? text.length > 100 + ? text.substring(0, 100) + '...' + : text + : '(no text)', + }); + return { success: false, error: String(error) }; + } + }; + + // Audio feedback using system TTS command - queued to prevent overlap + ipcMain.handle('notification:speak', async (_event, text: string, command?: string) => { + // Add to queue and return a promise that resolves when this TTS completes + return new Promise<{ success: boolean; ttsId?: number; error?: string }>((resolve) => { + ttsQueue.push({ text, command, resolve }); + logger.debug(`TTS queued, queue length: ${ttsQueue.length}`, 'TTS'); + processNextTts(); + }); + }); + + // Stop a running TTS process + ipcMain.handle('notification:stopSpeak', async (_event, ttsId: number) => { + console.log('[TTS Main] notification:stopSpeak called for ID:', ttsId); + + const ttsProcess = activeTtsProcesses.get(ttsId); + if (!ttsProcess) { + console.log('[TTS Main] No active TTS process found with ID:', ttsId); + return { success: false, error: 'No active TTS process with that ID' }; + } + + try { + // Kill the process and all its children + ttsProcess.process.kill('SIGTERM'); + activeTtsProcesses.delete(ttsId); + + logger.info('TTS process stopped', 'TTS', { + ttsId, + command: ttsProcess.command, + }); + + console.log('[TTS Main] TTS process killed successfully'); + return { success: true }; + } catch (error) { + console.error('[TTS Main] Error stopping TTS process:', error); + logger.error('TTS error stopping process', 'TTS', { + ttsId, + error: String(error), + }); + return { success: false, error: String(error) }; + } + }); + + // Attachments API - store images per Maestro session + // Images are stored in userData/attachments/{sessionId}/{filename} + ipcMain.handle( + 'attachments:save', + async (_event, sessionId: string, base64Data: string, filename: string) => { + try { + const userDataPath = app.getPath('userData'); + const attachmentsDir = path.join(userDataPath, 'attachments', sessionId); + + // Ensure the attachments directory exists + await fs.mkdir(attachmentsDir, { recursive: true }); + + // Extract the base64 content (remove data:image/...;base64, prefix if present) + const base64Match = base64Data.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/); + let buffer: Buffer; + let finalFilename = filename; + + if (base64Match) { + const extension = base64Match[1]; + buffer = Buffer.from(base64Match[2], 'base64'); + // Update filename with correct extension if not already present + if (!filename.includes('.')) { + finalFilename = `${filename}.${extension}`; + } + } else { + // Assume raw base64 + buffer = Buffer.from(base64Data, 'base64'); + } + + const filePath = path.join(attachmentsDir, finalFilename); + await fs.writeFile(filePath, buffer); + + logger.info(`Saved attachment: ${filePath}`, 'Attachments', { + sessionId, + filename: finalFilename, + size: buffer.length, + }); + return { success: true, path: filePath, filename: finalFilename }; + } catch (error) { + logger.error('Error saving attachment', 'Attachments', error); + return { success: false, error: String(error) }; + } + } + ); + + ipcMain.handle('attachments:load', async (_event, sessionId: string, filename: string) => { + try { + const userDataPath = app.getPath('userData'); + const filePath = path.join(userDataPath, 'attachments', sessionId, filename); + + const buffer = await fs.readFile(filePath); + const base64 = buffer.toString('base64'); + + // Determine MIME type from extension + const ext = path.extname(filename).toLowerCase().slice(1); + const mimeTypes: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + }; + const mimeType = mimeTypes[ext] || 'image/png'; + + logger.debug(`Loaded attachment: ${filePath}`, 'Attachments', { + sessionId, + filename, + size: buffer.length, + }); + return { success: true, dataUrl: `data:${mimeType};base64,${base64}` }; + } catch (error) { + logger.error('Error loading attachment', 'Attachments', error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle('attachments:delete', async (_event, sessionId: string, filename: string) => { + try { + const userDataPath = app.getPath('userData'); + const filePath = path.join(userDataPath, 'attachments', sessionId, filename); + + await fs.unlink(filePath); + logger.info(`Deleted attachment: ${filePath}`, 'Attachments', { sessionId, filename }); + return { success: true }; + } catch (error) { + logger.error('Error deleting attachment', 'Attachments', error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle('attachments:list', async (_event, sessionId: string) => { + try { + const userDataPath = app.getPath('userData'); + const attachmentsDir = path.join(userDataPath, 'attachments', sessionId); + + try { + const files = await fs.readdir(attachmentsDir); + const imageFiles = files.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(f)); + logger.debug(`Listed attachments for session: ${sessionId}`, 'Attachments', { + count: imageFiles.length, + }); + return { success: true, files: imageFiles }; + } catch (err: any) { + if (err.code === 'ENOENT') { + // Directory doesn't exist yet - no attachments + return { success: true, files: [] }; + } + throw err; + } + } catch (error) { + logger.error('Error listing attachments', 'Attachments', error); + return { success: false, error: String(error), files: [] }; + } + }); + + ipcMain.handle('attachments:getPath', async (_event, sessionId: string) => { + const userDataPath = app.getPath('userData'); + const attachmentsDir = path.join(userDataPath, 'attachments', sessionId); + return { success: true, path: attachmentsDir }; + }); + + // Auto Run operations - extracted to src/main/ipc/handlers/autorun.ts + + // Playbook operations - extracted to src/main/ipc/handlers/playbooks.ts + + // ========================================================================== + // Leaderboard API + // ========================================================================== + + // Get the unique installation ID for this Maestro installation + ipcMain.handle('leaderboard:getInstallationId', async () => { + return store.get('installationId') || null; + }); + + // Submit leaderboard entry to runmaestro.ai + ipcMain.handle( + 'leaderboard:submit', + async ( + _event, + data: { + email: string; + displayName: string; + githubUsername?: string; + twitterHandle?: string; + linkedinHandle?: string; + discordUsername?: string; + blueskyHandle?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + longestRunMs?: number; + longestRunDate?: string; + currentRunMs?: number; // Duration in milliseconds of the run that just completed + theme?: string; + clientToken?: string; // Client-generated token for polling auth status + authToken?: string; // Required for confirmed email addresses + // Delta mode for multi-device aggregation + deltaMs?: number; // Time in milliseconds to ADD to server-side cumulative total + deltaRuns?: number; // Number of runs to ADD to server-side total runs count + // Installation tracking for multi-device differentiation + installationId?: string; // Unique GUID per Maestro installation + clientTotalTimeMs?: number; // Client's self-proclaimed total time (for discrepancy detection) + } + ): Promise<{ + success: boolean; + message: string; + pendingEmailConfirmation?: boolean; + error?: string; + authTokenRequired?: boolean; // True if 401 due to missing token + ranking?: { + cumulative: { + rank: number; + total: number; + previousRank: number | null; + improved: boolean; + }; + longestRun: { + rank: number; + total: number; + previousRank: number | null; + improved: boolean; + } | null; + }; + // Server-side totals for multi-device sync + serverTotals?: { + cumulativeTimeMs: number; + totalRuns: number; + }; + }> => { + try { + // Auto-inject installation ID if not provided + const installationId = data.installationId || store.get('installationId') || undefined; + + logger.info('Submitting leaderboard entry', 'Leaderboard', { + displayName: data.displayName, + email: data.email.substring(0, 3) + '***', + badgeLevel: data.badgeLevel, + hasClientToken: !!data.clientToken, + hasAuthToken: !!data.authToken, + hasInstallationId: !!installationId, + hasClientTotalTime: !!data.clientTotalTimeMs, + }); + + // Prepare submission data with server-expected field names + // Server expects 'installId' not 'installationId' + const submissionData = { + ...data, + installId: installationId, // Map to server field name + }; + + const response = await fetch('https://runmaestro.ai/api/m4estr0/submit', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + body: JSON.stringify(submissionData), + }); + + const result = (await response.json()) as { + success?: boolean; + message?: string; + pendingEmailConfirmation?: boolean; + error?: string; + ranking?: { + cumulative: { + rank: number; + total: number; + previousRank: number | null; + improved: boolean; + }; + longestRun: { + rank: number; + total: number; + previousRank: number | null; + improved: boolean; + } | null; + }; + // Server-side totals for multi-device sync + serverTotals?: { + cumulativeTimeMs: number; + totalRuns: number; + }; + }; + + if (response.ok) { + logger.info('Leaderboard submission successful', 'Leaderboard', { + pendingEmailConfirmation: result.pendingEmailConfirmation, + ranking: result.ranking, + serverTotals: result.serverTotals, + }); + return { + success: true, + message: result.message || 'Submission received', + pendingEmailConfirmation: result.pendingEmailConfirmation, + ranking: result.ranking, + serverTotals: result.serverTotals, + }; + } else if (response.status === 401) { + // Auth token required or invalid + logger.warn('Leaderboard submission requires auth token', 'Leaderboard', { + error: result.error || result.message, + }); + return { + success: false, + message: result.message || 'Authentication required', + error: result.error || 'Auth token required for confirmed email addresses', + authTokenRequired: true, + }; + } else { + logger.warn('Leaderboard submission failed', 'Leaderboard', { + status: response.status, + error: result.error || result.message, + }); + return { + success: false, + message: result.message || 'Submission failed', + error: result.error || `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error submitting to leaderboard', 'Leaderboard', error); + return { + success: false, + message: 'Failed to connect to leaderboard server', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Poll for auth token after email confirmation + ipcMain.handle( + 'leaderboard:pollAuthStatus', + async ( + _event, + clientToken: string + ): Promise<{ + status: 'pending' | 'confirmed' | 'expired' | 'error'; + authToken?: string; + message?: string; + error?: string; + }> => { + try { + logger.debug('Polling leaderboard auth status', 'Leaderboard'); + + const response = await fetch( + `https://runmaestro.ai/api/m4estr0/auth-status?clientToken=${encodeURIComponent(clientToken)}`, + { + headers: { + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + } + ); + + const result = (await response.json()) as { + status: 'pending' | 'confirmed' | 'expired'; + authToken?: string; + message?: string; + }; + + if (response.ok) { + if (result.status === 'confirmed' && result.authToken) { + logger.info('Leaderboard auth token received', 'Leaderboard'); + } + return { + status: result.status, + authToken: result.authToken, + message: result.message, + }; + } else { + return { + status: 'error', + error: result.message || `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error polling leaderboard auth status', 'Leaderboard', error); + return { + status: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Resend confirmation email (self-service auth token recovery) + ipcMain.handle( + 'leaderboard:resendConfirmation', + async ( + _event, + data: { + email: string; + clientToken: string; + } + ): Promise<{ + success: boolean; + message?: string; + error?: string; + }> => { + try { + logger.info('Requesting leaderboard confirmation resend', 'Leaderboard', { + email: data.email.substring(0, 3) + '***', + }); + + const response = await fetch('https://runmaestro.ai/api/m4estr0/resend-confirmation', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + body: JSON.stringify({ + email: data.email, + clientToken: data.clientToken, + }), + }); + + const result = (await response.json()) as { + success?: boolean; + message?: string; + error?: string; + }; + + if (response.ok && result.success) { + logger.info('Leaderboard confirmation email resent', 'Leaderboard'); + return { + success: true, + message: result.message || 'Confirmation email sent. Please check your inbox.', + }; + } else { + return { + success: false, + error: result.error || result.message || `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error resending leaderboard confirmation', 'Leaderboard', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Get leaderboard entries + ipcMain.handle( + 'leaderboard:get', + async ( + _event, + options?: { limit?: number } + ): Promise<{ + success: boolean; + entries?: Array<{ + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + }>; + error?: string; + }> => { + try { + const limit = options?.limit || 50; + const response = await fetch(`https://runmaestro.ai/api/leaderboard?limit=${limit}`, { + headers: { + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + }); + + if (response.ok) { + const data = (await response.json()) as { entries?: unknown[] }; + return { + success: true, + entries: data.entries as Array<{ + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + }>, + }; + } else { + return { + success: false, + error: `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error fetching leaderboard', 'Leaderboard', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Get longest runs leaderboard + ipcMain.handle( + 'leaderboard:getLongestRuns', + async ( + _event, + options?: { limit?: number } + ): Promise<{ + success: boolean; + entries?: Array<{ + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + longestRunMs: number; + runDate: string; + }>; + error?: string; + }> => { + try { + const limit = options?.limit || 50; + const response = await fetch(`https://runmaestro.ai/api/longest-runs?limit=${limit}`, { + headers: { + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + }); + + if (response.ok) { + const data = (await response.json()) as { entries?: unknown[] }; + return { + success: true, + entries: data.entries as Array<{ + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + longestRunMs: number; + runDate: string; + }>, + }; + } else { + return { + success: false, + error: `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error fetching longest runs leaderboard', 'Leaderboard', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Sync user stats from server (for new device installations) + ipcMain.handle( + 'leaderboard:sync', + async ( + _event, + data: { + email: string; + authToken: string; + } + ): Promise<{ + success: boolean; + found: boolean; + message?: string; + error?: string; + errorCode?: 'EMAIL_NOT_CONFIRMED' | 'INVALID_TOKEN' | 'MISSING_FIELDS'; + data?: { + displayName: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + longestRunMs: number | null; + longestRunDate: string | null; + keyboardLevel: number | null; + coveragePercent: number | null; + ranking: { + cumulative: { rank: number; total: number }; + longestRun: { rank: number; total: number } | null; + }; + }; + }> => { + try { + logger.info('Syncing leaderboard stats from server', 'Leaderboard', { + email: data.email.substring(0, 3) + '***', + }); + + const response = await fetch('https://runmaestro.ai/api/m4estr0/sync', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + body: JSON.stringify({ + email: data.email, + authToken: data.authToken, + }), + }); + + const result = (await response.json()) as { + success: boolean; + found?: boolean; + message?: string; + error?: string; + errorCode?: string; + data?: { + displayName: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + longestRunMs: number | null; + longestRunDate: string | null; + keyboardLevel: number | null; + coveragePercent: number | null; + ranking: { + cumulative: { rank: number; total: number }; + longestRun: { rank: number; total: number } | null; + }; + }; + }; + + if (response.ok && result.success) { + if (result.found && result.data) { + logger.info('Leaderboard sync successful', 'Leaderboard', { + badgeLevel: result.data.badgeLevel, + cumulativeTimeMs: result.data.cumulativeTimeMs, + }); + return { + success: true, + found: true, + data: result.data, + }; + } else { + logger.info('Leaderboard sync: user not found', 'Leaderboard'); + return { + success: true, + found: false, + message: result.message || 'No existing registration found', + }; + } + } else if (response.status === 401) { + logger.warn('Leaderboard sync: invalid token', 'Leaderboard'); + return { + success: false, + found: false, + error: result.error || 'Invalid authentication token', + errorCode: 'INVALID_TOKEN', + }; + } else if (response.status === 403) { + logger.warn('Leaderboard sync: email not confirmed', 'Leaderboard'); + return { + success: false, + found: false, + error: result.error || 'Email not yet confirmed', + errorCode: 'EMAIL_NOT_CONFIRMED', + }; + } else if (response.status === 400) { + return { + success: false, + found: false, + error: result.error || 'Missing required fields', + errorCode: 'MISSING_FIELDS', + }; + } else { + return { + success: false, + found: false, + error: result.error || `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error syncing from leaderboard server', 'Leaderboard', error); + return { + success: false, + found: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); } // Buffer for group chat output (keyed by sessionId) // We buffer output and only route it on process exit to avoid duplicate messages from streaming chunks -const groupChatOutputBuffers = new Map(); +// Uses array of chunks for O(1) append performance instead of O(n) string concatenation +// Tracks totalLength incrementally to avoid O(n) reduce on every append +const groupChatOutputBuffers = new Map(); + +/** Append data to group chat output buffer. O(1) operation. */ +function appendToGroupChatBuffer(sessionId: string, data: string): number { + let buffer = groupChatOutputBuffers.get(sessionId); + if (!buffer) { + buffer = { chunks: [], totalLength: 0 }; + groupChatOutputBuffers.set(sessionId, buffer); + } + buffer.chunks.push(data); + buffer.totalLength += data.length; + return buffer.totalLength; +} + +/** Get buffered output as a single string. Joins chunks on read. */ +function getGroupChatBufferedOutput(sessionId: string): string | undefined { + const buffer = groupChatOutputBuffers.get(sessionId); + if (!buffer || buffer.chunks.length === 0) return undefined; + return buffer.chunks.join(''); +} /** * Extract text content from agent JSON output format. @@ -2637,56 +2846,62 @@ const groupChatOutputBuffers = new Map(); * @returns Extracted text content */ function extractTextFromAgentOutput(rawOutput: string, agentType: string): string { - const parser = getOutputParser(agentType); + const parser = getOutputParser(agentType); - // If no parser found, try a generic extraction - if (!parser) { - logger.warn(`No parser found for agent type '${agentType}', using generic extraction`, '[GroupChat]'); - return extractTextGeneric(rawOutput); - } + // If no parser found, try a generic extraction + if (!parser) { + logger.warn( + `No parser found for agent type '${agentType}', using generic extraction`, + '[GroupChat]' + ); + return extractTextGeneric(rawOutput); + } - const lines = rawOutput.split('\n'); + const lines = rawOutput.split('\n'); - // Check if this looks like JSONL output (first non-empty line starts with '{') - // If not JSONL, return the raw output as-is (it's already parsed text from process-manager) - const firstNonEmptyLine = lines.find(line => line.trim()); - if (firstNonEmptyLine && !firstNonEmptyLine.trim().startsWith('{')) { - logger.debug(`[GroupChat] Input is not JSONL, returning as plain text (len=${rawOutput.length})`, '[GroupChat]'); - return rawOutput; - } + // Check if this looks like JSONL output (first non-empty line starts with '{') + // If not JSONL, return the raw output as-is (it's already parsed text from process-manager) + const firstNonEmptyLine = lines.find((line) => line.trim()); + if (firstNonEmptyLine && !firstNonEmptyLine.trim().startsWith('{')) { + logger.debug( + `[GroupChat] Input is not JSONL, returning as plain text (len=${rawOutput.length})`, + '[GroupChat]' + ); + return rawOutput; + } - const textParts: string[] = []; - let resultText: string | null = null; - let _resultMessageCount = 0; - let _textMessageCount = 0; + const textParts: string[] = []; + let resultText: string | null = null; + let _resultMessageCount = 0; + let _textMessageCount = 0; - for (const line of lines) { - if (!line.trim()) continue; + for (const line of lines) { + if (!line.trim()) continue; - const event = parser.parseJsonLine(line); - if (!event) continue; + const event = parser.parseJsonLine(line); + if (!event) continue; - // Extract text based on event type - if (event.type === 'result' && event.text) { - // Result message is the authoritative final response - save it - resultText = event.text; - _resultMessageCount++; - } + // Extract text based on event type + if (event.type === 'result' && event.text) { + // Result message is the authoritative final response - save it + resultText = event.text; + _resultMessageCount++; + } - if (event.type === 'text' && event.text) { - textParts.push(event.text); - _textMessageCount++; - } - } + if (event.type === 'text' && event.text) { + textParts.push(event.text); + _textMessageCount++; + } + } - // Prefer result message if available (it contains the complete formatted response) - if (resultText) { - return resultText; - } + // Prefer result message if available (it contains the complete formatted response) + if (resultText) { + return resultText; + } - // Fallback: if no result message, concatenate streaming text parts with newlines - // to preserve paragraph structure from partial streaming events - return textParts.join('\n'); + // Fallback: if no result message, concatenate streaming text parts with newlines + // to preserve paragraph structure from partial streaming events + return textParts.join('\n'); } /** @@ -2694,11 +2909,11 @@ function extractTextFromAgentOutput(rawOutput: string, agentType: string): strin * Uses the agent-specific parser when the agent type is known. */ function extractTextFromStreamJson(rawOutput: string, agentType?: string): string { - if (agentType) { - return extractTextFromAgentOutput(rawOutput, agentType); - } + if (agentType) { + return extractTextFromAgentOutput(rawOutput, agentType); + } - return extractTextGeneric(rawOutput); + return extractTextGeneric(rawOutput); } /** @@ -2706,43 +2921,43 @@ function extractTextFromStreamJson(rawOutput: string, agentType?: string): strin * Tries common patterns for JSON output. */ function extractTextGeneric(rawOutput: string): string { - const lines = rawOutput.split('\n'); + const lines = rawOutput.split('\n'); - // Check if this looks like JSONL output (first non-empty line starts with '{') - // If not JSONL, return the raw output as-is (it's already parsed text) - const firstNonEmptyLine = lines.find(line => line.trim()); - if (firstNonEmptyLine && !firstNonEmptyLine.trim().startsWith('{')) { - return rawOutput; - } + // Check if this looks like JSONL output (first non-empty line starts with '{') + // If not JSONL, return the raw output as-is (it's already parsed text) + const firstNonEmptyLine = lines.find((line) => line.trim()); + if (firstNonEmptyLine && !firstNonEmptyLine.trim().startsWith('{')) { + return rawOutput; + } - const textParts: string[] = []; + const textParts: string[] = []; - for (const line of lines) { - if (!line.trim()) continue; + for (const line of lines) { + if (!line.trim()) continue; - try { - const msg = JSON.parse(line); + try { + const msg = JSON.parse(line); - // Try common patterns - if (msg.result) return msg.result; - if (msg.text) textParts.push(msg.text); - if (msg.part?.text) textParts.push(msg.part.text); - if (msg.message?.content) { - const content = msg.message.content; - if (typeof content === 'string') { - textParts.push(content); - } - } - } catch { - // Not valid JSON - include raw text if it looks like content - if (!line.startsWith('{') && !line.includes('session_id') && !line.includes('sessionID')) { - textParts.push(line); - } - } - } + // Try common patterns + if (msg.result) return msg.result; + if (msg.text) textParts.push(msg.text); + if (msg.part?.text) textParts.push(msg.part.text); + if (msg.message?.content) { + const content = msg.message.content; + if (typeof content === 'string') { + textParts.push(content); + } + } + } catch { + // Not valid JSON - include raw text if it looks like content + if (!line.startsWith('{') && !line.includes('session_id') && !line.includes('sessionID')) { + textParts.push(line); + } + } + } - // Join with newlines to preserve paragraph structure - return textParts.join('\n'); + // Join with newlines to preserve paragraph structure + return textParts.join('\n'); } /** @@ -2756,521 +2971,686 @@ function extractTextGeneric(rawOutput: string): string { * * @returns null if not a participant session ID, otherwise { groupChatId, participantName } */ -function parseParticipantSessionId(sessionId: string): { groupChatId: string; participantName: string } | null { - // First check if this is a participant session ID at all - if (!sessionId.includes('-participant-')) { - return null; - } +function parseParticipantSessionId( + sessionId: string +): { groupChatId: string; participantName: string } | null { + // First check if this is a participant session ID at all + if (!sessionId.includes('-participant-')) { + return null; + } - // Try matching with UUID suffix first (36 chars: 8-4-4-4-12 format) - // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - const uuidMatch = sessionId.match(/^group-chat-(.+)-participant-(.+)-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i); - if (uuidMatch) { - return { groupChatId: uuidMatch[1], participantName: uuidMatch[2] }; - } + // Try matching with UUID suffix first (36 chars: 8-4-4-4-12 format) + // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + const uuidMatch = sessionId.match(REGEX_PARTICIPANT_UUID); + if (uuidMatch) { + return { groupChatId: uuidMatch[1], participantName: uuidMatch[2] }; + } - // Try matching with timestamp suffix (13 digits) - const timestampMatch = sessionId.match(/^group-chat-(.+)-participant-(.+)-(\d{13,})$/); - if (timestampMatch) { - return { groupChatId: timestampMatch[1], participantName: timestampMatch[2] }; - } + // Try matching with timestamp suffix (13 digits) + const timestampMatch = sessionId.match(REGEX_PARTICIPANT_TIMESTAMP); + if (timestampMatch) { + return { groupChatId: timestampMatch[1], participantName: timestampMatch[2] }; + } - // Fallback: try the old pattern for backwards compatibility (non-hyphenated names) - const fallbackMatch = sessionId.match(/^group-chat-(.+)-participant-([^-]+)-/); - if (fallbackMatch) { - return { groupChatId: fallbackMatch[1], participantName: fallbackMatch[2] }; - } + // Fallback: try the old pattern for backwards compatibility (non-hyphenated names) + const fallbackMatch = sessionId.match(REGEX_PARTICIPANT_FALLBACK); + if (fallbackMatch) { + return { groupChatId: fallbackMatch[1], participantName: fallbackMatch[2] }; + } - return null; + return null; } // Handle process output streaming (set up after initialization) function setupProcessListeners() { - if (processManager) { - processManager.on('data', (sessionId: string, data: string) => { - // Handle group chat moderator output - buffer it - // Session ID format: group-chat-{groupChatId}-moderator-{uuid} or group-chat-{groupChatId}-moderator-synthesis-{uuid} - const moderatorMatch = sessionId.match(/^group-chat-(.+)-moderator-/); - if (moderatorMatch) { - const groupChatId = moderatorMatch[1]; - console.log(`[GroupChat:Debug] MODERATOR DATA received for chat ${groupChatId}`); - console.log(`[GroupChat:Debug] Session ID: ${sessionId}`); - console.log(`[GroupChat:Debug] Data length: ${data.length}`); - // Buffer the output - will be routed on process exit - const existing = groupChatOutputBuffers.get(sessionId) || ''; - groupChatOutputBuffers.set(sessionId, existing + data); - console.log(`[GroupChat:Debug] Buffered total: ${(existing + data).length} chars`); - return; // Don't send to regular process:data handler - } + if (processManager) { + processManager.on('data', (sessionId: string, data: string) => { + // Handle group chat moderator output - buffer it + // Session ID format: group-chat-{groupChatId}-moderator-{uuid} or group-chat-{groupChatId}-moderator-synthesis-{uuid} + const moderatorMatch = sessionId.match(REGEX_MODERATOR_SESSION); + if (moderatorMatch) { + const groupChatId = moderatorMatch[1]; + debugLog('GroupChat:Debug', `MODERATOR DATA received for chat ${groupChatId}`); + debugLog('GroupChat:Debug', `Session ID: ${sessionId}`); + debugLog('GroupChat:Debug', `Data length: ${data.length}`); + // Buffer the output - will be routed on process exit + const totalLength = appendToGroupChatBuffer(sessionId, data); + debugLog('GroupChat:Debug', `Buffered total: ${totalLength} chars`); + return; // Don't send to regular process:data handler + } - // Handle group chat participant output - buffer it - // Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp} - const participantInfo = parseParticipantSessionId(sessionId); - if (participantInfo) { - console.log(`[GroupChat:Debug] PARTICIPANT DATA received`); - console.log(`[GroupChat:Debug] Chat: ${participantInfo.groupChatId}, Participant: ${participantInfo.participantName}`); - console.log(`[GroupChat:Debug] Session ID: ${sessionId}`); - console.log(`[GroupChat:Debug] Data length: ${data.length}`); - // Buffer the output - will be routed on process exit - const existing = groupChatOutputBuffers.get(sessionId) || ''; - groupChatOutputBuffers.set(sessionId, existing + data); - console.log(`[GroupChat:Debug] Buffered total: ${(existing + data).length} chars`); - return; // Don't send to regular process:data handler - } + // Handle group chat participant output - buffer it + // Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp} + const participantInfo = parseParticipantSessionId(sessionId); + if (participantInfo) { + debugLog('GroupChat:Debug', 'PARTICIPANT DATA received'); + debugLog( + 'GroupChat:Debug', + `Chat: ${participantInfo.groupChatId}, Participant: ${participantInfo.participantName}` + ); + debugLog('GroupChat:Debug', `Session ID: ${sessionId}`); + debugLog('GroupChat:Debug', `Data length: ${data.length}`); + // Buffer the output - will be routed on process exit + const totalLength = appendToGroupChatBuffer(sessionId, data); + debugLog('GroupChat:Debug', `Buffered total: ${totalLength} chars`); + return; // Don't send to regular process:data handler + } - safeSend('process:data', sessionId, data); + safeSend('process:data', sessionId, data); - // Broadcast to web clients - extract base session ID (remove -ai or -terminal suffix) - // IMPORTANT: Skip PTY terminal output (-terminal suffix) as it contains raw ANSI codes. - // Web interface terminal commands use runCommand() which emits with plain session IDs. - if (webServer) { - // Don't broadcast raw PTY terminal output to web clients - if (sessionId.endsWith('-terminal')) { - console.log(`[WebBroadcast] SKIPPING PTY terminal output for web: session=${sessionId}`); - return; - } + // Broadcast to web clients - extract base session ID (remove -ai or -terminal suffix) + // IMPORTANT: Skip PTY terminal output (-terminal suffix) as it contains raw ANSI codes. + // Web interface terminal commands use runCommand() which emits with plain session IDs. + if (webServer) { + // Don't broadcast raw PTY terminal output to web clients + if (sessionId.endsWith('-terminal')) { + debugLog('WebBroadcast', `SKIPPING PTY terminal output for web: session=${sessionId}`); + return; + } - // Don't broadcast background batch/synopsis output to web clients - // These are internal Auto Run operations that should only appear in history, not as chat messages - if (sessionId.includes('-batch-') || sessionId.includes('-synopsis-')) { - console.log(`[WebBroadcast] SKIPPING batch/synopsis output for web: session=${sessionId}`); - return; - } + // Don't broadcast background batch/synopsis output to web clients + // These are internal Auto Run operations that should only appear in history, not as chat messages + if (sessionId.includes('-batch-') || sessionId.includes('-synopsis-')) { + debugLog('WebBroadcast', `SKIPPING batch/synopsis output for web: session=${sessionId}`); + return; + } - // Extract base session ID and tab ID from format: {id}-ai-{tabId} - const baseSessionId = sessionId.replace(/-ai-[^-]+$/, ''); - const isAiOutput = sessionId.includes('-ai-'); + // Extract base session ID and tab ID from format: {id}-ai-{tabId} + const baseSessionId = sessionId.replace(REGEX_AI_SUFFIX, ''); + const isAiOutput = sessionId.includes('-ai-'); - // Extract tab ID from session ID format: {id}-ai-{tabId} - const tabIdMatch = sessionId.match(/-ai-([^-]+)$/); - const tabId = tabIdMatch ? tabIdMatch[1] : undefined; + // Extract tab ID from session ID format: {id}-ai-{tabId} + const tabIdMatch = sessionId.match(REGEX_AI_TAB_ID); + const tabId = tabIdMatch ? tabIdMatch[1] : undefined; - const msgId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - console.log(`[WebBroadcast] Broadcasting session_output: msgId=${msgId}, session=${baseSessionId}, tabId=${tabId || 'none'}, source=${isAiOutput ? 'ai' : 'terminal'}, dataLen=${data.length}`); - webServer.broadcastToSessionClients(baseSessionId, { - type: 'session_output', - sessionId: baseSessionId, - tabId, - data, - source: isAiOutput ? 'ai' : 'terminal', - timestamp: Date.now(), - msgId, - }); - } - }); + const msgId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + debugLog( + 'WebBroadcast', + `Broadcasting session_output: msgId=${msgId}, session=${baseSessionId}, tabId=${tabId || 'none'}, source=${isAiOutput ? 'ai' : 'terminal'}, dataLen=${data.length}` + ); + webServer.broadcastToSessionClients(baseSessionId, { + type: 'session_output', + sessionId: baseSessionId, + tabId, + data, + source: isAiOutput ? 'ai' : 'terminal', + timestamp: Date.now(), + msgId, + }); + } + }); - processManager.on('exit', (sessionId: string, code: number) => { - // Remove power block reason for this session - // This allows system sleep when no AI sessions are active - powerManager.removeBlockReason(`session:${sessionId}`); + processManager.on('exit', (sessionId: string, code: number) => { + // Remove power block reason for this session + // This allows system sleep when no AI sessions are active + powerManager.removeBlockReason(`session:${sessionId}`); - // Handle group chat moderator exit - route buffered output and set state back to idle - // Session ID format: group-chat-{groupChatId}-moderator-{uuid} - // This handles BOTH initial moderator responses AND synthesis responses. - // The routeModeratorResponse function will check for @mentions: - // - If @mentions present: route to agents (continue conversation) - // - If no @mentions: final response to user (conversation complete for this turn) - const moderatorMatch = sessionId.match(/^group-chat-(.+)-moderator-/); - if (moderatorMatch) { - const groupChatId = moderatorMatch[1]; - console.log(`[GroupChat:Debug] ========== MODERATOR PROCESS EXIT ==========`); - console.log(`[GroupChat:Debug] Group Chat ID: ${groupChatId}`); - console.log(`[GroupChat:Debug] Session ID: ${sessionId}`); - console.log(`[GroupChat:Debug] Exit code: ${code}`); - logger.debug(`[GroupChat] Moderator exit: groupChatId=${groupChatId}`, 'ProcessListener', { sessionId }); - // Route the buffered output now that process is complete - const bufferedOutput = groupChatOutputBuffers.get(sessionId); - console.log(`[GroupChat:Debug] Buffered output length: ${bufferedOutput?.length ?? 0}`); - if (bufferedOutput) { - console.log(`[GroupChat:Debug] Raw buffered output preview: "${bufferedOutput.substring(0, 300)}${bufferedOutput.length > 300 ? '...' : ''}"`); - logger.debug(`[GroupChat] Moderator has buffered output (${bufferedOutput.length} chars)`, 'ProcessListener', { groupChatId }); - void (async () => { - try { - const chat = await loadGroupChat(groupChatId); - console.log(`[GroupChat:Debug] Chat loaded for parsing: ${chat?.name || 'null'}`); - const agentType = chat?.moderatorAgentId; - console.log(`[GroupChat:Debug] Agent type for parsing: ${agentType}`); - const parsedText = extractTextFromStreamJson(bufferedOutput, agentType); - console.log(`[GroupChat:Debug] Parsed text length: ${parsedText.length}`); - console.log(`[GroupChat:Debug] Parsed text preview: "${parsedText.substring(0, 300)}${parsedText.length > 300 ? '...' : ''}"`); - if (parsedText.trim()) { - console.log(`[GroupChat:Debug] Routing moderator response...`); - logger.info(`[GroupChat] Routing moderator response (${parsedText.length} chars)`, 'ProcessListener', { groupChatId }); - const readOnly = getGroupChatReadOnlyState(groupChatId); - console.log(`[GroupChat:Debug] Read-only state: ${readOnly}`); - routeModeratorResponse(groupChatId, parsedText, processManager ?? undefined, agentDetector ?? undefined, readOnly).catch(err => { - console.error(`[GroupChat:Debug] ERROR routing moderator response:`, err); - logger.error('[GroupChat] Failed to route moderator response', 'ProcessListener', { error: String(err) }); - }); - } else { - console.log(`[GroupChat:Debug] WARNING: Parsed text is empty!`); - logger.warn('[GroupChat] Moderator output parsed to empty string', 'ProcessListener', { groupChatId, bufferedLength: bufferedOutput.length }); - } - } catch (err) { - console.error(`[GroupChat:Debug] ERROR loading chat:`, err); - logger.error('[GroupChat] Failed to load chat for moderator output parsing', 'ProcessListener', { error: String(err) }); - const parsedText = extractTextFromStreamJson(bufferedOutput); - if (parsedText.trim()) { - const readOnly = getGroupChatReadOnlyState(groupChatId); - routeModeratorResponse(groupChatId, parsedText, processManager ?? undefined, agentDetector ?? undefined, readOnly).catch(routeErr => { - console.error(`[GroupChat:Debug] ERROR routing moderator response (fallback):`, routeErr); - logger.error('[GroupChat] Failed to route moderator response', 'ProcessListener', { error: String(routeErr) }); - }); - } - } - })().finally(() => { - groupChatOutputBuffers.delete(sessionId); - console.log(`[GroupChat:Debug] Cleared output buffer for session`); - }); - } else { - console.log(`[GroupChat:Debug] WARNING: No buffered output!`); - logger.warn('[GroupChat] Moderator exit with no buffered output', 'ProcessListener', { groupChatId, sessionId }); - } - groupChatEmitters.emitStateChange?.(groupChatId, 'idle'); - console.log(`[GroupChat:Debug] Emitted state change: idle`); - console.log(`[GroupChat:Debug] =============================================`); - // Don't send to regular exit handler - return; - } + // Handle group chat moderator exit - route buffered output and set state back to idle + // Session ID format: group-chat-{groupChatId}-moderator-{uuid} + // This handles BOTH initial moderator responses AND synthesis responses. + // The routeModeratorResponse function will check for @mentions: + // - If @mentions present: route to agents (continue conversation) + // - If no @mentions: final response to user (conversation complete for this turn) + const moderatorMatch = sessionId.match(REGEX_MODERATOR_SESSION); + if (moderatorMatch) { + const groupChatId = moderatorMatch[1]; + debugLog('GroupChat:Debug', ` ========== MODERATOR PROCESS EXIT ==========`); + debugLog('GroupChat:Debug', ` Group Chat ID: ${groupChatId}`); + debugLog('GroupChat:Debug', ` Session ID: ${sessionId}`); + debugLog('GroupChat:Debug', ` Exit code: ${code}`); + logger.debug(`[GroupChat] Moderator exit: groupChatId=${groupChatId}`, 'ProcessListener', { + sessionId, + }); + // Route the buffered output now that process is complete + const bufferedOutput = getGroupChatBufferedOutput(sessionId); + debugLog('GroupChat:Debug', ` Buffered output length: ${bufferedOutput?.length ?? 0}`); + if (bufferedOutput) { + debugLog( + 'GroupChat:Debug', + ` Raw buffered output preview: "${bufferedOutput.substring(0, 300)}${bufferedOutput.length > 300 ? '...' : ''}"` + ); + logger.debug( + `[GroupChat] Moderator has buffered output (${bufferedOutput.length} chars)`, + 'ProcessListener', + { groupChatId } + ); + void (async () => { + try { + const chat = await loadGroupChat(groupChatId); + debugLog('GroupChat:Debug', ` Chat loaded for parsing: ${chat?.name || 'null'}`); + const agentType = chat?.moderatorAgentId; + debugLog('GroupChat:Debug', ` Agent type for parsing: ${agentType}`); + const parsedText = extractTextFromStreamJson(bufferedOutput, agentType); + debugLog('GroupChat:Debug', ` Parsed text length: ${parsedText.length}`); + debugLog( + 'GroupChat:Debug', + ` Parsed text preview: "${parsedText.substring(0, 300)}${parsedText.length > 300 ? '...' : ''}"` + ); + if (parsedText.trim()) { + debugLog('GroupChat:Debug', ` Routing moderator response...`); + logger.info( + `[GroupChat] Routing moderator response (${parsedText.length} chars)`, + 'ProcessListener', + { groupChatId } + ); + const readOnly = getGroupChatReadOnlyState(groupChatId); + debugLog('GroupChat:Debug', ` Read-only state: ${readOnly}`); + routeModeratorResponse( + groupChatId, + parsedText, + processManager ?? undefined, + agentDetector ?? undefined, + readOnly + ).catch((err) => { + debugLog('GroupChat:Debug', ` ERROR routing moderator response:`, err); + logger.error( + '[GroupChat] Failed to route moderator response', + 'ProcessListener', + { error: String(err) } + ); + }); + } else { + debugLog('GroupChat:Debug', ` WARNING: Parsed text is empty!`); + logger.warn( + '[GroupChat] Moderator output parsed to empty string', + 'ProcessListener', + { groupChatId, bufferedLength: bufferedOutput.length } + ); + } + } catch (err) { + debugLog('GroupChat:Debug', ` ERROR loading chat:`, err); + logger.error( + '[GroupChat] Failed to load chat for moderator output parsing', + 'ProcessListener', + { error: String(err) } + ); + const parsedText = extractTextFromStreamJson(bufferedOutput); + if (parsedText.trim()) { + const readOnly = getGroupChatReadOnlyState(groupChatId); + routeModeratorResponse( + groupChatId, + parsedText, + processManager ?? undefined, + agentDetector ?? undefined, + readOnly + ).catch((routeErr) => { + debugLog( + 'GroupChat:Debug', + ` ERROR routing moderator response (fallback):`, + routeErr + ); + logger.error( + '[GroupChat] Failed to route moderator response', + 'ProcessListener', + { error: String(routeErr) } + ); + }); + } + } + })().finally(() => { + groupChatOutputBuffers.delete(sessionId); + debugLog('GroupChat:Debug', ` Cleared output buffer for session`); + }); + } else { + debugLog('GroupChat:Debug', ` WARNING: No buffered output!`); + logger.warn('[GroupChat] Moderator exit with no buffered output', 'ProcessListener', { + groupChatId, + sessionId, + }); + } + groupChatEmitters.emitStateChange?.(groupChatId, 'idle'); + debugLog('GroupChat:Debug', ` Emitted state change: idle`); + debugLog('GroupChat:Debug', ` =============================================`); + // Don't send to regular exit handler + return; + } - // Handle group chat participant exit - route buffered output and update participant state - // Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp} - const participantExitInfo = parseParticipantSessionId(sessionId); - if (participantExitInfo) { - const { groupChatId, participantName } = participantExitInfo; - console.log(`[GroupChat:Debug] ========== PARTICIPANT PROCESS EXIT ==========`); - console.log(`[GroupChat:Debug] Group Chat ID: ${groupChatId}`); - console.log(`[GroupChat:Debug] Participant: ${participantName}`); - console.log(`[GroupChat:Debug] Session ID: ${sessionId}`); - console.log(`[GroupChat:Debug] Exit code: ${code}`); - logger.debug(`[GroupChat] Participant exit: ${participantName} (groupChatId=${groupChatId})`, 'ProcessListener', { sessionId }); + // Handle group chat participant exit - route buffered output and update participant state + // Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp} + const participantExitInfo = parseParticipantSessionId(sessionId); + if (participantExitInfo) { + const { groupChatId, participantName } = participantExitInfo; + debugLog('GroupChat:Debug', ` ========== PARTICIPANT PROCESS EXIT ==========`); + debugLog('GroupChat:Debug', ` Group Chat ID: ${groupChatId}`); + debugLog('GroupChat:Debug', ` Participant: ${participantName}`); + debugLog('GroupChat:Debug', ` Session ID: ${sessionId}`); + debugLog('GroupChat:Debug', ` Exit code: ${code}`); + logger.debug( + `[GroupChat] Participant exit: ${participantName} (groupChatId=${groupChatId})`, + 'ProcessListener', + { sessionId } + ); - // Emit participant state change to show this participant is done working - groupChatEmitters.emitParticipantState?.(groupChatId, participantName, 'idle'); - console.log(`[GroupChat:Debug] Emitted participant state: idle`); + // Emit participant state change to show this participant is done working + groupChatEmitters.emitParticipantState?.(groupChatId, participantName, 'idle'); + debugLog('GroupChat:Debug', ` Emitted participant state: idle`); - // Route the buffered output now that process is complete - // IMPORTANT: We must wait for the response to be logged before triggering synthesis - // to avoid a race condition where synthesis reads the log before the response is written - const bufferedOutput = groupChatOutputBuffers.get(sessionId); - console.log(`[GroupChat:Debug] Buffered output length: ${bufferedOutput?.length ?? 0}`); + // Route the buffered output now that process is complete + // IMPORTANT: We must wait for the response to be logged before triggering synthesis + // to avoid a race condition where synthesis reads the log before the response is written + const bufferedOutput = getGroupChatBufferedOutput(sessionId); + debugLog('GroupChat:Debug', ` Buffered output length: ${bufferedOutput?.length ?? 0}`); - // Helper function to mark participant and potentially trigger synthesis - const markAndMaybeSynthesize = () => { - const isLastParticipant = markParticipantResponded(groupChatId, participantName); - console.log(`[GroupChat:Debug] Is last participant to respond: ${isLastParticipant}`); - if (isLastParticipant && processManager && agentDetector) { - // All participants have responded - spawn moderator synthesis round - console.log(`[GroupChat:Debug] All participants responded - spawning synthesis round...`); - logger.info('[GroupChat] All participants responded, spawning moderator synthesis', 'ProcessListener', { groupChatId }); - spawnModeratorSynthesis(groupChatId, processManager, agentDetector).catch(err => { - console.error(`[GroupChat:Debug] ERROR spawning synthesis:`, err); - logger.error('[GroupChat] Failed to spawn moderator synthesis', 'ProcessListener', { error: String(err), groupChatId }); - }); - } else if (!isLastParticipant) { - // More participants pending - console.log(`[GroupChat:Debug] Waiting for more participants to respond...`); - } - }; + // Helper function to mark participant and potentially trigger synthesis + const markAndMaybeSynthesize = () => { + const isLastParticipant = markParticipantResponded(groupChatId, participantName); + debugLog('GroupChat:Debug', ` Is last participant to respond: ${isLastParticipant}`); + if (isLastParticipant && processManager && agentDetector) { + // All participants have responded - spawn moderator synthesis round + debugLog( + 'GroupChat:Debug', + ` All participants responded - spawning synthesis round...` + ); + logger.info( + '[GroupChat] All participants responded, spawning moderator synthesis', + 'ProcessListener', + { groupChatId } + ); + spawnModeratorSynthesis(groupChatId, processManager, agentDetector).catch((err) => { + debugLog('GroupChat:Debug', ` ERROR spawning synthesis:`, err); + logger.error('[GroupChat] Failed to spawn moderator synthesis', 'ProcessListener', { + error: String(err), + groupChatId, + }); + }); + } else if (!isLastParticipant) { + // More participants pending + debugLog('GroupChat:Debug', ` Waiting for more participants to respond...`); + } + }; - if (bufferedOutput) { - console.log(`[GroupChat:Debug] Raw buffered output preview: "${bufferedOutput.substring(0, 300)}${bufferedOutput.length > 300 ? '...' : ''}"`); + if (bufferedOutput) { + debugLog( + 'GroupChat:Debug', + ` Raw buffered output preview: "${bufferedOutput.substring(0, 300)}${bufferedOutput.length > 300 ? '...' : ''}"` + ); - // Handle session recovery and normal processing in an async IIFE - void (async () => { - // Check if this is a session_not_found error - if so, recover and retry - const chat = await loadGroupChat(groupChatId); - const agentType = chat?.participants.find(p => p.name === participantName)?.agentId; + // Handle session recovery and normal processing in an async IIFE + void (async () => { + // Check if this is a session_not_found error - if so, recover and retry + const chat = await loadGroupChat(groupChatId); + const agentType = chat?.participants.find((p) => p.name === participantName)?.agentId; - if (needsSessionRecovery(bufferedOutput, agentType)) { - console.log(`[GroupChat:Debug] Session not found error detected for ${participantName} - initiating recovery`); - logger.info('[GroupChat] Session recovery needed', 'ProcessListener', { groupChatId, participantName }); + if (needsSessionRecovery(bufferedOutput, agentType)) { + debugLog( + 'GroupChat:Debug', + ` Session not found error detected for ${participantName} - initiating recovery` + ); + logger.info('[GroupChat] Session recovery needed', 'ProcessListener', { + groupChatId, + participantName, + }); - // Clear the buffer first - groupChatOutputBuffers.delete(sessionId); + // Clear the buffer first + groupChatOutputBuffers.delete(sessionId); - // Initiate recovery (clears agentSessionId) - await initiateSessionRecovery(groupChatId, participantName); + // Initiate recovery (clears agentSessionId) + await initiateSessionRecovery(groupChatId, participantName); - // Re-spawn the participant with recovery context - if (processManager && agentDetector) { - console.log(`[GroupChat:Debug] Re-spawning ${participantName} with recovery context...`); - try { - await respawnParticipantWithRecovery( - groupChatId, - participantName, - processManager, - agentDetector - ); - console.log(`[GroupChat:Debug] Successfully re-spawned ${participantName} for recovery`); - // Don't mark as responded yet - the recovery spawn will complete and trigger this - } catch (respawnErr) { - console.error(`[GroupChat:Debug] Failed to respawn ${participantName}:`, respawnErr); - logger.error('[GroupChat] Failed to respawn participant for recovery', 'ProcessListener', { - error: String(respawnErr), - participant: participantName, - }); - // Mark as responded since recovery failed - markAndMaybeSynthesize(); - } - } else { - console.log(`[GroupChat:Debug] Cannot respawn - processManager or agentDetector not available`); - markAndMaybeSynthesize(); - } - console.log(`[GroupChat:Debug] ===============================================`); - return; - } + // Re-spawn the participant with recovery context + if (processManager && agentDetector) { + debugLog( + 'GroupChat:Debug', + ` Re-spawning ${participantName} with recovery context...` + ); + try { + await respawnParticipantWithRecovery( + groupChatId, + participantName, + processManager, + agentDetector + ); + debugLog( + 'GroupChat:Debug', + ` Successfully re-spawned ${participantName} for recovery` + ); + // Don't mark as responded yet - the recovery spawn will complete and trigger this + } catch (respawnErr) { + debugLog('GroupChat:Debug', ` Failed to respawn ${participantName}:`, respawnErr); + logger.error( + '[GroupChat] Failed to respawn participant for recovery', + 'ProcessListener', + { + error: String(respawnErr), + participant: participantName, + } + ); + // Mark as responded since recovery failed + markAndMaybeSynthesize(); + } + } else { + debugLog( + 'GroupChat:Debug', + ` Cannot respawn - processManager or agentDetector not available` + ); + markAndMaybeSynthesize(); + } + debugLog('GroupChat:Debug', ` ===============================================`); + return; + } - // Normal processing - parse and route the response - try { - console.log(`[GroupChat:Debug] Chat loaded for participant parsing: ${chat?.name || 'null'}`); - console.log(`[GroupChat:Debug] Agent type for parsing: ${agentType}`); - const parsedText = extractTextFromStreamJson(bufferedOutput, agentType); - console.log(`[GroupChat:Debug] Parsed text length: ${parsedText.length}`); - console.log(`[GroupChat:Debug] Parsed text preview: "${parsedText.substring(0, 200)}${parsedText.length > 200 ? '...' : ''}"`); - if (parsedText.trim()) { - console.log(`[GroupChat:Debug] Routing agent response from ${participantName}...`); - // Await the response logging before marking participant as responded - await routeAgentResponse(groupChatId, participantName, parsedText, processManager ?? undefined); - console.log(`[GroupChat:Debug] Successfully routed agent response from ${participantName}`); - } else { - console.log(`[GroupChat:Debug] WARNING: Parsed text is empty for ${participantName}!`); - } - } catch (err) { - console.error(`[GroupChat:Debug] ERROR loading chat for participant:`, err); - logger.error('[GroupChat] Failed to load chat for participant output parsing', 'ProcessListener', { error: String(err), participant: participantName }); - try { - const parsedText = extractTextFromStreamJson(bufferedOutput); - if (parsedText.trim()) { - await routeAgentResponse(groupChatId, participantName, parsedText, processManager ?? undefined); - } - } catch (routeErr) { - console.error(`[GroupChat:Debug] ERROR routing agent response (fallback):`, routeErr); - logger.error('[GroupChat] Failed to route agent response', 'ProcessListener', { error: String(routeErr), participant: participantName }); - } - } - })().finally(() => { - groupChatOutputBuffers.delete(sessionId); - console.log(`[GroupChat:Debug] Cleared output buffer for participant session`); - // Mark participant and trigger synthesis AFTER logging is complete - markAndMaybeSynthesize(); - }); - } else { - console.log(`[GroupChat:Debug] WARNING: No buffered output for participant ${participantName}!`); - // No output to log, so mark participant as responded immediately - markAndMaybeSynthesize(); - } - console.log(`[GroupChat:Debug] ===============================================`); - // Don't send to regular exit handler - return; - } + // Normal processing - parse and route the response + try { + debugLog( + 'GroupChat:Debug', + ` Chat loaded for participant parsing: ${chat?.name || 'null'}` + ); + debugLog('GroupChat:Debug', ` Agent type for parsing: ${agentType}`); + const parsedText = extractTextFromStreamJson(bufferedOutput, agentType); + debugLog('GroupChat:Debug', ` Parsed text length: ${parsedText.length}`); + debugLog( + 'GroupChat:Debug', + ` Parsed text preview: "${parsedText.substring(0, 200)}${parsedText.length > 200 ? '...' : ''}"` + ); + if (parsedText.trim()) { + debugLog('GroupChat:Debug', ` Routing agent response from ${participantName}...`); + // Await the response logging before marking participant as responded + await routeAgentResponse( + groupChatId, + participantName, + parsedText, + processManager ?? undefined + ); + debugLog( + 'GroupChat:Debug', + ` Successfully routed agent response from ${participantName}` + ); + } else { + debugLog( + 'GroupChat:Debug', + ` WARNING: Parsed text is empty for ${participantName}!` + ); + } + } catch (err) { + debugLog('GroupChat:Debug', ` ERROR loading chat for participant:`, err); + logger.error( + '[GroupChat] Failed to load chat for participant output parsing', + 'ProcessListener', + { error: String(err), participant: participantName } + ); + try { + const parsedText = extractTextFromStreamJson(bufferedOutput); + if (parsedText.trim()) { + await routeAgentResponse( + groupChatId, + participantName, + parsedText, + processManager ?? undefined + ); + } + } catch (routeErr) { + debugLog('GroupChat:Debug', ` ERROR routing agent response (fallback):`, routeErr); + logger.error('[GroupChat] Failed to route agent response', 'ProcessListener', { + error: String(routeErr), + participant: participantName, + }); + } + } + })().finally(() => { + groupChatOutputBuffers.delete(sessionId); + debugLog('GroupChat:Debug', ` Cleared output buffer for participant session`); + // Mark participant and trigger synthesis AFTER logging is complete + markAndMaybeSynthesize(); + }); + } else { + debugLog( + 'GroupChat:Debug', + ` WARNING: No buffered output for participant ${participantName}!` + ); + // No output to log, so mark participant as responded immediately + markAndMaybeSynthesize(); + } + debugLog('GroupChat:Debug', ` ===============================================`); + // Don't send to regular exit handler + return; + } - safeSend('process:exit', sessionId, code); + safeSend('process:exit', sessionId, code); - // Broadcast exit to web clients - if (webServer) { - // Extract base session ID from formats: {id}-ai-{tabId}, {id}-terminal, {id}-batch-{timestamp}, {id}-synopsis-{timestamp} - const baseSessionId = sessionId.replace(/-ai-[^-]+$|-terminal$|-batch-\d+$|-synopsis-\d+$/, ''); - webServer.broadcastToSessionClients(baseSessionId, { - type: 'session_exit', - sessionId: baseSessionId, - exitCode: code, - timestamp: Date.now(), - }); - } - }); + // Broadcast exit to web clients + if (webServer) { + // Extract base session ID from formats: {id}-ai-{tabId}, {id}-terminal, {id}-batch-{timestamp}, {id}-synopsis-{timestamp} + const baseSessionId = sessionId.replace( + /-ai-[^-]+$|-terminal$|-batch-\d+$|-synopsis-\d+$/, + '' + ); + webServer.broadcastToSessionClients(baseSessionId, { + type: 'session_exit', + sessionId: baseSessionId, + exitCode: code, + timestamp: Date.now(), + }); + } + }); - processManager.on('session-id', (sessionId: string, agentSessionId: string) => { - // Handle group chat participant session ID - store the agent's session ID - // Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp} - const participantSessionInfo = parseParticipantSessionId(sessionId); - if (participantSessionInfo) { - const { groupChatId, participantName } = participantSessionInfo; - // Update the participant with the agent's session ID - updateParticipant(groupChatId, participantName, { agentSessionId }).then(async () => { - // Emit participants changed so UI updates with the new session ID - const chat = await loadGroupChat(groupChatId); - if (chat) { - groupChatEmitters.emitParticipantsChanged?.(groupChatId, chat.participants); - } - }).catch(err => { - logger.error('[GroupChat] Failed to update participant agentSessionId', 'ProcessListener', { error: String(err), participant: participantName }); - }); - // Don't return - still send to renderer for logging purposes - } + processManager.on('session-id', (sessionId: string, agentSessionId: string) => { + // Handle group chat participant session ID - store the agent's session ID + // Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp} + const participantSessionInfo = parseParticipantSessionId(sessionId); + if (participantSessionInfo) { + const { groupChatId, participantName } = participantSessionInfo; + // Update the participant with the agent's session ID + updateParticipant(groupChatId, participantName, { agentSessionId }) + .then(async () => { + // Emit participants changed so UI updates with the new session ID + const chat = await loadGroupChat(groupChatId); + if (chat) { + groupChatEmitters.emitParticipantsChanged?.(groupChatId, chat.participants); + } + }) + .catch((err) => { + logger.error( + '[GroupChat] Failed to update participant agentSessionId', + 'ProcessListener', + { error: String(err), participant: participantName } + ); + }); + // Don't return - still send to renderer for logging purposes + } - // Handle group chat moderator session ID - store the real agent session ID - // Session ID format: group-chat-{groupChatId}-moderator-{timestamp} - const moderatorMatch = sessionId.match(/^group-chat-(.+)-moderator-\d+$/); - if (moderatorMatch) { - const groupChatId = moderatorMatch[1]; - // Update the group chat with the moderator's real agent session ID - // Store in moderatorAgentSessionId (not moderatorSessionId which is the routing prefix) - updateGroupChat(groupChatId, { moderatorAgentSessionId: agentSessionId }).then(() => { - // Emit session ID change event so UI updates with the new session ID - groupChatEmitters.emitModeratorSessionIdChanged?.(groupChatId, agentSessionId); - }).catch((err: unknown) => { - logger.error('[GroupChat] Failed to update moderator agent session ID', 'ProcessListener', { error: String(err), groupChatId }); - }); - // Don't return - still send to renderer for logging purposes - } + // Handle group chat moderator session ID - store the real agent session ID + // Session ID format: group-chat-{groupChatId}-moderator-{timestamp} + const moderatorMatch = sessionId.match(REGEX_MODERATOR_SESSION_TIMESTAMP); + if (moderatorMatch) { + const groupChatId = moderatorMatch[1]; + // Update the group chat with the moderator's real agent session ID + // Store in moderatorAgentSessionId (not moderatorSessionId which is the routing prefix) + updateGroupChat(groupChatId, { moderatorAgentSessionId: agentSessionId }) + .then(() => { + // Emit session ID change event so UI updates with the new session ID + groupChatEmitters.emitModeratorSessionIdChanged?.(groupChatId, agentSessionId); + }) + .catch((err: unknown) => { + logger.error( + '[GroupChat] Failed to update moderator agent session ID', + 'ProcessListener', + { error: String(err), groupChatId } + ); + }); + // Don't return - still send to renderer for logging purposes + } - safeSend('process:session-id', sessionId, agentSessionId); - }); + safeSend('process:session-id', sessionId, agentSessionId); + }); - // Handle slash commands from Claude Code init message - processManager.on('slash-commands', (sessionId: string, slashCommands: string[]) => { - safeSend('process:slash-commands', sessionId, slashCommands); - }); + // Handle slash commands from Claude Code init message + processManager.on('slash-commands', (sessionId: string, slashCommands: string[]) => { + safeSend('process:slash-commands', sessionId, slashCommands); + }); - // Handle thinking/streaming content chunks from AI agents - // Emitted when agents produce partial text events (isPartial: true) - // Renderer decides whether to display based on tab's showThinking setting - processManager.on('thinking-chunk', (sessionId: string, content: string) => { - safeSend('process:thinking-chunk', sessionId, content); - }); + // Handle thinking/streaming content chunks from AI agents + // Emitted when agents produce partial text events (isPartial: true) + // Renderer decides whether to display based on tab's showThinking setting + processManager.on('thinking-chunk', (sessionId: string, content: string) => { + safeSend('process:thinking-chunk', sessionId, content); + }); - // Handle tool execution events (OpenCode, Codex) - processManager.on('tool-execution', (sessionId: string, toolEvent: { toolName: string; state?: unknown; timestamp: number }) => { - safeSend('process:tool-execution', sessionId, toolEvent); - }); + // Handle tool execution events (OpenCode, Codex) + processManager.on( + 'tool-execution', + (sessionId: string, toolEvent: { toolName: string; state?: unknown; timestamp: number }) => { + safeSend('process:tool-execution', sessionId, toolEvent); + } + ); - // Handle stderr separately from runCommand (for clean command execution) - processManager.on('stderr', (sessionId: string, data: string) => { - safeSend('process:stderr', sessionId, data); - }); + // Handle stderr separately from runCommand (for clean command execution) + processManager.on('stderr', (sessionId: string, data: string) => { + safeSend('process:stderr', sessionId, data); + }); - // Handle command exit (from runCommand - separate from PTY exit) - processManager.on('command-exit', (sessionId: string, code: number) => { - safeSend('process:command-exit', sessionId, code); - }); + // Handle command exit (from runCommand - separate from PTY exit) + processManager.on('command-exit', (sessionId: string, code: number) => { + safeSend('process:command-exit', sessionId, code); + }); - // Handle usage statistics from AI responses - processManager.on('usage', (sessionId: string, usageStats: { - inputTokens: number; - outputTokens: number; - cacheReadInputTokens: number; - cacheCreationInputTokens: number; - totalCostUsd: number; - contextWindow: number; - reasoningTokens?: number; // Separate reasoning tokens (Codex o3/o4-mini) - }) => { - // Handle group chat participant usage - update participant stats - const participantUsageInfo = parseParticipantSessionId(sessionId); - if (participantUsageInfo) { - const { groupChatId, participantName } = participantUsageInfo; + // Handle usage statistics from AI responses + processManager.on( + 'usage', + ( + sessionId: string, + usageStats: { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalCostUsd: number; + contextWindow: number; + reasoningTokens?: number; // Separate reasoning tokens (Codex o3/o4-mini) + } + ) => { + // Handle group chat participant usage - update participant stats + const participantUsageInfo = parseParticipantSessionId(sessionId); + if (participantUsageInfo) { + const { groupChatId, participantName } = participantUsageInfo; - // Calculate context usage percentage using agent-specific logic - // Note: For group chat, we don't have agent type here, defaults to Claude behavior - const totalContextTokens = calculateContextTokens(usageStats); - const contextUsage = usageStats.contextWindow > 0 - ? Math.round((totalContextTokens / usageStats.contextWindow) * 100) - : 0; + // Calculate context usage percentage using agent-specific logic + // Note: For group chat, we don't have agent type here, defaults to Claude behavior + const totalContextTokens = calculateContextTokens(usageStats); + const contextUsage = + usageStats.contextWindow > 0 + ? Math.round((totalContextTokens / usageStats.contextWindow) * 100) + : 0; - // Update participant with usage stats - updateParticipant(groupChatId, participantName, { - contextUsage, - tokenCount: totalContextTokens, - totalCost: usageStats.totalCostUsd, - }).then(async () => { - // Emit participants changed so UI updates - const chat = await loadGroupChat(groupChatId); - if (chat) { - groupChatEmitters.emitParticipantsChanged?.(groupChatId, chat.participants); - } - }).catch(err => { - logger.error('[GroupChat] Failed to update participant usage', 'ProcessListener', { - error: String(err), - participant: participantName, - }); - }); - // Still send to renderer for consistency - } + // Update participant with usage stats + updateParticipant(groupChatId, participantName, { + contextUsage, + tokenCount: totalContextTokens, + totalCost: usageStats.totalCostUsd, + }) + .then(async () => { + // Emit participants changed so UI updates + const chat = await loadGroupChat(groupChatId); + if (chat) { + groupChatEmitters.emitParticipantsChanged?.(groupChatId, chat.participants); + } + }) + .catch((err) => { + logger.error('[GroupChat] Failed to update participant usage', 'ProcessListener', { + error: String(err), + participant: participantName, + }); + }); + // Still send to renderer for consistency + } - // Handle group chat moderator usage - emit for UI - const moderatorMatch = sessionId.match(/^group-chat-(.+)-moderator-/); - if (moderatorMatch) { - const groupChatId = moderatorMatch[1]; - // Calculate context usage percentage using agent-specific logic - // Note: Moderator is typically Claude, defaults to Claude behavior - const totalContextTokens = calculateContextTokens(usageStats); - const contextUsage = usageStats.contextWindow > 0 - ? Math.round((totalContextTokens / usageStats.contextWindow) * 100) - : 0; + // Handle group chat moderator usage - emit for UI + const moderatorUsageMatch = sessionId.match(REGEX_MODERATOR_SESSION); + if (moderatorUsageMatch) { + const groupChatId = moderatorUsageMatch[1]; + // Calculate context usage percentage using agent-specific logic + // Note: Moderator is typically Claude, defaults to Claude behavior + const totalContextTokens = calculateContextTokens(usageStats); + const contextUsage = + usageStats.contextWindow > 0 + ? Math.round((totalContextTokens / usageStats.contextWindow) * 100) + : 0; - // Emit moderator usage for the moderator card - groupChatEmitters.emitModeratorUsage?.(groupChatId, { - contextUsage, - totalCost: usageStats.totalCostUsd, - tokenCount: totalContextTokens, - }); - } + // Emit moderator usage for the moderator card + groupChatEmitters.emitModeratorUsage?.(groupChatId, { + contextUsage, + totalCost: usageStats.totalCostUsd, + tokenCount: totalContextTokens, + }); + } - safeSend('process:usage', sessionId, usageStats); - }); + safeSend('process:usage', sessionId, usageStats); + } + ); - // Handle agent errors (auth expired, token exhaustion, rate limits, etc.) - processManager.on('agent-error', (sessionId: string, agentError: { - type: string; - message: string; - recoverable: boolean; - agentId: string; - sessionId?: string; - timestamp: number; - raw?: { - exitCode?: number; - stderr?: string; - stdout?: string; - errorLine?: string; - }; - }) => { - logger.info(`Agent error detected: ${agentError.type}`, 'AgentError', { - sessionId, - agentId: agentError.agentId, - errorType: agentError.type, - message: agentError.message, - recoverable: agentError.recoverable, - }); - safeSend('agent:error', sessionId, agentError); - }); + // Handle agent errors (auth expired, token exhaustion, rate limits, etc.) + processManager.on( + 'agent-error', + ( + sessionId: string, + agentError: { + type: string; + message: string; + recoverable: boolean; + agentId: string; + sessionId?: string; + timestamp: number; + raw?: { + exitCode?: number; + stderr?: string; + stdout?: string; + errorLine?: string; + }; + } + ) => { + logger.info(`Agent error detected: ${agentError.type}`, 'AgentError', { + sessionId, + agentId: agentError.agentId, + errorType: agentError.type, + message: agentError.message, + recoverable: agentError.recoverable, + }); + safeSend('agent:error', sessionId, agentError); + } + ); - // Handle query-complete events for stats tracking - // This is emitted when a batch mode AI query completes (user or auto) - processManager.on('query-complete', (_sessionId: string, queryData: { - sessionId: string; - agentType: string; - source: 'user' | 'auto'; - startTime: number; - duration: number; - projectPath?: string; - tabId?: string; - }) => { - try { - const db = getStatsDB(); - if (db.isReady()) { - const id = db.insertQueryEvent({ - sessionId: queryData.sessionId, - agentType: queryData.agentType, - source: queryData.source, - startTime: queryData.startTime, - duration: queryData.duration, - projectPath: queryData.projectPath, - tabId: queryData.tabId, - }); - logger.debug(`Recorded query event: ${id}`, '[Stats]', { - sessionId: queryData.sessionId, - agentType: queryData.agentType, - source: queryData.source, - duration: queryData.duration, - }); - // Broadcast stats update to renderer for real-time dashboard refresh - safeSend('stats:updated'); - } - } catch (error) { - logger.error(`Failed to record query event: ${error}`, '[Stats]', { - sessionId: queryData.sessionId, - }); - } - }); - } + // Handle query-complete events for stats tracking + // This is emitted when a batch mode AI query completes (user or auto) + processManager.on( + 'query-complete', + ( + _sessionId: string, + queryData: { + sessionId: string; + agentType: string; + source: 'user' | 'auto'; + startTime: number; + duration: number; + projectPath?: string; + tabId?: string; + } + ) => { + try { + const db = getStatsDB(); + if (db.isReady()) { + const id = db.insertQueryEvent({ + sessionId: queryData.sessionId, + agentType: queryData.agentType, + source: queryData.source, + startTime: queryData.startTime, + duration: queryData.duration, + projectPath: queryData.projectPath, + tabId: queryData.tabId, + }); + logger.debug(`Recorded query event: ${id}`, '[Stats]', { + sessionId: queryData.sessionId, + agentType: queryData.agentType, + source: queryData.source, + duration: queryData.duration, + }); + // Broadcast stats update to renderer for real-time dashboard refresh + safeSend('stats:updated'); + } + } catch (error) { + logger.error(`Failed to record query event: ${error}`, '[Stats]', { + sessionId: queryData.sessionId, + }); + } + } + ); + } } diff --git a/src/main/ipc/handlers/persistence.ts b/src/main/ipc/handlers/persistence.ts index ef8c3711..7c4bd2e7 100644 --- a/src/main/ipc/handlers/persistence.ts +++ b/src/main/ipc/handlers/persistence.ts @@ -22,190 +22,210 @@ import { WebServer } from '../../web-server'; * Interface for Maestro settings store */ export interface MaestroSettings { - activeThemeId: string; - llmProvider: string; - modelSlug: string; - apiKey: string; - shortcuts: Record; - fontSize: number; - fontFamily: string; - customFonts: string[]; - logLevel: 'debug' | 'info' | 'warn' | 'error'; - defaultShell: string; - webAuthEnabled: boolean; - webAuthToken: string | null; - // SSH Remote configuration - sshRemotes: any[]; - defaultSshRemoteId: string | null; - [key: string]: any; + activeThemeId: string; + llmProvider: string; + modelSlug: string; + apiKey: string; + shortcuts: Record; + fontSize: number; + fontFamily: string; + customFonts: string[]; + logLevel: 'debug' | 'info' | 'warn' | 'error'; + defaultShell: string; + webAuthEnabled: boolean; + webAuthToken: string | null; + // Web interface custom port + webInterfaceUseCustomPort: boolean; + webInterfaceCustomPort: number; + // SSH Remote configuration + sshRemotes: any[]; + defaultSshRemoteId: string | null; + // Unique installation identifier (generated once on first run) + installationId: string | null; + [key: string]: any; } /** * Interface for sessions store */ export interface SessionsData { - sessions: any[]; + sessions: any[]; } /** * Interface for groups store */ export interface GroupsData { - groups: any[]; + groups: any[]; } /** * Dependencies required for persistence handlers */ export interface PersistenceHandlerDependencies { - settingsStore: Store; - sessionsStore: Store; - groupsStore: Store; - getWebServer: () => WebServer | null; + settingsStore: Store; + sessionsStore: Store; + groupsStore: Store; + getWebServer: () => WebServer | null; } /** * Register all persistence-related IPC handlers. */ export function registerPersistenceHandlers(deps: PersistenceHandlerDependencies): void { - const { settingsStore, sessionsStore, groupsStore, getWebServer } = deps; + const { settingsStore, sessionsStore, groupsStore, getWebServer } = deps; - // Settings management - ipcMain.handle('settings:get', async (_, key: string) => { - const value = settingsStore.get(key); - logger.debug(`Settings read: ${key}`, 'Settings', { key, value }); - return value; - }); + // Settings management + ipcMain.handle('settings:get', async (_, key: string) => { + const value = settingsStore.get(key); + logger.debug(`Settings read: ${key}`, 'Settings', { key, value }); + return value; + }); - ipcMain.handle('settings:set', async (_, key: string, value: any) => { - settingsStore.set(key, value); - logger.info(`Settings updated: ${key}`, 'Settings', { key, value }); + ipcMain.handle('settings:set', async (_, key: string, value: any) => { + settingsStore.set(key, value); + logger.info(`Settings updated: ${key}`, 'Settings', { key, value }); - const webServer = getWebServer(); - // Broadcast theme changes to connected web clients - if (key === 'activeThemeId' && webServer && webServer.getWebClientCount() > 0) { - const theme = getThemeById(value); - if (theme) { - webServer.broadcastThemeChange(theme); - logger.info(`Broadcasted theme change to web clients: ${value}`, 'WebServer'); - } - } + const webServer = getWebServer(); + // Broadcast theme changes to connected web clients + if (key === 'activeThemeId' && webServer && webServer.getWebClientCount() > 0) { + const theme = getThemeById(value); + if (theme) { + webServer.broadcastThemeChange(theme); + logger.info(`Broadcasted theme change to web clients: ${value}`, 'WebServer'); + } + } - // Broadcast custom commands changes to connected web clients - if (key === 'customAICommands' && webServer && webServer.getWebClientCount() > 0) { - webServer.broadcastCustomCommands(value); - logger.info(`Broadcasted custom commands change to web clients: ${value.length} commands`, 'WebServer'); - } + // Broadcast custom commands changes to connected web clients + if (key === 'customAICommands' && webServer && webServer.getWebClientCount() > 0) { + webServer.broadcastCustomCommands(value); + logger.info( + `Broadcasted custom commands change to web clients: ${value.length} commands`, + 'WebServer' + ); + } - return true; - }); + return true; + }); - ipcMain.handle('settings:getAll', async () => { - const settings = settingsStore.store; - logger.debug('All settings retrieved', 'Settings', { count: Object.keys(settings).length }); - return settings; - }); + ipcMain.handle('settings:getAll', async () => { + const settings = settingsStore.store; + logger.debug('All settings retrieved', 'Settings', { count: Object.keys(settings).length }); + return settings; + }); - // Sessions persistence - ipcMain.handle('sessions:getAll', async () => { - const sessions = sessionsStore.get('sessions', []); - console.log(`[sessions:getAll] Loaded ${sessions.length} sessions from store path: ${(sessionsStore as any).path}`); - return sessions; - }); + // Sessions persistence + ipcMain.handle('sessions:getAll', async () => { + const sessions = sessionsStore.get('sessions', []); + console.log( + `[sessions:getAll] Loaded ${sessions.length} sessions from store path: ${(sessionsStore as any).path}` + ); + return sessions; + }); - ipcMain.handle('sessions:setAll', async (_, sessions: any[]) => { - // Get previous sessions to detect changes - const previousSessions = sessionsStore.get('sessions', []); - const previousSessionMap = new Map(previousSessions.map((s: any) => [s.id, s])); - const currentSessionMap = new Map(sessions.map((s: any) => [s.id, s])); + ipcMain.handle('sessions:setAll', async (_, sessions: any[]) => { + // Get previous sessions to detect changes + const previousSessions = sessionsStore.get('sessions', []); + const previousSessionMap = new Map(previousSessions.map((s: any) => [s.id, s])); + const currentSessionMap = new Map(sessions.map((s: any) => [s.id, s])); - // Log session lifecycle events at DEBUG level - for (const session of sessions) { - const prevSession = previousSessionMap.get(session.id); - if (!prevSession) { - // New session created - logger.debug('Session created', 'Sessions', { sessionId: session.id, name: session.name, toolType: session.toolType, cwd: session.cwd }); - } - } - for (const prevSession of previousSessions) { - if (!currentSessionMap.has(prevSession.id)) { - // Session destroyed - logger.debug('Session destroyed', 'Sessions', { sessionId: prevSession.id, name: prevSession.name }); - } - } + // Log session lifecycle events at DEBUG level + for (const session of sessions) { + const prevSession = previousSessionMap.get(session.id); + if (!prevSession) { + // New session created + logger.debug('Session created', 'Sessions', { + sessionId: session.id, + name: session.name, + toolType: session.toolType, + cwd: session.cwd, + }); + } + } + for (const prevSession of previousSessions) { + if (!currentSessionMap.has(prevSession.id)) { + // Session destroyed + logger.debug('Session destroyed', 'Sessions', { + sessionId: prevSession.id, + name: prevSession.name, + }); + } + } - const webServer = getWebServer(); - // Detect and broadcast changes to web clients - if (webServer && webServer.getWebClientCount() > 0) { - // Check for state changes in existing sessions - for (const session of sessions) { - const prevSession = previousSessionMap.get(session.id); - if (prevSession) { - // Session exists - check if state or other tracked properties changed - if (prevSession.state !== session.state || - prevSession.inputMode !== session.inputMode || - prevSession.name !== session.name || - prevSession.cwd !== session.cwd || - JSON.stringify(prevSession.cliActivity) !== JSON.stringify(session.cliActivity)) { - webServer.broadcastSessionStateChange(session.id, session.state, { - name: session.name, - toolType: session.toolType, - inputMode: session.inputMode, - cwd: session.cwd, - cliActivity: session.cliActivity, - }); - } - } else { - // New session added - webServer.broadcastSessionAdded({ - id: session.id, - name: session.name, - toolType: session.toolType, - state: session.state, - inputMode: session.inputMode, - cwd: session.cwd, - groupId: session.groupId || null, - groupName: session.groupName || null, - groupEmoji: session.groupEmoji || null, - parentSessionId: session.parentSessionId || null, - worktreeBranch: session.worktreeBranch || null, - }); - } - } + const webServer = getWebServer(); + // Detect and broadcast changes to web clients + if (webServer && webServer.getWebClientCount() > 0) { + // Check for state changes in existing sessions + for (const session of sessions) { + const prevSession = previousSessionMap.get(session.id); + if (prevSession) { + // Session exists - check if state or other tracked properties changed + if ( + prevSession.state !== session.state || + prevSession.inputMode !== session.inputMode || + prevSession.name !== session.name || + prevSession.cwd !== session.cwd || + JSON.stringify(prevSession.cliActivity) !== JSON.stringify(session.cliActivity) + ) { + webServer.broadcastSessionStateChange(session.id, session.state, { + name: session.name, + toolType: session.toolType, + inputMode: session.inputMode, + cwd: session.cwd, + cliActivity: session.cliActivity, + }); + } + } else { + // New session added + webServer.broadcastSessionAdded({ + id: session.id, + name: session.name, + toolType: session.toolType, + state: session.state, + inputMode: session.inputMode, + cwd: session.cwd, + groupId: session.groupId || null, + groupName: session.groupName || null, + groupEmoji: session.groupEmoji || null, + parentSessionId: session.parentSessionId || null, + worktreeBranch: session.worktreeBranch || null, + }); + } + } - // Check for removed sessions - for (const prevSession of previousSessions) { - if (!currentSessionMap.has(prevSession.id)) { - webServer.broadcastSessionRemoved(prevSession.id); - } - } - } + // Check for removed sessions + for (const prevSession of previousSessions) { + if (!currentSessionMap.has(prevSession.id)) { + webServer.broadcastSessionRemoved(prevSession.id); + } + } + } - sessionsStore.set('sessions', sessions); + sessionsStore.set('sessions', sessions); - return true; - }); + return true; + }); - // Groups persistence - ipcMain.handle('groups:getAll', async () => { - return groupsStore.get('groups', []); - }); + // Groups persistence + ipcMain.handle('groups:getAll', async () => { + return groupsStore.get('groups', []); + }); - ipcMain.handle('groups:setAll', async (_, groups: any[]) => { - groupsStore.set('groups', groups); - return true; - }); + ipcMain.handle('groups:setAll', async (_, groups: any[]) => { + groupsStore.set('groups', groups); + return true; + }); - // CLI activity (for detecting when CLI is running playbooks) - ipcMain.handle('cli:getActivity', async () => { - try { - const cliActivityPath = path.join(app.getPath('userData'), 'cli-activity.json'); - const content = await fs.readFile(cliActivityPath, 'utf-8'); - const data = JSON.parse(content); - return data.activities || []; - } catch { - // File doesn't exist or is invalid - return empty array - return []; - } - }); + // CLI activity (for detecting when CLI is running playbooks) + ipcMain.handle('cli:getActivity', async () => { + try { + const cliActivityPath = path.join(app.getPath('userData'), 'cli-activity.json'); + const content = await fs.readFile(cliActivityPath, 'utf-8'); + const data = JSON.parse(content); + return data.activities || []; + } catch { + // File doesn't exist or is invalid - return empty array + return []; + } + }); } diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index 08fc98bf..77aeb4be 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -4,17 +4,22 @@ import * as os from 'os'; import { ProcessManager } from '../../process-manager'; import { AgentDetector } from '../../agent-detector'; import { logger } from '../../utils/logger'; -import { buildAgentArgs, applyAgentConfigOverrides, getContextWindowValue } from '../../utils/agent-args'; import { - withIpcErrorLogging, - requireProcessManager, - requireDependency, - CreateHandlerOptions, + buildAgentArgs, + applyAgentConfigOverrides, + getContextWindowValue, +} from '../../utils/agent-args'; +import { + withIpcErrorLogging, + requireProcessManager, + requireDependency, + CreateHandlerOptions, } from '../../utils/ipcHandler'; import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver'; import { buildSshCommand } from '../../utils/ssh-command-builder'; import type { SshRemoteConfig } from '../../../shared/types'; import { powerManager } from '../../power-manager'; +import { MaestroSettings } from './persistence'; const LOG_CONTEXT = '[ProcessManager]'; @@ -22,44 +27,30 @@ const LOG_CONTEXT = '[ProcessManager]'; * Helper to create handler options with consistent context */ const handlerOpts = ( - operation: string, - extra?: Partial + operation: string, + extra?: Partial ): Pick => ({ - context: LOG_CONTEXT, - operation, - ...extra, + context: LOG_CONTEXT, + operation, + ...extra, }); /** * Interface for agent configuration store data */ interface AgentConfigsData { - configs: Record>; -} - -/** - * Interface for Maestro settings store - */ -interface MaestroSettings { - defaultShell: string; - customShellPath?: string; // Custom path to shell binary (overrides auto-detected path) - shellArgs?: string; // Additional CLI arguments for shell sessions - shellEnvVars?: Record; // Environment variables for shell sessions - // SSH remote execution - sshRemotes: SshRemoteConfig[]; - defaultSshRemoteId: string | null; - [key: string]: any; + configs: Record>; } /** * Dependencies required for process handler registration */ export interface ProcessHandlerDependencies { - getProcessManager: () => ProcessManager | null; - getAgentDetector: () => AgentDetector | null; - agentConfigsStore: Store; - settingsStore: Store; - getMainWindow: () => BrowserWindow | null; + getProcessManager: () => ProcessManager | null; + getAgentDetector: () => AgentDetector | null; + agentConfigsStore: Store; + settingsStore: Store; + getMainWindow: () => BrowserWindow | null; } /** @@ -75,435 +66,478 @@ export interface ProcessHandlerDependencies { * - runCommand: Execute a single command and capture output */ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void { - const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow } = deps; + const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow } = + deps; - // Spawn a new process for a session - // Supports agent-specific argument builders for batch mode, JSON output, resume, read-only mode, YOLO mode - ipcMain.handle( - 'process:spawn', - withIpcErrorLogging(handlerOpts('spawn'), async (config: { - sessionId: string; - toolType: string; - cwd: string; - command: string; - args: string[]; - prompt?: string; - shell?: string; - images?: string[]; // Base64 data URLs for images - // Agent-specific spawn options (used to build args via agent config) - agentSessionId?: string; // For session resume - readOnlyMode?: boolean; // For read-only/plan mode - modelId?: string; // For model selection - yoloMode?: boolean; // For YOLO/full-access mode (bypasses confirmations) - // Per-session overrides (take precedence over agent-level config) - sessionCustomPath?: string; // Session-specific custom path - sessionCustomArgs?: string; // Session-specific custom args - sessionCustomEnvVars?: Record; // Session-specific env vars - sessionCustomModel?: string; // Session-specific model selection - sessionCustomContextWindow?: number; // Session-specific context window size - // Per-session SSH remote config (takes precedence over agent-level SSH config) - sessionSshRemoteConfig?: { - enabled: boolean; - remoteId: string | null; - workingDirOverride?: string; - }; - // Stats tracking options - querySource?: 'user' | 'auto'; // Whether this query is user-initiated or from Auto Run - tabId?: string; // Tab ID for multi-tab tracking - }) => { - const processManager = requireProcessManager(getProcessManager); - const agentDetector = requireDependency(getAgentDetector, 'Agent detector'); + // Spawn a new process for a session + // Supports agent-specific argument builders for batch mode, JSON output, resume, read-only mode, YOLO mode + ipcMain.handle( + 'process:spawn', + withIpcErrorLogging( + handlerOpts('spawn'), + async (config: { + sessionId: string; + toolType: string; + cwd: string; + command: string; + args: string[]; + prompt?: string; + shell?: string; + images?: string[]; // Base64 data URLs for images + // Agent-specific spawn options (used to build args via agent config) + agentSessionId?: string; // For session resume + readOnlyMode?: boolean; // For read-only/plan mode + modelId?: string; // For model selection + yoloMode?: boolean; // For YOLO/full-access mode (bypasses confirmations) + // Per-session overrides (take precedence over agent-level config) + sessionCustomPath?: string; // Session-specific custom path + sessionCustomArgs?: string; // Session-specific custom args + sessionCustomEnvVars?: Record; // Session-specific env vars + sessionCustomModel?: string; // Session-specific model selection + sessionCustomContextWindow?: number; // Session-specific context window size + // Per-session SSH remote config (takes precedence over agent-level SSH config) + sessionSshRemoteConfig?: { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; + }; + // Stats tracking options + querySource?: 'user' | 'auto'; // Whether this query is user-initiated or from Auto Run + tabId?: string; // Tab ID for multi-tab tracking + }) => { + const processManager = requireProcessManager(getProcessManager); + const agentDetector = requireDependency(getAgentDetector, 'Agent detector'); - // Get agent definition to access config options and argument builders - const agent = await agentDetector.getAgent(config.toolType); - // Use INFO level on Windows for better visibility in logs - const isWindows = process.platform === 'win32'; - const logFn = isWindows ? logger.info.bind(logger) : logger.debug.bind(logger); - logFn(`Spawn config received`, LOG_CONTEXT, { - platform: process.platform, - configToolType: config.toolType, - configCommand: config.command, - agentId: agent?.id, - agentCommand: agent?.command, - agentPath: agent?.path, - agentPathExtension: agent?.path ? require('path').extname(agent.path) : 'none', - hasAgentSessionId: !!config.agentSessionId, - hasPrompt: !!config.prompt, - promptLength: config.prompt?.length, - // On Windows, show prompt preview to help debug truncation issues - promptPreview: config.prompt && isWindows ? { - first50: config.prompt.substring(0, 50), - last50: config.prompt.substring(Math.max(0, config.prompt.length - 50)), - containsHash: config.prompt.includes('#'), - containsNewline: config.prompt.includes('\n'), - } : undefined, - }); - let finalArgs = buildAgentArgs(agent, { - baseArgs: config.args, - prompt: config.prompt, - cwd: config.cwd, - readOnlyMode: config.readOnlyMode, - modelId: config.modelId, - yoloMode: config.yoloMode, - agentSessionId: config.agentSessionId, - }); + // Get agent definition to access config options and argument builders + const agent = await agentDetector.getAgent(config.toolType); + // Use INFO level on Windows for better visibility in logs + const isWindows = process.platform === 'win32'; + const logFn = isWindows ? logger.info.bind(logger) : logger.debug.bind(logger); + logFn(`Spawn config received`, LOG_CONTEXT, { + platform: process.platform, + configToolType: config.toolType, + configCommand: config.command, + agentId: agent?.id, + agentCommand: agent?.command, + agentPath: agent?.path, + agentPathExtension: agent?.path ? require('path').extname(agent.path) : 'none', + hasAgentSessionId: !!config.agentSessionId, + hasPrompt: !!config.prompt, + promptLength: config.prompt?.length, + // On Windows, show prompt preview to help debug truncation issues + promptPreview: + config.prompt && isWindows + ? { + first50: config.prompt.substring(0, 50), + last50: config.prompt.substring(Math.max(0, config.prompt.length - 50)), + containsHash: config.prompt.includes('#'), + containsNewline: config.prompt.includes('\n'), + } + : undefined, + }); + let finalArgs = buildAgentArgs(agent, { + baseArgs: config.args, + prompt: config.prompt, + cwd: config.cwd, + readOnlyMode: config.readOnlyMode, + modelId: config.modelId, + yoloMode: config.yoloMode, + agentSessionId: config.agentSessionId, + }); - // ======================================================================== - // Apply agent config options and session overrides - // Session-level overrides take precedence over agent-level config - // ======================================================================== - const allConfigs = agentConfigsStore.get('configs', {}); - const agentConfigValues = allConfigs[config.toolType] || {}; - const configResolution = applyAgentConfigOverrides(agent, finalArgs, { - agentConfigValues, - sessionCustomModel: config.sessionCustomModel, - sessionCustomArgs: config.sessionCustomArgs, - sessionCustomEnvVars: config.sessionCustomEnvVars, - }); - finalArgs = configResolution.args; + // ======================================================================== + // Apply agent config options and session overrides + // Session-level overrides take precedence over agent-level config + // ======================================================================== + const allConfigs = agentConfigsStore.get('configs', {}); + const agentConfigValues = allConfigs[config.toolType] || {}; + const configResolution = applyAgentConfigOverrides(agent, finalArgs, { + agentConfigValues, + sessionCustomModel: config.sessionCustomModel, + sessionCustomArgs: config.sessionCustomArgs, + sessionCustomEnvVars: config.sessionCustomEnvVars, + }); + finalArgs = configResolution.args; - if (configResolution.modelSource === 'session' && config.sessionCustomModel) { - logger.debug(`Using session-level model for ${config.toolType}`, LOG_CONTEXT, { model: config.sessionCustomModel }); - } + if (configResolution.modelSource === 'session' && config.sessionCustomModel) { + logger.debug(`Using session-level model for ${config.toolType}`, LOG_CONTEXT, { + model: config.sessionCustomModel, + }); + } - if (configResolution.customArgsSource !== 'none') { - logger.debug(`Appending custom args for ${config.toolType} (${configResolution.customArgsSource}-level)`, LOG_CONTEXT); - } + if (configResolution.customArgsSource !== 'none') { + logger.debug( + `Appending custom args for ${config.toolType} (${configResolution.customArgsSource}-level)`, + LOG_CONTEXT + ); + } - const effectiveCustomEnvVars = configResolution.effectiveCustomEnvVars; - if (configResolution.customEnvSource !== 'none' && effectiveCustomEnvVars) { - logger.debug(`Custom env vars configured for ${config.toolType} (${configResolution.customEnvSource}-level)`, LOG_CONTEXT, { keys: Object.keys(effectiveCustomEnvVars) }); - } + const effectiveCustomEnvVars = configResolution.effectiveCustomEnvVars; + if (configResolution.customEnvSource !== 'none' && effectiveCustomEnvVars) { + logger.debug( + `Custom env vars configured for ${config.toolType} (${configResolution.customEnvSource}-level)`, + LOG_CONTEXT, + { keys: Object.keys(effectiveCustomEnvVars) } + ); + } - // If no shell is specified and this is a terminal session, use the default shell from settings - // For terminal sessions, we also load custom shell path, args, and env vars - let shellToUse = config.shell || (config.toolType === 'terminal' ? settingsStore.get('defaultShell', 'zsh') : undefined); - let shellArgsStr: string | undefined; - let shellEnvVars: Record | undefined; + // If no shell is specified and this is a terminal session, use the default shell from settings + // For terminal sessions, we also load custom shell path, args, and env vars + let shellToUse = + config.shell || + (config.toolType === 'terminal' ? settingsStore.get('defaultShell', 'zsh') : undefined); + let shellArgsStr: string | undefined; + let shellEnvVars: Record | undefined; - if (config.toolType === 'terminal') { - // Custom shell path overrides the detected/selected shell path - const customShellPath = settingsStore.get('customShellPath', ''); - if (customShellPath && customShellPath.trim()) { - shellToUse = customShellPath.trim(); - logger.debug('Using custom shell path for terminal', LOG_CONTEXT, { customShellPath }); - } - // Load additional shell args and env vars - shellArgsStr = settingsStore.get('shellArgs', ''); - shellEnvVars = settingsStore.get('shellEnvVars', {}) as Record; - } + if (config.toolType === 'terminal') { + // Custom shell path overrides the detected/selected shell path + const customShellPath = settingsStore.get('customShellPath', ''); + if (customShellPath && customShellPath.trim()) { + shellToUse = customShellPath.trim(); + logger.debug('Using custom shell path for terminal', LOG_CONTEXT, { customShellPath }); + } + // Load additional shell args and env vars + shellArgsStr = settingsStore.get('shellArgs', ''); + shellEnvVars = settingsStore.get('shellEnvVars', {}) as Record; + } - // Extract session ID from args for logging (supports both --resume and --session flags) - const resumeArgIndex = finalArgs.indexOf('--resume'); - const sessionArgIndex = finalArgs.indexOf('--session'); - const agentSessionId = resumeArgIndex !== -1 - ? finalArgs[resumeArgIndex + 1] - : sessionArgIndex !== -1 - ? finalArgs[sessionArgIndex + 1] - : config.agentSessionId; + // Extract session ID from args for logging (supports both --resume and --session flags) + const resumeArgIndex = finalArgs.indexOf('--resume'); + const sessionArgIndex = finalArgs.indexOf('--session'); + const agentSessionId = + resumeArgIndex !== -1 + ? finalArgs[resumeArgIndex + 1] + : sessionArgIndex !== -1 + ? finalArgs[sessionArgIndex + 1] + : config.agentSessionId; - logger.info(`Spawning process: ${config.command}`, LOG_CONTEXT, { - sessionId: config.sessionId, - toolType: config.toolType, - cwd: config.cwd, - command: config.command, - fullCommand: `${config.command} ${finalArgs.join(' ')}`, - args: finalArgs, - requiresPty: agent?.requiresPty || false, - shell: shellToUse, - ...(agentSessionId && { agentSessionId }), - ...(config.readOnlyMode && { readOnlyMode: true }), - ...(config.yoloMode && { yoloMode: true }), - ...(config.modelId && { modelId: config.modelId }), - ...(config.prompt && { prompt: config.prompt.length > 500 ? config.prompt.substring(0, 500) + '...' : config.prompt }) - }); + logger.info(`Spawning process: ${config.command}`, LOG_CONTEXT, { + sessionId: config.sessionId, + toolType: config.toolType, + cwd: config.cwd, + command: config.command, + fullCommand: `${config.command} ${finalArgs.join(' ')}`, + args: finalArgs, + requiresPty: agent?.requiresPty || false, + shell: shellToUse, + ...(agentSessionId && { agentSessionId }), + ...(config.readOnlyMode && { readOnlyMode: true }), + ...(config.yoloMode && { yoloMode: true }), + ...(config.modelId && { modelId: config.modelId }), + ...(config.prompt && { + prompt: + config.prompt.length > 500 ? config.prompt.substring(0, 500) + '...' : config.prompt, + }), + }); - // Get contextWindow: session-level override takes priority over agent-level config - // Falls back to the agent's configOptions default (e.g., 400000 for Codex, 128000 for OpenCode) - const contextWindow = getContextWindowValue(agent, agentConfigValues, config.sessionCustomContextWindow); + // Get contextWindow: session-level override takes priority over agent-level config + // Falls back to the agent's configOptions default (e.g., 400000 for Codex, 128000 for OpenCode) + const contextWindow = getContextWindowValue( + agent, + agentConfigValues, + config.sessionCustomContextWindow + ); - // ======================================================================== - // Command Resolution: Apply session-level custom path override if set - // This allows users to override the detected agent path per-session - // ======================================================================== - let commandToSpawn = config.sessionCustomPath || config.command; - let argsToSpawn = finalArgs; + // ======================================================================== + // Command Resolution: Apply session-level custom path override if set + // This allows users to override the detected agent path per-session + // ======================================================================== + let commandToSpawn = config.sessionCustomPath || config.command; + let argsToSpawn = finalArgs; - if (config.sessionCustomPath) { - logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, { - customPath: config.sessionCustomPath, - originalCommand: config.command, - }); - } + if (config.sessionCustomPath) { + logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, { + customPath: config.sessionCustomPath, + originalCommand: config.command, + }); + } - // ======================================================================== - // SSH Remote Execution: Detect and wrap command for remote execution - // Terminal sessions are always local (they need PTY for shell interaction) - // ======================================================================== - let sshRemoteUsed: SshRemoteConfig | null = null; + // ======================================================================== + // SSH Remote Execution: Detect and wrap command for remote execution + // Terminal sessions are always local (they need PTY for shell interaction) + // ======================================================================== + let sshRemoteUsed: SshRemoteConfig | null = null; - // Only consider SSH remote for non-terminal AI agent sessions - // SSH is session-level ONLY - no agent-level or global defaults - if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig) { - // Session-level SSH config provided - resolve and use it - logger.debug(`Using session-level SSH config`, LOG_CONTEXT, { - sessionId: config.sessionId, - enabled: config.sessionSshRemoteConfig.enabled, - remoteId: config.sessionSshRemoteConfig.remoteId, - }); + // Only consider SSH remote for non-terminal AI agent sessions + // SSH is session-level ONLY - no agent-level or global defaults + if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig) { + // Session-level SSH config provided - resolve and use it + logger.debug(`Using session-level SSH config`, LOG_CONTEXT, { + sessionId: config.sessionId, + enabled: config.sessionSshRemoteConfig.enabled, + remoteId: config.sessionSshRemoteConfig.remoteId, + }); - // Resolve effective SSH remote configuration - const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore); - const sshResult = getSshRemoteConfig(sshStoreAdapter, { - sessionSshConfig: config.sessionSshRemoteConfig, - }); + // Resolve effective SSH remote configuration + const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore); + const sshResult = getSshRemoteConfig(sshStoreAdapter, { + sessionSshConfig: config.sessionSshRemoteConfig, + }); - if (sshResult.config) { - // SSH remote is configured - wrap the command for remote execution - sshRemoteUsed = sshResult.config; + if (sshResult.config) { + // SSH remote is configured - wrap the command for remote execution + sshRemoteUsed = sshResult.config; - // For SSH execution, we need to include the prompt in the args here - // because ProcessManager.spawn() won't add it (we pass prompt: undefined for SSH) - // Use promptArgs if available (e.g., OpenCode -p), otherwise use positional arg - let sshArgs = finalArgs; - if (config.prompt) { - if (agent?.promptArgs) { - sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)]; - } else if (agent?.noPromptSeparator) { - sshArgs = [...finalArgs, config.prompt]; - } else { - sshArgs = [...finalArgs, '--', config.prompt]; - } - } + // For SSH execution, we need to include the prompt in the args here + // because ProcessManager.spawn() won't add it (we pass prompt: undefined for SSH) + // Use promptArgs if available (e.g., OpenCode -p), otherwise use positional arg + let sshArgs = finalArgs; + if (config.prompt) { + if (agent?.promptArgs) { + sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)]; + } else if (agent?.noPromptSeparator) { + sshArgs = [...finalArgs, config.prompt]; + } else { + sshArgs = [...finalArgs, '--', config.prompt]; + } + } - // Build the SSH command that wraps the agent execution - // - // 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; - const sshCommand = await buildSshCommand(sshResult.config, { - command: remoteCommand, - args: sshArgs, - // Use the cwd from config - this is the project directory on the remote - cwd: config.cwd, - // Pass custom environment variables to the remote command - env: effectiveCustomEnvVars, - }); + // 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; + const sshCommand = await buildSshCommand(sshResult.config, { + command: remoteCommand, + args: sshArgs, + // Use the cwd from config - this is the project directory on the remote + cwd: config.cwd, + // Pass custom environment variables to the remote command + env: effectiveCustomEnvVars, + }); - commandToSpawn = sshCommand.command; - argsToSpawn = sshCommand.args; + commandToSpawn = sshCommand.command; + argsToSpawn = sshCommand.args; - logger.info(`SSH remote execution configured`, LOG_CONTEXT, { - sessionId: config.sessionId, - toolType: config.toolType, - remoteName: sshResult.config.name, - remoteHost: sshResult.config.host, - source: sshResult.source, - localCommand: config.command, - remoteCommand: remoteCommand, - customPath: config.sessionCustomPath || null, - hasCustomEnvVars: !!effectiveCustomEnvVars && Object.keys(effectiveCustomEnvVars).length > 0, - sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`, - }); - } - } + logger.info(`SSH remote execution configured`, LOG_CONTEXT, { + sessionId: config.sessionId, + toolType: config.toolType, + remoteName: sshResult.config.name, + remoteHost: sshResult.config.host, + source: sshResult.source, + localCommand: config.command, + remoteCommand: remoteCommand, + customPath: config.sessionCustomPath || null, + hasCustomEnvVars: + !!effectiveCustomEnvVars && Object.keys(effectiveCustomEnvVars).length > 0, + sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`, + }); + } + } - const result = processManager.spawn({ - ...config, - command: commandToSpawn, - args: argsToSpawn, - // When using SSH, use user's home directory as local cwd - // The remote working directory is embedded in the SSH command itself - // This fixes ENOENT errors when session.cwd is a remote-only path - cwd: sshRemoteUsed ? os.homedir() : config.cwd, - // When using SSH, disable PTY (SSH provides its own terminal handling) - // and env vars are passed via the remote command string - requiresPty: sshRemoteUsed ? false : agent?.requiresPty, - // When using SSH, the prompt was already added to sshArgs above before - // building the SSH command, so don't let ProcessManager add it again - prompt: sshRemoteUsed ? undefined : config.prompt, - shell: shellToUse, - shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions) - shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions) - contextWindow, // Pass configured context window to process manager - // When using SSH, env vars are passed in the remote command string, not locally - customEnvVars: sshRemoteUsed ? undefined : effectiveCustomEnvVars, - imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode) - promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode) - noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt - // Stats tracking: use cwd as projectPath if not explicitly provided - projectPath: config.cwd, - // SSH remote context (for SSH-specific error messages) - sshRemoteId: sshRemoteUsed?.id, - sshRemoteHost: sshRemoteUsed?.host, - }); + const result = processManager.spawn({ + ...config, + command: commandToSpawn, + args: argsToSpawn, + // When using SSH, use user's home directory as local cwd + // The remote working directory is embedded in the SSH command itself + // This fixes ENOENT errors when session.cwd is a remote-only path + cwd: sshRemoteUsed ? os.homedir() : config.cwd, + // When using SSH, disable PTY (SSH provides its own terminal handling) + // and env vars are passed via the remote command string + requiresPty: sshRemoteUsed ? false : agent?.requiresPty, + // When using SSH, the prompt was already added to sshArgs above before + // building the SSH command, so don't let ProcessManager add it again + prompt: sshRemoteUsed ? undefined : config.prompt, + shell: shellToUse, + shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions) + shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions) + contextWindow, // Pass configured context window to process manager + // When using SSH, env vars are passed in the remote command string, not locally + customEnvVars: sshRemoteUsed ? undefined : effectiveCustomEnvVars, + imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode) + promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode) + noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt + // Stats tracking: use cwd as projectPath if not explicitly provided + projectPath: config.cwd, + // SSH remote context (for SSH-specific error messages) + sshRemoteId: sshRemoteUsed?.id, + sshRemoteHost: sshRemoteUsed?.host, + }); - logger.info(`Process spawned successfully`, LOG_CONTEXT, { - sessionId: config.sessionId, - pid: result.pid, - ...(sshRemoteUsed && { sshRemoteId: sshRemoteUsed.id, sshRemoteName: sshRemoteUsed.name }) - }); + logger.info(`Process spawned successfully`, LOG_CONTEXT, { + sessionId: config.sessionId, + pid: result.pid, + ...(sshRemoteUsed && { + sshRemoteId: sshRemoteUsed.id, + sshRemoteName: sshRemoteUsed.name, + }), + }); - // Add power block reason for AI sessions (not terminals) - // This prevents system sleep while AI is processing - if (config.toolType !== 'terminal') { - powerManager.addBlockReason(`session:${config.sessionId}`); - } + // Add power block reason for AI sessions (not terminals) + // This prevents system sleep while AI is processing + if (config.toolType !== 'terminal') { + powerManager.addBlockReason(`session:${config.sessionId}`); + } - // Emit SSH remote status event for renderer to update session state - // This is emitted for all spawns (sshRemote will be null for local execution) - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - const sshRemoteInfo = sshRemoteUsed ? { - id: sshRemoteUsed.id, - name: sshRemoteUsed.name, - host: sshRemoteUsed.host, - } : null; - mainWindow.webContents.send('process:ssh-remote', config.sessionId, sshRemoteInfo); - } + // Emit SSH remote status event for renderer to update session state + // This is emitted for all spawns (sshRemote will be null for local execution) + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + const sshRemoteInfo = sshRemoteUsed + ? { + id: sshRemoteUsed.id, + name: sshRemoteUsed.name, + host: sshRemoteUsed.host, + } + : null; + mainWindow.webContents.send('process:ssh-remote', config.sessionId, sshRemoteInfo); + } - // Return spawn result with SSH remote info if used - return { - ...result, - sshRemote: sshRemoteUsed ? { - id: sshRemoteUsed.id, - name: sshRemoteUsed.name, - host: sshRemoteUsed.host, - } : undefined, - }; - }) - ); + // Return spawn result with SSH remote info if used + return { + ...result, + sshRemote: sshRemoteUsed + ? { + id: sshRemoteUsed.id, + name: sshRemoteUsed.name, + host: sshRemoteUsed.host, + } + : undefined, + }; + } + ) + ); - // Write data to a process - ipcMain.handle( - 'process:write', - withIpcErrorLogging(handlerOpts('write'), async (sessionId: string, data: string) => { - const processManager = requireProcessManager(getProcessManager); - logger.debug(`Writing to process: ${sessionId}`, LOG_CONTEXT, { sessionId, dataLength: data.length }); - return processManager.write(sessionId, data); - }) - ); + // Write data to a process + ipcMain.handle( + 'process:write', + withIpcErrorLogging(handlerOpts('write'), async (sessionId: string, data: string) => { + const processManager = requireProcessManager(getProcessManager); + logger.debug(`Writing to process: ${sessionId}`, LOG_CONTEXT, { + sessionId, + dataLength: data.length, + }); + return processManager.write(sessionId, data); + }) + ); - // Send SIGINT to a process - ipcMain.handle( - 'process:interrupt', - withIpcErrorLogging(handlerOpts('interrupt'), async (sessionId: string) => { - const processManager = requireProcessManager(getProcessManager); - logger.info(`Interrupting process: ${sessionId}`, LOG_CONTEXT, { sessionId }); - return processManager.interrupt(sessionId); - }) - ); + // Send SIGINT to a process + ipcMain.handle( + 'process:interrupt', + withIpcErrorLogging(handlerOpts('interrupt'), async (sessionId: string) => { + const processManager = requireProcessManager(getProcessManager); + logger.info(`Interrupting process: ${sessionId}`, LOG_CONTEXT, { sessionId }); + return processManager.interrupt(sessionId); + }) + ); - // Kill a process - ipcMain.handle( - 'process:kill', - withIpcErrorLogging(handlerOpts('kill'), async (sessionId: string) => { - const processManager = requireProcessManager(getProcessManager); - logger.info(`Killing process: ${sessionId}`, LOG_CONTEXT, { sessionId }); - return processManager.kill(sessionId); - }) - ); + // Kill a process + ipcMain.handle( + 'process:kill', + withIpcErrorLogging(handlerOpts('kill'), async (sessionId: string) => { + const processManager = requireProcessManager(getProcessManager); + logger.info(`Killing process: ${sessionId}`, LOG_CONTEXT, { sessionId }); + return processManager.kill(sessionId); + }) + ); - // Resize PTY dimensions - ipcMain.handle( - 'process:resize', - withIpcErrorLogging(handlerOpts('resize'), async (sessionId: string, cols: number, rows: number) => { - const processManager = requireProcessManager(getProcessManager); - return processManager.resize(sessionId, cols, rows); - }) - ); + // Resize PTY dimensions + ipcMain.handle( + 'process:resize', + withIpcErrorLogging( + handlerOpts('resize'), + async (sessionId: string, cols: number, rows: number) => { + const processManager = requireProcessManager(getProcessManager); + return processManager.resize(sessionId, cols, rows); + } + ) + ); - // Get all active processes managed by the ProcessManager - ipcMain.handle( - 'process:getActiveProcesses', - withIpcErrorLogging(handlerOpts('getActiveProcesses'), async () => { - const processManager = requireProcessManager(getProcessManager); - const processes = processManager.getAll(); - // Return serializable process info (exclude non-serializable PTY/child process objects) - return processes.map(p => ({ - sessionId: p.sessionId, - toolType: p.toolType, - pid: p.pid, - cwd: p.cwd, - isTerminal: p.isTerminal, - isBatchMode: p.isBatchMode || false, - startTime: p.startTime, - command: p.command, - args: p.args, - })); - }) - ); + // Get all active processes managed by the ProcessManager + ipcMain.handle( + 'process:getActiveProcesses', + withIpcErrorLogging(handlerOpts('getActiveProcesses'), async () => { + const processManager = requireProcessManager(getProcessManager); + const processes = processManager.getAll(); + // Return serializable process info (exclude non-serializable PTY/child process objects) + return processes.map((p) => ({ + sessionId: p.sessionId, + toolType: p.toolType, + pid: p.pid, + cwd: p.cwd, + isTerminal: p.isTerminal, + isBatchMode: p.isBatchMode || false, + startTime: p.startTime, + command: p.command, + args: p.args, + })); + }) + ); - // Run a single command and capture only stdout/stderr (no PTY echo/prompts) - // Supports SSH remote execution when sessionSshRemoteConfig is provided - ipcMain.handle( - 'process:runCommand', - withIpcErrorLogging(handlerOpts('runCommand'), async (config: { - sessionId: string; - command: string; - cwd: string; - shell?: string; - // Per-session SSH remote config (same as process:spawn) - sessionSshRemoteConfig?: { - enabled: boolean; - remoteId: string | null; - workingDirOverride?: string; - }; - }) => { - const processManager = requireProcessManager(getProcessManager); + // Run a single command and capture only stdout/stderr (no PTY echo/prompts) + // Supports SSH remote execution when sessionSshRemoteConfig is provided + ipcMain.handle( + 'process:runCommand', + withIpcErrorLogging( + handlerOpts('runCommand'), + async (config: { + sessionId: string; + command: string; + cwd: string; + shell?: string; + // Per-session SSH remote config (same as process:spawn) + sessionSshRemoteConfig?: { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; + }; + }) => { + const processManager = requireProcessManager(getProcessManager); - // Get the shell from settings if not provided - // Custom shell path takes precedence over the selected shell ID - let shell = config.shell || settingsStore.get('defaultShell', 'zsh'); - const customShellPath = settingsStore.get('customShellPath', ''); - if (customShellPath && customShellPath.trim()) { - shell = customShellPath.trim(); - } + // Get the shell from settings if not provided + // Custom shell path takes precedence over the selected shell ID + let shell = config.shell || settingsStore.get('defaultShell', 'zsh'); + const customShellPath = settingsStore.get('customShellPath', ''); + if (customShellPath && customShellPath.trim()) { + shell = customShellPath.trim(); + } - // Get shell env vars for passing to runCommand - const shellEnvVars = settingsStore.get('shellEnvVars', {}) as Record; + // Get shell env vars for passing to runCommand + const shellEnvVars = settingsStore.get('shellEnvVars', {}) as Record; - // ======================================================================== - // SSH Remote Execution: Resolve SSH config if provided - // ======================================================================== - let sshRemoteConfig: SshRemoteConfig | null = null; + // ======================================================================== + // SSH Remote Execution: Resolve SSH config if provided + // ======================================================================== + let sshRemoteConfig: SshRemoteConfig | null = null; - if (config.sessionSshRemoteConfig?.enabled && config.sessionSshRemoteConfig?.remoteId) { - const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore); - const sshResult = getSshRemoteConfig(sshStoreAdapter, { - sessionSshConfig: config.sessionSshRemoteConfig, - }); + if (config.sessionSshRemoteConfig?.enabled && config.sessionSshRemoteConfig?.remoteId) { + const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore); + const sshResult = getSshRemoteConfig(sshStoreAdapter, { + sessionSshConfig: config.sessionSshRemoteConfig, + }); - if (sshResult.config) { - sshRemoteConfig = sshResult.config; - logger.info(`Terminal command will execute via SSH`, LOG_CONTEXT, { - sessionId: config.sessionId, - remoteName: sshResult.config.name, - remoteHost: sshResult.config.host, - source: sshResult.source, - }); - } - } + if (sshResult.config) { + sshRemoteConfig = sshResult.config; + logger.info(`Terminal command will execute via SSH`, LOG_CONTEXT, { + sessionId: config.sessionId, + remoteName: sshResult.config.name, + remoteHost: sshResult.config.host, + source: sshResult.source, + }); + } + } - logger.debug(`Running command: ${config.command}`, LOG_CONTEXT, { - sessionId: config.sessionId, - cwd: config.cwd, - shell, - hasCustomEnvVars: Object.keys(shellEnvVars).length > 0, - sshRemote: sshRemoteConfig?.name || null, - }); + logger.debug(`Running command: ${config.command}`, LOG_CONTEXT, { + sessionId: config.sessionId, + cwd: config.cwd, + shell, + hasCustomEnvVars: Object.keys(shellEnvVars).length > 0, + sshRemote: sshRemoteConfig?.name || null, + }); - return processManager.runCommand( - config.sessionId, - config.command, - config.cwd, - shell, - shellEnvVars, - sshRemoteConfig - ); - }) - ); + return processManager.runCommand( + config.sessionId, + config.command, + config.cwd, + shell, + shellEnvVars, + sshRemoteConfig + ); + } + ) + ); } diff --git a/src/main/ipc/handlers/system.ts b/src/main/ipc/handlers/system.ts index a83865e8..ee8ef4d3 100644 --- a/src/main/ipc/handlers/system.ts +++ b/src/main/ipc/handlers/system.ts @@ -27,500 +27,531 @@ import { checkForUpdates } from '../../update-checker'; import { setAllowPrerelease } from '../../auto-updater'; import { WebServer } from '../../web-server'; import { powerManager } from '../../power-manager'; +import { MaestroSettings } from './persistence'; // Type for tunnel manager instance type TunnelManagerType = typeof tunnelManagerInstance; -/** - * Interface for Maestro settings store (subset needed for system handlers) - */ -interface MaestroSettings { - logLevel: 'debug' | 'info' | 'warn' | 'error'; - maxLogBuffer?: number; - [key: string]: any; -} - /** * Interface for bootstrap settings (custom storage location) */ interface BootstrapSettings { - customSyncPath?: string; - iCloudSyncEnabled?: boolean; // Legacy - kept for backwards compatibility + customSyncPath?: string; + iCloudSyncEnabled?: boolean; // Legacy - kept for backwards compatibility } /** * Dependencies required for system handlers */ export interface SystemHandlerDependencies { - getMainWindow: () => BrowserWindow | null; - app: App; - settingsStore: Store; - tunnelManager: TunnelManagerType; - getWebServer: () => WebServer | null; - bootstrapStore?: Store; + getMainWindow: () => BrowserWindow | null; + app: App; + settingsStore: Store; + tunnelManager: TunnelManagerType; + getWebServer: () => WebServer | null; + bootstrapStore?: Store; } /** * Register all system-related IPC handlers. */ export function registerSystemHandlers(deps: SystemHandlerDependencies): void { - const { getMainWindow, app, settingsStore, tunnelManager, getWebServer } = deps; - - // ============ Dialog Handlers ============ - - // Folder selection dialog - ipcMain.handle('dialog:selectFolder', async () => { - const mainWindow = getMainWindow(); - if (!mainWindow) return null; - - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openDirectory', 'createDirectory'], - title: 'Select Working Directory', - }); - - if (result.canceled || result.filePaths.length === 0) { - return null; - } - - return result.filePaths[0]; - }); - - // File save dialog - ipcMain.handle( - 'dialog:saveFile', - async ( - _event, - options: { - defaultPath?: string; - filters?: Array<{ name: string; extensions: string[] }>; - title?: string; - } - ) => { - const mainWindow = getMainWindow(); - if (!mainWindow) return null; - - const result = await dialog.showSaveDialog(mainWindow, { - defaultPath: options.defaultPath, - filters: options.filters, - title: options.title ?? 'Save File', - }); - - if (result.canceled || !result.filePath) { - return null; - } - - return result.filePath; - } - ); - - // ============ Font Detection Handlers ============ - - // Font detection - ipcMain.handle('fonts:detect', async () => { - try { - // Use fc-list on all platforms (faster than system_profiler on macOS) - // macOS: 0.74s (was 8.77s with system_profiler) - 11.9x faster - // Linux/Windows: 0.5-0.6s - const result = await execFileNoThrow('fc-list', [':', 'family']); - - if (result.exitCode === 0 && result.stdout) { - // Parse font list and deduplicate - const fonts = result.stdout - .split('\n') - .filter(Boolean) - .map((line: string) => line.trim()) - .filter(font => font.length > 0); - - // Deduplicate fonts (fc-list can return duplicates) - return [...new Set(fonts)]; - } - - // Fallback if fc-list not available (rare on modern systems) - return ['Monaco', 'Menlo', 'Courier New', 'Consolas', 'Roboto Mono', 'Fira Code', 'JetBrains Mono']; - } catch (error) { - console.error('Font detection error:', error); - // Return common monospace fonts as fallback - return ['Monaco', 'Menlo', 'Courier New', 'Consolas', 'Roboto Mono', 'Fira Code', 'JetBrains Mono']; - } - }); - - // ============ Shell Detection Handlers ============ - - // Shell detection - ipcMain.handle('shells:detect', async () => { - try { - logger.info('Detecting available shells', 'ShellDetector'); - const shells = await detectShells(); - logger.info(`Detected ${shells.filter(s => s.available).length} available shells`, 'ShellDetector', { - shells: shells.filter(s => s.available).map(s => s.id) - }); - return shells; - } catch (error) { - logger.error('Shell detection error', 'ShellDetector', error); - // Return default shell list with all marked as unavailable - return [ - { id: 'zsh', name: 'Zsh', available: false }, - { id: 'bash', name: 'Bash', available: false }, - { id: 'sh', name: 'Bourne Shell (sh)', available: false }, - { id: 'fish', name: 'Fish', available: false }, - { id: 'tcsh', name: 'Tcsh', available: false }, - ]; - } - }); - - // Shell operations - open external URLs - ipcMain.handle('shell:openExternal', async (_event, url: string) => { - // Validate URL before opening - Fixes MAESTRO-1S - if (!url || typeof url !== 'string') { - throw new Error('Invalid URL: URL must be a non-empty string'); - } - try { - new URL(url); - } catch { - throw new Error(`Invalid URL: ${url}`); - } - await shell.openExternal(url); - }); - - // Shell operations - move item to system trash - ipcMain.handle('shell:trashItem', async (_event, itemPath: string) => { - if (!itemPath || typeof itemPath !== 'string') { - throw new Error('Invalid path: path must be a non-empty string'); - } - // Resolve to absolute path and verify it exists - const absolutePath = path.resolve(itemPath); - if (!fsSync.existsSync(absolutePath)) { - throw new Error(`Path does not exist: ${absolutePath}`); - } - await shell.trashItem(absolutePath); - }); - - // ============ Tunnel Handlers (Cloudflare) ============ - - ipcMain.handle('tunnel:isCloudflaredInstalled', async () => { - return await isCloudflaredInstalled(); - }); - - ipcMain.handle('tunnel:start', async () => { - const webServer = getWebServer(); - // Get web server URL (includes the security token) - const serverUrl = webServer?.getSecureUrl(); - if (!serverUrl) { - return { success: false, error: 'Web server not running' }; - } - - // Parse the URL to get port and token path - const parsedUrl = new URL(serverUrl); - const port = parseInt(parsedUrl.port, 10); - const tokenPath = parsedUrl.pathname; // e.g., "/7d7f7162-614c-43e2-bb8a-8a8123c2f56a" - - const result = await tunnelManager.start(port); - - if (result.success && result.url) { - // Append the token path to the tunnel URL for security - // e.g., "https://xyz.trycloudflare.com" + "/TOKEN" = "https://xyz.trycloudflare.com/TOKEN" - const fullTunnelUrl = result.url + tokenPath; - return { success: true, url: fullTunnelUrl }; - } - - return result; - }); - - ipcMain.handle('tunnel:stop', async () => { - await tunnelManager.stop(); - return { success: true }; - }); - - ipcMain.handle('tunnel:getStatus', async () => { - return tunnelManager.getStatus(); - }); - - // ============ DevTools Handlers ============ - - ipcMain.handle('devtools:open', async () => { - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.openDevTools(); - } - }); - - ipcMain.handle('devtools:close', async () => { - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.closeDevTools(); - } - }); - - ipcMain.handle('devtools:toggle', async () => { - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - if (mainWindow.webContents.isDevToolsOpened()) { - mainWindow.webContents.closeDevTools(); - } else { - mainWindow.webContents.openDevTools(); - } - } - }); - - // ============ Update Check Handler ============ - - ipcMain.handle('updates:check', async (_event, includePrerelease: boolean = false) => { - const currentVersion = app.getVersion(); - return checkForUpdates(currentVersion, includePrerelease); - }); - - // Set whether to allow prerelease updates (for electron-updater) - ipcMain.handle('updates:setAllowPrerelease', async (_event, allow: boolean) => { - setAllowPrerelease(allow); - }); - - // ============ Logger Handlers ============ - - ipcMain.handle('logger:log', async (_event, level: string, message: string, context?: string, data?: unknown) => { - const logLevel = level as 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'; - switch (logLevel) { - case 'debug': - logger.debug(message, context, data); - break; - case 'info': - logger.info(message, context, data); - break; - case 'warn': - logger.warn(message, context, data); - break; - case 'error': - logger.error(message, context, data); - break; - case 'toast': - logger.toast(message, context, data); - break; - case 'autorun': - logger.autorun(message, context, data); - break; - default: - // Log unknown levels as info to prevent silent failures - logger.info(`[${level}] ${message}`, context, data); - break; - } - }); - - ipcMain.handle('logger:getLogs', async (_event, filter?: { level?: string; context?: string; limit?: number }) => { - const typedFilter = filter ? { - level: filter.level as 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | undefined, - context: filter.context, - limit: filter.limit, - } : undefined; - return logger.getLogs(typedFilter); - }); - - ipcMain.handle('logger:clearLogs', async () => { - logger.clearLogs(); - }); - - ipcMain.handle('logger:setLogLevel', async (_event, level: string) => { - const logLevel = level as 'debug' | 'info' | 'warn' | 'error'; - logger.setLogLevel(logLevel); - settingsStore.set('logLevel', logLevel); - }); - - ipcMain.handle('logger:getLogLevel', async () => { - return logger.getLogLevel(); - }); - - ipcMain.handle('logger:setMaxLogBuffer', async (_event, max: number) => { - logger.setMaxLogBuffer(max); - settingsStore.set('maxLogBuffer', max); - }); - - ipcMain.handle('logger:getMaxLogBuffer', async () => { - return logger.getMaxLogBuffer(); - }); - - // Get the path to the debug log file (useful for Windows debugging) - ipcMain.handle('logger:getLogFilePath', async () => { - return logger.getLogFilePath(); - }); - - // Check if file logging is enabled - ipcMain.handle('logger:isFileLoggingEnabled', async () => { - return logger.isFileLoggingEnabled(); - }); - - // Enable file logging (automatically enabled on Windows) - ipcMain.handle('logger:enableFileLogging', async () => { - logger.enableFileLogging(); - }); - - // ============ Sync (Custom Storage Location) Handlers ============ - - // List of settings files that should be migrated - const SETTINGS_FILES = [ - 'maestro-settings.json', - 'maestro-sessions.json', - 'maestro-groups.json', - 'maestro-agent-configs.json', - 'maestro-claude-session-origins.json', - ]; - - // Get the default storage path - ipcMain.handle('sync:getDefaultPath', async () => { - return app.getPath('userData'); - }); - - // Get current sync settings - ipcMain.handle('sync:getSettings', async () => { - if (!deps.bootstrapStore) { - return { customSyncPath: undefined }; - } - return { - customSyncPath: deps.bootstrapStore.get('customSyncPath') || undefined, - }; - }); - - // Get current storage location (either custom or default) - ipcMain.handle('sync:getCurrentStoragePath', async () => { - if (!deps.bootstrapStore) { - return app.getPath('userData'); - } - const customPath = deps.bootstrapStore.get('customSyncPath'); - return customPath || app.getPath('userData'); - }); - - // Select custom sync folder via dialog - ipcMain.handle('sync:selectSyncFolder', async () => { - const mainWindow = getMainWindow(); - if (!mainWindow) return null; - - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openDirectory', 'createDirectory'], - title: 'Select Settings Folder', - message: 'Choose a folder for Maestro settings. Use a synced folder (iCloud Drive, Dropbox, OneDrive) to share settings across devices.', - }); - - if (result.canceled || result.filePaths.length === 0) { - return null; - } - - return result.filePaths[0]; - }); - - // Set custom sync path and migrate settings - ipcMain.handle('sync:setCustomPath', async (_event, newPath: string | null) => { - if (!deps.bootstrapStore) { - return { success: false, error: 'Bootstrap store not available' }; - } - - const defaultPath = app.getPath('userData'); - const currentCustomPath = deps.bootstrapStore.get('customSyncPath'); - const currentPath = currentCustomPath || defaultPath; - const targetPath = newPath || defaultPath; - - // Don't do anything if paths are the same - if (currentPath === targetPath) { - return { success: true, migrated: 0 }; - } - - // Ensure target directory exists - if (!fsSync.existsSync(targetPath)) { - try { - fsSync.mkdirSync(targetPath, { recursive: true }); - } catch { - return { success: false, error: `Cannot create directory: ${targetPath}` }; - } - } - - // Migrate settings files - let migratedCount = 0; - const errors: string[] = []; - - for (const file of SETTINGS_FILES) { - const sourcePath = path.join(currentPath, file); - const destPath = path.join(targetPath, file); - - try { - if (fsSync.existsSync(sourcePath)) { - // Check if destination already exists - if (fsSync.existsSync(destPath)) { - // Read both files to compare - const sourceContent = fsSync.readFileSync(sourcePath, 'utf-8'); - const destContent = fsSync.readFileSync(destPath, 'utf-8'); - - if (sourceContent !== destContent) { - // Backup existing destination file - const backupPath = destPath + '.backup.' + Date.now(); - fsSync.copyFileSync(destPath, backupPath); - logger.info(`Backed up existing ${file} to ${backupPath}`, 'Sync'); - } - } - - // Copy file to new location - fsSync.copyFileSync(sourcePath, destPath); - migratedCount++; - logger.info(`Migrated ${file} to ${targetPath}`, 'Sync'); - } - } catch (error) { - const errMsg = error instanceof Error ? error.message : String(error); - errors.push(`Failed to migrate ${file}: ${errMsg}`); - logger.error(`Failed to migrate ${file}`, 'Sync', error); - } - } - - // Update bootstrap store - if (newPath) { - deps.bootstrapStore.set('customSyncPath', newPath); - } else { - deps.bootstrapStore.delete('customSyncPath' as keyof BootstrapSettings); - } - - // Clear the old iCloudSyncEnabled flag if it exists (legacy cleanup) - if (deps.bootstrapStore.get('iCloudSyncEnabled')) { - deps.bootstrapStore.delete('iCloudSyncEnabled' as keyof BootstrapSettings); - } - - logger.info(`Storage location changed to ${targetPath}, migrated ${migratedCount} files`, 'Sync'); - - return { - success: errors.length === 0, - migrated: migratedCount, - errors: errors.length > 0 ? errors : undefined, - requiresRestart: true, - }; - }); - - // ============ Power Management Handlers ============ - - // Load saved preference and enable power manager if it was enabled - const savedPreventSleep = settingsStore.get('preventSleepEnabled' as keyof MaestroSettings); - if (savedPreventSleep === true) { - powerManager.setEnabled(true); - logger.info('Sleep prevention restored from settings', 'PowerManager'); - } - - // Set whether sleep prevention is enabled - ipcMain.handle('power:setEnabled', async (_event, enabled: boolean) => { - powerManager.setEnabled(enabled); - settingsStore.set('preventSleepEnabled' as keyof MaestroSettings, enabled); - }); - - // Check if sleep prevention is enabled - ipcMain.handle('power:isEnabled', async () => { - return powerManager.isEnabled(); - }); - - // Get current power management status - ipcMain.handle('power:getStatus', async () => { - return powerManager.getStatus(); - }); - - // Add a reason to block sleep (for renderer to signal auto-run, etc.) - ipcMain.handle('power:addReason', async (_event, reason: string) => { - powerManager.addBlockReason(reason); - }); - - // Remove a reason for blocking sleep - ipcMain.handle('power:removeReason', async (_event, reason: string) => { - powerManager.removeBlockReason(reason); - }); + const { getMainWindow, app, settingsStore, tunnelManager, getWebServer } = deps; + + // ============ Dialog Handlers ============ + + // Folder selection dialog + ipcMain.handle('dialog:selectFolder', async () => { + const mainWindow = getMainWindow(); + if (!mainWindow) return null; + + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openDirectory', 'createDirectory'], + title: 'Select Working Directory', + }); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + return result.filePaths[0]; + }); + + // File save dialog + ipcMain.handle( + 'dialog:saveFile', + async ( + _event, + options: { + defaultPath?: string; + filters?: Array<{ name: string; extensions: string[] }>; + title?: string; + } + ) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return null; + + const result = await dialog.showSaveDialog(mainWindow, { + defaultPath: options.defaultPath, + filters: options.filters, + title: options.title ?? 'Save File', + }); + + if (result.canceled || !result.filePath) { + return null; + } + + return result.filePath; + } + ); + + // ============ Font Detection Handlers ============ + + // Font detection + ipcMain.handle('fonts:detect', async () => { + try { + // Use fc-list on all platforms (faster than system_profiler on macOS) + // macOS: 0.74s (was 8.77s with system_profiler) - 11.9x faster + // Linux/Windows: 0.5-0.6s + const result = await execFileNoThrow('fc-list', [':', 'family']); + + if (result.exitCode === 0 && result.stdout) { + // Parse font list and deduplicate + const fonts = result.stdout + .split('\n') + .filter(Boolean) + .map((line: string) => line.trim()) + .filter((font) => font.length > 0); + + // Deduplicate fonts (fc-list can return duplicates) + return [...new Set(fonts)]; + } + + // Fallback if fc-list not available (rare on modern systems) + return [ + 'Monaco', + 'Menlo', + 'Courier New', + 'Consolas', + 'Roboto Mono', + 'Fira Code', + 'JetBrains Mono', + ]; + } catch (error) { + console.error('Font detection error:', error); + // Return common monospace fonts as fallback + return [ + 'Monaco', + 'Menlo', + 'Courier New', + 'Consolas', + 'Roboto Mono', + 'Fira Code', + 'JetBrains Mono', + ]; + } + }); + + // ============ Shell Detection Handlers ============ + + // Shell detection + ipcMain.handle('shells:detect', async () => { + try { + logger.info('Detecting available shells', 'ShellDetector'); + const shells = await detectShells(); + logger.info( + `Detected ${shells.filter((s) => s.available).length} available shells`, + 'ShellDetector', + { + shells: shells.filter((s) => s.available).map((s) => s.id), + } + ); + return shells; + } catch (error) { + logger.error('Shell detection error', 'ShellDetector', error); + // Return default shell list with all marked as unavailable + return [ + { id: 'zsh', name: 'Zsh', available: false }, + { id: 'bash', name: 'Bash', available: false }, + { id: 'sh', name: 'Bourne Shell (sh)', available: false }, + { id: 'fish', name: 'Fish', available: false }, + { id: 'tcsh', name: 'Tcsh', available: false }, + ]; + } + }); + + // Shell operations - open external URLs + ipcMain.handle('shell:openExternal', async (_event, url: string) => { + // Validate URL before opening - Fixes MAESTRO-1S + if (!url || typeof url !== 'string') { + throw new Error('Invalid URL: URL must be a non-empty string'); + } + try { + new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + await shell.openExternal(url); + }); + + // Shell operations - move item to system trash + ipcMain.handle('shell:trashItem', async (_event, itemPath: string) => { + if (!itemPath || typeof itemPath !== 'string') { + throw new Error('Invalid path: path must be a non-empty string'); + } + // Resolve to absolute path and verify it exists + const absolutePath = path.resolve(itemPath); + if (!fsSync.existsSync(absolutePath)) { + throw new Error(`Path does not exist: ${absolutePath}`); + } + await shell.trashItem(absolutePath); + }); + + // ============ Tunnel Handlers (Cloudflare) ============ + + ipcMain.handle('tunnel:isCloudflaredInstalled', async () => { + return await isCloudflaredInstalled(); + }); + + ipcMain.handle('tunnel:start', async () => { + const webServer = getWebServer(); + // Get web server URL (includes the security token) + const serverUrl = webServer?.getSecureUrl(); + if (!serverUrl) { + return { success: false, error: 'Web server not running' }; + } + + // Parse the URL to get port and token path + const parsedUrl = new URL(serverUrl); + const port = parseInt(parsedUrl.port, 10); + const tokenPath = parsedUrl.pathname; // e.g., "/7d7f7162-614c-43e2-bb8a-8a8123c2f56a" + + const result = await tunnelManager.start(port); + + if (result.success && result.url) { + // Append the token path to the tunnel URL for security + // e.g., "https://xyz.trycloudflare.com" + "/TOKEN" = "https://xyz.trycloudflare.com/TOKEN" + const fullTunnelUrl = result.url + tokenPath; + return { success: true, url: fullTunnelUrl }; + } + + return result; + }); + + ipcMain.handle('tunnel:stop', async () => { + await tunnelManager.stop(); + return { success: true }; + }); + + ipcMain.handle('tunnel:getStatus', async () => { + return tunnelManager.getStatus(); + }); + + // ============ DevTools Handlers ============ + + ipcMain.handle('devtools:open', async () => { + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.openDevTools(); + } + }); + + ipcMain.handle('devtools:close', async () => { + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.closeDevTools(); + } + }); + + ipcMain.handle('devtools:toggle', async () => { + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.webContents.isDevToolsOpened()) { + mainWindow.webContents.closeDevTools(); + } else { + mainWindow.webContents.openDevTools(); + } + } + }); + + // ============ Update Check Handler ============ + + ipcMain.handle('updates:check', async (_event, includePrerelease: boolean = false) => { + const currentVersion = app.getVersion(); + return checkForUpdates(currentVersion, includePrerelease); + }); + + // Set whether to allow prerelease updates (for electron-updater) + ipcMain.handle('updates:setAllowPrerelease', async (_event, allow: boolean) => { + setAllowPrerelease(allow); + }); + + // ============ Logger Handlers ============ + + ipcMain.handle( + 'logger:log', + async (_event, level: string, message: string, context?: string, data?: unknown) => { + const logLevel = level as 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'; + switch (logLevel) { + case 'debug': + logger.debug(message, context, data); + break; + case 'info': + logger.info(message, context, data); + break; + case 'warn': + logger.warn(message, context, data); + break; + case 'error': + logger.error(message, context, data); + break; + case 'toast': + logger.toast(message, context, data); + break; + case 'autorun': + logger.autorun(message, context, data); + break; + default: + // Log unknown levels as info to prevent silent failures + logger.info(`[${level}] ${message}`, context, data); + break; + } + } + ); + + ipcMain.handle( + 'logger:getLogs', + async (_event, filter?: { level?: string; context?: string; limit?: number }) => { + const typedFilter = filter + ? { + level: filter.level as + | 'debug' + | 'info' + | 'warn' + | 'error' + | 'toast' + | 'autorun' + | undefined, + context: filter.context, + limit: filter.limit, + } + : undefined; + return logger.getLogs(typedFilter); + } + ); + + ipcMain.handle('logger:clearLogs', async () => { + logger.clearLogs(); + }); + + ipcMain.handle('logger:setLogLevel', async (_event, level: string) => { + const logLevel = level as 'debug' | 'info' | 'warn' | 'error'; + logger.setLogLevel(logLevel); + settingsStore.set('logLevel', logLevel); + }); + + ipcMain.handle('logger:getLogLevel', async () => { + return logger.getLogLevel(); + }); + + ipcMain.handle('logger:setMaxLogBuffer', async (_event, max: number) => { + logger.setMaxLogBuffer(max); + settingsStore.set('maxLogBuffer', max); + }); + + ipcMain.handle('logger:getMaxLogBuffer', async () => { + return logger.getMaxLogBuffer(); + }); + + // Get the path to the debug log file (useful for Windows debugging) + ipcMain.handle('logger:getLogFilePath', async () => { + return logger.getLogFilePath(); + }); + + // Check if file logging is enabled + ipcMain.handle('logger:isFileLoggingEnabled', async () => { + return logger.isFileLoggingEnabled(); + }); + + // Enable file logging (automatically enabled on Windows) + ipcMain.handle('logger:enableFileLogging', async () => { + logger.enableFileLogging(); + }); + + // ============ Sync (Custom Storage Location) Handlers ============ + + // List of settings files that should be migrated + const SETTINGS_FILES = [ + 'maestro-settings.json', + 'maestro-sessions.json', + 'maestro-groups.json', + 'maestro-agent-configs.json', + 'maestro-claude-session-origins.json', + ]; + + // Get the default storage path + ipcMain.handle('sync:getDefaultPath', async () => { + return app.getPath('userData'); + }); + + // Get current sync settings + ipcMain.handle('sync:getSettings', async () => { + if (!deps.bootstrapStore) { + return { customSyncPath: undefined }; + } + return { + customSyncPath: deps.bootstrapStore.get('customSyncPath') || undefined, + }; + }); + + // Get current storage location (either custom or default) + ipcMain.handle('sync:getCurrentStoragePath', async () => { + if (!deps.bootstrapStore) { + return app.getPath('userData'); + } + const customPath = deps.bootstrapStore.get('customSyncPath'); + return customPath || app.getPath('userData'); + }); + + // Select custom sync folder via dialog + ipcMain.handle('sync:selectSyncFolder', async () => { + const mainWindow = getMainWindow(); + if (!mainWindow) return null; + + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openDirectory', 'createDirectory'], + title: 'Select Settings Folder', + message: + 'Choose a folder for Maestro settings. Use a synced folder (iCloud Drive, Dropbox, OneDrive) to share settings across devices.', + }); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + return result.filePaths[0]; + }); + + // Set custom sync path and migrate settings + ipcMain.handle('sync:setCustomPath', async (_event, newPath: string | null) => { + if (!deps.bootstrapStore) { + return { success: false, error: 'Bootstrap store not available' }; + } + + const defaultPath = app.getPath('userData'); + const currentCustomPath = deps.bootstrapStore.get('customSyncPath'); + const currentPath = currentCustomPath || defaultPath; + const targetPath = newPath || defaultPath; + + // Don't do anything if paths are the same + if (currentPath === targetPath) { + return { success: true, migrated: 0 }; + } + + // Ensure target directory exists + if (!fsSync.existsSync(targetPath)) { + try { + fsSync.mkdirSync(targetPath, { recursive: true }); + } catch { + return { success: false, error: `Cannot create directory: ${targetPath}` }; + } + } + + // Migrate settings files + let migratedCount = 0; + const errors: string[] = []; + + for (const file of SETTINGS_FILES) { + const sourcePath = path.join(currentPath, file); + const destPath = path.join(targetPath, file); + + try { + if (fsSync.existsSync(sourcePath)) { + // Check if destination already exists + if (fsSync.existsSync(destPath)) { + // Read both files to compare + const sourceContent = fsSync.readFileSync(sourcePath, 'utf-8'); + const destContent = fsSync.readFileSync(destPath, 'utf-8'); + + if (sourceContent !== destContent) { + // Backup existing destination file + const backupPath = destPath + '.backup.' + Date.now(); + fsSync.copyFileSync(destPath, backupPath); + logger.info(`Backed up existing ${file} to ${backupPath}`, 'Sync'); + } + } + + // Copy file to new location + fsSync.copyFileSync(sourcePath, destPath); + migratedCount++; + logger.info(`Migrated ${file} to ${targetPath}`, 'Sync'); + } + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + errors.push(`Failed to migrate ${file}: ${errMsg}`); + logger.error(`Failed to migrate ${file}`, 'Sync', error); + } + } + + // Update bootstrap store + if (newPath) { + deps.bootstrapStore.set('customSyncPath', newPath); + } else { + deps.bootstrapStore.delete('customSyncPath' as keyof BootstrapSettings); + } + + // Clear the old iCloudSyncEnabled flag if it exists (legacy cleanup) + if (deps.bootstrapStore.get('iCloudSyncEnabled')) { + deps.bootstrapStore.delete('iCloudSyncEnabled' as keyof BootstrapSettings); + } + + logger.info( + `Storage location changed to ${targetPath}, migrated ${migratedCount} files`, + 'Sync' + ); + + return { + success: errors.length === 0, + migrated: migratedCount, + errors: errors.length > 0 ? errors : undefined, + requiresRestart: true, + }; + }); + + // ============ Power Management Handlers ============ + + // Load saved preference and enable power manager if it was enabled + const savedPreventSleep = settingsStore.get('preventSleepEnabled' as keyof MaestroSettings); + if (savedPreventSleep === true) { + powerManager.setEnabled(true); + logger.info('Sleep prevention restored from settings', 'PowerManager'); + } + + // Set whether sleep prevention is enabled + ipcMain.handle('power:setEnabled', async (_event, enabled: boolean) => { + powerManager.setEnabled(enabled); + settingsStore.set('preventSleepEnabled' as keyof MaestroSettings, enabled); + }); + + // Check if sleep prevention is enabled + ipcMain.handle('power:isEnabled', async () => { + return powerManager.isEnabled(); + }); + + // Get current power management status + ipcMain.handle('power:getStatus', async () => { + return powerManager.getStatus(); + }); + + // Add a reason to block sleep (for renderer to signal auto-run, etc.) + ipcMain.handle('power:addReason', async (_event, reason: string) => { + powerManager.addBlockReason(reason); + }); + + // Remove a reason for blocking sleep + ipcMain.handle('power:removeReason', async (_event, reason: string) => { + powerManager.removeBlockReason(reason); + }); } /** @@ -528,15 +559,20 @@ export function registerSystemHandlers(deps: SystemHandlerDependencies): void { * This should be called after the main window is created. */ export function setupLoggerEventForwarding(getMainWindow: () => BrowserWindow | null): void { - logger.on('newLog', (entry) => { - const mainWindow = getMainWindow(); - // Safely send - handle cases where renderer is disposed (GPU crash, window closing) - try { - if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents && !mainWindow.webContents.isDestroyed()) { - mainWindow.webContents.send('logger:newLog', entry); - } - } catch { - // Silently ignore - renderer not available - } - }); + logger.on('newLog', (entry) => { + const mainWindow = getMainWindow(); + // Safely send - handle cases where renderer is disposed (GPU crash, window closing) + try { + if ( + mainWindow && + !mainWindow.isDestroyed() && + mainWindow.webContents && + !mainWindow.webContents.isDestroyed() + ) { + mainWindow.webContents.send('logger:newLog', entry); + } + } catch { + // Silently ignore - renderer not available + } + }); }