orca/src/main/index.ts

1115 lines
46 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 { grantDirAcl } from './win32-utils'
import { existsSync } from 'fs'
import { join } from 'path'
import os from 'node:os'
import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron'
import { electronApp, is } from '@electron-toolkit/utils'
import * as QRCode from 'qrcode'
import devIcon from '../../resources/icon-dev.png?asset'
import { Store, initDataPath } from './persistence'
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 } from './daemon/daemon-init'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { registerCoreHandlers } from './ipc/register-core-handlers'
import { registerMobileHandlers } from './ipc/mobile'
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce } from './telemetry/client'
import { runManagedHookInstallers } from './agent-hooks/install-telemetry'
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 { registerAppMenu, rebuildAppMenu } from './menu/register-app-menu'
import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater'
import {
configureDevUserDataPath,
enableMainProcessGpuFeatures,
installDevParentDisconnectQuit,
installDevParentWatchdog,
installUncaughtPipeErrorGuard,
patchPackagedProcessPath
} from './startup/configure-process'
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 } from './startup/single-instance-lock'
import { RateLimitService } from './rate-limits/service'
import { attachMainWindowServices } from './window/attach-main-window-services'
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
import { CodexAccountService } from './codex-accounts/service'
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
import { ClaudeAccountService } from './claude-accounts/service'
import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service'
import { StarNagService } from './star-nag/service'
import { agentHookServer } from './agent-hooks/server'
import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state'
import { claudeHookService } from './claude/hook-service'
import { codexHookService } from './codex/hook-service'
import { geminiHookService } from './gemini/hook-service'
import { cursorHookService } from './cursor/hook-service'
import { droidHookService } from './droid/hook-service'
import { grokHookService } from './grok/hook-service'
import { copilotHookService } from './copilot/hook-service'
import { hermesHookService } from './hermes/hook-service'
import {
getPtyIdForPaneKey,
registerPaneKeyTeardownListener,
getLocalPtyProvider,
registerHeadlessPtyRuntime
} from './ipc/pty'
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
import { browserManager } from './browser/browser-manager'
import { setUnreadDockBadgeCount } from './dock/unread-badge'
import { registerFeatureWallFirstAgentTour } from './feature-wall/first-agent-tour'
import { AutomationService } from './automations/service'
import { AgentAwakeService } from './agent-awake-service'
import {
getCrashBreadcrumbSnapshot,
recordCrashBreadcrumb
} from './crash-reporting/crash-breadcrumb-store'
import { CrashReportStore } from './crash-reporting/crash-report-store'
import { isCrashReportReason } from '../shared/crash-reporting'
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
let starNag: StarNagService | null = null
let agentAwakeService: AgentAwakeService | null = null
let crashReports: CrashReportStore | null = null
let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null
let disposeFeatureWallFirstAgentTour: (() => void) | null = null
let watcherShutdownPromise: Promise<void> | null = null
let watcherShutdownDone = false
let automations: AutomationService | null = null
const isServeMode = process.argv.includes('--serve')
const devInstanceIdentity = getDevInstanceIdentity(is.dev)
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)
function focusExistingWindow(): void {
// Why: the second-instance event fires on the *primary* Electron process
// after another launch tries (and fails) to acquire the lock. Bring the
// existing window forward so the user sees the same focus behaviour as
// re-clicking the dock/taskbar icon, rather than a silent no-op.
//
// Why show() as well as restore() + focus(): isMinimized() only covers the
// dock-minimised case. A hidden window (close-to-tray on macOS via Cmd+W,
// or a window on a different macOS Space) is NOT minimised, so focus()
// alone is a silent no-op. show() handles those plus Windows taskbar
// focus-steal, which focus() alone does not reliably trigger.
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
if (!mainWindow.isVisible()) {
mainWindow.show()
}
mainWindow.focus()
}
// Pre-window case: the primary is still booting and will call
// openMainWindow() from whenReady(). No action needed here.
}
// 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` and
// `endpoint.env` may race (the bundled `orca-dev` CLI / agent hooks route
// to whichever instance wrote last). The dev build is not used for real
// agent work, so that routing ambiguity is acceptable. Packaged Orca keeps
// the lock to protect against the corruption documented in PR #1326 /
// issue #1312.
const hasSingleInstanceLock =
is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow)
if (!hasSingleInstanceLock) {
if (is.dev) {
// Why: packaged runs have no attached console, but dev runs do. Emit a
// single line so a `pnpm dev` operator does not mistake a silent exit
// for a broken launcher.
console.log(
'[single-instance] Another Orca instance is already running against this userData path — focusing existing window.'
)
}
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)
// 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()
// 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
})
enableMainProcessGpuFeatures()
}
function openMainWindow(): BrowserWindow {
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'
)
}
// Why: Chromium's BrowserWindow constructor resets the userData DACL to a
// Protected DACL. Grant explicit Full Control ACEs on all existing children
// before the constructor runs so they survive the upcoming DACL reset.
// Per-write EPERM retries in fs-utils/installer-utils serve as the backstop
// for any directories created after startup.
if (process.platform === 'win32') {
try {
grantDirAcl(app.getPath('userData'), { recursive: true })
} catch {
// Non-fatal; per-call retries are the backstop.
}
}
const window = createMainWindow(store, {
getIsQuitting: () => isQuitting,
onQuitAborted: () => {
isQuitting = false
},
onRendererProcessGone: (details) => {
recordProcessGoneCrash('renderer', 'renderer', details.reason, details.exitCode ?? null, {
processType: 'renderer'
})
},
shouldRecoverRenderer: () => !isQuitting && !isQuittingForUpdate(),
deferLoad: true,
title: devInstanceIdentity.name
})
recordCrashBreadcrumb('main_window_created')
// 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 onFirstWindowLoad = (): void => {
recordCrashBreadcrumb('main_window_loaded')
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,
window.webContents.id,
automations,
{
prepareForCodexLaunch: () =>
store!.getSettings().activeCodexManagedAccountId
? codexRuntimeHome!.prepareForCodexLaunch()
: null,
prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch()
},
agentAwakeService ?? undefined,
crashReports ?? undefined
)
automations.setWebContents(window.webContents)
automations.start()
attachMainWindowServices(
window,
store,
runtime,
() =>
store!.getSettings().activeCodexManagedAccountId
? codexRuntimeHome!.prepareForCodexLaunch()
: null,
() => claudeRuntimeAuth!.prepareForClaudeLaunch()
)
rateLimits.attach(window)
rateLimits.start()
window.on('closed', () => {
if (mainWindow === window) {
mainWindow = null
}
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)
setMigrationUnsupportedPtyListener(null)
// Why: any running synthesized-title spinner intervals would fire into a
// destroyed webContents; stop them all here instead of deferring to
// per-pane teardown, which may never run for restored-but-never-torn-down
// panes when the window goes away. stopSyntheticTitleSpinner deletes only
// the current entry, which the Map iterator handles safely.
for (const paneKey of syntheticTitleSpinnerByPaneKey.keys()) {
stopSyntheticTitleSpinner(paneKey)
}
})
mainWindow = window
agentHookServer.setListener(
({ paneKey, tabId, worktreeId, connectionId, payload, receivedAt, stateStartedAt }) => {
if (mainWindow?.isDestroyed()) {
return
}
mainWindow?.webContents.send('agentStatus:set', {
...payload,
paneKey,
tabId,
worktreeId,
connectionId,
receivedAt,
stateStartedAt
})
recordCrashBreadcrumb('agent_state_changed', {
agentType: payload.agentType ?? 'unknown',
state: payload.state
})
// Why: cursor-agent's OSC title stays "Cursor Agent" for the whole turn,
// and opencode's stays bare "OpenCode" — neither carries a working/idle
// signal the title heuristic can read. Synthesize an OSC title update
// from the hook state and inject it into the pane's data stream so the
// existing renderer-side title tracker (which drives the sidebar
// spinner, unread badge, and worktree status dot for every other agent)
// lights up for these panes too. Braille prefix → working keyword path;
// "action required" → permission; bare label → idle.
const profile = SYNTHETIC_TITLE_PROFILES[payload.agentType ?? '']
if (profile) {
driveSyntheticTitleFromHook(paneKey, payload.state, profile)
}
}
)
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
})
}
})
loadMainWindow(window)
return window
}
function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void {
const webContents =
targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents
webContents?.send('ui:openFeatureTour')
}
function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void {
const webContents =
targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents
webContents?.send('ui:openCrashReport')
}
const recentCrashKeys = new Map<string, number>()
function recordProcessGoneCrash(
source: 'renderer' | 'child',
processType: string,
reason: string,
exitCode: number | null,
details: Record<string, unknown>
): void {
if (!crashReports || !isCrashReportReason(reason)) {
return
}
const key = `${processType}:${reason}:${exitCode ?? 'null'}`
const now = Date.now()
if (now - (recentCrashKeys.get(key) ?? 0) < 2_000) {
return
}
recentCrashKeys.set(key, now)
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,
// 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 = closeAllWatchers()
.catch((error) => {
console.error('[filesystem-watcher] shutdown failed:', error)
})
.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
// Why: per-agent labels for the synthesized titles. The detector classifies
// these labels via `containsAgentName` + spinner/keyword rules, so the chosen
// strings must round-trip through detectAgentStatusFromTitle to the right
// status. See agent-status.test.ts for the pinned classifications.
type SyntheticTitleProfile = {
workingLabel: string
permissionLabel: string
idleLabel: string
}
const SYNTHETIC_TITLE_PROFILES: Record<string, SyntheticTitleProfile> = {
cursor: {
workingLabel: 'Cursor Agent',
permissionLabel: 'Cursor - action required',
idleLabel: 'Cursor ready'
},
opencode: {
workingLabel: 'OpenCode',
permissionLabel: 'OpenCode - action required',
idleLabel: 'OpenCode ready'
},
droid: {
workingLabel: 'Droid',
permissionLabel: 'Droid - action required',
idleLabel: 'Droid ready'
},
hermes: {
workingLabel: 'Hermes',
permissionLabel: 'Hermes - action required',
idleLabel: 'Hermes ready'
}
}
const syntheticTitleSpinnerByPaneKey = new Map<
string,
{ timer: ReturnType<typeof setInterval>; frame: number; profile: SyntheticTitleProfile }
>()
type ServeOptions = {
json: boolean
wsPort?: number
pairingAddress: string | null
noPairing: boolean
mobilePairing: boolean
}
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')
}
}
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')
}
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.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→ptyId mapping is dropped, so the spinner
// interval would keep firing but sendSyntheticTitle would no-op forever.
// Stop the interval explicitly so the process doesn't carry a timer per dead
// pane.
registerPaneKeyTeardownListener((paneKey) => {
stopSyntheticTitleSpinner(paneKey)
})
function sendSyntheticTitle(ptyId: string, data: string): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
mainWindow.webContents.send('pty:data', { id: ptyId, data })
}
function stopSyntheticTitleSpinner(paneKey: string): void {
const entry = syntheticTitleSpinnerByPaneKey.get(paneKey)
if (entry) {
clearInterval(entry.timer)
syntheticTitleSpinnerByPaneKey.delete(paneKey)
}
}
function driveSyntheticTitleFromHook(
paneKey: string,
state: string,
profile: SyntheticTitleProfile
): 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
}
const timer = setInterval(() => {
const ptyIdNow = getPtyIdForPaneKey(paneKey)
if (!ptyIdNow) {
stopSyntheticTitleSpinner(paneKey)
return
}
const cur = syntheticTitleSpinnerByPaneKey.get(paneKey)
if (!cur) {
return
}
cur.frame = (cur.frame + 1) % SPINNER_FRAMES.length
sendSyntheticTitle(
ptyIdNow,
`\x1b]0;${SPINNER_FRAMES[cur.frame]} ${cur.profile.workingLabel}\x07`
)
}, SPINNER_INTERVAL_MS)
syntheticTitleSpinnerByPaneKey.set(paneKey, { timer, frame, profile })
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
// done/permission frames also carry a trailing BEL (0x07 outside of any OSC
// sequence) so the tab-level unread badge + notification dispatch in
// pty-connection lights up — those key off BEL, not the working→idle title
// transition.
stopSyntheticTitleSpinner(paneKey)
const label =
state === 'blocked' || state === 'waiting' ? profile.permissionLabel : profile.idleLabel
sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07\x07`)
}
app.whenReady().then(async () => {
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
app.setName(devInstanceIdentity.name)
if (process.platform === 'darwin' && is.dev) {
const dockIcon = nativeImage.createFromPath(devIcon)
app.dock?.setIcon(dockIcon)
}
store = new Store()
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: 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(() => codexRuntimeHome!.prepareForRateLimitFetch())
rateLimits.setClaudeAuthPreparationResolver(() => claudeRuntimeAuth!.prepareForRateLimitFetch())
rateLimits.setSettingsResolver(() => store!.getSettings())
rateLimits.setInactiveClaudeAccountsResolver(() => {
const settings = store!.getSettings()
return settings.claudeManagedAccounts
.filter((account) => account.id !== settings.activeClaudeManagedAccountId)
.map((account) => ({ id: account.id, managedAuthPath: account.managedAuthPath }))
})
rateLimits.setInactiveCodexAccountsResolver(() => {
const settings = store!.getSettings()
return settings.codexManagedAccounts
.filter((account) => account.id !== settings.activeCodexManagedAccountId)
.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()
})
runtime = runtimeService
automations = new AutomationService(store, { claudeUsage, codexUsage })
runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
runtimeService.setCommitMessageAgentEnvironmentResolvers({
prepareForCodexLaunch: () =>
store!.getSettings().activeCodexManagedAccountId
? codexRuntimeHome!.prepareForCodexLaunch()
: null,
prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch()
})
disposeFeatureWallFirstAgentTour = registerFeatureWallFirstAgentTour({
stats,
getWindow: () => mainWindow
})
starNag = new StarNagService(store, stats)
starNag.start()
starNag.registerIpcHandlers()
runtimeService.setAgentBrowserBridge(
new AgentBrowserBridge(browserManager, {
onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId)
})
)
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
// Why: managed hook installation mutates user-global agent config. Each
// installer runs inside its own try/catch so a malformed local config
// (e.g. corrupted ~/.claude/settings.json) cannot brick Orca startup.
// The agent label travels with each installer so the catch can attribute
// the failure in the `agent_hook_install_failed` telemetry event.
const managedHookInstallers = [
['claude', () => claudeHookService.install()],
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['cursor', () => cursorHookService.install()],
['droid', () => droidHookService.install()],
['grok', () => grokHookService.install()],
['copilot', () => copilotHookService.install()],
['hermes', () => hermesHookService.install()]
] as const
runManagedHookInstallers(managedHookInstallers)
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
})
})
registerAppMenu({
onCheckForUpdates: (options) => checkForUpdatesFromMenu(options),
onOpenSettings: () => {
recordCrashBreadcrumb('settings_opened')
mainWindow?.webContents.send('ui:openSettings')
},
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()
store.updateSettings({ [key]: !current[key] })
// Why: settings:get returns the current snapshot; renderer tracks
// settings through window.api.settings.get(). Push the new value so
// the sidebar/titlebar re-render without waiting for a round-trip.
mainWindow?.webContents.send('settings:changed', { [key]: !current[key] })
rebuildAppMenu()
},
getAppearanceState: () => {
const settings = store?.getSettings()
const ui = store?.getUI()
return {
showTasksButton: settings?.showTasksButton !== false,
showTitlebarAppName: settings?.showTitlebarAppName !== false,
statusBarVisible: ui?.statusBarVisible !== false
}
}
})
// 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.
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
}
runtimeRpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: app.getPath('userData'),
enableWebSocket: true,
...(isE2E ? { wsPort: 0 } : {}),
...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}),
webClientRoot: getBundledWebClientRoot()
})
registerMobileHandlers(runtimeRpc)
if (!isServeMode) {
await startFirstWindowStartupServices({
// Why: the persistent-terminal daemon is desktop-only. Headless
// `orca serve` registers its PTY runtime below and must not spawn the
// desktop daemon or hook loopback listener.
startDaemonPtyProvider: () => initDaemonPtyProvider(),
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state,
// so the hook server must start before restored terminals can mount.
startAgentHookServer: () =>
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.
userDataPath: app.getPath('userData')
}),
onDaemonError: (error) => {
console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', 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)
}
})
}
if (serveOptions) {
registerHeadlessPtyRuntime(
runtime,
() => codexRuntimeHome!.prepareForCodexLaunch(),
() => store!.getSettings(),
() => claudeRuntimeAuth!.prepareForClaudeLaunch(),
store
)
// 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()
await printServeReady(serveOptions)
return
}
// Why: once the hook server is ready (or has already failed open), window
// creation and runtime RPC startup are independent.
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
unsubscribeAgentAwakeStatusChanges?.()
unsubscribeAgentAwakeStatusChanges = null
agentAwakeService?.dispose()
agentAwakeService = null
disposeFeatureWallFirstAgentTour?.()
disposeFeatureWallFirstAgentTour = 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: 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()
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) {
clearRuntimeMetadataIfOwned(app.getPath('userData'), 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.
Promise.allSettled([disconnectDaemon(), rpcStopAndClear, watcherShutdown])
.then(() => shutdownTelemetry())
.catch(() => {
/* swallow — telemetry must never prevent app.quit() */
})
.then(() => {
daemonDisconnectDone = true
app.quit()
})
}
})
app.on('window-all-closed', () => {
// 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 (process.platform !== 'darwin' || isQuitting) {
app.quit()
}
})