2612 lines
108 KiB
TypeScript
2612 lines
108 KiB
TypeScript
/* eslint-disable max-lines -- main-process entry point; owns app lifecycle, service wiring, window creation, and hook/daemon startup with no cleaner split seam. */
|
|
import { existsSync, statSync } from 'node:fs'
|
|
import { isAbsolute, join } from 'node:path'
|
|
import os from 'node:os'
|
|
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type Tray } from 'electron'
|
|
import { electronApp, is } from '@electron-toolkit/utils'
|
|
import * as QRCode from 'qrcode'
|
|
import {
|
|
Store,
|
|
initDataPath,
|
|
getCanonicalUserDataPath,
|
|
migrateMobilePairingDataToCanonicalUserDataPath
|
|
} from './persistence'
|
|
import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence'
|
|
import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store'
|
|
import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config'
|
|
import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths'
|
|
import { applyAppIcon } from './app-icon'
|
|
import { relaunchApp } from './app-relaunch'
|
|
import { StatsCollector, initStatsPath } from './stats/collector'
|
|
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
|
|
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
|
|
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store'
|
|
import { killAllPty } from './ipc/pty'
|
|
import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init'
|
|
import { closeAllWatchers } from './ipc/filesystem-watcher'
|
|
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
|
|
import { registerCoreHandlers } from './ipc/register-core-handlers'
|
|
import { initObservability, shutdownObservability } from './observability'
|
|
import { registerMobileHandlers } from './ipc/mobile'
|
|
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client'
|
|
import { classifyError } from './telemetry/classify-error'
|
|
import { runManagedHookInstallers } from './agent-hooks/install-telemetry'
|
|
import {
|
|
isAgentStatusHooksEnabled,
|
|
MANAGED_AGENT_HOOK_INSTALLERS,
|
|
removeManagedAgentHooks
|
|
} from './agent-hooks/managed-agent-hook-controls'
|
|
import { initCohortClassifier } from './telemetry/cohort-classifier'
|
|
import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier'
|
|
import { resolveConsent } from './telemetry/consent'
|
|
import { triggerStartupNotificationRegistration } from './ipc/notifications'
|
|
import { OrcaRuntimeService } from './runtime/orca-runtime'
|
|
import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity'
|
|
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
|
|
import { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint'
|
|
import { ServeReadinessPublisher } from './server/serve-readiness'
|
|
import { reserveServeStdoutForReadiness } from './server/serve-stdout-boundary'
|
|
import { DesktopRelayService } from './runtime/relay/desktop-relay-service'
|
|
import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker'
|
|
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
|
|
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
|
|
import { ensureMainI18n, setMainUiLanguage } from './i18n/main-i18n'
|
|
import {
|
|
getNextDefaultOnAppearanceSettingValue,
|
|
registerAppMenu,
|
|
rebuildAppMenu
|
|
} from './menu/register-app-menu'
|
|
import {
|
|
checkForRemoteServerUpdate,
|
|
checkForUpdatesFromMenu,
|
|
downloadRemoteServerUpdate,
|
|
getRemoteServerUpdaterSnapshot,
|
|
installRemoteServerUpdate,
|
|
isQuittingForUpdate,
|
|
resolveUpdateInstallMode
|
|
} from './updater'
|
|
import { configureRemoteServerUpdater } from './runtime/remote-server-updater'
|
|
import type { TuiAgent, UpdateCheckOptions } from '../shared/types'
|
|
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
|
import {
|
|
installServeSupervisorDisconnectQuit,
|
|
notifyServeSupervisorReady
|
|
} from './serve-update-handoff'
|
|
import {
|
|
configureElectronNetworkCompatibility,
|
|
configureDevUserDataPath,
|
|
configureOrcaUserDataPathEnv,
|
|
enableMainProcessGpuFeatures,
|
|
installDevParentDisconnectQuit,
|
|
installDevParentSignalQuit,
|
|
installDevParentWatchdog,
|
|
isDevParentShutdownRequested,
|
|
patchPackagedProcessPath,
|
|
shouldInstallManagedHooks
|
|
} from './startup/configure-process'
|
|
import {
|
|
installUncaughtPipeErrorGuard,
|
|
installUnhandledRejectionLogging
|
|
} from './startup/main-process-error-guards'
|
|
import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom'
|
|
import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display'
|
|
import {
|
|
readActiveGpuFallbackMarker,
|
|
writeGpuFallbackMarker,
|
|
type GpuFallbackEnvironment,
|
|
type WindowsGpuFallbackEnvironment
|
|
} from './startup/gpu-fallback-marker'
|
|
import {
|
|
DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD,
|
|
DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
|
|
GpuCrashFallbackTracker,
|
|
isGpuFallbackCrashCandidate
|
|
} from './crash-reporting/gpu-crash-fallback-decision'
|
|
import {
|
|
promptForGpuFallbackRestart,
|
|
type GpuFallbackRestartDecision
|
|
} from './crash-reporting/gpu-fallback-restart-prompt'
|
|
import {
|
|
shouldSuppressDevEducation,
|
|
suppressDevEducationForStore
|
|
} from './startup/dev-education-suppression'
|
|
import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect'
|
|
import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect'
|
|
import { startFirstWindowStartupServices } from './startup/first-window-startup-services'
|
|
import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier'
|
|
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
|
|
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
|
|
import {
|
|
acquireSingleInstanceLock,
|
|
logSingleInstanceLockBypass,
|
|
logSingleInstanceLockFailure,
|
|
shouldBypassSingleInstanceLock,
|
|
shouldSkipSingleInstanceLock
|
|
} from './startup/single-instance-lock'
|
|
import { startEventLoopStallProbe } from './startup/event-loop-stall-probe'
|
|
import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe'
|
|
import {
|
|
isStartupDiagnosticsEnabled,
|
|
logStartupDiagnostic,
|
|
logStartupMilestone
|
|
} from './startup/startup-diagnostics'
|
|
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
|
|
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
|
|
import { createServeDesktopActivationGate } from './startup/serve-desktop-activation'
|
|
import { RateLimitService } from './rate-limits/service'
|
|
import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store'
|
|
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
|
|
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
|
|
import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync'
|
|
import {
|
|
attachMainWindowServices,
|
|
ensureAutoUpdaterConfigured
|
|
} from './window/attach-main-window-services'
|
|
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
|
|
import { zoomDashboardPopoutIfFocused } from './window/dashboard-popout-window'
|
|
import {
|
|
createSystemTray,
|
|
destroySystemTray,
|
|
setMacMenuBarIconVisible,
|
|
setTrayAttention,
|
|
type SystemTrayOptions
|
|
} from './tray/system-tray'
|
|
import { focusExistingMainWindow } from './window/focus-existing-window'
|
|
import { notifyMainWindowBecameVisible } from './window/main-window-visibility'
|
|
import { CodexAccountService } from './codex-accounts/service'
|
|
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
|
|
import { markCodexProjectTrusted } from './agent-trust-presets'
|
|
import {
|
|
normalizeCodexRuntimeSelection,
|
|
type CodexAccountSelectionTarget
|
|
} from './codex-accounts/runtime-selection'
|
|
import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection'
|
|
import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service'
|
|
import {
|
|
ensureRealHomeCodexHookState,
|
|
isRealHomeCodexHookLaneUsable
|
|
} from './codex/codex-real-home-hook-install'
|
|
import { setCodexTrustGrantTelemetry } from './codex/codex-trust-grant-telemetry'
|
|
import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill'
|
|
import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal'
|
|
import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler'
|
|
import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume'
|
|
import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home'
|
|
import { findTrustedCodexSessionResume } from './codex/codex-session-resume-home'
|
|
import { getSystemCodexHomePath } from './codex/codex-home-paths'
|
|
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
|
|
import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume'
|
|
import { getDefaultWslDistro } from './wsl'
|
|
import { ClaudeAccountService } from './claude-accounts/service'
|
|
import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service'
|
|
import {
|
|
attachClaudeLivePtyPersistence,
|
|
onLiveClaudePtysDrained,
|
|
seedLiveClaudePtysFromPersistence
|
|
} from './claude-accounts/live-pty-gate'
|
|
import { StarNagService } from './star-nag/service'
|
|
import { agentHookServer } from './agent-hooks/server'
|
|
import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager'
|
|
import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename'
|
|
import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output'
|
|
import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename'
|
|
import { moveWorktree } from './git/worktree'
|
|
import { setDefaultWslDistroOverride } from './git/runner'
|
|
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
|
|
import { parseWorkspaceKey } from '../shared/workspace-scope'
|
|
import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state'
|
|
import {
|
|
clearProviderPtyState,
|
|
getPtyIdForPaneKey,
|
|
registerPaneKeyTeardownListener,
|
|
getLocalPtyProvider,
|
|
getSshPtyProvider,
|
|
registerHeadlessPtyRuntime
|
|
} from './ipc/pty'
|
|
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
|
|
import { EmulatorBridge } from './emulator/emulator-bridge'
|
|
import { browserCertificateTrustController, browserManager } from './browser/browser-manager'
|
|
import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend'
|
|
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
|
|
import { setUnreadDockBadgeCount } from './dock/unread-badge'
|
|
import { AutomationService } from './automations/service'
|
|
import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch'
|
|
import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create'
|
|
import { AgentAwakeService } from './agent-awake-service'
|
|
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
|
|
import { settleTeardownWithinDeadline } from './quit-teardown-deadline'
|
|
import {
|
|
recordCoalescedCrashBreadcrumb,
|
|
recordCrashBreadcrumb
|
|
} from './crash-reporting/crash-breadcrumb-store'
|
|
import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb'
|
|
import { getMainProcessLifecycleIdentity } from './crash-reporting/main-process-lifecycle-identity'
|
|
import { CrashReportStore } from './crash-reporting/crash-report-store'
|
|
import {
|
|
shouldRecoverRendererAfterProcessGone,
|
|
type ExpectedTeardownScope
|
|
} from './crash-reporting/process-gone-classification'
|
|
import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder'
|
|
import {
|
|
advanceSyntheticTitleSpinnerEntries,
|
|
type SyntheticTitleSpinnerEntry
|
|
} from './synthetic-title-spinner'
|
|
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
|
|
import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing'
|
|
import {
|
|
getSyntheticAgentTitleProfile,
|
|
shouldDriveSyntheticAgentTitleFromHook,
|
|
type SyntheticAgentTitleProfile
|
|
} from '../shared/synthetic-agent-title'
|
|
import type { AgentStatusState } from '../shared/agent-status-types'
|
|
import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions'
|
|
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
|
|
import {
|
|
HEADLESS_RUNTIME_WINDOW_ID,
|
|
type RuntimeDesktopWindowStatus
|
|
} from '../shared/runtime-types'
|
|
import { LocalPtyProvider } from './providers/local-pty-provider'
|
|
import { KeybindingService } from './keybindings/keybinding-service'
|
|
import { applyElectronProxySettings } from './network/proxy-settings'
|
|
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
|
|
import { CliInstaller } from './cli/cli-installer'
|
|
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
|
|
import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation'
|
|
|
|
let mainWindow: BrowserWindow | null = null
|
|
/** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */
|
|
let isQuitting = false
|
|
let store: Store | null = null
|
|
let stats: StatsCollector | null = null
|
|
let claudeUsage: ClaudeUsageStore | null = null
|
|
let codexUsage: CodexUsageStore | null = null
|
|
let openCodeUsage: OpenCodeUsageStore | null = null
|
|
let codexAccounts: CodexAccountService | null = null
|
|
let codexRuntimeHome: CodexRuntimeHomeService | null = null
|
|
let claudeAccounts: ClaudeAccountService | null = null
|
|
let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null
|
|
let runtime: OrcaRuntimeService | null = null
|
|
let rateLimits: RateLimitService | null = null
|
|
let runtimeRpc: OrcaRuntimeRpcServer | null = null
|
|
const serveReadinessPublisher = new ServeReadinessPublisher()
|
|
let desktopRelayService: DesktopRelayService | null = null
|
|
let desktopRelayStatus: RelayBrokerStatus = 'offline'
|
|
let pendingUnpairedDeviceAuthFailure = false
|
|
// Why: gates whether headless serve installs the offscreen browser backend (and advertises browser pane support).
|
|
let headlessBrowserDisplayAvailable = false
|
|
|
|
let starNag: StarNagService | null = null
|
|
let agentAwakeService: AgentAwakeService | null = null
|
|
let crashReports: CrashReportStore | null = null
|
|
let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null
|
|
let unsubscribeSystemResumeBroadcast: (() => void) | null = null
|
|
let watcherShutdownPromise: Promise<void> | null = null
|
|
let watcherShutdownDone = false
|
|
let automations: AutomationService | null = null
|
|
let keybindings: KeybindingService | null = null
|
|
// Why: a reload intent must not leak to a later load; the recovery reload re-fires did-finish-load, so its flag spares live PTYs from the orphan sweep (#5787).
|
|
const expectedRendererReload = createWebContentsTimedFlag()
|
|
const recoveryReloadInFlight = createWebContentsTimedFlag()
|
|
// Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount.
|
|
const pendingOpenSettings = createWebContentsTimedFlag()
|
|
let firstWindowStartupServicesReady: Promise<void> = Promise.resolve()
|
|
let managedWslCliReconciliationReady: Promise<void> = Promise.resolve()
|
|
let managedWslCliStartupBarrierReady: Promise<void> = Promise.resolve()
|
|
// Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op).
|
|
let managedWslCliReconciliationStatus: 'pending' | 'settled' | 'failed' = 'settled'
|
|
const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({
|
|
windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
|
|
threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD
|
|
})
|
|
let gpuFallbackActiveThisLaunch = false
|
|
let localPtyStartupReady: Promise<void> = Promise.resolve()
|
|
let localPtyProviderStartupReady: Promise<void> = Promise.resolve()
|
|
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
|
|
const isServeMode = process.argv.includes('--serve')
|
|
if (isServeMode) {
|
|
reserveServeStdoutForReadiness()
|
|
}
|
|
const desktopActivationGate = createServeDesktopActivationGate({
|
|
initialState: isServeMode ? 'initializing' : 'ready',
|
|
activateWindow: () => {
|
|
// Why: an updater replacement must not resurrect the old app bundle.
|
|
if (!isQuittingForUpdate()) {
|
|
focusExistingWindow()
|
|
}
|
|
},
|
|
onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`)
|
|
})
|
|
// Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below.
|
|
const packagedCliEntryRedirect = maybeRedirectPackagedCliEntryLaunch({
|
|
isPackaged: app.isPackaged,
|
|
resourcesPath: process.resourcesPath,
|
|
execPath: process.execPath
|
|
})
|
|
if (packagedCliEntryRedirect.redirected) {
|
|
app.exit(packagedCliEntryRedirect.status)
|
|
}
|
|
const appImageCliRedirect = maybeRedirectAppImageCliLaunch({
|
|
isPackaged: app.isPackaged,
|
|
resourcesPath: process.resourcesPath,
|
|
execPath: process.execPath
|
|
})
|
|
if (appImageCliRedirect.redirected) {
|
|
app.exit(appImageCliRedirect.status)
|
|
}
|
|
|
|
// Kill switch for the first-work on-disk folder rename; the renderer reconciles the id change (migrateWorktreeIdentity) so it isn't mistaken for a deletion.
|
|
const ENABLE_FIRST_WORK_FOLDER_RENAME = false
|
|
|
|
// Why: inject the index.ts store/runtime singletons so the rename orchestrator stays module-state-free and unit-testable.
|
|
function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
|
|
paneKey: string
|
|
tabId: string | undefined
|
|
worktreeId: string | undefined
|
|
payload: { state: string; prompt?: string; lastAssistantMessage?: string }
|
|
isReplay: boolean | undefined
|
|
}): void {
|
|
const currentStore = store
|
|
const currentRuntime = runtime
|
|
if (!currentStore || !currentRuntime) {
|
|
return
|
|
}
|
|
void maybeAutoRenameBranchOnFirstWork(
|
|
{
|
|
paneKey: event.paneKey,
|
|
tabId: event.tabId,
|
|
worktreeId: event.worktreeId,
|
|
state: event.payload.state,
|
|
prompt: event.payload.prompt,
|
|
assistantMessage: event.payload.lastAssistantMessage,
|
|
isReplay: event.isReplay
|
|
},
|
|
{
|
|
getSettings: () => currentStore.getSettings(),
|
|
getRepo: (repoId) => currentStore.getRepo(repoId),
|
|
getAgentEnvResolvers: () => currentRuntime.getCommitMessageAgentEnvironmentResolvers(),
|
|
getCurrentDisplayName: (worktreeId) => {
|
|
const scope = parseWorkspaceKey(worktreeId)
|
|
if (scope?.type === 'folder') {
|
|
return currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.name
|
|
}
|
|
return currentStore.getWorktreeMeta(worktreeId)?.displayName
|
|
},
|
|
getFolderWorkspacePath: (worktreeId) => {
|
|
const scope = parseWorkspaceKey(worktreeId)
|
|
return scope?.type === 'folder'
|
|
? currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath
|
|
: undefined
|
|
},
|
|
isPendingFirstAgentMessageRename: (worktreeId) => {
|
|
const scope = parseWorkspaceKey(worktreeId)
|
|
if (scope?.type === 'folder') {
|
|
return (
|
|
currentStore.getFolderWorkspace(scope.folderWorkspaceId)
|
|
?.pendingFirstAgentMessageRename === true
|
|
)
|
|
}
|
|
return currentStore.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true
|
|
},
|
|
canRenameOrcaCreatedBranch: (worktreeId) => {
|
|
const meta = currentStore.getWorktreeMeta(worktreeId)
|
|
// Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename.
|
|
return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true
|
|
},
|
|
setDisplayName: (worktreeId, displayName) => {
|
|
rememberBranchRenameFailureOutput(worktreeId, null)
|
|
const scope = parseWorkspaceKey(worktreeId)
|
|
if (scope?.type === 'folder') {
|
|
currentStore.updateFolderWorkspace(scope.folderWorkspaceId, {
|
|
name: displayName,
|
|
pendingFirstAgentMessageRename: false,
|
|
firstAgentMessageRenameError: null
|
|
})
|
|
currentRuntime.notifyFolderWorkspaceChanged()
|
|
return
|
|
}
|
|
currentStore.setWorktreeMeta(worktreeId, {
|
|
displayName,
|
|
pendingFirstAgentMessageRename: false,
|
|
// Success clears the failure badge (redundant with the explicit setRenameError(null)).
|
|
firstAgentMessageRenameError: null
|
|
})
|
|
},
|
|
renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME
|
|
? (worktreeId, newLeaf) =>
|
|
renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, {
|
|
getRepo: (repoId) => currentStore.getRepo(repoId),
|
|
getSettings: () => currentStore.getSettings(),
|
|
migrateWorktreeIdentity: (oldId, newId) =>
|
|
currentStore.migrateWorktreeIdentity(oldId, newId),
|
|
notifyWorktreeRenamed: (repoId, oldId, newId) =>
|
|
currentRuntime.notifyWorktreeFolderRenamed(repoId, oldId, newId),
|
|
pathExists: async (candidate) => existsSync(candidate),
|
|
moveWorktree
|
|
})
|
|
: undefined,
|
|
setRenameError: (worktreeId, error, failureOutput) => {
|
|
// Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run.
|
|
rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput)
|
|
// Skip the write + push when unchanged — most settled worktrees never had an error to clear.
|
|
const scope = parseWorkspaceKey(worktreeId)
|
|
if (scope?.type === 'folder') {
|
|
const current = currentStore.getFolderWorkspace(
|
|
scope.folderWorkspaceId
|
|
)?.firstAgentMessageRenameError
|
|
if ((current ?? null) === (error ?? null)) {
|
|
return
|
|
}
|
|
currentStore.updateFolderWorkspace(scope.folderWorkspaceId, {
|
|
firstAgentMessageRenameError: error
|
|
})
|
|
currentRuntime.notifyFolderWorkspaceChanged()
|
|
return
|
|
}
|
|
const current = currentStore.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError
|
|
if ((current ?? null) === (error ?? null)) {
|
|
return
|
|
}
|
|
currentStore.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error })
|
|
// Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects.
|
|
currentRuntime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId))
|
|
},
|
|
resolveWorktreeIdForTab: (tabId) => currentStore.getWorktreeIdForTab(tabId),
|
|
onRenamed: (repoIdOrWorktreeId) => {
|
|
if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') {
|
|
currentRuntime.notifyFolderWorkspaceChanged()
|
|
return
|
|
}
|
|
currentRuntime.notifyBranchRenamed(repoIdOrWorktreeId)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
const devInstanceIdentity = getDevInstanceIdentity(is.dev)
|
|
const devAgentHookEndpointNamespace = devInstanceIdentity.isDev
|
|
? devInstanceIdentity.appUserModelId
|
|
: undefined
|
|
|
|
installUncaughtPipeErrorGuard()
|
|
// Why (issue #9441): without this, one rejected background promise during startup restore kills main silently (exit 1, no crash report).
|
|
installUnhandledRejectionLogging()
|
|
// Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron.
|
|
process.env.ORCA_APP_VERSION = app.getVersion()
|
|
configureRemoteServerUpdater({
|
|
getSnapshot: getRemoteServerUpdaterSnapshot,
|
|
check: checkForRemoteServerUpdate,
|
|
download: downloadRemoteServerUpdate,
|
|
install: installRemoteServerUpdate
|
|
})
|
|
patchPackagedProcessPath()
|
|
// Why: the sync seed above covers early IPC (homebrew/nix); the async login-shell probe below (packaged only) then adds the user's rc PATH.
|
|
if (app.isPackaged && process.platform !== 'win32') {
|
|
void hydrateShellPath().then((result) => {
|
|
if (result.ok) {
|
|
mergePathSegments(result.segments)
|
|
}
|
|
})
|
|
}
|
|
configureDevUserDataPath(is.dev)
|
|
configureOrcaUserDataPathEnv()
|
|
installServeSupervisorDisconnectQuit(isServeMode)
|
|
|
|
// Why: just past createMainWindow's 10s ready-to-show fallback, so a window revealed that way still gets its tray icon.
|
|
const TRAY_CREATE_FALLBACK_MS = 12_000
|
|
|
|
const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled()
|
|
if (startupDiagnosticsEnabled) {
|
|
logStartupDiagnostic('before-single-instance-lock', {
|
|
version: app.getVersion(),
|
|
packaged: app.isPackaged,
|
|
platform: process.platform,
|
|
osRelease: os.release(),
|
|
userData: app.getPath('userData'),
|
|
e2eUserData: Boolean(process.env.ORCA_E2E_USER_DATA_DIR)
|
|
})
|
|
startEventLoopStallProbe()
|
|
}
|
|
// Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; runs the whole session to catch steady-state churn (issue #7576).
|
|
startMainThreadChurnProbe()
|
|
|
|
function focusExistingWindow(): void {
|
|
focusExistingMainWindow({
|
|
app,
|
|
getWindow: () => mainWindow,
|
|
openWindow: openMainWindow,
|
|
warn: console.warn
|
|
})
|
|
}
|
|
|
|
function requestDesktopActivation(): void {
|
|
desktopActivationGate.requestActivation()
|
|
}
|
|
|
|
function getDesktopWindowStatus(): RuntimeDesktopWindowStatus {
|
|
const state = desktopActivationGate.getState()
|
|
return state === 'ready' ? 'openable' : state
|
|
}
|
|
|
|
function settleServeDesktopActivation(): void {
|
|
if (getLocalPtyProvider() instanceof LocalPtyProvider) {
|
|
desktopActivationGate.markBlocked('persistent PTY provider unavailable')
|
|
return
|
|
}
|
|
desktopActivationGate.markReady()
|
|
}
|
|
|
|
// Why: webContents-scoped auto-expiring flag so an intent can't leak to a later renderer load; `consume` clears on match for one-shot signals.
|
|
function createWebContentsTimedFlag(defaultDurationMs = 10_000): {
|
|
mark: (webContentsId: number, durationMs?: number) => void
|
|
clear: (webContentsId?: number) => void
|
|
matches: (webContentsId: number, options?: { consume?: boolean }) => boolean
|
|
} {
|
|
let state: { webContentsId: number; until: number } | null = null
|
|
return {
|
|
mark(webContentsId, durationMs = defaultDurationMs) {
|
|
state = { webContentsId, until: Date.now() + durationMs }
|
|
},
|
|
clear(webContentsId) {
|
|
if (webContentsId === undefined || state?.webContentsId === webContentsId) {
|
|
state = null
|
|
}
|
|
},
|
|
matches(webContentsId, options) {
|
|
if (!state || Date.now() > state.until) {
|
|
state = null
|
|
return false
|
|
}
|
|
if (state.webContentsId !== webContentsId) {
|
|
return false
|
|
}
|
|
if (options?.consume) {
|
|
state = null
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
function markExpectedRendererReload(webContentsId: number, durationMs = 10_000): void {
|
|
expectedRendererReload.mark(webContentsId, durationMs)
|
|
}
|
|
|
|
function clearExpectedRendererReload(webContentsId?: number): void {
|
|
expectedRendererReload.clear(webContentsId)
|
|
}
|
|
|
|
function getExpectedTeardownScope(webContentsId?: number): ExpectedTeardownScope {
|
|
if (isQuitting || isQuittingForUpdate()) {
|
|
return 'app-shutdown'
|
|
}
|
|
if (webContentsId === undefined) {
|
|
return 'none'
|
|
}
|
|
return expectedRendererReload.matches(webContentsId) ? 'renderer-reload' : 'none'
|
|
}
|
|
|
|
function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void {
|
|
recoveryReloadInFlight.mark(webContentsId, durationMs)
|
|
}
|
|
|
|
function isRecoveryReloadInFlight(webContentsId: number): boolean {
|
|
// Why: consume on read — the recovery reload fires exactly one did-finish-load, so a later genuine reload still sweeps orphaned PTYs.
|
|
return recoveryReloadInFlight.matches(webContentsId, { consume: true })
|
|
}
|
|
|
|
function recordAgentStateCrashBreadcrumb(agentType: string, state: string): void {
|
|
// Why: hook pings arrive many times/sec; coalesce so identical state pings don't fill all 30 breadcrumbs, leaving room for renderer errors.
|
|
recordCoalescedCrashBreadcrumb({
|
|
name: 'agent_state_changed',
|
|
data: { agentType, state },
|
|
coalesceKey: `agent:${agentType}:${state}`,
|
|
minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS
|
|
})
|
|
}
|
|
|
|
// Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces.
|
|
// Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312).
|
|
const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({
|
|
isDev: is.dev,
|
|
isServeMode
|
|
})
|
|
const skipSingleInstanceLock = shouldSkipSingleInstanceLock({
|
|
isDev: is.dev,
|
|
isServeMode
|
|
})
|
|
if (bypassSingleInstanceLock) {
|
|
// Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist.
|
|
logSingleInstanceLockBypass()
|
|
}
|
|
const hasSingleInstanceLock = skipSingleInstanceLock
|
|
? true
|
|
: bypassSingleInstanceLock
|
|
? true
|
|
: acquireSingleInstanceLock(app, requestDesktopActivation)
|
|
if (startupDiagnosticsEnabled) {
|
|
logStartupDiagnostic('single-instance-lock-result', {
|
|
acquired: hasSingleInstanceLock,
|
|
bypassed: bypassSingleInstanceLock,
|
|
skippedForDev: skipSingleInstanceLock
|
|
})
|
|
}
|
|
if (!hasSingleInstanceLock) {
|
|
// Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line.
|
|
logSingleInstanceLockFailure()
|
|
app.quit()
|
|
}
|
|
|
|
// Why: when another process holds the lock we've already quit; skip file-writing side effects so this transient process never touches userData.
|
|
if (hasSingleInstanceLock) {
|
|
// Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime.
|
|
const shouldCoupleToDevParent = is.dev && !isServeMode
|
|
installDevParentDisconnectQuit(shouldCoupleToDevParent)
|
|
installDevParentWatchdog(shouldCoupleToDevParent)
|
|
installDevParentSignalQuit(shouldCoupleToDevParent)
|
|
// Why: run after configureDevUserDataPath but before app.setName('Orca') (whenReady), which changes the resolved path on case-sensitive filesystems.
|
|
initDataPath()
|
|
// Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence.
|
|
initSessionParseCachePersistence({
|
|
filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'),
|
|
appVersion: app.getVersion()
|
|
})
|
|
initOrcaProfilePaths()
|
|
// Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28.
|
|
initStatsPath()
|
|
initClaudeUsagePath()
|
|
initCodexUsagePath()
|
|
initOpenCodeUsagePath()
|
|
crashReports = CrashReportStore.fromUserData()
|
|
recordCrashBreadcrumb('app_started', {
|
|
packaged: app.isPackaged,
|
|
platform: process.platform,
|
|
...getMainProcessLifecycleIdentity()
|
|
})
|
|
configureElectronNetworkCompatibility()
|
|
enableRendererHeapHeadroom()
|
|
maybeApplyGpuFallbackForThisLaunch()
|
|
if (!gpuFallbackActiveThisLaunch) {
|
|
enableMainProcessGpuFeatures()
|
|
}
|
|
// Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed.
|
|
headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ isServeMode })
|
|
}
|
|
|
|
ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
|
|
// Why: restored WSL terminals get a bounded chance to receive launcher repairs before window rendering proceeds.
|
|
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
|
|
})
|
|
|
|
// Why: the renderer pulls this once its ui:openSettings listener attaches, so a Settings request queued before mount isn't lost.
|
|
ipcMain.handle('ui:consumePendingOpenSettings', (event) =>
|
|
pendingOpenSettings.matches(event.sender.id, { consume: true })
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'app:startupDiagnostic',
|
|
(_event, event: string, details?: Record<string, unknown>) => {
|
|
if (!startupDiagnosticsEnabled || !event.startsWith('renderer-')) {
|
|
return
|
|
}
|
|
logStartupMilestone(event, details && typeof details === 'object' ? details : {})
|
|
}
|
|
)
|
|
|
|
function startTerminalRuntimeStartupServices(): Promise<void> {
|
|
logStartupMilestone('first-window-startup-services-start')
|
|
const startupServices = startFirstWindowStartupServices({
|
|
// Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer.
|
|
startDaemonPtyProvider: async (signal) => {
|
|
logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' })
|
|
// Why: only GUI-spawned macOS daemons watch for login-session death; a headless
|
|
// serve daemon must survive its spawning session ending (SSH disconnect).
|
|
await initDaemonPtyProvider(signal, {
|
|
macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode
|
|
})
|
|
logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' })
|
|
},
|
|
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect.
|
|
startAgentHookServer: async () => {
|
|
if (!isAgentStatusHooksEnabled(store?.getSettings())) {
|
|
return
|
|
}
|
|
logStartupMilestone('startup-service-start', { service: 'agent-hook-server' })
|
|
await agentHookServer.start({
|
|
env: app.isPackaged ? 'production' : 'development',
|
|
// Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`).
|
|
userDataPath: app.getPath('userData'),
|
|
endpointNamespace: devAgentHookEndpointNamespace
|
|
})
|
|
logStartupMilestone('startup-service-done', { service: 'agent-hook-server' })
|
|
},
|
|
onDaemonError: (error) => {
|
|
// Why: daemon failure silently falls back to non-persistent local PTYs; log + telemetry so a fleet-wide outage is observable (was invisible in v1.4.129-rc.1).
|
|
const reason = error instanceof Error ? error.message : String(error)
|
|
console.error(
|
|
`[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}`
|
|
)
|
|
track('daemon_start_failed', classifyError(error))
|
|
},
|
|
onAgentHookServerError: (error) => {
|
|
// Why: hook callbacks are sidebar enrichment only; Orca must still boot if the loopback receiver fails.
|
|
console.error('[agent-hooks] Failed to start local hook server:', error)
|
|
}
|
|
})
|
|
firstWindowStartupServicesReady = startupServices.firstWindowReady
|
|
localPtyStartupReady = startupServices.localPtyReady
|
|
localPtyProviderStartupReady = startupServices.localPtyProviderReady
|
|
void firstWindowStartupServicesReady.then(() => {
|
|
logStartupMilestone('first-window-startup-services-ready')
|
|
})
|
|
void localPtyStartupReady.then(() => {
|
|
logStartupMilestone('local-pty-startup-ready')
|
|
})
|
|
return firstWindowStartupServicesReady
|
|
}
|
|
|
|
function prepareCodexRuntimeHomeForLaunch(
|
|
target?: CodexAccountSelectionTarget,
|
|
launchEnv?: NodeJS.ProcessEnv,
|
|
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
|
|
): string | null {
|
|
if (
|
|
target?.runtime !== 'wsl' &&
|
|
launchContext?.launchAgent === 'codex' &&
|
|
launchContext.workspacePath
|
|
) {
|
|
try {
|
|
// Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs synchronously before every recognized Codex spawn.
|
|
markCodexProjectTrusted(launchContext.workspacePath)
|
|
} catch (error) {
|
|
console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error)
|
|
}
|
|
}
|
|
const ensureRealHomeHooksIfSelected = (): boolean => {
|
|
if (
|
|
target?.runtime === 'wsl' ||
|
|
!codexRuntimeHome!.isHostSystemDefaultRealHomeSelected(launchEnv)
|
|
) {
|
|
return false
|
|
}
|
|
// Why (flag ON, system default): the hook entry must exist — appended last
|
|
// and trusted by codex's own app-server grant — in the real ~/.codex before
|
|
// the pane spawns. An incapable grant flips the lane gate so the launch
|
|
// below falls back to the managed home instead of a status-blind pane.
|
|
ensureRealHomeCodexHookState({
|
|
hooksEnabled: isAgentStatusHooksEnabled(store?.getSettings()),
|
|
userDataPath: app.getPath('userData')
|
|
})
|
|
return true
|
|
}
|
|
let realHomeHooksPrepared = ensureRealHomeHooksIfSelected()
|
|
let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv)
|
|
if (runtimeHomePath === null && !realHomeHooksPrepared) {
|
|
// Why: a managed home can lose auth during launch prep, which clears its
|
|
// selection and falls through to real home. Establish hook capability for
|
|
// that newly selected lane, then re-resolve if the capability gate rejects it.
|
|
realHomeHooksPrepared = ensureRealHomeHooksIfSelected()
|
|
if (realHomeHooksPrepared) {
|
|
runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv)
|
|
}
|
|
}
|
|
if (runtimeHomePath === null && target?.runtime !== 'wsl') {
|
|
// Why: Codex runs on the user's real ~/.codex; the managed-home hook
|
|
// install below would target a home Codex never reads on this lane.
|
|
return null
|
|
}
|
|
const hookTarget =
|
|
target?.runtime === 'wsl'
|
|
? {
|
|
runtime: 'wsl' as const,
|
|
wslDistro: target.wslDistro?.trim() || getDefaultWslDistro()
|
|
}
|
|
: target
|
|
const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings())
|
|
try {
|
|
// Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks.
|
|
const status = hooksEnabled
|
|
? (codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget) ??
|
|
// Why: a managed account's launch home is its own self-contained
|
|
// CODEX_HOME, so hooks/trust must install there, not the shared mirror.
|
|
codexHookService.install(runtimeHomePath ?? undefined))
|
|
: (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ??
|
|
codexHookService.refreshRuntimeUserHooks(runtimeHomePath ?? undefined))
|
|
if (status.state === 'error') {
|
|
console.warn(
|
|
`[codex-hook-service] failed to ${
|
|
hooksEnabled ? 'refresh' : 'refresh user'
|
|
} runtime hooks before launch`,
|
|
status.detail
|
|
)
|
|
}
|
|
} catch (error) {
|
|
// Why: hook install is best-effort launch prep; a malformed hooks file must not block Codex from starting.
|
|
console.warn(
|
|
`[codex-hook-service] failed to ${
|
|
hooksEnabled ? 'refresh' : 'refresh user'
|
|
} runtime hooks before launch`,
|
|
error
|
|
)
|
|
}
|
|
return runtimeHomePath
|
|
}
|
|
|
|
async function prepareCodexSessionResumeForLaunch(args: {
|
|
providerSession: AgentProviderSessionMetadata
|
|
target: CodexAccountSelectionTarget
|
|
launchEnv?: NodeJS.ProcessEnv
|
|
workspacePath?: string
|
|
}): Promise<{ codexHomePath: string | null } | null> {
|
|
if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) {
|
|
return null
|
|
}
|
|
const systemHomePath = getSystemCodexHomePath()
|
|
// Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth.
|
|
const trustedHomes = [
|
|
systemHomePath,
|
|
...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery()
|
|
]
|
|
const sessionSource = await findTrustedCodexSessionResume({
|
|
sessionId: args.providerSession.id,
|
|
transcriptPath: args.providerSession.transcriptPath,
|
|
trustedCodexHomes: trustedHomes
|
|
})
|
|
if (!sessionSource) {
|
|
if (args.providerSession.transcriptPath) {
|
|
throw new Error(
|
|
'Orca could not verify the originating Codex session file, so automatic resume was stopped to avoid using a different account.'
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
let migrated = { useRealCodexHome: false }
|
|
try {
|
|
migrated = await prepareLegacySharedCodexSessionResume(
|
|
{
|
|
agent: 'codex',
|
|
executionHostId: 'local',
|
|
filePath: sessionSource.transcriptPath,
|
|
codexHome: sessionSource.homePath
|
|
},
|
|
{
|
|
isHostSystemDefaultRealHome: () => codexRuntimeHome!.isHostSystemDefaultRealHome(),
|
|
systemCodexHomePath: systemHomePath
|
|
}
|
|
)
|
|
} catch (error) {
|
|
// Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home.
|
|
console.warn(
|
|
'[codex-session-resume] Legacy rollout migration failed; using origin home:',
|
|
error
|
|
)
|
|
}
|
|
const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath
|
|
|
|
if (args.workspacePath) {
|
|
try {
|
|
markCodexProjectTrusted(args.workspacePath)
|
|
} catch (error) {
|
|
console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error)
|
|
}
|
|
}
|
|
const isSystemHome =
|
|
normalizeRuntimePathForComparison(resumeHome) ===
|
|
normalizeRuntimePathForComparison(systemHomePath)
|
|
const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings())
|
|
try {
|
|
if (isSystemHome) {
|
|
ensureRealHomeCodexHookState({ hooksEnabled, userDataPath: app.getPath('userData') })
|
|
} else if (hooksEnabled) {
|
|
codexHookService.install(resumeHome)
|
|
} else {
|
|
codexHookService.refreshRuntimeUserHooks(resumeHome)
|
|
}
|
|
} catch (error) {
|
|
// Why: hook repair is best-effort; session provenance must still win over the currently selected home.
|
|
console.warn('[codex-hook-service] failed to prepare automatic resume home:', error)
|
|
}
|
|
return { codexHomePath: resumeHome }
|
|
}
|
|
|
|
// Why: restore the window the close handler may have hidden to tray, or reopen it (dock-reactivation style) if fully torn down.
|
|
function showMainWindowFromTray(): void {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
if (mainWindow.isMinimized()) {
|
|
mainWindow.restore()
|
|
}
|
|
mainWindow.show()
|
|
mainWindow.focus()
|
|
return
|
|
}
|
|
if (!isQuittingForUpdate()) {
|
|
openMainWindow()
|
|
}
|
|
}
|
|
|
|
function openSettingsFromSystemMenu(): void {
|
|
showMainWindowFromTray()
|
|
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null
|
|
if (!targetWindow) {
|
|
return
|
|
}
|
|
recordCrashBreadcrumb('settings_opened')
|
|
|
|
// Why: no signal proves the renderer listener is attached — push, and also leave a one-shot intent the unmounted renderer pulls at mount.
|
|
targetWindow.webContents.send('ui:openSettings')
|
|
// Why: untimed — any TTL can be outrun by a slow cold start; id-scoping + consume-on-read still prevent leaking to a later renderer.
|
|
pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY)
|
|
}
|
|
|
|
function quitFromSystemTray(): void {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
// Why: a hidden session may veto shutdown with a save/discard prompt, so make the window visible.
|
|
showMainWindowFromTray()
|
|
}
|
|
// Why: set the quit latch before app.quit() so the 'close' handler tears down instead of re-hiding to tray.
|
|
isQuitting = true
|
|
app.quit()
|
|
}
|
|
|
|
// Why: menu/tray are clickable before anything else configures the updater.
|
|
function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void {
|
|
ensureAutoUpdaterConfigured()
|
|
checkForUpdatesFromMenu(options)
|
|
}
|
|
|
|
function getSystemTrayOptions(): SystemTrayOptions | null {
|
|
if (!store) {
|
|
return null
|
|
}
|
|
return {
|
|
appIcon: store.getSettings().appIcon,
|
|
isDevInstance: devInstanceIdentity.isDev,
|
|
devInstanceLabel: devInstanceIdentity.devLabel,
|
|
onOpen: showMainWindowFromTray,
|
|
onOpenSettings: openSettingsFromSystemMenu,
|
|
onCheckForUpdates: () => {
|
|
// Why: updater status renders in the main window, so a bare check would complete invisibly.
|
|
showMainWindowFromTray()
|
|
runUserInitiatedUpdateCheck()
|
|
},
|
|
onQuit: quitFromSystemTray
|
|
}
|
|
}
|
|
|
|
function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null {
|
|
if (process.platform !== 'darwin' || isServeMode) {
|
|
return null
|
|
}
|
|
const options = getSystemTrayOptions()
|
|
return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null
|
|
}
|
|
|
|
function openMainWindow(): BrowserWindow {
|
|
logStartupMilestone('open-main-window-start')
|
|
if (!store) {
|
|
throw new Error('Store must be initialized before opening the main window')
|
|
}
|
|
if (!runtime) {
|
|
throw new Error('Runtime must be initialized before opening the main window')
|
|
}
|
|
if (!stats) {
|
|
throw new Error('Stats must be initialized before opening the main window')
|
|
}
|
|
if (!claudeUsage) {
|
|
throw new Error('Claude usage store must be initialized before opening the main window')
|
|
}
|
|
if (!codexUsage) {
|
|
throw new Error('Codex usage store must be initialized before opening the main window')
|
|
}
|
|
if (!openCodeUsage) {
|
|
throw new Error('OpenCode usage store must be initialized before opening the main window')
|
|
}
|
|
if (!rateLimits) {
|
|
throw new Error('Rate limit service must be initialized before opening the main window')
|
|
}
|
|
if (!automations) {
|
|
throw new Error('Automation service must be initialized before opening the main window')
|
|
}
|
|
if (!codexAccounts) {
|
|
throw new Error('Codex account service must be initialized before opening the main window')
|
|
}
|
|
if (!codexRuntimeHome) {
|
|
throw new Error('Codex runtime home service must be initialized before opening the main window')
|
|
}
|
|
if (!claudeAccounts) {
|
|
throw new Error('Claude account service must be initialized before opening the main window')
|
|
}
|
|
if (!claudeRuntimeAuth) {
|
|
throw new Error(
|
|
'Claude runtime auth service must be initialized before opening the main window'
|
|
)
|
|
}
|
|
if (!keybindings) {
|
|
throw new Error('Keybinding service must be initialized before opening the main window')
|
|
}
|
|
|
|
// Why: Chromium's BrowserWindow ctor resets userData to a Protected DACL, breaking writes; re-grant ACEs (marker-gated to avoid a ~60s startup stall).
|
|
if (process.platform === 'win32') {
|
|
logStartupMilestone('acl-grant-start')
|
|
ensureWindowsUserDataAclGrant(app.getPath('userData'), {
|
|
onDone: (result) => {
|
|
logStartupMilestone('acl-grant-done', { mode: result.mode })
|
|
if (result.mode === 'failed') {
|
|
console.warn('[win32-acl] userData ACL grant failed:', result.reason)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const window = createMainWindow(store, {
|
|
getIsQuitting: () => isQuitting,
|
|
onQuitAborted: () => {
|
|
isQuitting = false
|
|
clearExpectedRendererReload()
|
|
},
|
|
onRendererProcessGone: (details, webContentsId) => {
|
|
recordProcessGoneCrash(
|
|
'renderer',
|
|
'renderer',
|
|
details.reason,
|
|
details.exitCode ?? null,
|
|
{
|
|
processType: 'renderer'
|
|
},
|
|
webContentsId
|
|
)
|
|
},
|
|
shouldRecoverRenderer: (details, webContentsId) =>
|
|
shouldRecoverRendererAfterProcessGone({
|
|
reason: details.reason,
|
|
expectedTeardown: getExpectedTeardownScope(webContentsId)
|
|
}),
|
|
onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => {
|
|
recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', {
|
|
reason: details.reason,
|
|
exitCode: details.exitCode ?? null,
|
|
recentRecoveryCount
|
|
})
|
|
void presentRendererRecoveryPrompt(recentRecoveryCount)
|
|
},
|
|
deferLoad: true,
|
|
title: devInstanceIdentity.name,
|
|
getKeybindings: () => keybindings?.getOverrides(),
|
|
onBeforeReload: ({ ignoreCache, webContentsId }) => {
|
|
if (mainWindow?.webContents.id === webContentsId) {
|
|
markExpectedRendererReload(webContentsId)
|
|
}
|
|
recordCrashBreadcrumb('manual_reload_requested', { ignoreCache })
|
|
},
|
|
// Why: the recovery reload re-fires did-finish-load; flag it so the local-PTY orphan sweep skips that reload (#5787).
|
|
onBeforeRecoveryReload: (webContentsId) => {
|
|
markRecoveryReloadInFlight(webContentsId)
|
|
recordDurableCrashBreadcrumb('renderer_recovery_reload')
|
|
}
|
|
})
|
|
recordCrashBreadcrumb('main_window_created')
|
|
logStartupMilestone('window-created')
|
|
// Why: Windows Tray construction can block synchronously on Shell_NotifyIcon, so both platforms defer creation to after first paint.
|
|
let trayCreated = false
|
|
const createSystemTrayDeferred = (): void => {
|
|
if (trayCreated || window.isDestroyed() || isQuitting || !store) {
|
|
return
|
|
}
|
|
trayCreated = true
|
|
if (process.platform === 'darwin') {
|
|
// Why: route through syncMacMenuBarIcon so startup and the live toggle share one serve-mode/visibility policy.
|
|
if (syncMacMenuBarIcon(store.getSettings().showMenuBarIcon !== false)) {
|
|
logStartupMilestone('tray-created')
|
|
}
|
|
return
|
|
}
|
|
const options = getSystemTrayOptions()
|
|
if (options && createSystemTray(options)) {
|
|
logStartupMilestone('tray-created')
|
|
}
|
|
}
|
|
window.once('ready-to-show', () => {
|
|
logStartupMilestone('ready-to-show')
|
|
setImmediate(createSystemTrayDeferred)
|
|
})
|
|
const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS)
|
|
trayCreateFallback.unref?.()
|
|
|
|
// Why: telemetry-plan.md anchors default-on app_opened to the first main-window load; this path fires only once consent is already enabled.
|
|
const rendererWebContentsId = window.webContents.id
|
|
const onFirstWindowLoad = (): void => {
|
|
clearExpectedRendererReload(rendererWebContentsId)
|
|
recordCrashBreadcrumb('main_window_loaded')
|
|
logStartupMilestone('did-finish-load')
|
|
if (!store) {
|
|
return
|
|
}
|
|
const consent = resolveConsent(store.getSettings())
|
|
if (consent.effective !== 'enabled') {
|
|
return
|
|
}
|
|
trackAppOpenedOnce()
|
|
}
|
|
window.webContents.on('did-finish-load', onFirstWindowLoad)
|
|
|
|
registerCoreHandlers(
|
|
store,
|
|
runtime,
|
|
stats,
|
|
claudeUsage,
|
|
codexUsage,
|
|
openCodeUsage,
|
|
codexAccounts,
|
|
claudeAccounts,
|
|
rateLimits,
|
|
rendererWebContentsId,
|
|
automations,
|
|
{
|
|
prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch,
|
|
prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target)
|
|
},
|
|
agentAwakeService ?? undefined,
|
|
crashReports ?? undefined,
|
|
keybindings,
|
|
{
|
|
getAdditionalAiVaultCodexHomePaths: () =>
|
|
codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [],
|
|
prepareAiVaultSessionResume: (args) =>
|
|
prepareLegacySharedCodexSessionResume(args, {
|
|
isHostSystemDefaultRealHome: () =>
|
|
codexRuntimeHome?.isHostSystemDefaultRealHome() === true,
|
|
systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings())
|
|
}),
|
|
onBeforeRelaunch: async () => {
|
|
isQuitting = true
|
|
desktopRelayService?.fenceAndCloseNow()
|
|
await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })
|
|
},
|
|
onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated(),
|
|
onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow()
|
|
}
|
|
)
|
|
automations.setWebContents(window.webContents)
|
|
automations.start()
|
|
attachMainWindowServices(
|
|
window,
|
|
store,
|
|
runtime,
|
|
prepareCodexRuntimeHomeForLaunch,
|
|
(target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target),
|
|
{
|
|
prepareCodexSessionResume: prepareCodexSessionResumeForLaunch,
|
|
awaitLocalPtyStartup: () => localPtyStartupReady,
|
|
awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady,
|
|
onBeforeRendererReload: ({ ignoreCache, webContentsId }) => {
|
|
if (window.webContents.id === webContentsId) {
|
|
markExpectedRendererReload(webContentsId)
|
|
}
|
|
recordCrashBreadcrumb('renderer_reload_requested', { ignoreCache })
|
|
},
|
|
// Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787).
|
|
isRecoveryReloadInFlight,
|
|
onBeforeUpdateQuit: () =>
|
|
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }),
|
|
updateInstallMode: resolveUpdateInstallMode(isServeMode)
|
|
}
|
|
)
|
|
rateLimits.attach(window)
|
|
// Why: quota probes spawn CLIs and hit network, so don't fetch immediately and compete with first paint; show/focus listeners refresh later.
|
|
rateLimits.start({ fetchImmediately: false })
|
|
window.on('closed', () => {
|
|
if (mainWindow === window) {
|
|
mainWindow = null
|
|
}
|
|
clearExpectedRendererReload(rendererWebContentsId)
|
|
automations?.setWebContents(null)
|
|
// Why: detach the hook listener on close so the server never fires into destroyed webContents before reopen, and replay runs only on deliberate recreations.
|
|
agentHookServer.setListener(null)
|
|
agentHookServer.setPaneStatusClearListener(null)
|
|
setMigrationUnsupportedPtyListener(null)
|
|
// Why: stop the spinner timer here — it would fire into destroyed webContents, and per-pane teardown may never run for restored-but-untorn panes.
|
|
stopAllSyntheticTitleSpinners()
|
|
})
|
|
mainWindow = window
|
|
window.on('show', resumeSyntheticTitleSpinnerTimer)
|
|
window.on('restore', resumeSyntheticTitleSpinnerTimer)
|
|
window.on('hide', stopSyntheticTitleSpinnerTimer)
|
|
window.on('minimize', stopSyntheticTitleSpinnerTimer)
|
|
// Why: visibility-gated pollers (SSH port scanner) park while hidden and resume on this signal; re-wired per window since dock re-activation recreates it.
|
|
window.on('show', notifyMainWindowBecameVisible)
|
|
window.on('restore', notifyMainWindowBecameVisible)
|
|
// Why: user is back on show/restore, so clear the tray attention dot set while hidden (see notifications.ts).
|
|
window.on('show', () => setTrayAttention(false))
|
|
window.on('restore', () => setTrayAttention(false))
|
|
agentHookServer.setListener(
|
|
({
|
|
paneKey,
|
|
tabId,
|
|
worktreeId,
|
|
connectionId,
|
|
payload,
|
|
receivedAt,
|
|
stateStartedAt,
|
|
launchToken,
|
|
providerSession,
|
|
providerSessionOnly,
|
|
promptInteractionKey,
|
|
isReplay
|
|
}) => {
|
|
if (mainWindow?.isDestroyed()) {
|
|
return
|
|
}
|
|
if (providerSessionOnly) {
|
|
// Why: session_start just refreshes durable resume identity while Pi is idle; forward it without titles, telemetry, or status UI.
|
|
mainWindow?.webContents.send('agentStatus:set', {
|
|
...payload,
|
|
paneKey,
|
|
...(launchToken ? { launchToken } : {}),
|
|
tabId,
|
|
worktreeId,
|
|
connectionId,
|
|
receivedAt,
|
|
stateStartedAt,
|
|
...(providerSession ? { providerSession } : {}),
|
|
providerSessionOnly: true
|
|
})
|
|
return
|
|
}
|
|
maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay })
|
|
const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey)
|
|
const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey)
|
|
mainWindow?.webContents.send('agentStatus:set', {
|
|
...payload,
|
|
paneKey,
|
|
...(launchToken ? { launchToken } : {}),
|
|
...(terminalHandle ? { terminalHandle } : {}),
|
|
tabId,
|
|
worktreeId,
|
|
connectionId,
|
|
receivedAt,
|
|
stateStartedAt,
|
|
...(providerSession ? { providerSession } : {}),
|
|
...(promptInteractionKey ? { promptInteractionKey } : {}),
|
|
...(orchestration ? { orchestration } : {})
|
|
})
|
|
recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state)
|
|
// Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync.
|
|
const profile = getSyntheticAgentTitleProfile(payload.agentType)
|
|
const suppressSyntheticCodexAutoApprovalTitle =
|
|
payload.agentType === 'codex' &&
|
|
(payload.state === 'waiting' || payload.state === 'blocked')
|
|
? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({
|
|
agentType: payload.agentType,
|
|
state: payload.state,
|
|
launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken })
|
|
})
|
|
: false
|
|
if (
|
|
profile &&
|
|
shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) &&
|
|
!suppressSyntheticCodexAutoApprovalTitle
|
|
) {
|
|
driveSyntheticTitleFromHook(paneKey, payload.state, profile)
|
|
}
|
|
}
|
|
)
|
|
agentHookServer.setPaneStatusClearListener((clear) => {
|
|
if (mainWindow?.isDestroyed()) {
|
|
return
|
|
}
|
|
mainWindow?.webContents.send('agentStatus:clear', clear)
|
|
})
|
|
setMigrationUnsupportedPtyListener((event) => {
|
|
if (mainWindow?.isDestroyed()) {
|
|
return
|
|
}
|
|
if (event.type === 'set') {
|
|
mainWindow?.webContents.send('agentStatus:migrationUnsupported', event.entry)
|
|
} else {
|
|
mainWindow?.webContents.send('agentStatus:migrationUnsupportedClear', {
|
|
ptyId: event.ptyId
|
|
})
|
|
}
|
|
})
|
|
logStartupMilestone('load-start')
|
|
loadMainWindow(window)
|
|
return window
|
|
}
|
|
|
|
function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void {
|
|
const webContents =
|
|
targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents
|
|
webContents?.send('ui:openFeatureTour')
|
|
}
|
|
|
|
function sendOpenSetupGuide(targetWindow?: BrowserWindow | null): void {
|
|
const webContents =
|
|
targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents
|
|
webContents?.send('ui:openSetupGuide')
|
|
}
|
|
|
|
function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void {
|
|
const webContents =
|
|
targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents
|
|
webContents?.send('ui:openCrashReport')
|
|
}
|
|
|
|
// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface.
|
|
async function presentRendererRecoveryPrompt(recentRecoveryCount: number): Promise<void> {
|
|
if (isQuitting) {
|
|
return
|
|
}
|
|
const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
|
|
const options = {
|
|
type: 'error' as const,
|
|
buttons: ['Reload', 'Quit'],
|
|
defaultId: 0,
|
|
cancelId: 1,
|
|
title: 'Orca keeps failing to load',
|
|
message: 'The app window crashed repeatedly and stopped reloading automatically.',
|
|
detail: `Orca tried to recover ${recentRecoveryCount} times in a row without success. This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.`
|
|
}
|
|
const { response } = window
|
|
? await dialog.showMessageBox(window, options)
|
|
: await dialog.showMessageBox(options)
|
|
if (response === 0 && mainWindow && !mainWindow.isDestroyed()) {
|
|
recordDurableCrashBreadcrumb('renderer_recovery_manual_retry')
|
|
loadMainWindow(mainWindow)
|
|
} else if (response === 1) {
|
|
isQuitting = true
|
|
app.quit()
|
|
}
|
|
}
|
|
|
|
function getGpuFallbackEnvironment(): GpuFallbackEnvironment {
|
|
return {
|
|
appVersion: app.getVersion(),
|
|
electronVersion: process.versions.electron ?? '',
|
|
platform: process.platform
|
|
}
|
|
}
|
|
|
|
function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | null {
|
|
const environment = getGpuFallbackEnvironment()
|
|
if (environment.platform !== 'win32') {
|
|
return null
|
|
}
|
|
return { ...environment, platform: 'win32' }
|
|
}
|
|
|
|
// Why: read the GPU-fallback marker before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only.
|
|
function maybeApplyGpuFallbackForThisLaunch(): void {
|
|
if (isServeMode || process.platform !== 'win32') {
|
|
return
|
|
}
|
|
const marker = readActiveGpuFallbackMarker(app.getPath('userData'), getGpuFallbackEnvironment())
|
|
if (!marker) {
|
|
return
|
|
}
|
|
app.disableHardwareAcceleration()
|
|
app.commandLine.appendSwitch('disable-gpu')
|
|
gpuFallbackActiveThisLaunch = true
|
|
recordCrashBreadcrumb('gpu_fallback_applied', {
|
|
crashesInWindow: marker.crashesInWindow
|
|
})
|
|
}
|
|
|
|
// Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering.
|
|
async function handleGpuChildCrash(reason: string, exitCode: number | null): Promise<void> {
|
|
// Software rendering already active or shutting down: nothing more to do.
|
|
if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) {
|
|
return
|
|
}
|
|
const result = gpuCrashFallbackTracker.recordGpuCrash(performance.now())
|
|
if (!result.shouldEngageFallback) {
|
|
return
|
|
}
|
|
recordCrashBreadcrumb('gpu_fallback_engaged', {
|
|
reason,
|
|
exitCode,
|
|
crashesInWindow: result.crashesInWindow
|
|
})
|
|
const engagedAt = Date.now()
|
|
const environment = getWindowsGpuFallbackEnvironment()
|
|
if (!environment) {
|
|
return
|
|
}
|
|
try {
|
|
writeGpuFallbackMarker(
|
|
app.getPath('userData'),
|
|
{
|
|
engagedAt,
|
|
crashesInWindow: result.crashesInWindow
|
|
},
|
|
environment
|
|
)
|
|
} catch (error) {
|
|
console.warn('[gpu-fallback] failed to persist marker:', error)
|
|
return
|
|
}
|
|
const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
|
|
let restartDecision: GpuFallbackRestartDecision
|
|
try {
|
|
restartDecision = await promptForGpuFallbackRestart(window)
|
|
} catch (error) {
|
|
console.warn('[gpu-fallback] failed to show restart prompt:', error)
|
|
return
|
|
}
|
|
const fallbackData = {
|
|
processReason: reason,
|
|
exitCode,
|
|
crashesInWindow: result.crashesInWindow
|
|
}
|
|
if (isQuitting) {
|
|
return
|
|
}
|
|
if (restartDecision !== 'restart') {
|
|
recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData)
|
|
return
|
|
}
|
|
isQuitting = true
|
|
relaunchApp('gpu-fallback', fallbackData)
|
|
// Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon.
|
|
destroySystemTray()
|
|
app.exit(0)
|
|
}
|
|
|
|
function recordProcessGoneCrash(
|
|
source: 'renderer' | 'child',
|
|
processType: string,
|
|
reason: string,
|
|
exitCode: number | null,
|
|
details: Record<string, unknown>,
|
|
webContentsId?: number
|
|
): void {
|
|
recordProcessGoneCrashEvent(crashReports, {
|
|
source,
|
|
processType,
|
|
reason,
|
|
exitCode,
|
|
expectedTeardown: getExpectedTeardownScope(webContentsId),
|
|
details
|
|
})
|
|
}
|
|
|
|
function shutdownWatchersOnce(): Promise<void> {
|
|
if (watcherShutdownDone) {
|
|
return Promise.resolve()
|
|
}
|
|
if (!watcherShutdownPromise) {
|
|
// Why: @parcel/watcher tears down native async work on unsubscribe; Electron must await it before Node's environment exits.
|
|
watcherShutdownPromise = Promise.allSettled([
|
|
closeAllWatchers(),
|
|
disposeWorktreeBaseDirectoryWatchers()
|
|
])
|
|
.then((results) => {
|
|
for (const result of results) {
|
|
if (result.status === 'rejected') {
|
|
console.error('[filesystem-watcher] shutdown failed:', result.reason)
|
|
}
|
|
}
|
|
})
|
|
.then(() => {
|
|
watcherShutdownDone = true
|
|
})
|
|
}
|
|
return watcherShutdownPromise
|
|
}
|
|
|
|
// Why: cursor-agent re-emits its own OSC title on every redraw, overwriting a one-shot frame — so re-assert a working frame on an interval.
|
|
// 80ms matches Pi's cadence (smooth but under the IPC budget). opencode needs only one frame but reuses this for consistent animated UX.
|
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
const SPINNER_INTERVAL_MS = 80
|
|
|
|
const syntheticTitleSpinnerByPaneKey = new Map<
|
|
string,
|
|
SyntheticTitleSpinnerEntry<SyntheticAgentTitleProfile>
|
|
>()
|
|
let syntheticTitleSpinnerTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
type ServeOptions = {
|
|
json: boolean
|
|
wsPort?: number
|
|
pairingAddress: string | null
|
|
noPairing: boolean
|
|
mobilePairing: boolean
|
|
recipeJson: boolean
|
|
projectRoot: string | null
|
|
}
|
|
|
|
function getServeOptions(argv = process.argv): ServeOptions {
|
|
const valueAfter = (flag: string): string | null => {
|
|
const index = argv.indexOf(flag)
|
|
if (index === -1) {
|
|
return null
|
|
}
|
|
const value = argv[index + 1]
|
|
return value && !value.startsWith('--') ? value : null
|
|
}
|
|
const rawPort = valueAfter('--serve-port')
|
|
let wsPort: number | undefined
|
|
if (rawPort) {
|
|
const parsedPort = Number(rawPort)
|
|
if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) {
|
|
throw new Error(`Invalid --serve-port value: ${rawPort}`)
|
|
}
|
|
wsPort = parsedPort
|
|
}
|
|
return {
|
|
json: argv.includes('--serve-json'),
|
|
...(wsPort !== undefined ? { wsPort } : {}),
|
|
pairingAddress: valueAfter('--serve-pairing-address'),
|
|
noPairing: argv.includes('--serve-no-pairing'),
|
|
mobilePairing: argv.includes('--serve-mobile-pairing'),
|
|
recipeJson: argv.includes('--serve-recipe-json'),
|
|
projectRoot: valueAfter('--serve-project-root')
|
|
}
|
|
}
|
|
|
|
function getBundledWebClientRoot(): string | undefined {
|
|
const appPath = app.getAppPath()
|
|
const roots = [
|
|
join(appPath, 'out', 'web'),
|
|
// Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle.
|
|
join(appPath, '..', 'web')
|
|
]
|
|
return roots.find((root) => existsSync(join(root, 'web-index.html')))
|
|
}
|
|
|
|
async function renderTerminalPairingQr(pairingUrl: string): Promise<string | null> {
|
|
try {
|
|
return await QRCode.toString(pairingUrl, { type: 'terminal', small: true })
|
|
} catch {
|
|
try {
|
|
return await QRCode.toString(pairingUrl, { type: 'utf8' })
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
async function printServeReady(options: ServeOptions): Promise<void> {
|
|
if (!runtime || !runtimeRpc) {
|
|
throw new Error('Runtime server must be initialized before printing serve readiness')
|
|
}
|
|
if (options.recipeJson) {
|
|
if (!options.projectRoot) {
|
|
throw new Error('--serve-recipe-json requires --serve-project-root')
|
|
}
|
|
if (!isAbsolute(options.projectRoot)) {
|
|
throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`)
|
|
}
|
|
const projectRootStats = statSync(options.projectRoot)
|
|
if (!projectRootStats.isDirectory()) {
|
|
throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`)
|
|
}
|
|
}
|
|
const boundEndpoint = runtimeRpc.getWebSocketEndpoint()
|
|
const advertised = boundEndpoint
|
|
? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress)
|
|
: null
|
|
const pairing = options.noPairing
|
|
? ({
|
|
available: false,
|
|
reason: 'disabled_by_operator',
|
|
guidance: 'Restart without --no-pairing to create a client pairing offer.'
|
|
} as const)
|
|
: runtimeRpc.createPairingOffer({
|
|
address: options.pairingAddress,
|
|
name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`,
|
|
scope: options.mobilePairing ? 'mobile' : 'runtime'
|
|
})
|
|
const pairingQr =
|
|
pairing.available && options.mobilePairing
|
|
? await renderTerminalPairingQr(pairing.pairingUrl)
|
|
: null
|
|
await serveReadinessPublisher.publish(
|
|
{
|
|
runtimeId: runtime.getRuntimeId(),
|
|
boundEndpoint,
|
|
advertisedEndpoint: advertised?.ok ? advertised.endpoint : null,
|
|
// Why: the WSL reconciliation barrier fails open, so 'pending' warns a WSL PTY launch may still race a repair.
|
|
managedWslCliReconciliation: managedWslCliReconciliationStatus,
|
|
pairing: pairing.available
|
|
? {
|
|
available: true,
|
|
url: pairing.pairingUrl,
|
|
endpoint: pairing.endpoint,
|
|
deviceId: pairing.deviceId,
|
|
webClientUrl: pairing.webClientUrl,
|
|
scope: options.mobilePairing ? 'mobile' : 'runtime',
|
|
qr: pairingQr
|
|
}
|
|
: pairing
|
|
},
|
|
options.recipeJson
|
|
? { mode: 'recipe-json', projectRoot: options.projectRoot! }
|
|
: { mode: options.json ? 'json' : 'human' }
|
|
)
|
|
notifyServeSupervisorReady(runtime.getRuntimeId())
|
|
}
|
|
|
|
function installServeSignalHandlers(): void {
|
|
const quit = (): void => {
|
|
// Why: route SIGINT/SIGTERM through Electron's normal quit so runtime metadata, daemon checkpoints, and telemetry flush.
|
|
app.quit()
|
|
}
|
|
process.once('SIGINT', quit)
|
|
process.once('SIGTERM', quit)
|
|
}
|
|
|
|
// Why: on PTY teardown drop the spinner entry explicitly, else the shared timer keeps ticking with sendSyntheticTitle no-oping forever.
|
|
registerPaneKeyTeardownListener((paneKey) => {
|
|
stopSyntheticTitleSpinner(paneKey)
|
|
})
|
|
|
|
function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void {
|
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
return
|
|
}
|
|
// Why: throttle decorative spinner frames (up to 80ms/agent); final/permission frames are forced because they drive BEL.
|
|
if (
|
|
!shouldSendSyntheticTitleFrame({
|
|
force: options.force === true,
|
|
windowVisible: isSyntheticTitleWindowVisible()
|
|
})
|
|
) {
|
|
return
|
|
}
|
|
// Why: feed the per-PTY tracker directly, never onPtyData — emulator/tails/transcripts/stats must not see fabricated bytes.
|
|
runtime?.ingestSyntheticTitleFrame(ptyId, data)
|
|
// Why: only the kill-switch-off renderer byte-parses synthetic frames; under main authority the copy mints phantom ACKs (see synthetic-title-frame-routing.ts).
|
|
if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) {
|
|
mainWindow.webContents.send('pty:data', { id: ptyId, data })
|
|
}
|
|
}
|
|
|
|
function isSyntheticTitleWindowVisible(): boolean {
|
|
return (
|
|
mainWindow !== null &&
|
|
!mainWindow.isDestroyed() &&
|
|
mainWindow.isVisible() &&
|
|
!mainWindow.isMinimized()
|
|
)
|
|
}
|
|
|
|
function canSendDecorativeSyntheticTitle(): boolean {
|
|
return shouldSendSyntheticTitleFrame({
|
|
force: false,
|
|
windowVisible: isSyntheticTitleWindowVisible()
|
|
})
|
|
}
|
|
|
|
function stopSyntheticTitleSpinner(paneKey: string): void {
|
|
if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) {
|
|
stopSyntheticTitleSpinnerTimerIfIdle()
|
|
}
|
|
}
|
|
|
|
function stopAllSyntheticTitleSpinners(): void {
|
|
syntheticTitleSpinnerByPaneKey.clear()
|
|
stopSyntheticTitleSpinnerTimer()
|
|
}
|
|
|
|
function stopSyntheticTitleSpinnerTimer(): void {
|
|
if (!syntheticTitleSpinnerTimer) {
|
|
return
|
|
}
|
|
clearInterval(syntheticTitleSpinnerTimer)
|
|
syntheticTitleSpinnerTimer = null
|
|
}
|
|
|
|
function stopSyntheticTitleSpinnerTimerIfIdle(): void {
|
|
if (syntheticTitleSpinnerByPaneKey.size === 0) {
|
|
stopSyntheticTitleSpinnerTimer()
|
|
}
|
|
}
|
|
|
|
function tickSyntheticTitleSpinners(): void {
|
|
if (!canSendDecorativeSyntheticTitle()) {
|
|
stopSyntheticTitleSpinnerTimer()
|
|
return
|
|
}
|
|
const ticks = advanceSyntheticTitleSpinnerEntries({
|
|
entries: syntheticTitleSpinnerByPaneKey,
|
|
frameCount: SPINNER_FRAMES.length,
|
|
getPtyIdForPaneKey
|
|
})
|
|
for (const tick of ticks) {
|
|
sendSyntheticTitle(
|
|
tick.ptyId,
|
|
`\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07`
|
|
)
|
|
}
|
|
stopSyntheticTitleSpinnerTimerIfIdle()
|
|
}
|
|
|
|
function ensureSyntheticTitleSpinnerTimer(): void {
|
|
if (
|
|
syntheticTitleSpinnerTimer ||
|
|
syntheticTitleSpinnerByPaneKey.size === 0 ||
|
|
!canSendDecorativeSyntheticTitle()
|
|
) {
|
|
return
|
|
}
|
|
// Why: one shared timer for all spinners — per-pane intervals multiplied idle wakeups when several agents were working.
|
|
syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS)
|
|
}
|
|
|
|
function resumeSyntheticTitleSpinnerTimer(): void {
|
|
ensureSyntheticTitleSpinnerTimer()
|
|
}
|
|
|
|
function driveSyntheticTitleFromHook(
|
|
paneKey: string,
|
|
state: AgentStatusState,
|
|
profile: SyntheticAgentTitleProfile
|
|
): void {
|
|
const ptyId = getPtyIdForPaneKey(paneKey)
|
|
if (!ptyId) {
|
|
return
|
|
}
|
|
if (state === 'working') {
|
|
// Why: emit the first frame immediately so the spinner is visible now, not up to 80ms later at the next interval tick.
|
|
const existing = syntheticTitleSpinnerByPaneKey.get(paneKey)
|
|
const frame = existing ? existing.frame : 0
|
|
sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[frame]} ${profile.workingLabel}\x07`)
|
|
if (existing) {
|
|
// Why: refresh the profile so a mid-pane agent-type change lands on the right idle/permission labels at terminal state.
|
|
existing.profile = profile
|
|
return
|
|
}
|
|
syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile })
|
|
ensureSyntheticTitleSpinnerTimer()
|
|
return
|
|
}
|
|
// Why: stop the spinner first so the next tick can't race the state back to "working", then inject the terminal frame.
|
|
// Permission frames add a trailing BEL to light up user-input states; done frames omit it (completion notifications own that attention).
|
|
stopSyntheticTitleSpinner(paneKey)
|
|
const needsUserInput = state === 'blocked' || state === 'waiting'
|
|
const label = needsUserInput ? profile.permissionLabel : profile.idleLabel
|
|
sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, {
|
|
force: true
|
|
})
|
|
}
|
|
|
|
function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: {
|
|
agentType: string | null | undefined
|
|
state: AgentStatusState
|
|
launchConfig:
|
|
| {
|
|
agentArgs?: string | null
|
|
agentEnv?: Record<string, string> | null
|
|
}
|
|
| null
|
|
| undefined
|
|
}): boolean {
|
|
if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) {
|
|
return false
|
|
}
|
|
if (!args.launchConfig) {
|
|
return false
|
|
}
|
|
return (
|
|
resolveTuiAgentPermissionMode({
|
|
agent: 'codex',
|
|
agentArgs: args.launchConfig.agentArgs,
|
|
agentEnv: args.launchConfig.agentEnv
|
|
}) === 'yolo'
|
|
)
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
logStartupMilestone('app-ready')
|
|
// Why: install certificate decisions before any webview or headless window issues its first TLS request.
|
|
app.on(
|
|
'certificate-error',
|
|
(event, webContents, url, error, certificate, callback, isMainFrame) => {
|
|
browserCertificateTrustController.handleCertificateError({
|
|
event,
|
|
webContents,
|
|
url,
|
|
error,
|
|
certificate,
|
|
callback,
|
|
isMainFrame
|
|
})
|
|
}
|
|
)
|
|
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
|
|
// Why: setName drives the macOS safeStorage Keychain item name; use the stable appName (not per-branch `name`) so dev branches share one key and don't re-prompt.
|
|
app.setName(devInstanceIdentity.appName)
|
|
|
|
// Why: managed WSL launchers live outside the Windows app bundle, so keep their launcher/bridge contract synced across app updates.
|
|
managedWslCliReconciliationStatus = 'pending'
|
|
managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({
|
|
isPackaged: app.isPackaged,
|
|
userDataPath: getCanonicalUserDataPath(),
|
|
appVersion: app.getVersion()
|
|
})
|
|
.then((results) => {
|
|
for (const result of results) {
|
|
if (result.outcome === 'failed') {
|
|
console.warn(
|
|
`[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}`
|
|
)
|
|
} else if (result.outcome === 'repaired') {
|
|
console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`)
|
|
}
|
|
}
|
|
managedWslCliReconciliationStatus = 'settled'
|
|
})
|
|
.catch((error) => {
|
|
managedWslCliReconciliationStatus = 'failed'
|
|
console.warn(
|
|
'[wsl-cli] Managed registration reconciliation discovery failed:',
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
})
|
|
managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier(
|
|
managedWslCliReconciliationReady
|
|
)
|
|
|
|
const activeOrcaProfile = ensureActiveOrcaProfile()
|
|
store = new Store({ dataFile: activeOrcaProfile.dataFile })
|
|
logStartupMilestone('store-loaded')
|
|
// Why: apply initial fallback WSL distro from store settings for global git/CLI calls.
|
|
setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null)
|
|
store.onSettingsChanged((updates, settings) => {
|
|
if ('terminalWindowsWslDistro' in updates) {
|
|
// Why: synchronize fallback WSL distro updates to runner.
|
|
setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null)
|
|
}
|
|
if ('showMenuBarIcon' in updates) {
|
|
// Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live.
|
|
syncMacMenuBarIcon(settings.showMenuBarIcon !== false)
|
|
}
|
|
})
|
|
// Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session.
|
|
attachClaudeLivePtyPersistence(store)
|
|
// Why: while a live claude defers the managed OAuth refresh, usage shows
|
|
// "Waiting for Claude session"; refetch when the last live PTY exits so the
|
|
// error clears immediately instead of after the failure backoff.
|
|
onLiveClaudePtysDrained(() => {
|
|
void rateLimits?.refreshAfterClaudeLivePtysDrained()
|
|
})
|
|
const persistedClaudePtyIds = store.getClaudeLivePtySessionIds()
|
|
seedLiveClaudePtysFromPersistence(persistedClaudePtyIds)
|
|
if (persistedClaudePtyIds.length > 0) {
|
|
console.log(
|
|
`[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate`
|
|
)
|
|
}
|
|
applyAppIcon(store.getSettings().appIcon)
|
|
if (shouldSuppressDevEducation({ isDev: is.dev })) {
|
|
suppressDevEducationForStore(store)
|
|
}
|
|
try {
|
|
// Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run.
|
|
await applyElectronProxySettings(store.getSettings())
|
|
} catch {
|
|
console.warn('[proxy] Failed to apply network proxy settings')
|
|
}
|
|
// Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path.
|
|
initializeBrowserSessionsForApp({
|
|
orcaProfileId: activeOrcaProfile.profile.id,
|
|
profileDirectory: activeOrcaProfile.profileDirectory
|
|
})
|
|
unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast()
|
|
agentAwakeService = new AgentAwakeService()
|
|
agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)
|
|
// Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake.
|
|
agentAwakeService.setStatuses([])
|
|
unsubscribeAgentAwakeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => {
|
|
agentAwakeService?.setStatuses(statuses)
|
|
})
|
|
// Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early.
|
|
initTelemetry(store)
|
|
// Why: the trust-grant module is bundled into plain-node CLI entries where
|
|
// the telemetry client cannot load, so the tracker is injected here instead
|
|
// of imported there.
|
|
setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => {
|
|
track('codex_trust_grant', {
|
|
outcome,
|
|
host_kind: hostKind,
|
|
lane,
|
|
...(reason !== undefined ? { fallback_reason: reason } : {}),
|
|
...(errorClass !== undefined ? { error_class: errorClass } : {}),
|
|
...(verifyClass !== undefined ? { verify_class: verifyClass } : {})
|
|
})
|
|
})
|
|
// Why: the error-tracking lane (telemetry-error-tracking.md) is its own
|
|
// composition root — independent of product telemetry — and must
|
|
// initialize before any IPC handler / runtime span is created so the
|
|
// tracer's active sink is populated at the moment the first span fires.
|
|
// Honors DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / ORCA_DIAGNOSTICS_DISABLED
|
|
// / CI internally; those gates do not need to be re-checked here.
|
|
initObservability()
|
|
recordDurableCrashBreadcrumb('main_process_lifecycle_started', {
|
|
packaged: app.isPackaged,
|
|
platform: process.platform
|
|
})
|
|
// Why: cohort-classifier reads repo count synchronously at every emit, so hydrate it here — before any IPC handler or window can trigger track().
|
|
initCohortClassifier(store)
|
|
initOnboardingCohortClassifier(store)
|
|
stats = new StatsCollector()
|
|
claudeUsage = new ClaudeUsageStore(store)
|
|
codexUsage = new CodexUsageStore(store)
|
|
openCodeUsage = new OpenCodeUsageStore(store)
|
|
rateLimits = new RateLimitService()
|
|
codexRuntimeHome = new CodexRuntimeHomeService(store)
|
|
// Why: an incapable trust-grant host must fall back to the managed home for
|
|
// every consumer (PTY env, rate limits, commit messages) in one place.
|
|
codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable())
|
|
// Why: while the real-home lane owns ~/.codex/hooks.json, the legacy
|
|
// system-home sweep inside managed installs would delete the entry the
|
|
// real-home installer just appended. Flag OFF, hooks off, or an incapable
|
|
// trust lane re-arms the sweep so downgrade, opt-out, and rollback converge.
|
|
setSystemCodexHomeHookSweepSuppressed(
|
|
() =>
|
|
codexRuntimeHome !== null &&
|
|
codexRuntimeHome.isHostSystemDefaultRealHome() &&
|
|
isAgentStatusHooksEnabled(store?.getSettings())
|
|
)
|
|
const codexSessionMigration = createCodexSessionMigrationScheduler({
|
|
isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true,
|
|
isQuitting: () => isQuitting,
|
|
resolveSystemCodexHomePathOverride: () =>
|
|
resolveHostCodexSessionSourceHome(store!.getSettings()),
|
|
startBackfill: startCodexSessionBackfillInBackground,
|
|
startIndexHeal: startCodexSessionIndexHealInBackground
|
|
})
|
|
codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, {
|
|
onHostSystemDefaultSelected: codexSessionMigration.requestRun
|
|
})
|
|
// Why: one-time per-host backfill makes historical Orca-managed Codex
|
|
// sessions visible to the user's own resume picker and app history (#4444,
|
|
// #8612). Deferred so startup and first PTY spawns never compete with the
|
|
// sessions tree walk.
|
|
codexSessionMigration.scheduleInitialRun()
|
|
claudeRuntimeAuth = new ClaudeRuntimeAuthService(store)
|
|
claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth)
|
|
rateLimits.setCodexHomePathResolver((target) =>
|
|
codexRuntimeHome!.prepareForRateLimitFetch(target)
|
|
)
|
|
rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings()))
|
|
rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings()))
|
|
const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync(
|
|
rateLimits,
|
|
store.getSettings()
|
|
)
|
|
store.onSettingsChanged((updates, settings) => {
|
|
// Why: auto is a live policy; retarget only providers whose settings-derived runtime changed.
|
|
void syncAccountRuntimeTargets(updates, settings).catch((error) =>
|
|
console.warn('[rate-limits] Failed to apply account runtime target:', error)
|
|
)
|
|
})
|
|
rateLimits.setClaudeAuthPreparationResolver((target) =>
|
|
claudeRuntimeAuth!.prepareForRateLimitFetch(target)
|
|
)
|
|
// Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s).
|
|
agentHookServer.setClaudeStatusLineListener((event) => {
|
|
rateLimits?.ingestLiveClaudeRateLimits(event)
|
|
})
|
|
rateLimits.setOpenCodeGoConfigResolver(() => {
|
|
const settings = store!.getSettings()
|
|
return {
|
|
sessionCookie: settings.opencodeSessionCookie,
|
|
workspaceIdOverride: settings.opencodeWorkspaceId
|
|
}
|
|
})
|
|
rateLimits.setMiniMaxConfigResolver(() => {
|
|
const settings = store!.getSettings()
|
|
return {
|
|
sessionCookie: readMiniMaxSessionCookie() ?? '',
|
|
groupId: settings.minimaxGroupId,
|
|
models: settings.minimaxUsageModels
|
|
}
|
|
})
|
|
rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled)
|
|
rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings())
|
|
keybindings = new KeybindingService({
|
|
homePath: app.getPath('home'),
|
|
getLegacyOverrides: () => store!.getSettings().keybindings,
|
|
legacyTabSwitchSeed: {
|
|
isPending: () => store!.getSettings().tabSwitchKeybindingSeed === 'pending',
|
|
markSeeded: () => {
|
|
store!.updateSettings({ tabSwitchKeybindingSeed: 'done' })
|
|
}
|
|
}
|
|
})
|
|
browserManager.setSettingsResolver(() => ({ keybindings: keybindings?.getOverrides() }))
|
|
rateLimits.setInactiveClaudeAccountsResolver(() => {
|
|
const settings = store!.getSettings()
|
|
const activeIds = new Set(
|
|
[
|
|
normalizeClaudeRuntimeSelection(settings).host,
|
|
...Object.values(normalizeClaudeRuntimeSelection(settings).wsl)
|
|
].filter(Boolean)
|
|
)
|
|
return settings.claudeManagedAccounts
|
|
.filter((account) => !activeIds.has(account.id))
|
|
.map((account) => ({
|
|
id: account.id,
|
|
managedAuthPath: account.managedAuthPath,
|
|
managedAuthRuntime: account.managedAuthRuntime,
|
|
wslDistro: account.wslDistro,
|
|
wslLinuxAuthPath: account.wslLinuxAuthPath
|
|
}))
|
|
})
|
|
rateLimits.setInactiveCodexAccountsResolver(() => {
|
|
const settings = store!.getSettings()
|
|
const activeIds = new Set(
|
|
[
|
|
normalizeCodexRuntimeSelection(settings).host,
|
|
...Object.values(normalizeCodexRuntimeSelection(settings).wsl)
|
|
].filter(Boolean)
|
|
)
|
|
return settings.codexManagedAccounts
|
|
.filter((account) => !activeIds.has(account.id))
|
|
.map((account) => ({ id: account.id, managedHomePath: account.managedHomePath }))
|
|
})
|
|
const runtimeService = new OrcaRuntimeService(store, stats, {
|
|
agentSessionClaimSigner: loadAgentSessionClaimSigner(
|
|
getProfileUserDataPath(),
|
|
getProfileUserDataPath()
|
|
),
|
|
// Why: resolve the PTY provider lazily — a daemon swap happens later, so an eager reference would freeze the pre-daemon provider (design §4.3).
|
|
getLocalProvider: () => getLocalPtyProvider(),
|
|
// Why: SSH relay providers register after construction and may reconnect, so destructive cleanup must resolve the current generation.
|
|
getSshProvider: (connectionId) => getSshPtyProvider(connectionId),
|
|
onPtyStopped: clearProviderPtyState,
|
|
onTerminalAgentStatus: (event) => {
|
|
agentHookServer.ingestTerminalStatus(event)
|
|
},
|
|
// Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer.
|
|
onTerminalSideEffects: (batch: TerminalSideEffectBatch) => {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('pty:sideEffect', batch)
|
|
}
|
|
},
|
|
getDesktopWindowStatus: getDesktopWindowStatus,
|
|
// Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents.
|
|
getAgentStatusSnapshot: () =>
|
|
agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true),
|
|
// Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only.
|
|
getAdditionalAiVaultCodexHomePaths: () =>
|
|
codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [],
|
|
prepareAiVaultSessionResume: (args) =>
|
|
prepareLegacySharedCodexSessionResume(args, {
|
|
isHostSystemDefaultRealHome: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true,
|
|
systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings())
|
|
}),
|
|
buildAgentHookPtyEnv: () =>
|
|
isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}
|
|
})
|
|
runtime = runtimeService
|
|
browserManager.setBrowserGuestStateChangedListener((worktreeId) => {
|
|
runtimeService.notifyMobileSessionTabsChanged(worktreeId)
|
|
})
|
|
automations = new AutomationService(store, {
|
|
claudeUsage,
|
|
codexUsage,
|
|
// Why: desktop clients mirror remote-host automations, but only a server process should execute remote_host_service-owned schedules.
|
|
allowRemoteHostScheduling: isServeMode,
|
|
headlessDispatcher: isServeMode
|
|
? async ({ automation, run, target }) => {
|
|
const terminalSnapshotLimit = 2_000
|
|
let terminalHandle: string
|
|
let terminalSessionId: string | null = null
|
|
let terminalPaneKey: string | null = null
|
|
let terminalPtyId: string | null = null
|
|
let workspaceId: string
|
|
let workspaceDisplayName: string | null = null
|
|
|
|
if (automation.workspaceMode === 'new_per_run') {
|
|
const created = await runtimeService.createManagedWorktree({
|
|
...buildHeadlessAutomationWorktreeCreateArgs({
|
|
automation,
|
|
run,
|
|
repo: target.repo
|
|
})
|
|
})
|
|
terminalHandle = created.startupTerminal?.handle ?? ''
|
|
terminalSessionId = created.startupTerminal?.tabId ?? null
|
|
terminalPaneKey = created.startupTerminal?.paneKey ?? null
|
|
terminalPtyId = created.startupTerminal?.ptyId ?? null
|
|
workspaceId = created.worktree.id
|
|
workspaceDisplayName = created.worktree.displayName ?? null
|
|
if (!terminalHandle) {
|
|
throw new Error(
|
|
created.warning ||
|
|
'Automation workspace was created, but no agent terminal started.'
|
|
)
|
|
}
|
|
} else {
|
|
if (!automation.workspaceId) {
|
|
throw new Error('The target workspace is no longer available.')
|
|
}
|
|
const terminal = await runtimeService.launchAgentTerminal(
|
|
`id:${automation.workspaceId}`,
|
|
{
|
|
agent: automation.agentId,
|
|
prompt: automation.prompt,
|
|
title: run.title
|
|
}
|
|
)
|
|
terminalHandle = terminal.handle
|
|
terminalSessionId = terminal.tabId ?? null
|
|
terminalPaneKey = terminal.paneKey ?? null
|
|
terminalPtyId = terminal.ptyId ?? null
|
|
workspaceId = terminal.worktreeId
|
|
const worktree = await runtimeService.showManagedWorktree(`id:${workspaceId}`)
|
|
workspaceDisplayName = worktree.displayName ?? null
|
|
}
|
|
|
|
const completion = (async () => {
|
|
const wait = await runtimeService.waitForTerminal(terminalHandle, {
|
|
condition: 'tui-idle'
|
|
})
|
|
const read = await runtimeService.readTerminal(terminalHandle, {
|
|
limit: terminalSnapshotLimit
|
|
})
|
|
const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer()
|
|
snapshotBuffer.append(read.tail.join('\n'))
|
|
if (wait.satisfied) {
|
|
return {
|
|
status: 'completed' as const,
|
|
outputSnapshot: snapshotBuffer.snapshot(),
|
|
error: null
|
|
}
|
|
}
|
|
return {
|
|
status: 'dispatch_failed' as const,
|
|
outputSnapshot: snapshotBuffer.snapshot(),
|
|
error: wait.blockedReason
|
|
? `Automation agent is blocked: ${wait.blockedReason}.`
|
|
: 'Automation agent did not report completion.'
|
|
}
|
|
})()
|
|
|
|
return {
|
|
workspaceId,
|
|
workspaceDisplayName,
|
|
terminalSessionId,
|
|
terminalPaneKey,
|
|
terminalPtyId,
|
|
completion
|
|
}
|
|
}
|
|
: undefined
|
|
})
|
|
runtimeService.setAutomationService(automations)
|
|
runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
|
|
runtimeService.setCommitMessageAgentEnvironmentResolvers({
|
|
// Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home.
|
|
prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch,
|
|
prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target)
|
|
})
|
|
starNag = new StarNagService(store, stats)
|
|
starNag.start()
|
|
starNag.registerIpcHandlers()
|
|
runtimeService.setAgentBrowserBridge(
|
|
new AgentBrowserBridge(browserManager, {
|
|
onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId)
|
|
})
|
|
)
|
|
|
|
// Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser.
|
|
// Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace.
|
|
const emulatorBridge = new EmulatorBridge()
|
|
runtimeService.setEmulatorBridge(emulatorBridge)
|
|
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
|
|
if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) {
|
|
// Why: establish capability before managed-hook reconciliation so an
|
|
// incapable host re-arms and completes the legacy real-home sweep now.
|
|
ensureRealHomeCodexHookState({
|
|
hooksEnabled: isAgentStatusHooksEnabled(store.getSettings()),
|
|
userDataPath: app.getPath('userData')
|
|
})
|
|
}
|
|
if (shouldInstallManagedHooks(is.dev)) {
|
|
// Why: check the persisted off switch before any auto-install so removed hooks don't silently reappear on launch.
|
|
if (isAgentStatusHooksEnabled(store.getSettings())) {
|
|
runManagedHookInstallers(MANAGED_AGENT_HOOK_INSTALLERS)
|
|
} else {
|
|
removeManagedAgentHooks()
|
|
}
|
|
}
|
|
app.on('child-process-gone', (_event, details) => {
|
|
recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, {
|
|
name: details.name,
|
|
serviceName: details.serviceName,
|
|
type: details.type
|
|
})
|
|
if (
|
|
isGpuFallbackCrashCandidate({
|
|
platform: process.platform,
|
|
processType: details.type,
|
|
reason: details.reason
|
|
})
|
|
) {
|
|
void handleGpuChildCrash(details.reason, details.exitCode ?? null)
|
|
}
|
|
})
|
|
|
|
logStartupMilestone('services-initialized')
|
|
await ensureMainI18n()
|
|
await setMainUiLanguage(store.getSettings().uiLanguage)
|
|
logStartupMilestone('i18n-ready')
|
|
|
|
registerAppMenu({
|
|
appMenuLabel: devInstanceIdentity.name,
|
|
onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options),
|
|
onBeforeReload: ({ ignoreCache, webContentsId }) => {
|
|
if (mainWindow?.webContents.id === webContentsId) {
|
|
markExpectedRendererReload(webContentsId)
|
|
}
|
|
recordCrashBreadcrumb('manual_reload_requested', { ignoreCache })
|
|
},
|
|
onOpenSettings: openSettingsFromSystemMenu,
|
|
onOpenSetupGuide: (targetWindow) => {
|
|
recordCrashBreadcrumb('setup_guide_opened')
|
|
const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null
|
|
sendOpenSetupGuide(targetBrowserWindow)
|
|
},
|
|
onOpenCrashReport: (targetWindow) => {
|
|
recordCrashBreadcrumb('crash_report_opened')
|
|
const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null
|
|
sendOpenCrashReport(targetBrowserWindow)
|
|
},
|
|
onOpenFeatureTour: (targetWindow) => {
|
|
recordCrashBreadcrumb('feature_tour_opened')
|
|
// Why: use the invoking BrowserWindow so hidden/E2E and multi-window flows route to the right renderer, not global focus.
|
|
const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null
|
|
sendOpenFeatureTour(targetBrowserWindow)
|
|
},
|
|
// Why: menu zoom must act on the window the user is looking at — routing to
|
|
// the main window while the dashboard pop-out is focused zooms behind it.
|
|
onZoomIn: () => {
|
|
if (!zoomDashboardPopoutIfFocused('in')) {
|
|
mainWindow?.webContents.send('terminal:zoom', 'in')
|
|
}
|
|
},
|
|
onZoomOut: () => {
|
|
if (!zoomDashboardPopoutIfFocused('out')) {
|
|
mainWindow?.webContents.send('terminal:zoom', 'out')
|
|
}
|
|
},
|
|
onZoomReset: () => {
|
|
if (!zoomDashboardPopoutIfFocused('reset')) {
|
|
mainWindow?.webContents.send('terminal:zoom', 'reset')
|
|
}
|
|
},
|
|
onToggleLeftSidebar: () => {
|
|
mainWindow?.webContents.send('ui:toggleLeftSidebar')
|
|
},
|
|
onToggleRightSidebar: () => {
|
|
mainWindow?.webContents.send('ui:toggleRightSidebar')
|
|
},
|
|
onToggleAppearance: (key) => {
|
|
if (!store) {
|
|
return
|
|
}
|
|
if (key === 'statusBarVisible') {
|
|
// Why: status bar visibility lives in persisted UI state (not settings) and the renderer owns the toggle — forward the event, let it flip + store.
|
|
mainWindow?.webContents.send('ui:toggleStatusBar')
|
|
return
|
|
}
|
|
const current = store.getSettings()
|
|
// Why: these appearance settings are default-on, so a missing persisted value must toggle from visible -> hidden.
|
|
const next = getNextDefaultOnAppearanceSettingValue(current[key])
|
|
store.updateSettings({ [key]: next }, { notifyListeners: true })
|
|
rebuildAppMenu()
|
|
},
|
|
getAppearanceState: () => {
|
|
const settings = store?.getSettings()
|
|
const ui = store?.getUI()
|
|
return {
|
|
showTasksButton: settings?.showTasksButton !== false,
|
|
showAutomationsButton: settings?.showAutomationsButton !== false,
|
|
showMobileButton: settings?.showMobileButton !== false,
|
|
showTitlebarAppName: settings?.showTitlebarAppName !== false,
|
|
statusBarVisible: ui?.statusBarVisible !== false
|
|
}
|
|
},
|
|
getKeybindings: () => keybindings?.getOverrides()
|
|
})
|
|
// Why: parallel E2E Electron instances would race the fixed port (EADDRINUSE); port 0 gives each a random OS-assigned port.
|
|
const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR)
|
|
const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT
|
|
const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort)
|
|
if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) {
|
|
throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`)
|
|
}
|
|
// Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511).
|
|
const devWsPort = is.dev && !isE2E ? 6769 : undefined
|
|
let serveOptions: ServeOptions | null = null
|
|
try {
|
|
serveOptions = isServeMode ? getServeOptions() : null
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error))
|
|
app.exit(1)
|
|
return
|
|
}
|
|
// Why: existing installs may have pairing creds under the late app.getPath('userData'); copy them forward before switching to the canonical path.
|
|
migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData'))
|
|
runtimeRpc = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
// Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts.
|
|
userDataPath: getCanonicalUserDataPath(),
|
|
enableWebSocket: true,
|
|
...(isE2E ? { wsPort: e2eWsPort } : {}),
|
|
...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
|
|
...(serveOptions?.wsPort !== undefined
|
|
? {
|
|
wsPort: serveOptions.wsPort,
|
|
// Why: only explicit `orca serve --port` overrides a stale STA-1511 fallback (issue #8535); default/dev stay fallback-first for pairing stability.
|
|
preferPinnedWsPort: true
|
|
}
|
|
: {}),
|
|
webClientRoot: getBundledWebClientRoot()
|
|
})
|
|
registerMobileHandlers(runtimeRpc, {
|
|
getRelayStatus: () => desktopRelayStatus,
|
|
consumePendingUnpairedDeviceAuthFailure: (webContentsId) => {
|
|
if (
|
|
!mainWindow ||
|
|
mainWindow.isDestroyed() ||
|
|
mainWindow.webContents.id !== webContentsId ||
|
|
!pendingUnpairedDeviceAuthFailure
|
|
) {
|
|
return false
|
|
}
|
|
pendingUnpairedDeviceAuthFailure = false
|
|
return true
|
|
}
|
|
})
|
|
// Why: repeated direct auth failures otherwise look like a client that never connects; point users to re-pairing.
|
|
runtimeRpc.setOnUnpairedDeviceAuthFailure(() => {
|
|
// Why: runtime startup races renderer mount; retain the one-shot until the listener consumes it.
|
|
pendingUnpairedDeviceAuthFailure = true
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure')
|
|
}
|
|
})
|
|
|
|
startTerminalRuntimeStartupServices()
|
|
app.on('activate', requestDesktopActivation)
|
|
|
|
if (serveOptions) {
|
|
// Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness.
|
|
logStartupMilestone('wsl-cli-barrier-start')
|
|
await managedWslCliStartupBarrierReady
|
|
logStartupMilestone('wsl-cli-barrier-resolved', {
|
|
reconciliation: managedWslCliReconciliationStatus
|
|
})
|
|
// Why: headless PTYs must not start on the fallback provider, then get swept when an activated renderer registers desktop lifecycle handlers.
|
|
await localPtyStartupReady
|
|
registerHeadlessPtyRuntime(
|
|
runtime,
|
|
prepareCodexRuntimeHomeForLaunch,
|
|
() => store!.getSettings(),
|
|
(target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target),
|
|
store,
|
|
prepareCodexSessionResumeForLaunch
|
|
)
|
|
// Why: headless servers can't mount <webview> panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest.
|
|
if (headlessBrowserDisplayAvailable) {
|
|
runtime.setOffscreenBrowserBackend(new OffscreenBrowserBackend(browserManager))
|
|
}
|
|
// Why: headless servers have no renderer graph publisher; publish an explicit empty graph so status clients see a ready server.
|
|
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
|
|
await runtimeRpc.start().catch((error) => {
|
|
console.error('[runtime] Failed to start headless RPC transport:', error)
|
|
throw error
|
|
})
|
|
settleServeDesktopActivation()
|
|
installServeSignalHandlers()
|
|
// Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals).
|
|
if (process.platform === 'darwin' || process.platform === 'linux') {
|
|
try {
|
|
// Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none.
|
|
const cliStatus = await new CliInstaller({
|
|
privilegedRunner: async () => {
|
|
throw new Error('serve CLI auto-install must not request administrator privileges')
|
|
}
|
|
}).install()
|
|
console.log(
|
|
`[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}`
|
|
)
|
|
} catch (error) {
|
|
console.warn(
|
|
'[serve] orca CLI install skipped:',
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
}
|
|
}
|
|
// Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort.
|
|
if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) {
|
|
try {
|
|
const dispatcher = await installLinuxBareOrcaDispatcher({
|
|
resourcesPath: process.resourcesPath
|
|
})
|
|
console.log(
|
|
`[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` +
|
|
`${dispatcher.target ? ` -> ${dispatcher.target}` : ''}`
|
|
)
|
|
} catch (error) {
|
|
console.warn(
|
|
'[serve] bare orca dispatcher install skipped:',
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
}
|
|
}
|
|
// Why: headless serve never opens a renderer, so arm scheduled automation dispatch here.
|
|
automations.start()
|
|
await printServeReady(serveOptions)
|
|
return
|
|
}
|
|
|
|
// Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap.
|
|
const [win] = await Promise.all([
|
|
Promise.resolve(openMainWindow()),
|
|
runtimeRpc.start().catch((error) => {
|
|
console.error('[runtime] Failed to start local RPC transport:', error)
|
|
})
|
|
])
|
|
|
|
const cloudAuth = getOrcaCloudAuthConfig()
|
|
if (cloudAuth.configured) {
|
|
try {
|
|
const relayService = new DesktopRelayService({
|
|
authConfig: cloudAuth.config,
|
|
userDataPath: getProfileUserDataPath(),
|
|
appVersion: app.getVersion(),
|
|
runtimeRpc,
|
|
onStatus: (status) => {
|
|
desktopRelayStatus = status
|
|
mainWindow?.webContents.send('mobile:relayStatusChanged', status)
|
|
}
|
|
})
|
|
desktopRelayService = relayService
|
|
runtimeRpc.setMobileRelayPairingProvider({
|
|
createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId),
|
|
onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item),
|
|
onDemandStateChanged: () => relayService.demandStateChanged(),
|
|
getEndpoints: (context, params) => relayService.getEndpoints(context, params),
|
|
provisionRelay: (context, params) => relayService.provisionRelay(context, params)
|
|
})
|
|
relayService.start()
|
|
} catch (error) {
|
|
console.warn(
|
|
'[relay] Desktop relay startup unavailable:',
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window.
|
|
win.once('show', () => {
|
|
// Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener.
|
|
if (!store) {
|
|
return
|
|
}
|
|
const onboarding = store.getOnboarding()
|
|
if (onboarding.closedAt !== null) {
|
|
triggerStartupNotificationRegistration(store)
|
|
}
|
|
})
|
|
})
|
|
|
|
app.on('before-quit', () => {
|
|
if (isQuittingForUpdate()) {
|
|
recordUpdaterLifecycle('before_quit_allowed', undefined, {
|
|
message: 'before-quit allowed for update install'
|
|
})
|
|
}
|
|
isQuitting = true
|
|
desktopRelayService?.fenceAndCloseNow()
|
|
runtimeRpc?.setMobileRelayPairingProvider(null)
|
|
unsubscribeSystemResumeBroadcast?.()
|
|
unsubscribeSystemResumeBroadcast = null
|
|
unsubscribeAgentAwakeStatusChanges?.()
|
|
unsubscribeAgentAwakeStatusChanges = null
|
|
agentAwakeService?.dispose()
|
|
agentAwakeService = null
|
|
// Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks).
|
|
rateLimits?.stop()
|
|
})
|
|
|
|
// Why: will-quit fires twice — first pass runs sync cleanup + preventDefault to await checkpoint writes; second pass exits.
|
|
let daemonDisconnectDone = false
|
|
app.on('will-quit', (e) => {
|
|
const updateQuitInProgress = isQuittingForUpdate()
|
|
if (updateQuitInProgress) {
|
|
recordUpdaterLifecycle(
|
|
'will_quit_cleanup_started',
|
|
{ daemonTeardown: 'disconnect' },
|
|
{ message: 'will-quit cleanup for update install; daemonTeardown=disconnect' }
|
|
)
|
|
}
|
|
// Why: before-quit can still be aborted by renderer beforeunload; only remove the Windows tray icon on the committed quit path.
|
|
destroySystemTray()
|
|
// Why: stats.flush() must precede killAllPty() so still-running agents emit synthetic agent_stop events (killAllPty skips runtime.onPtyExit()).
|
|
starNag?.stop()
|
|
automations?.stop()
|
|
setUnreadDockBadgeCount(0)
|
|
agentHookServer.stop()
|
|
// Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown.
|
|
wslHookRelayManager.disposeAll()
|
|
stats?.flush()
|
|
// Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk.
|
|
runtime?.getAgentBrowserBridge()?.destroyAllSessions()
|
|
// Why: headless offscreen browser windows are main-process owned; tear them down explicitly on quit.
|
|
runtime?.getOffscreenBrowserBackend()?.destroyAll?.()
|
|
browserManager.setBrowserGuestStateChangedListener(null)
|
|
const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve()
|
|
killAllPty()
|
|
const watcherShutdown = shutdownWatchersOnce()
|
|
store?.flush()
|
|
|
|
// Why: preventDefault to await disconnectDaemon's async checkpoint writes (else data lost); guard prevents an infinite quit loop on the re-fired will-quit.
|
|
if (!daemonDisconnectDone) {
|
|
e.preventDefault()
|
|
// Why: capture pid/runtimeId synchronously (before any await) so a later teardown path can't null them out mid-chain.
|
|
const ownedPid = process.pid
|
|
const ownedRuntimeId = runtime?.getRuntimeId()
|
|
// Why: keep inside the !daemonDisconnectDone guard so the re-fired will-quit doesn't re-run RPC.stop()/metadata-clear against the updater's replacement process.
|
|
const rpcStopAndClear = runtimeRpc
|
|
? runtimeRpc
|
|
.stop()
|
|
.then(() => awaitRuntimeFileWatcherUnsubscribes())
|
|
.then(() => {
|
|
if (ownedRuntimeId) {
|
|
// Why: must match the path the runtime server wrote metadata to (getCanonicalUserDataPath), not late app.getPath('userData').
|
|
clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId)
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error('[runtime] Failed to stop local RPC transport:', error)
|
|
})
|
|
: Promise.resolve()
|
|
// Why: allSettled (not all) keeps fail-open — a daemon-disconnect rejection still quits instead of hanging.
|
|
// Why: telemetry flush folds in before app.quit() (bounded 2s); catch defensively so a flush failure can't cancel the quit chain.
|
|
// Why: normal quits keep the detached daemon for warm reattach, but a dead dev parent leaves the temp/dev profile ownerless.
|
|
const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon()
|
|
// Why: a wedged transport (half-open post-sleep socket) can leave one
|
|
// member unsettled forever and block app.quit() until Force Quit (#9447).
|
|
settleTeardownWithinDeadline([
|
|
{ name: 'daemon', promise: daemonTeardown },
|
|
{ name: 'runtime-rpc', promise: rpcStopAndClear },
|
|
{ name: 'watchers', promise: watcherShutdown },
|
|
{ name: 'emulator', promise: emulatorShutdown }
|
|
])
|
|
.then((pendingTeardowns) => {
|
|
if (pendingTeardowns.length > 0) {
|
|
console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns })
|
|
}
|
|
})
|
|
.then(() => shutdownTelemetry())
|
|
.then(() => shutdownObservability())
|
|
.catch(() => {
|
|
/* swallow — telemetry must never prevent app.quit() */
|
|
})
|
|
.then(() => {
|
|
daemonDisconnectDone = true
|
|
app.quit()
|
|
})
|
|
}
|
|
})
|
|
|
|
app.on('window-all-closed', () => {
|
|
// Why: serve mode / disposable offscreen browser windows must not take down runtime RPC — the policy fn keeps the app alive.
|
|
// Why: on macOS a quit-in-progress (Cmd+Q) is canceled by the renderer buffer-capture deferral; re-trigger quit so it actually exits.
|
|
if (
|
|
shouldQuitWhenAllWindowsClosed({
|
|
platform: process.platform,
|
|
isQuitting,
|
|
isServeMode
|
|
})
|
|
) {
|
|
app.quit()
|
|
}
|
|
})
|