Skip to content

Commit 3e1a78b

Browse files
committed
Desktop: attach to the supervised daemon instead of a private sidecar
The Electron app becomes a thin client of the OS-supervised gateway: - On packaged macOS launch it attaches to a running supervised daemon, or (with a one-time consent prompt) registers one by pointing a launchd LaunchAgent at the bundled sidecar binary in supervised mode. Falls back to the existing managed sidecar everywhere else. - Quitting the app, restarting the window, updating, or resetting state no longer stops a supervised daemon -- it keeps serving MCP. The old 'another server owns the data dir -> fatal dialog' path becomes a clean attach. - The sidecar entry, when supervised, self-writes the discovery manifest and reads its password from service.key. - A crash monitor shows a reconnecting overlay while launchd restarts the daemon; regenerating the password reinstalls + re-points the window.
1 parent 6b0d8d1 commit 3e1a78b

4 files changed

Lines changed: 621 additions & 9 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 212 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { existsSync } from "node:fs";
2+
import { homedir } from "node:os";
23
import { join } from "node:path";
34
import { fileURLToPath } from "node:url";
45
import {
@@ -18,6 +19,7 @@ import updater from "electron-updater";
1819
const { autoUpdater } = updater;
1920
type UpdateInfo = { readonly version: string };
2021
import {
22+
attachToSupervisedDaemon,
2123
startSidecar,
2224
stopSidecar,
2325
onUnexpectedSidecarExit,
@@ -33,6 +35,12 @@ import {
3335
reportAProblem,
3436
} from "./diagnostics";
3537
import { sidecarCrashHtml } from "./crash-screen";
38+
import {
39+
installSupervisedService,
40+
restartSupervisedService,
41+
supervisedServiceStatus,
42+
uninstallSupervisedService,
43+
} from "./service";
3644
import { announceBackup, confirmResetState, resetExecutorState } from "./reset-state";
3745
import {
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+
103229
const installBasicAuthHeader = (origin: string, password: string | null) => {
104230
authHeaderUnsubscribe?.();
105231
authHeaderUnsubscribe = null;
@@ -275,8 +401,15 @@ const startWithCurrentSettings = async (): Promise<SidecarConnection | null> =>
275401
};
276402

277403
const 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

Comments
 (0)