/** * Main process entry point for 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 */ // Increase UV thread pool size BEFORE any async I/O. // Default is 4 threads which is far too few for startup: // binary resolution stat() calls, CLI subprocess spawning, fs.watch(), // and readFile/readdir from IPC handlers all compete for the pool. // On Windows this saturates all threads, blocking the event loop. process.env.UV_THREADPOOL_SIZE ??= '16'; // Keep userData stable before any integration can initialize Electron storage. // Sentry must stay near the top to capture early errors after storage migration. // eslint-disable-next-line simple-import-sort/imports -- userData migration must run before Sentry initializes Electron storage. import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration'; import './sentry'; import { type CodexAccountFeatureFacade, createCodexAccountFeature, registerCodexAccountIpc, removeCodexAccountIpc, } from '@features/codex-account/main'; import { type CodexModelCatalogFeatureFacade, createCodexModelCatalogFeature, } from '@features/codex-model-catalog/main'; import { createMemberLogStreamFeature, registerMemberLogStreamIpc, removeMemberLogStreamIpc, } from '@features/member-log-stream/main'; import { buildMemberWorkSyncRuntimeTurnSettledEnvironment, createMemberWorkSyncFeature, type MemberWorkSyncFeatureFacade, registerMemberWorkSyncIpc, removeMemberWorkSyncIpc, } from '@features/member-work-sync/main'; import { createRecentProjectsFeature, type RecentProjectsFeatureFacade, registerRecentProjectsIpc, removeRecentProjectsIpc, } from '@features/recent-projects/main'; import { createRuntimeProviderManagementFeature, registerRuntimeProviderManagementIpc, removeRuntimeProviderManagementIpc, type RuntimeProviderManagementFeatureFacade, } from '@features/runtime-provider-management/main'; import { createWorkspaceTrustCoordinator } from '@features/workspace-trust/main'; import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor'; import { SchedulerService } from '@main/services/schedule/SchedulerService'; import { JsonTaskChangePresenceRepository } from '@main/services/team/cache/JsonTaskChangePresenceRepository'; import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; import { CrossTeamService } from '@main/services/team/CrossTeamService'; import { FileContentResolver } from '@main/services/team/FileContentResolver'; import { GitDiffFallback } from '@main/services/team/GitDiffFallback'; import { clearOpenCodeLocalMcpLaunchEnv, copyOpenCodeLocalMcpLaunchEnv, hasOpenCodeLocalMcpLaunchEnv, isOpenCodeMcpHttpBridgeEnabled, snapshotOpenCodeLocalMcpLaunchEnv, } from '@main/services/team/opencode/bridge/OpenCodeMcpBridgeEnv'; import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import { TeamBackupService } from '@main/services/team/TeamBackupService'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; import { resolveAgentTeamsMcpLaunchSpec, TeamMcpConfigBuilder, } from '@main/services/team/TeamMcpConfigBuilder'; import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver'; import { killTrackedCliProcesses } from '@main/utils/childProcess'; import { APP_STARTUP_GET_STATUS, APP_STARTUP_PROGRESS, CONTEXT_CHANGED, SCHEDULE_CHANGE, SKILLS_CHANGED, SSH_STATUS, TEAM_CHANGE, TEAM_PROJECT_BRANCH_CHANGE, TEAM_TOOL_APPROVAL_EVENT, 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 { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics'; import { parseInboxJson } from '@shared/utils/inboxNoise'; import { createLogger } from '@shared/utils/logger'; import { isReviewPickupEscalationMessage } from '@shared/utils/teamAutomationMessages'; import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages'; import { createHash } from 'crypto'; import { app, BrowserWindow, ipcMain } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; import { cleanupEditorState, setEditorMainWindow } from './ipc/editor'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { registerRendererLogHandlers } from './ipc/rendererLogs'; import { setReviewMainWindow } from './ipc/review'; import { setTmuxMainWindow } from './ipc/tmux'; import { ApiKeyService, createExtensionsRuntimeAdapter, ExtensionFacadeService, GlamaMcpEnrichmentService, McpCatalogAggregator, McpHealthDiagnosticsService, McpInstallationStateService, McpInstallService, OfficialMcpRegistryService, PluginCatalogService, PluginInstallationStateService, PluginInstallService, SkillsCatalogService, SkillsMutationService, SkillsWatcherService, } from './services/extensions'; import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor'; import { HttpServer } from './services/infrastructure/HttpServer'; import { clearAutoResumeService } from './services/team/AutoResumeService'; import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer'; import { LaunchIoGovernor } from './services/team/LaunchIoGovernor'; import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient'; import { createOpenCodeBridgeCommandLeaseStore, createOpenCodeBridgeCommandLedgerStore, } from './services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore'; import { createOpenCodeBridgeClientIdentity, OpenCodeBridgeCommandHandshakePort, } from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; import { cleanupManagedOpenCodeServeProcesses } from './services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup'; import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; import { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { buildTeamControlApiBaseUrl, clearTeamControlApiState, writeTeamControlApiState, } from './services/team/TeamControlApiState'; import { getTeamDataWorkerClient } from './services/team/TeamDataWorkerClient'; import { getTeamFsWorkerClient } from './services/team/TeamFsWorkerClient'; import { TeamInboxReader } from './services/team/TeamInboxReader'; import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService'; import { createTeamReconcileDrainScheduler, type TeamReconcileTrigger, } from './services/team/TeamReconcileDrainScheduler'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; import { getAppIconPath } from './utils/appIcon'; import { getAutoDetectedClaudeBasePath, getClaudeBasePath, getHomeDir, getProjectsBasePath, getTeamsBasePath, getTodosBasePath, } from './utils/pathDecoder'; import { clearRendererAvailability, markRendererReady, markRendererUnavailable, safeSendToRenderer, } from './utils/safeWebContentsSend'; import { syncTelemetryFlag } from './sentry'; import { setCodexRuntimeMainWindow } from './ipc/codexRuntime'; import { ActiveTeamRegistry, BoardTaskActivityDetailService, BoardTaskActivityRecordSource, BoardTaskActivityService, BoardTaskExactLogDetailService, BoardTaskExactLogsService, BoardTaskLogStreamService, BranchStatusService, ClaudeBinaryResolver, CliInstallerService, configManager, LocalFileSystemProvider, MemberStatsComputer, NotificationManager, OpenCodeRuntimeInstallerService, OpenCodeReadinessBridge, OpenCodeTeamRuntimeAdapter, PtyTerminalService, ServiceContext, ServiceContextRegistry, SshConnectionManager, TaskBoundaryParser, TeamDataService, TeamKanbanManager, TeamLogSourceTracker, TeammateToolTracker, TeamMemberLogsFinder, TeamMembersMetaStore, TeamProvisioningService, TeamRuntimeAdapterRegistry, TeamTaskReader, TeamTaskStallJournal, TeamTaskStallMonitor, TeamTaskStallNotifier, TeamTaskStallPolicy, TeamTaskStallSnapshotSource, TeamTranscriptSourceLocator, UpdaterService, resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath, } from './services'; import type { FileChangeEvent } from '@main/types'; import type { AppStartupStatus, AppStartupStep, TeamChangeEvent } from '@shared/types'; const logger = createLogger('App'); const appStartedAtMs = Date.now(); const openCodeManagedHostInstanceId = `${process.pid}-${appStartedAtMs}`; let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null; if ( earlyElectronUserDataMigrationResult.migrated && earlyElectronUserDataMigrationResult.legacyPath && earlyElectronUserDataMigrationResult.currentPath ) { logger.info( `Migrated Electron userData from ${earlyElectronUserDataMigrationResult.legacyPath} to ${earlyElectronUserDataMigrationResult.currentPath}` ); } else if ( earlyElectronUserDataMigrationResult.reason === 'legacy-reused' && earlyElectronUserDataMigrationResult.legacyPath ) { logger.info( `Reusing legacy Electron userData at ${earlyElectronUserDataMigrationResult.legacyPath}` ); } else if ( earlyElectronUserDataMigrationResult.fallbackToLegacy && earlyElectronUserDataMigrationResult.legacyPath ) { logger.warn(`Electron userData migration failed, using legacy path for this run`); } startEventLoopLagMonitor(); // Windows: set AppUserModelId early so native notifications show the correct // application title instead of the default "electron.app.{name}" identifier. // Must match the appId in electron-builder config (package.json → build.appId). if (process.platform === 'win32') { app.setAppUserModelId('com.agent-teams.app'); } // --- Team message notification tracking --- const teamInboxReader = new TeamInboxReader(); const teamInboxWriter = new TeamInboxWriter(); const sentMessagesStore = new TeamSentMessagesStore(); /** Track last-seen message count per inbox file to detect new messages. */ const inboxMessageCounts = new Map(); /** Track last-seen message count per team sentMessages.json to detect new user-directed messages. */ const sentMessageCounts = new Map(); /** Debounce per-inbox to avoid flooding during batch writes. */ const inboxNotifyTimers = new Map>(); const INBOX_NOTIFY_DEBOUNCE_MS = 500; /** Messages sent from our UI (user_sent) - suppress notifications for these. */ const suppressedSources = new Set(['user_sent']); function buildMemberWorkSyncReviewPickupEscalationMessageId(input: { teamName: string; memberName: string; reason: string; reviewRequestEventIds?: readonly string[]; taskRefs: readonly { taskId: string; displayId?: string }[]; }): string { const stableKey = JSON.stringify({ teamName: input.teamName, memberName: input.memberName.trim().toLowerCase(), reason: input.reason, reviewRequestEventIds: [...new Set(input.reviewRequestEventIds ?? [])].sort(), taskIds: [...new Set(input.taskRefs.map((taskRef) => taskRef.taskId).filter(Boolean))].sort(), }); const digest = createHash('sha256').update(stableKey).digest('hex').slice(0, 20); return `member-work-sync-review-pickup-escalation:${digest}`; } function buildMemberWorkSyncReviewPickupEscalationText(input: { memberName: string; reason: string; diagnostics?: readonly string[]; taskRefs: readonly { taskId: string; displayId?: string }[]; }): string { const taskLines = input.taskRefs.length ? input.taskRefs .map((taskRef) => `- ${taskRef.displayId ?? taskRef.taskId.slice(0, 8)}`) .join('\n') : '- No task refs recorded'; const reasonText = describeMemberWorkSyncReviewPickupEscalationReason(input.reason); return [ 'Review pickup needs lead attention.', '', `Reviewer: ${input.memberName}`, reasonText, '', 'Tasks:', taskLines, '', 'No review_start, review_approve, or review_request_changes was recorded for the current review request.', 'Consider reassigning the reviewer or sending a direct instruction.', ] .filter(Boolean) .join('\n'); } function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): string { if (reason.startsWith('provider_not_supported:')) { return 'Direct review-pickup wake is not available for this member runtime, so the lead needs to handle the stuck review.'; } if (reason === 'review_pickup_already_delivered_still_stuck') { return 'A review-pickup reminder was delivered, but the review is still waiting for a review tool action.'; } if (reason === 'review_pickup_delivery_failed_still_stuck') { return 'The review-pickup reminder could not be delivered reliably, and the review is still waiting.'; } if (reason.includes('delivery_port_unavailable')) { return 'No reliable review-pickup delivery path is available for this member runtime.'; } return 'The current review request is still waiting for explicit review pickup.'; } async function createOpenCodeRuntimeAdapterRegistry( reportProgress: (phase: string, message: string) => void = () => undefined ): Promise { const binaryPath = await ClaudeBinaryResolver.resolve({ onProgress: ({ phase, message }) => reportProgress(`runtime-${phase}`, message), }); if (!binaryPath) { logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved'); reportProgress( 'runtime-unavailable', 'Runtime not found. Continuing with limited launch support...' ); openCodeLifecycleBridge = null; return new TeamRuntimeAdapterRegistry(); } reportProgress('runtime-environment', 'Preparing runtime environment...'); const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env }); bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId; bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath(); const useHttpMcpBridge = isOpenCodeMcpHttpBridgeEnabled(bridgeEnv); const explicitLocalMcpLaunchEnv = snapshotOpenCodeLocalMcpLaunchEnv(bridgeEnv); if (!useHttpMcpBridge) { delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; } else { delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; clearOpenCodeLocalMcpLaunchEnv(bridgeEnv); } const applyMcpLaunchSpecEnv = async ( targetEnv: NodeJS.ProcessEnv, options: { emitProgress?: boolean } = {} ): Promise => { try { if (options.emitProgress) { reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...'); } const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({ onProgress: options.emitProgress ? ({ phase, message }) => reportProgress(`mcp-${phase}`, message) : undefined, }); const mcpEntry = mcpLaunchSpec.args[0]; if (mcpEntry) { targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args); } } catch (error) { logger.warn( `[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${ error instanceof Error ? error.message : String(error) }` ); } }; const ensureOpenCodeLocalMcpLaunchEnv = async ( targetEnv: NodeJS.ProcessEnv, options: { emitProgress?: boolean } = {} ): Promise => { if (hasOpenCodeLocalMcpLaunchEnv(bridgeEnv)) { copyOpenCodeLocalMcpLaunchEnv(bridgeEnv, targetEnv); return; } if (explicitLocalMcpLaunchEnv) { copyOpenCodeLocalMcpLaunchEnv(explicitLocalMcpLaunchEnv, targetEnv); copyOpenCodeLocalMcpLaunchEnv(explicitLocalMcpLaunchEnv, bridgeEnv); return; } await applyMcpLaunchSpecEnv(targetEnv, options); if (hasOpenCodeLocalMcpLaunchEnv(targetEnv)) { copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv); } }; try { const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); if (appManagedOpenCodeBinary && !bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH) { bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary; } } catch (error) { logger.warn( `[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${ error instanceof Error ? error.message : String(error) }` ); } try { reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...'); const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({ teamsBasePath: getTeamsBasePath(), provider: 'opencode', }); if (turnSettledEnv) { Object.assign(bridgeEnv, turnSettledEnv); } } catch (error) { logger.warn( `[OpenCode] Runtime adapter bridge turn-settled spool unavailable: ${ error instanceof Error ? error.message : String(error) }` ); } if (useHttpMcpBridge) { try { reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...'); const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted(); bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; clearOpenCodeLocalMcpLaunchEnv(bridgeEnv); reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...'); } catch (error) { logger.warn( `[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${ error instanceof Error ? error.message : String(error) }` ); } } if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) { await ensureOpenCodeLocalMcpLaunchEnv(bridgeEnv, { emitProgress: true }); } reportProgress('runtime-bridge', 'Preparing OpenCode bridge...'); const resolveBridgeCommandEnv = async (): Promise => { const nextEnv = { ...bridgeEnv }; if (!useHttpMcpBridge) { return nextEnv; } try { const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted(); bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url; clearOpenCodeLocalMcpLaunchEnv(bridgeEnv); clearOpenCodeLocalMcpLaunchEnv(nextEnv); } catch (error) { delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; clearOpenCodeLocalMcpLaunchEnv(nextEnv); await ensureOpenCodeLocalMcpLaunchEnv(nextEnv); logger.warn( `[OpenCode] Runtime adapter bridge MCP HTTP server refresh failed: ${ error instanceof Error ? error.message : String(error) }` ); } return nextEnv; }; const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath, tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'), env: bridgeEnv, envProvider: resolveBridgeCommandEnv, }); const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge'); const clientIdentity = createOpenCodeBridgeClientIdentity({ appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.3.0', gitSha: process.env.VITE_GIT_SHA ?? process.env.GIT_SHA ?? null, buildId: process.env.VITE_BUILD_ID ?? process.env.BUILD_ID ?? null, }); const stateChangingCommands = new OpenCodeStateChangingBridgeCommandService({ expectedClientIdentity: clientIdentity, handshakePort: new OpenCodeBridgeCommandHandshakePort({ bridge: bridgeClient, clientIdentity, }), leaseStore: createOpenCodeBridgeCommandLeaseStore({ filePath: join(bridgeControlDir, 'command-leases.json'), }), ledger: createOpenCodeBridgeCommandLedgerStore({ filePath: join(bridgeControlDir, 'command-ledger.json'), }), bridge: bridgeClient, manifestReader: new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: getTeamsBasePath(), }), }); const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { stateChangingCommands, }); openCodeLifecycleBridge = readinessBridge; return new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(readinessBridge)]); } async function cleanupOpenCodeHostsForLifecycle(reason: 'startup' | 'shutdown'): Promise { let registryHostPids = new Set(); let registryCleanupAvailable = false; if (openCodeLifecycleBridge) { const result = await openCodeLifecycleBridge.cleanupOpenCodeHosts({ reason, mode: reason === 'shutdown' ? 'force' : 'stale', staleAgeMs: reason === 'startup' ? 5 * 60_000 : null, leaseStaleAgeMs: reason === 'startup' ? 24 * 60 * 60_000 : null, preflightLeaseStaleAgeMs: reason === 'startup' ? 2 * 60_000 : null, }); registryHostPids = new Set( result.hosts .filter((host) => host.action.startsWith('kept_')) .map((host) => host.pid) .filter((pid) => Number.isFinite(pid) && pid > 0) ); if (result.cleaned > 0) { logger.info( `[OpenCode] ${reason} host cleanup removed ${result.cleaned} registry host(s), ${result.remaining} remaining` ); } for (const diagnostic of result.diagnostics) { logger.warn(`[OpenCode] ${reason} host cleanup: ${diagnostic}`); } registryCleanupAvailable = !result.diagnostics.some((diagnostic) => diagnostic.startsWith('OpenCode host cleanup bridge failed:') ); } if (reason === 'startup' && !registryCleanupAvailable) { logger.warn( '[OpenCode] Startup fallback cleanup skipped because host registry cleanup is unavailable' ); return; } await cleanupOpenCodeHostProcessFallback(`${reason} fallback`, { mode: reason === 'shutdown' ? 'force' : 'orphaned', excludePids: reason === 'startup' ? registryHostPids : undefined, requiredDetailsMarkers: reason === 'shutdown' ? [`CLAUDE_TEAM_APP_INSTANCE_ID=${openCodeManagedHostInstanceId}`] : undefined, startedBeforeMs: reason === 'startup' ? appStartedAtMs : null, }); } async function cleanupOpenCodeHostProcessFallback( label: string, options: Parameters[0] ): Promise { const fallback = await cleanupManagedOpenCodeServeProcesses(options); if (fallback.killed > 0) { logger.info(`[OpenCode] ${label} cleanup killed ${fallback.killed} managed host(s)`); } for (const diagnostic of fallback.diagnostics) { logger.warn(`[OpenCode] ${label} cleanup: ${diagnostic}`); } } // --- Team display name cache (avoid listTeams() on every notification) --- const TEAM_DISPLAY_NAME_TTL_MS = 30_000; const teamDisplayNameCache = new Map(); let teamListInFlight: Promise> | null = null; async function refreshTeamDisplayNameCache(): Promise> { if (teamListInFlight) { return teamListInFlight; } teamListInFlight = (async () => { const out = new Map(); try { if (!teamDataService) return out; const summary = await teamDataService.listTeams(); for (const team of summary) { if (team?.teamName) { out.set(team.teamName, team.displayName || team.teamName); } } } catch { // ignore } finally { teamListInFlight = null; } return out; })(); return teamListInFlight; } /** Resolve human-friendly team display name, falling back to raw teamName. */ async function resolveTeamDisplayName(teamName: string): Promise { const cached = teamDisplayNameCache.get(teamName); if (cached && cached.expiresAt > Date.now()) { return cached.value; } const map = await refreshTeamDisplayNameCache(); const resolved = map.get(teamName) ?? teamName; teamDisplayNameCache.set(teamName, { value: resolved, expiresAt: Date.now() + TEAM_DISPLAY_NAME_TTL_MS, }); return resolved; } /** * Extracts human-readable summary and body from an inbox message. * Handles both plain text and serialized JSON ({"type":"message","content":"...","summary":"..."}). */ function extractNotificationContent(text: string): { summary: string; body: string } { const parsed = parseInboxJson(text); if (!parsed) return { summary: text.slice(0, 80), body: text }; const content = typeof parsed.content === 'string' ? parsed.content : null; const summary = typeof parsed.summary === 'string' ? parsed.summary : null; const message = typeof parsed.message === 'string' ? parsed.message : null; const bestBody = content || message || summary || text; const bestSummary = summary || (content ? content.slice(0, 80) : null) || message || text.slice(0, 80); return { summary: bestSummary, body: bestBody }; } async function notifyNewInboxMessages(teamName: string, detail: string): Promise { logger.debug(`[inbox-notify] called: team=${teamName} detail=${detail}`); const config = configManager.getConfig(); // Skip orphaned team directories without config.json (e.g., "default"). // Claude Code may write to these when its internal teamContext is lost after session resume. // Our stdout capture in TeamProvisioningService already persists these messages under the // correct team name via sentMessages.json, so inbox notifications from orphaned dirs // would be duplicates with a wrong team name. if (!existsSync(join(getTeamsBasePath(), teamName, 'config.json'))) { logger.debug(`[inbox-notify] skipped: no config.json for team=${teamName}`); return; // No config.json → orphaned team dir, skip notification } // detail is like "inboxes/carol.json" — extract member name const match = /^inboxes\/(.+)\.json$/.exec(detail); if (!match) return; const memberName = match[1]; // Determine inbox type and per-type toggle state. // Storage is always unconditional; toggles only suppress the OS toast. const leadName = teamDataService ? await teamDataService.getLeadMemberName(teamName) : null; const isLeadInbox = leadName !== null && memberName === leadName; const isUserInbox = memberName === 'user'; if (!isLeadInbox && !isUserInbox) return; const suppressToast = !config.notifications.enabled || (isLeadInbox && !config.notifications.notifyOnLeadInbox) || (isUserInbox && !config.notifications.notifyOnUserInbox); const key = `${teamName}:${memberName}`; try { const messages = await teamInboxReader.getMessagesFor(teamName, memberName); const isFirstLoad = !inboxMessageCounts.has(key); const prevCount = inboxMessageCounts.get(key) ?? 0; if (isFirstLoad) { // First load — seed count, don't notify for pre-existing messages logger.debug(`[inbox-notify] first load for ${key}: seeding count=${messages.length}`); inboxMessageCounts.set(key, messages.length); return; } if (messages.length <= prevCount) { inboxMessageCounts.set(key, messages.length); return; } // Messages are sorted newest-first, so new ones are at the beginning const newMessages = messages.slice(0, messages.length - prevCount); inboxMessageCounts.set(key, messages.length); logger.debug( `[inbox-notify] ${key}: prevCount=${prevCount} newCount=${messages.length} newMessages=${newMessages.length} suppressToast=${String(suppressToast)}` ); const teamDisplayName = await resolveTeamDisplayName(teamName); for (let i = 0; i < newMessages.length; i++) { const msg = newMessages[i]; // Skip messages sent from our own UI if (msg.source && suppressedSources.has(msg.source)) continue; // Skip app-owned private bootstrap/control prompts. They are durable runtime proof inputs, // not user-visible conversation messages. if (isTeamInternalControlMessageEnvelope(msg)) continue; // Skip internal review-pickup escalations. They are control-plane signals to the lead runtime, // not user-facing inbox messages. if (isReviewPickupEscalationMessage(msg)) continue; // Skip internal coordination noise (idle_notification, shutdown_*, etc.) if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue; const fromLabel = msg.from || 'Unknown'; const extracted = extractNotificationContent(msg.text); const summary = msg.summary || extracted.summary; const msgId = msg.timestamp ?? String(prevCount + i); // Cross-team messages get their own event type and per-type toggle const isCrossTeam = msg.source === 'cross_team'; const eventType: 'lead_inbox' | 'user_inbox' | 'cross_team_message' = isCrossTeam ? 'cross_team_message' : isLeadInbox ? 'lead_inbox' : 'user_inbox'; const effectiveSuppressToast = isCrossTeam ? !config.notifications.enabled || !config.notifications.notifyOnCrossTeamMessage : suppressToast; void notificationManager .addTeamNotification({ teamEventType: eventType, teamName, teamDisplayName, from: fromLabel, summary, body: extracted.body, dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`, target: isCrossTeam ? { kind: 'team', teamName, section: 'messages' } : { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' }, suppressToast: effectiveSuppressToast, }) .catch(() => undefined); } } catch (error) { logger.warn(`Failed to check inbox messages for ${key}:`, error); } } /** * Notify for new messages in sentMessages.json (lead → user messages). * Mirrors notifyNewInboxMessages() but reads from TeamSentMessagesStore. */ async function notifyNewSentMessages(teamName: string): Promise { const config = configManager.getConfig(); const suppressToast = !config.notifications.enabled || !config.notifications.notifyOnUserInbox; try { const messages = await sentMessagesStore.readMessages(teamName); const isFirstLoad = !sentMessageCounts.has(teamName); const prevCount = sentMessageCounts.get(teamName) ?? 0; if (isFirstLoad) { sentMessageCounts.set(teamName, messages.length); return; } if (messages.length <= prevCount) { sentMessageCounts.set(teamName, messages.length); return; } // Messages are appended at the end, new ones are at the tail const newMessages = messages.slice(prevCount); sentMessageCounts.set(teamName, messages.length); const teamDisplayName = await resolveTeamDisplayName(teamName); for (let i = 0; i < newMessages.length; i++) { const msg = newMessages[i]; if ((msg.to ?? '').trim() !== 'user') continue; // Skip messages sent from our own UI if (msg.source && suppressedSources.has(msg.source)) continue; // Skip internal coordination noise if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue; const fromLabel = msg.from || 'team-lead'; const extracted = extractNotificationContent(msg.text); const summary = msg.summary || extracted.summary; void notificationManager .addTeamNotification({ teamEventType: 'user_inbox', teamName, teamDisplayName, from: fromLabel, summary, body: extracted.body, dedupeKey: `sent:${teamName}:${msg.timestamp ?? String(prevCount + i)}`, target: { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' }, suppressToast, }) .catch(() => undefined); } } catch (error) { logger.warn(`Failed to check sent messages for ${teamName}:`, error); } } 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 codexAccountFeature: CodexAccountFeatureFacade | null = null; let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null; let recentProjectsFeature: RecentProjectsFeatureFacade; let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade; let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; let launchIoGovernor: LaunchIoGovernor | null = null; let cliInstallerService: CliInstallerService; let openCodeRuntimeInstallerService: OpenCodeRuntimeInstallerService; let ptyTerminalService: PtyTerminalService; let httpServer: HttpServer; let schedulerService: SchedulerService; let teamTaskStallMonitor: TeamTaskStallMonitor | null = null; let skillsWatcherService: SkillsWatcherService | null = null; let teamBackupService: TeamBackupService | null = null; let branchStatusService: BranchStatusService | null = null; let rendererRecoveryTimer: ReturnType | null = null; let rendererRecoveryAttempts = 0; let servicesReady = false; let rendererDidFinishLoad = false; let fileWatcherStartupStarted = false; let backgroundStartupTasksStarted = false; let appStartupHandlersRegistered = false; // File watcher event cleanup functions let fileChangeCleanup: (() => void) | null = null; let todoChangeCleanup: (() => void) | null = null; let teamChangeCleanup: (() => void) | null = null; let shutdownPromise: Promise | null = null; let shutdownComplete = false; const startupTimers = new Set>(); const SHUTDOWN_STEP_TIMEOUT_MS = 5_000; const STARTUP_RECOVERY_DELAY_MS = 10_000; const STARTUP_RECOVERY_CONCURRENCY = 1; const appStartupStartedAt = Date.now(); let appStartupSteps: AppStartupStep[] = [ { phase: 'boot', message: 'Starting Agent Teams AI...', startedAt: appStartupStartedAt, updatedAt: appStartupStartedAt, }, ]; let appStartupStatus: AppStartupStatus = { phase: 'boot', message: 'Starting Agent Teams AI...', ready: false, error: null, startedAt: appStartupStartedAt, updatedAt: appStartupStartedAt, steps: appStartupSteps, }; function isShutdownStarted(): boolean { return shutdownComplete || shutdownPromise !== null; } function scheduleStartupTask(action: () => void, delayMs: number): void { const timer = setTimeout(() => { startupTimers.delete(timer); if (isShutdownStarted()) { return; } action(); }, delayMs); timer.unref?.(); startupTimers.add(timer); } function registerAppStartupHandlers(): void { if (appStartupHandlersRegistered) { return; } appStartupHandlersRegistered = true; registerRendererLogHandlers(ipcMain); ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus); } function cloneStartupSteps(): AppStartupStep[] { return appStartupSteps.map((step) => ({ ...step })); } function updateStartupTimeline(update: Partial, now: number): void { if (!update.phase && !update.message) { return; } const phase = update.phase ?? appStartupStatus.phase; const message = update.message ?? appStartupStatus.message; const current = appStartupSteps[appStartupSteps.length - 1]; if (current?.phase !== phase) { if (current && !current.finishedAt) { current.finishedAt = now; current.durationMs = now - current.startedAt; current.updatedAt = now; } appStartupSteps.push({ phase, message, startedAt: now, updatedAt: now, }); if (appStartupSteps.length > 32) { appStartupSteps = appStartupSteps.slice(-32); } } else { current.message = message; current.updatedAt = now; } } function finishCurrentStartupStep(now: number): void { const current = appStartupSteps[appStartupSteps.length - 1]; if (!current || current.finishedAt) { return; } current.finishedAt = now; current.durationMs = now - current.startedAt; current.updatedAt = now; } function publishStartupStatus(update: Partial): void { const now = Date.now(); updateStartupTimeline(update, now); if (update.ready === true || update.error) { finishCurrentStartupStep(now); } appStartupStatus = { ...appStartupStatus, ...update, updatedAt: now, steps: cloneStartupSteps(), }; safeSendToRenderer(mainWindow, APP_STARTUP_PROGRESS, appStartupStatus); } async function runStartupJobsBounded( items: readonly T[], concurrency: number, run: (item: T) => Promise ): Promise { const workerCount = Math.max(1, Math.min(concurrency, items.length)); const workers = Array.from({ length: workerCount }, async (_, workerIndex) => { for (let index = workerIndex; index < items.length; index += workerCount) { if (isShutdownStarted()) { return; } await run(items[index]); } }); await Promise.allSettled(workers); } function clearStartupTimers(): void { for (const timer of startupTimers) { clearTimeout(timer); } startupTimers.clear(); } function clearInboxNotifyTimers(): void { for (const timer of inboxNotifyTimers.values()) { clearTimeout(timer); } inboxNotifyTimers.clear(); } async function runShutdownStep( label: string, action: () => void | Promise, timeoutMs: number = SHUTDOWN_STEP_TIMEOUT_MS ): Promise { let timeout: ReturnType | null = null; try { await Promise.race([ Promise.resolve().then(action), new Promise((resolve) => { timeout = setTimeout(() => { logger.warn(`Shutdown step timed out after ${timeoutMs}ms: ${label}`); resolve(); }, timeoutMs); timeout.unref?.(); }), ]); } catch (error) { logger.warn( `Shutdown step failed (${label}): ${error instanceof Error ? error.message : String(error)}` ); } finally { if (timeout) { clearTimeout(timeout); } } } /** * 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]; } function getTeamControlApiBaseUrl(): string | null { if (!httpServer?.isRunning()) { return null; } return buildTeamControlApiBaseUrl(httpServer.getPort()); } async function syncTeamControlApiState(): Promise { const baseUrl = getTeamControlApiBaseUrl(); if (!baseUrl) { await clearTeamControlApiState(); return; } await writeTeamControlApiState(baseUrl); } /** * 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 SCAN_CACHE_INVALIDATE_DEBOUNCE_MS = 250; let scanCacheInvalidateTimer: ReturnType | null = null; const scheduleScanCacheInvalidation = (): void => { if (scanCacheInvalidateTimer) { clearTimeout(scanCacheInvalidateTimer); } scanCacheInvalidateTimer = setTimeout(() => { scanCacheInvalidateTimer = null; context.projectScanner.clearScanCache(); }, SCAN_CACHE_INVALIDATE_DEBOUNCE_MS); }; const fileChangeHandler = (event: unknown): void => { // Avoid triggering a full project rescan on every session append. // The ProjectScanner already has a short TTL cache; we only invalidate for // structural changes (add/unlink), and we debounce bursts of events. try { if (event && typeof event === 'object') { const row = event as Partial; const isSubagent = row.isSubagent === true; const changeType = row.type; if (!isSubagent && (changeType === 'add' || changeType === 'unlink')) { scheduleScanCacheInvalidation(); } } else { // Fallback: if we can't classify the event, invalidate (debounced). scheduleScanCacheInvalidation(); } } catch { // ignore } safeSendToRenderer(mainWindow, 'file-change', event); httpServer?.broadcast('file-change', event); }; context.fileWatcher.on('file-change', fileChangeHandler); fileChangeCleanup = () => { context.fileWatcher.off('file-change', fileChangeHandler); if (scanCacheInvalidateTimer) { clearTimeout(scanCacheInvalidateTimer); scanCacheInvalidateTimer = null; } }; // Forward checklist-change events to renderer and HTTP SSE (mirrors file-change pattern above) const todoChangeHandler = (event: unknown): void => { safeSendToRenderer(mainWindow, 'todo-change', event); httpServer?.broadcast('todo-change', event); }; context.fileWatcher.on('todo-change', todoChangeHandler); todoChangeCleanup = () => context.fileWatcher.off('todo-change', todoChangeHandler); const reconcileScheduler = teamDataService ? createTeamReconcileDrainScheduler({ run: async (teamName: string, trigger: TeamReconcileTrigger) => { try { await teamDataService.reconcileTeamArtifacts(teamName, trigger); } catch (e) { if (trigger.source === 'task') { logger.warn( `[FileWatcher] task reconcile failed for ${teamName} detail=${trigger.detail}: ${String(e)}` ); } else { logger.warn( `[FileWatcher] reconcile failed for ${teamName} source=${trigger.source} detail=${trigger.detail}: ${String(e)}` ); } throw e; } }, }) : null; // Forward team-change events to renderer and HTTP SSE const teamChangeHandler = (event: unknown): void => { safeSendToRenderer(mainWindow, TEAM_CHANGE, event); httpServer?.broadcast('team-change', event); // Process inbox and task change events. try { if (!event || typeof event !== 'object') return; const row = event as { type?: unknown; teamName?: unknown; detail?: unknown }; if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return; const teamName = row.teamName.trim(); const detail = typeof row.detail === 'string' ? row.detail : ''; launchIoGovernor?.noteTeamChange(row as TeamChangeEvent); if (row.type === 'config') { if (detail === 'config.json') { TeamConfigReader.invalidateTeam(teamName); getTeamDataWorkerClient().invalidateTeamConfig(teamName); teamDataService?.invalidateTeamRuntimeAdvisories(teamName); getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName); } else if (detail === 'team.meta.json' || detail === 'members.meta.json') { TeamConfigReader.invalidateListTeamsCache(); getTeamDataWorkerClient().invalidateTeamConfig(teamName); teamDataService?.invalidateTeamRuntimeAdvisories(teamName); getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName); } } if (row.type === 'task') { TeamTaskReader.invalidateAllTasksCache(); teamDataService?.invalidateTeamRuntimeAdvisories(teamName); getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName); } if (row.type === 'member-advisory') { teamDataService?.invalidateTeamRuntimeAdvisories(teamName); getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName); } memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent); if ( teamDataService && (row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config') ) { teamDataService.invalidateMessageFeed(teamName); if (row.type === 'inbox' || row.type === 'lead-message') { getTeamDataWorkerClient().invalidateTeamMessageFeed(teamName); } } // --- Inbox change events: relay to lead + native OS notifications --- if (row.type === 'inbox') { if (reconcileScheduler) { reconcileScheduler.schedule(teamName, { source: 'inbox', detail }); } // Relay inbox changes into active runtime recipients. if (detail.startsWith('inboxes/')) { const match = /^inboxes\/(.+)\.json$/.exec(detail); if (match) { const inboxName = match[1]; void teamProvisioningService .relayInboxFileToLiveRecipient(teamName, inboxName) .then((relay) => { if (relay.diagnostics?.length) { logger.warn( `[FileWatcher] relay diagnostics for ${teamName}/${inboxName}: ${relay.diagnostics.join('; ')}` ); } }) .catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`) ); } } // Show native OS notification for new inbox messages (debounced per inbox). if (detail.startsWith('inboxes/')) { const timerKey = `${teamName}:${detail}`; const existing = inboxNotifyTimers.get(timerKey); if (existing) clearTimeout(existing); const timer = setTimeout(() => { inboxNotifyTimers.delete(timerKey); void notifyNewInboxMessages(teamName, detail).catch(() => undefined); }, INBOX_NOTIFY_DEBOUNCE_MS); timer.unref?.(); inboxNotifyTimers.set(timerKey, timer); } // Show native OS notification for new lead → user messages (sentMessages.json). if (detail === 'sentMessages.json') { const timerKey = `${teamName}:sentMessages`; const existing = inboxNotifyTimers.get(timerKey); if (existing) clearTimeout(existing); const timer = setTimeout(() => { inboxNotifyTimers.delete(timerKey); void notifyNewSentMessages(teamName).catch(() => undefined); }, INBOX_NOTIFY_DEBOUNCE_MS); timer.unref?.(); inboxNotifyTimers.set(timerKey, timer); } } // --- Task change events: notify lead when teammate starts a task via CLI --- if (row.type === 'task' && detail.endsWith('.json') && teamDataService) { reconcileScheduler?.schedule(teamName, { source: 'task', detail }); const taskId = detail.replace('.json', ''); void teamDataService .notifyLeadOnTeammateTaskStart(teamName, taskId) .catch((e: unknown) => logger.warn( `[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${String(e)}` ) ); void teamDataService .notifyLeadOnTeammateTaskComment(teamName, taskId) .catch((e: unknown) => logger.warn( `[FileWatcher] task comment notify failed for ${teamName}#${taskId}: ${String(e)}` ) ); // Schedule debounced backup for changed task file if (teamBackupService) { teamBackupService.scheduleTaskBackup(teamName, detail); } } // Backup on config changes (covers team ready, config updates) if (row.type === 'config' && detail === 'config.json' && teamBackupService) { void teamBackupService.backupTeam(teamName).catch(() => undefined); } } catch { // ignore } }; context.fileWatcher.on('team-change', teamChangeHandler); teamChangeCleanup = () => { context.fileWatcher.off('team-change', teamChangeHandler); reconcileScheduler?.dispose(); }; 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 safeSendToRenderer(mainWindow, SSH_STATUS, sshConnectionManager.getStatus()); safeSendToRenderer(mainWindow, 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. */ async function initializeServices(): Promise { logger.info('Initializing services...'); publishStartupStatus({ phase: 'services', message: 'Preparing app services...', ready: false, error: null, }); // 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 context and start cache cleanup only. // FileWatcher is deferred to did-finish-load to avoid blocking window creation // with fs.watch() setup (especially slow on Windows NTFS with recursive watchers). contextRegistry.registerContext(localContext); localContext.startCacheOnly(); 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); launchIoGovernor = new LaunchIoGovernor({ logger: createLogger('Service:LaunchIoGovernor'), }); // Wire file watcher events for local context wireFileWatcherEvents(localContext); // Initialize updater and CLI installer services updaterService = new UpdaterService(); updaterService.setBeforeQuitAndInstall(async () => { try { await shutdownServices(); } catch (error) { logger.error( `Shutdown before update install failed: ${ error instanceof Error ? error.message : String(error) }` ); } finally { shutdownComplete = true; } }); cliInstallerService = new CliInstallerService(); openCodeRuntimeInstallerService = new OpenCodeRuntimeInstallerService(); ptyTerminalService = new PtyTerminalService(); const teamMemberLogsFinder = new TeamMemberLogsFinder(); const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder); const taskLogConfigReader = new TeamConfigReader(); const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator( new TeamTranscriptProjectResolver({ getConfig: (teamName) => taskLogConfigReader.getConfigSnapshot(teamName), }) ); teamLogSourceTracker.onLogSourceChange((teamName) => { teamTranscriptSourceLocator.invalidateTeam(teamName); }); const boardTaskActivityRecordSource = new BoardTaskActivityRecordSource( teamTranscriptSourceLocator ); const boardTaskActivityService = new BoardTaskActivityService(boardTaskActivityRecordSource); const boardTaskActivityDetailService = new BoardTaskActivityDetailService( boardTaskActivityRecordSource ); const boardTaskExactLogsService = new BoardTaskExactLogsService(boardTaskActivityRecordSource); const boardTaskExactLogDetailService = new BoardTaskExactLogDetailService( boardTaskActivityRecordSource ); const boardTaskLogStreamService = new BoardTaskLogStreamService( boardTaskActivityRecordSource, undefined, undefined, undefined, undefined, undefined, teamTranscriptSourceLocator ); const memberLogStreamFeature = createMemberLogStreamFeature({ logsFinder: teamMemberLogsFinder, logSourceTracker: teamLogSourceTracker, runtimeBridge: new ClaudeMultimodelBridgeService(), configReader: taskLogConfigReader, logger: createLogger('Feature:MemberLogStream'), }); const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService( teamMemberLogsFinder ); teamDataService = new TeamDataService(); teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService); teamProvisioningService = new TeamProvisioningService(); teamProvisioningService.setWorkspaceTrustCoordinator( createWorkspaceTrustCoordinator({ claudeConfigDir: () => getClaudeBasePath(), globalConfigFilePath: () => { const claudeBasePath = getClaudeBasePath(); return claudeBasePath !== getAutoDetectedClaudeBasePath() ? join(claudeBasePath, '.claude.json') : join(getHomeDir(), '.claude.json'); }, }) ); teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => { teamDataService?.invalidateMemberRuntimeAdvisory(teamName, memberName); getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName, memberName); }); publishStartupStatus({ phase: 'runtime', message: 'Resolving local runtime...', }); teamProvisioningService.setRuntimeAdapterRegistry( await createOpenCodeRuntimeAdapterRegistry((phase, message) => publishStartupStatus({ phase, message }) ) ); scheduleStartupTask(() => { void cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) => logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`) ); }, STARTUP_RECOVERY_DELAY_MS); // Startup GC: remove stale MCP config files from previous sessions (best-effort) void new TeamMcpConfigBuilder().gcStaleConfigs(); void teamDataService .initializeTaskCommentNotificationState() .catch((error: unknown) => logger.warn(`[Init] task comment notification init failed: ${String(error)}`) ); teamBackupService = new TeamBackupService(); // Fire-and-forget: initializeServices() is sync, cannot await. // Safe because TeamBackupService.initialized flag blocks all backup/restore // operations until initialize() completes internally (restore → prune → set flag). void teamBackupService .initialize() .catch((error: unknown) => logger.warn(`[Init] TeamBackupService init failed: ${String(error)}`) ); // Cross-team communication service const crossTeamConfigReader = new TeamConfigReader(); const crossTeamInboxWriter = new TeamInboxWriter(); const crossTeamService = new CrossTeamService( crossTeamConfigReader, teamDataService, crossTeamInboxWriter, teamProvisioningService ); teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request)); const taskChangePresenceRepository = new JsonTaskChangePresenceRepository(); teamTaskStallMonitor = new TeamTaskStallMonitor( new ActiveTeamRegistry(teamDataService, teamLogSourceTracker), new TeamTaskStallSnapshotSource(teamTranscriptSourceLocator), new TeamTaskStallPolicy(), new TeamTaskStallJournal(), new TeamTaskStallNotifier(teamDataService, teamProvisioningService) ); let teammateToolTracker: TeammateToolTracker | null = null; branchStatusService = new BranchStatusService((event) => { safeSendToRenderer(mainWindow, TEAM_PROJECT_BRANCH_CHANGE, event); }); const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder); const taskBoundaryParser = new TaskBoundaryParser(); const changeExtractor = new ChangeExtractorService( teamMemberLogsFinder, taskBoundaryParser, undefined, undefined, undefined, openCodeLifecycleBridge ); teamDataService.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker); changeExtractor.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker); const gitDiffFallback = new GitDiffFallback(); const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback); const reviewApplier = new ReviewApplierService(); // Create SchedulerService for cron-based task execution const scheduleRepository = new JsonScheduleRepository(); const scheduledTaskExecutor = new ScheduledTaskExecutor(); schedulerService = new SchedulerService( scheduleRepository, scheduledTaskExecutor, async (cwd: string) => { const result = await teamProvisioningService.prepareForProvisioning(cwd, { forceFresh: true, }); return { ready: result.ready, message: result.message }; } ); // Extension Store services const pluginCatalogService = new PluginCatalogService(); const pluginStateService = new PluginInstallationStateService(); const officialMcpRegistry = new OfficialMcpRegistryService(); const glamaMcpService = new GlamaMcpEnrichmentService(); const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService); const extensionsRuntimeAdapter = createExtensionsRuntimeAdapter(); const mcpStateService = new McpInstallationStateService(extensionsRuntimeAdapter); const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(extensionsRuntimeAdapter); const skillsCatalogService = new SkillsCatalogService(); const skillsMutationService = new SkillsMutationService(); skillsWatcherService = new SkillsWatcherService(); const extensionFacadeService = new ExtensionFacadeService( pluginCatalogService, pluginStateService, mcpAggregator, mcpStateService ); // Install services — resolve binary dynamically via ClaudeBinaryResolver const pluginInstallService = new PluginInstallService( pluginCatalogService, extensionsRuntimeAdapter ); const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter); const apiKeyService = new ApiKeyService(); providerConnectionService.setApiKeyService(apiKeyService); publishStartupStatus({ phase: 'settings', message: 'Loading secure settings...', }); // warmup() and ensureInstalled() are deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. httpServer = new HttpServer(); teamProvisioningService.setControlApiBaseUrlResolver(async () => { if (!httpServer.isRunning()) { await startHttpServer(handleModeSwitch); } return getTeamControlApiBaseUrl(); }); const forwardTeamChange = (event: TeamChangeEvent): void => { launchIoGovernor?.noteTeamChange(event); if (event.type === 'config') { if (event.detail === 'config.json') { TeamConfigReader.invalidateTeam(event.teamName); getTeamDataWorkerClient().invalidateTeamConfig(event.teamName); teamDataService?.invalidateTeamRuntimeAdvisories(event.teamName); getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(event.teamName); } else if (event.detail === 'team.meta.json' || event.detail === 'members.meta.json') { TeamConfigReader.invalidateListTeamsCache(); getTeamDataWorkerClient().invalidateTeamConfig(event.teamName); teamDataService?.invalidateTeamRuntimeAdvisories(event.teamName); getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(event.teamName); } } if (event.type === 'task') { TeamTaskReader.invalidateAllTasksCache(); teamDataService?.invalidateTeamRuntimeAdvisories(event.teamName); getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(event.teamName); } if (event.type === 'member-advisory') { teamDataService?.invalidateTeamRuntimeAdvisories(event.teamName); getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(event.teamName); } if ( teamDataService && (event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config') ) { teamDataService.invalidateMessageFeed(event.teamName); if (event.type === 'inbox' || event.type === 'lead-message') { getTeamDataWorkerClient().invalidateTeamMessageFeed(event.teamName); } } safeSendToRenderer(mainWindow, TEAM_CHANGE, event); httpServer?.broadcast('team-change', event); }; teammateToolTracker = new TeammateToolTracker( teamMemberLogsFinder, teamLogSourceTracker, forwardTeamChange ); // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). const teamChangeEmitter = (event: TeamChangeEvent): void => { forwardTeamChange(event); teamTaskStallMonitor?.noteTeamChange(event); memberWorkSyncFeature?.noteTeamChange(event); if (event.type === 'lead-activity' && event.detail === 'offline') { teammateToolTracker?.handleTeamOffline(event.teamName); } }; teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter); teamLogSourceTracker.setEmitter(teamChangeEmitter); teamLogSourceTracker.onLogSourceChange((teamName) => { teammateToolTracker?.handleLogSourceChange(teamName); }); scheduleStartupTask(() => { void teamDataService .listTeams() .then(async (teams) => { const activeTeamNames = teams .filter((team) => !team.deletedAt) .map((team) => team.teamName); await runStartupJobsBounded( activeTeamNames, STARTUP_RECOVERY_CONCURRENCY, async (teamName) => { await teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(teamName); } ); }) .catch((error: unknown) => logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`) ); }, STARTUP_RECOVERY_DELAY_MS); teamTaskStallMonitor.start(); // Allow SchedulerService to push schedule events to renderer schedulerService.setChangeEmitter((event) => { safeSendToRenderer(mainWindow, SCHEDULE_CHANGE, event); }); skillsWatcherService.setEmitter((event) => { safeSendToRenderer(mainWindow, SKILLS_CHANGED, event); }); teamProvisioningService.setToolApprovalEventEmitter((event) => { safeSendToRenderer(mainWindow, TEAM_TOOL_APPROVAL_EVENT, event); }); teamProvisioningService.setMainWindow(mainWindow); recentProjectsFeature = createRecentProjectsFeature({ getActiveContext: () => contextRegistry.getActive(), getLocalContext: () => contextRegistry.get('local'), logger: createLogger('Feature:RecentProjects'), }); runtimeProviderManagementFeature = createRuntimeProviderManagementFeature(); memberWorkSyncFeature = createMemberWorkSyncFeature({ teamsBasePath: getTeamsBasePath(), configReader: new TeamConfigReader(), taskReader: new TeamTaskReader(), kanbanManager: new TeamKanbanManager(), membersMetaStore: new TeamMembersMetaStore(), isTeamActive: (teamName) => teamProvisioningService.isTeamAlive(teamName) || teamProvisioningService.hasProvisioningRun(teamName), canDispatchNudges: (teamName) => teamProvisioningService.isTeamAlive(teamName), listLifecycleActiveTeamNames: async () => { const teams = await teamDataService.listTeams(); return teams .filter( (team) => !team.deletedAt && (teamProvisioningService.isTeamAlive(team.teamName) || teamProvisioningService.hasProvisioningRun(team.teamName)) ) .map((team) => team.teamName); }, extraBusySignals: [ { isBusy: (input) => teamProvisioningService.getOpenCodeMemberDeliveryBusyStatus(input), }, ], proofMissingRecoveryGuard: { shouldDispatch: async (input) => { const status = await teamProvisioningService.getOpenCodeRuntimeDeliveryStatus( input.teamName, input.originalMessageId ); if (!status) { return { ok: false, reason: 'proof_missing_recovery_record_missing', retryable: false, }; } const impact = status.userVisibleImpact; if (impact?.reasonCode === 'protocol_proof_missing') { if (impact.state === 'checking') { return { ok: false, reason: 'proof_missing_recovery_still_in_grace', retryable: true, ...(impact.nextReviewAt ? { nextAttemptAt: impact.nextReviewAt } : {}), }; } return { ok: true }; } if (status.responsePending) { return { ok: false, reason: 'proof_missing_recovery_delivery_still_pending', retryable: true, }; } return { ok: false, reason: 'proof_missing_recovery_suppressed', retryable: false, }; }, }, nudgeDeliveryWake: { schedule: async (input) => { if (input.providerId === 'opencode') { teamProvisioningService.scheduleOpenCodeMemberInboxDeliveryWake({ teamName: input.teamName, memberName: input.memberName, messageId: input.messageId, delayMs: input.delayMs, }); return; } const leadName = await teamDataService.getLeadMemberName(input.teamName).catch(() => null); if (leadName?.trim().toLowerCase() !== input.memberName.trim().toLowerCase()) { return; } const timer = setTimeout( () => { void teamProvisioningService .relayLeadInboxMessages(input.teamName) .catch((error: unknown) => logger.warn( `[${input.teamName}] member-work-sync lead nudge relay wake failed: ${String( error )}` ) ); }, Math.max(0, input.delayMs ?? 0) ); timer.unref?.(); }, }, reviewPickupDelivery: { canDeliver: (input) => input.providerId === 'opencode' ? { ok: true } : { ok: false, reason: `provider_not_supported:${input.providerId ?? 'unknown'}`, }, deliver: async (input) => { if (input.providerId !== 'opencode') { return { ok: false, reason: 'capability_absent', message: `provider_not_supported:${input.providerId ?? 'unknown'}`, }; } const relay = await teamProvisioningService.relayOpenCodeMemberInboxMessages( input.teamName, input.memberName, { onlyMessageId: input.messageId, source: 'member-work-sync-review-pickup', deliveryMetadata: { actionMode: input.payload.actionMode, taskRefs: input.payload.taskRefs, }, } ); const lastDelivery = relay.lastDelivery; const diagnostics = [...(relay.diagnostics ?? []), ...(lastDelivery?.diagnostics ?? [])]; if (lastDelivery?.accepted === true && lastDelivery.responsePending === true) { return { ok: true, state: 'prompt_accepted', messageId: input.messageId, diagnostics, }; } if (lastDelivery?.delivered && lastDelivery.accepted !== false) { return { ok: true, state: lastDelivery.responsePending ? 'prompt_accepted' : 'response_proven', messageId: input.messageId, diagnostics, }; } if ( lastDelivery?.reason === 'recipient_is_not_opencode' || lastDelivery?.reason === 'recipient_removed' || lastDelivery?.reason === 'opencode_recipient_unavailable' ) { return { ok: false, reason: 'capability_absent', message: lastDelivery.reason, diagnostics, }; } if (lastDelivery?.ledgerStatus === 'failed_terminal') { return { ok: false, reason: 'terminal_failure', message: lastDelivery.reason ?? 'opencode_review_pickup_delivery_failed_terminal', diagnostics, }; } return { ok: false, reason: 'retryable_failure', message: lastDelivery?.reason ?? 'opencode_review_pickup_delivery_not_confirmed', diagnostics, }; }, }, reviewPickupEscalation: { escalate: async (input) => { const leadName = (await teamDataService.getLeadMemberName(input.teamName)) ?? 'team-lead'; const messageId = buildMemberWorkSyncReviewPickupEscalationMessageId(input); const existing = await teamInboxReader.getMessagesFor(input.teamName, leadName); if (existing.some((message) => message.messageId === messageId)) { return; } await teamInboxWriter.sendMessage(input.teamName, { member: leadName, from: 'system', to: leadName, messageId, timestamp: input.nowIso, summary: 'Review pickup still pending', text: buildMemberWorkSyncReviewPickupEscalationText(input), taskRefs: input.taskRefs.map((taskRef) => ({ taskId: taskRef.taskId, displayId: taskRef.displayId ?? taskRef.taskId.slice(0, 8), teamName: taskRef.teamName ?? input.teamName, })), actionMode: 'do', source: 'system_notification', }); }, }, logger: createLogger('Feature:MemberWorkSync'), }); teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) => memberWorkSyncFeature ? memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings(input) : Promise.resolve(null) ); teamProvisioningService.setRuntimeTurnSettledEnvironmentProvider((input) => memberWorkSyncFeature ? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input) : Promise.resolve(null) ); teamProvisioningService.setMemberWorkSyncProofMissingRecoveryScheduler((input) => memberWorkSyncFeature ? memberWorkSyncFeature.scheduleProofMissingRecovery(input) : Promise.resolve({ scheduled: false, reason: 'invalid' }) ); scheduleStartupTask(() => { void teamDataService .listTeams() .then(async (teams) => { const lifecycleActiveTeamNames = teams .filter( (team) => !team.deletedAt && (teamProvisioningService.isTeamAlive(team.teamName) || teamProvisioningService.hasProvisioningRun(team.teamName)) ) .map((team) => team.teamName); await memberWorkSyncFeature?.replayPendingReports(lifecycleActiveTeamNames); await memberWorkSyncFeature?.enqueueStartupScan(lifecycleActiveTeamNames); }) .catch((error: unknown) => logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`) ); }, STARTUP_RECOVERY_DELAY_MS + 2_000); codexAccountFeature = createCodexAccountFeature({ logger: createLogger('Feature:CodexAccount'), configManager, }); providerConnectionService.setCodexAccountFeature(codexAccountFeature); codexModelCatalogFeature = createCodexModelCatalogFeature({ logger: createLogger('Feature:CodexModelCatalog'), codexAccountFeature, }); providerConnectionService.setCodexModelCatalogFeature(codexModelCatalogFeature); // startProcessHealthPolling() is deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. publishStartupStatus({ phase: 'ipc', message: 'Wiring app actions...', }); // Initialize IPC handlers with registry initializeIpcHandlers( contextRegistry, updaterService, sshConnectionManager, teamDataService, teamProvisioningService, teamMemberLogsFinder, memberStatsComputer, boardTaskActivityService, boardTaskActivityDetailService, boardTaskLogStreamService, boardTaskExactLogsService, boardTaskExactLogDetailService, teammateToolTracker ?? undefined, teamLogSourceTracker, branchStatusService ?? undefined, { rewire: rewireContextEvents, full: onContextSwitched, onClaudeRootPathUpdated: (_claudeRootPath: string | null) => { reconfigureLocalContextForClaudeRoot(); void schedulerService?.reloadForClaudeRootChange(); if (httpServer?.isRunning()) { void syncTeamControlApiState().catch(() => undefined); } }, }, { httpServer, startHttpServer: () => startHttpServer(handleModeSwitch), }, changeExtractor, fileContentResolver, reviewApplier, gitDiffFallback, cliInstallerService, openCodeRuntimeInstallerService, ptyTerminalService, schedulerService, extensionFacadeService, pluginInstallService, mcpInstallService, apiKeyService, mcpHealthDiagnosticsService, skillsCatalogService, skillsMutationService, skillsWatcherService, crossTeamService, teamBackupService ?? undefined, launchIoGovernor ?? undefined ); registerCodexAccountIpc(ipcMain, codexAccountFeature); registerRecentProjectsIpc(ipcMain, recentProjectsFeature); registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature); registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature); registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature); // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { safeSendToRenderer(mainWindow, 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).catch(() => undefined); } logger.info('Services initialized successfully'); publishStartupStatus({ phase: 'readying', message: 'Finishing startup...', }); } /** * Starts the HTTP sidecar server with services from the active context. */ async function startHttpServer( modeSwitchHandler: (mode: 'local' | 'ssh') => Promise ): Promise { if (isShutdownStarted()) { return; } try { if (httpServer.isRunning()) { await syncTeamControlApiState(); return; } 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, recentProjectsFeature, memberWorkSyncFeature: memberWorkSyncFeature ?? undefined, updaterService, sshConnectionManager, teamDataService, teamProvisioningService, }, modeSwitchHandler, config.httpServer?.port ?? 3456 ); if (isShutdownStarted()) { await httpServer.stop().catch(() => undefined); await clearTeamControlApiState().catch(() => undefined); return; } await syncTeamControlApiState(); logger.info(`HTTP sidecar server running on port ${port}`); } catch (error) { await clearTeamControlApiState().catch(() => undefined); logger.error('Failed to start HTTP server:', error); throw error; } } /** * Shuts down all services. */ async function shutdownServices(): Promise { if (shutdownPromise) { return shutdownPromise; } shutdownPromise = (async () => { logger.info('Shutting down services...'); clearStartupTimers(); clearInboxNotifyTimers(); // Clear pending auto-resume timers before anything else. Dangling timers can // keep the event loop alive and fire against a torn-down provisioning service. clearAutoResumeService(); // Kill all team CLI processes via SIGKILL before anything else. // This must happen before the OS closes stdin pipes on app exit, because // stdin EOF triggers CLI cleanup that can delete team files. if (teamProvisioningService) { await runShutdownStep('stop all teams', () => teamProvisioningService.stopAllTeams(), 10_000); } await runShutdownStep( 'OpenCode host registry cleanup', () => cleanupOpenCodeHostsForLifecycle('shutdown'), 10_000 ); await runShutdownStep('Agent Teams MCP HTTP server cleanup', () => agentTeamsMcpHttpServer.stop() ); await runShutdownStep('tracked CLI subprocess cleanup', () => killTrackedCliProcesses('SIGKILL') ); await runShutdownStep( 'OpenCode post-subprocess fallback cleanup', () => cleanupOpenCodeHostProcessFallback('post-subprocess shutdown fallback', { mode: 'force', requiredDetailsMarkers: [`CLAUDE_TEAM_APP_INSTANCE_ID=${openCodeManagedHostInstanceId}`], }), 5_000 ); await runShutdownStep('MCP config GC', () => new TeamMcpConfigBuilder().gcOwnConfigs()); // Sync backup all team data. Files are stable after SIGKILL. if (teamBackupService) { await runShutdownStep('team backup sync', () => teamBackupService?.runShutdownBackupSync()); } if (httpServer?.isRunning()) { await runShutdownStep('HTTP server stop', () => httpServer.stop()); } await runShutdownStep('team control state cleanup', () => clearTeamControlApiState()); await runShutdownStep('file watcher event cleanup', () => { if (fileChangeCleanup) { fileChangeCleanup(); fileChangeCleanup = null; } if (todoChangeCleanup) { todoChangeCleanup(); todoChangeCleanup = null; } if (teamChangeCleanup) { teamChangeCleanup(); teamChangeCleanup = null; } }); await runShutdownStep('editor cleanup', () => cleanupEditorState()); if (contextRegistry) { await runShutdownStep('context registry dispose', () => contextRegistry.dispose()); } if (sshConnectionManager) { await runShutdownStep('SSH connection manager dispose', () => sshConnectionManager.dispose()); } if (teamDataService) { await runShutdownStep('team data polling stop', () => teamDataService.stopProcessHealthPolling() ); } if (updaterService) { await runShutdownStep('updater periodic check stop', () => updaterService.stopPeriodicCheck() ); } if (teamTaskStallMonitor) { await runShutdownStep('team task stall monitor stop', () => teamTaskStallMonitor?.stop()); teamTaskStallMonitor = null; } await runShutdownStep('branch status dispose', () => branchStatusService?.dispose()); branchStatusService = null; if (schedulerService) { await runShutdownStep('scheduler stop', () => schedulerService.stop()); } await runShutdownStep('skills watcher stop', () => skillsWatcherService?.stopAll()); await runShutdownStep('provider connection feature detach', () => { providerConnectionService.setCodexModelCatalogFeature(null); providerConnectionService.setCodexAccountFeature(null); }); await runShutdownStep('Codex model catalog dispose', () => codexModelCatalogFeature?.dispose()); codexModelCatalogFeature = null; await runShutdownStep('Codex account dispose', () => codexAccountFeature?.dispose()); codexAccountFeature = null; await runShutdownStep('member work sync dispose', () => memberWorkSyncFeature?.dispose()); memberWorkSyncFeature = null; if (ptyTerminalService) { await runShutdownStep('PTY terminals kill', () => ptyTerminalService.killAll()); } await runShutdownStep('IPC handlers cleanup', () => { removeIpcHandlers(); removeCodexAccountIpc(ipcMain); removeRecentProjectsIpc(ipcMain); removeRuntimeProviderManagementIpc(ipcMain); removeMemberWorkSyncIpc(ipcMain); removeMemberLogStreamIpc(ipcMain); }); await runShutdownStep('team backup dispose', () => teamBackupService?.dispose()); logger.info('Services shut down successfully'); })(); return shutdownPromise; } /** * 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); } safeSendToRenderer(win, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor); } function attachMainWindowToServices(): void { const win = mainWindow; if (!win || win.isDestroyed()) { return; } notificationManager?.setMainWindow(win); updaterService?.setMainWindow(win); cliInstallerService?.setMainWindow(win); openCodeRuntimeInstallerService?.setMainWindow(win); setCodexRuntimeMainWindow(win); setTmuxMainWindow(win); ptyTerminalService?.setMainWindow(win); teamProvisioningService?.setMainWindow(win); codexAccountFeature?.setMainWindow(win); setEditorMainWindow(win); setReviewMainWindow(win); } function runPostRendererStartupTasks(): void { if (!servicesReady || !rendererDidFinishLoad || !mainWindow || mainWindow.isDestroyed()) { return; } if (!fileWatcherStartupStarted) { fileWatcherStartupStarted = true; // Start file watchers after both the visible window and main services are ready. const activeContext = contextRegistry.getActive(); if (process.platform === 'win32') { scheduleStartupTask(() => { if (!fileWatcherStartupStarted || !servicesReady || !rendererDidFinishLoad) { return; } activeContext.startFileWatcher(); }, 1500); } else if (!isShutdownStarted()) { activeContext.startFileWatcher(); } } if (backgroundStartupTasksStarted) { return; } backgroundStartupTasksStarted = true; if (!isShutdownStarted()) { scheduleStartupTask(() => void updaterService.checkForUpdates(), 3000); updaterService.startPeriodicCheck(60 * 60 * 1000); } scheduleStartupTask( () => { void getTeamFsWorkerClient() .prewarm() .catch((error: unknown) => logger.debug( `[startup] team-fs-worker prewarm skipped: ${ error instanceof Error ? error.message : String(error) }` ) ); void getTeamDataWorkerClient() .prewarm() .catch((error: unknown) => logger.debug( `[startup] team-data-worker prewarm skipped: ${ error instanceof Error ? error.message : String(error) }` ) ); }, process.platform === 'win32' ? 2500 : 1000 ); scheduleStartupTask(() => { void teamProvisioningService.warmup(); teamDataService.startProcessHealthPolling(); void schedulerService?.start(); }, 5000); } function scheduleRendererRecovery(win: BrowserWindow): void { if (isShutdownStarted()) { return; } if (rendererRecoveryTimer) { return; } if (rendererRecoveryAttempts >= 2) { logger.error('Renderer recovery limit reached; skipping automatic reload'); return; } rendererRecoveryAttempts += 1; const delayMs = rendererRecoveryAttempts * 1000; logger.warn(`Scheduling renderer recovery attempt ${rendererRecoveryAttempts} in ${delayMs}ms`); rendererRecoveryTimer = setTimeout(() => { rendererRecoveryTimer = null; if (isShutdownStarted()) { return; } if (!mainWindow || mainWindow !== win || win.isDestroyed()) { return; } markRendererUnavailable(win); try { win.webContents.reload(); } catch (error) { logger.error(`Renderer recovery reload failed: ${String(error)}`); } }, delayMs); rendererRecoveryTimer.unref?.(); } /** * Creates the main application window. */ function createWindow(): void { if (isShutdownStarted()) { return; } rendererDidFinishLoad = false; const isMac = process.platform === 'darwin'; const isDev = process.env.NODE_ENV === 'development'; const iconPath = isMac ? undefined : getAppIconPath(); 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, // In development, use a persistent partition so that renderer-side storage // (localStorage, IndexedDB — used by comment read state, etc.) survives // app restarts. A fixed name is used instead of per-PID to keep data stable. ...(isDev ? { partition: 'persist:dev' } : {}), }, backgroundColor: '#1a1a1a', ...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }), ...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }), title: 'Agent Teams AI', }); markRendererUnavailable(mainWindow); // In dev, forward selected renderer console warnings/errors to the main terminal. // Use the new single-argument event payload to avoid Electron deprecation warnings. if (isDev) { mainWindow.webContents.on('console-message', (details: unknown) => { if (!details || typeof details !== 'object') return; const d = details as { level?: unknown; message?: unknown; lineNumber?: unknown; sourceId?: unknown; }; const level = typeof d.level === 'string' ? d.level : 'info'; if (level !== 'warning' && level !== 'error') return; const message = typeof d.message === 'string' ? d.message.trim() : ''; if (!message) return; const isNamespaced = message.startsWith('[Store:') || message.startsWith('[Component:') || message.startsWith('[IPC:') || message.startsWith('[Service:') || message.startsWith('[Perf:') || message.startsWith('[startup]'); if (!isNamespaced) return; const sourceId = typeof d.sourceId === 'string' ? d.sourceId : 'unknown'; const line = typeof d.lineNumber === 'number' ? d.lineNumber : -1; logger.warn(`RendererConsole: ${message} (${sourceId}:${line})`); }); } // Load the renderer if (isDev) { // electron-vite may move the dev server off 5173 if it's already taken. // Always prefer the URL it provides via env; fallback to the default port. const envUrl = process.env.ELECTRON_RENDERER_URL || process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_VITE_DEV_SERVER_URL; const devUrl = envUrl?.trim() || `http://localhost:${DEV_SERVER_PORT}`; if (!envUrl) { logger.warn( `[dev] renderer dev server URL env not set; falling back to ${devUrl}. ` + `If you see "Port 5173 is in use" in the terminal, the UI may appear stuck until this is fixed.` ); } else { logger.warn(`[dev] loading renderer from ${devUrl}`); } void mainWindow.loadURL(devUrl); 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', () => { safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, true); }); mainWindow.on('leave-full-screen', () => { safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, false); }); mainWindow.webContents.on('did-start-loading', () => { if (isShutdownStarted()) { return; } rendererDidFinishLoad = false; markRendererUnavailable(mainWindow); branchStatusService?.resetAllTracking(); }); // Set traffic light position + notify renderer on first load, and auto-check for updates mainWindow.webContents.on('did-finish-load', () => { if (mainWindow && !mainWindow.isDestroyed()) { if (isShutdownStarted()) { return; } markRendererReady(mainWindow); rendererRecoveryAttempts = 0; if (rendererRecoveryTimer) { clearTimeout(rendererRecoveryTimer); rendererRecoveryTimer = null; } logger.warn('[startup] renderer did-finish-load'); syncTrafficLightPosition(mainWindow); const fullscreenSyncTimer = setTimeout(() => { if (!isShutdownStarted()) { safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, mainWindow?.isFullScreen()); } }, 0); fullscreenSyncTimer.unref?.(); rendererDidFinishLoad = true; runPostRendererStartupTasks(); } }); mainWindow.webContents.on('dom-ready', () => { logger.warn('[startup] renderer dom-ready'); }); // 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; // Cmd on macOS, Ctrl on Windows/Linux — unified modifier for cross-platform shortcuts const isMod = input.meta || input.control; // 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 / Ctrl+N from opening new window; forward to renderer for review shortcuts if (isMod && input.key.toLowerCase() === 'n') { event.preventDefault(); safeSendToRenderer(mainWindow, 'review:cmdN'); return; } if (!isMod) 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') { const zoomSyncTimer = setTimeout(() => { if (!isShutdownStarted() && mainWindow && !mainWindow.isDestroyed()) { syncTrafficLightPosition(mainWindow); } }, 100); zoomSyncTimer.unref?.(); } }); mainWindow.on('closed', () => { if (rendererRecoveryTimer) { clearTimeout(rendererRecoveryTimer); rendererRecoveryTimer = null; } clearRendererAvailability(mainWindow); mainWindow = null; // Clear main window references if (notificationManager) { notificationManager.setMainWindow(null); } if (updaterService) { updaterService.stopPeriodicCheck(); updaterService.setMainWindow(null); } if (cliInstallerService) { cliInstallerService.setMainWindow(null); } if (openCodeRuntimeInstallerService) { openCodeRuntimeInstallerService.setMainWindow(null); } setCodexRuntimeMainWindow(null); setTmuxMainWindow(null); if (ptyTerminalService) { ptyTerminalService.setMainWindow(null); } if (teamProvisioningService) { teamProvisioningService.setMainWindow(null); } codexAccountFeature?.setMainWindow(null); setEditorMainWindow(null); setReviewMainWindow(null); cleanupEditorState(); }); // 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); if (isShutdownStarted()) { return; } markRendererUnavailable(mainWindow); rendererDidFinishLoad = false; fileWatcherStartupStarted = false; branchStatusService?.resetAllTracking(); contextRegistry?.getActive()?.stopFileWatcher(); if (mainWindow) { scheduleRendererRecovery(mainWindow); } }); attachMainWindowToServices(); logger.info('Main window created'); } /** * Application ready handler. */ void app.whenReady().then(async () => { logger.info('App ready, initializing...'); registerAppStartupHandlers(); try { publishStartupStatus({ phase: 'electron-ready', message: 'Opening window...', }); const config = configManager.getConfig(); // Sync Sentry telemetry opt-in flag from persisted config syncTelemetryFlag(config.general.telemetryEnabled); // Apply launch-at-login only where Electron can persist it without noisy OS errors. // Local packaged macOS smoke builds run outside /Applications and cannot set login items. const canSyncLaunchAtLogin = app.isPackaged && (process.platform === 'win32' || (process.platform === 'darwin' && app.isInApplicationsFolder())); if (canSyncLaunchAtLogin) { 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. } createWindow(); await initializeServices(); servicesReady = true; attachMainWindowToServices(); publishStartupStatus({ phase: 'ready', message: 'Ready', ready: true, error: null, }); runPostRendererStartupTasks(); // Listen for notification click events notificationManager.on('notification-clicked', (_error) => { if (isShutdownStarted()) { return; } if (mainWindow) { mainWindow.show(); mainWindow.focus(); } }); } catch (error) { logger.error('Startup initialization failed:', error); publishStartupStatus({ phase: 'failed', message: 'Startup failed', ready: false, error: error instanceof Error ? error.message : String(error), }); if (!mainWindow) { createWindow(); } } app.on('activate', () => { if (isShutdownStarted()) { return; } if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); /** * All windows closed handler. */ app.on('window-all-closed', () => { const shouldQuitWhenAllWindowsClosed = process.platform !== 'darwin' || !configManager.getConfig().general.showDockIcon; if (shouldQuitWhenAllWindowsClosed) { app.quit(); } }); /** * Before quit handler - cleanup. */ app.on('before-quit', (event) => { if (shutdownComplete) { return; } event.preventDefault(); notificationManager.closeActiveNativeNotifications('app-before-quit'); for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) { win.hide(); } } void shutdownServices() .catch((error) => { logger.error(`Shutdown failed: ${error instanceof Error ? error.message : String(error)}`); }) .finally(() => { shutdownComplete = true; app.quit(); }); });