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

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 (60) hide show
  1. package/dist/assets/AdvancedSettings-BsZzYCUX.js +1 -0
  2. package/dist/assets/AdvancedSettings-D5VKpkPY.css +1 -0
  3. package/dist/assets/ConnectorsPane-Dzu2DRTc.css +1 -0
  4. package/dist/assets/ConnectorsPane-sXLzWlmi.js +1 -0
  5. package/dist/assets/DocumentLibrary-C8MaQVoZ.js +1 -0
  6. package/dist/assets/DocumentLibrary-Cj9MJ9yW.css +1 -0
  7. package/dist/assets/FileBrowser-D04I8Pvv.css +1 -0
  8. package/dist/assets/FileBrowser-DAjSQXvM.js +4 -0
  9. package/dist/assets/MemoryDashboard-CANryTDV.js +1 -0
  10. package/dist/assets/MemoryDashboard-DgugVDad.css +1 -0
  11. package/dist/assets/MobileAccessModal-BukNLr4c.js +2 -0
  12. package/dist/assets/MobileAccessModal-DuVy9F1u.css +1 -0
  13. package/dist/assets/ScheduleManager-GwVTsKQO.css +1 -0
  14. package/dist/assets/ScheduleManager-o_9LgcpS.js +2 -0
  15. package/dist/assets/{jsx-runtime-DVeaULmT.js → apiBase-y00JgRBP.js} +1 -1
  16. package/dist/assets/eye-B6FejHNG.js +1 -0
  17. package/dist/assets/index-B3UtHCtQ.css +1 -0
  18. package/dist/assets/index-CqsM0gYR.js +71 -0
  19. package/dist/assets/inter-latin-C2S99t-D.woff2 +0 -0
  20. package/dist/assets/inter-latin-ext-CFHvXkgd.woff2 +0 -0
  21. package/dist/assets/message-square-Dwm7y3C0.js +1 -0
  22. package/dist/index.html +6 -6
  23. package/main-safety-net.cjs +54 -7
  24. package/main.cjs +217 -58
  25. package/package.json +3 -1
  26. package/preload.cjs +24 -0
  27. package/services/agent-process-manager.cjs +101 -13
  28. package/services/auto-updater.cjs +26 -2
  29. package/services/backend-installer-progress-dialog.cjs +9 -9
  30. package/services/keep-awake.cjs +52 -0
  31. package/services/notification-service.cjs +62 -0
  32. package/services/port-manager.cjs +14 -70
  33. package/services/process-tree.cjs +305 -0
  34. package/services/quit-cleanup.cjs +77 -0
  35. package/services/quit-guard.cjs +242 -0
  36. package/services/tray-icons.cjs +174 -0
  37. package/services/tray-manager.cjs +582 -83
  38. package/services/tray-state.cjs +231 -0
  39. package/services/window-keys.cjs +43 -0
  40. package/assets/tray-icon.ico +0 -0
  41. package/dist/assets/AdvancedSettings-5cX0pGZc.js +0 -1
  42. package/dist/assets/AdvancedSettings-Ce6EvcAe.css +0 -1
  43. package/dist/assets/ConnectorsPane-CG7atLb2.css +0 -1
  44. package/dist/assets/ConnectorsPane-CuWxzcBY.js +0 -1
  45. package/dist/assets/DocumentLibrary-BdAKDe8H.js +0 -1
  46. package/dist/assets/DocumentLibrary-DrIP_032.css +0 -1
  47. package/dist/assets/FileBrowser-B8s6hKC5.js +0 -4
  48. package/dist/assets/FileBrowser-Bj6aB6Ro.css +0 -1
  49. package/dist/assets/MemoryDashboard-DQxtAW90.css +0 -1
  50. package/dist/assets/MemoryDashboard-DtolA-HZ.js +0 -1
  51. package/dist/assets/MobileAccessModal-C3nWkpWA.js +0 -2
  52. package/dist/assets/MobileAccessModal-C_t8DG0n.css +0 -1
  53. package/dist/assets/ScheduleManager-CIescbX6.css +0 -1
  54. package/dist/assets/ScheduleManager-HHdD6sko.js +0 -2
  55. package/dist/assets/dm-sans-latin-Xz1IZZA0.woff2 +0 -0
  56. package/dist/assets/index-CFVpPQal.css +0 -1
  57. package/dist/assets/index-DEPbenxC.js +0 -60
  58. package/dist/assets/message-square-Ccmbk96j.js +0 -1
  59. package/dist/assets/space-mono-400-Co7bH5Hm.woff2 +0 -0
  60. package/dist/assets/useEscapeToClose-B8fw5mn-.js +0 -1
@@ -26,6 +26,7 @@ const path = require("path");
26
26
  const fs = require("fs");
27
27
  const os = require("os");
28
28
  const { EventEmitter } = require("events");
29
+ const { killProcessTree, listDescendants } = require("./process-tree.cjs");
29
30
 
30
31
  // ── Constants ────────────────────────────────────────────────────────────
31
32
 
@@ -38,6 +39,9 @@ const MANIFEST_FILENAME = "agent-manifest.json";
38
39
  /** Graceful shutdown timeout before force kill (ms) */
39
40
  const SHUTDOWN_TIMEOUT = 5000;
40
41
 
42
+ /** After the graceful shutdown, how long the tree gets between SIGTERM and SIGKILL (ms) */
43
+ const TREE_KILL_GRACE = 2000;
44
+
41
45
  /** Health check interval (ms) — uses MCP "ping", not "initialize" */
42
46
  const HEALTH_CHECK_INTERVAL = 30000;
43
47
 
@@ -82,6 +86,7 @@ class AgentProcessManager extends EventEmitter {
82
86
  * handlers proxy to that backend's `/api/agents/*` routes (the real install
83
87
  * runtime), so without a resolver those handlers fail loudly rather than
84
88
  * duplicating download/verify/uv logic in the main process (issue #1721).
89
+ * killTree / listDescendants replace the process-tree helpers (tests).
85
90
  */
86
91
  constructor(mainWindow, options = {}) {
87
92
  super();
@@ -110,6 +115,12 @@ class AgentProcessManager extends EventEmitter {
110
115
  */
111
116
  this.processes = {};
112
117
 
118
+ this._killTree = options.killTree || killProcessTree;
119
+ this._listDescendants = options.listDescendants || listDescendants;
120
+
121
+ /** Set by stopAll(): GAIA is quitting, so nothing may start another agent. */
122
+ this._shuttingDown = false;
123
+
113
124
  /** Crash timestamps per agent for rate-limiting restart attempts */
114
125
  this._crashTimes = {};
115
126
 
@@ -130,6 +141,11 @@ class AgentProcessManager extends EventEmitter {
130
141
  * @returns {Promise<{ pid: number }>}
131
142
  */
132
143
  async startAgent(agentId) {
144
+ if (this._shuttingDown) {
145
+ throw new Error(
146
+ `Not starting agent "${agentId}": GAIA is quitting and has already stopped its agents.`
147
+ );
148
+ }
133
149
  if (this.processes[agentId]) {
134
150
  console.log(`[agent-mgr] Agent ${agentId} is already running (PID ${this.processes[agentId].process.pid})`);
135
151
  return { pid: this.processes[agentId].process.pid };
@@ -166,10 +182,18 @@ class AgentProcessManager extends EventEmitter {
166
182
  pendingRpc: {},
167
183
  healthTimer: null,
168
184
  stopping: false, // Set to true during intentional shutdown to suppress crash recovery
185
+ stopPromise: null, // The running stopAgent(), shared by duplicate calls
186
+ stdinError: null, // The error that broke stdin; nothing more can be written once set
169
187
  };
170
188
 
171
189
  this.processes[agentId] = entry;
172
190
 
191
+ // A write to a child that has died fails later, as an 'error' on the
192
+ // stream; without a listener that EPIPE crashes the whole app (#3498).
193
+ child.stdin.on("error", (err) => {
194
+ this._handleStdinError(agentId, child, err);
195
+ });
196
+
173
197
  // ── stdout: JSON-RPC message stream ──
174
198
  child.stdout.on("data", (data) => {
175
199
  this._handleStdout(agentId, data);
@@ -207,6 +231,7 @@ class AgentProcessManager extends EventEmitter {
207
231
  /**
208
232
  * Stop an agent gracefully using JSON-RPC shutdown protocol.
209
233
  * Cross-platform (works on Windows where SIGTERM = TerminateProcess).
234
+ * A second call while the first runs waits for that same stop.
210
235
  * @param {string} agentId
211
236
  * @returns {Promise<void>}
212
237
  */
@@ -217,23 +242,40 @@ class AgentProcessManager extends EventEmitter {
217
242
  return;
218
243
  }
219
244
 
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;
245
+ if (entry.stopPromise) {
246
+ console.log(`[agent-mgr] Agent ${agentId} is already being stopped — waiting for that stop`);
247
+ return entry.stopPromise;
224
248
  }
225
249
 
226
250
  console.log(`[agent-mgr] Stopping agent ${agentId} (PID ${entry.process.pid})...`);
227
251
 
228
252
  // Mark as intentionally stopping — suppresses crash recovery in _handleProcessExit
229
253
  entry.stopping = true;
254
+ entry.stopPromise = this._stopAgent(agentId, entry);
255
+ return entry.stopPromise;
256
+ }
230
257
 
258
+ async _stopAgent(agentId, entry) {
231
259
  // Clear health check timer
232
260
  if (entry.healthTimer) {
233
261
  clearInterval(entry.healthTimer);
234
262
  entry.healthTimer = null;
235
263
  }
236
264
 
265
+ // Read the tree while the agent is alive: once it exits, its children
266
+ // are re-parented on Linux and macOS and can no longer be found from it.
267
+ let knownDescendants = [];
268
+ if (entry.process.exitCode === null && entry.process.signalCode === null) {
269
+ try {
270
+ ({ descendants: knownDescendants } = await this._listDescendants(entry.process.pid));
271
+ } catch (err) {
272
+ console.error(
273
+ `[agent-mgr] Could not list the processes agent ${agentId} started (${err.message}); ` +
274
+ "any it leaves behind will keep running."
275
+ );
276
+ }
277
+ }
278
+
237
279
  // Step 1: Send JSON-RPC shutdown request via stdin
238
280
  try {
239
281
  this._sendJsonRpcRaw(agentId, "shutdown", {});
@@ -247,17 +289,17 @@ class AgentProcessManager extends EventEmitter {
247
289
  // Step 2: Wait up to SHUTDOWN_TIMEOUT for clean exit
248
290
  const exited = await this._waitForExit(agentId, SHUTDOWN_TIMEOUT);
249
291
 
250
- // Step 3: Force kill if still running
251
- if (!exited && this.processes[agentId]) {
292
+ // Step 3: Stop the agent if it ignored the request, and anything it started either way.
293
+ if (!exited) {
252
294
  console.warn(
253
295
  `[agent-mgr] Agent ${agentId} did not exit within ${SHUTDOWN_TIMEOUT}ms, force killing...`
254
296
  );
255
- try {
256
- entry.process.kill(); // SIGKILL on Unix, TerminateProcess on Windows
257
- } catch {
258
- // Already dead
259
- }
260
297
  }
298
+ await this._killTree(entry.process, {
299
+ knownDescendants,
300
+ graceMs: TREE_KILL_GRACE,
301
+ label: `agent ${agentId}`,
302
+ });
261
303
 
262
304
  // Note: _handleProcessExit may have already cleaned up if the process exited.
263
305
  // _cleanupProcess is idempotent, so calling it again is safe.
@@ -626,7 +668,13 @@ class AgentProcessManager extends EventEmitter {
626
668
 
627
669
  entry.pendingRpc[id] = { resolve, reject, timer };
628
670
 
629
- this._sendJsonRpcRaw(agentId, method, params, id);
671
+ try {
672
+ this._sendJsonRpcRaw(agentId, method, params, id);
673
+ } catch (err) {
674
+ clearTimeout(timer);
675
+ delete entry.pendingRpc[id];
676
+ reject(err);
677
+ }
630
678
  });
631
679
  }
632
680
 
@@ -639,6 +687,7 @@ class AgentProcessManager extends EventEmitter {
639
687
  const agentConfigs = this.config.agents || {};
640
688
 
641
689
  for (const [agentId, agentCfg] of Object.entries(agentConfigs)) {
690
+ if (this._shuttingDown) return;
642
691
  if (agentCfg.autoStart && !this.processes[agentId]) {
643
692
  try {
644
693
  await this.startAgent(agentId);
@@ -656,13 +705,16 @@ class AgentProcessManager extends EventEmitter {
656
705
  }
657
706
 
658
707
  /**
659
- * Stop all running agents gracefully.
708
+ * Stop all running agents gracefully. For quitting only: afterwards no
709
+ * agent can be started, so a pending auto-start or crash restart can't
710
+ * spawn one the app will never stop.
660
711
  */
661
712
  async stopAll() {
662
713
  const agentIds = Object.keys(this.processes);
663
714
  console.log(
664
715
  `[agent-mgr] Stopping all agents: ${agentIds.join(", ") || "(none)"}`
665
716
  );
717
+ this._shuttingDown = true;
666
718
  await Promise.all(agentIds.map((id) => this.stopAgent(id)));
667
719
  }
668
720
 
@@ -832,6 +884,7 @@ class AgentProcessManager extends EventEmitter {
832
884
  );
833
885
 
834
886
  setTimeout(async () => {
887
+ if (this._shuttingDown) return;
835
888
  try {
836
889
  await this.startAgent(agentId);
837
890
  console.log(`[agent-mgr] Agent ${agentId} restarted after crash`);
@@ -858,6 +911,11 @@ class AgentProcessManager extends EventEmitter {
858
911
  if (!entry || !entry.process.stdin || entry.process.stdin.destroyed) {
859
912
  throw new Error(`Cannot write to stdin of agent "${agentId}"`);
860
913
  }
914
+ if (entry.stdinError) {
915
+ throw new Error(
916
+ `Cannot write to stdin of agent "${agentId}": the pipe broke earlier (${entry.stdinError.message})`
917
+ );
918
+ }
861
919
 
862
920
  const msg = {
863
921
  jsonrpc: "2.0",
@@ -870,6 +928,36 @@ class AgentProcessManager extends EventEmitter {
870
928
  entry.process.stdin.write(payload);
871
929
  }
872
930
 
931
+ /**
932
+ * The agent's stdin failed — almost always EPIPE because the process is
933
+ * gone. Fail its pending calls now instead of letting them time out, and
934
+ * make sure the death is reported: a child that only closed its stdin is
935
+ * unusable, so it is stopped and its exit goes through the crash path.
936
+ */
937
+ _handleStdinError(agentId, child, err) {
938
+ const entry = this.processes[agentId];
939
+ const current = entry && entry.process === child ? entry : null;
940
+ const exited = child.exitCode !== null || child.signalCode !== null;
941
+ console.error(
942
+ `[agent-mgr] stdin of agent ${agentId} (PID ${child.pid}) failed: ${err.code || "error"} ${err.message}` +
943
+ (exited ? " — the agent had already exited" : "")
944
+ );
945
+ if (!current) return;
946
+ current.stdinError = err;
947
+
948
+ for (const [rpcId, pending] of Object.entries(current.pendingRpc)) {
949
+ clearTimeout(pending.timer);
950
+ delete current.pendingRpc[rpcId];
951
+ pending.reject(new Error(`Agent "${agentId}" stopped reading its input (${err.code || err.message})`));
952
+ }
953
+
954
+ if (exited || current.stopping) return;
955
+ console.error(`[agent-mgr] Agent ${agentId} can no longer receive requests — stopping it`);
956
+ this._killTree(child, { graceMs: TREE_KILL_GRACE, label: `agent ${agentId}` }).catch((killErr) => {
957
+ console.error(`[agent-mgr] Could not stop agent ${agentId} after its stdin broke: ${killErr.message}`);
958
+ });
959
+ }
960
+
873
961
  // ── Private: Health check ────────────────────────────────────────────
874
962
 
875
963
  async _healthCheck(agentId) {
@@ -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
@@ -90,13 +90,13 @@ function buildProgressHtml({ logPath }) {
90
90
  * src/styles/index.css, so the dark theme's role values are mirrored
91
91
  * here. Keep them in step with that file; nothing below names a hex. */
92
92
  :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;
93
+ --bg-primary: #1A1917;
94
+ --bg-tertiary: #2C2B27;
95
+ --border: #4A4740;
96
+ --text-primary: #EFECE6;
97
+ --text-secondary: #B3ADA5;
98
+ --text-muted: #958F87;
99
+ --accent-solid: #D4B47E;
100
100
  }
101
101
  html, body {
102
102
  margin: 0;
@@ -152,7 +152,7 @@ function buildProgressHtml({ logPath }) {
152
152
  }
153
153
  .fill {
154
154
  height: 100%;
155
- background: var(--accent-fill);
155
+ background: var(--accent-solid);
156
156
  border-radius: 9999px;
157
157
  transition: width 300ms ease-out;
158
158
  width: 0%;
@@ -268,7 +268,7 @@ function createProgressWindow() {
268
268
  frame: false,
269
269
  transparent: false,
270
270
  show: false,
271
- backgroundColor: "#17161C", // --bg-primary, dark theme (see the :root block above)
271
+ backgroundColor: "#1A1917", // --bg-primary, dark theme (see the :root block above)
272
272
  title: "Installing GAIA",
273
273
  webPreferences: {
274
274
  nodeIntegration: false,
@@ -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,7 @@ 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");
25
26
 
26
27
  /**
27
28
  * Resolve the GAIA home directory lazily so tests can point it elsewhere
@@ -54,86 +55,29 @@ function findFreePort() {
54
55
  }
55
56
 
56
57
  /**
57
- * Terminate a spawned backend: SIGTERM, wait up to 3 s, then SIGKILL.
58
+ * Terminate a spawned backend and everything it started: SIGTERM the tree,
59
+ * wait up to 3 s, then SIGKILL what is left. Without the tree walk, killing
60
+ * the uv `gaia.exe` launcher on Windows left its Python running.
58
61
  * Accepts either a ChildProcess reference (preferred — lets us
59
62
  * short-circuit via `exitCode` so PID reuse can never hit us) or a raw
60
- * pid (useful for tests). Safe to call with nullish.
63
+ * pid (the orphan cleanup and tests). Safe to call with nullish.
61
64
  */
62
65
  async function killBackend(procOrPid, logger) {
63
66
  const log = (logger && logger.log) || console.log;
64
67
  const logError = (logger && logger.error) || console.error;
65
-
66
68
  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.
69
+ const proc = typeof procOrPid === "object" ? procOrPid : null;
79
70
  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`);
98
- return;
99
- }
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}`);
71
+ log(`[port-manager] child pid ${proc.pid} already exited (exitCode=${proc.exitCode} signal=${proc.signalCode})`);
106
72
  return;
107
73
  }
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
- );
74
+ await killProcessTree(procOrPid, {
75
+ graceMs: 3000,
76
+ forceWaitMs: 1000,
77
+ log,
78
+ logError,
79
+ label: "backend",
80
+ });
137
81
  }
138
82
 
139
83
  /**