@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
@@ -26,6 +26,12 @@ const path = require("path");
26
26
  const fs = require("fs");
27
27
  const os = require("os");
28
28
  const { EventEmitter } = require("events");
29
+ const {
30
+ killProcessTree,
31
+ listDescendants,
32
+ listTimeoutMs,
33
+ worstCaseKillMs,
34
+ } = require("./process-tree.cjs");
29
35
 
30
36
  // ── Constants ────────────────────────────────────────────────────────────
31
37
 
@@ -38,6 +44,9 @@ const MANIFEST_FILENAME = "agent-manifest.json";
38
44
  /** Graceful shutdown timeout before force kill (ms) */
39
45
  const SHUTDOWN_TIMEOUT = 5000;
40
46
 
47
+ /** After the graceful shutdown, how long the tree gets between SIGTERM and SIGKILL (ms) */
48
+ const TREE_KILL_GRACE = 2000;
49
+
41
50
  /** Health check interval (ms) — uses MCP "ping", not "initialize" */
42
51
  const HEALTH_CHECK_INTERVAL = 30000;
43
52
 
@@ -82,6 +91,7 @@ class AgentProcessManager extends EventEmitter {
82
91
  * handlers proxy to that backend's `/api/agents/*` routes (the real install
83
92
  * runtime), so without a resolver those handlers fail loudly rather than
84
93
  * duplicating download/verify/uv logic in the main process (issue #1721).
94
+ * killTree / listDescendants replace the process-tree helpers (tests).
85
95
  */
86
96
  constructor(mainWindow, options = {}) {
87
97
  super();
@@ -110,6 +120,12 @@ class AgentProcessManager extends EventEmitter {
110
120
  */
111
121
  this.processes = {};
112
122
 
123
+ this._killTree = options.killTree || killProcessTree;
124
+ this._listDescendants = options.listDescendants || listDescendants;
125
+
126
+ /** Set by stopAll(): GAIA is quitting, so nothing may start another agent. */
127
+ this._shuttingDown = false;
128
+
113
129
  /** Crash timestamps per agent for rate-limiting restart attempts */
114
130
  this._crashTimes = {};
115
131
 
@@ -130,6 +146,11 @@ class AgentProcessManager extends EventEmitter {
130
146
  * @returns {Promise<{ pid: number }>}
131
147
  */
132
148
  async startAgent(agentId) {
149
+ if (this._shuttingDown) {
150
+ throw new Error(
151
+ `Not starting agent "${agentId}": GAIA is quitting and has already stopped its agents.`
152
+ );
153
+ }
133
154
  if (this.processes[agentId]) {
134
155
  console.log(`[agent-mgr] Agent ${agentId} is already running (PID ${this.processes[agentId].process.pid})`);
135
156
  return { pid: this.processes[agentId].process.pid };
@@ -166,10 +187,18 @@ class AgentProcessManager extends EventEmitter {
166
187
  pendingRpc: {},
167
188
  healthTimer: null,
168
189
  stopping: false, // Set to true during intentional shutdown to suppress crash recovery
190
+ stopPromise: null, // The running stopAgent(), shared by duplicate calls
191
+ stdinError: null, // The error that broke stdin; nothing more can be written once set
169
192
  };
170
193
 
171
194
  this.processes[agentId] = entry;
172
195
 
196
+ // A write to a child that has died fails later, as an 'error' on the
197
+ // stream; without a listener that EPIPE crashes the whole app (#3498).
198
+ child.stdin.on("error", (err) => {
199
+ this._handleStdinError(agentId, child, err);
200
+ });
201
+
173
202
  // ── stdout: JSON-RPC message stream ──
174
203
  child.stdout.on("data", (data) => {
175
204
  this._handleStdout(agentId, data);
@@ -207,6 +236,7 @@ class AgentProcessManager extends EventEmitter {
207
236
  /**
208
237
  * Stop an agent gracefully using JSON-RPC shutdown protocol.
209
238
  * Cross-platform (works on Windows where SIGTERM = TerminateProcess).
239
+ * A second call while the first runs waits for that same stop.
210
240
  * @param {string} agentId
211
241
  * @returns {Promise<void>}
212
242
  */
@@ -217,23 +247,40 @@ class AgentProcessManager extends EventEmitter {
217
247
  return;
218
248
  }
219
249
 
220
- // Guard against concurrent stopAgent() calls for the same agent
221
- if (entry.stopping) {
222
- console.log(`[agent-mgr] Agent ${agentId} is already being stopped — skipping duplicate`);
223
- return;
250
+ if (entry.stopPromise) {
251
+ console.log(`[agent-mgr] Agent ${agentId} is already being stopped — waiting for that stop`);
252
+ return entry.stopPromise;
224
253
  }
225
254
 
226
255
  console.log(`[agent-mgr] Stopping agent ${agentId} (PID ${entry.process.pid})...`);
227
256
 
228
257
  // Mark as intentionally stopping — suppresses crash recovery in _handleProcessExit
229
258
  entry.stopping = true;
259
+ entry.stopPromise = this._stopAgent(agentId, entry);
260
+ return entry.stopPromise;
261
+ }
230
262
 
263
+ async _stopAgent(agentId, entry) {
231
264
  // Clear health check timer
232
265
  if (entry.healthTimer) {
233
266
  clearInterval(entry.healthTimer);
234
267
  entry.healthTimer = null;
235
268
  }
236
269
 
270
+ // Read the tree while the agent is alive: once it exits, its children
271
+ // are re-parented on Linux and macOS and can no longer be found from it.
272
+ let knownDescendants = [];
273
+ if (entry.process.exitCode === null && entry.process.signalCode === null) {
274
+ try {
275
+ ({ descendants: knownDescendants } = await this._listDescendants(entry.process.pid));
276
+ } catch (err) {
277
+ console.error(
278
+ `[agent-mgr] Could not list the processes agent ${agentId} started (${err.message}); ` +
279
+ "any it leaves behind will keep running."
280
+ );
281
+ }
282
+ }
283
+
237
284
  // Step 1: Send JSON-RPC shutdown request via stdin
238
285
  try {
239
286
  this._sendJsonRpcRaw(agentId, "shutdown", {});
@@ -247,17 +294,17 @@ class AgentProcessManager extends EventEmitter {
247
294
  // Step 2: Wait up to SHUTDOWN_TIMEOUT for clean exit
248
295
  const exited = await this._waitForExit(agentId, SHUTDOWN_TIMEOUT);
249
296
 
250
- // Step 3: Force kill if still running
251
- if (!exited && this.processes[agentId]) {
297
+ // Step 3: Stop the agent if it ignored the request, and anything it started either way.
298
+ if (!exited) {
252
299
  console.warn(
253
300
  `[agent-mgr] Agent ${agentId} did not exit within ${SHUTDOWN_TIMEOUT}ms, force killing...`
254
301
  );
255
- try {
256
- entry.process.kill(); // SIGKILL on Unix, TerminateProcess on Windows
257
- } catch {
258
- // Already dead
259
- }
260
302
  }
303
+ await this._killTree(entry.process, {
304
+ knownDescendants,
305
+ graceMs: TREE_KILL_GRACE,
306
+ label: `agent ${agentId}`,
307
+ });
261
308
 
262
309
  // Note: _handleProcessExit may have already cleaned up if the process exited.
263
310
  // _cleanupProcess is idempotent, so calling it again is safe.
@@ -626,7 +673,13 @@ class AgentProcessManager extends EventEmitter {
626
673
 
627
674
  entry.pendingRpc[id] = { resolve, reject, timer };
628
675
 
629
- this._sendJsonRpcRaw(agentId, method, params, id);
676
+ try {
677
+ this._sendJsonRpcRaw(agentId, method, params, id);
678
+ } catch (err) {
679
+ clearTimeout(timer);
680
+ delete entry.pendingRpc[id];
681
+ reject(err);
682
+ }
630
683
  });
631
684
  }
632
685
 
@@ -639,6 +692,7 @@ class AgentProcessManager extends EventEmitter {
639
692
  const agentConfigs = this.config.agents || {};
640
693
 
641
694
  for (const [agentId, agentCfg] of Object.entries(agentConfigs)) {
695
+ if (this._shuttingDown) return;
642
696
  if (agentCfg.autoStart && !this.processes[agentId]) {
643
697
  try {
644
698
  await this.startAgent(agentId);
@@ -656,13 +710,16 @@ class AgentProcessManager extends EventEmitter {
656
710
  }
657
711
 
658
712
  /**
659
- * Stop all running agents gracefully.
713
+ * Stop all running agents gracefully. For quitting only: afterwards no
714
+ * agent can be started, so a pending auto-start or crash restart can't
715
+ * spawn one the app will never stop.
660
716
  */
661
717
  async stopAll() {
662
718
  const agentIds = Object.keys(this.processes);
663
719
  console.log(
664
720
  `[agent-mgr] Stopping all agents: ${agentIds.join(", ") || "(none)"}`
665
721
  );
722
+ this._shuttingDown = true;
666
723
  await Promise.all(agentIds.map((id) => this.stopAgent(id)));
667
724
  }
668
725
 
@@ -832,6 +889,7 @@ class AgentProcessManager extends EventEmitter {
832
889
  );
833
890
 
834
891
  setTimeout(async () => {
892
+ if (this._shuttingDown) return;
835
893
  try {
836
894
  await this.startAgent(agentId);
837
895
  console.log(`[agent-mgr] Agent ${agentId} restarted after crash`);
@@ -858,6 +916,11 @@ class AgentProcessManager extends EventEmitter {
858
916
  if (!entry || !entry.process.stdin || entry.process.stdin.destroyed) {
859
917
  throw new Error(`Cannot write to stdin of agent "${agentId}"`);
860
918
  }
919
+ if (entry.stdinError) {
920
+ throw new Error(
921
+ `Cannot write to stdin of agent "${agentId}": the pipe broke earlier (${entry.stdinError.message})`
922
+ );
923
+ }
861
924
 
862
925
  const msg = {
863
926
  jsonrpc: "2.0",
@@ -870,6 +933,36 @@ class AgentProcessManager extends EventEmitter {
870
933
  entry.process.stdin.write(payload);
871
934
  }
872
935
 
936
+ /**
937
+ * The agent's stdin failed — almost always EPIPE because the process is
938
+ * gone. Fail its pending calls now instead of letting them time out, and
939
+ * make sure the death is reported: a child that only closed its stdin is
940
+ * unusable, so it is stopped and its exit goes through the crash path.
941
+ */
942
+ _handleStdinError(agentId, child, err) {
943
+ const entry = this.processes[agentId];
944
+ const current = entry && entry.process === child ? entry : null;
945
+ const exited = child.exitCode !== null || child.signalCode !== null;
946
+ console.error(
947
+ `[agent-mgr] stdin of agent ${agentId} (PID ${child.pid}) failed: ${err.code || "error"} ${err.message}` +
948
+ (exited ? " — the agent had already exited" : "")
949
+ );
950
+ if (!current) return;
951
+ current.stdinError = err;
952
+
953
+ for (const [rpcId, pending] of Object.entries(current.pendingRpc)) {
954
+ clearTimeout(pending.timer);
955
+ delete current.pendingRpc[rpcId];
956
+ pending.reject(new Error(`Agent "${agentId}" stopped reading its input (${err.code || err.message})`));
957
+ }
958
+
959
+ if (exited || current.stopping) return;
960
+ console.error(`[agent-mgr] Agent ${agentId} can no longer receive requests — stopping it`);
961
+ this._killTree(child, { graceMs: TREE_KILL_GRACE, label: `agent ${agentId}` }).catch((killErr) => {
962
+ console.error(`[agent-mgr] Could not stop agent ${agentId} after its stdin broke: ${killErr.message}`);
963
+ });
964
+ }
965
+
873
966
  // ── Private: Health check ────────────────────────────────────────────
874
967
 
875
968
  async _healthCheck(agentId) {
@@ -1096,4 +1189,14 @@ class AgentProcessManager extends EventEmitter {
1096
1189
  }
1097
1190
  }
1098
1191
 
1192
+ /**
1193
+ * Longest stopAgent can take on `platform` (ms): the tree read while the
1194
+ * agent is alive, its shutdown wait, then killProcessTree. The quit's
1195
+ * cleanup deadline must cover it.
1196
+ */
1197
+ AgentProcessManager.worstCaseStopMs = (platform = process.platform) =>
1198
+ listTimeoutMs(platform) +
1199
+ SHUTDOWN_TIMEOUT +
1200
+ worstCaseKillMs({ platform, graceMs: TREE_KILL_GRACE });
1201
+
1099
1202
  module.exports = AgentProcessManager;
@@ -0,0 +1,29 @@
1
+ // Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ const path = require("path");
5
+
6
+ /**
7
+ * The GAIA window icon. Every BrowserWindow must pass it: a source build runs
8
+ * as electron.exe, so a window without one shows Electron's logo.
9
+ *
10
+ * @param {string} [platform] Defaults to process.platform.
11
+ * @returns {string} Absolute path to the icon asset.
12
+ */
13
+ function appIconPath(platform = process.platform) {
14
+ return path.join(__dirname, "..", "assets", platform === "win32" ? "icon.ico" : "icon.png");
15
+ }
16
+
17
+ /**
18
+ * The Windows AppUserModelID. Without one, the taskbar groups GAIA with every
19
+ * other window of the same electron.exe and shows Electron's icon. Packaged
20
+ * builds match the installer's shortcut (`appId` in electron-builder.yml).
21
+ *
22
+ * @param {boolean} isPackaged app.isPackaged
23
+ * @returns {string}
24
+ */
25
+ function appUserModelId(isPackaged) {
26
+ return isPackaged ? "ai.amd.gaia" : "ai.amd.gaia.dev";
27
+ }
28
+
29
+ module.exports = { appIconPath, appUserModelId };
@@ -106,6 +106,8 @@ const state = {
106
106
  };
107
107
 
108
108
  let mainWindowRef = null;
109
+ /** Asks before an update restart would cut off running work (set by init). */
110
+ let confirmRestartRef = null;
109
111
  let checkInProgress = false;
110
112
  let scheduledTimeout = null;
111
113
  let initialCheckTimeout = null;
@@ -616,13 +618,25 @@ function wireAutoUpdaterEvents() {
616
618
  }
617
619
  );
618
620
  if (choice && choice.response === 0) {
621
+ // quitAndInstall starts the installer at once, so ask about running
622
+ // work here; the quit that follows doesn't ask again.
623
+ if (!(await confirmRestartRef())) {
624
+ log("info", "Restart put off: work is still running — will install on next quit");
625
+ return;
626
+ }
619
627
  log("info", "User chose to restart — calling quitAndInstall");
620
628
  autoUpdaterRef.quitAndInstall(false, true);
621
629
  } else {
622
630
  log("info", "User deferred restart — will install on next quit");
623
631
  }
624
632
  } catch (err) {
625
- log("error", "Failed to show restart dialog:", err && err.message);
633
+ const message = (err && err.message) || String(err);
634
+ log("error", "Failed to show restart dialog:", message);
635
+ electronApi.dialog.showErrorBox(
636
+ "GAIA update",
637
+ `GAIA couldn't finish asking about the update restart: ${message}\n\n` +
638
+ "The update installs the next time you quit GAIA."
639
+ );
626
640
  }
627
641
  });
628
642
 
@@ -689,9 +703,19 @@ function unregisterIpcHandlers() {
689
703
  * continues, and this function itself never throws.
690
704
  *
691
705
  * @param {Electron.BrowserWindow | null} mainWindow
706
+ * @param {object} options
707
+ * @param {() => Promise<boolean>} options.confirmRestart - Whether "Restart now"
708
+ * may go ahead; asked before the installer starts. Required: without it an
709
+ * update restart would silently cut off a running answer.
692
710
  */
693
- function init(mainWindow) {
711
+ function init(mainWindow, options = {}) {
712
+ if (typeof options.confirmRestart !== "function") {
713
+ throw new TypeError(
714
+ "auto-updater init needs options.confirmRestart, so Restart now can ask before it stops running work"
715
+ );
716
+ }
694
717
  mainWindowRef = mainWindow || null;
718
+ confirmRestartRef = options.confirmRestart;
695
719
 
696
720
  // Disabled path — short-circuit BEFORE touching any Electron APIs so
697
721
  // this works in plain Node tests where require('electron') returns a
@@ -0,0 +1,102 @@
1
+ // Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The dialog shown when GAIA's backend exits on its own. Every choice ends
6
+ * somewhere: Restart relaunches the app, Quit quits it, and Save diagnostics
7
+ * writes the bundle and comes back to the same choice.
8
+ */
9
+
10
+ const RESTART = 0;
11
+ const SAVE_DIAGNOSTICS = 1;
12
+ const QUIT = 2;
13
+
14
+ // Colour codes the backend's logger writes; a dialog shows them as junk.
15
+ const ANSI_ESCAPE = /\u001b\[[0-9;]*[A-Za-z]/g;
16
+
17
+ function stripAnsi(text) {
18
+ return String(text).replace(ANSI_ESCAPE, "");
19
+ }
20
+
21
+ /** The dialog's detail text: what happened, what to do, then the log tail. */
22
+ function crashDetail(code, signal, tailLines) {
23
+ const how = signal ? `signal ${signal}` : `exit code ${code}`;
24
+ const tail = (tailLines || []).map(stripAnsi).join("\n") || "(nothing was logged)";
25
+ return (
26
+ `GAIA's background service stopped unexpectedly (${how}). Your chats are saved.\n` +
27
+ "Restart GAIA to continue. If it keeps happening, save diagnostics and attach " +
28
+ "them to a bug report.\n\n" +
29
+ `Last log lines:\n${tail}`
30
+ );
31
+ }
32
+
33
+ /**
34
+ * An AppImage runs from a mount that goes away when the app exits, so a
35
+ * relaunch must start the AppImage file itself, not process.execPath.
36
+ */
37
+ function relaunchOptions(env = process.env, argv = process.argv) {
38
+ return env.APPIMAGE ? { execPath: env.APPIMAGE, args: argv.slice(1) } : undefined;
39
+ }
40
+
41
+ /**
42
+ * Show the crash dialog until the user restarts or quits.
43
+ *
44
+ * @param {object} deps
45
+ * @param {object} deps.dialog Electron's dialog module
46
+ * @param {object} deps.app Electron's app module
47
+ * @param {() => Promise<string>} deps.writeDiagnostics resolves to the bundle path
48
+ * @param {() => void} deps.beforeQuit called before app.quit(), e.g. to skip the quit guard
49
+ * @param {object} [deps.env] where an AppImage's own path is read from
50
+ * @param {object} [deps.parent] window the dialog is modal to, e.g. mainWindow
51
+ * @returns {Promise<"restart"|"quit">}
52
+ */
53
+ async function showBackendCrashDialog({ dialog, app, code, signal, tailLines, writeDiagnostics, beforeQuit, env = process.env, parent }) {
54
+ const detail = crashDetail(code, signal, tailLines);
55
+ // Modal to GAIA's window and in front: unparented, it can open behind other apps.
56
+ const owner = parent && !parent.isDestroyed() ? parent : null;
57
+ if (owner) {
58
+ if (owner.isMinimized()) owner.restore();
59
+ owner.show();
60
+ owner.focus();
61
+ }
62
+ const show = (box) => (owner && !owner.isDestroyed() ? dialog.showMessageBox(owner, box) : dialog.showMessageBox(box));
63
+ for (;;) {
64
+ const { response } = await show({
65
+ type: "error",
66
+ title: "GAIA stopped",
67
+ message: "GAIA's background service stopped",
68
+ detail,
69
+ buttons: ["Restart GAIA", "Save diagnostics", "Quit"],
70
+ defaultId: RESTART,
71
+ cancelId: QUIT,
72
+ noLink: true,
73
+ });
74
+
75
+ if (response === SAVE_DIAGNOSTICS) {
76
+ try {
77
+ const bundlePath = await writeDiagnostics();
78
+ await show({
79
+ type: "info",
80
+ title: "Diagnostics saved",
81
+ message: "Diagnostics saved",
82
+ detail: `Attach this file to your bug report:\n${bundlePath}`,
83
+ buttons: ["OK"],
84
+ });
85
+ } catch (err) {
86
+ dialog.showErrorBox(
87
+ "Could not save diagnostics",
88
+ `Writing the diagnostics bundle failed: ${err.message}`
89
+ );
90
+ }
91
+ continue;
92
+ }
93
+
94
+ const choice = response === RESTART ? "restart" : "quit";
95
+ if (choice === "restart") app.relaunch(relaunchOptions(env));
96
+ if (beforeQuit) beforeQuit();
97
+ app.quit();
98
+ return choice;
99
+ }
100
+ }
101
+
102
+ module.exports = { showBackendCrashDialog, crashDetail, stripAnsi, relaunchOptions };
@@ -29,6 +29,7 @@ const path = require("path");
29
29
  const { BrowserWindow, dialog, ipcMain, shell, clipboard } = require("electron");
30
30
 
31
31
  const installer = require("./backend-installer.cjs");
32
+ const { appIconPath } = require("./app-icon.cjs");
32
33
 
33
34
  // IPC channels. Keep these in sync with preload.cjs.
34
35
  const IPC_PROGRESS_EVENT = "install:progress";
@@ -72,14 +73,45 @@ function registerIpcHandlers() {
72
73
  });
73
74
  }
74
75
 
76
+ // ── Stage labels ────────────────────────────────────────────────────────────
77
+
78
+ // Keyed by installer.STAGES values; the ids themselves stay in the install log.
79
+ const STAGE_LABELS = Object.freeze({
80
+ "pre-checks": "Checking your PC",
81
+ "ensure-uv": "Getting the installer ready",
82
+ "create-venv": "Preparing GAIA's workspace",
83
+ "install-package": "Installing GAIA",
84
+ "gaia-init": "Getting the AI model ready",
85
+ verify: "Finishing up",
86
+ });
87
+ const NEUTRAL_STAGE_LABEL = "Setting up GAIA";
88
+
89
+ const unlabeledStages = new Set();
90
+
91
+ /** What the window shows for an installer stage id. */
92
+ function stageLabel(stage) {
93
+ if (Object.prototype.hasOwnProperty.call(STAGE_LABELS, stage)) return STAGE_LABELS[stage];
94
+ if (stage && !unlabeledStages.has(stage)) {
95
+ unlabeledStages.add(stage);
96
+ installer.log(
97
+ `Warning: install stage "${stage}" has no label; the window shows "${NEUTRAL_STAGE_LABEL}"`
98
+ );
99
+ }
100
+ return NEUTRAL_STAGE_LABEL;
101
+ }
102
+
75
103
  // ── Embedded HTML ───────────────────────────────────────────────────────────
76
104
 
105
+ function escapeHtml(text) {
106
+ return String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
107
+ }
108
+
77
109
  /**
78
110
  * HTML payload for the progress window. Rendered via `loadURL('data:...')`.
79
111
  * The script uses `window.gaiaInstall` exposed by preload.cjs.
80
112
  */
81
113
  function buildProgressHtml({ logPath }) {
82
- const escapedLog = String(logPath || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
114
+ const escapedLog = escapeHtml(logPath || "");
83
115
  return `<!DOCTYPE html>
84
116
  <html lang="en">
85
117
  <head>
@@ -90,13 +122,13 @@ function buildProgressHtml({ logPath }) {
90
122
  * src/styles/index.css, so the dark theme's role values are mirrored
91
123
  * here. Keep them in step with that file; nothing below names a hex. */
92
124
  :root {
93
- --bg-primary: #17161C;
94
- --bg-tertiary: #2A2831;
95
- --border: #3A3842;
96
- --text-primary: #F0EDE7;
97
- --text-secondary: #AAA5B0;
98
- --text-muted: #8C8794;
99
- --accent-fill: #9A4930;
125
+ --bg-primary: #1A1917;
126
+ --bg-tertiary: #2C2B27;
127
+ --border: #4A4740;
128
+ --text-primary: #EFECE6;
129
+ --text-secondary: #B3ADA5;
130
+ --text-muted: #958F87;
131
+ --accent-solid: #D4B47E;
100
132
  }
101
133
  html, body {
102
134
  margin: 0;
@@ -132,8 +164,6 @@ function buildProgressHtml({ logPath }) {
132
164
  .stage {
133
165
  font-size: 12px;
134
166
  color: var(--text-secondary);
135
- text-transform: uppercase;
136
- letter-spacing: 0.5px;
137
167
  margin-bottom: 6px;
138
168
  min-height: 15px;
139
169
  }
@@ -152,7 +182,7 @@ function buildProgressHtml({ logPath }) {
152
182
  }
153
183
  .fill {
154
184
  height: 100%;
155
- background: var(--accent-fill);
185
+ background: var(--accent-solid);
156
186
  border-radius: 9999px;
157
187
  transition: width 300ms ease-out;
158
188
  width: 0%;
@@ -201,11 +231,11 @@ function buildProgressHtml({ logPath }) {
201
231
  </head>
202
232
  <body>
203
233
  <h1>Setting up GAIA</h1>
204
- <p class="subtitle">First-launch backend install — this can take a few minutes</p>
234
+ <p class="subtitle">First-time setup — this can take a few minutes</p>
205
235
  <div class="stage" id="stage">Starting…</div>
206
236
  <div class="bar"><div class="fill indeterminate" id="fill"></div></div>
207
237
  <div class="percent" id="percent"></div>
208
- <div class="message" id="message">Running pre-flight checks…</div>
238
+ <div class="message" id="message">Checking your PC…</div>
209
239
  <div class="footer">Log: <code>${escapedLog}</code></div>
210
240
  <script>
211
241
  (function() {
@@ -216,7 +246,7 @@ function buildProgressHtml({ logPath }) {
216
246
 
217
247
  function onProgress(payload) {
218
248
  if (!payload) return;
219
- const stage = payload.stage || '';
249
+ const stage = payload.label || '';
220
250
  const percent = typeof payload.percent === 'number' ? payload.percent : null;
221
251
  const message = payload.message || '';
222
252
 
@@ -268,8 +298,9 @@ function createProgressWindow() {
268
298
  frame: false,
269
299
  transparent: false,
270
300
  show: false,
271
- backgroundColor: "#17161C", // --bg-primary, dark theme (see the :root block above)
301
+ backgroundColor: "#1A1917", // --bg-primary, dark theme (see the :root block above)
272
302
  title: "Installing GAIA",
303
+ icon: appIconPath(),
273
304
  webPreferences: {
274
305
  nodeIntegration: false,
275
306
  contextIsolation: true,
@@ -305,7 +336,7 @@ function createProgressWindow() {
305
336
  });
306
337
 
307
338
  const onProgress = (stage, percent, message) => {
308
- lastProgress = { stage, percent, message };
339
+ lastProgress = { stage, label: stageLabel(stage), percent, message };
309
340
  if (window.isDestroyed()) return;
310
341
  if (!rendererReady) return; // will be replayed on did-finish-load
311
342
  try {
@@ -364,21 +395,19 @@ async function showFailureDialog(parentWindow, errorInfo = {}) {
364
395
  .filter(Boolean)
365
396
  .join("\n");
366
397
 
367
- // Loop so that certain actions (Copy/Open log) return control to the
368
- // same dialog rather than exiting the flow. The dialog includes a
369
- // one-click "Install uv" action which attempts the packaged rescue
370
- // installer and then returns 'retry' on success so the caller can
371
- // re-run the full backend install.
372
- const buttons = [
373
- "Install uv (auto)",
374
- "Retry",
375
- "Manual install instructions",
376
- "Copy log path",
377
- "Open log file",
378
- "Quit",
398
+ // Copy/Open log return to the same dialog. "Install uv" is offered only
399
+ // when uv is what failed; for any other stage it would just repeat Retry.
400
+ const uvFailed = stage === "ensure-uv";
401
+ const actions = [
402
+ ...(uvFailed ? [["install-uv", "Install uv (auto)"]] : []),
403
+ ["retry", "Retry"],
404
+ ["manual", "Manual install instructions"],
405
+ ["copy-log", "Copy log path"],
406
+ ["open-log", "Open log file"],
407
+ ["quit", "Quit"],
379
408
  ];
409
+ const buttons = actions.map(([, label]) => label);
380
410
 
381
- // Helper to show the dialog and handle the response.
382
411
  const show = async () => {
383
412
  const result = await dialog.showMessageBox(parentWindow || null, {
384
413
  type: "error",
@@ -391,15 +420,12 @@ async function showFailureDialog(parentWindow, errorInfo = {}) {
391
420
  noLink: true,
392
421
  });
393
422
 
394
- switch (result.response) {
395
- case 0: {
396
- // Run the full packaged install flow (ensureBackend) so the user
397
- // gets a single-click recovery that attempts the entire backend
398
- // install rather than only uv provisioning. Show progress UI
399
- // while the operation runs. On success, tell the caller to retry
400
- // (which will detect READY and proceed); on failure, re-show the
401
- // dialog with augmented details.
402
- const { window, onProgress, close } = createProgressWindow();
423
+ const action = (actions[result.response] || ["quit"])[0];
424
+ switch (action) {
425
+ case "install-uv": {
426
+ // Run the full packaged install (ensureBackend), with progress, so one
427
+ // click retries everything, not only uv; re-show with the new error.
428
+ const { onProgress, close } = createProgressWindow();
403
429
  try {
404
430
  await installer.ensureBackend({ onProgress, isPackaged: true });
405
431
  try { close(); } catch {}
@@ -414,9 +440,9 @@ async function showFailureDialog(parentWindow, errorInfo = {}) {
414
440
  return showFailureDialog(parentWindow, nextInfo);
415
441
  }
416
442
  }
417
- case 1:
443
+ case "retry":
418
444
  return "retry";
419
- case 2: {
445
+ case "manual": {
420
446
  try {
421
447
  await shell.openExternal("https://amd-gaia.ai/docs/quickstart#cli-install");
422
448
  } catch {
@@ -424,7 +450,7 @@ async function showFailureDialog(parentWindow, errorInfo = {}) {
424
450
  }
425
451
  return "manual";
426
452
  }
427
- case 3: {
453
+ case "copy-log": {
428
454
  try {
429
455
  clipboard.writeText(logPath);
430
456
  } catch {
@@ -432,7 +458,7 @@ async function showFailureDialog(parentWindow, errorInfo = {}) {
432
458
  }
433
459
  return showFailureDialog(parentWindow, errorInfo);
434
460
  }
435
- case 4: {
461
+ case "open-log": {
436
462
  try {
437
463
  await shell.openPath(logPath);
438
464
  } catch {
@@ -440,7 +466,6 @@ async function showFailureDialog(parentWindow, errorInfo = {}) {
440
466
  }
441
467
  return showFailureDialog(parentWindow, errorInfo);
442
468
  }
443
- case 5:
444
469
  default:
445
470
  return "quit";
446
471
  }
@@ -472,6 +497,9 @@ async function showPreCheckFailureDialog(parentWindow, { title, message, detail
472
497
 
473
498
  module.exports = {
474
499
  createProgressWindow,
500
+ buildProgressHtml,
501
+ stageLabel,
502
+ STAGE_LABELS,
475
503
  showFailureDialog,
476
504
  showPreCheckFailureDialog,
477
505
  // IPC channel names for preload.cjs