@amd-gaia/agent-ui 0.25.0 → 0.25.1-rc.10

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 (68) hide show
  1. package/assets/tray-icon.png +0 -0
  2. package/assets/tray-icon@2x.png +0 -0
  3. package/dist/assets/AdvancedSettings-CtYKNNWc.js +1 -0
  4. package/dist/assets/AdvancedSettings-D5VKpkPY.css +1 -0
  5. package/dist/assets/ConnectorsPane-Ctfp9d5s.js +1 -0
  6. package/dist/assets/ConnectorsPane-Dzu2DRTc.css +1 -0
  7. package/dist/assets/DocumentLibrary-CIKp6qld.css +1 -0
  8. package/dist/assets/DocumentLibrary-Fp70O1-8.js +4 -0
  9. package/dist/assets/FileBrowser-3H3DoJIm.js +4 -0
  10. package/dist/assets/FileBrowser-D04I8Pvv.css +1 -0
  11. package/dist/assets/MemoryDashboard-D5HzmtDi.js +1 -0
  12. package/dist/assets/MemoryDashboard-Mo721-BI.css +1 -0
  13. package/dist/assets/MobileAccessModal-B9peVPY4.js +2 -0
  14. package/dist/assets/MobileAccessModal-DuVy9F1u.css +1 -0
  15. package/dist/assets/ScheduleManager-DotJl5JW.css +1 -0
  16. package/dist/assets/ScheduleManager-HcGB9P6m.js +2 -0
  17. package/dist/assets/{jsx-runtime-DVeaULmT.js → apiBase-y00JgRBP.js} +1 -1
  18. package/dist/assets/eye-B6FejHNG.js +1 -0
  19. package/dist/assets/fraunces-latin-BwwriW9Y.woff2 +0 -0
  20. package/dist/assets/index-D2UMLtwY.js +74 -0
  21. package/dist/assets/index-DF5qku9L.css +1 -0
  22. package/dist/assets/inter-latin-C2S99t-D.woff2 +0 -0
  23. package/dist/assets/inter-latin-ext-CFHvXkgd.woff2 +0 -0
  24. package/dist/assets/message-square-Dwm7y3C0.js +1 -0
  25. package/dist/fonts-OFL.txt +101 -0
  26. package/dist/index.html +6 -6
  27. package/main-safety-net.cjs +54 -7
  28. package/main.cjs +276 -120
  29. package/package.json +3 -1
  30. package/preload.cjs +24 -0
  31. package/services/agent-process-manager.cjs +116 -13
  32. package/services/app-icon.cjs +29 -0
  33. package/services/auto-updater.cjs +26 -2
  34. package/services/backend-crash-dialog.cjs +102 -0
  35. package/services/backend-installer-progress-dialog.cjs +71 -43
  36. package/services/backend-installer.cjs +233 -25
  37. package/services/chromium-switches.cjs +29 -0
  38. package/services/keep-awake.cjs +52 -0
  39. package/services/notification-service.cjs +62 -0
  40. package/services/port-manager.cjs +19 -70
  41. package/services/process-tree.cjs +328 -0
  42. package/services/quit-cleanup.cjs +86 -0
  43. package/services/quit-guard.cjs +242 -0
  44. package/services/tray-icons.cjs +174 -0
  45. package/services/tray-manager.cjs +582 -83
  46. package/services/tray-state.cjs +231 -0
  47. package/services/window-keys.cjs +43 -0
  48. package/assets/tray-icon.ico +0 -0
  49. package/dist/assets/AdvancedSettings-5cX0pGZc.js +0 -1
  50. package/dist/assets/AdvancedSettings-Ce6EvcAe.css +0 -1
  51. package/dist/assets/ConnectorsPane-CG7atLb2.css +0 -1
  52. package/dist/assets/ConnectorsPane-CuWxzcBY.js +0 -1
  53. package/dist/assets/DocumentLibrary-BdAKDe8H.js +0 -1
  54. package/dist/assets/DocumentLibrary-DrIP_032.css +0 -1
  55. package/dist/assets/FileBrowser-B8s6hKC5.js +0 -4
  56. package/dist/assets/FileBrowser-Bj6aB6Ro.css +0 -1
  57. package/dist/assets/MemoryDashboard-DQxtAW90.css +0 -1
  58. package/dist/assets/MemoryDashboard-DtolA-HZ.js +0 -1
  59. package/dist/assets/MobileAccessModal-C3nWkpWA.js +0 -2
  60. package/dist/assets/MobileAccessModal-C_t8DG0n.css +0 -1
  61. package/dist/assets/ScheduleManager-CIescbX6.css +0 -1
  62. package/dist/assets/ScheduleManager-HHdD6sko.js +0 -2
  63. package/dist/assets/dm-sans-latin-Xz1IZZA0.woff2 +0 -0
  64. package/dist/assets/index-CFVpPQal.css +0 -1
  65. package/dist/assets/index-DEPbenxC.js +0 -60
  66. package/dist/assets/message-square-Ccmbk96j.js +0 -1
  67. package/dist/assets/space-mono-400-Co7bH5Hm.woff2 +0 -0
  68. package/dist/assets/useEscapeToClose-B8fw5mn-.js +0 -1
@@ -55,8 +55,8 @@ const GAIA_PYTHON_BIN = IS_WINDOWS
55
55
  const STATE_FILE = path.join(GAIA_HOME, "electron-install-state.json");
56
56
  const LOG_FILE = path.join(GAIA_HOME, "electron-install.log");
57
57
 
58
- // 5 GB — PyTorch wheels have grown significantly and `gaia init` downloads
59
- // additional model data on first run; 3 GB is no longer enough headroom.
58
+ // Floor for uv, Python and the GAIA package only. The models, often 30 GB,
59
+ // are checked by `gaia init` against Lemonade's model store, which it sizes.
60
60
  const MIN_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
61
61
  const NETWORK_CHECK_HOSTS = Object.freeze([
62
62
  "https://pypi.org/simple/",
@@ -135,6 +135,12 @@ const STAGES = Object.freeze({
135
135
  VERIFY: "verify",
136
136
  });
137
137
 
138
+ const GAIA_INIT_MESSAGE =
139
+ "Setting up Lemonade Server and downloading models (this can take several minutes)";
140
+ const WAITING_FOR_LEMONADE_CHOICE_MESSAGE =
141
+ "Waiting for your answer: GAIA found a Lemonade Server already running " +
142
+ "and is asking whether to use it. Setup continues once you choose.";
143
+
138
144
  // Weight each stage contributes to the overall 0-100 progress.
139
145
  // Sum must equal 100.
140
146
  const STAGE_WEIGHTS = {
@@ -399,6 +405,148 @@ function makeProgressReporter(onProgress) {
399
405
  };
400
406
  }
401
407
 
408
+ // ── gaia init progress (`gaia init --progress-json`) ─────────────────────────
409
+
410
+ // Where the plan and the end of the downloads land in the GAIA_INIT slice; the
411
+ // rest of the slice is Lemonade setup before and verification after.
412
+ const INIT_PLAN_PERCENT = 5;
413
+ const INIT_DOWNLOADED_PERCENT = 95;
414
+
415
+ /**
416
+ * Read one line of `gaia init --progress-json` output. Returns `{ event }` for
417
+ * a progress event, `{ error }` for a line that looks like one but is not, and
418
+ * null for human output.
419
+ */
420
+ function parseInitEventLine(line) {
421
+ const text = String(line || "").trim();
422
+ if (!text.startsWith("{")) return null;
423
+ let value;
424
+ try {
425
+ value = JSON.parse(text);
426
+ } catch (err) {
427
+ return { error: err.message };
428
+ }
429
+ if (!value || typeof value !== "object" || typeof value.event !== "string") {
430
+ return { error: 'no "event" field' };
431
+ }
432
+ return { event: value };
433
+ }
434
+
435
+ function formatGB(bytes) {
436
+ return `${(Math.max(0, bytes || 0) / 1e9).toFixed(1)} GB`;
437
+ }
438
+
439
+ function formatSpeed(bytesPerSecond) {
440
+ return `${(bytesPerSecond / 1e6).toFixed(1)} MB/s`;
441
+ }
442
+
443
+ function formatTimeLeft(seconds) {
444
+ if (seconds < 60) return "less than a minute left";
445
+ const minutes = Math.round(seconds / 60);
446
+ if (minutes < 60) return `about ${minutes} min left`;
447
+ return `about ${Math.floor(minutes / 60)} h ${minutes % 60} min left`;
448
+ }
449
+
450
+ /**
451
+ * Map `gaia init --progress-json` events onto the GAIA_INIT slice of the bar.
452
+ * `report(withinStagePercent, message)` receives a percent that never goes
453
+ * back. `onLine` returns true for the lines too frequent to log.
454
+ */
455
+ function createInitProgress(report, logFn = log) {
456
+ const models = new Map();
457
+ let percent = 0;
458
+
459
+ const advance = (within, message) => {
460
+ percent = Math.max(percent, Math.round(within));
461
+ report(percent, message);
462
+ };
463
+ const totals = () => {
464
+ let done = 0;
465
+ let total = 0;
466
+ for (const m of models.values()) {
467
+ total += m.total;
468
+ done += Math.min(m.done, m.total);
469
+ }
470
+ return { done, total };
471
+ };
472
+ const downloadPercent = () => {
473
+ const { done, total } = totals();
474
+ const share = total > 0 ? done / total : 0;
475
+ return INIT_PLAN_PERCENT + (INIT_DOWNLOADED_PERCENT - INIT_PLAN_PERCENT) * share;
476
+ };
477
+ const model = (id) => {
478
+ if (!models.has(id)) models.set(id, { done: 0, total: 0 });
479
+ return models.get(id);
480
+ };
481
+
482
+ function handle(event) {
483
+ switch (event.event) {
484
+ case "step":
485
+ advance(percent, `Step ${event.step} of ${event.total}: ${event.message}`);
486
+ return true;
487
+ case "plan":
488
+ for (const m of event.models || []) {
489
+ if (!m.downloaded) model(m.id).total = m.bytes_total || 0;
490
+ }
491
+ advance(
492
+ INIT_PLAN_PERCENT,
493
+ event.bytes_total > 0
494
+ ? `Downloading ${formatGB(event.bytes_total)} of models`
495
+ : "Models already downloaded"
496
+ );
497
+ return true;
498
+ case "progress": {
499
+ const m = model(event.model);
500
+ m.done = event.bytes_done || 0;
501
+ m.total = Math.max(event.bytes_total || 0, m.done);
502
+ const parts = [
503
+ `Downloading ${event.model} (${event.index} of ${event.count}): ` +
504
+ `${formatGB(m.done)} of ${formatGB(m.total)}`,
505
+ ];
506
+ if (event.speed_bps > 0) {
507
+ const { done, total } = totals();
508
+ parts.push(
509
+ `at ${formatSpeed(event.speed_bps)}, ` +
510
+ formatTimeLeft((total - done) / event.speed_bps)
511
+ );
512
+ }
513
+ advance(downloadPercent(), parts.join(" "));
514
+ return true;
515
+ }
516
+ case "model_done": {
517
+ const m = model(event.model);
518
+ m.done = m.total;
519
+ advance(downloadPercent(), `${event.model} ready`);
520
+ return true;
521
+ }
522
+ case "done":
523
+ advance(
524
+ event.ok ? INIT_DOWNLOADED_PERCENT : percent,
525
+ event.ok ? "Models downloaded, finishing setup" : "Model download did not finish"
526
+ );
527
+ return true;
528
+ case "error":
529
+ advance(percent, String(event.message || "gaia init reported an error").trim());
530
+ return true;
531
+ default:
532
+ return false;
533
+ }
534
+ }
535
+
536
+ function onLine(line) {
537
+ const parsed = parseInitEventLine(line);
538
+ if (!parsed) return false;
539
+ if (parsed.error) {
540
+ logFn(`Malformed gaia init progress line (${parsed.error}): ${line}`);
541
+ return false;
542
+ }
543
+ handle(parsed.event);
544
+ return parsed.event.event === "progress";
545
+ }
546
+
547
+ return { onLine, handle };
548
+ }
549
+
402
550
  // ── Command helpers ──────────────────────────────────────────────────────────
403
551
 
404
552
  /**
@@ -430,8 +578,11 @@ function findGaiaBin() {
430
578
  /**
431
579
  * Run a child process and stream output to the log file in real time.
432
580
  * Returns a Promise that resolves with { code, stdout, stderr }.
581
+ *
582
+ * `onLine(line)` sees each whole stdout line as it arrives; a line it returns
583
+ * true for is left out of the log.
433
584
  */
434
- function runCommand(cmd, args, { env, stageLabel } = {}) {
585
+ function runCommand(cmd, args, { env, stageLabel, onLine } = {}) {
435
586
  return new Promise((resolve) => {
436
587
  log(`$ ${cmd} ${args.join(" ")}`);
437
588
  let proc;
@@ -451,14 +602,42 @@ function runCommand(cmd, args, { env, stageLabel } = {}) {
451
602
 
452
603
  let stdout = "";
453
604
  let stderr = "";
605
+ let partial = "";
606
+ const logLine = (line) => {
607
+ if (line) log(` ${stageLabel ? `[${stageLabel}] ` : ""}${line}`);
608
+ };
609
+ // An in-place progress bar redraws with \r, so each redraw is its own segment.
610
+ const takeLine = (line) => {
611
+ let quiet = true;
612
+ for (const segment of line.split("\r")) {
613
+ if (!segment) continue;
614
+ try {
615
+ quiet = onLine(segment) === true && quiet;
616
+ } catch (err) {
617
+ logError(`onLine callback threw: ${err.message}`);
618
+ quiet = false;
619
+ }
620
+ }
621
+ if (!quiet) logLine(line);
622
+ };
454
623
 
455
624
  proc.stdout.on("data", (data) => {
456
625
  const chunk = data.toString();
457
626
  stdout += chunk;
458
- chunk.split(/\r?\n/).forEach((line) => {
459
- if (line) log(` ${stageLabel ? `[${stageLabel}] ` : ""}${line}`);
460
- });
627
+ if (!onLine) {
628
+ chunk.split(/\r?\n/).forEach(logLine);
629
+ return;
630
+ }
631
+ const lines = (partial + chunk).split(/\r?\n/);
632
+ partial = lines.pop();
633
+ lines.forEach(takeLine);
461
634
  });
635
+ if (onLine) {
636
+ proc.stdout.on("end", () => {
637
+ if (partial) takeLine(partial);
638
+ partial = "";
639
+ });
640
+ }
462
641
 
463
642
  proc.stderr.on("data", (data) => {
464
643
  const chunk = data.toString();
@@ -1387,37 +1566,52 @@ function shouldSkipGaiaInit(opts) {
1387
1566
  * `chooseLemonade` is asked about a Lemonade Server already running here; see
1388
1567
  * lemonadeInitArgs.
1389
1568
  *
1390
- * Returns "done", "failed" or "skipped". The state file records it: a backend
1391
- * whose setup never finished is not installed, and the next launch runs it.
1569
+ * Returns `{ init }`, "done", "failed" or "skipped", plus `initError` when it
1570
+ * failed. The state file records both: a backend whose setup never finished is
1571
+ * not installed, so the next launch runs it, and the app shows the error.
1392
1572
  */
1393
1573
  async function runGaiaInit(gaiaBin, report, version, skipGaiaInit, chooseLemonade) {
1394
1574
  if (skipGaiaInit) {
1395
1575
  log("Skipping gaia init (skipGaiaInit=true or GAIA_SKIP_GAIA_INIT set)");
1396
- return "skipped";
1576
+ return { init: "skipped" };
1397
1577
  }
1398
1578
  setState(STATES.INSTALLING, { stage: STAGES.GAIA_INIT, version });
1399
- report(
1400
- STAGES.GAIA_INIT,
1401
- 0,
1402
- "Setting up Lemonade Server and downloading models (this can take several minutes)"
1579
+ report(STAGES.GAIA_INIT, 0, GAIA_INIT_MESSAGE);
1580
+ const lemonadeArgs = await lemonadeInitArgs(gaiaBin, chooseLemonade, runCommand, report);
1581
+ const progress = createInitProgress((within, message) =>
1582
+ report(STAGES.GAIA_INIT, within, message)
1403
1583
  );
1404
- const lemonadeArgs = await lemonadeInitArgs(gaiaBin, chooseLemonade);
1405
1584
  const initResult = await runCommand(
1406
1585
  gaiaBin,
1407
- ["init", "--profile", "gaia", "--yes", "--skip-webui-build", ...lemonadeArgs],
1408
- { stageLabel: "gaia-init" }
1586
+ ["init", "--profile", "gaia", "--yes", "--skip-webui-build", "--progress-json", ...lemonadeArgs],
1587
+ // Unwrapped, so an error line reaches initFailureReason whole.
1588
+ { stageLabel: "gaia-init", env: { ...process.env, COLUMNS: "1000" }, onLine: progress.onLine }
1409
1589
  );
1410
1590
  if (initResult.code !== 0) {
1411
- // Non-fatal: the app opens and its setup screen retries, and so does the
1412
- // next launch, because the state file records that setup did not finish.
1591
+ // Non-fatal: the app opens and its start screen shows initError, and the
1592
+ // next launch runs setup again, because the state file records the failure.
1413
1593
  log(
1414
1594
  `Warning: gaia init exited with code ${initResult.code}. Continuing; setup runs again next launch.`
1415
1595
  );
1416
1596
  report(STAGES.GAIA_INIT, 100, "Lemonade Server setup did not finish");
1417
- return "failed";
1597
+ return { init: "failed", initError: initFailureReason(initResult) };
1418
1598
  }
1419
1599
  report(STAGES.GAIA_INIT, 100, "Lemonade Server setup complete");
1420
- return "done";
1600
+ return { init: "done" };
1601
+ }
1602
+
1603
+ /** Why `gaia init` failed, in its own words: its last ❌ line, else its last line. */
1604
+ function initFailureReason({ code, stdout, stderr }) {
1605
+ const lines = `${stdout || ""}\n${stderr || ""}`
1606
+ .split(/\r?\n/)
1607
+ .map((line) => line.trim())
1608
+ // --progress-json event lines are for the progress bar, not the user.
1609
+ .filter((line) => line && !line.startsWith('{"event"'));
1610
+ const marked = lines.filter((line) => line.startsWith("❌"));
1611
+ const reason = marked.length
1612
+ ? marked[marked.length - 1].replace(/^❌\s*/, "")
1613
+ : lines[lines.length - 1];
1614
+ return reason || `gaia init exited with code ${code} and printed nothing.`;
1421
1615
  }
1422
1616
 
1423
1617
  /**
@@ -1448,8 +1642,11 @@ async function readGaiaJson(run, gaiaBin, args) {
1448
1642
  * `--lemonade-url <url>`, "gaia" adds `--gaia-lemonade`, null adds nothing:
1449
1643
  * init then sets up GAIA's own and prints how to switch, as it always has.
1450
1644
  * A server that needs a key or is too old is left to the app's setup screen.
1645
+ *
1646
+ * `report` is the progress reporter: while the question is open the progress
1647
+ * window says setup is waiting on the user, then goes back to GAIA_INIT_MESSAGE.
1451
1648
  */
1452
- async function lemonadeInitArgs(gaiaBin, chooseLemonade, run = runCommand) {
1649
+ async function lemonadeInitArgs(gaiaBin, chooseLemonade, run = runCommand, report = () => {}) {
1453
1650
  if (typeof chooseLemonade !== "function") return [];
1454
1651
  const unchanged = "gaia init will set up GAIA's own Lemonade Server; to use yours later run `gaia lemonade connect <url>`.";
1455
1652
  const status = await readGaiaJson(run, gaiaBin, ["lemonade", "status", "--json"]);
@@ -1469,11 +1666,14 @@ async function lemonadeInitArgs(gaiaBin, chooseLemonade, run = runCommand) {
1469
1666
  return [];
1470
1667
  }
1471
1668
  let answer;
1669
+ report(STAGES.GAIA_INIT, 0, WAITING_FOR_LEMONADE_CHOICE_MESSAGE);
1472
1670
  try {
1473
1671
  answer = await chooseLemonade(found);
1474
1672
  } catch (err) {
1475
1673
  log(`Warning: could not ask whether to use the Lemonade Server at ${found.url}: ${err.message}; ${unchanged}`);
1476
1674
  return [];
1675
+ } finally {
1676
+ report(STAGES.GAIA_INIT, 0, GAIA_INIT_MESSAGE);
1477
1677
  }
1478
1678
  log(`Lemonade Server at ${found.url}: user chose ${answer || "not to answer"}`);
1479
1679
  if (answer === "external") return ["--lemonade-url", found.url];
@@ -1708,7 +1908,7 @@ async function installBackend(opts = {}) {
1708
1908
  report(STAGES.INSTALL_PACKAGE, 100, "GAIA package installed");
1709
1909
 
1710
1910
  // Stage 4: gaia init
1711
- const init = await runGaiaInit(
1911
+ const setup = await runGaiaInit(
1712
1912
  GAIA_BIN,
1713
1913
  report,
1714
1914
  version,
@@ -1774,7 +1974,7 @@ async function installBackend(opts = {}) {
1774
1974
  log(`Shim creation check failed: ${err.message}`);
1775
1975
  }
1776
1976
 
1777
- setState(STATES.READY, { stage: null, version, installedVersion, init });
1977
+ setState(STATES.READY, { stage: null, version, installedVersion, ...setup });
1778
1978
  log("Backend install complete");
1779
1979
  }
1780
1980
 
@@ -1914,14 +2114,14 @@ async function ensureBackend(opts = {}) {
1914
2114
  `(state=${prev.state}, stage=${prev.stage || "-"}) — running gaia init`
1915
2115
  );
1916
2116
  const report = makeProgressReporter(opts.onProgress);
1917
- const init = await runGaiaInit(
2117
+ const setup = await runGaiaInit(
1918
2118
  existingBin,
1919
2119
  report,
1920
2120
  expectedVersion,
1921
2121
  shouldSkipGaiaInit(opts),
1922
2122
  opts.chooseLemonade
1923
2123
  );
1924
- setState(STATES.READY, { version: expectedVersion, installedVersion, init });
2124
+ setState(STATES.READY, { version: expectedVersion, installedVersion, ...setup });
1925
2125
  report(STAGES.VERIFY, 100, `GAIA ${installedVersion} ready`);
1926
2126
  return existingBin;
1927
2127
  }
@@ -1997,6 +2197,7 @@ module.exports = {
1997
2197
  findGaiaBin,
1998
2198
  toPep440,
1999
2199
  lemonadeInitArgs,
2200
+ initFailureReason,
2000
2201
 
2001
2202
  // Pre-checks
2002
2203
  runPreChecks,
@@ -2015,6 +2216,11 @@ module.exports = {
2015
2216
  setState,
2016
2217
  clearState,
2017
2218
 
2219
+ // gaia init progress
2220
+ parseInitEventLine,
2221
+ createInitProgress,
2222
+ computeOverallPercent,
2223
+
2018
2224
  // Logging
2019
2225
  openLog,
2020
2226
  closeLog,
@@ -2026,6 +2232,8 @@ module.exports = {
2026
2232
  // Constants
2027
2233
  STATES,
2028
2234
  STAGES,
2235
+ GAIA_INIT_MESSAGE,
2236
+ WAITING_FOR_LEMONADE_CHOICE_MESSAGE,
2029
2237
  GAIA_HOME,
2030
2238
  GAIA_VENV,
2031
2239
  GAIA_BIN,
@@ -0,0 +1,29 @@
1
+ // Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Chromium switches main.cjs appends before app.whenReady(). Pure so the
6
+ * per-platform and per-env choices are testable without launching Electron.
7
+ */
8
+
9
+ const TRUTHY = new Set(["1", "true", "yes"]);
10
+
11
+ function gpuDisabled(env) {
12
+ return TRUTHY.has(String(env.GAIA_DISABLE_GPU || "").trim().toLowerCase());
13
+ }
14
+
15
+ /** @returns {Array<[string, string?]>} switch name and optional value, in append order */
16
+ function chromiumSwitches({ platform, env }) {
17
+ const switches = [];
18
+ if (platform === "linux") {
19
+ // chrome-sandbox is stripped by after-pack.cjs, and direct ./AppImage runs skip the .desktop flag (#782).
20
+ switches.push(["no-sandbox"]);
21
+ // Partial wp_color_manager support in some Wayland compositors SIGTRAPs Chromium.
22
+ switches.push(["disable-features", "WaylandColorManagement"]);
23
+ }
24
+ // Opt-in escape hatch for GPU stacks whose GPU process aborts at startup (#999).
25
+ if (gpuDisabled(env)) switches.push(["disable-gpu"]);
26
+ return switches;
27
+ }
28
+
29
+ module.exports = { chromiumSwitches, gpuDisabled };
@@ -0,0 +1,52 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Keep the PC from idle-sleeping while GAIA works (#5226).
6
+ *
7
+ * The renderer decides when (a turn is running, the setting is on, and the
8
+ * battery policy allows it) and calls `power:keep-awake`; this service owns
9
+ * the one powerSaveBlocker. The display may still turn off — only system
10
+ * sleep is held off.
11
+ */
12
+
13
+ const { ipcMain, powerSaveBlocker } = require("electron");
14
+
15
+ class KeepAwake {
16
+ constructor() {
17
+ /** @type {number | null} */
18
+ this._blockerId = null;
19
+ ipcMain.handle("power:keep-awake", (_event, on) => this.set(on));
20
+ }
21
+
22
+ /**
23
+ * @param {boolean} on
24
+ * @returns {boolean} whether sleep is now being held off.
25
+ */
26
+ set(on) {
27
+ if (typeof on !== "boolean") {
28
+ throw new TypeError(`power:keep-awake expects a boolean, got ${typeof on}`);
29
+ }
30
+ if (on && this._blockerId === null) {
31
+ this._blockerId = powerSaveBlocker.start("prevent-app-suspension");
32
+ console.log("[power] Holding off idle sleep while GAIA works");
33
+ } else if (!on && this._blockerId !== null) {
34
+ powerSaveBlocker.stop(this._blockerId);
35
+ this._blockerId = null;
36
+ console.log("[power] Idle sleep allowed again");
37
+ }
38
+ return this.active;
39
+ }
40
+
41
+ /** @returns {boolean} */
42
+ get active() {
43
+ return this._blockerId !== null && powerSaveBlocker.isStarted(this._blockerId);
44
+ }
45
+
46
+ destroy() {
47
+ this.set(false);
48
+ ipcMain.removeHandler("power:keep-awake");
49
+ }
50
+ }
51
+
52
+ module.exports = KeepAwake;
@@ -314,6 +314,55 @@ class NotificationService extends EventEmitter {
314
314
  this._sendToRenderer("tray:navigate", `notification:${notif.id}`);
315
315
  }
316
316
 
317
+ // ── Chat alerts ──────────────────────────────────────────────────────
318
+
319
+ /**
320
+ * Show an OS banner for a chat event. Clicking it focuses the window and
321
+ * opens that chat. Silent: the renderer plays the user's chosen sound.
322
+ *
323
+ * @param {{title: string, body: string, sessionId: string}} payload
324
+ * @returns {boolean} false when the OS has no notification support.
325
+ */
326
+ showChatBanner(payload) {
327
+ if (!payload || typeof payload.title !== "string" || typeof payload.sessionId !== "string") {
328
+ throw new TypeError("notification:show expects {title, body, sessionId}");
329
+ }
330
+ if (!Notification.isSupported()) {
331
+ console.warn("[notif] OS notifications not supported on this platform");
332
+ return false;
333
+ }
334
+ const osNotif = new Notification({
335
+ title: payload.title,
336
+ body: typeof payload.body === "string" ? payload.body : "",
337
+ icon: path.join(__dirname, "..", "assets", "icon.png"),
338
+ silent: true,
339
+ });
340
+ osNotif.on("click", () => {
341
+ if (!this.mainWindow || this.mainWindow.isDestroyed()) return;
342
+ if (this.mainWindow.isMinimized()) this.mainWindow.restore();
343
+ this.mainWindow.show();
344
+ this.mainWindow.focus();
345
+ this._sendToRenderer("tray:navigate", `session:${payload.sessionId}`);
346
+ });
347
+ osNotif.show();
348
+ return true;
349
+ }
350
+
351
+ /**
352
+ * Flash the taskbar button until the window is focused. A no-op while
353
+ * it already has focus.
354
+ * @returns {boolean} whether it flashed.
355
+ */
356
+ flashWindow() {
357
+ const win = this.mainWindow;
358
+ if (!win || win.isDestroyed() || win.isFocused()) return false;
359
+ win.flashFrame(true);
360
+ win.once("focus", () => {
361
+ if (!win.isDestroyed()) win.flashFrame(false);
362
+ });
363
+ return true;
364
+ }
365
+
317
366
  // ── Private: Tray badge ──────────────────────────────────────────────
318
367
 
319
368
  _updateTrayBadge() {
@@ -423,6 +472,19 @@ class NotificationService extends EventEmitter {
423
472
  this._respondToPermission(notifId, action, remember);
424
473
  }
425
474
  );
475
+
476
+ // Chat alerts (#5226): the renderer decides what fires per the user's
477
+ // settings; main owns the OS surfaces it can't reach.
478
+ ipcMain.handle("notification:show", (_event, payload) =>
479
+ this.showChatBanner(payload)
480
+ );
481
+ ipcMain.handle("notification:flash", () => this.flashWindow());
482
+ ipcMain.handle("notification:attention", (_event, count) => {
483
+ if (!this.trayManager) {
484
+ throw new Error("No tray to badge: the tray manager was not created");
485
+ }
486
+ this.trayManager.setAttentionCount(count);
487
+ });
426
488
  }
427
489
 
428
490
  // ── Private: Helpers ─────────────────────────────────────────────────
@@ -22,6 +22,10 @@ const fs = require("fs");
22
22
  const os = require("os");
23
23
  const path = require("path");
24
24
  const { execFileSync } = require("child_process");
25
+ const { killProcessTree } = require("./process-tree.cjs");
26
+
27
+ const BACKEND_KILL_GRACE_MS = 3000;
28
+ const BACKEND_FORCE_WAIT_MS = 1000;
25
29
 
26
30
  /**
27
31
  * Resolve the GAIA home directory lazily so tests can point it elsewhere
@@ -54,86 +58,29 @@ function findFreePort() {
54
58
  }
55
59
 
56
60
  /**
57
- * Terminate a spawned backend: SIGTERM, wait up to 3 s, then SIGKILL.
61
+ * Terminate a spawned backend and everything it started: SIGTERM the tree,
62
+ * wait up to 3 s, then SIGKILL what is left. Without the tree walk, killing
63
+ * the uv `gaia.exe` launcher on Windows left its Python running.
58
64
  * Accepts either a ChildProcess reference (preferred — lets us
59
65
  * short-circuit via `exitCode` so PID reuse can never hit us) or a raw
60
- * pid (useful for tests). Safe to call with nullish.
66
+ * pid (the orphan cleanup and tests). Safe to call with nullish.
61
67
  */
62
68
  async function killBackend(procOrPid, logger) {
63
69
  const log = (logger && logger.log) || console.log;
64
70
  const logError = (logger && logger.error) || console.error;
65
-
66
71
  if (!procOrPid) return;
67
-
68
- // Distinguish ChildProcess vs raw pid. A ChildProcess has both .pid
69
- // and .exitCode/.signalCode; typeof === "object" is enough.
70
- const isChildProcess = typeof procOrPid === "object";
71
- const proc = isChildProcess ? procOrPid : null;
72
- const pid = isChildProcess ? procOrPid.pid : procOrPid;
73
-
74
- if (!pid) return;
75
-
76
- // If we have the ChildProcess handle, trust it — the exitCode is set
77
- // synchronously by Node's child_process before any pid reuse could
78
- // happen, so this closes the PID-reuse TOCTOU that a pid-only API has.
72
+ const proc = typeof procOrPid === "object" ? procOrPid : null;
79
73
  if (proc && (proc.exitCode !== null || proc.signalCode !== null)) {
80
- log(`[port-manager] child pid ${pid} already exited (exitCode=${proc.exitCode} signal=${proc.signalCode})`);
81
- return;
82
- }
83
-
84
- // Is the process still alive? kill(pid, 0) throws ESRCH if not.
85
- // Only relied on when the caller passed a raw pid (tests); callers
86
- // with a ChildProcess were already short-circuited above.
87
- const alive = (p) => {
88
- try {
89
- process.kill(p, 0);
90
- return true;
91
- } catch {
92
- return false;
93
- }
94
- };
95
-
96
- if (!alive(pid)) {
97
- log(`[port-manager] pid ${pid} already gone`);
74
+ log(`[port-manager] child pid ${proc.pid} already exited (exitCode=${proc.exitCode} signal=${proc.signalCode})`);
98
75
  return;
99
76
  }
100
-
101
- try {
102
- process.kill(pid, "SIGTERM");
103
- log(`[port-manager] SIGTERM sent to pid ${pid}`);
104
- } catch (err) {
105
- logError(`[port-manager] SIGTERM failed for pid ${pid}: ${err.message}`);
106
- return;
107
- }
108
-
109
- const deadline = Date.now() + 3000;
110
- while (Date.now() < deadline) {
111
- if (!alive(pid)) {
112
- log(`[port-manager] pid ${pid} exited after SIGTERM`);
113
- return;
114
- }
115
- await new Promise((r) => setTimeout(r, 100));
116
- }
117
-
118
- try {
119
- process.kill(pid, "SIGKILL");
120
- log(`[port-manager] SIGKILL sent to pid ${pid} (did not exit on SIGTERM)`);
121
- } catch (err) {
122
- logError(`[port-manager] SIGKILL failed for pid ${pid}: ${err.message}`);
123
- return;
124
- }
125
-
126
- // Poll until the kernel reaps the process so callers can safely rebind
127
- // the port immediately after we return. 1 s cap at 50 ms intervals —
128
- // SIGKILL is synchronous in the kernel but reaping the zombie can lag.
129
- const killDeadline = Date.now() + 1000;
130
- while (Date.now() < killDeadline) {
131
- if (!alive(pid)) return;
132
- await new Promise((r) => setTimeout(r, 50));
133
- }
134
- logError(
135
- `[port-manager] pid ${pid} still alive 1s after SIGKILL — orphan risk`,
136
- );
77
+ await killProcessTree(procOrPid, {
78
+ graceMs: BACKEND_KILL_GRACE_MS,
79
+ forceWaitMs: BACKEND_FORCE_WAIT_MS,
80
+ log,
81
+ logError,
82
+ label: "backend",
83
+ });
137
84
  }
138
85
 
139
86
  /**
@@ -231,4 +178,6 @@ module.exports = PortManager;
231
178
  module.exports.PortManager = PortManager;
232
179
  module.exports.findFreePort = findFreePort;
233
180
  module.exports.killBackend = killBackend;
181
+ module.exports.BACKEND_KILL_GRACE_MS = BACKEND_KILL_GRACE_MS;
182
+ module.exports.BACKEND_FORCE_WAIT_MS = BACKEND_FORCE_WAIT_MS;
234
183
  module.exports.writeDiagnosticsBundle = writeDiagnosticsBundle;