@amd-gaia/agent-ui 0.17.3 → 0.17.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.
package/dist/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="./favicon.png" />
7
7
  <title>GAIA</title>
8
- <script type="module" crossorigin src="./assets/index-B4Qzv7Ys.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-iAjQas0m.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="./assets/index-eQemgF08.css">
10
10
  </head>
11
11
  <body>
package/main.cjs CHANGED
@@ -13,7 +13,7 @@
13
13
  // services/notification-service.js — Desktop notifications + permission prompts (T5)
14
14
  // preload.cjs — contextBridge for IPC channels (T0/T1)
15
15
 
16
- const { app, BrowserWindow, shell } = require("electron");
16
+ const { app, BrowserWindow, dialog, shell } = require("electron");
17
17
  const path = require("path");
18
18
  const fs = require("fs");
19
19
  const os = require("os");
@@ -23,16 +23,106 @@ const { spawn } = require("child_process");
23
23
  const TrayManager = require("./services/tray-manager.cjs");
24
24
  const AgentProcessManager = require("./services/agent-process-manager.cjs");
25
25
  const NotificationService = require("./services/notification-service.cjs");
26
+ const PortManager = require("./services/port-manager.cjs");
26
27
  const backendInstaller = require("./services/backend-installer.cjs");
27
28
  const installerProgressDialog = require("./services/backend-installer-progress-dialog.cjs");
28
29
  const autoUpdater = require("./services/auto-updater.cjs");
29
30
  const agentSeeder = require("./services/agent-seeder.cjs");
30
31
 
32
+ // ── F7: Ozone hint (issue #782) ─────────────────────────────────────────────
33
+ // Electron-recommended switch for distro-agnostic Linux behaviour: picks
34
+ // Wayland on Wayland sessions, X11 elsewhere. Must be set before
35
+ // app.whenReady() fires.
36
+ app.commandLine.appendSwitch("ozone-platform-hint", "auto");
37
+
38
+ // ── F7: --no-sandbox on Linux (issue #782) ───────────────────────────────────
39
+ // chrome-sandbox is deleted from the packaged tree by after-pack.cjs so
40
+ // Chromium must use its unprivileged user-namespace sandbox on every launch
41
+ // path. The .desktop Exec= line already carries --no-sandbox via
42
+ // electron-builder.yml linux.executableArgs, but direct `./GAIA.AppImage`
43
+ // invocations bypass the .desktop entry. Appending the switch here makes
44
+ // all Linux launch paths behave identically.
45
+ if (process.platform === "linux") {
46
+ app.commandLine.appendSwitch("no-sandbox");
47
+ }
48
+
49
+ // ── F7: Log tee to ~/.gaia/electron-main.log (issue #782) ───────────────────
50
+ // Users often launch AppImages by double-click, not from a terminal, so
51
+ // console output vanishes. Mirror console.log/error to a file so the
52
+ // diagnostics bundler has something to attach.
53
+ (function installMainLogTee() {
54
+ try {
55
+ const gaiaDir = path.join(os.homedir(), ".gaia");
56
+ try { fs.mkdirSync(gaiaDir, { recursive: true }); } catch { /* ignore */ }
57
+ const logPath = path.join(gaiaDir, "electron-main.log");
58
+
59
+ // Rotate if > 5 MB — truncate to last ~5 MB on startup.
60
+ try {
61
+ const st = fs.statSync(logPath);
62
+ if (st.size > 5 * 1024 * 1024) {
63
+ const fd = fs.openSync(logPath, "r");
64
+ const keep = 5 * 1024 * 1024;
65
+ const buf = Buffer.alloc(keep);
66
+ // readSync can return fewer bytes than requested; only write
67
+ // what we actually read so we don't append zero-padding.
68
+ const bytesRead = fs.readSync(
69
+ fd,
70
+ buf,
71
+ 0,
72
+ keep,
73
+ Math.max(0, st.size - keep),
74
+ );
75
+ fs.closeSync(fd);
76
+ fs.writeFileSync(logPath, buf.subarray(0, bytesRead));
77
+ }
78
+ } catch {
79
+ // ENOENT or permission — best-effort; just fall through.
80
+ }
81
+
82
+ const stream = fs.createWriteStream(logPath, { flags: "a" });
83
+ stream.write(
84
+ `\n──── electron-main opened (${new Date().toISOString()}) pid=${process.pid} ────\n`
85
+ );
86
+
87
+ const flushAndEnd = () => {
88
+ try { stream.end(); } catch { /* ignore */ }
89
+ };
90
+ process.on("exit", flushAndEnd);
91
+
92
+ const wrap = (origFn, level) => (...args) => {
93
+ try {
94
+ const line = args
95
+ .map((a) =>
96
+ typeof a === "string"
97
+ ? a
98
+ : a instanceof Error
99
+ ? (a.stack || a.message || String(a))
100
+ : JSON.stringify(a)
101
+ )
102
+ .join(" ");
103
+ stream.write(`[${new Date().toISOString()}] ${level} ${line}\n`);
104
+ } catch {
105
+ // swallow — we must not recurse into console.error from here
106
+ }
107
+ return origFn.apply(console, args);
108
+ };
109
+ console.log = wrap(console.log.bind(console), "INFO");
110
+ console.warn = wrap(console.warn.bind(console), "WARN");
111
+ console.error = wrap(console.error.bind(console), "ERROR");
112
+ } catch (err) {
113
+ // If this fails, we silently keep the original console — the app must
114
+ // not refuse to launch over a log-tee failure.
115
+ process.stderr.write(`[main] log tee failed: ${err.message}\n`);
116
+ }
117
+ })();
118
+
31
119
  // ── Configuration ──────────────────────────────────────────────────────────
32
120
 
33
121
  const APP_NAME = "GAIA";
34
- const BACKEND_PORT = 4200;
35
- const HEALTH_CHECK_URL = `http://localhost:${BACKEND_PORT}/api/health`;
122
+ // Default fallback only — at runtime we always allocate a free random port
123
+ // via PortManager.findFreePort() to avoid EADDRINUSE on zombie backends
124
+ // from prior aborted sessions (issue #782 / T5).
125
+ const DEFAULT_BACKEND_PORT = 4200;
36
126
  const STARTUP_TIMEOUT = 30000;
37
127
 
38
128
  // Parse CLI args (T11: --minimized flag for auto-start)
@@ -59,6 +149,10 @@ const windowConfig = appConfig.window || {
59
149
  // ── State ──────────────────────────────────────────────────────────────────
60
150
 
61
151
  let backendProcess = null;
152
+ let backendPort = DEFAULT_BACKEND_PORT;
153
+ let healthCheckUrl = `http://localhost:${backendPort}/api/health`;
154
+ let backendStderrTail = [];
155
+ let isIntentionalKill = false;
62
156
  let mainWindow = null;
63
157
 
64
158
  /** @type {TrayManager | null} */
@@ -70,6 +164,11 @@ let agentProcessManager = null;
70
164
  /** @type {NotificationService | null} */
71
165
  let notificationService = null;
72
166
 
167
+ /** @type {PortManager} */
168
+ const portManager = new PortManager({
169
+ logger: { log: console.log.bind(console), error: console.error.bind(console) },
170
+ });
171
+
73
172
  /**
74
173
  * Set to true when the user explicitly quits (via tray "Quit" or Cmd+Q).
75
174
  * Prevents minimize-to-tray from intercepting the close event.
@@ -86,7 +185,7 @@ let isQuitting = false;
86
185
  * Returns the ChildProcess, or null if the gaia binary cannot be found
87
186
  * (shouldn't happen post-ensureBackend, but we guard just in case).
88
187
  */
89
- function startBackend() {
188
+ async function startBackend() {
90
189
  const gaiaCmd = backendInstaller.findGaiaBin();
91
190
 
92
191
  if (!gaiaCmd) {
@@ -96,11 +195,27 @@ function startBackend() {
96
195
  return null;
97
196
  }
98
197
 
99
- console.log(`Starting backend: ${gaiaCmd} chat --ui --ui-port ${BACKEND_PORT}`);
198
+ // F5: always spawn on a free random port. Never reuse/probe — the
199
+ // probe-and-reuse path is spoofable and leaves orphans (issue #782).
200
+ try {
201
+ backendPort = await portManager.findFreePort();
202
+ } catch (err) {
203
+ console.warn(
204
+ `[main] findFreePort failed (${err.message}); falling back to ${DEFAULT_BACKEND_PORT}`
205
+ );
206
+ backendPort = DEFAULT_BACKEND_PORT;
207
+ }
208
+ healthCheckUrl = `http://localhost:${backendPort}/api/health`;
209
+
210
+ console.log(`Starting backend: ${gaiaCmd} chat --ui --ui-port ${backendPort}`);
211
+
212
+ // Reset per-spawn state so a fresh crash dialog doesn't mix tails.
213
+ backendStderrTail = [];
214
+ isIntentionalKill = false;
100
215
 
101
216
  const child = spawn(
102
217
  gaiaCmd,
103
- ["chat", "--ui", "--ui-port", String(BACKEND_PORT)],
218
+ ["chat", "--ui", "--ui-port", String(backendPort)],
104
219
  {
105
220
  cwd: os.homedir(), // Electron's cwd is "/" on macOS when launched from Finder
106
221
  stdio: ["ignore", "pipe", "pipe"],
@@ -116,24 +231,85 @@ function startBackend() {
116
231
  });
117
232
 
118
233
  child.stderr.on("data", (data) => {
119
- const line = data.toString().trim();
120
- if (line) console.log(`[backend] ${line}`);
234
+ const chunk = data.toString();
235
+ chunk.split(/\r?\n/).forEach((line) => {
236
+ if (!line) return;
237
+ console.log(`[backend] ${line}`);
238
+ // Cap per-line length so pathological no-newline backend output
239
+ // can't balloon the in-memory tail or the crash-dialog body.
240
+ const capped =
241
+ line.length > 2048 ? line.slice(0, 2048) + "…[truncated]" : line;
242
+ backendStderrTail.push(capped);
243
+ if (backendStderrTail.length > 20) backendStderrTail.shift();
244
+ });
121
245
  });
122
246
 
123
247
  child.on("error", (err) => {
124
248
  console.error("Failed to start backend:", err.message);
125
249
  });
126
250
 
127
- child.on("exit", (code) => {
251
+ child.on("exit", (code, signal) => {
128
252
  if (code !== 0 && code !== null) {
129
- console.error(`Backend exited with code ${code}`);
253
+ console.error(`Backend exited with code ${code} (signal=${signal})`);
130
254
  }
255
+ const crashed = !isIntentionalKill && code !== 0 && code !== null;
131
256
  backendProcess = null;
257
+
258
+ if (crashed && !isQuitting) {
259
+ // Fire-and-forget — don't block the event loop.
260
+ void handleBackendCrash(code, signal);
261
+ }
132
262
  });
133
263
 
134
264
  return child;
135
265
  }
136
266
 
267
+ /**
268
+ * Show a user-facing crash dialog with the last ~20 stderr lines and
269
+ * offer to write a diagnostics bundle. Invoked from child.on("exit")
270
+ * when the kill was NOT initiated by us.
271
+ */
272
+ async function handleBackendCrash(code, signal) {
273
+ const tail = backendStderrTail.slice(-20).join("\n") || "(no stderr captured)";
274
+ const detail =
275
+ `The GAIA backend exited unexpectedly (code=${code}, signal=${signal}).\n\n` +
276
+ `Recent log output:\n${tail}`;
277
+
278
+ try {
279
+ const choice = await dialog.showMessageBox({
280
+ type: "error",
281
+ title: "GAIA backend crashed",
282
+ message: "GAIA backend crashed",
283
+ detail,
284
+ buttons: ["Copy diagnostics", "Quit"],
285
+ defaultId: 0,
286
+ cancelId: 1,
287
+ noLink: true,
288
+ });
289
+
290
+ if (choice.response === 0) {
291
+ try {
292
+ const bundlePath = await portManager.writeDiagnosticsBundle();
293
+ await dialog.showMessageBox({
294
+ type: "info",
295
+ title: "Diagnostics saved",
296
+ message: "Diagnostics bundle written",
297
+ detail: `Attach this file to your bug report:\n${bundlePath}`,
298
+ buttons: ["OK"],
299
+ });
300
+ } catch (err) {
301
+ console.error("[main] Could not write diagnostics bundle:", err.message);
302
+ dialog.showErrorBox(
303
+ "Could not write diagnostics",
304
+ `Failed to write diagnostics bundle: ${err.message}`
305
+ );
306
+ }
307
+ }
308
+ } catch (err) {
309
+ console.error("[main] handleBackendCrash failed:", err.message);
310
+ }
311
+ }
312
+
137
313
  async function waitForBackend(timeoutMs) {
138
314
  const start = Date.now();
139
315
  const http = require("http");
@@ -141,7 +317,7 @@ async function waitForBackend(timeoutMs) {
141
317
  while (Date.now() - start < timeoutMs) {
142
318
  try {
143
319
  await new Promise((resolve, reject) => {
144
- const req = http.get(HEALTH_CHECK_URL, (res) => {
320
+ const req = http.get(healthCheckUrl, (res) => {
145
321
  if (res.statusCode === 200) {
146
322
  resolve();
147
323
  } else {
@@ -221,10 +397,12 @@ function createWindow() {
221
397
 
222
398
  // Show window when ready (unless --minimized or startMinimized config)
223
399
  mainWindow.once("ready-to-show", () => {
400
+ console.log("[main] ready-to-show fired");
224
401
  const shouldStartMinimized =
225
402
  startMinimized || (trayManager && trayManager.startMinimized);
226
403
 
227
404
  if (!shouldStartMinimized) {
405
+ console.log("[main] mainWindow.show() called");
228
406
  mainWindow.show();
229
407
  } else {
230
408
  console.log("[main] Starting minimized to tray");
@@ -241,9 +419,15 @@ async function loadApp() {
241
419
  // Always load the bundled frontend from the asar. The backend only
242
420
  // serves the API (no frontend files in the pip package), so loading
243
421
  // http://localhost:4200/ would show raw JSON instead of the UI.
422
+ //
423
+ // Pass the real backend base URL as a query parameter so the renderer
424
+ // can reach whatever random port port-manager picked (see #851).
425
+ // Without this, the compiled bundle falls back to its hardcoded
426
+ // `http://localhost:4200/api` and every API call 404s.
244
427
  const indexPath = path.join(distPath, "index.html");
245
- console.log("Loading app from:", indexPath);
246
- await mainWindow.loadFile(indexPath);
428
+ const apiBase = `http://127.0.0.1:${backendPort}/api`;
429
+ console.log("Loading app from:", indexPath, "api:", apiBase);
430
+ await mainWindow.loadFile(indexPath, { query: { api: apiBase } });
247
431
  } else {
248
432
  // Show a simple loading/error page
249
433
  mainWindow.loadURL(
@@ -254,7 +438,7 @@ async function loadApp() {
254
438
  <div style="text-align:center;">
255
439
  <h1>${APP_NAME}</h1>
256
440
  <p>Waiting for backend to start...</p>
257
- <p style="color:#888; font-size:12px;">Backend: http://localhost:${BACKEND_PORT}</p>
441
+ <p style="color:#888; font-size:12px;">Backend: http://localhost:${backendPort}</p>
258
442
  </div>
259
443
  </body>
260
444
  </html>`
@@ -271,7 +455,7 @@ function initializeServices() {
271
455
  agentProcessManager = new AgentProcessManager(mainWindow);
272
456
 
273
457
  // T1: Tray Manager (system tray icon + context menu)
274
- trayManager = new TrayManager(mainWindow, { backendPort: BACKEND_PORT });
458
+ trayManager = new TrayManager(mainWindow, { backendPort });
275
459
  trayManager.create();
276
460
 
277
461
  // T5: Notification Service (routes agent notifications to OS + renderer)
@@ -369,6 +553,7 @@ async function bootstrapBackend() {
369
553
  try {
370
554
  await backendInstaller.ensureBackend({
371
555
  onProgress: progress.onProgress,
556
+ isPackaged: app.isPackaged,
372
557
  });
373
558
  progress.close();
374
559
  console.log("[main] Backend bootstrap complete");
@@ -484,7 +669,7 @@ app.whenReady().then(async () => {
484
669
  }
485
670
 
486
671
  // Start the Python backend
487
- backendProcess = startBackend();
672
+ backendProcess = await startBackend();
488
673
 
489
674
  // Create the window (hidden until ready-to-show)
490
675
  createWindow();
@@ -520,7 +705,7 @@ app.whenReady().then(async () => {
520
705
  console.log("Waiting for backend to start...");
521
706
  const ready = await waitForBackend(STARTUP_TIMEOUT);
522
707
  if (ready) {
523
- console.log("Backend API is ready on port", BACKEND_PORT);
708
+ console.log("Backend API is ready on port", backendPort);
524
709
  } else {
525
710
  console.warn("Backend did not respond within timeout.");
526
711
  }
@@ -575,6 +760,9 @@ let cleanupDone = false;
575
760
 
576
761
  app.on("before-quit", () => {
577
762
  isQuitting = true;
763
+ // Mark any subsequent backend exit as intentional so we don't pop the
764
+ // crash dialog during normal shutdown.
765
+ isIntentionalKill = true;
578
766
  });
579
767
 
580
768
  app.on("will-quit", (event) => {
@@ -628,39 +816,34 @@ async function cleanup() {
628
816
  trayManager = null;
629
817
  }
630
818
 
631
- // Stop the Python backend
819
+ // Stop the Python backend via the port-manager (SIGTERM → wait 3 s → SIGKILL).
820
+ // F5: previously we did this inline; extracted so main.cjs stays lean and
821
+ // to prevent orphan leaks across runs (issue #782).
632
822
  if (backendProcess) {
633
823
  console.log("Stopping backend process...");
634
- const proc = backendProcess; // Save reference before nulling
824
+ const proc = backendProcess;
635
825
  backendProcess = null;
826
+ isIntentionalKill = true;
636
827
 
637
828
  try {
638
- proc.kill("SIGTERM");
639
- } catch {
640
- // Already dead
829
+ // Pass the ChildProcess handle (not a bare pid) so port-manager
830
+ // can short-circuit via `proc.exitCode` and close the PID-reuse
831
+ // TOCTOU window.
832
+ await portManager.killBackend(proc);
833
+ } catch (err) {
834
+ console.error("Error stopping backend:", err.message);
641
835
  }
642
836
 
643
- // Wait for the process to exit, with a force-kill fallback
644
- await new Promise((resolve) => {
645
- // Check if already exited (exitCode is set once the process exits)
646
- if (proc.exitCode !== null) {
647
- resolve();
648
- return;
649
- }
650
-
651
- const forceKillTimer = setTimeout(() => {
652
- try {
653
- proc.kill("SIGKILL");
654
- } catch {
655
- // Already dead
656
- }
657
- resolve();
658
- }, 3000);
659
-
660
- proc.once("exit", () => {
661
- clearTimeout(forceKillTimer);
662
- resolve();
837
+ // If the child object still reports alive (race), give its exit
838
+ // listener a brief moment to fire before we continue teardown.
839
+ if (proc.exitCode === null) {
840
+ await new Promise((resolve) => {
841
+ const timer = setTimeout(resolve, 500);
842
+ proc.once("exit", () => {
843
+ clearTimeout(timer);
844
+ resolve();
845
+ });
663
846
  });
664
- });
847
+ }
665
848
  }
666
849
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amd-gaia/agent-ui",
3
- "version": "0.17.3",
3
+ "version": "0.17.4",
4
4
  "type": "module",
5
5
  "productName": "GAIA Agent UI",
6
6
  "description": "Privacy-first agentic AI interface with document Q&A - runs 100% locally on AMD Ryzen AI",