/** * Main process entry point for Claude Agent Teams UI. * * Responsibilities: * - Initialize Electron app and main window * - Set up IPC handlers for data access * - Initialize ServiceContextRegistry with local context * - Start file watcher for live updates * - Manage application lifecycle */ import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; import { FileContentResolver } from '@main/services/team/FileContentResolver'; import { GitDiffFallback } from '@main/services/team/GitDiffFallback'; import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import { CONTEXT_CHANGED, SSH_STATUS, TEAM_CHANGE, WINDOW_FULLSCREEN_CHANGED, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload } from '@preload/constants/ipcChannels'; import { DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, DEV_SERVER_PORT, getTrafficLightPositionForZoom, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, } from '@shared/constants'; import { createLogger } from '@shared/utils/logger'; import { app, BrowserWindow } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { HttpServer } from './services/infrastructure/HttpServer'; import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder'; import { configManager, LocalFileSystemProvider, MemberStatsComputer, NotificationManager, ServiceContext, ServiceContextRegistry, SshConnectionManager, TaskBoundaryParser, TeamAgentToolsInstaller, TeamDataService, TeamMemberLogsFinder, TeamProvisioningService, UpdaterService, } from './services'; const logger = createLogger('App'); // Window icon path for non-mac platforms. const getWindowIconPath = (): string | undefined => { const isDev = process.env.NODE_ENV === 'development'; const candidates = isDev ? [join(process.cwd(), 'resources/icon.png')] : [ join(process.resourcesPath, 'resources/icon.png'), join(__dirname, '../../resources/icon.png'), ]; for (const candidate of candidates) { if (existsSync(candidate)) { return candidate; } } return undefined; }; process.on('unhandledRejection', (reason) => { logger.error('Unhandled promise rejection in main process:', reason); }); process.on('uncaughtException', (error) => { logger.error('Uncaught exception in main process:', error); }); // ============================================================================= // Application State // ============================================================================= let mainWindow: BrowserWindow | null = null; // Service registry and global services let contextRegistry: ServiceContextRegistry; let notificationManager: NotificationManager; let updaterService: UpdaterService; let sshConnectionManager: SshConnectionManager; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; let httpServer: HttpServer; // File watcher event cleanup functions let fileChangeCleanup: (() => void) | null = null; let todoChangeCleanup: (() => void) | null = null; let teamChangeCleanup: (() => void) | null = null; /** * Resolve production renderer index path. * Main bundle lives in dist-electron/main, while renderer lives in out/renderer. */ function getRendererIndexPath(): string { const candidates = [ join(__dirname, '../../out/renderer/index.html'), join(__dirname, '../renderer/index.html'), ]; return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]; } /** * Wires file watcher events from a ServiceContext to the renderer and HTTP SSE clients. * Cleans up previous listeners before adding new ones. */ function wireFileWatcherEvents(context: ServiceContext): void { logger.info(`Wiring FileWatcher events for context: ${context.id}`); // Clean up previous listeners if (fileChangeCleanup) { fileChangeCleanup(); fileChangeCleanup = null; } if (todoChangeCleanup) { todoChangeCleanup(); todoChangeCleanup = null; } if (teamChangeCleanup) { teamChangeCleanup(); teamChangeCleanup = null; } // Wire file-change events to renderer and HTTP SSE const fileChangeHandler = (event: unknown): void => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('file-change', event); } httpServer?.broadcast('file-change', event); }; context.fileWatcher.on('file-change', fileChangeHandler); fileChangeCleanup = () => context.fileWatcher.off('file-change', fileChangeHandler); // Forward checklist-change events to renderer and HTTP SSE (mirrors file-change pattern above) const todoChangeHandler = (event: unknown): void => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('todo-change', event); } httpServer?.broadcast('todo-change', event); }; context.fileWatcher.on('todo-change', todoChangeHandler); todoChangeCleanup = () => context.fileWatcher.off('todo-change', todoChangeHandler); // Forward team-change events to renderer and HTTP SSE const teamChangeHandler = (event: unknown): void => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(TEAM_CHANGE, event); } httpServer?.broadcast('team-change', event); // Auto-relay direct messages to live team lead process (no UI dependency). try { if (!event || typeof event !== 'object') return; const row = event as { type?: unknown; teamName?: unknown }; if (row.type !== 'inbox') return; if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return; const teamName = row.teamName.trim(); if (!teamProvisioningService.isTeamAlive(teamName)) return; void teamProvisioningService.relayLeadInboxMessages(teamName).catch(() => undefined); } catch { // ignore } }; context.fileWatcher.on('team-change', teamChangeHandler); teamChangeCleanup = () => context.fileWatcher.off('team-change', teamChangeHandler); logger.info(`FileWatcher events wired for context: ${context.id}`); } /** * Handles mode switch requests from the HTTP server. * Switches the active context back to local when requested. */ async function handleModeSwitch(mode: 'local' | 'ssh'): Promise { if (mode === 'local' && contextRegistry.getActiveContextId() !== 'local') { const { current } = contextRegistry.switch('local'); onContextSwitched(current); } } /** * Re-wires file watcher events only. No renderer notification. * Used for renderer-initiated switches where the renderer already handles state. */ export function rewireContextEvents(context: ServiceContext): void { wireFileWatcherEvents(context); } /** * Full callback: re-wire + notify renderer. * Used for external/unexpected switches (e.g., HTTP server mode switch). */ function onContextSwitched(context: ServiceContext): void { rewireContextEvents(context); // Notify renderer of context change if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(SSH_STATUS, sshConnectionManager.getStatus()); mainWindow.webContents.send(CONTEXT_CHANGED, { id: context.id, type: context.type, }); } } /** * Rebuilds the local ServiceContext using the current configured Claude root paths. * Called when general.claudeRootPath changes. */ function reconfigureLocalContextForClaudeRoot(): void { try { const currentLocal = contextRegistry.get('local'); if (!currentLocal) { logger.error('Cannot reconfigure local context: local context not found'); return; } const wasLocalActive = contextRegistry.getActiveContextId() === 'local'; const projectsDir = getProjectsBasePath(); const todosDir = getTodosBasePath(); logger.info(`Reconfiguring local context: projectsDir=${projectsDir}, todosDir=${todosDir}`); if (wasLocalActive) { currentLocal.stopFileWatcher(); } const replacementLocal = new ServiceContext({ id: 'local', type: 'local', fsProvider: new LocalFileSystemProvider(), projectsDir, todosDir, }); if (notificationManager) { replacementLocal.fileWatcher.setNotificationManager(notificationManager); } replacementLocal.start(); if (!wasLocalActive) { replacementLocal.stopFileWatcher(); } contextRegistry.replaceContext('local', replacementLocal); if (wasLocalActive) { wireFileWatcherEvents(replacementLocal); } } catch (error) { logger.error('Failed to reconfigure local context for Claude root change:', error); } } /** * Initializes all services. */ function initializeServices(): void { logger.info('Initializing services...'); // Initialize SSH connection manager sshConnectionManager = new SshConnectionManager(); // Create ServiceContextRegistry contextRegistry = new ServiceContextRegistry(); const localProjectsDir = getProjectsBasePath(); const localTodosDir = getTodosBasePath(); // Create local context const localContext = new ServiceContext({ id: 'local', type: 'local', fsProvider: new LocalFileSystemProvider(), projectsDir: localProjectsDir, todosDir: localTodosDir, }); // Register and start local context contextRegistry.registerContext(localContext); localContext.start(); logger.info(`Projects directory: ${localContext.projectScanner.getProjectsDir()}`); // Initialize notification manager (singleton, not context-scoped) notificationManager = NotificationManager.getInstance(); // Set notification manager on local context's file watcher localContext.fileWatcher.setNotificationManager(notificationManager); // Wire file watcher events for local context wireFileWatcherEvents(localContext); // Initialize updater service updaterService = new UpdaterService(); teamDataService = new TeamDataService(); teamProvisioningService = new TeamProvisioningService(); const teamMemberLogsFinder = new TeamMemberLogsFinder(); const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder); const taskBoundaryParser = new TaskBoundaryParser(); const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser); const gitDiffFallback = new GitDiffFallback(); const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback); const reviewApplier = new ReviewApplierService(); // Fire-and-forget: warm up CLI and install teamctl.js at startup void teamProvisioningService.warmup(); void new TeamAgentToolsInstaller().ensureInstalled(); httpServer = new HttpServer(); // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). teamProvisioningService.setTeamChangeEmitter((event) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(TEAM_CHANGE, event); } httpServer?.broadcast('team-change', event); }); // Initialize IPC handlers with registry initializeIpcHandlers( contextRegistry, updaterService, sshConnectionManager, teamDataService, teamProvisioningService, teamMemberLogsFinder, memberStatsComputer, { rewire: rewireContextEvents, full: onContextSwitched, onClaudeRootPathUpdated: (_claudeRootPath: string | null) => { reconfigureLocalContextForClaudeRoot(); }, }, { httpServer, startHttpServer: () => startHttpServer(handleModeSwitch), }, changeExtractor, fileContentResolver, reviewApplier, gitDiffFallback ); // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(SSH_STATUS, status); } httpServer.broadcast('ssh:status', status); }); // Forward notification events to HTTP SSE clients notificationManager.on('notification-new', (notification: unknown) => { httpServer.broadcast('notification:new', notification); }); notificationManager.on('notification-updated', (data: unknown) => { httpServer.broadcast('notification:updated', data); }); notificationManager.on('notification-clicked', (data: unknown) => { httpServer.broadcast('notification:clicked', data); }); // Start HTTP server if enabled in config const appConfig = configManager.getConfig(); if (appConfig.httpServer?.enabled) { void startHttpServer(handleModeSwitch); } logger.info('Services initialized successfully'); } /** * Starts the HTTP sidecar server with services from the active context. */ async function startHttpServer( modeSwitchHandler: (mode: 'local' | 'ssh') => Promise ): Promise { try { const config = configManager.getConfig(); const activeContext = contextRegistry.getActive(); const port = await httpServer.start( { projectScanner: activeContext.projectScanner, sessionParser: activeContext.sessionParser, subagentResolver: activeContext.subagentResolver, chunkBuilder: activeContext.chunkBuilder, dataCache: activeContext.dataCache, updaterService, sshConnectionManager, }, modeSwitchHandler, config.httpServer?.port ?? 3456 ); logger.info(`HTTP sidecar server running on port ${port}`); } catch (error) { logger.error('Failed to start HTTP server:', error); } } /** * Shuts down all services. */ function shutdownServices(): void { logger.info('Shutting down services...'); // Stop HTTP server if (httpServer?.isRunning()) { void httpServer.stop(); } // Clean up file watcher event listeners if (fileChangeCleanup) { fileChangeCleanup(); fileChangeCleanup = null; } if (todoChangeCleanup) { todoChangeCleanup(); todoChangeCleanup = null; } if (teamChangeCleanup) { teamChangeCleanup(); teamChangeCleanup = null; } // Dispose all contexts (including local) if (contextRegistry) { contextRegistry.dispose(); } // Dispose SSH connection manager if (sshConnectionManager) { sshConnectionManager.dispose(); } // Remove IPC handlers removeIpcHandlers(); logger.info('Services shut down successfully'); } /** * Update native traffic-light position and notify renderer of the current zoom factor. */ function syncTrafficLightPosition(win: BrowserWindow): void { const zoomFactor = win.webContents.getZoomFactor(); const position = getTrafficLightPositionForZoom(zoomFactor); // setWindowButtonPosition is macOS-only (traffic light buttons) if (process.platform === 'darwin') { win.setWindowButtonPosition(position); } win.webContents.send(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor); } /** * Creates the main application window. */ function createWindow(): void { const isMac = process.platform === 'darwin'; const iconPath = isMac ? undefined : getWindowIconPath(); const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar; mainWindow = new BrowserWindow({ width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT, ...(iconPath ? { icon: iconPath } : {}), webPreferences: { preload: join(__dirname, '../preload/index.js'), nodeIntegration: false, contextIsolation: true, }, backgroundColor: '#1a1a1a', ...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }), ...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }), title: 'Claude Agent Teams UI', }); // Load the renderer if (process.env.NODE_ENV === 'development') { void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`); mainWindow.webContents.openDevTools(); } else { void mainWindow.loadFile(getRendererIndexPath()).catch((error: unknown) => { logger.error('Failed to load renderer entry HTML:', error); }); } // Notify renderer when entering/leaving fullscreen (so traffic light padding can be removed) mainWindow.on('enter-full-screen', () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, true); } }); mainWindow.on('leave-full-screen', () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, false); } }); // Set traffic light position + notify renderer on first load, and auto-check for updates mainWindow.webContents.on('did-finish-load', () => { if (mainWindow && !mainWindow.isDestroyed()) { syncTrafficLightPosition(mainWindow); setTimeout(() => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, mainWindow.isFullScreen()); } }, 0); setTimeout(() => updaterService.checkForUpdates(), 3000); } }); // Log top-level renderer load failures (helps diagnose blank/black window issues in packaged apps) mainWindow.webContents.on( 'did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { if (isMainFrame) { logger.error( `Failed to load renderer (code=${errorCode}): ${errorDescription} - ${validatedURL}` ); } } ); // Sync traffic light position when zoom changes (Cmd+/-, Cmd+0) // zoom-changed event doesn't fire in Electron 40, so we detect zoom keys directly. // Also keeps zoom bounds within a practical readability range. const MIN_ZOOM_LEVEL = -3; // ~70% const MAX_ZOOM_LEVEL = 5; const ZOOM_IN_KEYS = new Set(['+', '=']); const ZOOM_OUT_KEYS = new Set(['-', '_']); mainWindow.webContents.on('before-input-event', (event, input) => { if (!mainWindow || mainWindow.isDestroyed()) return; if (input.type !== 'keyDown') return; // Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer // keyboard handler can use it as "Refresh Session" (fixes #58). // Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload). if ((input.control || input.meta) && input.key.toLowerCase() === 'r') { event.preventDefault(); return; } // Prevent Cmd+N from opening new window; forward to renderer for review shortcuts if (input.meta && input.key.toLowerCase() === 'n') { event.preventDefault(); mainWindow.webContents.send('review:cmdN'); return; } if (!input.meta) return; const currentLevel = mainWindow.webContents.getZoomLevel(); // Block zoom-out beyond minimum if (ZOOM_OUT_KEYS.has(input.key) && currentLevel <= MIN_ZOOM_LEVEL) { event.preventDefault(); return; } // Block zoom-in beyond maximum if (ZOOM_IN_KEYS.has(input.key) && currentLevel >= MAX_ZOOM_LEVEL) { event.preventDefault(); return; } // For zoom keys (including Cmd+0 reset), defer sync until zoom is applied if (ZOOM_IN_KEYS.has(input.key) || ZOOM_OUT_KEYS.has(input.key) || input.key === '0') { setTimeout(() => { if (mainWindow && !mainWindow.isDestroyed()) { syncTrafficLightPosition(mainWindow); } }, 100); } }); mainWindow.on('closed', () => { mainWindow = null; // Clear main window references if (notificationManager) { notificationManager.setMainWindow(null); } if (updaterService) { updaterService.setMainWindow(null); } }); // Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event) mainWindow.webContents.on('render-process-gone', (_event, details) => { logger.error('Renderer process gone:', details.reason, details.exitCode); // Could show an error dialog or attempt to reload the window }); // Set main window reference for notification manager and updater if (notificationManager) { notificationManager.setMainWindow(mainWindow); } if (updaterService) { updaterService.setMainWindow(mainWindow); } logger.info('Main window created'); } /** * Application ready handler. */ void app.whenReady().then(() => { logger.info('App ready, initializing...'); try { // Initialize services first initializeServices(); // Apply configuration settings const config = configManager.getConfig(); // Apply launch at login setting app.setLoginItemSettings({ openAtLogin: config.general.launchAtLogin, }); // Apply dock visibility and icon (macOS) if (process.platform === 'darwin') { if (!config.general.showDockIcon) { app.dock?.hide(); } // macOS app icon is already provided by the signed bundle (.icns) // so we avoid runtime setIcon calls that can fail and block startup. } // Then create window createWindow(); // Listen for notification click events notificationManager.on('notification-clicked', (_error) => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } }); } catch (error) { logger.error('Startup initialization failed:', error); if (!mainWindow) { createWindow(); } } app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); /** * All windows closed handler. */ app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } }); /** * Before quit handler - cleanup. */ app.on('before-quit', () => { shutdownServices(); });