11import { existsSync } from "node:fs" ;
2+ import { homedir } from "node:os" ;
23import { join } from "node:path" ;
34import { fileURLToPath } from "node:url" ;
45import {
@@ -18,6 +19,7 @@ import updater from "electron-updater";
1819const { autoUpdater } = updater ;
1920type UpdateInfo = { readonly version : string } ;
2021import {
22+ attachToSupervisedDaemon ,
2123 startSidecar ,
2224 stopSidecar ,
2325 onUnexpectedSidecarExit ,
@@ -33,6 +35,12 @@ import {
3335 reportAProblem ,
3436} from "./diagnostics" ;
3537import { sidecarCrashHtml } from "./crash-screen" ;
38+ import {
39+ installSupervisedService ,
40+ restartSupervisedService ,
41+ supervisedServiceStatus ,
42+ uninstallSupervisedService ,
43+ } from "./service" ;
3644import { announceBackup , confirmResetState , resetExecutorState } from "./reset-state" ;
3745import {
3846 getServerProfiles ,
@@ -100,6 +108,124 @@ const ensureSingleInstance = () => {
100108 return true ;
101109} ;
102110
111+ /**
112+ * Stop the local server only when WE own it. A supervised daemon (launchd/etc.)
113+ * outlives this app by design — quitting, restarting the window, or resetting
114+ * state must never kill it. Spawned sidecars (`child` set) are stopped as before.
115+ */
116+ const stopConnection = async ( conn : SidecarConnection ) : Promise < void > => {
117+ if ( conn . supervisedDaemon || ! conn . child ) return ;
118+ await stopSidecar ( conn . child ) ;
119+ } ;
120+
121+ // The supervised daemon (and the desktop sidecar) own this data dir — the same
122+ // path the CLI's `executor web`/daemon uses, so desktop and CLI share state.
123+ const DESKTOP_DATA_DIR = join ( homedir ( ) , ".executor" ) ;
124+
125+ const delay = ( ms : number ) : Promise < void > => new Promise ( ( r ) => setTimeout ( r , ms ) ) ;
126+
127+ /** Poll for a reachable supervised daemon until the deadline. */
128+ const waitForSupervisedAttach = async ( timeoutMs : number ) : Promise < SidecarConnection | null > => {
129+ const deadline = Date . now ( ) + timeoutMs ;
130+ for ( ; ; ) {
131+ const attached = await attachToSupervisedDaemon ( ) ;
132+ if ( attached ) return attached ;
133+ if ( Date . now ( ) >= deadline ) return null ;
134+ await delay ( 300 ) ;
135+ }
136+ } ;
137+
138+ const confirmEnableBackgroundService = async ( ) : Promise < boolean > => {
139+ const { response } = await dialog . showMessageBox ( {
140+ type : "question" ,
141+ title : "Keep Executor running in the background?" ,
142+ message : "Keep your connections available after you quit Executor?" ,
143+ detail :
144+ "Executor can run as a lightweight background service so your MCP tools keep working after you close this window or restart your Mac. You can turn this off anytime in Settings. It will appear under System Settings → General → Login Items." ,
145+ buttons : [ "Keep running in the background" , "Not now" ] ,
146+ defaultId : 0 ,
147+ cancelId : 1 ,
148+ } ) ;
149+ return response === 0 ;
150+ } ;
151+
152+ /**
153+ * Resolve a connection to the OS-supervised daemon, installing it on first run
154+ * (with consent). Returns null when supervision is unavailable or the user
155+ * declined — the caller then falls back to managed-spawn.
156+ */
157+ const ensureSupervisedConnection = async ( ) : Promise < SidecarConnection | null > => {
158+ // 1. Already running → attach.
159+ const attached = await attachToSupervisedDaemon ( ) ;
160+ if ( attached ) return attached ;
161+
162+ const status = await supervisedServiceStatus ( ) ;
163+ if ( ! status . supported ) return null ;
164+
165+ // 2. Registered but not currently serving → kick it and wait.
166+ if ( status . registered ) {
167+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a restart failure just falls through to managed-spawn
168+ try {
169+ await restartSupervisedService ( ) ;
170+ } catch ( error ) {
171+ log . warn ( "Failed to kickstart supervised service" , error ) ;
172+ }
173+ return waitForSupervisedAttach ( 15_000 ) ;
174+ }
175+
176+ // 3. First run → ask, then install + start.
177+ if ( ! ( await confirmEnableBackgroundService ( ) ) ) return null ;
178+ const settings = getServerSettings ( ) ;
179+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: install failure falls back to managed-spawn so the app still launches
180+ try {
181+ await installSupervisedService ( {
182+ port : settings . port ,
183+ password : settings . requireAuth ? settings . password : null ,
184+ dataDir : DESKTOP_DATA_DIR ,
185+ } ) ;
186+ } catch ( error ) {
187+ log . error ( "Failed to install supervised service; using managed sidecar" , error ) ;
188+ return null ;
189+ }
190+ return waitForSupervisedAttach ( 15_000 ) ;
191+ } ;
192+
193+ // Crash monitor for the supervised daemon: launchd restarts it on crash, but
194+ // during that ~1s window the window's requests fail. Poll, show a reconnecting
195+ // overlay while it's down, and reload once it's back.
196+ let supervisedMonitorTimer : ReturnType < typeof setInterval > | null = null ;
197+ let supervisedDaemonDown = false ;
198+
199+ const stopSupervisedMonitor = ( ) => {
200+ if ( supervisedMonitorTimer ) clearInterval ( supervisedMonitorTimer ) ;
201+ supervisedMonitorTimer = null ;
202+ supervisedDaemonDown = false ;
203+ } ;
204+
205+ const armSupervisedMonitor = ( ) => {
206+ stopSupervisedMonitor ( ) ;
207+ supervisedMonitorTimer = setInterval ( ( ) => {
208+ void ( async ( ) => {
209+ const live = await attachToSupervisedDaemon ( ) ;
210+ const window = liveMainWindow ( ) ;
211+ if ( ! live ) {
212+ if ( ! supervisedDaemonDown && window ) {
213+ supervisedDaemonDown = true ;
214+ const html = sidecarCrashHtml ( { reported : errorReportingEnabled } ) ;
215+ void window . loadURL ( `data:text/html;charset=utf-8,${ encodeURIComponent ( html ) } ` ) ;
216+ }
217+ return ;
218+ }
219+ if ( supervisedDaemonDown ) {
220+ supervisedDaemonDown = false ;
221+ connection = live ;
222+ installBasicAuthHeader ( live . baseUrl , live . authPassword ) ;
223+ if ( window ) void window . loadURL ( live . baseUrl ) ;
224+ }
225+ } ) ( ) ;
226+ } , 10_000 ) ;
227+ } ;
228+
103229const installBasicAuthHeader = ( origin : string , password : string | null ) => {
104230 authHeaderUnsubscribe ?.( ) ;
105231 authHeaderUnsubscribe = null ;
@@ -275,8 +401,15 @@ const startWithCurrentSettings = async (): Promise<SidecarConnection | null> =>
275401} ;
276402
277403const restartSidecarAndReload = async ( ) : Promise < DesktopServerConnection > => {
404+ // A supervised daemon isn't ours to restart — just reload the window against
405+ // the same endpoint instead of tearing down a process we don't own.
406+ if ( connection ?. supervisedDaemon ) {
407+ const window = liveMainWindow ( ) ;
408+ if ( window ) await window . loadURL ( connection . baseUrl ) ;
409+ return toDesktopServerConnection ( connection ) ;
410+ }
278411 if ( connection ) {
279- await stopSidecar ( connection . child ) ;
412+ await stopConnection ( connection ) ;
280413 connection = null ;
281414 }
282415 const next = await startWithCurrentSettings ( ) ;
@@ -320,7 +453,58 @@ const registerIpcHandlers = () => {
320453 ) ;
321454 ipcMain . handle (
322455 "executor:settings:regenerate-password" ,
323- ( ) : DesktopServerSettings => regeneratePassword ( ) ,
456+ async ( ) : Promise < DesktopServerSettings > => {
457+ const settings = regeneratePassword ( ) ;
458+ // When supervised, the daemon reads its password from service.key —
459+ // reinstall to rewrite the key + restart, then re-point the window at the
460+ // refreshed credential so the in-app console keeps authenticating.
461+ if ( connection ?. supervisedDaemon ) {
462+ await installSupervisedService ( {
463+ port : settings . port ,
464+ password : settings . requireAuth ? settings . password : null ,
465+ dataDir : DESKTOP_DATA_DIR ,
466+ } ) ;
467+ const next = await waitForSupervisedAttach ( 15_000 ) ;
468+ if ( next ) {
469+ connection = next ;
470+ installBasicAuthHeader ( next . baseUrl , next . authPassword ) ;
471+ const window = liveMainWindow ( ) ;
472+ if ( window ) await window . loadURL ( next . baseUrl ) ;
473+ }
474+ }
475+ return settings ;
476+ } ,
477+ ) ;
478+ // Background-service control surface (macOS) — lets a Settings toggle enable
479+ // or disable the supervised daemon. Disabling tears down the service and
480+ // falls back to a managed sidecar on next launch.
481+ ipcMain . handle ( "executor:service:status" , ( ) => supervisedServiceStatus ( ) ) ;
482+ ipcMain . handle (
483+ "executor:service:set-enabled" ,
484+ async ( _evt , enabled : unknown ) : Promise < boolean > => {
485+ if ( typeof enabled !== "boolean" ) return false ;
486+ if ( enabled ) {
487+ const settings = getServerSettings ( ) ;
488+ await installSupervisedService ( {
489+ port : settings . port ,
490+ password : settings . requireAuth ? settings . password : null ,
491+ dataDir : DESKTOP_DATA_DIR ,
492+ } ) ;
493+ const next = await waitForSupervisedAttach ( 15_000 ) ;
494+ if ( next ) {
495+ if ( connection && ! connection . supervisedDaemon ) await stopConnection ( connection ) ;
496+ connection = next ;
497+ armSupervisedMonitor ( ) ;
498+ installBasicAuthHeader ( next . baseUrl , next . authPassword ) ;
499+ const window = liveMainWindow ( ) ;
500+ if ( window ) await window . loadURL ( next . baseUrl ) ;
501+ }
502+ return true ;
503+ }
504+ stopSupervisedMonitor ( ) ;
505+ await uninstallSupervisedService ( DESKTOP_DATA_DIR ) ;
506+ return true ;
507+ } ,
324508 ) ;
325509 ipcMain . handle ( "executor:server-profiles:get" , ( ) : string | null => getServerProfiles ( ) ) ;
326510 ipcMain . handle ( "executor:server-profiles:set" , ( _evt , value : unknown ) : void => {
@@ -340,7 +524,7 @@ const registerIpcHandlers = () => {
340524 ipcMain . handle ( "executor:state:reset" , async ( ) : Promise < boolean > => {
341525 if ( ! ( await confirmResetState ( ) ) ) return false ;
342526 if ( connection ) {
343- await stopSidecar ( connection . child ) ;
527+ await stopConnection ( connection ) ;
344528 connection = null ;
345529 }
346530 const { backupDir } = resetExecutorState ( ) ;
@@ -395,9 +579,10 @@ const promptInstallUpdate = async (version: string) => {
395579 cancelId : 1 ,
396580 } ) ;
397581 if ( response . response === 0 ) {
398- // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle.
582+ // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. A
583+ // supervised daemon is left running — it's independent of this bundle.
399584 if ( connection ) {
400- await stopSidecar ( connection . child ) ;
585+ await stopConnection ( connection ) ;
401586 connection = null ;
402587 }
403588 autoUpdater . quitAndInstall ( false , true ) ;
@@ -570,6 +755,21 @@ const boot = async () => {
570755 // self-heal as the fatal startup path).
571756 void runUpdateCheck ( { alertOnFail : false } ) ;
572757 } ) ;
758+ // Prefer an OS-supervised daemon: attach to one that's running, kick one
759+ // that's installed, or offer to install on first run. Quitting the app then
760+ // leaves MCP serving. This is also the clean handoff that replaces the old
761+ // "another server owns the data dir → fatal error" path. Packaged macOS only;
762+ // dev and unsupported platforms keep managed-spawn.
763+ if ( app . isPackaged ) {
764+ const supervised = await ensureSupervisedConnection ( ) ;
765+ if ( supervised ) {
766+ connection = supervised ;
767+ await createWindow ( supervised ) ; // installs the Basic-auth header itself
768+ armSupervisedMonitor ( ) ;
769+ void runUpdateCheck ( { alertOnFail : false } ) ;
770+ return ;
771+ }
772+ }
573773 connection = await startWithCurrentSettings ( ) ;
574774 if ( ! connection && lastSidecarStartError != null ) {
575775 // Port conflicts already showed their dialog inside
@@ -610,8 +810,14 @@ if (ensureSingleInstance()) {
610810
611811 app . on ( "before-quit" , async ( event ) => {
612812 if ( ! connection ) return ;
813+ // A supervised daemon must keep serving after the app quits — don't stop it,
814+ // and don't block the quit on teardown we don't need to do.
815+ if ( connection . supervisedDaemon ) {
816+ connection = null ;
817+ return ;
818+ }
613819 event . preventDefault ( ) ;
614- await stopSidecar ( connection . child ) ;
820+ await stopConnection ( connection ) ;
615821 connection = null ;
616822 app . exit ( 0 ) ;
617823 } ) ;
0 commit comments