@amd-gaia/agent-ui 0.17.2 → 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,8 +5,8 @@
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-CmLC9Yd5.js"></script>
9
- <link rel="stylesheet" crossorigin href="./assets/index-DdsmIsYZ.css">
8
+ <script type="module" crossorigin src="./assets/index-iAjQas0m.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./assets/index-eQemgF08.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
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,15 +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");
30
+ const agentSeeder = require("./services/agent-seeder.cjs");
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
+ })();
29
118
 
30
119
  // ── Configuration ──────────────────────────────────────────────────────────
31
120
 
32
121
  const APP_NAME = "GAIA";
33
- const BACKEND_PORT = 4200;
34
- 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;
35
126
  const STARTUP_TIMEOUT = 30000;
36
127
 
37
128
  // Parse CLI args (T11: --minimized flag for auto-start)
@@ -58,6 +149,10 @@ const windowConfig = appConfig.window || {
58
149
  // ── State ──────────────────────────────────────────────────────────────────
59
150
 
60
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;
61
156
  let mainWindow = null;
62
157
 
63
158
  /** @type {TrayManager | null} */
@@ -69,6 +164,11 @@ let agentProcessManager = null;
69
164
  /** @type {NotificationService | null} */
70
165
  let notificationService = null;
71
166
 
167
+ /** @type {PortManager} */
168
+ const portManager = new PortManager({
169
+ logger: { log: console.log.bind(console), error: console.error.bind(console) },
170
+ });
171
+
72
172
  /**
73
173
  * Set to true when the user explicitly quits (via tray "Quit" or Cmd+Q).
74
174
  * Prevents minimize-to-tray from intercepting the close event.
@@ -85,7 +185,7 @@ let isQuitting = false;
85
185
  * Returns the ChildProcess, or null if the gaia binary cannot be found
86
186
  * (shouldn't happen post-ensureBackend, but we guard just in case).
87
187
  */
88
- function startBackend() {
188
+ async function startBackend() {
89
189
  const gaiaCmd = backendInstaller.findGaiaBin();
90
190
 
91
191
  if (!gaiaCmd) {
@@ -95,11 +195,27 @@ function startBackend() {
95
195
  return null;
96
196
  }
97
197
 
98
- 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;
99
215
 
100
216
  const child = spawn(
101
217
  gaiaCmd,
102
- ["chat", "--ui", "--ui-port", String(BACKEND_PORT)],
218
+ ["chat", "--ui", "--ui-port", String(backendPort)],
103
219
  {
104
220
  cwd: os.homedir(), // Electron's cwd is "/" on macOS when launched from Finder
105
221
  stdio: ["ignore", "pipe", "pipe"],
@@ -115,24 +231,85 @@ function startBackend() {
115
231
  });
116
232
 
117
233
  child.stderr.on("data", (data) => {
118
- const line = data.toString().trim();
119
- 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
+ });
120
245
  });
121
246
 
122
247
  child.on("error", (err) => {
123
248
  console.error("Failed to start backend:", err.message);
124
249
  });
125
250
 
126
- child.on("exit", (code) => {
251
+ child.on("exit", (code, signal) => {
127
252
  if (code !== 0 && code !== null) {
128
- console.error(`Backend exited with code ${code}`);
253
+ console.error(`Backend exited with code ${code} (signal=${signal})`);
129
254
  }
255
+ const crashed = !isIntentionalKill && code !== 0 && code !== null;
130
256
  backendProcess = null;
257
+
258
+ if (crashed && !isQuitting) {
259
+ // Fire-and-forget — don't block the event loop.
260
+ void handleBackendCrash(code, signal);
261
+ }
131
262
  });
132
263
 
133
264
  return child;
134
265
  }
135
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
+
136
313
  async function waitForBackend(timeoutMs) {
137
314
  const start = Date.now();
138
315
  const http = require("http");
@@ -140,7 +317,7 @@ async function waitForBackend(timeoutMs) {
140
317
  while (Date.now() - start < timeoutMs) {
141
318
  try {
142
319
  await new Promise((resolve, reject) => {
143
- const req = http.get(HEALTH_CHECK_URL, (res) => {
320
+ const req = http.get(healthCheckUrl, (res) => {
144
321
  if (res.statusCode === 200) {
145
322
  resolve();
146
323
  } else {
@@ -220,10 +397,12 @@ function createWindow() {
220
397
 
221
398
  // Show window when ready (unless --minimized or startMinimized config)
222
399
  mainWindow.once("ready-to-show", () => {
400
+ console.log("[main] ready-to-show fired");
223
401
  const shouldStartMinimized =
224
402
  startMinimized || (trayManager && trayManager.startMinimized);
225
403
 
226
404
  if (!shouldStartMinimized) {
405
+ console.log("[main] mainWindow.show() called");
227
406
  mainWindow.show();
228
407
  } else {
229
408
  console.log("[main] Starting minimized to tray");
@@ -240,9 +419,15 @@ async function loadApp() {
240
419
  // Always load the bundled frontend from the asar. The backend only
241
420
  // serves the API (no frontend files in the pip package), so loading
242
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.
243
427
  const indexPath = path.join(distPath, "index.html");
244
- console.log("Loading app from:", indexPath);
245
- 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 } });
246
431
  } else {
247
432
  // Show a simple loading/error page
248
433
  mainWindow.loadURL(
@@ -253,7 +438,7 @@ async function loadApp() {
253
438
  <div style="text-align:center;">
254
439
  <h1>${APP_NAME}</h1>
255
440
  <p>Waiting for backend to start...</p>
256
- <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>
257
442
  </div>
258
443
  </body>
259
444
  </html>`
@@ -270,7 +455,7 @@ function initializeServices() {
270
455
  agentProcessManager = new AgentProcessManager(mainWindow);
271
456
 
272
457
  // T1: Tray Manager (system tray icon + context menu)
273
- trayManager = new TrayManager(mainWindow, { backendPort: BACKEND_PORT });
458
+ trayManager = new TrayManager(mainWindow, { backendPort });
274
459
  trayManager.create();
275
460
 
276
461
  // T5: Notification Service (routes agent notifications to OS + renderer)
@@ -368,6 +553,7 @@ async function bootstrapBackend() {
368
553
  try {
369
554
  await backendInstaller.ensureBackend({
370
555
  onProgress: progress.onProgress,
556
+ isPackaged: app.isPackaged,
371
557
  });
372
558
  progress.close();
373
559
  console.log("[main] Backend bootstrap complete");
@@ -454,6 +640,25 @@ app.on("second-instance", (_event, _argv, _cwd) => {
454
640
  });
455
641
 
456
642
  app.whenReady().then(async () => {
643
+ // Phase 0: seed bundled agents BEFORE the Python backend starts, so the
644
+ // agent registry sees them on its first discovery pass. Failures here are
645
+ // non-fatal — the app must still launch even if seeding is blocked (e.g.
646
+ // permission error on ~/.gaia/agents).
647
+ try {
648
+ const seedResult = await agentSeeder.seedBundledAgents();
649
+ if (seedResult.seeded.length > 0) {
650
+ console.log("[main] Seeded agents:", seedResult.seeded);
651
+ }
652
+ if (seedResult.errors.length > 0) {
653
+ console.warn(
654
+ "[main] Agent seeding errors:",
655
+ seedResult.errors.map((e) => e.id)
656
+ );
657
+ }
658
+ } catch (err) {
659
+ console.warn("[main] Agent seeding failed (non-fatal):", err);
660
+ }
661
+
457
662
  // Phase A: ensure the Python backend is installed BEFORE creating the
458
663
  // main window. The progress dialog owns the UI during this phase.
459
664
  const bootstrapOk = await bootstrapBackend();
@@ -464,7 +669,7 @@ app.whenReady().then(async () => {
464
669
  }
465
670
 
466
671
  // Start the Python backend
467
- backendProcess = startBackend();
672
+ backendProcess = await startBackend();
468
673
 
469
674
  // Create the window (hidden until ready-to-show)
470
675
  createWindow();
@@ -500,7 +705,7 @@ app.whenReady().then(async () => {
500
705
  console.log("Waiting for backend to start...");
501
706
  const ready = await waitForBackend(STARTUP_TIMEOUT);
502
707
  if (ready) {
503
- console.log("Backend API is ready on port", BACKEND_PORT);
708
+ console.log("Backend API is ready on port", backendPort);
504
709
  } else {
505
710
  console.warn("Backend did not respond within timeout.");
506
711
  }
@@ -555,6 +760,9 @@ let cleanupDone = false;
555
760
 
556
761
  app.on("before-quit", () => {
557
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;
558
766
  });
559
767
 
560
768
  app.on("will-quit", (event) => {
@@ -608,39 +816,34 @@ async function cleanup() {
608
816
  trayManager = null;
609
817
  }
610
818
 
611
- // 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).
612
822
  if (backendProcess) {
613
823
  console.log("Stopping backend process...");
614
- const proc = backendProcess; // Save reference before nulling
824
+ const proc = backendProcess;
615
825
  backendProcess = null;
826
+ isIntentionalKill = true;
616
827
 
617
828
  try {
618
- proc.kill("SIGTERM");
619
- } catch {
620
- // 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);
621
835
  }
622
836
 
623
- // Wait for the process to exit, with a force-kill fallback
624
- await new Promise((resolve) => {
625
- // Check if already exited (exitCode is set once the process exits)
626
- if (proc.exitCode !== null) {
627
- resolve();
628
- return;
629
- }
630
-
631
- const forceKillTimer = setTimeout(() => {
632
- try {
633
- proc.kill("SIGKILL");
634
- } catch {
635
- // Already dead
636
- }
637
- resolve();
638
- }, 3000);
639
-
640
- proc.once("exit", () => {
641
- clearTimeout(forceKillTimer);
642
- 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
+ });
643
846
  });
644
- });
847
+ }
645
848
  }
646
849
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amd-gaia/agent-ui",
3
- "version": "0.17.2",
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",