mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
* fix(codex): safely re-land WSL direct homes * fix(codex): finish WSL direct-home cutover * fix(codex): coalesce WSL launch hook installs * perf(codex): avoid duplicate retired WSL session scan * fix(codex): retain canonical WSL retired-home path * fix(codex): fail closed before retiring WSL auth * fix(codex): reopen WSL drain after rollback * fix(codex): preserve WSL source on unknown panes * fix(codex): harden repeated WSL runtime drains * perf(codex): bound pending WSL session scans * fix(codex): recover invalid WSL session watermarks * fix(codex): validate retained WSL scan state * fix(codex): accept durable WSL scan state * test(codex): cover the drain's inode-identity guard against destination replacement Removing the four `target_auth -ef temporary_destination_auth` assertions left all 33 apply-script tests passing, so a regression deleting them would have shipped silently. Reproduced before writing this. A hash check cannot catch the case. The pinned hard link keeps the original inode, so it still hashes correctly after another writer atomically renames a different file over the destination path; only inode identity sees it. Without the guard the script exits 0 and retires the source, leaving the user holding bytes nothing validated. The new case asserts the source survives. The harness is split by responsibility so no file exceeds its max-lines budget: fixtures, the coreutils interference shims, the run types, the apply runner, and the recovery/absent runners. The atomic-rename hook is deliberately separate from the in-place rewrite shim because different guards catch them. * fix(codex): keep the split drain harness inside the child-process boundaries Extracting the harness into non-test modules moved it out of the exemptions the single test file had: three new files import child_process, and two spawned without windowsHide. Adds the three to the import allowlist, and sets windowsHide on the spawns rather than exempting them - the flag is correct for these calls regardless of the ratchet, and they are skipped on win32 anyway. --------- Co-authored-by: Merge Sim <sim@local>
3803 lines
166 KiB
TypeScript
3803 lines
166 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 { randomUUID } from 'node:crypto'
|
|
import { isAbsolute, join } from 'node:path'
|
|
import os from 'node:os'
|
|
import {
|
|
app,
|
|
BrowserWindow,
|
|
dialog,
|
|
ipcMain,
|
|
nativeTheme,
|
|
powerMonitor,
|
|
type Tray,
|
|
session
|
|
} from 'electron'
|
|
import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default'
|
|
import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice'
|
|
import { electronApp, is } from '@electron-toolkit/utils'
|
|
import {
|
|
Store,
|
|
initDataPath,
|
|
getCanonicalUserDataPath,
|
|
migrateMobilePairingDataToCanonicalUserDataPath
|
|
} from './persistence'
|
|
import { setAppEnvironment } from '../shared/app-environment'
|
|
import { ElectronAppEnvironment } from './host/electron-app-environment'
|
|
import { setPtyHostBindings } from './ipc/pty-host-bindings'
|
|
import { electronRuntimeDesktopSurface } from './host/electron-runtime-desktop-surface'
|
|
import { setRuntimeDesktopSurface } from './runtime/runtime-desktop-surface'
|
|
import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-commands'
|
|
import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory'
|
|
import { electronHttpClient } from './host/electron-http-client'
|
|
import { setMainHttpClient } from './network/http-client'
|
|
import { electronSpeechServiceFactories } from './host/electron-speech-services'
|
|
import { setSpeechServiceFactories } from './speech/speech-runtime-service'
|
|
import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal'
|
|
import { setSecretStore } from '../shared/secret-store'
|
|
import { ElectronSecretStore } from './host/electron-secret-store'
|
|
import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report'
|
|
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 { initSshHostKeyStoreFile } from './ssh/ssh-host-key-store'
|
|
import { AgentSessionTransitionRecorder } from './stats/agent-session-transition-recorder'
|
|
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
|
|
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
|
|
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store'
|
|
import {
|
|
killAllPty,
|
|
clearProviderPtyState,
|
|
getPtyIdForPaneKey,
|
|
registerPaneKeyTeardownListener,
|
|
getLocalPtyProvider,
|
|
getSshPtyProvider,
|
|
registerHeadlessPtyRuntime,
|
|
type CodexHomeLaunchContext
|
|
} from './ipc/pty'
|
|
import {
|
|
initDaemonPtyProvider,
|
|
disconnectDaemon,
|
|
getDaemonProvider,
|
|
listLiveDaemonPtyIds,
|
|
shutdownDaemon
|
|
} from './daemon/daemon-init'
|
|
import {
|
|
type CodexPaneHomeRoute,
|
|
getCodexPaneAccount,
|
|
hasAnyRecordedLegacyWslCodexPane,
|
|
hasRecordedManagedHostCodexPane,
|
|
isCodexPaneHomeRouteProvenAwayFromSharedHome,
|
|
reconcileCodexPaneAccountsWithLivePtys
|
|
} from './codex/codex-pane-account-registry'
|
|
import { closeAllWatchers, desktopWorktreeWatcherRemoval } from './ipc/filesystem-watcher'
|
|
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
|
|
import { stopFolderRepoGitUpgradeWatch } from './ipc/folder-repo-git-upgrade'
|
|
import { registerCoreHandlers } from './ipc/register-core-handlers/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 { recordManagedHookInstallFailure } from './agent-hooks/install-telemetry'
|
|
import {
|
|
indexPersistedPaneKeyPtyIds,
|
|
isLocalExecutionHost,
|
|
resolveAgentWorkspaceExecutionHostId,
|
|
sweepRestoredSubagentsWithoutLiveAgent
|
|
} from './agent-hooks/restored-subagent-liveness-sweep'
|
|
import {
|
|
installManagedAgentHooks,
|
|
isAgentStatusHooksEnabled,
|
|
removeManagedAgentHooksAsync,
|
|
resolveStartupManagedHookAction,
|
|
shouldInstallStartupManagedAgentHook,
|
|
shouldContinueManagedHookStartup
|
|
} 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/startup-notification-registration'
|
|
import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime'
|
|
import { ArtifactCloudService } from './artifacts/artifact-cloud-service'
|
|
import { SkillCloudService } from './skills/skill-cloud-service'
|
|
import { recoverPendingSkillTransactions } from './skills/skill-transaction-startup-recovery'
|
|
import { isArtifactSharingEnabled } from '../shared/artifact-sharing-gate'
|
|
import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity'
|
|
import {
|
|
fingerprintOrchestrationPeer,
|
|
type OrchestrationEnvironmentTransport
|
|
} from './runtime/orchestration/environment-transport'
|
|
import { callRuntimeEnvironment } from './ipc/runtime-environment-transport-routing'
|
|
import { resolveEnvironment } from '../shared/runtime-environment-store'
|
|
import { getPreferredPairingOffer } from '../shared/runtime-environments'
|
|
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
|
|
import {
|
|
recordRuntimeRpcStartFailure,
|
|
showRuntimeRpcStartupFailureDialog
|
|
} from './runtime/runtime-rpc-startup-failure'
|
|
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 { scheduleAllPendingHistoryTreeRemovals } from './terminal-history-deletion'
|
|
import { ensureMainI18n, setMainPluginLanguagePacks, setMainUiLanguage } from './i18n/main-i18n'
|
|
import {
|
|
getNextDefaultOnAppearanceSettingValue,
|
|
registerAppMenu,
|
|
rebuildAppMenu
|
|
} from './menu/register-app-menu'
|
|
import { createGpuAccelerationAboutPanelOptions } from './menu/gpu-acceleration-about-panel'
|
|
import {
|
|
checkForRemoteServerUpdate,
|
|
checkForUpdatesFromMenu,
|
|
downloadRemoteServerUpdate,
|
|
getRemoteServerUpdaterSnapshot,
|
|
installRemoteServerUpdate,
|
|
isQuittingForUpdate,
|
|
resolveUpdateInstallMode
|
|
} from './updater'
|
|
import { configureRemoteServerUpdater } from './runtime/remote-server-updater'
|
|
import type { UpdateCheckOptions } from '../shared/update-status-types'
|
|
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
|
import {
|
|
installServeSupervisorDisconnectQuit,
|
|
notifyServeSupervisorReady
|
|
} from './serve-update-handoff'
|
|
import {
|
|
configureElectronNetworkCompatibility,
|
|
configureDevUserDataPath,
|
|
configureOrcaUserDataPathEnv,
|
|
disableUnsupportedChromiumFeatures,
|
|
optOutOfHiddenPageWakeUpThrottling,
|
|
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 { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv'
|
|
import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display'
|
|
import {
|
|
clearGpuFallbackMarker,
|
|
readActiveGpuFallbackMarker,
|
|
writeGpuFallbackMarker,
|
|
type GpuFallbackMarker,
|
|
type GpuFallbackEnvironment,
|
|
type WindowsGpuFallbackEnvironment
|
|
} from './startup/gpu-fallback-marker'
|
|
import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches'
|
|
import {
|
|
DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD,
|
|
DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
|
|
GpuCrashFallbackTracker,
|
|
isGpuFallbackCrashCandidate
|
|
} from './crash-reporting/gpu-crash-fallback-decision'
|
|
import { promptForGpuFallbackRestart } from './crash-reporting/gpu-fallback-restart-prompt'
|
|
import { engageGpuFallbackAfterCrashBurst } from './crash-reporting/gpu-fallback-engagement'
|
|
import { GpuCrashDiagnosticsRecorder } from './crash-reporting/gpu-crash-diagnostics'
|
|
import {
|
|
handleGpuFallbackRecoveredLaunch,
|
|
promptForGpuFallbackRecoveredLaunch
|
|
} from './crash-reporting/gpu-fallback-recovered-launch'
|
|
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 { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy-worker-renderer-recovery'
|
|
import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier'
|
|
import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './startup/dev-instance-identity'
|
|
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
|
|
import { createWindowsShellPathHydration } from './startup/windows-shell-path-hydration'
|
|
import {
|
|
startWindowsDesktopBeforeShellPathReady,
|
|
type WindowsDesktopStartupServices
|
|
} from './startup/windows-desktop-shell-path-startup'
|
|
import {
|
|
acquireSingleInstanceLock,
|
|
logSingleInstanceLockBypass,
|
|
logSingleInstanceLockFailure,
|
|
shouldActivateDesktopForSecondInstance,
|
|
shouldBypassSingleInstanceLock,
|
|
shouldSkipSingleInstanceLock,
|
|
SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE
|
|
} from './startup/single-instance-lock'
|
|
import { startEventLoopStallProbe } from './startup/event-loop-stall-probe'
|
|
import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe'
|
|
import { settledDiffCache } from './git/source-control/git-read-cache-invalidation'
|
|
import { parseSkillShareId } from '../shared/skill-share-link'
|
|
import { SkillShareDeepLinkState } from './startup/skill-share-deep-link-state'
|
|
import {
|
|
isStartupDiagnosticsEnabled,
|
|
logStartupDiagnostic,
|
|
logStartupMilestone
|
|
} from './startup/startup-diagnostics'
|
|
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
|
|
import { probeWindowsInstallDirAcl } from './startup/windows-install-dir-acl-probe'
|
|
import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
|
|
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
|
|
import { registerServeSignalHandlers } from './startup/serve-signal-handlers'
|
|
import {
|
|
createServeDesktopActivationGate,
|
|
settleServeDesktopActivation as settleServeDesktopActivationGate
|
|
} 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 { getKimiRuntimeTarget, resolveKimiHome } from './kimi/kimi-runtime-home'
|
|
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 { shutdownPairedRuntimeBrowserClientHosts } from './browser/paired-runtime-browser-client-host-runtime'
|
|
import {
|
|
getDashboardPopoutWindow,
|
|
zoomDashboardPopoutIfFocused
|
|
} from './window/dashboard-popout-window'
|
|
import {
|
|
createSystemTray,
|
|
destroySystemTray,
|
|
setMacMenuBarIconVisible,
|
|
setTrayAttention,
|
|
type SystemTrayOptions
|
|
} from './tray/system-tray'
|
|
import { createMacAppActivationHandler } from './window/macos-app-activation'
|
|
import { focusExistingMainWindow, safelyRevealWindow } from './window/focus-existing-window'
|
|
import { applyBackgroundActivationPolicy } from './window/foreground-activation-policy'
|
|
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 { reconcileRetainedCodexHookHomes } from './codex/retained-codex-hook-state'
|
|
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 {
|
|
startCodexStateDbBackfillRecoveryInBackground,
|
|
stopCodexStateDbBackfillRecoveries
|
|
} from './codex/codex-state-db-backfill-recovery'
|
|
import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler'
|
|
import { prepareCodexAiVaultSessionResume } from './codex/codex-ai-vault-session-resume'
|
|
import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume'
|
|
import { ManagedCodexHomeTemporarilyUnavailableError } from './codex-accounts/host-codex-managed-home-ownership'
|
|
import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home'
|
|
import type { CodexSessionResumePreparation } from './codex/codex-session-resume-home'
|
|
import { prepareCodexSessionResume } from './codex/codex-session-resume-preparation'
|
|
import { getOrcaManagedCodexHomePath, 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 { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from './worktree-trash'
|
|
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, type AgentHookProviderSessionIdentity } from './agent-hooks/server'
|
|
import { createHookProviderSessionInvalidator } from './agent-hooks/hook-provider-session-invalidation'
|
|
import { createHookStatusSessionTabsInvalidator } from './agent-hooks/hook-status-session-tabs-invalidation'
|
|
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 {
|
|
configureWindowsHostGitEnvironmentReadiness,
|
|
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 { AgentBrowserBridge } from './browser/agent-browser-bridge'
|
|
import { configureBrowserClientPageAutomationRuntime } from './browser/browser-client-page-automation-runtime'
|
|
import { BrowserClientPageCommandError } from './browser/browser-client-page-command-failure'
|
|
import { EmulatorBridge } from './emulator/emulator-bridge'
|
|
import { browserCertificateTrustController, browserManager } from './browser/browser-manager'
|
|
import { RpcDispatcher } from './runtime/rpc/dispatcher'
|
|
import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend'
|
|
import { browserSessionRegistry } from './browser/browser-session-registry'
|
|
import {
|
|
applyBrowserSessionProxies,
|
|
setBrowserNetworkProxySettingsResolver
|
|
} from './browser/browser-session-proxy'
|
|
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
|
|
import {
|
|
installDocPreviewProtocolHandler,
|
|
registerDocPreviewSchemePrivileges
|
|
} from './browser/doc-preview-protocol'
|
|
import { registerDocPreviewGrantHandlers } from './ipc/doc-preview-grant-ipc'
|
|
import { initializeBrowserClientHostId } from './browser/browser-client-host-id'
|
|
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 { createRuntimeAutomationRunTerminalObserver } from './automations/runtime-terminal-run-observer'
|
|
import { AgentAwakeService } from './agent-awake-service'
|
|
import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode'
|
|
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
|
|
import { settleTeardownWithinDeadline, settleWithinMs } from './quit-teardown-deadline'
|
|
import { stopStructuredAgentSessionRuntime } from './runtime/structured-agent-session-runtime'
|
|
import { quitTeardownStartGate } from './quit-teardown-start-gate'
|
|
import { beginSshShutdown } from './ipc/ssh-shutdown-drain'
|
|
import { PluginService } from './plugins/plugin-service'
|
|
import { PluginKillListService } from './plugins/plugin-kill-list-service'
|
|
import { getPluginsDataDir } from './plugins/plugin-discovery'
|
|
import { PluginMarketplaceService } from './plugins/plugin-marketplace-service'
|
|
import { PluginMarketplaceInstaller } from './plugins/plugin-marketplace-installer'
|
|
import { PluginBundledBootstrapCoordinator } from './plugins/plugin-bundled-bootstrap-coordinator'
|
|
import { resolveBundledPluginRoot } from './plugins/plugin-bundled-bootstrap'
|
|
import { resolvePluginHostEntryPath } from './plugins/plugin-host-process'
|
|
import { applyPluginConsent, applyPluginEnablement } from './plugins/plugin-enablement'
|
|
import { setPluginServiceForRpc } from './runtime/rpc/methods/plugins'
|
|
import {
|
|
normalizePluginConsents,
|
|
normalizePluginIdList
|
|
} from '../shared/plugins/plugin-consent-state'
|
|
import {
|
|
recordCoalescedCrashBreadcrumb,
|
|
recordCrashBreadcrumb
|
|
} from './crash-reporting/crash-breadcrumb-store'
|
|
import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb'
|
|
import { installMainThreadHangWatchdog } from './hang-watchdog/main-thread-hang-watchdog'
|
|
import {
|
|
consumeHangDetectionMarker,
|
|
hangDetectionMarkerPath
|
|
} from './hang-watchdog/hang-detection-marker'
|
|
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 { startCrashpadCapture } from './crash-reporting/crashpad-capture'
|
|
import { startPreGoneProcessMetricsSampling } from './crash-reporting/process-gone-diagnostics'
|
|
import { resolveExpectedTeardownScope } from './crash-reporting/expected-teardown-state'
|
|
import {
|
|
advanceSyntheticTitleSpinnerEntries,
|
|
getSyntheticTitleSpinnerPaneKeyToStop,
|
|
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 { isAskUserQuestionTool } from '../shared/agent-question-answered-intent'
|
|
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,
|
|
setDefaultProxySessionResolver
|
|
} from './network/proxy-settings'
|
|
import { handleElectronProxyLogin } from './network/electron-proxy-credentials'
|
|
import { installElectronProxyRequestGuard } from './network/electron-proxy-request-guard'
|
|
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 codexSessionMigration: ReturnType<typeof createCodexSessionMigrationScheduler> | 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 pluginService: PluginService | null = null
|
|
let pluginKillListService: PluginKillListService | null = null
|
|
let pluginMarketplaceService: PluginMarketplaceService | null = null
|
|
let pluginMarketplaceInstaller: PluginMarketplaceInstaller | null = null
|
|
let keybindings: KeybindingService | null = null
|
|
|
|
function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void {
|
|
pluginService?.emitEvent(
|
|
event.kind === 'created' ? 'worktree.created' : 'worktree.removed',
|
|
event.kind === 'created'
|
|
? { worktreeId: event.worktreeId, path: event.path, branch: event.branch }
|
|
: { worktreeId: event.worktreeId, path: event.path }
|
|
)
|
|
}
|
|
// 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()
|
|
const skillShareDeepLinks = new SkillShareDeepLinkState()
|
|
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 activeGpuFallbackMarker: GpuFallbackMarker | null = null
|
|
let gpuFallbackActiveThisLaunch = false
|
|
let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null
|
|
const gpuCrashDiagnostics =
|
|
process.platform === 'win32'
|
|
? new GpuCrashDiagnosticsRecorder({
|
|
provider: {
|
|
getGPUInfo: (infoType) => app.getGPUInfo(infoType),
|
|
getGPUFeatureStatus: () => app.getGPUFeatureStatus()
|
|
},
|
|
recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data)
|
|
})
|
|
: null
|
|
let localPtyStartupReady: Promise<void> = Promise.resolve()
|
|
let localPtyProviderStartupReady: Promise<void> = Promise.resolve()
|
|
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
|
|
|
|
function handleCodexHomePtySpawned(args: {
|
|
id: string
|
|
codexHomePath: string | null
|
|
reattached?: boolean
|
|
reattachedHomeRoute?: CodexPaneHomeRoute | null
|
|
launchEnv?: NodeJS.ProcessEnv
|
|
startedAt?: Date
|
|
startedSequence?: number
|
|
}): void {
|
|
// Why: only shared or ambiguous retained shells can create rollout logs that still need publication.
|
|
if (args.reattached && args.startedSequence !== undefined) {
|
|
const paneAccount = getCodexPaneAccount(args.id)
|
|
const homeRoute =
|
|
args.reattachedHomeRoute !== undefined
|
|
? (args.reattachedHomeRoute ?? undefined)
|
|
: paneAccount?.homeRoute
|
|
if (codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) {
|
|
codexSessionMigration.ignoreLaunch(args.id, args.startedSequence)
|
|
return
|
|
}
|
|
}
|
|
const fullScanRequired =
|
|
codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, {
|
|
reattached: args.reattached,
|
|
launchEnv: args.launchEnv
|
|
}) ?? null
|
|
if (fullScanRequired !== null) {
|
|
codexSessionMigration?.beginLaunch(
|
|
args.id,
|
|
args.reattached === true || fullScanRequired,
|
|
args.startedAt,
|
|
args.startedSequence
|
|
)
|
|
}
|
|
}
|
|
|
|
function handlePtyExit(id: string, exitSequence: number): void {
|
|
codexSessionMigration?.finishLaunch(id, exitSequence)
|
|
}
|
|
// 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.
|
|
// Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim.
|
|
// It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its
|
|
// command-name lookup finds a port number and strands the launch in an in-process serve. The
|
|
// packaged-CLI one matches on the entry path instead, so order cannot affect it either way.
|
|
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)
|
|
}
|
|
// Why: extracted AppRun / binary launches can land CLI-form `serve` args on the
|
|
// Electron process without the CLI rewrite that injects `--serve` (#12677).
|
|
// Guarded so a normal GUI launch keeps its original argv array identity.
|
|
if (argvRequestsServeMode(process.argv)) {
|
|
process.argv = normalizeServeModeArgv(process.argv)
|
|
}
|
|
const isServeMode = process.argv.includes('--serve')
|
|
|
|
function updateGpuAccelerationAboutPanel(): void {
|
|
app.setAboutPanelOptions(
|
|
createGpuAccelerationAboutPanelOptions({
|
|
appName: app.name,
|
|
appVersion: app.getVersion(),
|
|
platform: process.platform,
|
|
gpuFallbackActive: gpuFallbackActiveThisLaunch,
|
|
gpuFeatureStatus
|
|
})
|
|
)
|
|
}
|
|
|
|
app.on('gpu-info-update', () => {
|
|
gpuFeatureStatus = app.getGPUFeatureStatus()
|
|
gpuCrashDiagnostics?.warm()
|
|
if (app.isReady()) {
|
|
updateGpuAccelerationAboutPanel()
|
|
}
|
|
})
|
|
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}`)
|
|
})
|
|
|
|
// 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)
|
|
return
|
|
}
|
|
// Why: on failure the seeded fallbacks stay in front. For an nvm user that is
|
|
// now their `default` version rather than the newest install, so it is usually
|
|
// survivable — but it is still not what their shell would have resolved. Name
|
|
// the reason so it shows up in a log bundle instead of as a missing CLI.
|
|
console.warn(
|
|
`[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH`
|
|
)
|
|
})
|
|
}
|
|
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).
|
|
// Why the diff-cache counters ride along: a stamp the filesystem reports unstably makes the cache
|
|
// look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart.
|
|
startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) })
|
|
|
|
function focusExistingWindow(): void {
|
|
focusExistingMainWindow({
|
|
app,
|
|
getWindow: () => mainWindow,
|
|
openWindow: openMainWindow,
|
|
warn: console.warn
|
|
})
|
|
}
|
|
|
|
function requestDesktopActivation(argv: readonly string[] = []): void {
|
|
skillShareDeepLinks.capture(argv, (shareId) => {
|
|
mainWindow?.webContents.send('ui:openSkillShare', shareId)
|
|
})
|
|
// Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935).
|
|
if (!shouldActivateDesktopForSecondInstance(argv)) {
|
|
return
|
|
}
|
|
desktopActivationGate.requestActivation()
|
|
}
|
|
|
|
app.on('open-url', (event, url) => {
|
|
if (!parseSkillShareId(url)) {
|
|
return
|
|
}
|
|
event.preventDefault()
|
|
requestDesktopActivation([url])
|
|
})
|
|
|
|
skillShareDeepLinks.capture(process.argv)
|
|
|
|
const handleMacAppActivation = createMacAppActivationHandler({
|
|
getWindow: () => mainWindow,
|
|
requestActivation: requestDesktopActivation
|
|
})
|
|
|
|
function getDesktopWindowStatus(): RuntimeDesktopWindowStatus {
|
|
const state = desktopActivationGate.getState()
|
|
return state === 'ready' ? 'openable' : state
|
|
}
|
|
|
|
function settleServeDesktopActivation(): void {
|
|
settleServeDesktopActivationGate(desktopActivationGate, {
|
|
hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider)
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
includeSystemSessionEnd = true
|
|
): ExpectedTeardownScope {
|
|
return resolveExpectedTeardownScope({
|
|
isQuitting,
|
|
isQuittingForUpdate: isQuittingForUpdate(),
|
|
isExpectedRendererReload:
|
|
webContentsId !== undefined && expectedRendererReload.matches(webContentsId),
|
|
includeSystemSessionEnd
|
|
})
|
|
}
|
|
|
|
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()
|
|
// Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935).
|
|
app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)
|
|
}
|
|
|
|
// Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData.
|
|
if (hasSingleInstanceLock) {
|
|
// Why first: both accessors throw until installed, and everything below this line
|
|
// may resolve a path or read a credential. Neither constructor touches `app` or
|
|
// `safeStorage` — they resolve lazily per call — so installing here changes no
|
|
// timing, in particular not the pre-ready Keychain service-name resolution and
|
|
// the app.setName ordering the userData captures below depend on.
|
|
setAppEnvironment(new ElectronAppEnvironment())
|
|
setSecretStore(new ElectronSecretStore())
|
|
// Why at process level, not per-window: pty.ts registers against injected surfaces so
|
|
// it can load without electron, and an Electron main process always has ipcMain —
|
|
// whether a window exists is irrelevant. Installing this in attachMainWindowServices
|
|
// meant `orca serve` registered its PTY handlers against no-ops before any window
|
|
// attached, so a paired desktop owner never received them.
|
|
setPtyHostBindings({ ipc: ipcMain, power: powerMonitor })
|
|
// Why also at process level: the runtime's notification, window-lookup and
|
|
// tab-create-reply channel are desktop-only. A Node host installs none and the
|
|
// runtime routes notifications to paired clients instead.
|
|
setRuntimeDesktopSurface(electronRuntimeDesktopSurface)
|
|
// Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser
|
|
// cluster into the graph. The desktop installs it; a Node host installs none and every
|
|
// browser RPC rejects, which capability filtering already tells clients about.
|
|
setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory)
|
|
// Why here: proxy-settings only needed electron for `session.defaultSession`. The
|
|
// desktop supplies it; a Node host has no Chromium proxy config to consult, so the
|
|
// environment variables are the whole answer there.
|
|
setDefaultProxySessionResolver(() => session.defaultSession)
|
|
// Why here: integrations use Chromium's network stack on the desktop. A Node host
|
|
// falls back to the platform default, which is a real behavioural difference (proxy
|
|
// read from the environment, Node's user agent) rather than a transparent swap.
|
|
setMainHttpClient(electronHttpClient)
|
|
// Why here: constructing the speech services is what pulls Electron's streaming net
|
|
// request in. A host without them rejects speech calls rather than pretending.
|
|
setSpeechServiceFactories(electronSpeechServiceFactories)
|
|
setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval)
|
|
// 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 here: initDataPath above gives the canonical userData path for the record file; the write
|
|
// itself lands for the next launch (see macos-press-and-hold-default.ts).
|
|
applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())
|
|
// 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()
|
|
// Why: Electron resolves the macOS safeStorage Keychain service name
|
|
// ("<app name> Safe Storage") before `ready`, so the setName in whenReady is
|
|
// too late to move it — dev otherwise lands on the package.json name. Dev-only
|
|
// so a packaged build keeps deriving the key from its own CFBundleName.
|
|
// Safe here: dev always pins userData via app.setPath (configure-process.ts),
|
|
// so setName cannot shift the paths captured just above.
|
|
if (shouldApplyPreReadyAppName(devInstanceIdentity)) {
|
|
app.setName(devInstanceIdentity.appName)
|
|
}
|
|
// Why: Electron freezes the privileged scheme table at ready, so the doc-preview
|
|
// scheme must be declared here or its webview loses fetch/secure-origin privileges.
|
|
registerDocPreviewSchemePrivileges()
|
|
// Why: must precede app.whenReady() so Crashpad is installed before the
|
|
// first renderer spawns; a CHECK before this point is still exit-code-only.
|
|
startCrashpadCapture()
|
|
crashReports = CrashReportStore.fromUserData()
|
|
recordCrashBreadcrumb('app_started', {
|
|
packaged: app.isPackaged,
|
|
platform: process.platform,
|
|
...getMainProcessLifecycleIdentity()
|
|
})
|
|
disableUnsupportedChromiumFeatures()
|
|
// Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below.
|
|
optOutOfHiddenPageWakeUpThrottling()
|
|
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 () => {
|
|
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
|
|
})
|
|
|
|
ipcMain.handle('app:prepareTerminalStartupRestoration', async () => {
|
|
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
|
|
await runtime?.prepareStructuredAgentSessionStartupRestoration()
|
|
})
|
|
|
|
ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () =>
|
|
recoverLegacyWorkerTerminalsForRendererStartup({
|
|
firstWindowStartupServicesReady,
|
|
managedWslCliStartupBarrierReady,
|
|
localPtyProviderStartupReady,
|
|
reconcile: async () => {
|
|
await runtime?.refreshRestoredOrchestrationAuthority()
|
|
return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })
|
|
},
|
|
onDeferredRecoveryError: (error) => {
|
|
console.warn('[orchestration] legacy worker provider-ready recovery failed', error)
|
|
}
|
|
})
|
|
)
|
|
|
|
// 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('ui:consumePendingSkillShare', () => {
|
|
return skillShareDeepLinks.consume()
|
|
})
|
|
|
|
ipcMain.handle(
|
|
'app:startupDiagnostic',
|
|
(_event, event: string, details?: Record<string, unknown>) => {
|
|
if (!startupDiagnosticsEnabled || !event.startsWith('renderer-')) {
|
|
return
|
|
}
|
|
logStartupMilestone(event, details && typeof details === 'object' ? details : {})
|
|
}
|
|
)
|
|
|
|
/** A PTY that dies while Orca is down never runs the teardown that clears pane
|
|
* state, so hydrate can rebuild a Claude subagent roster that no later hook can
|
|
* retire — pinning the pane 'working' and locking its agent out of hibernation
|
|
* for good. Once provider and hook hydration settle, targeted PTY liveness can
|
|
* retire only rows whose local owner is proven gone. */
|
|
async function reapRestoredSubagentsWithoutLiveAgent(): Promise<void> {
|
|
const currentStore = store
|
|
if (!currentStore) {
|
|
return
|
|
}
|
|
const provider = getDaemonProvider()
|
|
if (!provider) {
|
|
return
|
|
}
|
|
const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds(
|
|
currentStore.getWorkspaceSession().terminalLayoutsByTabId ?? {}
|
|
)
|
|
await sweepRestoredSubagentsWithoutLiveAgent({
|
|
probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId),
|
|
isLocalExecutionHost: (worktreeId) =>
|
|
isLocalExecutionHost(
|
|
resolveAgentWorkspaceExecutionHostId(worktreeId, {
|
|
getRepo: (repoId) => currentStore.getRepo(repoId),
|
|
getWorktreeMeta: (resolvedWorktreeId) => currentStore.getWorktreeMeta(resolvedWorktreeId),
|
|
getFolderWorkspace: (folderWorkspaceId) =>
|
|
currentStore.getFolderWorkspace(folderWorkspaceId),
|
|
getProjectGroups: () => currentStore.getProjectGroups()
|
|
})
|
|
),
|
|
getBoundPtyIdForPaneKey: getPtyIdForPaneKey,
|
|
getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey),
|
|
reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) =>
|
|
agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent(
|
|
isLocalHost,
|
|
isLocalPaneAgentLive,
|
|
isLocalPaneLivenessEvidenceCurrent
|
|
)
|
|
})
|
|
}
|
|
|
|
function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices {
|
|
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
|
|
})
|
|
// Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes.
|
|
const hasRetainedManagedHostPane = hasRecordedManagedHostCodexPane()
|
|
if (codexRuntimeHome && (hasRetainedManagedHostPane || hasAnyRecordedLegacyWslCodexPane())) {
|
|
const livePtyIds = await listLiveDaemonPtyIds()
|
|
if (livePtyIds) {
|
|
reconcileCodexPaneAccountsWithLivePtys(livePtyIds)
|
|
const settings = store?.getSettings()
|
|
// Why (#16441): each retained home can run a codex app-server grant
|
|
// session. Awaiting them here delayed the first window by N sessions;
|
|
// a retained shell cannot invoke Codex before this provider serves.
|
|
if (hasRetainedManagedHostPane) {
|
|
void reconcileRetainedCodexHookHomes({
|
|
hookService: codexHookService,
|
|
hooksEnabled:
|
|
isAgentStatusHooksEnabled(settings) &&
|
|
settings?.disabledTuiAgents.includes('codex') !== true,
|
|
runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds)
|
|
}).catch((error: unknown) => {
|
|
console.warn('[codex-hook-service] retained Codex home reconcile failed:', error)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
// Why: retained shells can invoke Codex immediately after the startup gate.
|
|
codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()
|
|
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' })
|
|
// Why (#11217): the hook listener fails open on every request error, so an IDS resetting
|
|
// loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness.
|
|
// Log + telemetry (the daemon_start_failed pattern) so it is diagnosable without a packet capture.
|
|
agentHookServer.setTransportInterferenceListener((report) => {
|
|
track('agent_hook_transport_blocked', { count: report.count })
|
|
})
|
|
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)
|
|
}
|
|
})
|
|
void startupServices.firstWindowReady.then(() => {
|
|
logStartupMilestone('first-window-startup-services-ready')
|
|
})
|
|
void startupServices.localPtyReady.then(() => {
|
|
logStartupMilestone('local-pty-startup-ready')
|
|
void reapRestoredSubagentsWithoutLiveAgent().catch((error) => {
|
|
console.warn('[agent-hooks] restored-subagent liveness probe failed:', error)
|
|
})
|
|
})
|
|
return startupServices
|
|
}
|
|
|
|
function bindTerminalRuntimeStartupServices(
|
|
services: Promise<WindowsDesktopStartupServices>
|
|
): void {
|
|
firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady)
|
|
localPtyStartupReady = services.then((value) => value.localPtyReady)
|
|
localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady)
|
|
}
|
|
|
|
async function prepareCodexRuntimeHomeForLaunch(
|
|
target?: CodexAccountSelectionTarget,
|
|
launchEnv?: NodeJS.ProcessEnv,
|
|
launchContext?: CodexHomeLaunchContext
|
|
): Promise<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 before every recognized Codex spawn.
|
|
await markCodexProjectTrusted(launchContext.workspacePath)
|
|
} catch (error) {
|
|
console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error)
|
|
}
|
|
}
|
|
const ensureRealHomeHooksIfSelected = async (): Promise<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.
|
|
await ensureRealHomeCodexHookState({
|
|
hooksEnabled: isAgentStatusHooksEnabled(store?.getSettings()),
|
|
userDataPath: app.getPath('userData')
|
|
})
|
|
return true
|
|
}
|
|
let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected()
|
|
// Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught —
|
|
// the fallbacks below all key off `null`, which means "system default", so
|
|
// swallowing the refusal would launch the wrong account (#STA-4422).
|
|
let runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, {
|
|
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
|
|
})
|
|
if (runtimeHomePath === null && !realHomeHooksPrepared) {
|
|
// Why: launch prep can reject an untrusted managed home and clear its
|
|
// selection. Establish hook capability for that newly selected lane, then
|
|
// re-resolve if the capability gate rejects it.
|
|
realHomeHooksPrepared = await ensureRealHomeHooksIfSelected()
|
|
if (realHomeHooksPrepared) {
|
|
runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, {
|
|
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
|
|
})
|
|
}
|
|
}
|
|
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 = await codexHookService.prepareRuntimeHomeForLaunch(
|
|
runtimeHomePath,
|
|
hookTarget,
|
|
hooksEnabled
|
|
)
|
|
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<CodexSessionResumePreparation | 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 settingsStore = store
|
|
// Why: resolved eagerly, once, before any ranking or provenance match. The
|
|
// marker read used to be deferred into the ranking thunk so a
|
|
// provenance-present resume never paid for it, but that optimisation let an
|
|
// unreadable selected home reach the PTY as "no selection": the provenance
|
|
// branch simply omits the account from `trustedHomes` and another account's
|
|
// readable alias wins. A throw here refuses the whole resume instead
|
|
// (#STA-4422).
|
|
const selectedAccountCodexHome =
|
|
codexRuntimeHome.resolveSelectedHostAccountCodexHomePathForResume()
|
|
// Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is
|
|
// no verified origin home to prepare, so the PTY layer drops the resume argv (#10793).
|
|
const preparation = await prepareCodexSessionResume({
|
|
sessionId: args.providerSession.id,
|
|
transcriptPath: args.providerSession.transcriptPath,
|
|
trustedCodexHomes: trustedHomes,
|
|
// Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account;
|
|
// rank it by the current selection so settings insertion order can never decide the account.
|
|
getSelectedAccountCodexHome: () => selectedAccountCodexHome,
|
|
systemCodexHomePath: systemHomePath,
|
|
// Why: the mirror winning is what triggers the migration into ~/.codex below, so it must
|
|
// outrank the path-sorted account homes or a system-default selection resumes as an account.
|
|
sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(),
|
|
resolveVerifiedResumeHome: async (sessionSource) => {
|
|
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: this launch path pins CODEX_HOME to the account that OWNS the
|
|
// rollout and deliberately refuses to repin onto whichever account is
|
|
// selected now (#10793), so it does not wire
|
|
// getSelectedHostAccountCodexHomePath and this branch cannot fire today.
|
|
// It stays as a contract guard: the blanket catch below must never
|
|
// silently swallow a typed refusal if that ever changes.
|
|
if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) {
|
|
throw 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 {
|
|
await 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(settingsStore.getSettings())
|
|
try {
|
|
if (isSystemHome) {
|
|
await ensureRealHomeCodexHookState({
|
|
hooksEnabled,
|
|
userDataPath: app.getPath('userData')
|
|
})
|
|
} else if (hooksEnabled) {
|
|
await codexHookService.install(resumeHome)
|
|
} else {
|
|
await 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 resumeHome
|
|
}
|
|
})
|
|
return preparation.outcome === 'resume'
|
|
? {
|
|
...preparation,
|
|
reconcileSharedRuntimeAuth:
|
|
normalizeRuntimePathForComparison(preparation.codexHomePath) ===
|
|
normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath())
|
|
}
|
|
: preparation
|
|
}
|
|
|
|
// 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()) {
|
|
safelyRevealWindow(mainWindow)
|
|
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(options: { revealOnDidFinishLoad?: boolean } = {}): 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)
|
|
}
|
|
}
|
|
})
|
|
// Why here: read-only, and the install DACL is the one thing a 0x80000003
|
|
// child death cannot tell us about itself. See electron/electron#51761.
|
|
probeWindowsInstallDirAcl({ isServeMode })
|
|
}
|
|
|
|
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, false)
|
|
}),
|
|
onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => {
|
|
recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', {
|
|
reason: details.reason,
|
|
exitCode: details.exitCode ?? null,
|
|
recentRecoveryCount
|
|
})
|
|
void presentRendererRecoveryPrompt(recentRecoveryCount)
|
|
},
|
|
deferLoad: true,
|
|
...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: 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)
|
|
})
|
|
window.once('show', () => {
|
|
logStartupMilestone('window-shown')
|
|
void presentGpuFallbackRecoveredLaunchPrompt(window)
|
|
})
|
|
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) =>
|
|
prepareCodexAiVaultSessionResume(args, {
|
|
runtimeHome: codexRuntimeHome,
|
|
systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings())
|
|
}),
|
|
onBeforeRelaunch: async () => {
|
|
isQuitting = true
|
|
desktopRelayService?.fenceAndCloseNow()
|
|
await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })
|
|
},
|
|
onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated(),
|
|
onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow()
|
|
},
|
|
pluginService ?? undefined,
|
|
pluginMarketplaceService && pluginMarketplaceInstaller
|
|
? { marketplace: pluginMarketplaceService, installer: pluginMarketplaceInstaller }
|
|
: undefined
|
|
)
|
|
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,
|
|
onCodexHomePtySpawned: handleCodexHomePtySpawned,
|
|
onPtyExit: handlePtyExit,
|
|
onBeforeUpdateQuit: () =>
|
|
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }),
|
|
updateInstallMode: resolveUpdateInstallMode(isServeMode),
|
|
onWorktreeLifecycle: emitPluginWorktreeLifecycle
|
|
}
|
|
)
|
|
// Why: attach the durable renderer pull now, but launch the diagnostic process after first paint.
|
|
initTccPromptNotice(window, { deferWatchUntilReadyToShow: true })
|
|
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,
|
|
restoredUnconfirmed,
|
|
observation,
|
|
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 } : {}),
|
|
...(observation ? { observation } : {}),
|
|
providerSessionOnly: true
|
|
})
|
|
return
|
|
}
|
|
if (!restoredUnconfirmed) {
|
|
maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay })
|
|
}
|
|
const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey)
|
|
const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey)
|
|
const suppressSyntheticCodexAutoApprovalTitle =
|
|
payload.agentType === 'codex' &&
|
|
(payload.state === 'waiting' || payload.state === 'blocked')
|
|
? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({
|
|
agentType: payload.agentType,
|
|
state: payload.state,
|
|
launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken })
|
|
})
|
|
: false
|
|
const statusEvent = {
|
|
...payload,
|
|
paneKey,
|
|
...(launchToken ? { launchToken } : {}),
|
|
...(terminalHandle ? { terminalHandle } : {}),
|
|
tabId,
|
|
worktreeId,
|
|
connectionId,
|
|
receivedAt,
|
|
stateStartedAt,
|
|
...(providerSession ? { providerSession } : {}),
|
|
...(promptInteractionKey ? { promptInteractionKey } : {}),
|
|
...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
|
|
...(observation ? { observation } : {}),
|
|
...(orchestration ? { orchestration } : {})
|
|
}
|
|
mainWindow?.webContents.send('agentStatus:set', statusEvent)
|
|
if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) {
|
|
getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent)
|
|
}
|
|
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)
|
|
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)
|
|
getDashboardPopoutWindow()?.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' }
|
|
}
|
|
|
|
// Writes both crash-time and post-recovery consent states through one build-scoped path.
|
|
function persistGpuFallbackMarker(
|
|
userDataPath: string,
|
|
info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean }
|
|
): boolean {
|
|
const environment = getWindowsGpuFallbackEnvironment()
|
|
if (!environment) {
|
|
return false
|
|
}
|
|
try {
|
|
writeGpuFallbackMarker(userDataPath, info, environment)
|
|
return true
|
|
} catch (error) {
|
|
console.warn('[gpu-fallback] failed to persist marker:', error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Read 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
|
|
}
|
|
activeGpuFallbackMarker = marker
|
|
app.disableHardwareAcceleration()
|
|
const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform)
|
|
gpuFallbackActiveThisLaunch = true
|
|
// Why: with no GPU child left, child-process-gone can't report a GPU fault, so
|
|
// name the applied switches in the trail any later crash report carries.
|
|
recordCrashBreadcrumb('gpu_fallback_applied', {
|
|
crashesInWindow: marker.crashesInWindow,
|
|
switches: appliedSwitches.join(',')
|
|
})
|
|
}
|
|
|
|
async function presentGpuFallbackRecoveredLaunchPrompt(window: BrowserWindow): Promise<void> {
|
|
const marker = activeGpuFallbackMarker
|
|
if (!marker || marker.userConfirmed || window.isDestroyed() || isQuitting) {
|
|
return
|
|
}
|
|
// One prompt per process. A failure leaves the on-disk marker unconfirmed so the next launch retries.
|
|
activeGpuFallbackMarker = null
|
|
const userDataPath = app.getPath('userData')
|
|
await handleGpuFallbackRecoveredLaunch({
|
|
isQuitting: () => isQuitting,
|
|
prompt: () => promptForGpuFallbackRecoveredLaunch(window),
|
|
confirmSafeGraphics: () => {
|
|
persistGpuFallbackMarker(userDataPath, {
|
|
engagedAt: marker.engagedAt,
|
|
crashesInWindow: marker.crashesInWindow,
|
|
userConfirmed: true
|
|
})
|
|
},
|
|
clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath),
|
|
onPromptFailed: (error) =>
|
|
console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error),
|
|
onSafeGraphicsKept: () =>
|
|
recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', {
|
|
crashesInWindow: marker.crashesInWindow
|
|
}),
|
|
restartWithHardware: () => {
|
|
isQuitting = true
|
|
relaunchApp('gpu-fallback', {
|
|
mode: 'hardware-retry',
|
|
crashesInWindow: marker.crashesInWindow
|
|
})
|
|
destroySystemTray()
|
|
app.exit(0)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
crashedAt: number
|
|
): Promise<void> {
|
|
// Software rendering already active or shutting down: nothing more to do.
|
|
if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) {
|
|
return
|
|
}
|
|
const result = gpuCrashFallbackTracker.recordGpuCrash(crashedAt)
|
|
if (!result.shouldEngageFallback) {
|
|
return
|
|
}
|
|
const fallbackData = {
|
|
processReason: reason,
|
|
exitCode,
|
|
crashesInWindow: result.crashesInWindow
|
|
}
|
|
const userDataPath = app.getPath('userData')
|
|
await engageGpuFallbackAfterCrashBurst(
|
|
{ reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() },
|
|
{
|
|
isQuitting: () => isQuitting,
|
|
onEngaged: (engagement) =>
|
|
recordCrashBreadcrumb('gpu_fallback_engaged', {
|
|
reason: engagement.reason,
|
|
exitCode: engagement.exitCode,
|
|
crashesInWindow: engagement.crashesInWindow
|
|
}),
|
|
persistMarker: (engagement) =>
|
|
persistGpuFallbackMarker(userDataPath, {
|
|
engagedAt: engagement.engagedAt,
|
|
crashesInWindow: engagement.crashesInWindow,
|
|
userConfirmed: false
|
|
}),
|
|
confirmMarker: (engagement) => {
|
|
persistGpuFallbackMarker(userDataPath, {
|
|
engagedAt: engagement.engagedAt,
|
|
crashesInWindow: engagement.crashesInWindow,
|
|
userConfirmed: true
|
|
})
|
|
},
|
|
clearMarker: () => clearGpuFallbackMarker(userDataPath),
|
|
promptForRestart: () =>
|
|
promptForGpuFallbackRestart(
|
|
mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
|
|
),
|
|
onPromptFailed: (error) =>
|
|
console.warn('[gpu-fallback] failed to show restart prompt:', error),
|
|
onRestartDeferred: () =>
|
|
recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData),
|
|
restartIntoSafeGraphics: () => {
|
|
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,
|
|
...(webContentsId !== undefined ? { webContentsId } : {})
|
|
})
|
|
}
|
|
|
|
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.
|
|
stopFolderRepoGitUpgradeWatch()
|
|
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> {
|
|
// Why dynamic: qrcode is only reachable from mobile pairing, so launch should
|
|
// not parse it for the majority who never pair a device.
|
|
const QRCode = await import('qrcode')
|
|
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())
|
|
}
|
|
|
|
// Why: on PTY teardown drop the spinner entry explicitly, else the shared timer keeps ticking with sendSyntheticTitle no-oping forever.
|
|
registerPaneKeyTeardownListener((paneKey) => {
|
|
stopSyntheticTitleSpinner(paneKey)
|
|
})
|
|
|
|
// Why: the spinner is a stand-in for a live hook status, so it must retire with the row it
|
|
// stands in for — otherwise a pane whose status was cleared or dismissed keeps rotating a
|
|
// working title long after the agent finished (#13890). Both paths are covered: the
|
|
// pane-scoped clear fan-out, and user dismissal, which never routes through it.
|
|
agentHookServer.subscribePaneStatusClear((clear) => {
|
|
const paneKey = getSyntheticTitleSpinnerPaneKeyToStop(clear)
|
|
if (paneKey) {
|
|
stopSyntheticTitleSpinner(paneKey)
|
|
}
|
|
})
|
|
agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner)
|
|
|
|
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'
|
|
)
|
|
}
|
|
|
|
void app.whenReady().then(async () => {
|
|
logStartupMilestone('app-ready')
|
|
// Why: a headless automated run must not claim a macOS Dock tile or the menu bar.
|
|
applyBackgroundActivationPolicy({ warn: console.warn })
|
|
installElectronProxyRequestGuard(session.defaultSession)
|
|
app.on('login', (event, webContents, details, authInfo, callback) => {
|
|
handleElectronProxyLogin(
|
|
event,
|
|
webContents,
|
|
details,
|
|
authInfo,
|
|
callback,
|
|
session.defaultSession
|
|
)
|
|
})
|
|
installMainThreadHangWatchdog({ userDataPath: getCanonicalUserDataPath() })
|
|
const hangDetection = consumeHangDetectionMarker(
|
|
hangDetectionMarkerPath(getCanonicalUserDataPath())
|
|
)
|
|
if (hangDetection) {
|
|
recordDurableCrashBreadcrumb('main_thread_hang_detected', {
|
|
unresponsiveMs: hangDetection.unresponsiveMs,
|
|
previousPid: hangDetection.parentPid,
|
|
selfRecovered: hangDetection.selfRecovered
|
|
})
|
|
}
|
|
// 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: names the app menu/About panel. Dev already applied this pre-ready (see the
|
|
// safeStorage note above); this call stays unconditional so packaged builds keep their
|
|
// existing post-ready rename, which lands after the Keychain name is already resolved.
|
|
app.setName(devInstanceIdentity.appName)
|
|
updateGpuAccelerationAboutPanel()
|
|
|
|
// 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()
|
|
// Why this early: the first window stamps the hosting id into its renderer's argv, so the durable
|
|
// read has to have happened by then or the renderer and the browser-host lease disagree.
|
|
initializeBrowserClientHostId(activeOrcaProfile.profileDirectory)
|
|
store = new Store({
|
|
dataFile: activeOrcaProfile.dataFile,
|
|
storageAuthority: isServeMode ? 'runtime' : 'desktop'
|
|
})
|
|
// Why: create pending readiness before the guard can observe the default session.
|
|
const initialProxyApplication = applyElectronProxySettings(store.getSettings())
|
|
installElectronProxyRequestGuard(session.defaultSession)
|
|
// Why armed here and not at install time: the report remembers what it last said, and
|
|
// that state lives beside the profile data file, which does not exist until now.
|
|
// Why scheduled and not called: the report probes the OS keyring, which blocks on Linux
|
|
// and must not gate the first window (STA-5765).
|
|
scheduleSecretProtectionGapReport({
|
|
dataFile: activeOrcaProfile.dataFile,
|
|
force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1',
|
|
deferUntilFirstWindow: !isServeMode
|
|
})
|
|
// Why here: the host key store is a sidecar of the same profile, and every SSH connect consults
|
|
// it. Left unbound it reports nothing trusted, which is safe but silently discards our own
|
|
// accept records on every launch.
|
|
initSshHostKeyStoreFile(activeOrcaProfile.dataFile)
|
|
// Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow.
|
|
neutralizeLegacyTerminalShimDir(app.getPath('userData'))
|
|
const windowsShellPathHydration = createWindowsShellPathHydration()
|
|
configureWindowsHostGitEnvironmentReadiness(
|
|
process.platform === 'win32' ? windowsShellPathHydration.whenReady : null
|
|
)
|
|
if (process.platform === 'win32') {
|
|
const settings = store.getSettings()
|
|
if (app.isPackaged) {
|
|
void windowsShellPathHydration.hydrate(
|
|
settings.terminalWindowsShell,
|
|
settings.terminalWindowsPowerShellImplementation
|
|
)
|
|
} else {
|
|
windowsShellPathHydration.configure(
|
|
settings.terminalWindowsShell,
|
|
settings.terminalWindowsPowerShellImplementation
|
|
)
|
|
}
|
|
}
|
|
wslHookRelayManager.setManagedHookSettingsResolver(() => store?.getSettings() ?? null)
|
|
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 (
|
|
('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) &&
|
|
process.platform === 'win32'
|
|
) {
|
|
if (app.isPackaged) {
|
|
void windowsShellPathHydration.hydrate(
|
|
settings.terminalWindowsShell,
|
|
settings.terminalWindowsPowerShellImplementation
|
|
)
|
|
} else {
|
|
windowsShellPathHydration.configure(
|
|
settings.terminalWindowsShell,
|
|
settings.terminalWindowsPowerShellImplementation
|
|
)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
if ('agentStatusHooksEnabled' in updates) {
|
|
// Why both directions: the ensure gate only blocks NEW relays, so off must stop the running
|
|
// guest process and timers, and on must restart them — otherwise open WSL panes report no
|
|
// status until their next spawn.
|
|
if (isAgentStatusHooksEnabled(settings)) {
|
|
wslHookRelayManager.resumeStoppedRelays()
|
|
} else {
|
|
wslHookRelayManager.disposeAll({ permanent: 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.
|
|
const proxyApplyResult = await initialProxyApplication
|
|
if (proxyApplyResult.source === 'invalid-settings') {
|
|
// Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable.
|
|
console.warn('[proxy] persisted proxy settings are invalid; using direct networking')
|
|
}
|
|
} catch {
|
|
console.warn('[proxy] Failed to apply network proxy settings')
|
|
}
|
|
// Why: the partition installer reads the proxy through this resolver, so register it before sessions materialize.
|
|
setBrowserNetworkProxySettingsResolver(() => store!.getSettings())
|
|
// Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches.
|
|
installDocPreviewProtocolHandler()
|
|
registerDocPreviewGrantHandlers()
|
|
// 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,
|
|
// Why: local direct-SSH partitions are scoped to targets, and the orphan
|
|
// sweep must see the live target list or it would clear their cookie jars.
|
|
listLocalSshTargetIds: () => {
|
|
if (!store) {
|
|
// Why: an empty list would read as "every SSH jar is an orphan"; throwing skips the sweep.
|
|
throw new Error('ssh target store unavailable at partition sweep')
|
|
}
|
|
return store.getSshTargets().map((target) => target.id)
|
|
}
|
|
})
|
|
try {
|
|
// Why: awaited here so the first guest navigation cannot race the installer's fire-and-forget write.
|
|
await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings())
|
|
} catch {
|
|
console.warn('[proxy] Failed to apply network proxy settings to browser sessions')
|
|
}
|
|
unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast()
|
|
agentAwakeService = new AgentAwakeService()
|
|
agentAwakeService.setMode(
|
|
normalizeComputerAwakeMode(
|
|
store.getSettings().computerAwakeMode,
|
|
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([])
|
|
const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator()
|
|
const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => {
|
|
const ownedIdentities = identities.map((identity) => ({
|
|
...identity,
|
|
worktreeId:
|
|
identity.worktreeId ??
|
|
runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ??
|
|
undefined
|
|
}))
|
|
for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) {
|
|
// Why not `notifyMobileSessionTabsChanged` alone: it re-emits at the unchanged
|
|
// `snapshotVersion`, which every client drops on its monotonic gate.
|
|
runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true })
|
|
}
|
|
}
|
|
const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => {
|
|
agentAwakeService?.setStatuses(statuses)
|
|
})
|
|
const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges(
|
|
(sessions) => {
|
|
// Healthy session.tabs streams need a push when transcript identity changes.
|
|
publishProviderSessionChanges(sessions)
|
|
}
|
|
)
|
|
// Why: hook rows are the only carrier of live agent state on a headless host, and
|
|
// nothing else republishes `session.tabs` when one changes — so a paired client
|
|
// would keep the pane's last projection until an unrelated PTY touch came along.
|
|
const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator()
|
|
const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => {
|
|
if (hookStatusChangedSessionTabs(enriched)) {
|
|
runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null)
|
|
}
|
|
})
|
|
// Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land
|
|
// here. Without it the live state published above becomes a zombie question card.
|
|
const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => {
|
|
const clearedPaneKeys =
|
|
'paneKey' in clear
|
|
? [clear.paneKey]
|
|
: hookStatusChangedSessionTabs.forgetConnection(clear.connectionId)
|
|
for (const paneKey of clearedPaneKeys) {
|
|
hookStatusChangedSessionTabs.forgetPane(paneKey)
|
|
runtime?.touchMobileSessionTabsForPane(paneKey)
|
|
}
|
|
})
|
|
unsubscribeAgentAwakeStatusChanges = () => {
|
|
unsubscribeStatusChanges()
|
|
unsubscribeProviderSessionChanges()
|
|
unsubscribeHookStatusSessionTabs()
|
|
unsubscribeHookStatusClear()
|
|
}
|
|
// 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 breadcrumb alone never leaves the machine — it rides crash reports, and a hang is not
|
|
// a crash (the app is force-quit, so no report is ever generated). Without this the incidence
|
|
// number the watchdog exists to produce would sit unread on the user's disk. Must run after
|
|
// initTelemetry: track() drops silently until the client and store are wired.
|
|
if (hangDetection) {
|
|
track('main_thread_hang_detected', {
|
|
unresponsive_ms: Math.round(hangDetection.unresponsiveMs),
|
|
self_recovered: hangDetection.selfRecovered
|
|
})
|
|
}
|
|
// 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
|
|
})
|
|
const skillTransactionRecovery = recoverPendingSkillTransactions(
|
|
join(app.getPath('userData'), 'skill-installs')
|
|
)
|
|
void skillTransactionRecovery
|
|
.then((report) => {
|
|
if (report.scanned || report.failures.length || report.truncated) {
|
|
console.info('[skills] startup transaction recovery:', {
|
|
scanned: report.scanned,
|
|
recovered: report.recovered,
|
|
failures: report.failures.map((failure) => failure.code),
|
|
truncated: report.truncated
|
|
})
|
|
}
|
|
})
|
|
.catch((error) => console.warn('[skills] startup transaction recovery failed:', error))
|
|
// 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()
|
|
// Agent-session stats come from hook status transitions, the same truth the
|
|
// sidebar and dashboard read — never from OSC terminal titles, which miss
|
|
// hook-only agents and count any spinner TUI as an agent (#10201).
|
|
const agentSessionRecorder = new AgentSessionTransitionRecorder(stats)
|
|
agentHookServer.subscribeEnrichedStatus((enriched) => {
|
|
agentSessionRecorder.onStatus(enriched)
|
|
})
|
|
agentHookServer.subscribePaneStatusClear((clear) => {
|
|
agentSessionRecorder.onCleared(clear)
|
|
})
|
|
claudeUsage = new ClaudeUsageStore(store)
|
|
codexUsage = new CodexUsageStore(store)
|
|
openCodeUsage = new OpenCodeUsageStore(store)
|
|
rateLimits = new RateLimitService()
|
|
codexRuntimeHome = new CodexRuntimeHomeService(store)
|
|
void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath())
|
|
// 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())
|
|
)
|
|
codexSessionMigration = createCodexSessionMigrationScheduler({
|
|
isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true,
|
|
isQuitting: () => isQuitting,
|
|
resolveSystemCodexHomePathOverride: () =>
|
|
resolveHostCodexSessionSourceHome(store!.getSettings()),
|
|
prepareScheduledRun: (scanDates) =>
|
|
codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(scanDates),
|
|
finishScheduledRun: () => codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(),
|
|
startBackfill: startCodexSessionBackfillInBackground,
|
|
startIndexHeal: startCodexSessionIndexHealInBackground
|
|
})
|
|
codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, {
|
|
onHostSystemDefaultSelected: codexSessionMigration.requestRun
|
|
})
|
|
// Why: migrate historical shared-home sessions after startup; compatibility
|
|
// launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480).
|
|
codexSessionMigration.scheduleInitialRun()
|
|
claudeRuntimeAuth = new ClaudeRuntimeAuthService(store)
|
|
claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth)
|
|
rateLimits.setCodexHomePathResolver((target) =>
|
|
codexRuntimeHome!.prepareForRateLimitFetch(target)
|
|
)
|
|
rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings()))
|
|
// Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the
|
|
// usage fetch must read the WSL-side credentials when that's the configured runtime (#12370).
|
|
rateLimits.setKimiHomeResolver(() => resolveKimiHome(getKimiRuntimeTarget(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,
|
|
resolveHome: () => {
|
|
const resolved = codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account)
|
|
return resolved.kind === 'ready'
|
|
? { kind: 'ready' as const, managedHomePath: resolved.homePath }
|
|
: { kind: 'skip' as const }
|
|
}
|
|
}))
|
|
})
|
|
const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = {
|
|
resolve: (selector) => {
|
|
const environment = resolveEnvironment(app.getPath('userData'), selector)
|
|
const pairing = getPreferredPairingOffer(environment)
|
|
return {
|
|
environmentId: environment.id,
|
|
name: environment.name,
|
|
peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64)
|
|
}
|
|
},
|
|
call: (selector, method, params, timeoutMs, envelope) =>
|
|
callRuntimeEnvironment(
|
|
app.getPath('userData'),
|
|
selector,
|
|
method,
|
|
params,
|
|
timeoutMs,
|
|
undefined,
|
|
envelope
|
|
)
|
|
}
|
|
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: the filter above hides resume-identity rows from the live-agent views, but
|
|
// those rows carry the provider session mobile native chat addresses transcripts
|
|
// by — Pi publishes identity that way and would otherwise be unreachable.
|
|
getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(),
|
|
getAgentProviderSessionRowsForPane: (paneKey) =>
|
|
agentHookServer.getStatusSnapshotForPane(paneKey),
|
|
attestAgentHookCompatibilityAuthority: (candidate) =>
|
|
agentHookServer.attestCompatibilityAuthority(candidate),
|
|
retireAgentHookCompatibilityAuthority: (paneKey) =>
|
|
agentHookServer.retirePaneAuthority(paneKey),
|
|
reconcileAgentStatusForEndedProcess: (paneKeys) => {
|
|
agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys)
|
|
},
|
|
canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null,
|
|
// Why: evaluated per call, not captured — the RPC server that owns the device registry is
|
|
// constructed with this runtime and does not exist yet at this point.
|
|
getPairedDeviceName: (pairedDeviceId) =>
|
|
runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null,
|
|
// 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) =>
|
|
prepareCodexAiVaultSessionResume(args, {
|
|
runtimeHome: codexRuntimeHome,
|
|
systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings())
|
|
}),
|
|
prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) =>
|
|
prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, {
|
|
launchAgent: 'codex',
|
|
workspacePath
|
|
}),
|
|
buildAgentHookPtyEnv: () =>
|
|
isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {},
|
|
orchestrationEnvironmentTransport,
|
|
skillTransactionRecovery
|
|
})
|
|
runtime = runtimeService
|
|
runtimeService.prepareLegacyWorkerTerminalRecovery()
|
|
// Why before anything can attach: a client host that reattaches to a restarted runtime is only
|
|
// handed its pages back if the runtime found them first.
|
|
runtimeService.rehydrateClientHostedBrowserPages()
|
|
publishProviderSessionChanges(agentHookServer.getProviderSessionIdentities())
|
|
browserManager.setBrowserGuestStateChangedListener((worktreeId) => {
|
|
runtimeService.notifyMobileSessionTabsChanged(worktreeId)
|
|
})
|
|
automations = new AutomationService(store, {
|
|
claudeUsage,
|
|
codexUsage,
|
|
terminalObserver: createRuntimeAutomationRunTerminalObserver(runtimeService),
|
|
onAutomationsChanged: (payload) => runtimeService.notifyAutomationsChanged(payload),
|
|
// 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.setArtifactService(
|
|
new ArtifactCloudService(app.getPath('userData'), () =>
|
|
isArtifactSharingEnabled(store?.getSettings())
|
|
)
|
|
)
|
|
runtimeService.setSkillCloudService(new SkillCloudService(app.getPath('userData')))
|
|
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)
|
|
})
|
|
const pluginSystemStartupStartedAt = performance.now()
|
|
pluginKillListService = new PluginKillListService({
|
|
pluginsDataDir: getPluginsDataDir(app.getPath('userData'))
|
|
})
|
|
await pluginKillListService.initialize()
|
|
pluginMarketplaceService = new PluginMarketplaceService({
|
|
pluginsDataDir: getPluginsDataDir(app.getPath('userData')),
|
|
getKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null
|
|
})
|
|
const requestOfficialMarketplaceSeed = (): void => {
|
|
if (store?.getSettings().pluginSystemEnabled !== true) {
|
|
return
|
|
}
|
|
void pluginMarketplaceService?.seedOfficialSource().catch((error) => {
|
|
console.warn('[plugins] failed to configure the official marketplace:', error)
|
|
})
|
|
}
|
|
pluginMarketplaceInstaller = new PluginMarketplaceInstaller({
|
|
marketplace: pluginMarketplaceService,
|
|
userDataPath: app.getPath('userData'),
|
|
hostVersion: app.getVersion(),
|
|
blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null
|
|
})
|
|
pluginService = new PluginService({
|
|
userDataPath: app.getPath('userData'),
|
|
hostVersion: app.getVersion(),
|
|
// Feature flag: with the setting off, discovery returns nothing and no
|
|
// plugin code path runs at all.
|
|
isPluginSystemEnabled: () => store?.getSettings().pluginSystemEnabled === true,
|
|
getDisabledPlugins: () => normalizePluginIdList(store?.getSettings().disabledPlugins),
|
|
getPluginConsents: () => normalizePluginConsents(store?.getSettings().pluginConsents),
|
|
getDevPluginPaths: () => normalizePluginIdList(store?.getSettings().devPluginPaths),
|
|
getKeybindings: () => keybindings?.getOverrides() ?? {},
|
|
getPluginKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null,
|
|
hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged)
|
|
})
|
|
const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({
|
|
root: resolveBundledPluginRoot({
|
|
isPackaged: app.isPackaged,
|
|
resourcesPath: process.resourcesPath,
|
|
appPath: app.getAppPath()
|
|
}),
|
|
userDataPath: app.getPath('userData'),
|
|
hostVersion: app.getVersion(),
|
|
isEnabled: () => store?.getSettings().pluginSystemEnabled === true,
|
|
blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null,
|
|
refreshPlugins: () => pluginService?.refresh() ?? Promise.resolve()
|
|
})
|
|
const requestBundledPluginBootstrap = (): void => {
|
|
void bundledPluginBootstrap
|
|
.request()
|
|
.then((result) => {
|
|
for (const failure of result?.errors ?? []) {
|
|
console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error)
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.warn('[plugins] failed to bootstrap bundled plugins:', error)
|
|
})
|
|
}
|
|
pluginKillListService.onChanged(() => {
|
|
void pluginService?.reconcileActivationState().catch((error) => {
|
|
console.warn('[plugins] failed to apply plugin safety-list refresh:', error)
|
|
})
|
|
})
|
|
store.onSettingsChanged((updates) => {
|
|
if (updates.pluginSystemEnabled === true) {
|
|
requestBundledPluginBootstrap()
|
|
requestOfficialMarketplaceSeed()
|
|
}
|
|
if (app.isPackaged && updates.pluginSystemEnabled === true) {
|
|
void pluginKillListService?.refresh().catch((error) => {
|
|
console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error)
|
|
})
|
|
}
|
|
})
|
|
// Why: headless `orca serve` clients reach plugins through the runtime RPC
|
|
// methods, which resolve the service via this module-level setter. Consent
|
|
// over RPC uses the same hash-keyed write path as the desktop dialog.
|
|
setPluginServiceForRpc(pluginService, {
|
|
applyConsent: (request) =>
|
|
applyPluginConsent({ store: store!, pluginService: pluginService!, ...request }),
|
|
applyEnablement: (pluginKey, enabled) =>
|
|
applyPluginEnablement({ store: store!, pluginService: pluginService!, pluginKey, enabled })
|
|
})
|
|
// Lazy kernel: initialize() only discovers manifests — no worker forks, no
|
|
// panel reads. Zero plugin code runs before an explicit trigger.
|
|
void pluginService
|
|
.initialize()
|
|
.then(() => {
|
|
logStartupMilestone('plugin-system-initialized', {
|
|
durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)),
|
|
installedPlugins: pluginService?.getDiscovered().length ?? 0
|
|
})
|
|
})
|
|
.catch((error) => {
|
|
console.warn('[plugins] failed to initialize plugin service:', error)
|
|
})
|
|
if (app.isPackaged && store?.getSettings().pluginSystemEnabled === true) {
|
|
void pluginKillListService.refresh().catch((error) => {
|
|
console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error)
|
|
})
|
|
}
|
|
pluginService.onChanged((event) => {
|
|
if (
|
|
event.contentPacksChanged &&
|
|
setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? [])
|
|
) {
|
|
void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu())
|
|
}
|
|
for (const window of BrowserWindow.getAllWindows()) {
|
|
if (!window.isDestroyed()) {
|
|
window.webContents.send('plugins:changed', event)
|
|
}
|
|
}
|
|
})
|
|
requestBundledPluginBootstrap()
|
|
requestOfficialMarketplaceSeed()
|
|
// v0 plugin event seams: agent status (hook pipeline tap) + worktree
|
|
// lifecycle (runtime tap). Server-side filtered per plugin subscription.
|
|
agentHookServer.subscribeEnrichedStatus((enriched) => {
|
|
// Why: plugins may automate on `working`; restored rows are historical claims, not fresh activity.
|
|
if (enriched.restoredUnconfirmed) {
|
|
return
|
|
}
|
|
pluginService?.emitEvent('agent.status.changed', {
|
|
worktreeId: enriched.worktreeId ?? null,
|
|
paneKey: enriched.paneKey,
|
|
state: enriched.payload.state,
|
|
receivedAt: enriched.receivedAt
|
|
})
|
|
})
|
|
runtimeService.onWorktreeLifecycle((event) => {
|
|
emitPluginWorktreeLifecycle(event)
|
|
})
|
|
starNag = new StarNagService(store, stats)
|
|
starNag.start()
|
|
starNag.registerIpcHandlers()
|
|
const agentBrowserBridge = new AgentBrowserBridge(browserManager, {
|
|
onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId)
|
|
})
|
|
runtimeService.setAgentBrowserBridge(agentBrowserBridge)
|
|
// Why: daemons a crashed or SIGKILL'd previous run left behind answer to nobody; nothing else reclaims them.
|
|
void agentBrowserBridge.sweepOrphanedSessions()
|
|
const browserClientAutomationDispatcher = new RpcDispatcher({ runtime: runtimeService })
|
|
configureBrowserClientPageAutomationRuntime({
|
|
browserManager,
|
|
getAgentBrowserBridge: () => agentBrowserBridge,
|
|
executeRpc: async (method, params, signal) => {
|
|
const response = await browserClientAutomationDispatcher.dispatch(
|
|
{
|
|
id: randomUUID(),
|
|
authToken: 'local-browser-client-automation',
|
|
method,
|
|
params
|
|
},
|
|
{ signal }
|
|
)
|
|
if (!response.ok) {
|
|
throw new BrowserClientPageCommandError(response.error.code)
|
|
}
|
|
return response.result
|
|
}
|
|
})
|
|
|
|
// 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)
|
|
// Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or
|
|
// crash mid-delete can leave the moved directory on disk.
|
|
void sweepStaleWorktreeTrash(
|
|
collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings())
|
|
).catch((error) => {
|
|
console.warn('[worktrees] Failed to sweep leftover worktree directories:', error)
|
|
})
|
|
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
|
|
// Why (#16441): the real-home grant runs a codex app-server session. It stays
|
|
// ordered before managed-hook reconciliation — an incapable host must re-arm
|
|
// and complete the legacy real-home sweep first — but awaiting it inline
|
|
// stalled app init behind that session, so chain instead of blocking.
|
|
const startupManagedHookSettings = store.getSettings()
|
|
const shouldReconcileStartupManagedHooks =
|
|
shouldInstallManagedHooks(is.dev) &&
|
|
resolveStartupManagedHookAction(startupManagedHookSettings) === 'install'
|
|
const realHomeCodexHookState =
|
|
shouldReconcileStartupManagedHooks &&
|
|
shouldInstallStartupManagedAgentHook(startupManagedHookSettings, 'codex') &&
|
|
codexRuntimeHome.isHostSystemDefaultRealHomeSelected()
|
|
? ensureRealHomeCodexHookState({
|
|
hooksEnabled: true,
|
|
userDataPath: app.getPath('userData')
|
|
}).catch((error: unknown) => {
|
|
console.warn('[codex-real-home-hooks] startup ensure failed:', error)
|
|
})
|
|
: Promise.resolve()
|
|
// Why skip rather than remove when the off switch is set: the hook files are user-global but this
|
|
// decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca
|
|
// instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch.
|
|
if (shouldReconcileStartupManagedHooks) {
|
|
const managedHookStore = store
|
|
void realHomeCodexHookState
|
|
.then(() =>
|
|
installManagedAgentHooks(managedHookStore.getSettings(), {
|
|
shouldHydrateShellPath: app.isPackaged,
|
|
onInstallError: recordManagedHookInstallFailure,
|
|
shouldContinue: (agent) => {
|
|
const settings = managedHookStore.getSettings()
|
|
return shouldContinueManagedHookStartup(isQuitting, settings, agent)
|
|
}
|
|
})
|
|
)
|
|
.catch((error: unknown) => {
|
|
console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error)
|
|
})
|
|
}
|
|
// Why: process-gone metrics only see survivors; retain a recent whole-app
|
|
// snapshot for comparison in crash reports.
|
|
startPreGoneProcessMetricsSampling()
|
|
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
|
|
})
|
|
) {
|
|
const crashedAt = performance.now()
|
|
void gpuCrashDiagnostics?.record()
|
|
void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt)
|
|
}
|
|
})
|
|
|
|
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,
|
|
// Why: STA-2370 — the desktop app binds the WS listener to loopback until the user pairs a device;
|
|
// `orca serve` is an explicit remote opt-in, and E2E keeps the wide bind its harness connects over.
|
|
exposeNetworkByDefault: Boolean(serveOptions) || isE2E,
|
|
...(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')
|
|
}
|
|
})
|
|
|
|
const shellPathReady = windowsShellPathHydration.whenReady()
|
|
let desktopWindow: BrowserWindow | null = null
|
|
if (process.platform === 'win32' && app.isPackaged && !serveOptions) {
|
|
const desktopStartup = startWindowsDesktopBeforeShellPathReady({
|
|
bindServices: bindTerminalRuntimeStartupServices,
|
|
openWindow: () => openMainWindow({ revealOnDidFinishLoad: true }),
|
|
shellPathReady,
|
|
startServices: startTerminalRuntimeStartupServices
|
|
})
|
|
desktopWindow = desktopStartup.window
|
|
} else {
|
|
await shellPathReady
|
|
bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))
|
|
}
|
|
app.on('activate', handleMacAppActivation)
|
|
|
|
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
|
|
await localPtyProviderStartupReady
|
|
await registerHeadlessPtyRuntime(
|
|
runtime,
|
|
prepareCodexRuntimeHomeForLaunch,
|
|
() => store!.getSettings(),
|
|
(target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target),
|
|
store,
|
|
prepareCodexSessionResumeForLaunch,
|
|
{
|
|
onCodexHomePtySpawned: handleCodexHomePtySpawned,
|
|
onPtyExit: handlePtyExit
|
|
}
|
|
)
|
|
await runtime.refreshRestoredOrchestrationAuthority()
|
|
await runtime.reconcileLegacyWorkerTerminals()
|
|
// 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, {
|
|
getAgentBrowserBridge: () => agentBrowserBridge
|
|
})
|
|
)
|
|
}
|
|
// 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()
|
|
// Why: every attempt must reach app.quit(); a page beforeunload can veto an earlier signal.
|
|
registerServeSignalHandlers(process, () => app.quit())
|
|
// 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()
|
|
// Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is
|
|
// armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch.
|
|
scheduleAllPendingHistoryTreeRemovals()
|
|
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 desktopRuntimeRpc = runtimeRpc
|
|
if (!desktopRuntimeRpc) {
|
|
throw new Error('runtime_rpc_unavailable')
|
|
}
|
|
const [win, runtimeRpcStartResult] = await Promise.all([
|
|
Promise.resolve(desktopWindow ?? openMainWindow()),
|
|
shellPathReady
|
|
.then(() => desktopRuntimeRpc.start())
|
|
.then(
|
|
() => ({ ok: true as const }),
|
|
(error: unknown) => {
|
|
recordRuntimeRpcStartFailure(error)
|
|
return { ok: false as const, error }
|
|
}
|
|
)
|
|
])
|
|
if (!runtimeRpcStartResult.ok) {
|
|
void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.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()
|
|
// Why: sleeping past relay-token expiry kills the broker with no retry
|
|
// timer; resume is the moment that state becomes recoverable.
|
|
powerMonitor.on('resume', () => desktopRelayService?.ensureLive())
|
|
} 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)
|
|
}
|
|
})
|
|
})
|
|
|
|
// Why: app.exit() skips Electron quit events, so keep its log child from surviving forced exits.
|
|
process.once('exit', stopTccPromptNotice)
|
|
|
|
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)
|
|
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 preventDefaults and runs teardown; second pass exits.
|
|
let daemonDisconnectDone = false
|
|
// Why 2s: a config delete is best-effort, not durable state.
|
|
const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000
|
|
|
|
app.on('will-quit', (e) => {
|
|
// Why return instead of re-running teardown: the second pass is Electron re-firing after
|
|
// our own app.quit(), so every step below already ran and every durable write already
|
|
// landed. Re-entering would start a fresh unawaited write that the exit then tears down.
|
|
if (daemonDisconnectDone) {
|
|
return
|
|
}
|
|
// Why preventDefault before any work: everything below must be free to await, and a
|
|
// synchronous durable write here parks the main thread — uninterruptibly, on a stalled
|
|
// network profile mount. The teardown deadline cannot rescue that, because its timer
|
|
// lives on the same thread it would need to bound (#9447 covers the wedged-transport
|
|
// half; this covers the blocked-syscall half).
|
|
if (!quitTeardownStartGate.tryStart(e)) {
|
|
return
|
|
}
|
|
unsubscribeSystemResumeBroadcast?.()
|
|
unsubscribeSystemResumeBroadcast = null
|
|
// Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes.
|
|
stopTccPromptNotice()
|
|
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: an agent still working at quit gets no terminating hook, so stats.flushAsync() closes those sessions out synchronously (only the write is deferred) — otherwise their duration is lost.
|
|
starNag?.stop()
|
|
automations?.stop()
|
|
// Why: plugin hosts are forked children; dispose sends shutdown and
|
|
// escalates to SIGKILL so they cannot outlive the app. The promise joins
|
|
// the teardown barrier below — quitting before it resolves would let
|
|
// Electron exit first and orphan the hosts.
|
|
setPluginServiceForRpc(null)
|
|
pluginKillListService = null
|
|
pluginMarketplaceService = null
|
|
pluginMarketplaceInstaller = null
|
|
const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve()
|
|
const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries()
|
|
const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime()
|
|
pluginService = null
|
|
setUnreadDockBadgeCount(0)
|
|
agentHookServer.stop()
|
|
// Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a
|
|
// bare script path that cannot express the guard and would otherwise keep spawning after quit.
|
|
// Why bounded here: every other teardown member carries its own ceiling, and this one reaches
|
|
// $GROK_HOME -- which can be a stalled network mount, where the fs calls never settle and the
|
|
// shared 20s deadline becomes the only thing ending the quit.
|
|
const grokHookCleanup =
|
|
process.platform === 'win32'
|
|
? settleWithinMs(
|
|
removeManagedAgentHooksAsync({ agents: ['grok'] }),
|
|
GROK_HOOK_CLEANUP_DEADLINE_MS
|
|
).then((settled) => {
|
|
if (settled.outcome === 'timed-out') {
|
|
console.warn('[agent-hooks] Grok hook cleanup on quit timed out')
|
|
return
|
|
}
|
|
if (settled.outcome === 'failed') {
|
|
console.warn('[agent-hooks] Grok hook cleanup on quit failed:', settled.error)
|
|
return
|
|
}
|
|
// Why: removers report failures as statuses, so inspect details even after fulfillment.
|
|
for (const status of settled.value.filter((entry) => entry.detail)) {
|
|
console.warn(`[agent-hooks] ${status.agent} hook cleanup on quit: ${status.detail}`)
|
|
}
|
|
})
|
|
: Promise.resolve()
|
|
// Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown.
|
|
wslHookRelayManager.disposeAll()
|
|
const statsFlush = stats?.flushAsync() ?? Promise.resolve()
|
|
// Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk.
|
|
// Why the barrier below: each session's close is its own agent-browser child taking hundreds of ms,
|
|
// so an unawaited call reaches app.quit() first and every open tab's daemon survives the quit (#16367).
|
|
// Why retire headless page owners first: it closes those helpers without a duplicate close fanout.
|
|
const browserShutdown = (async (): Promise<void> => {
|
|
await runtime?.getOffscreenBrowserBackend()?.destroyAll?.()
|
|
await runtime?.getAgentBrowserBridge()?.destroyAllSessions()
|
|
})()
|
|
// Why (review P2-4): local SSH browser routes own loopback listeners and, on the
|
|
// system-ssh path, `ssh -N -D` children that would otherwise outlive the app.
|
|
const localSshRouteShutdown = import('./browser/local-ssh-browser-route')
|
|
.then((routes) => routes.closeAllLocalSshBrowserRoutes())
|
|
.catch(() => {})
|
|
browserManager.setBrowserGuestStateChangedListener(null)
|
|
const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve()
|
|
// Why immediately before store.flushAsync() with no await in between: beginSshShutdown() marks every
|
|
// active SSH lease detached in memory synchronously, and that flush is what persists it.
|
|
const sshShutdown = beginSshShutdown()
|
|
killAllPty()
|
|
const watcherShutdown = shutdownWatchersOnce()
|
|
const storeFlush = store?.flushAsync() ?? Promise.resolve()
|
|
// Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a
|
|
// scan completion would drop the final snapshot. Captured before any await; joins the barrier below.
|
|
const usageCacheFlush = Promise.all([
|
|
claudeUsage?.flush(),
|
|
codexUsage?.flush(),
|
|
openCodeUsage?.flush()
|
|
]).then(() => {})
|
|
const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts()
|
|
const skillUploadShutdown = runtime?.disposeSkillUploadSessions() ?? Promise.resolve()
|
|
|
|
// 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()
|
|
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).
|
|
// Why stats/state join here: their writes are durable but not worth hanging the app for.
|
|
// Losing at most the last debounce interval beats a quit that never completes, and the
|
|
// temp+rename swap means a write cut short by the deadline leaves the old file intact.
|
|
settleTeardownWithinDeadline([
|
|
{ name: 'daemon', promise: daemonTeardown },
|
|
{ name: 'browser', promise: browserShutdown },
|
|
{ name: 'runtime-rpc', promise: rpcStopAndClear },
|
|
{ name: 'watchers', promise: watcherShutdown },
|
|
{ name: 'emulator', promise: emulatorShutdown },
|
|
{ name: 'browser-client-hosts', promise: browserClientHostShutdown },
|
|
{ name: 'local-ssh-browser-routes', promise: localSshRouteShutdown },
|
|
{ name: 'ssh', promise: sshShutdown },
|
|
{ name: 'plugin-hosts', promise: pluginHostShutdown },
|
|
{ name: 'skill-uploads', promise: skillUploadShutdown },
|
|
{ name: 'grok-hooks', promise: grokHookCleanup },
|
|
{ name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown },
|
|
{ name: 'structured-agent-session', promise: structuredAgentSessionShutdown },
|
|
{ name: 'usage-cache', promise: usageCacheFlush },
|
|
{ name: 'stats', promise: statsFlush },
|
|
{ name: 'state', promise: storeFlush }
|
|
])
|
|
.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()
|
|
}
|
|
})
|