@amd-gaia/agent-ui 0.24.1 → 0.25.0-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 (33) hide show
  1. package/README.md +4 -3
  2. package/bin/gaia-ui.cjs +30 -12
  3. package/dist/assets/AdvancedSettings-Ce6EvcAe.css +1 -0
  4. package/dist/assets/AdvancedSettings-DabrR6sf.js +1 -0
  5. package/dist/assets/ConnectorsPane-CG7atLb2.css +1 -0
  6. package/dist/assets/ConnectorsPane-KLjT1y3F.js +1 -0
  7. package/dist/assets/DocumentLibrary-CElkmNvc.js +1 -0
  8. package/dist/assets/DocumentLibrary-DrIP_032.css +1 -0
  9. package/dist/assets/FileBrowser-Bj6aB6Ro.css +1 -0
  10. package/dist/assets/FileBrowser-ROrpBhVm.js +4 -0
  11. package/dist/assets/MemoryDashboard-B-YH4b1Z.css +1 -0
  12. package/dist/assets/MemoryDashboard-DG9P8Bqf.js +1 -0
  13. package/dist/assets/MobileAccessModal-C_t8DG0n.css +1 -0
  14. package/dist/assets/MobileAccessModal-Jub4U6L2.js +2 -0
  15. package/dist/assets/ScheduleManager-BStuwsjn.js +2 -0
  16. package/dist/assets/ScheduleManager-CIescbX6.css +1 -0
  17. package/dist/assets/{browser-BKCjVmVD.js → browser-CCrYwriZ.js} +2 -2
  18. package/dist/assets/index-B8E7IBmc.css +1 -0
  19. package/dist/assets/index-CXbVMk3u.js +58 -0
  20. package/dist/assets/jsx-runtime-DVeaULmT.js +1 -0
  21. package/dist/assets/message-square-Ccmbk96j.js +1 -0
  22. package/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/index.html +7 -2
  24. package/main.cjs +133 -42
  25. package/package.json +9 -9
  26. package/services/backend-installer-progress-dialog.cjs +34 -15
  27. package/services/backend-installer.cjs +29 -13
  28. package/services/deep-link-queue.cjs +77 -0
  29. package/services/link-policy.cjs +90 -0
  30. package/services/notification-service.cjs +36 -14
  31. package/services/tray-manager.cjs +163 -23
  32. package/dist/assets/index-0fRr8P_5.js +0 -69
  33. package/dist/assets/index-D1S2dWYe.css +0 -1
@@ -0,0 +1,77 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * deep-link-queue.cjs — hold gaia:// links until the backend can verify them.
6
+ *
7
+ * A cold-start "Open in GAIA" link reaches the app long before the backend
8
+ * listens, and the install gate resolves the agent through the backend's
9
+ * catalog API (failing closed). This queue holds every link that arrives
10
+ * before startup has waited for the backend, then dispatches each exactly
11
+ * once. Electron-free so the ordering can be unit-tested.
12
+ */
13
+
14
+ "use strict";
15
+
16
+ /**
17
+ * @param {object} deps
18
+ * @param {(rawUrl: string) => void} deps.dispatch Act on one validated link.
19
+ * @param {(rawUrls: string[]) => void} deps.reportNotReady Tell the user the
20
+ * queued links could not be opened because the backend never came up.
21
+ * @param {{log: Function}} [deps.logger]
22
+ */
23
+ function createStartupDeepLinkQueue({ dispatch, reportNotReady, logger = console }) {
24
+ if (typeof dispatch !== "function" || typeof reportNotReady !== "function") {
25
+ throw new Error("createStartupDeepLinkQueue requires dispatch and reportNotReady functions");
26
+ }
27
+ const pending = [];
28
+ let drained = false;
29
+
30
+ return {
31
+ /**
32
+ * Accept a validated link: queue it until drain(), dispatch it after.
33
+ * @returns {"queued" | "duplicate" | "dispatched"}
34
+ */
35
+ accept(rawUrl) {
36
+ if (drained) {
37
+ dispatch(rawUrl);
38
+ return "dispatched";
39
+ }
40
+ if (pending.includes(rawUrl)) return "duplicate";
41
+ pending.push(rawUrl);
42
+ logger.log(`[deep-link] Queued until the backend is ready: ${rawUrl}`);
43
+ return "queued";
44
+ },
45
+
46
+ /**
47
+ * Release the queue once, after startup has waited for the backend.
48
+ * Later calls are no-ops, so nothing is dispatched twice.
49
+ *
50
+ * @param {{backendReady: boolean, argvUrl?: string|null}} opts
51
+ * @returns {string[]} The links dispatched by this call.
52
+ */
53
+ drain({ backendReady, argvUrl = null }) {
54
+ if (drained) return [];
55
+ drained = true;
56
+ const urls = pending.splice(0);
57
+ if (argvUrl && !urls.includes(argvUrl)) urls.push(argvUrl);
58
+ if (urls.length === 0) return [];
59
+ if (!backendReady) {
60
+ reportNotReady(urls);
61
+ return [];
62
+ }
63
+ for (const url of urls) dispatch(url);
64
+ return urls;
65
+ },
66
+
67
+ get isDrained() {
68
+ return drained;
69
+ },
70
+
71
+ get pendingCount() {
72
+ return pending.length;
73
+ },
74
+ };
75
+ }
76
+
77
+ module.exports = { createStartupDeepLinkQueue };
@@ -0,0 +1,90 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * link-policy.cjs — which URLs the shell is allowed to hand to the OS.
6
+ *
7
+ * The renderer is loaded from `file://`, so a scheme-less link in
8
+ * LLM-rendered markdown (`/Windows/System32/calc.exe`, `//host/share/x.exe`)
9
+ * RESOLVES to `file:///C:/…` / `file://host/…` before Electron hands it to
10
+ * `setWindowOpenHandler`. Passing that to `shell.openExternal` is a
11
+ * ShellExecute on a local binary or an SMB fetch. The check therefore runs on
12
+ * the resolved URL and allows only web/mail schemes.
13
+ *
14
+ * Electron-free so it can be unit-tested without a running app; main.cjs owns
15
+ * the window handlers that call it.
16
+ */
17
+
18
+ "use strict";
19
+
20
+ /** The only schemes `shell.openExternal` may ever receive. */
21
+ const ALLOWED_EXTERNAL_SCHEMES = new Set(["http:", "https:", "mailto:"]);
22
+
23
+ /**
24
+ * @param {string} rawUrl A URL already resolved by Electron/Chromium.
25
+ * @returns {boolean} True when it is safe to hand to `shell.openExternal`.
26
+ */
27
+ function isAllowedExternalUrl(rawUrl) {
28
+ if (typeof rawUrl !== "string" || rawUrl === "") return false;
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(rawUrl);
32
+ } catch {
33
+ return false; // Unresolvable/relative — never openExternal it.
34
+ }
35
+ return ALLOWED_EXTERNAL_SCHEMES.has(parsed.protocol.toLowerCase());
36
+ }
37
+
38
+ /**
39
+ * True when `targetUrl` is the document already loaded, differing only by
40
+ * fragment or query — i.e. an in-app anchor, not a navigation away.
41
+ *
42
+ * @param {string} targetUrl
43
+ * @param {string} currentUrl
44
+ * @returns {boolean}
45
+ */
46
+ function isInAppNavigation(targetUrl, currentUrl) {
47
+ if (typeof targetUrl !== "string" || typeof currentUrl !== "string") return false;
48
+ let target;
49
+ let current;
50
+ try {
51
+ target = new URL(targetUrl);
52
+ current = new URL(currentUrl);
53
+ } catch {
54
+ return false;
55
+ }
56
+ return (
57
+ target.protocol === current.protocol &&
58
+ target.host === current.host &&
59
+ target.pathname === current.pathname
60
+ );
61
+ }
62
+
63
+ /**
64
+ * A one-line, user-facing reason a URL was refused. Never silently dropped —
65
+ * the caller logs this and shows it, so a blocked click is explainable.
66
+ *
67
+ * @param {string} rawUrl
68
+ * @returns {string}
69
+ */
70
+ function describeBlockedUrl(rawUrl) {
71
+ const shown = typeof rawUrl === "string" && rawUrl ? rawUrl : String(rawUrl);
72
+ let scheme = "no scheme";
73
+ try {
74
+ scheme = new URL(shown).protocol.replace(/:$/, "");
75
+ } catch {
76
+ /* keep "no scheme" */
77
+ }
78
+ return (
79
+ `GAIA refused to open "${shown.slice(0, 300)}" (${scheme}). ` +
80
+ "Only http, https and mailto links open outside the app — a link that " +
81
+ "resolves to a local file or network share is never launched."
82
+ );
83
+ }
84
+
85
+ module.exports = {
86
+ ALLOWED_EXTERNAL_SCHEMES,
87
+ isAllowedExternalUrl,
88
+ isInAppNavigation,
89
+ describeBlockedUrl,
90
+ };
@@ -330,36 +330,58 @@ class NotificationService extends EventEmitter {
330
330
  // Listen for agent notifications via the EventEmitter
331
331
  this.agentProcessManager.on(
332
332
  "agent-notification",
333
- (agentId, params) => {
333
+ this._guardListener("agent-notification", (agentId, params) => {
334
334
  this.handleAgentNotification(agentId, params);
335
- }
335
+ })
336
336
  );
337
337
 
338
338
  // Agent crash → generate error notification
339
- this.agentProcessManager.on("status-change", (payload) => {
340
- if (payload.status === "stopped" && payload.detail) {
341
- // Only notify on unexpected stops (crashes)
342
- this.handleAgentNotification(payload.agentId, {
343
- type: "error",
344
- title: "Agent Crashed",
345
- message: payload.detail || `Agent ${payload.agentId} stopped unexpectedly`,
346
- });
347
- }
348
- });
339
+ this.agentProcessManager.on(
340
+ "status-change",
341
+ this._guardListener("status-change", (payload) => {
342
+ if (payload.status === "stopped" && payload.detail) {
343
+ // Only notify on unexpected stops (crashes)
344
+ this.handleAgentNotification(payload.agentId, {
345
+ type: "error",
346
+ title: "Agent Crashed",
347
+ message: payload.detail || `Agent ${payload.agentId} stopped unexpectedly`,
348
+ });
349
+ }
350
+ })
351
+ );
349
352
 
350
353
  // Crash limit reached → generate error notification
351
354
  this.agentProcessManager.on(
352
355
  "agent-crash-limit",
353
- (agentId, crashCount) => {
356
+ this._guardListener("agent-crash-limit", (agentId, crashCount) => {
354
357
  this.handleAgentNotification(agentId, {
355
358
  type: "error",
356
359
  title: "Agent Crash Limit Reached",
357
360
  message: `Agent ${agentId} crashed ${crashCount} times — automatic restart disabled`,
358
361
  });
359
- }
362
+ })
360
363
  );
361
364
  }
362
365
 
366
+ /**
367
+ * Wrap an EventEmitter listener so a throw inside it is logged instead of
368
+ * escaping as an uncaughtException — these fire from a child-process `exit`
369
+ * handler, where an unhandled throw takes the whole app down with the
370
+ * backend and sidecars still running.
371
+ */
372
+ _guardListener(eventName, fn) {
373
+ return (...args) => {
374
+ try {
375
+ fn(...args);
376
+ } catch (err) {
377
+ console.error(
378
+ `[notif] Listener for "${eventName}" threw and was contained: ` +
379
+ `${err && err.stack ? err.stack : err}`
380
+ );
381
+ }
382
+ };
383
+ }
384
+
363
385
  // ── Private: Persistence ─────────────────────────────────────────────
364
386
 
365
387
  _loadNotifications() {
@@ -33,8 +33,47 @@ const DEFAULT_CONFIG = {
33
33
  startMinimized: false,
34
34
  startOnLogin: false,
35
35
  },
36
+ agents: {},
36
37
  };
37
38
 
39
+ const AGENT_LOG_LEVELS = ["debug", "info", "warn", "error"];
40
+
41
+ function isPlainObject(value) {
42
+ return value !== null && typeof value === "object" && !Array.isArray(value);
43
+ }
44
+
45
+ /** Validate an `agents` section (id → AgentConfig), dropping unknown keys. */
46
+ function validateAgents(agents, where) {
47
+ if (!isPlainObject(agents)) {
48
+ throw new TypeError(`${where}: agents must be an object`);
49
+ }
50
+ const out = {};
51
+ for (const [id, cfg] of Object.entries(agents)) {
52
+ if (id.trim() === "") {
53
+ throw new TypeError(`${where}: agent id must be a non-empty string`);
54
+ }
55
+ if (!isPlainObject(cfg)) {
56
+ throw new TypeError(`${where}: agents.${id} must be an object`);
57
+ }
58
+ for (const key of ["autoStart", "restartOnCrash"]) {
59
+ if (typeof cfg[key] !== "boolean") {
60
+ throw new TypeError(`${where}: agents.${id}.${key} must be a boolean`);
61
+ }
62
+ }
63
+ if (!AGENT_LOG_LEVELS.includes(cfg.logLevel)) {
64
+ throw new TypeError(
65
+ `${where}: agents.${id}.logLevel must be one of ${AGENT_LOG_LEVELS.join(", ")}`
66
+ );
67
+ }
68
+ out[id] = {
69
+ autoStart: cfg.autoStart,
70
+ restartOnCrash: cfg.restartOnCrash,
71
+ logLevel: cfg.logLevel,
72
+ };
73
+ }
74
+ return out;
75
+ }
76
+
38
77
  // ── TrayManager ──────────────────────────────────────────────────────────
39
78
 
40
79
  class TrayManager {
@@ -53,6 +92,9 @@ class TrayManager {
53
92
  /** @type {Electron.Tray | null} */
54
93
  this.tray = null;
55
94
 
95
+ /** @type {number} Unread notifications reflected in the tooltip/badge. */
96
+ this._notificationCount = 0;
97
+
56
98
  /** @type {object} */
57
99
  this.config = this._loadConfig();
58
100
 
@@ -69,7 +111,8 @@ class TrayManager {
69
111
  if (this.tray) return;
70
112
 
71
113
  this.tray = new Tray(this._icon);
72
- this.tray.setToolTip("GAIA");
114
+ // Re-apply any count that arrived before the tray existed.
115
+ this.setNotificationCount(this._notificationCount);
73
116
 
74
117
  // Single-click: show/focus window
75
118
  this.tray.on("click", () => this._showWindow());
@@ -95,6 +138,45 @@ class TrayManager {
95
138
  this._rebuildContextMenu();
96
139
  }
97
140
 
141
+ /**
142
+ * Reflect the unread-notification count in the tray tooltip (and the dock /
143
+ * launcher badge where the platform has one).
144
+ *
145
+ * @param {number} count Unread notifications; negative/NaN is treated as 0.
146
+ */
147
+ setNotificationCount(count) {
148
+ const unread =
149
+ typeof count === "number" && Number.isFinite(count) && count > 0
150
+ ? Math.floor(count)
151
+ : 0;
152
+ if (unread !== this._notificationCount) {
153
+ console.log(`[tray] Unread notifications: ${unread}`);
154
+ }
155
+ this._notificationCount = unread;
156
+
157
+ const trayAlive =
158
+ this.tray &&
159
+ !(typeof this.tray.isDestroyed === "function" && this.tray.isDestroyed());
160
+ if (trayAlive) {
161
+ this.tray.setToolTip(
162
+ unread > 0
163
+ ? `GAIA — ${unread} unread notification${unread === 1 ? "" : "s"}`
164
+ : "GAIA"
165
+ );
166
+ }
167
+
168
+ // Dock (macOS) / Unity launcher (Linux) badge. Windows has no
169
+ // app.setBadgeCount equivalent — the tooltip above is the badge there.
170
+ if (process.platform !== "win32" && typeof app.setBadgeCount === "function") {
171
+ app.setBadgeCount(unread);
172
+ }
173
+ }
174
+
175
+ /** @returns {number} The unread count last passed to setNotificationCount. */
176
+ get notificationCount() {
177
+ return this._notificationCount || 0;
178
+ }
179
+
98
180
  /** @returns {boolean} Whether minimize-to-tray is enabled. */
99
181
  get minimizeToTray() {
100
182
  return this.config.tray.minimizeToTray;
@@ -184,32 +266,63 @@ class TrayManager {
184
266
  // ── Private: Config persistence ──────────────────────────────────────
185
267
 
186
268
  _loadConfig() {
269
+ let loaded = null;
187
270
  try {
188
271
  if (fs.existsSync(CONFIG_PATH)) {
189
- const raw = fs.readFileSync(CONFIG_PATH, "utf8");
190
- const loaded = JSON.parse(raw);
191
- return {
192
- ...DEFAULT_CONFIG,
193
- ...loaded,
194
- tray: { ...DEFAULT_CONFIG.tray, ...(loaded.tray || {}) },
195
- };
272
+ loaded = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
196
273
  }
197
274
  } catch (err) {
198
275
  console.warn("[tray] Could not load tray config:", err.message);
199
276
  }
200
- return { ...DEFAULT_CONFIG };
201
- }
277
+ if (!isPlainObject(loaded)) {
278
+ return { ...DEFAULT_CONFIG, tray: { ...DEFAULT_CONFIG.tray }, agents: {} };
279
+ }
202
280
 
203
- _saveConfig() {
281
+ // Scoped to the agents section: one bad entry must not cost the user their
282
+ // tray settings, which the next save would then overwrite with defaults.
283
+ let agents = {};
204
284
  try {
205
- if (!fs.existsSync(GAIA_DIR)) {
206
- fs.mkdirSync(GAIA_DIR, { recursive: true });
285
+ agents = validateAgents(loaded.agents ?? {}, CONFIG_PATH);
286
+ } catch (err) {
287
+ console.error(
288
+ `[tray] Ignoring invalid agents section in ${CONFIG_PATH}: ${err.message}. ` +
289
+ "Agent settings fall back to defaults; tray settings are kept."
290
+ );
291
+ }
292
+
293
+ // Repair on read: the pre-validation handler could store wrong-typed
294
+ // values, and set-config re-reads stored keys the payload omits.
295
+ const stored = isPlainObject(loaded.tray) ? loaded.tray : {};
296
+ const tray = { ...DEFAULT_CONFIG.tray };
297
+ for (const key of Object.keys(DEFAULT_CONFIG.tray)) {
298
+ if (typeof stored[key] === "boolean") {
299
+ tray[key] = stored[key];
300
+ } else if (key in stored) {
301
+ console.warn(
302
+ `[tray] Ignoring non-boolean ${key} in ${CONFIG_PATH}; using default`
303
+ );
207
304
  }
208
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(this.config, null, 2), "utf8");
209
- console.log("[tray] Config saved to", CONFIG_PATH);
305
+ }
306
+
307
+ return {
308
+ ...DEFAULT_CONFIG,
309
+ ...loaded,
310
+ tray,
311
+ agents,
312
+ };
313
+ }
314
+
315
+ _saveConfig(config) {
316
+ try {
317
+ fs.mkdirSync(GAIA_DIR, { recursive: true });
318
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
210
319
  } catch (err) {
211
- console.error("[tray] Could not save tray config:", err.message);
320
+ throw new Error(
321
+ `Could not save settings to ${CONFIG_PATH}: ${err.message}. ` +
322
+ "Check that the file and its folder are writable."
323
+ );
212
324
  }
325
+ console.log("[tray] Config saved to", CONFIG_PATH);
213
326
  }
214
327
 
215
328
  // ── Private: IPC handlers ────────────────────────────────────────────
@@ -220,15 +333,42 @@ class TrayManager {
220
333
  });
221
334
 
222
335
  ipcMain.handle("tray:set-config", (_event, cfg) => {
223
- if (cfg.tray) {
224
- this.config.tray = { ...this.config.tray, ...cfg.tray };
336
+ if (!isPlainObject(cfg)) {
337
+ throw new TypeError("tray:set-config expects an object payload");
338
+ }
339
+ if (cfg.tray !== undefined && !isPlainObject(cfg.tray)) {
340
+ throw new TypeError("tray:set-config: tray must be an object");
341
+ }
342
+ const agents =
343
+ cfg.agents === undefined
344
+ ? null
345
+ : validateAgents(cfg.agents, "tray:set-config");
346
+
347
+ let tray = null;
348
+ if (cfg.tray !== undefined) {
349
+ tray = {};
350
+ for (const key of Object.keys(DEFAULT_CONFIG.tray)) {
351
+ const value = key in cfg.tray ? cfg.tray[key] : this.config.tray[key];
352
+ if (typeof value !== "boolean") {
353
+ throw new TypeError(`tray:set-config: tray.${key} must be a boolean`);
354
+ }
355
+ tray[key] = value;
356
+ }
225
357
  }
226
358
 
227
- this._saveConfig();
228
-
229
- // Apply login-item setting if changed
230
- if (cfg.tray && "startOnLogin" in cfg.tray) {
231
- this._applyLoginItemSetting(cfg.tray.startOnLogin);
359
+ if (tray === null && agents === null) {
360
+ return this.config;
361
+ }
362
+ const next = {
363
+ ...this.config,
364
+ tray: tray ?? this.config.tray,
365
+ agents: agents ? { ...this.config.agents, ...agents } : this.config.agents,
366
+ };
367
+ this._saveConfig(next);
368
+ this.config = next;
369
+
370
+ if (tray !== null && "startOnLogin" in cfg.tray) {
371
+ this._applyLoginItemSetting(tray.startOnLogin);
232
372
  }
233
373
 
234
374
  return this.config;