Files
orca/src/main/index.ts
T
Jinjing 22dbbf3dba Harden layout validation, watcher lifecycle, and connection robustness
- Throw instead of silently skipping when the packaged daemon-entry is
  missing, preventing layout regressions from passing build checks.
- Terminate idle parcel-watcher processes to reclaim native handles and
  avoid crash-prone native node module teardowns on shutdown.
- Bind the persisted WS fallback port first to prevent orphaning active
  mobile pairings when the preferred port becomes free again.
- Cap concurrent disk reads for restored dirty tab verification at three
  to prevent startup connection bottlenecks on remote SSH workspaces.
2026-07-09 16:07:04 -07:00

2329 lines
94 KiB
TypeScript

/* eslint-disable max-lines -- Why: this is Orca's main-process entry point;
it owns app lifecycle, service wiring, window creation, and hook/daemon
startup. Splitting by line count would fragment tightly coupled startup
logic across files without a cleaner ownership seam. */
import { existsSync, statSync } from 'node:fs'
import { isAbsolute, join } from 'node:path'
import os from 'node:os'
import { app, BrowserWindow, dialog, ipcMain, nativeTheme } from 'electron'
import { electronApp, is } from '@electron-toolkit/utils'
import * as QRCode from 'qrcode'
import {
Store,
initDataPath,
getCanonicalUserDataPath,
migrateMobilePairingDataToCanonicalUserDataPath
} from './persistence'
import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store'
import { applyAppIcon } from './app-icon'
import { StatsCollector, initStatsPath } from './stats/collector'
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store'
import { killAllPty } from './ipc/pty'
import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
import { registerCoreHandlers } from './ipc/register-core-handlers'
import { initObservability, shutdownObservability } from './observability'
import { startSpan } from './observability/tracer'
import { registerMobileHandlers } from './ipc/mobile'
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client'
import { classifyError } from './telemetry/classify-error'
import { runManagedHookInstallers } from './agent-hooks/install-telemetry'
import {
isAgentStatusHooksEnabled,
MANAGED_AGENT_HOOK_INSTALLERS,
removeManagedAgentHooks
} from './agent-hooks/managed-agent-hook-controls'
import { initCohortClassifier } from './telemetry/cohort-classifier'
import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier'
import { resolveConsent } from './telemetry/consent'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
import { ensureMainI18n, setMainUiLanguage } from './i18n/main-i18n'
import {
getNextDefaultOnAppearanceSettingValue,
registerAppMenu,
rebuildAppMenu
} from './menu/register-app-menu'
import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater'
import {
configureElectronNetworkCompatibility,
configureDevUserDataPath,
configureOrcaUserDataPathEnv,
enableMainProcessGpuFeatures,
installDevParentDisconnectQuit,
installDevParentSignalQuit,
installDevParentWatchdog,
installUncaughtPipeErrorGuard,
isDevParentShutdownRequested,
patchPackagedProcessPath,
shouldInstallManagedHooks
} from './startup/configure-process'
import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom'
import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display'
import {
readActiveGpuFallbackMarker,
writeGpuFallbackMarker,
type GpuFallbackEnvironment,
type WindowsGpuFallbackEnvironment
} from './startup/gpu-fallback-marker'
import {
DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD,
DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
GpuCrashFallbackTracker,
isGpuFallbackCrashCandidate
} from './crash-reporting/gpu-crash-fallback-decision'
import {
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 { getDevInstanceIdentity } from './startup/dev-instance-identity'
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
import {
acquireSingleInstanceLock,
logSingleInstanceLockBypass,
logSingleInstanceLockFailure,
shouldBypassSingleInstanceLock
} from './startup/single-instance-lock'
import { startEventLoopStallProbe } from './startup/event-loop-stall-probe'
import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe'
import {
isStartupDiagnosticsEnabled,
logStartupDiagnostic,
logStartupMilestone
} from './startup/startup-diagnostics'
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
import { 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 {
attachMainWindowServices,
ensureAutoUpdaterConfigured
} from './window/attach-main-window-services'
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
import { createSystemTray, destroySystemTray, setTrayAttention } from './tray/system-tray'
import { focusExistingMainWindow } from './window/focus-existing-window'
import { notifyMainWindowBecameVisible } from './window/main-window-visibility'
import { CodexAccountService } from './codex-accounts/service'
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
import {
normalizeCodexRuntimeSelection,
type CodexAccountSelectionTarget
} from './codex-accounts/runtime-selection'
import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection'
import { codexHookService } from './codex/hook-service'
import { ClaudeAccountService } from './claude-accounts/service'
import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service'
import {
attachClaudeLivePtyPersistence,
seedLiveClaudePtysFromPersistence
} from './claude-accounts/live-pty-gate'
import { StarNagService } from './star-nag/service'
import { agentHookServer } from './agent-hooks/server'
import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename'
import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename'
import { moveWorktree } from './git/worktree'
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
import { parseWorkspaceKey } from '../shared/workspace-scope'
import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state'
import {
clearProviderPtyState,
getPtyIdForPaneKey,
registerPaneKeyTeardownListener,
getLocalPtyProvider,
registerHeadlessPtyRuntime
} from './ipc/pty'
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
import { EmulatorBridge } from './emulator/emulator-bridge'
import { serveSimStateWatcher } from './emulator/serve-sim-state-watcher'
import { browserManager } from './browser/browser-manager'
import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend'
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
import { setUnreadDockBadgeCount } from './dock/unread-badge'
import { AutomationService } from './automations/service'
import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch'
import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create'
import { AgentAwakeService } from './agent-awake-service'
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
import {
getCrashBreadcrumbSnapshot,
recordCoalescedCrashBreadcrumb,
recordCrashBreadcrumb
} from './crash-reporting/crash-breadcrumb-store'
import { CrashReportStore } from './crash-reporting/crash-report-store'
import {
shouldRecordProcessGoneCrash,
shouldRecoverRendererAfterProcessGone,
type ExpectedTeardownScope
} from './crash-reporting/process-gone-classification'
import {
buildProcessGoneCrashDetails,
buildSuppressedProcessGoneBreadcrumbData
} from './crash-reporting/process-gone-diagnostics'
import { getProcessGoneDedupeKey, processGoneDedupe } from './crash-reporting/process-gone-dedupe'
import {
advanceSyntheticTitleSpinnerEntries,
type SyntheticTitleSpinnerEntry
} from './synthetic-title-spinner'
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
import { isCrashReportReason } from '../shared/crash-reporting'
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 { KeybindingService } from './keybindings/keybinding-service'
import { applyElectronProxySettings } from './network/proxy-settings'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
import { CliInstaller } from './cli/cli-installer'
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal'
let mainWindow: BrowserWindow | null = null
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
* window close handler so it can tell the renderer to skip the running-process
* confirmation dialog and proceed directly to buffer capture + close. */
let isQuitting = false
let store: Store | null = null
let stats: StatsCollector | null = null
let claudeUsage: ClaudeUsageStore | null = null
let codexUsage: CodexUsageStore | null = null
let openCodeUsage: OpenCodeUsageStore | null = null
let codexAccounts: CodexAccountService | null = null
let codexRuntimeHome: CodexRuntimeHomeService | null = null
let claudeAccounts: ClaudeAccountService | null = null
let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null
let runtime: OrcaRuntimeService | null = null
let rateLimits: RateLimitService | null = null
let runtimeRpc: OrcaRuntimeRpcServer | null = null
// Why: set during early startup; gates whether headless serve installs the
// offscreen browser backend (and thus advertises browser pane support).
let headlessBrowserDisplayAvailable = false
let starNag: StarNagService | null = null
let agentAwakeService: AgentAwakeService | null = null
let crashReports: CrashReportStore | null = null
let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null
let unsubscribeSystemResumeBroadcast: (() => void) | null = null
let watcherShutdownPromise: Promise<void> | null = null
let watcherShutdownDone = false
let automations: AutomationService | null = null
let keybindings: KeybindingService | null = null
// Why: a reload/teardown intent set for one renderer must not leak to a later load.
// The recovery reload re-fires did-finish-load, so its flag lets the local-PTY orphan
// sweep spare live sessions across that one reload (#5787).
const expectedRendererReload = createWebContentsTimedFlag()
const recoveryReloadInFlight = createWebContentsTimedFlag()
let firstWindowStartupServicesReady: Promise<void> = Promise.resolve()
// Why: GPU child crashes clustered right after launch indicate a broken driver;
// track them so Orca can move this build onto software rendering.
const gpuLaunchTimeMs = Date.now()
const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({
windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD
})
let gpuFallbackActiveThisLaunch = false
let localPtyStartupReady: Promise<void> = Promise.resolve()
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
const isServeMode = process.argv.includes('--serve')
// Why: on Windows a CLI-shaped launch (Orca.exe <unpacked CLI entry>) that lost
// ELECTRON_RUN_AS_NODE would otherwise boot the GUI, lose the single-instance
// lock to a running window, and exit silently. Redirect it to node mode here,
// before the lock gate below can bounce it.
const packagedCliEntryRedirect = maybeRedirectPackagedCliEntryLaunch({
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
execPath: process.execPath
})
if (packagedCliEntryRedirect.redirected) {
app.exit(packagedCliEntryRedirect.status)
}
const appImageCliRedirect = maybeRedirectAppImageCliLaunch({
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
execPath: process.execPath
})
if (appImageCliRedirect.redirected) {
app.exit(appImageCliRedirect.status)
}
// Kill switch for the first-work on-disk folder rename. The renderer reconciles a
// worktree id change via migrateWorktreeIdentity + a rename-aware worktrees:changed
// handler, so an old->new id change is no longer mistaken for a deletion. Flip off
// to disable the on-disk move (branch + display rename still happen) if needed.
const ENABLE_FIRST_WORK_FOLDER_RENAME = false
// Why: the store/runtime singletons live here in index.ts; injecting them keeps
// the rename orchestrator free of module-level state 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/imported branch can coincidentally be named after a creature.
// Only worktrees Orca stamped at creation are safe to auto-rename.
return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true
},
setDisplayName: (worktreeId, displayName) => {
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 always clears the failure badge, even if the explicit
// setRenameError(null) clear is redundant.
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) => {
// Skip the write + renderer push when nothing changes — benign skips
// clear the error on every settled worktree, most of which never had one.
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 })
// Push to the renderer so the badge updates — the hook only knows the
// worktreeId, so derive the repoId the same way 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: propagate the Orca app version into `process.env` so PTY-env
// construction in both main (local-pty-provider) and the forked daemon
// (pty-subprocess) can set `TERM_PROGRAM_VERSION` without re-importing
// electron. The daemon inherits `process.env` via fork (daemon-init.ts:93).
process.env.ORCA_APP_VERSION = app.getVersion()
patchPackagedProcessPath()
// Why: patchPackagedProcessPath seeds a minimal list of well-known system
// dirs synchronously so early IPC (e.g. preflight before the shell spawn
// completes) doesn't miss homebrew/nix. Kick off the login-shell probe in
// parallel for packaged runs — when it resolves, its PATH is prepended and
// detectInstalledAgents picks up whatever the user's rc files put on PATH
// (cargo/pyenv/volta/custom tool install dirs) without hardcoding each one.
// Dev runs already inherit a complete PATH from the launching terminal, so
// the spawn cost is only paid where it's needed.
if (app.isPackaged && process.platform !== 'win32') {
void hydrateShellPath().then((result) => {
if (result.ok) {
mergePathSegments(result.segments)
}
})
}
configureDevUserDataPath(is.dev)
configureOrcaUserDataPathEnv()
// Why: just past createMainWindow's win32 10s ready-to-show reveal fallback,
// so a window revealed on that path 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; unlike the startup probe it
// runs for the whole session to catch steady-state churn (issue #7576).
startMainThreadChurnProbe()
function focusExistingWindow(): void {
focusExistingMainWindow({
app,
getWindow: () => mainWindow,
openWindow: openMainWindow,
warn: console.warn
})
}
// Why: a webContents-scoped flag that auto-expires so an intent set for one renderer
// can't leak to a later load. `consume` clears on a positive match for one-shot
// signals (the recovery reload fires exactly one did-finish-load).
function createWebContentsTimedFlag(defaultDurationMs = 10_000): {
mark: (webContentsId: number, durationMs?: number) => void
clear: (webContentsId?: number) => void
matches: (webContentsId: number, options?: { consume?: boolean }) => boolean
} {
let state: { webContentsId: number; until: number } | null = null
return {
mark(webContentsId, durationMs = defaultDurationMs) {
state = { webContentsId, until: Date.now() + durationMs }
},
clear(webContentsId) {
if (webContentsId === undefined || state?.webContentsId === webContentsId) {
state = null
}
},
matches(webContentsId, options) {
if (!state || Date.now() > state.until) {
state = null
return false
}
if (state.webContentsId !== webContentsId) {
return false
}
if (options?.consume) {
state = null
}
return true
}
}
}
function markExpectedRendererReload(webContentsId: number, durationMs = 10_000): void {
expectedRendererReload.mark(webContentsId, durationMs)
}
function clearExpectedRendererReload(webContentsId?: number): void {
expectedRendererReload.clear(webContentsId)
}
function getExpectedTeardownScope(webContentsId?: number): ExpectedTeardownScope {
if (isQuitting || isQuittingForUpdate()) {
return 'app-shutdown'
}
if (webContentsId === undefined) {
return 'none'
}
return expectedRendererReload.matches(webContentsId) ? 'renderer-reload' : 'none'
}
function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void {
recoveryReloadInFlight.mark(webContentsId, durationMs)
}
function isRecoveryReloadInFlight(webContentsId: number): boolean {
// Why: consume on read — the recovery reload fires exactly one did-finish-load,
// so a later genuine reload still sweeps orphaned local PTYs.
return recoveryReloadInFlight.matches(webContentsId, { consume: true })
}
function recordAgentStateCrashBreadcrumb(agentType: string, state: string): void {
// Why: hook pings can arrive many times per second while an agent works.
// Coalescing preserves crash-report room for renderer errors and memory
// samples instead of filling all 30 breadcrumbs with identical state pings.
recordCoalescedCrashBreadcrumb({
name: 'agent_state_changed',
data: { agentType, state },
coalesceKey: `agent:${agentType}:${state}`,
minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS
})
}
// Why: the lock must be acquired AFTER configureDevUserDataPath — Electron
// derives the lock identity from the `userData` path, so this placement lets
// dev (`orca-dev`) and packaged (`orca`) runs lock in separate namespaces
// instead of serialising against each other.
//
// Why skip in dev: engineers routinely run `pnpm dev` in parallel from
// multiple worktrees while shipping features, and the lock makes the second
// `pnpm dev` exit silently. In dev we accept that `orca-runtime.json` may race
// (the bundled `orca-dev` CLI routes to whichever instance wrote last). Agent
// hook endpoint files are namespaced per dev instance when the hook server
// starts below. Packaged Orca keeps the lock to protect against the corruption
// documented in PR #1326 / issue #1312.
const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({
isDev: is.dev,
isServeMode
})
if (bypassSingleInstanceLock) {
// Why: this is an explicit diagnostic escape hatch for macOS builds where
// Electron reports a false lock loss before any normal app logs exist.
logSingleInstanceLockBypass()
}
const hasSingleInstanceLock =
is.dev && !isServeMode
? true
: bypassSingleInstanceLock
? true
: acquireSingleInstanceLock(app, focusExistingWindow)
if (startupDiagnosticsEnabled) {
logStartupDiagnostic('single-instance-lock-result', {
acquired: hasSingleInstanceLock,
bypassed: bypassSingleInstanceLock,
skippedForDev: is.dev && !isServeMode
})
}
if (!hasSingleInstanceLock) {
// Why: if Electron returns a false negative here, packaged macOS launches
// otherwise look like silent crashes. `open --stderr` can capture this line.
logSingleInstanceLockFailure()
app.quit()
}
// Why: when the lock is held by another process, we've already called
// app.quit() above. Skip every remaining file-writing side effect so this
// transient process never touches userData, and let handler registration
// below happen — those handlers only fire after whenReady, which app.quit()
// prevents from ever dispatching.
if (hasSingleInstanceLock) {
// Why: dev parent shutdown coupling is only for electron-vite desktop runs.
// `orca serve` may be launched through a CLI shim or background shell whose
// parent lifetime is not the intended server lifetime.
const shouldCoupleToDevParent = is.dev && !isServeMode
installDevParentDisconnectQuit(shouldCoupleToDevParent)
installDevParentWatchdog(shouldCoupleToDevParent)
installDevParentSignalQuit(shouldCoupleToDevParent)
// Why: must run after configureDevUserDataPath (which redirects userData to
// orca-dev in dev mode) but before app.setName('Orca') inside whenReady
// (which would change the resolved path on case-sensitive filesystems).
initDataPath()
initOrcaProfilePaths()
// Why: same timing constraint as initDataPath — capture the userData path
// before app.setName changes it. See persistence.ts:20-28.
initStatsPath()
initClaudeUsagePath()
initCodexUsagePath()
initOpenCodeUsagePath()
crashReports = CrashReportStore.fromUserData()
recordCrashBreadcrumb('app_started', {
packaged: app.isPackaged,
platform: process.platform
})
configureElectronNetworkCompatibility()
enableRendererHeapHeadroom()
maybeApplyGpuFallbackForThisLaunch()
if (!gpuFallbackActiveThisLaunch) {
enableMainProcessGpuFeatures()
}
// Why: headless serve backs browser panes with offscreen BrowserWindows, which
// need an X display on Linux. Ensure one (Xvfb) before whenReady; the result
// gates whether the offscreen backend is installed so capability stays honest.
headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ isServeMode })
}
ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
await firstWindowStartupServicesReady
})
ipcMain.handle(
'app:startupDiagnostic',
(_event, event: string, details?: Record<string, unknown>) => {
if (!startupDiagnosticsEnabled || !event.startsWith('renderer-')) {
return
}
logStartupMilestone(event, details && typeof details === 'object' ? details : {})
}
)
function startDesktopFirstWindowStartupServices(): Promise<void> {
logStartupMilestone('first-window-startup-services-start')
const startupServices = startFirstWindowStartupServices({
// Why: the persistent-terminal daemon is desktop-only. Headless `orca serve`
// registers its PTY runtime separately and must not spawn the desktop daemon
// or hook loopback listener.
startDaemonPtyProvider: async (signal) => {
logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' })
await initDaemonPtyProvider(signal)
logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' })
},
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, so
// the renderer awaits this barrier before restored terminals reconnect.
startAgentHookServer: async () => {
logStartupMilestone('startup-service-start', { service: 'agent-hook-server' })
await agentHookServer.start({
env: app.isPackaged ? 'production' : 'development',
// Why: hooks source this endpoint file at invocation time, so old PTY
// env still reaches the current Orca process after an app restart.
// Dev uses a namespace because all worktrees share `orca-dev`.
userDataPath: app.getPath('userData'),
endpointNamespace: devAgentHookEndpointNamespace
})
logStartupMilestone('startup-service-done', { service: 'agent-hook-server' })
},
onDaemonError: (error) => {
// Why: daemon startup failure silently dropped terminals onto the local
// provider (killed on quit, no persistence) — the v1.4.129-rc.1 outage was
// invisible in the field. Log loudly (error.message carries the captured
// daemon stderr tail from the fork) and emit a low-cardinality telemetry
// signal so a fleet-wide daemon failure is observable without a bug report.
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: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar
// enrichment only. Orca must still boot if the loopback receiver fails.
console.error('[agent-hooks] Failed to start local hook server:', error)
}
})
firstWindowStartupServicesReady = startupServices.firstWindowReady
localPtyStartupReady = startupServices.localPtyReady
void firstWindowStartupServicesReady.then(() => {
logStartupMilestone('first-window-startup-services-ready')
})
void localPtyStartupReady.then(() => {
logStartupMilestone('local-pty-startup-ready')
})
return firstWindowStartupServicesReady
}
async function startServeAgentHookServer(): Promise<void> {
if (!isAgentStatusHooksEnabled(store?.getSettings())) {
return
}
try {
await agentHookServer.start({
env: app.isPackaged ? 'production' : 'development',
userDataPath: app.getPath('userData'),
endpointNamespace: devAgentHookEndpointNamespace
})
} catch (error) {
// Why: remote hook callbacks enrich agent status only. A headless runtime
// should still serve terminals if the loopback receiver cannot bind.
console.error('[agent-hooks] Failed to start serve hook server:', error)
}
}
function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null {
const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target)
const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings())
try {
// Why: launch prep is reachable after startup via PTY/runtime paths; honor
// the persisted off switch so those launches cannot reinstall removed hooks.
const status = hooksEnabled
? codexHookService.install()
: codexHookService.refreshRuntimeUserHooks()
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/removal is best-effort launch prep. A malformed hooks file
// should not block the Codex process from starting with its prepared auth.
console.warn(
`[codex-hook-service] failed to ${
hooksEnabled ? 'refresh' : 'refresh user'
} runtime hooks before launch`,
error
)
}
return runtimeHomePath
}
// Why: tray "Open Orca" / left-click restores the window the close handler may
// have hidden to the tray; if the window was fully torn down, reopen it the
// same way macOS dock re-activation does (guarded against update relaunch).
function showMainWindowFromTray(): void {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
return
}
if (!isQuittingForUpdate()) {
openMainWindow()
}
}
function openMainWindow(): BrowserWindow {
logStartupMilestone('open-main-window-start')
if (!store) {
throw new Error('Store must be initialized before opening the main window')
}
if (!runtime) {
throw new Error('Runtime must be initialized before opening the main window')
}
if (!stats) {
throw new Error('Stats must be initialized before opening the main window')
}
if (!claudeUsage) {
throw new Error('Claude usage store must be initialized before opening the main window')
}
if (!codexUsage) {
throw new Error('Codex usage store must be initialized before opening the main window')
}
if (!openCodeUsage) {
throw new Error('OpenCode usage store must be initialized before opening the main window')
}
if (!rateLimits) {
throw new Error('Rate limit service must be initialized before opening the main window')
}
if (!automations) {
throw new Error('Automation service must be initialized before opening the main window')
}
if (!codexAccounts) {
throw new Error('Codex account service must be initialized before opening the main window')
}
if (!codexRuntimeHome) {
throw new Error('Codex runtime home service must be initialized before opening the main window')
}
if (!claudeAccounts) {
throw new Error('Claude account service must be initialized before opening the main window')
}
if (!claudeRuntimeAuth) {
throw new Error(
'Claude runtime auth service must be initialized before opening the main window'
)
}
if (!keybindings) {
throw new Error('Keybinding service must be initialized before opening the main window')
}
// Why: Chromium's BrowserWindow constructor resets the userData DACL to a
// Protected DACL, breaking writes in pre-existing subdirs. Explicit ACEs on
// userData + immediate children fix the tree permanently; the grant runs in
// the background on first launch only (marker-gated) because the previous
// synchronous recursive walk blocked startup ~60s on large profiles. See
// startup/windows-user-data-acl.ts; per-write EPERM retries are the backstop.
if (process.platform === 'win32') {
logStartupMilestone('acl-grant-start')
ensureWindowsUserDataAclGrant(app.getPath('userData'), {
onDone: (result) => {
logStartupMilestone('acl-grant-done', { mode: result.mode })
if (result.mode === 'failed') {
console.warn('[win32-acl] userData ACL grant failed:', result.reason)
}
}
})
}
const window = createMainWindow(store, {
getIsQuitting: () => isQuitting,
onQuitAborted: () => {
isQuitting = false
clearExpectedRendererReload()
},
onRendererProcessGone: (details, webContentsId) => {
recordProcessGoneCrash(
'renderer',
'renderer',
details.reason,
details.exitCode ?? null,
{
processType: 'renderer'
},
webContentsId
)
},
shouldRecordRendererCrash: (details, webContentsId) =>
shouldRecordProcessGoneCrash({
source: 'renderer',
processType: 'renderer',
reason: details.reason,
exitCode: details.exitCode ?? null,
expectedTeardown: getExpectedTeardownScope(webContentsId)
}),
shouldRecoverRenderer: (details, webContentsId) =>
shouldRecoverRendererAfterProcessGone({
reason: details.reason,
expectedTeardown: getExpectedTeardownScope(webContentsId)
}),
onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => {
recordCrashBreadcrumb('renderer_recovery_circuit_breaker_open', {
reason: details.reason,
exitCode: details.exitCode ?? null,
recentRecoveryCount
})
void presentRendererRecoveryPrompt(recentRecoveryCount)
},
deferLoad: true,
title: devInstanceIdentity.name,
getKeybindings: () => keybindings?.getOverrides(),
onBeforeReload: ({ ignoreCache, webContentsId }) => {
if (mainWindow?.webContents.id === webContentsId) {
markExpectedRendererReload(webContentsId)
}
recordCrashBreadcrumb('manual_reload_requested', { ignoreCache })
},
// Why: the in-place recovery reload re-fires did-finish-load; flag it so the
// local-PTY orphan sweep is skipped for that one reload (#5787).
onBeforeRecoveryReload: (webContentsId) => {
markRecoveryReloadInFlight(webContentsId)
recordCrashBreadcrumb('renderer_recovery_reload')
}
})
recordCrashBreadcrumb('main_window_created')
logStartupMilestone('window-created')
// Why: new Tray() is a synchronous Shell_NotifyIcon call that can block for
// seconds while explorer.exe's notification area is busy (part of issue
// #7225's pre-paint stall), so create it after first paint. The timer
// fallback covers windows revealed without ready-to-show ever firing
// (createMainWindow's win32 10s reveal fallback) — those can still be
// hidden to the tray on close, so the icon must exist by then.
let trayCreated = false
const createSystemTrayDeferred = (): void => {
if (trayCreated || window.isDestroyed() || isQuitting || !store) {
return
}
trayCreated = true
// Why: Windows-only system tray. createSystemTray is idempotent and a
// no-op off win32, so calling it on each window open keeps exactly one
// live icon.
createSystemTray({
appIcon: store.getSettings().appIcon,
onOpen: showMainWindowFromTray,
onQuit: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
// Why: a real quit can still surface renderer save/discard prompts;
// the window must be visible if a hidden-to-tray session vetoes
// shutdown.
showMainWindowFromTray()
}
// Why: set the quit latch before app.quit() so the window 'close'
// handler proceeds to teardown instead of re-hiding to the tray.
isQuitting = true
app.quit()
}
})
logStartupMilestone('tray-created')
}
window.once('ready-to-show', () => {
logStartupMilestone('ready-to-show')
setImmediate(createSystemTrayDeferred)
})
const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS)
trayCreateFallback.unref?.()
// Why: telemetry-plan.md§First-launch experience anchors default-on
// `app_opened` to the first main-window load. Existing users in the
// pending-banner cohort resolve through telemetry/client.ts; this load
// path only fires 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.getHostRuntimeHomePath()] : [],
onBeforeRelaunch: async () => {
isQuitting = true
await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })
}
}
)
automations.setWebContents(window.webContents)
automations.start()
attachMainWindowServices(
window,
store,
runtime,
prepareCodexRuntimeHomeForLaunch,
(target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target),
{
awaitLocalPtyStartup: () => localPtyStartupReady,
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 one recovery reload
// that re-fires did-finish-load, so live local sessions survive it (#5787).
isRecoveryReloadInFlight,
onBeforeUpdateQuit: () =>
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })
}
)
rateLimits.attach(window)
// Why: quota probes can spawn CLIs and hit network. The attached show/focus
// listeners refresh as soon as the window can present quota UI, so do not
// compete with first paint.
rateLimits.start({ fetchImmediately: false })
window.on('closed', () => {
if (mainWindow === window) {
mainWindow = null
}
clearExpectedRendererReload(rendererWebContentsId)
automations?.setWebContents(null)
// Why: detach the agent hook listener on window close so the server
// never fires into a destroyed webContents during the gap before
// reopen (e.g. macOS dock re-activation). This also ensures the
// replay-loop through lastStatusByPaneKey runs only on deliberate
// window recreations instead of stacking on top of stale listeners.
agentHookServer.setListener(null)
agentHookServer.setPaneStatusClearListener(null)
setMigrationUnsupportedPtyListener(null)
// Why: any running synthesized-title spinner timer would fire into a
// destroyed webContents; stop it here instead of deferring to per-pane
// teardown, which may never run for restored-but-never-torn-down panes
// when the window goes away.
stopAllSyntheticTitleSpinners()
})
mainWindow = window
window.on('show', resumeSyntheticTitleSpinnerTimer)
window.on('restore', resumeSyntheticTitleSpinnerTimer)
window.on('hide', stopSyntheticTitleSpinnerTimer)
window.on('minimize', stopSyntheticTitleSpinnerTimer)
// Why: visibility-gated main-process pollers (SSH port scanner) park while
// hidden and rely on this signal to resume; re-wired per window because
// macOS dock re-activation recreates the BrowserWindow.
window.on('show', notifyMainWindowBecameVisible)
window.on('restore', notifyMainWindowBecameVisible)
// Why: showing/restoring the window means the user is back, so clear the
// tray attention dot set while it was minimized/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,
isReplay
}) => {
if (mainWindow?.isDestroyed()) {
return
}
maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay })
const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey)
const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey)
mainWindow?.webContents.send('agentStatus:set', {
...payload,
paneKey,
...(launchToken ? { launchToken } : {}),
...(terminalHandle ? { terminalHandle } : {}),
tabId,
worktreeId,
connectionId,
receivedAt,
stateStartedAt,
...(providerSession ? { providerSession } : {}),
...(orchestration ? { orchestration } : {})
})
recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state)
// Why: some native OSC titles miss terminal idle/permission frames.
// Inject hook-derived frames so the renderer title tracker updates too.
const profile = getSyntheticAgentTitleProfile(payload.agentType)
const suppressSyntheticCodexAutoApprovalTitle =
payload.agentType === 'codex' &&
(payload.state === 'waiting' || payload.state === 'blocked')
? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({
agentType: payload.agentType,
state: payload.state,
launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken })
})
: false
if (
profile &&
shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) &&
!suppressSyntheticCodexAutoApprovalTitle
) {
driveSyntheticTitleFromHook(paneKey, payload.state, profile)
}
}
)
agentHookServer.setPaneStatusClearListener((paneKey) => {
if (mainWindow?.isDestroyed()) {
return
}
mainWindow?.webContents.send('agentStatus:clear', { paneKey })
})
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: when the renderer crash-loops, the breaker stops auto-reloading and the
// window is left blank. The renderer is dead, so a main-process dialog is the
// only surface that can offer a retry or a clean quit instead of a silent loop.
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()) {
recordCrashBreadcrumb('renderer_recovery_manual_retry')
loadMainWindow(mainWindow)
} else if (response === 1) {
isQuitting = true
app.quit()
}
}
function getGpuFallbackEnvironment(): GpuFallbackEnvironment {
return {
appVersion: app.getVersion(),
electronVersion: process.versions.electron ?? '',
platform: process.platform
}
}
function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | null {
const environment = getGpuFallbackEnvironment()
if (environment.platform !== 'win32') {
return null
}
return { ...environment, platform: 'win32' }
}
// Why: the GPU-fallback marker must be read before app.whenReady() resolves so
// app.disableHardwareAcceleration() can take effect. Windows desktop only -
// headless serve already runs software rendering on the platforms that need it.
function maybeApplyGpuFallbackForThisLaunch(): void {
if (isServeMode || process.platform !== 'win32') {
return
}
const marker = readActiveGpuFallbackMarker(app.getPath('userData'), getGpuFallbackEnvironment())
if (!marker) {
return
}
app.disableHardwareAcceleration()
app.commandLine.appendSwitch('disable-gpu')
gpuFallbackActiveThisLaunch = true
recordCrashBreadcrumb('gpu_fallback_applied', {
crashesInWindow: marker.crashesInWindow
})
}
// Why: a burst of crash-shaped Windows GPU child failures right after launch
// means hardware acceleration is unusable on this machine. Persist a build-
// scoped marker and relaunch into software rendering instead of looping crashes.
function handleGpuChildCrash(reason: string, exitCode: number | null): void {
// Software rendering already active or shutting down: nothing more to do.
if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) {
return
}
const result = gpuCrashFallbackTracker.recordGpuCrash(Date.now() - gpuLaunchTimeMs)
if (!result.shouldEngageFallback) {
return
}
recordCrashBreadcrumb('gpu_fallback_engaged', {
reason,
exitCode,
crashesInWindow: result.crashesInWindow
})
const engagedAt = Date.now()
const environment = getWindowsGpuFallbackEnvironment()
if (!environment) {
return
}
try {
writeGpuFallbackMarker(
app.getPath('userData'),
{
engagedAt,
crashesInWindow: result.crashesInWindow
},
environment
)
} catch (error) {
console.warn('[gpu-fallback] failed to persist marker:', error)
return
}
isQuitting = true
app.relaunch()
app.exit(0)
}
function recordProcessGoneCrash(
source: 'renderer' | 'child',
processType: string,
reason: string,
exitCode: number | null,
details: Record<string, unknown>,
webContentsId?: number
): void {
if (!crashReports || !isCrashReportReason(reason)) {
return
}
if (
!shouldRecordProcessGoneCrash({
source,
processType,
serviceName: typeof details.serviceName === 'string' ? details.serviceName : undefined,
reason,
exitCode,
expectedTeardown: getExpectedTeardownScope(webContentsId)
})
) {
recordCrashBreadcrumb(
'process_gone_suppressed',
buildSuppressedProcessGoneBreadcrumbData({
source,
processType,
reason,
exitCode,
details
})
)
return
}
const key = getProcessGoneDedupeKey(source, processType, reason, exitCode)
if (!processGoneDedupe.shouldRecord(key)) {
return
}
const crashDetails = buildProcessGoneCrashDetails(details)
const span = startSpan('electron.process_gone', {
attributes: {
'crash.source': source,
'crash.process_type': processType,
'crash.reason': reason,
...(exitCode !== null ? { 'crash.exit_code': exitCode } : {}),
'app.version': app.getVersion(),
platform: process.platform,
osRelease: os.release(),
arch: process.arch,
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
details: crashDetails,
breadcrumbs: getCrashBreadcrumbSnapshot()
}
})
// Why: renderer/child crashes belong in the local trace lane so the
// diagnostic bundle has the same process-gone signal as the startup prompt.
span.fail(`${source} process gone: ${processType} ${reason} (${exitCode ?? 'unknown'})`)
void crashReports
.record({
source,
processType,
reason,
exitCode,
appVersion: app.getVersion(),
platform: process.platform,
osRelease: os.release(),
arch: process.arch,
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
details: crashDetails,
// Why: breadcrumbs stay memory-only during normal operation. Persist a
// snapshot only after Electron reports a crash-like process exit.
breadcrumbs: getCrashBreadcrumbSnapshot()
})
.catch((error) => {
console.error('[crash-reporting] Failed to persist crash report:', error)
})
}
function shutdownWatchersOnce(): Promise<void> {
if (watcherShutdownDone) {
return Promise.resolve()
}
if (!watcherShutdownPromise) {
// Why: @parcel/watcher tears down native async work during unsubscribe.
// Electron must wait for that cleanup before Node's environment exits.
watcherShutdownPromise = Promise.allSettled([
closeAllWatchers(),
disposeWorktreeBaseDirectoryWatchers()
])
.then((results) => {
for (const result of results) {
if (result.status === 'rejected') {
console.error('[filesystem-watcher] shutdown failed:', result.reason)
}
}
})
.then(() => {
watcherShutdownDone = true
})
}
return watcherShutdownPromise
}
// Why: Pi-style persistent spinner — cursor-agent re-emits its own
// "Cursor Agent" OSC title on every internal redraw, so a single synthesized
// "⠋ Cursor Agent" frame gets silently overwritten in the renderer within
// milliseconds and the sidebar dot snaps back to solid. Keep asserting a
// fresh working frame on an interval until the hook reports a non-working
// state. Interval matches Pi's 80ms cadence — fast enough for a smooth
// spinner, slow enough to stay well under the per-flush IPC budget.
// Why: opencode emits a single literal "OpenCode" title at startup and
// nothing thereafter, so a one-shot working frame would suffice for it. We
// reuse the same persistent-spinner mechanism rather than branching because
// the animated spinner is also nicer UX (matches every other working agent).
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 root = join(app.getAppPath(), 'out', 'web')
return existsSync(join(root, 'web-index.html')) ? root : undefined
}
async function renderTerminalPairingQr(pairingUrl: string): Promise<string | null> {
try {
return await QRCode.toString(pairingUrl, { type: 'terminal', small: true })
} catch {
try {
return await QRCode.toString(pairingUrl, { type: 'utf8' })
} catch {
return null
}
}
}
async function printServeReady(options: ServeOptions): Promise<void> {
if (!runtime || !runtimeRpc) {
throw new Error('Runtime server must be initialized before printing serve readiness')
}
if (options.recipeJson) {
if (!options.projectRoot) {
throw new Error('--serve-recipe-json requires --serve-project-root')
}
if (!isAbsolute(options.projectRoot)) {
throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`)
}
const projectRootStats = statSync(options.projectRoot)
if (!projectRootStats.isDirectory()) {
throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`)
}
}
const endpoint = runtimeRpc.getWebSocketEndpoint()
const pairing = options.noPairing
? ({ available: false } 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
if (options.recipeJson) {
if (!pairing.available) {
throw new Error('Recipe JSON output requires runtime pairing to be available')
}
console.log(
JSON.stringify({
schemaVersion: 1,
pairingCode: pairing.pairingUrl,
projectRoot: options.projectRoot
})
)
return
}
if (options.json) {
console.log(
JSON.stringify({
type: 'orca_server_ready',
runtimeId: runtime.getRuntimeId(),
endpoint,
pairing: pairing.available
? {
url: pairing.pairingUrl,
endpoint: pairing.endpoint,
deviceId: pairing.deviceId,
webClientUrl: pairing.webClientUrl,
scope: options.mobilePairing ? 'mobile' : 'runtime',
qr: pairingQr
}
: null
})
)
return
}
console.log(`Orca server ready: ${endpoint ?? 'websocket unavailable'}`)
if (pairing.available) {
if (pairing.webClientUrl) {
console.log(`Web client URL: ${pairing.webClientUrl}`)
}
if (options.mobilePairing && pairingQr) {
console.log(`Mobile pairing QR:\n${pairingQr}`)
}
console.log(`Pairing URL: ${pairing.pairingUrl}`)
}
}
function installServeSignalHandlers(): void {
const quit = (): void => {
// Why: foreground `orca serve` is controlled by the parent CLI/terminal,
// so POSIX termination signals should follow Electron's normal quit path
// and flush runtime metadata, daemon checkpoints, and telemetry.
app.quit()
}
process.once('SIGINT', quit)
process.once('SIGTERM', quit)
}
// Why: on PTY teardown the paneKey mapping is dropped, so the spinner tick
// would keep firing but sendSyntheticTitle would no-op forever. Drop the
// entry explicitly so the shared timer shuts down once no panes are active.
registerPaneKeyTeardownListener((paneKey) => {
stopSyntheticTitleSpinner(paneKey)
})
function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
// Why: repeated working-spinner frames are decorative and can arrive every
// 80ms per agent. Final/permission frames are forced because they drive BEL.
if (
!shouldSendSyntheticTitleFrame({
force: options.force === true,
windowVisible: isSyntheticTitleWindowVisible()
})
) {
return
}
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: a single process timer covers all synthesized title spinners; per-pane
// intervals multiplied idle wakeups when several retained 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: immediately emit the first frame so the spinner starts visible at
// this hook event even if the interval's next tick is 80ms away. Subsequent
// frames come from the interval below.
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 an agent-type change mid-pane (rare, but
// possible if a hook reports a different agentType than the previous
// event) lands on the right idle/permission labels at terminal state.
existing.profile = profile
return
}
syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile })
ensureSyntheticTitleSpinnerTimer()
return
}
// Why: leaving the spinner running after a `blocked`/`waiting`/`done` event
// would immediately race the terminal state back to "working" on the next
// tick. Stop first, then inject the terminal frame. Idle/done uses a
// decorated "<Agent> ready" label rather than the bare native title — which
// for cursor the detector deliberately treats as a no-op so cursor's own
// per-turn re-emissions cannot clobber our synthesized state. The
// Permission frames also carry a trailing BEL (0x07 outside of any OSC
// sequence) so user-input-required states light up immediately. Done frames
// intentionally avoid the extra BEL: hook/status completion notifications
// own final-task attention and can cancel milestone noise during loops.
stopSyntheticTitleSpinner(paneKey)
const needsUserInput = state === 'blocked' || state === 'waiting'
const label = needsUserInput ? profile.permissionLabel : profile.idleLabel
sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, {
force: true
})
}
function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: {
agentType: string | null | undefined
state: AgentStatusState
launchConfig:
| {
agentArgs?: string | null
agentEnv?: Record<string, string> | null
}
| null
| undefined
}): boolean {
if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) {
return false
}
if (!args.launchConfig) {
return false
}
return (
resolveTuiAgentPermissionMode({
agent: 'codex',
agentArgs: args.launchConfig.agentArgs,
agentEnv: args.launchConfig.agentEnv
}) === 'yolo'
)
}
app.whenReady().then(async () => {
logStartupMilestone('app-ready')
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
app.setName(devInstanceIdentity.name)
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
logStartupMilestone('store-loaded')
// Why: must run before ClaudeRuntimeAuthService's constructor sync — a Claude
// CLI that survived the restart inside the daemon still holds the current
// single-use refresh token, and an unguarded early refresh would rotate it
// out from under that process (it then shows "Not logged in" mid-session).
attachClaudeLivePtyPersistence(store)
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`
)
}
selfHealRuntimeEnvironmentFocus({ store, userDataPath: app.getPath('userData') })
applyAppIcon(store.getSettings().appIcon)
if (shouldSuppressDevEducation({ isDev: is.dev })) {
suppressDevEducationForStore(store)
}
try {
// Why: Dock/Launchpad launches do not inherit shell proxy env vars, so the
// persisted proxy must be applied before any app-owned network fetchers run.
await applyElectronProxySettings(store.getSettings())
} catch {
console.warn('[proxy] Failed to apply network proxy settings')
}
// Why: browser sessions are used by desktop webviews and runtime profile
// commands, so initialize them at app startup instead of a renderer IPC path.
initializeBrowserSessionsForApp({
orcaProfileId: activeOrcaProfile.profile.id,
profileDirectory: activeOrcaProfile.profileDirectory
})
unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast()
agentAwakeService = new AgentAwakeService()
agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)
// Why: disk-hydrated status rows are UI continuity only. The service starts
// from an empty snapshot; only hook events observed in this runtime can keep
// the local computer awake.
agentAwakeService.setStatuses([])
unsubscribeAgentAwakeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => {
agentAwakeService?.setStatuses(statuses)
})
// Why: telemetry must initialize before any IPC handler / renderer can
// call `track()`. The client is a no-op in dev/contributor builds
// (`IS_OFFICIAL_BUILD === false`) and a no-op while `TELEMETRY_ENABLED`
// is false in PR 2 — so this call is safe to run early; it only records
// the Store reference, seeds common props, and resets per-session burst
// caps. Actual transport initialization is still gated by both flags.
initTelemetry(store)
// 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()
// Why: cohort-classifier reads the repo count synchronously at every emit
// for cohort-extended events. The Store has been sync-loaded above, and
// this init runs before any IPC handler is registered and before any
// window loads — so the classifier is hydrated before any `track()` call,
// regardless of whether it originates from the renderer, an IPC handler,
// or `trackAppOpenedOnce` / `did-finish-load`.
initCohortClassifier(store)
initOnboardingCohortClassifier(store)
stats = new StatsCollector()
claudeUsage = new ClaudeUsageStore(store)
codexUsage = new CodexUsageStore(store)
openCodeUsage = new OpenCodeUsageStore(store)
rateLimits = new RateLimitService()
codexRuntimeHome = new CodexRuntimeHomeService(store)
codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome)
claudeRuntimeAuth = new ClaudeRuntimeAuthService(store)
claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth)
rateLimits.setCodexHomePathResolver((target) =>
codexRuntimeHome!.prepareForRateLimitFetch(target)
)
rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings()))
rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings()))
rateLimits.setClaudeAuthPreparationResolver((target) =>
claudeRuntimeAuth!.prepareForRateLimitFetch(target)
)
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
})
browserManager.setSettingsResolver(() => ({ keybindings: keybindings?.getOverrides() }))
rateLimits.setInactiveClaudeAccountsResolver(() => {
const settings = store!.getSettings()
const activeIds = new Set(
[
normalizeClaudeRuntimeSelection(settings).host,
...Object.values(normalizeClaudeRuntimeSelection(settings).wsl)
].filter(Boolean)
)
return settings.claudeManagedAccounts
.filter((account) => !activeIds.has(account.id))
.map((account) => ({
id: account.id,
managedAuthPath: account.managedAuthPath,
managedAuthRuntime: account.managedAuthRuntime,
wslDistro: account.wslDistro,
wslLinuxAuthPath: account.wslLinuxAuthPath
}))
})
rateLimits.setInactiveCodexAccountsResolver(() => {
const settings = store!.getSettings()
const activeIds = new Set(
[
normalizeCodexRuntimeSelection(settings).host,
...Object.values(normalizeCodexRuntimeSelection(settings).wsl)
].filter(Boolean)
)
return settings.codexManagedAccounts
.filter((account) => !activeIds.has(account.id))
.map((account) => ({ id: account.id, managedHomePath: account.managedHomePath }))
})
const runtimeService = new OrcaRuntimeService(store, stats, {
// Why: resolve the PTY provider lazily. initDaemonPtyProvider() runs later
// inside attachMainWindowServices and calls setLocalPtyProvider(routedAdapter)
// to swap the in-process provider for the daemon-routed one. Capturing the
// provider reference eagerly here would freeze the pre-daemon LocalPtyProvider
// and defeat the teardown helper's prefix sweep (design §4.3 wire-up).
getLocalProvider: () => getLocalPtyProvider(),
onPtyStopped: clearProviderPtyState,
onTerminalAgentStatus: (event) => {
agentHookServer.ingestTerminalStatus(event)
},
// Why: hook-reported agent status is the same source the desktop sidebar
// reads. worktree.ps pulls it at query time so mobile shows the same agents.
getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(),
buildAgentHookPtyEnv: () =>
isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}
})
runtime = runtimeService
automations = new AutomationService(store, {
claudeUsage,
codexUsage,
// Why: desktop clients may mirror remote-host automations, but only a
// server process should execute schedules owned by `remote_host_service`.
allowRemoteHostScheduling: isServeMode,
headlessDispatcher: isServeMode
? async ({ automation, run, target }) => {
const terminalSnapshotLimit = 2_000
let terminalHandle: string
let terminalSessionId: string | null = null
let terminalPaneKey: string | null = null
let terminalPtyId: string | null = null
let workspaceId: string
let workspaceDisplayName: string | null = null
if (automation.workspaceMode === 'new_per_run') {
const created = await runtimeService.createManagedWorktree({
...buildHeadlessAutomationWorktreeCreateArgs({
automation,
run,
repo: target.repo
})
})
terminalHandle = created.startupTerminal?.handle ?? ''
terminalSessionId = created.startupTerminal?.tabId ?? null
terminalPaneKey = created.startupTerminal?.paneKey ?? null
terminalPtyId = created.startupTerminal?.ptyId ?? null
workspaceId = created.worktree.id
workspaceDisplayName = created.worktree.displayName ?? null
if (!terminalHandle) {
throw new Error(
created.warning ||
'Automation workspace was created, but no agent terminal started.'
)
}
} else {
if (!automation.workspaceId) {
throw new Error('The target workspace is no longer available.')
}
const terminal = await runtimeService.launchAgentTerminal(
`id:${automation.workspaceId}`,
{
agent: automation.agentId,
prompt: automation.prompt,
title: run.title
}
)
terminalHandle = terminal.handle
terminalSessionId = terminal.tabId ?? null
terminalPaneKey = terminal.paneKey ?? null
terminalPtyId = terminal.ptyId ?? null
workspaceId = terminal.worktreeId
const worktree = await runtimeService.showManagedWorktree(`id:${workspaceId}`)
workspaceDisplayName = worktree.displayName ?? null
}
const completion = (async () => {
const wait = await runtimeService.waitForTerminal(terminalHandle, {
condition: 'tui-idle'
})
const read = await runtimeService.readTerminal(terminalHandle, {
limit: terminalSnapshotLimit
})
const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer()
snapshotBuffer.append(read.tail.join('\n'))
if (wait.satisfied) {
return {
status: 'completed' as const,
outputSnapshot: snapshotBuffer.snapshot(),
error: null
}
}
return {
status: 'dispatch_failed' as const,
outputSnapshot: snapshotBuffer.snapshot(),
error: wait.blockedReason
? `Automation agent is blocked: ${wait.blockedReason}.`
: 'Automation agent did not report completion.'
}
})()
return {
workspaceId,
workspaceDisplayName,
terminalSessionId,
terminalPaneKey,
terminalPtyId,
completion
}
}
: undefined
})
runtimeService.setAutomationService(automations)
runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
runtimeService.setCommitMessageAgentEnvironmentResolvers({
// Why: local Codex hooks and auth now live in Orca's managed runtime home
// even for the system-default path, so every Orca-launched Codex process
// must resolve CODEX_HOME through the runtime-home service.
prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch,
prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target)
})
starNag = new StarNagService(store, stats)
starNag.start()
starNag.registerIpcHandlers()
runtimeService.setAgentBrowserBridge(
new AgentBrowserBridge(browserManager, {
onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId)
})
)
// Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser.
const emulatorBridge = new EmulatorBridge()
runtimeService.setEmulatorBridge(emulatorBridge)
serveSimStateWatcher.start()
serveSimStateWatcher.onDetected(({ worktreeId, info }) => {
runtimeService.getEmulatorBridge()?.registerActiveEmulator(worktreeId, info, {
managed: false
})
serveSimStateWatcher.markOrcaManaged(info)
runtimeService.notifyEmulatorAutoAttachFromWatcher(worktreeId, info)
})
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
if (shouldInstallManagedHooks(is.dev)) {
// Why: the persisted off switch must run before any auto-install path so
// users who removed Orca-managed hooks do not see them silently reappear on launch.
if (isAgentStatusHooksEnabled(store.getSettings())) {
runManagedHookInstallers(MANAGED_AGENT_HOOK_INSTALLERS)
} else {
removeManagedAgentHooks()
}
}
app.on('child-process-gone', (_event, details) => {
recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, {
name: details.name,
serviceName: details.serviceName,
type: details.type
})
if (
isGpuFallbackCrashCandidate({
platform: process.platform,
processType: details.type,
reason: details.reason
})
) {
handleGpuChildCrash(details.reason, details.exitCode ?? null)
}
})
logStartupMilestone('services-initialized')
await ensureMainI18n()
await setMainUiLanguage(store.getSettings().uiLanguage)
logStartupMilestone('i18n-ready')
registerAppMenu({
onCheckForUpdates: (options) => {
// Why: the menu is clickable before first paint; a manual check must
// run against a configured updater (see attach-main-window-services).
ensureAutoUpdaterConfigured()
return checkForUpdatesFromMenu(options)
},
onBeforeReload: ({ ignoreCache, webContentsId }) => {
if (mainWindow?.webContents.id === webContentsId) {
markExpectedRendererReload(webContentsId)
}
recordCrashBreadcrumb('manual_reload_requested', { ignoreCache })
},
onOpenSettings: () => {
recordCrashBreadcrumb('settings_opened')
mainWindow?.webContents.send('ui:openSettings')
},
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: menu clicks provide the BrowserWindow that invoked the item. Use it
// first so hidden/headless E2E windows and future multi-window flows route
// the tour to the correct renderer instead of relying on global focus.
const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null
sendOpenFeatureTour(targetBrowserWindow)
},
onZoomIn: () => {
mainWindow?.webContents.send('terminal:zoom', 'in')
},
onZoomOut: () => {
mainWindow?.webContents.send('terminal:zoom', 'out')
},
onZoomReset: () => {
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 under the persisted UI state
// (ui:set/ui:get), not settings. The renderer owns the authoritative
// toggle logic (it knows the current value and persists it back), so
// we forward the event and let it flip + store.
mainWindow?.webContents.send('ui:toggleStatusBar')
return
}
const current = store.getSettings()
// Why: these appearance settings are default-on for older profiles, 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: E2E tests launch parallel Electron instances that would all race to
// bind the default fixed port, crashing on EADDRINUSE. Port 0 lets the OS
// assign a random available port per instance while still exercising the
// full WebSocket startup path.
const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR)
// Why: a developer running `pnpm dev` while the packaged Orca is also open
// would otherwise race the packaged app for 6768 and silently fall back to
// a random OS-assigned port — breaking deterministic mobile pairing/repro
// scripts against the dev instance. Pin the first dev instance to 6769 so
// ws://127.0.0.1:6769 is stable; a second dev instance still falls back via
// ws-transport's EADDRINUSE handler. Note: once an instance has ever fallen
// back, the persisted fallback port is re-bound in preference to 6769
// (STA-1511) until mobile-ws-fallback-port.json is removed from userData.
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 already written mobile pairing credentials
// under the late app.getPath('userData') directory. Copy any missing files
// forward before the runtime switches exclusively to the canonical path.
migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData'))
runtimeRpc = new OrcaRuntimeRpcServer({
runtime,
// Why: mobile pairing (DeviceRegistry + E2EE keypair + runtime metadata)
// must share the stable path captured before app.setName(), not a late
// app.getPath('userData') that resolves elsewhere and drops paired devices
// across restarts/updates. See persistence.ts:getCanonicalUserDataPath.
userDataPath: getCanonicalUserDataPath(),
enableWebSocket: true,
...(isE2E ? { wsPort: 0 } : {}),
...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}),
webClientRoot: getBundledWebClientRoot()
})
registerMobileHandlers(runtimeRpc)
if (!isServeMode) {
startDesktopFirstWindowStartupServices()
}
if (serveOptions) {
await startServeAgentHookServer()
registerHeadlessPtyRuntime(
runtime,
prepareCodexRuntimeHomeForLaunch,
() => store!.getSettings(),
(target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target),
store
)
// Why: headless servers have no renderer to mount <webview> browser panes.
// Back them with main-process offscreen WebContents instead, so this host can
// own browser pages and advertise browser.headless.v1 — but only when a
// display is actually available (set up above), so the capability stays honest.
if (headlessBrowserDisplayAvailable) {
runtime.setOffscreenBrowserBackend(new OffscreenBrowserBackend(browserManager))
}
// Why: headless servers have no renderer graph publisher. Publish an
// explicit empty graph so status clients see a ready server while
// renderer-only operations still fail at their own window boundary.
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
await runtimeRpc.start().catch((error) => {
console.error('[runtime] Failed to start headless RPC transport:', error)
throw error
})
installServeSignalHandlers()
// Why: the orca CLI command is normally installed by the renderer onboarding /
// Settings "Install CLI" flow via the cli:install IPC. Headless serve has no
// renderer, so the command is never created and an in-terminal `orca …` fails
// with command-not-found. Run the idempotent installer here for the platforms
// where it puts a resolvable command on the managed-terminal PATH: macOS (bare
// `orca` in /usr/local/bin or ~/.local/bin) and Linux (`orca-ide`; bare `orca`
// is added by the dispatcher below). Windows is excluded — there install() would
// only mutate the persistent user-registry PATH without helping the current
// serve's child terminals. Best-effort: a failure must not block serve start.
if (process.platform === 'darwin' || process.platform === 'linux') {
try {
// Why: serve is headless — never let a missing-write-permission fallback
// pop an osascript admin prompt (it would hang serve on a GUI host). Skip
// instead; the user-writable ~/.local/bin path needs no elevation anyway.
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: on Linux the CLI installs as `orca-ide`, NOT bare `orca` (above), but the
// Claude Team launcher typed into the initial managed terminal invokes bare `orca`.
// Drop a bare-`orca` dispatcher on ~/.local/bin (ahead of /usr/bin on the managed
// terminal PATH) so `orca claude-teams` resolves. Best-effort: a failure must not
// block serve startup. See installLinuxBareOrcaDispatcher for the full rationale.
if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) {
try {
const dispatcher = await installLinuxBareOrcaDispatcher({
resourcesPath: process.resourcesPath
})
console.log(
`[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` +
`${dispatcher.target ? ` -> ${dispatcher.target}` : ''}`
)
} catch (error) {
console.warn(
'[serve] bare orca dispatcher install skipped:',
error instanceof Error ? error.message : String(error)
)
}
}
// Why: headless serve never opens a renderer, so arm scheduled automation dispatch here.
automations.start()
await printServeReady(serveOptions)
return
}
// Why: window creation and runtime RPC startup are independent. Local PTY
// spawns are gated inside registerPtyHandlers so RPC can bind immediately
// without racing the daemon provider swap.
const [win] = await Promise.all([
Promise.resolve(openMainWindow()),
runtimeRpc.start().catch((error) => {
console.error('[runtime] Failed to start local RPC transport:', error)
})
])
// Why: the macOS notification permission dialog must fire after the window
// is visible and focused. If it fires before the window exists, the system
// dialog either doesn't appear or gets immediately covered by the maximized
// window, making it impossible for the user to click "Allow".
win.once('show', () => {
// Why: store can be null if init failed earlier; bail rather than risk a
// throw inside an Electron event listener.
if (!store) {
return
}
const onboarding = store.getOnboarding()
if (onboarding.closedAt !== null) {
triggerStartupNotificationRegistration(store)
}
})
app.on('activate', () => {
// Don't re-open a window while Squirrel's ShipIt is replacing the .app
// bundle. Without this guard the old version gets resurrected and the
// update never applies.
if (BrowserWindow.getAllWindows().length === 0 && !isQuittingForUpdate()) {
openMainWindow()
}
})
})
app.on('before-quit', () => {
isQuitting = true
unsubscribeSystemResumeBroadcast?.()
unsubscribeSystemResumeBroadcast = null
unsubscribeAgentAwakeStatusChanges?.()
unsubscribeAgentAwakeStatusChanges = null
agentAwakeService?.dispose()
agentAwakeService = null
// Why: PTY cleanup is deferred to will-quit so the renderer has a chance to
// capture terminal scrollback buffers before PTY exit events race in and
// unmount TerminalPane components (removing their capture callbacks).
// The window close handler passes isQuitting to the renderer so it skips the
// child-process confirmation dialog and proceeds directly to buffer capture.
rateLimits?.stop()
})
// Why: will-quit fires twice when daemon disconnect needs an async flush.
// First pass: run all sync cleanup, then preventDefault to await the final
// checkpoint writes. Second pass (after disconnect resolves): skip the
// async work and let Electron exit.
let daemonDisconnectDone = false
app.on('will-quit', (e) => {
// Why: before-quit can still be aborted by renderer beforeunload; wait until
// the committed quit path before removing the Windows notification icon.
destroySystemTray()
// Why: stats.flush() must run before killAllPty() so it can read the
// live agent state and emit synthetic agent_stop events for agents that
// are still running. killAllPty() does not call runtime.onPtyExit(),
// so without this ordering, running agents would produce orphaned
// agent_start events with no matching stops.
starNag?.stop()
automations?.stop()
setUnreadDockBadgeCount(0)
agentHookServer.stop()
stats?.flush()
// Why: agent-browser daemon processes would otherwise linger after Orca quits,
// holding ports and leaving stale session state on disk.
runtime?.getAgentBrowserBridge()?.destroyAllSessions()
// Why: headless offscreen browser windows are main-process owned; tear them
// down explicitly on quit alongside the other browser/session shutdowns.
runtime?.getOffscreenBrowserBackend()?.destroyAll?.()
const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve()
serveSimStateWatcher.stop()
killAllPty()
const watcherShutdown = shutdownWatchersOnce()
store?.flush()
// Why: disconnectDaemon writes final checkpoints via async getSnapshot RPCs.
// Without preventDefault, Electron exits before the RPCs complete and the
// checkpoint data is lost. The guard prevents an infinite quit loop —
// app.quit() re-fires will-quit, but the second pass skips straight through.
if (!daemonDisconnectDone) {
e.preventDefault()
// Why: capture ownership synchronously (before any await) so the guard
// still has the right pid/runtimeId to compare against if shutdown
// partially clears global state. Evaluating these inside .then() would
// let a later teardown path null them out mid-chain.
const ownedPid = process.pid
const ownedRuntimeId = runtime?.getRuntimeId()
// Why: the construction of rpcStopAndClear AND the allSettled() below must
// both live inside the `!daemonDisconnectDone` guard. will-quit re-fires
// after app.quit() below; without this guard, the second pass would
// re-invoke runtimeRpc.stop() (redundant rmSync on an already-removed
// socket) and re-run the ownership-guarded clear against a metadata file
// that may now belong to the auto-updater's replacement process.
const rpcStopAndClear = runtimeRpc
? runtimeRpc
.stop()
.then(() => awaitRuntimeFileWatcherUnsubscribes())
.then(() => {
if (ownedRuntimeId) {
// Why: must match the path the runtime server wrote metadata to
// (getCanonicalUserDataPath), not late app.getPath('userData').
clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId)
}
})
.catch((error) => {
console.error('[runtime] Failed to stop local RPC transport:', error)
})
: Promise.resolve()
// Why: Promise.allSettled — we need BOTH the daemon disconnect and the
// RPC stop + owned-metadata clear to complete before Electron exits.
// Using allSettled (not all) preserves the existing fail-open posture:
// if disconnectDaemon rejects, we still quit instead of hanging the app.
//
// Telemetry shutdown folds in after the daemon/RPC teardown and BEFORE
// app.quit(): the PostHog client has up to 2s of bounded flush. Errors
// inside `shutdownTelemetry()` are caught by the client itself — we
// catch again here defensively so a flush failure cannot cancel the
// quit chain.
// Why: normal quits preserve the detached daemon for warm reattach, but a
// dev parent dying means the temp/dev profile has no owner left to reattach.
const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon()
Promise.allSettled([daemonTeardown, rpcStopAndClear, watcherShutdown, emulatorShutdown])
.then(() => shutdownTelemetry())
.then(() => shutdownObservability())
.catch(() => {
/* swallow — telemetry must never prevent app.quit() */
})
.then(() => {
daemonDisconnectDone = true
app.quit()
})
}
})
app.on('window-all-closed', () => {
// Why: headless `orca serve` has no desktop window, and offscreen browser
// windows are disposable implementation details. Closing/crashing the last
// one must not take down terminal/runtime RPC for the VM workspace — the
// policy fn returns false for serve mode so the app stays alive.
//
// Why: on macOS, closing all windows normally keeps the app alive (dock
// stays active). But when a quit is in progress (Cmd+Q), the window close
// handler defers to the renderer for buffer capture, which cancels the
// original quit sequence. Re-trigger quit here so the app actually exits
// instead of requiring a second Cmd+Q.
if (
shouldQuitWhenAllWindowsClosed({
platform: process.platform,
isQuitting,
isServeMode
})
) {
app.quit()
}
})