@amd-gaia/agent-ui 0.25.4-rc.8 → 0.25.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/assets/AdvancedSettings-Dkjf4LIp.js +1 -0
  2. package/dist/assets/ConnectorsPane-CIV89VVI.js +1 -0
  3. package/dist/assets/DocumentLibrary-2IMvlsjP.js +4 -0
  4. package/dist/assets/FileBrowser-DMnjs5do.js +4 -0
  5. package/dist/assets/MemoryDashboard-BAqXtg9o.js +1 -0
  6. package/dist/assets/MobileAccessModal-CrtIKCP3.js +2 -0
  7. package/dist/assets/ScheduleManager-D_KcT6nO.js +1 -0
  8. package/dist/assets/api-D5mTAXjB.js +4 -0
  9. package/dist/assets/index-B7tI0eKR.css +1 -0
  10. package/dist/assets/index-CsgZhHsw.js +80 -0
  11. package/dist/assets/useT-CtECS-vp.js +3 -0
  12. package/dist/assets/{zap-CORjwTA2.js → zap-FkXztfRB.js} +1 -1
  13. package/dist/index.html +4 -4
  14. package/dist/sw.js +1 -1
  15. package/main.cjs +126 -20
  16. package/package.json +1 -1
  17. package/preload.cjs +8 -0
  18. package/services/auto-updater.cjs +26 -3
  19. package/services/backend-supervisor.cjs +150 -0
  20. package/services/port-manager.cjs +3 -0
  21. package/services/quit-guard.cjs +9 -0
  22. package/services/tray-host.cjs +84 -0
  23. package/services/tray-manager.cjs +33 -7
  24. package/dist/assets/AdvancedSettings-DNdcv0E1.js +0 -1
  25. package/dist/assets/ConnectorsPane-DMzaqCee.js +0 -1
  26. package/dist/assets/DocumentLibrary-BP-jP670.js +0 -4
  27. package/dist/assets/FileBrowser-CV8AsCp-.js +0 -4
  28. package/dist/assets/MemoryDashboard-BLpYob9X.js +0 -1
  29. package/dist/assets/MobileAccessModal-t727pxc6.js +0 -2
  30. package/dist/assets/ScheduleManager-Dv9WjtNl.js +0 -1
  31. package/dist/assets/api-BsMIEtSA.js +0 -4
  32. package/dist/assets/index-CNhwlnjv.css +0 -1
  33. package/dist/assets/index-DBKyXg4y.js +0 -78
  34. package/dist/assets/useT-DmcuCJlO.js +0 -3
@@ -1 +1 @@
1
- import{W as e}from"./useT-DmcuCJlO.js";var t={name:`message-square`,size:24,node:[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]]};t.node;var n=e(t),r={name:`zap`,size:24,node:[[`path`,{d:`M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z`,key:`1v7up4`}]]};r.node;var i=e(r);export{n,i as t};
1
+ import{q as e}from"./useT-CtECS-vp.js";var t={name:`message-square`,size:24,node:[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]]};t.node;var n=e(t),r={name:`zap`,size:24,node:[[`path`,{d:`M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z`,key:`1v7up4`}]]};r.node;var i=e(r);export{n,i as t};
package/dist/index.html CHANGED
@@ -12,11 +12,11 @@
12
12
  <meta name="apple-mobile-web-app-capable" content="yes" />
13
13
  <meta name="apple-mobile-web-app-title" content="GAIA" />
14
14
  <title>GAIA Agent UI</title>
15
- <script type="module" crossorigin src="./assets/index-DBKyXg4y.js"></script>
15
+ <script type="module" crossorigin src="./assets/index-CsgZhHsw.js"></script>
16
16
  <link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-XKyaHtl9.js">
17
- <link rel="modulepreload" crossorigin href="./assets/useT-DmcuCJlO.js">
18
- <link rel="modulepreload" crossorigin href="./assets/api-BsMIEtSA.js">
19
- <link rel="stylesheet" crossorigin href="./assets/index-CNhwlnjv.css">
17
+ <link rel="modulepreload" crossorigin href="./assets/useT-CtECS-vp.js">
18
+ <link rel="modulepreload" crossorigin href="./assets/api-D5mTAXjB.js">
19
+ <link rel="stylesheet" crossorigin href="./assets/index-B7tI0eKR.css">
20
20
  </head>
21
21
  <body>
22
22
  <div id="root"></div>
package/dist/sw.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // never sees a stale UI or stale data.
10
10
 
11
11
  const CACHE_PREFIX = 'gaia-assets-';
12
- const CACHE_NAME = CACHE_PREFIX + '0.25.4rc8-9ac42cdd460c7b27';
12
+ const CACHE_NAME = CACHE_PREFIX + '0.25.4-f9c04029eab871f5';
13
13
  const ASSETS_PATH = new URL('assets/', self.registration.scope).pathname;
14
14
 
15
15
  self.addEventListener('install', () => {
package/main.cjs CHANGED
@@ -85,6 +85,7 @@ const _inSafeMode = _crashCount >= SAFE_MODE_THRESHOLD;
85
85
 
86
86
  // Services (loaded after app.whenReady)
87
87
  const TrayManager = require("./services/tray-manager.cjs");
88
+ const { probeTrayHost } = require("./services/tray-host.cjs");
88
89
  const { appIconPath, appUserModelId } = require("./services/app-icon.cjs");
89
90
  const {
90
91
  attachWindowGuards,
@@ -101,6 +102,7 @@ const KeepAwake = require("./services/keep-awake.cjs");
101
102
  const PortManager = require("./services/port-manager.cjs");
102
103
  const { isGaiaBackendProcess } = require("./services/backend-orphan.cjs");
103
104
  const { showBackendCrashDialog } = require("./services/backend-crash-dialog.cjs");
105
+ const { createBackendSupervisor } = require("./services/backend-supervisor.cjs");
104
106
  const { buildIndexQuery, INSTALL_SCREEN_QUERY } = require("./services/index-query.cjs");
105
107
  const backendInstaller = require("./services/backend-installer.cjs");
106
108
  const { createEngineInstall } = require("./services/engine-install.cjs");
@@ -213,6 +215,8 @@ const APP_NAME = "GAIA";
213
215
  // from prior aborted sessions (issue #782 / T5).
214
216
  const DEFAULT_BACKEND_PORT = 4200;
215
217
  const STARTUP_TIMEOUT = 30000;
218
+ // A restart must answer within this, or it counts as a failed try.
219
+ const RESTART_TIMEOUT = 90000;
216
220
 
217
221
  // Parse CLI args (T11: --minimized flag for auto-start)
218
222
  const startMinimized = process.argv.includes("--minimized");
@@ -243,6 +247,8 @@ let backendProcess = null;
243
247
  let backendStarting = null;
244
248
  // Set as cleanup() begins; startup stops at its next step once it is.
245
249
  let shutdownStarted = false;
250
+ // Set once an update install starts quitting the app.
251
+ let updateQuitStarted = false;
246
252
  let backendPort = DEFAULT_BACKEND_PORT;
247
253
  let healthCheckUrl = `http://localhost:${backendPort}/api/health`;
248
254
  let backendStderrTail = [];
@@ -257,6 +263,8 @@ let engineInstall = null;
257
263
 
258
264
  /** @type {TrayManager | null} */
259
265
  let trayManager = null;
266
+ /** Settles once the tray knows whether this desktop shows tray icons. */
267
+ let trayHostReady = null;
260
268
 
261
269
  /** @type {AgentProcessManager | null} */
262
270
  let agentProcessManager = null;
@@ -278,6 +286,11 @@ const portManager = new PortManager({
278
286
  */
279
287
  let isQuitting = false;
280
288
 
289
+ /** Any quit is under way, an update install's included: start no backend. */
290
+ function quitStarted() {
291
+ return shutdownStarted || isQuitting || updateQuitStarted;
292
+ }
293
+
281
294
  // ── Backend Process ────────────────────────────────────────────────────────
282
295
 
283
296
  /**
@@ -386,6 +399,8 @@ async function startBackend() {
386
399
  env: {
387
400
  ...process.env,
388
401
  GAIA_APP_PID: String(process.pid),
402
+ // Piped stdout is block-buffered: a dying backend's last lines would never arrive.
403
+ PYTHONUNBUFFERED: "1",
389
404
  ...(app.isPackaged && {
390
405
  GAIA_BUNDLED_LEMONADE_DIR: path.join(process.resourcesPath, "vendor", "lemonade"),
391
406
  }),
@@ -431,17 +446,16 @@ async function startBackend() {
431
446
  }
432
447
 
433
448
  child.on("exit", (code, signal) => {
434
- if (code !== 0 && code !== null) {
435
- console.error(`Backend exited with code ${code} (signal=${signal})`);
449
+ // Any exit we didn't ask for leaves the window without a backend, even code 0.
450
+ const crashed = !isIntentionalKill && !isQuitting;
451
+ if (crashed) {
452
+ console.error(`Backend exited with code ${code} (signal=${signal}, pid=${child.pid})`);
436
453
  }
437
- const crashed = !isIntentionalKill && code !== 0 && code !== null;
438
- backendProcess = null;
454
+ if (backendProcess === child) backendProcess = null;
439
455
  if (trayManager && !isQuitting) trayManager.setBackendAlive(false);
440
456
 
441
- if (crashed && !isQuitting) {
442
- // Fire-and-forget — don't block the event loop.
443
- void handleBackendCrash(code, signal);
444
- }
457
+ // Once restarts run out, the supervisor's onStopped shows the crash dialog.
458
+ if (crashed) backendSupervisor.crashed({ code, signal });
445
459
 
446
460
  // Remove pidfile on exit to avoid leaving stale PID behind.
447
461
  try {
@@ -458,12 +472,82 @@ async function startBackend() {
458
472
  }
459
473
  });
460
474
 
475
+ // "close" waits for stdio to drain, so the tail holds the backend's last words.
476
+ child.once("close", (code, signal) => {
477
+ if (isIntentionalKill || isQuitting) return;
478
+ const tail = backendStderrTail.length ? backendStderrTail.join("\n") : "(nothing on stderr)";
479
+ console.error(`[main] Backend pid ${child.pid} closed (code=${code}, signal=${signal}). Last stderr lines:\n${tail}`);
480
+ });
481
+
461
482
  return child;
462
483
  }
463
484
 
485
+ /**
486
+ * Start a new backend for the supervisor and resolve once it answers.
487
+ * Rejects when it exits first or never answers, so the supervisor retries.
488
+ */
489
+ async function restartBackend() {
490
+ backendStarting = startBackend();
491
+ const child = await backendStarting;
492
+ if (!child) {
493
+ throw new Error("GAIA's backend program is missing, so it can't restart. Reinstall GAIA.");
494
+ }
495
+ if (quitStarted()) {
496
+ isIntentionalKill = true;
497
+ await portManager.killBackend(child);
498
+ throw new Error("GAIA is quitting, so the restarted backend was stopped.");
499
+ }
500
+ backendProcess = child;
501
+ const alive = () => child.exitCode === null && child.signalCode === null;
502
+ const ready = await waitForBackend(RESTART_TIMEOUT, alive);
503
+ if (!ready) {
504
+ if (!alive()) {
505
+ throw new Error(`The backend exited (code ${child.exitCode}, signal ${child.signalCode}) before it answered.`);
506
+ }
507
+ isIntentionalKill = true;
508
+ await portManager.killBackend(child);
509
+ throw new Error(`The backend did not answer within ${RESTART_TIMEOUT / 1000} s.`);
510
+ }
511
+ console.log(`[main] Backend restarted on port ${backendPort} (pid=${child.pid})`);
512
+ if (trayManager) {
513
+ trayManager.setBackendPort(backendPort);
514
+ trayManager.setBackendAlive(true);
515
+ }
516
+ return { port: backendPort };
517
+ }
518
+
519
+ /** What the renderer needs: the supervisor's state plus the API base to use. */
520
+ function backendStateForRenderer(state) {
521
+ return { ...state, apiBase: state.port ? buildIndexQuery(state.port).api : null };
522
+ }
523
+
524
+ const backendSupervisor = createBackendSupervisor({
525
+ start: restartBackend,
526
+ onStopped: (state) => {
527
+ const { code = null, signal = null } = state.lastExit || {};
528
+ void handleBackendCrash(code, signal);
529
+ },
530
+ onState: (state) => {
531
+ console.log(
532
+ `[main] Backend ${state.status}` +
533
+ (state.status === "restarting" ? ` (try ${state.attempt} of ${state.maxRestarts})` : "") +
534
+ (state.lastError ? `: ${state.lastError}` : "")
535
+ );
536
+ if (mainWindow && !mainWindow.isDestroyed()) {
537
+ mainWindow.webContents.send("backend:state", backendStateForRenderer(state));
538
+ }
539
+ },
540
+ });
541
+
542
+ ipcMain.handle("backend:get-state", () => backendStateForRenderer(backendSupervisor.getState()));
543
+ ipcMain.handle("backend:restart", () => {
544
+ backendSupervisor.restartNow();
545
+ return backendStateForRenderer(backendSupervisor.getState());
546
+ });
547
+
464
548
  /**
465
549
  * Tell the user the backend stopped and offer Restart / Save diagnostics /
466
- * Quit. Invoked from child.on("exit") when the kill was NOT initiated by us.
550
+ * Quit. Invoked by the supervisor's onStopped once automatic restarts run out.
467
551
  */
468
552
  async function handleBackendCrash(code, signal) {
469
553
  autoUpdater.reportStartFailure();
@@ -485,11 +569,11 @@ async function handleBackendCrash(code, signal) {
485
569
  }
486
570
  }
487
571
 
488
- async function waitForBackend(timeoutMs) {
572
+ async function waitForBackend(timeoutMs, isAlive = () => true) {
489
573
  const start = Date.now();
490
574
  const http = require("http");
491
575
 
492
- while (Date.now() - start < timeoutMs) {
576
+ while (Date.now() - start < timeoutMs && isAlive()) {
493
577
  try {
494
578
  await new Promise((resolve, reject) => {
495
579
  const req = http.get(healthCheckUrl, (res) => {
@@ -666,15 +750,20 @@ function createWindow() {
666
750
  // Show window when ready (unless --minimized or startMinimized config)
667
751
  mainWindow.once("ready-to-show", () => {
668
752
  console.log("[main] ready-to-show fired");
669
- const shouldStartMinimized =
670
- startMinimized || (trayManager && trayManager.startMinimized);
671
-
672
- if (!shouldStartMinimized) {
673
- console.log("[main] mainWindow.show() called");
674
- mainWindow.show();
675
- } else {
676
- console.log("[main] Starting minimized to tray");
677
- }
753
+ Promise.resolve(trayHostReady).then(() => {
754
+ if (!mainWindow || mainWindow.isDestroyed()) return;
755
+ // Never start hidden where no tray icon would bring the window back.
756
+ const canHide = Boolean(trayManager && trayManager.trayHost.available);
757
+ const shouldStartMinimized =
758
+ canHide && (startMinimized || trayManager.startMinimized);
759
+
760
+ if (!shouldStartMinimized) {
761
+ console.log("[main] mainWindow.show() called");
762
+ mainWindow.show();
763
+ } else {
764
+ console.log("[main] Starting minimized to tray");
765
+ }
766
+ });
678
767
  });
679
768
 
680
769
  return mainWindow;
@@ -782,6 +871,14 @@ function initializeServices() {
782
871
  reopenWindow: () => reopenWindow(),
783
872
  });
784
873
  trayManager.create();
874
+ trayHostReady = probeTrayHost()
875
+ .then((host) => {
876
+ if (trayManager) trayManager.setHost(host);
877
+ })
878
+ .catch((err) => {
879
+ // The host stays unknown, so closing quits instead of hiding.
880
+ console.error(`[main] Could not record the tray host: ${err.message}`);
881
+ });
785
882
  try {
786
883
  trayManager.syncLoginItem();
787
884
  } catch (err) {
@@ -1197,6 +1294,7 @@ app.whenReady().then(async () => {
1197
1294
  if (shutdownStarted) return;
1198
1295
  backendStarting = startBackend();
1199
1296
  backendProcess = await backendStarting;
1297
+ if (backendProcess) backendSupervisor.running(backendPort);
1200
1298
  if (shutdownStarted) {
1201
1299
  console.log("[main] Quit during startup — not opening the window");
1202
1300
  return;
@@ -1328,6 +1426,7 @@ const quitGuard = installQuitGuard({
1328
1426
  // Mark any subsequent backend exit as intentional so we don't pop the
1329
1427
  // crash dialog during normal shutdown.
1330
1428
  isIntentionalKill = true;
1429
+ backendSupervisor.stop();
1331
1430
  },
1332
1431
  onError: (err) => {
1333
1432
  console.error("[main] Quit check failed:", err);
@@ -1339,12 +1438,19 @@ const quitGuard = installQuitGuard({
1339
1438
  },
1340
1439
  });
1341
1440
 
1441
+ // The installer is already running: a restart now would hold its files open.
1442
+ electronAutoUpdater.on("before-quit-for-update", () => {
1443
+ updateQuitStarted = true;
1444
+ backendSupervisor.stop();
1445
+ });
1446
+
1342
1447
  quitOnTermSignal({ proc: process, app, guard: quitGuard });
1343
1448
 
1344
1449
  installQuitCleanup({ app, cleanup });
1345
1450
 
1346
1451
  async function cleanup() {
1347
1452
  shutdownStarted = true;
1453
+ backendSupervisor.stop();
1348
1454
  const installStopped = engineInstall ? engineInstall.stop() : null;
1349
1455
 
1350
1456
  // Phase F: tear down auto-updater timers and IPC handlers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amd-gaia/agent-ui",
3
- "version": "0.25.4-rc.8",
3
+ "version": "0.25.4",
4
4
  "type": "module",
5
5
  "productName": "GAIA Agent UI",
6
6
  "description": "Chat with the GAIA agent - local and private by default on AMD Ryzen AI, cloud models when you choose them",
package/preload.cjs CHANGED
@@ -16,6 +16,7 @@
16
16
  * system:* — System metrics for the observability dashboard (#2007)
17
17
  * power:* — Keep the PC awake while a chat turn runs (#5226)
18
18
  * dialog:* — The OS folder picker
19
+ * backend:* — Backend restarts after a crash (services/backend-supervisor.cjs)
19
20
  */
20
21
 
21
22
  const { contextBridge, ipcRenderer } = require("electron");
@@ -90,6 +91,13 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
90
91
  dialog: {
91
92
  pickFolder: (defaultPath) => ipcRenderer.invoke("dialog:pick-folder", defaultPath),
92
93
  },
94
+
95
+ // ── GAIA's backend process: restarts after a crash, and its new port ──
96
+ backend: {
97
+ getState: () => ipcRenderer.invoke("backend:get-state"),
98
+ restart: () => ipcRenderer.invoke("backend:restart"),
99
+ onState: (cb) => onEvent("backend:state", cb),
100
+ },
93
101
  });
94
102
 
95
103
  // ── Window frame ─────────────────────────────────────────────────────────
@@ -1705,10 +1705,10 @@ function init(mainWindow, options = {}) {
1705
1705
  isBackendPrefetchedRef = options.isBackendPrefetched;
1706
1706
  }
1707
1707
 
1708
- // Disabled path — short-circuit BEFORE touching any Electron APIs so
1709
- // this works in plain Node tests where require('electron') returns a
1710
- // string and the electron-updater singleton throws on access.
1708
+ // Disabled path — short-circuit before electron-updater, whose singleton
1709
+ // throws on access in plain Node tests.
1711
1710
  if (isDisabled()) {
1711
+ _initDisabled();
1712
1712
  setState({ status: STATES.DISABLED });
1713
1713
  log("info", "Auto-updater disabled via GAIA_DISABLE_UPDATE=1");
1714
1714
  return;
@@ -1844,6 +1844,29 @@ function init(mainWindow, options = {}) {
1844
1844
  );
1845
1845
  }
1846
1846
 
1847
+ /**
1848
+ * Settings still needs the running version and the disabled status, so record
1849
+ * the one and answer the status request. Plain Node's require("electron") is a
1850
+ * path string, so a missing app or ipcMain leaves the updater as it is.
1851
+ */
1852
+ function _initDisabled() {
1853
+ let electron;
1854
+ try {
1855
+ // eslint-disable-next-line global-require
1856
+ electron = require("electron");
1857
+ } catch (err) {
1858
+ log("warn", "Electron unavailable — Settings can't show the running version:", err && err.message);
1859
+ return;
1860
+ }
1861
+ if (!electron || !electron.app || !electron.ipcMain) return;
1862
+ electronApi = { app: electron.app, ipcMain: electron.ipcMain, dialog: electron.dialog, net: electron.net };
1863
+ state.currentVersion = electron.app.getVersion() || null;
1864
+ if (!ipcHandlersRegistered) {
1865
+ electron.ipcMain.handle("gaia:update:get-status", () => getState());
1866
+ ipcHandlersRegistered = true;
1867
+ }
1868
+ }
1869
+
1847
1870
  function _onBeforeQuit(event) {
1848
1871
  _refreshPreferences();
1849
1872
  if (state.status !== STATES.DOWNLOADED || !autoUpdaterRef || !autoUpdaterRef.autoInstallOnAppQuit) {
@@ -0,0 +1,150 @@
1
+ // Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Restarts GAIA's backend when it exits on its own: after a growing wait,
6
+ * at most `maxRestarts` times in `windowMs`, then it stops and waits for the
7
+ * user's Restart GAIA (restartNow). No Electron imports, so it unit-tests.
8
+ *
9
+ * State, as sent to the renderer:
10
+ * status "running" | "restarting" | "stopped"
11
+ * port the backend's port once running
12
+ * since when this outage began (ms epoch), for the banner's timer
13
+ * attempt which try this outage is on
14
+ * nextAttemptAt when the next try starts, or null while one is starting
15
+ * lastExit { code, signal } of the last exit
16
+ * lastError why the last start failed, if it did
17
+ */
18
+
19
+ "use strict";
20
+
21
+ const DEFAULTS = Object.freeze({
22
+ maxRestarts: 3,
23
+ windowMs: 10 * 60_000,
24
+ backoffMs: [1000, 5000, 15000],
25
+ });
26
+
27
+ /**
28
+ * @param {object} deps
29
+ * @param {() => Promise<{port: number}>} deps.start spawns the backend and resolves once it answers
30
+ * @param {(state: object) => void} deps.onState called with every new state
31
+ * @param {(state: object) => void} [deps.onStopped] called once each time restarts run out
32
+ */
33
+ function createBackendSupervisor({
34
+ start,
35
+ onState,
36
+ onStopped = () => {},
37
+ maxRestarts = DEFAULTS.maxRestarts,
38
+ windowMs = DEFAULTS.windowMs,
39
+ backoffMs = DEFAULTS.backoffMs,
40
+ now = Date.now,
41
+ setTimer = setTimeout,
42
+ clearTimer = clearTimeout,
43
+ }) {
44
+ if (!Number.isInteger(maxRestarts) || maxRestarts < 1) {
45
+ throw new Error(`backend supervisor: maxRestarts must be a positive integer, got ${maxRestarts}`);
46
+ }
47
+ if (!Array.isArray(backoffMs) || backoffMs.length === 0) {
48
+ throw new Error("backend supervisor: backoffMs must list at least one delay");
49
+ }
50
+
51
+ // Automatic restarts in the window; a user's Restart GAIA isn't counted.
52
+ let history = [];
53
+ let timer = null;
54
+ let starting = false;
55
+ let stopped = false;
56
+ let state = { status: "running", port: null, since: null, attempt: 0, nextAttemptAt: null, lastExit: null, lastError: null };
57
+
58
+ function emit(next) {
59
+ state = { ...state, ...next, maxRestarts };
60
+ onState({ ...state });
61
+ }
62
+
63
+ function attemptStart() {
64
+ timer = null;
65
+ starting = true;
66
+ emit({ nextAttemptAt: null });
67
+ let started;
68
+ try {
69
+ started = Promise.resolve(start());
70
+ } catch (err) {
71
+ started = Promise.reject(err);
72
+ }
73
+ started.then(
74
+ ({ port }) => {
75
+ starting = false;
76
+ if (stopped) return;
77
+ emit({ status: "running", port, since: null, attempt: 0, lastError: null });
78
+ },
79
+ (err) => {
80
+ starting = false;
81
+ if (stopped) return;
82
+ schedule({ lastError: err && err.message ? err.message : String(err) });
83
+ }
84
+ );
85
+ }
86
+
87
+ function schedule(extra) {
88
+ const t = now();
89
+ history = history.filter((at) => t - at < windowMs);
90
+ const outage = state.status === "running" ? { since: t, attempt: 0 } : {};
91
+ if (history.length >= maxRestarts) {
92
+ const wasStopped = state.status === "stopped";
93
+ emit({ status: "stopped", nextAttemptAt: null, ...outage, ...extra });
94
+ if (!wasStopped) onStopped({ ...state });
95
+ return "stopped";
96
+ }
97
+ const delay = backoffMs[Math.min(history.length, backoffMs.length - 1)];
98
+ history.push(t + delay);
99
+ const attempt = (outage.attempt ?? state.attempt) + 1;
100
+ emit({ ...outage, status: "restarting", attempt, nextAttemptAt: t + delay, ...extra });
101
+ timer = setTimer(attemptStart, delay);
102
+ return "restarting";
103
+ }
104
+
105
+ return {
106
+ /** The backend is up on `port` (the first start, outside the supervisor). */
107
+ running(port) {
108
+ emit({ status: "running", port, since: null, attempt: 0, nextAttemptAt: null, lastError: null });
109
+ },
110
+
111
+ /** The backend exited on its own. Returns what happens next. */
112
+ crashed({ code = null, signal = null } = {}) {
113
+ if (stopped) return "stopped";
114
+ // An exit during a start is that start's failure, reported by start() itself;
115
+ // keep its exit so the crash dialog names the latest one.
116
+ if (starting) {
117
+ state = { ...state, lastExit: { code, signal } };
118
+ return state.status;
119
+ }
120
+ if (timer) return state.status;
121
+ return schedule({ lastExit: { code, signal } });
122
+ },
123
+
124
+ /** The user's Restart GAIA: start now, with a fresh budget. */
125
+ restartNow() {
126
+ if (stopped || starting) return;
127
+ if (timer) {
128
+ clearTimer(timer);
129
+ timer = null;
130
+ }
131
+ history = [];
132
+ if (state.status === "running") return;
133
+ emit({ status: "restarting", attempt: 1 });
134
+ attemptStart();
135
+ },
136
+
137
+ /** GAIA is quitting: no more restarts. */
138
+ stop() {
139
+ stopped = true;
140
+ if (timer) clearTimer(timer);
141
+ timer = null;
142
+ },
143
+
144
+ getState() {
145
+ return { ...state, maxRestarts };
146
+ },
147
+ };
148
+ }
149
+
150
+ module.exports = { createBackendSupervisor, DEFAULTS };
@@ -111,6 +111,9 @@ async function writeDiagnosticsBundle(destPath, logger) {
111
111
  "gaia.log",
112
112
  "electron-main.log",
113
113
  "electron-install-state.json",
114
+ // Fatal-crash stacks from the backend (src/gaia/ui/fault_log.py), and its rotated copy.
115
+ "logs/backend-faults.log",
116
+ "logs/backend-faults.log.1",
114
117
  ];
115
118
 
116
119
  const present = candidates.filter((rel) =>
@@ -242,6 +242,7 @@ function attachWindowGuards(win, { app, guard, getTray, isQuitting, platform, re
242
242
  *
243
243
  * @param {{proc: NodeJS.Process, app: Electron.App,
244
244
  * guard: ReturnType<typeof installQuitGuard>, logger?: {log: Function}}} deps
245
+ * @returns {Promise<void>} Settles once the handler is back on top of Electron's.
245
246
  */
246
247
  function quitOnTermSignal({ proc, app, guard, logger = console }) {
247
248
  proc.on("SIGTERM", () => {
@@ -249,6 +250,14 @@ function quitOnTermSignal({ proc, app, guard, logger = console }) {
249
250
  guard.allow();
250
251
  app.quit();
251
252
  });
253
+ // Electron installs its own SIGTERM handler after the main script runs, and
254
+ // it replaces Node's, so the listener above never fires. Re-adding every
255
+ // listener once ready makes Node install its handler again, over Electron's.
256
+ return app.whenReady().then(() => {
257
+ const listeners = proc.listeners("SIGTERM");
258
+ proc.removeAllListeners("SIGTERM");
259
+ for (const listener of listeners) proc.on("SIGTERM", listener);
260
+ });
252
261
  }
253
262
 
254
263
  module.exports = {
@@ -0,0 +1,84 @@
1
+ // Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Whether this desktop can show GAIA's tray icon at all.
6
+ *
7
+ * Windows and macOS always can. On Linux, Electron's tray is a
8
+ * StatusNotifierItem, which shows only when a host owns the
9
+ * `org.kde.StatusNotifierWatcher` name on the session bus. Stock GNOME has
10
+ * none (its AppIndicator extension adds one), so hiding the window there
11
+ * leaves GAIA running with no way back to it.
12
+ */
13
+
14
+ const { execFile } = require("child_process");
15
+
16
+ const WATCHER = "org.kde.StatusNotifierWatcher";
17
+ const PROBE_TIMEOUT_MS = 2000;
18
+
19
+ /** The two bus clients a Linux desktop ships, and how each reports NameHasOwner. */
20
+ const PROBES = [
21
+ {
22
+ cmd: "gdbus",
23
+ args: [
24
+ "call", "--session",
25
+ "--dest", "org.freedesktop.DBus",
26
+ "--object-path", "/org/freedesktop/DBus",
27
+ "--method", "org.freedesktop.DBus.NameHasOwner", WATCHER,
28
+ ],
29
+ parse: (out) => /\(\s*true\s*,?\s*\)/.test(out) ? true : /\(\s*false\s*,?\s*\)/.test(out) ? false : null,
30
+ },
31
+ {
32
+ cmd: "dbus-send",
33
+ args: [
34
+ "--session", "--print-reply", "--dest=org.freedesktop.DBus",
35
+ "/org/freedesktop/DBus", "org.freedesktop.DBus.NameHasOwner", `string:${WATCHER}`,
36
+ ],
37
+ parse: (out) => /boolean\s+true/.test(out) ? true : /boolean\s+false/.test(out) ? false : null,
38
+ },
39
+ ];
40
+
41
+ function runCommand(cmd, args) {
42
+ return new Promise((resolve, reject) => {
43
+ execFile(cmd, args, { timeout: PROBE_TIMEOUT_MS, encoding: "utf8" }, (err, stdout, stderr) => {
44
+ if (err) {
45
+ err.stderr = stderr;
46
+ reject(err);
47
+ return;
48
+ }
49
+ resolve(stdout);
50
+ });
51
+ });
52
+ }
53
+
54
+ /**
55
+ * @typedef {{available: boolean, reason: null | "no-host" | "check-failed", detail?: string}} TrayHost
56
+ * `no-host`: the bus answered and nothing hosts tray icons.
57
+ * `check-failed`: the bus couldn't be asked; treated as no tray, so closing never strands the app.
58
+ */
59
+
60
+ /**
61
+ * @param {{platform?: string, run?: (cmd: string, args: string[]) => Promise<string>}} [deps]
62
+ * @returns {Promise<TrayHost>}
63
+ */
64
+ async function probeTrayHost({ platform = process.platform, run = runCommand } = {}) {
65
+ if (platform !== "linux") return { available: true, reason: null };
66
+ const failures = [];
67
+ for (const probe of PROBES) {
68
+ let out;
69
+ try {
70
+ out = await run(probe.cmd, probe.args);
71
+ } catch (err) {
72
+ const why = err.code === "ENOENT" ? "not installed" : (err.stderr || err.message || String(err)).trim();
73
+ failures.push(`${probe.cmd}: ${why}`);
74
+ continue;
75
+ }
76
+ const owned = probe.parse(out);
77
+ if (owned === true) return { available: true, reason: null };
78
+ if (owned === false) return { available: false, reason: "no-host", detail: `nothing owns ${WATCHER}` };
79
+ failures.push(`${probe.cmd}: unexpected reply ${JSON.stringify(out.trim())}`);
80
+ }
81
+ return { available: false, reason: "check-failed", detail: failures.join("; ") };
82
+ }
83
+
84
+ module.exports = { probeTrayHost, WATCHER, PROBE_TIMEOUT_MS };