@amd-gaia/agent-ui 0.17.0

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.
@@ -0,0 +1,419 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * GAIA Agent UI — Notification Service (T5-service)
6
+ *
7
+ * Routes notifications from agents to OS native toasts and the renderer process.
8
+ *
9
+ * Design decisions (from spec):
10
+ * - OS native toasts are click-to-focus only (S5 fix) — no action buttons
11
+ * - All interactive prompts (Approve/Deny) happen in-app via PermissionPrompt modal
12
+ * - Permission responses are sent back to agents via JSON-RPC
13
+ * - Notification persistence in ~/.gaia/notifications.json (optional, last 200)
14
+ *
15
+ * Notification types:
16
+ * permission_request — Modal dialog (blocks action) + OS click-to-focus toast
17
+ * security_alert — In-app toast + OS click-to-focus toast
18
+ * status_change — In-app toast (auto-dismiss 5s)
19
+ * info — Notification center only
20
+ * error — In-app toast (persistent) + OS click-to-focus toast
21
+ */
22
+
23
+ const { Notification, ipcMain } = require("electron");
24
+ const { EventEmitter } = require("events");
25
+ const path = require("path");
26
+ const fs = require("fs");
27
+ const os = require("os");
28
+
29
+ // ── Constants ────────────────────────────────────────────────────────────
30
+
31
+ const GAIA_DIR = path.join(os.homedir(), ".gaia");
32
+ const NOTIFICATIONS_PATH = path.join(GAIA_DIR, "notifications.json");
33
+
34
+ /** Max persisted notifications */
35
+ const MAX_PERSISTED = 200;
36
+
37
+ /** Notification types that trigger OS native toasts */
38
+ const OS_TOAST_TYPES = new Set([
39
+ "permission_request",
40
+ "security_alert",
41
+ "error",
42
+ ]);
43
+
44
+ // ── NotificationService ──────────────────────────────────────────────────
45
+
46
+ class NotificationService extends EventEmitter {
47
+ /**
48
+ * @param {Electron.BrowserWindow} mainWindow
49
+ * @param {import('./agent-process-manager')} agentProcessManager
50
+ * @param {import('./tray-manager')} trayManager
51
+ */
52
+ constructor(mainWindow, agentProcessManager, trayManager) {
53
+ super();
54
+
55
+ /** @type {Electron.BrowserWindow} */
56
+ this.mainWindow = mainWindow;
57
+
58
+ /** @type {import('./agent-process-manager')} */
59
+ this.agentProcessManager = agentProcessManager;
60
+
61
+ /** @type {import('./tray-manager')} */
62
+ this.trayManager = trayManager;
63
+
64
+ /**
65
+ * All notifications (in-memory, most recent last).
66
+ * @type {Array<{
67
+ * id: string,
68
+ * type: string,
69
+ * agentId: string,
70
+ * title: string,
71
+ * message: string,
72
+ * tool?: string,
73
+ * toolArgs?: object,
74
+ * actions?: string[],
75
+ * timeoutSeconds?: number,
76
+ * timestamp: number,
77
+ * read: boolean,
78
+ * responded: boolean,
79
+ * response?: { action: string, remember: boolean },
80
+ * }>}
81
+ */
82
+ this.notifications = this._loadNotifications();
83
+
84
+ /** Counter for generating notification IDs (timestamp-based to avoid collisions across restarts) */
85
+ this._idCounter = Date.now();
86
+
87
+ /** Pending permission request timers (auto-deny on timeout) */
88
+ this._permissionTimers = {};
89
+
90
+ this._registerIpcHandlers();
91
+ this._listenToAgentEvents();
92
+ }
93
+
94
+ // ── Public API ───────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Handle an incoming notification from an agent.
98
+ * Called by AgentProcessManager when it receives a "notification/send" JSON-RPC message.
99
+ *
100
+ * @param {string} agentId
101
+ * @param {object} params — from the JSON-RPC notification/send message
102
+ */
103
+ handleAgentNotification(agentId, params) {
104
+ const notif = {
105
+ id: `notif-${this._idCounter++}`,
106
+ type: params.type || "info",
107
+ agentId,
108
+ title: params.title || "Agent Notification",
109
+ message: params.message || "",
110
+ tool: params.tool,
111
+ toolArgs: params.tool_args,
112
+ actions: params.actions,
113
+ timeoutSeconds: params.timeout_seconds,
114
+ timestamp: Date.now(),
115
+ read: false,
116
+ responded: false,
117
+ };
118
+
119
+ // Add to in-memory list
120
+ this.notifications.push(notif);
121
+ if (this.notifications.length > MAX_PERSISTED * 2) {
122
+ this.notifications = this.notifications.slice(-MAX_PERSISTED);
123
+ }
124
+
125
+ console.log(
126
+ `[notif] ${notif.type} from ${agentId}: ${notif.title} — ${notif.message}`
127
+ );
128
+
129
+ // Route based on type
130
+ switch (notif.type) {
131
+ case "permission_request":
132
+ this._handlePermissionRequest(notif);
133
+ break;
134
+ case "security_alert":
135
+ case "error":
136
+ this._sendToRenderer("notification:new", notif);
137
+ this._showOsToast(notif);
138
+ break;
139
+ case "status_change":
140
+ this._sendToRenderer("notification:new", notif);
141
+ break;
142
+ case "info":
143
+ default:
144
+ this._sendToRenderer("notification:new", notif);
145
+ break;
146
+ }
147
+
148
+ // Update tray badge
149
+ this._updateTrayBadge();
150
+
151
+ // Persist
152
+ this._saveNotifications();
153
+ }
154
+
155
+ /**
156
+ * Get the current unread notification count.
157
+ * @returns {number}
158
+ */
159
+ getUnreadCount() {
160
+ return this.notifications.filter((n) => !n.read).length;
161
+ }
162
+
163
+ /**
164
+ * Mark all notifications as read.
165
+ */
166
+ markAllRead() {
167
+ for (const notif of this.notifications) {
168
+ notif.read = true;
169
+ }
170
+ this._updateTrayBadge();
171
+ this._saveNotifications();
172
+ }
173
+
174
+ /**
175
+ * Clear all notifications.
176
+ */
177
+ clearAll() {
178
+ this.notifications = [];
179
+ this._updateTrayBadge();
180
+ this._saveNotifications();
181
+ }
182
+
183
+ /**
184
+ * Clean up all pending timers. Call during shutdown to prevent leaked timers.
185
+ */
186
+ destroy() {
187
+ for (const [id, timer] of Object.entries(this._permissionTimers)) {
188
+ clearTimeout(timer);
189
+ }
190
+ this._permissionTimers = {};
191
+ }
192
+
193
+ // ── Private: Permission requests ─────────────────────────────────────
194
+
195
+ _handlePermissionRequest(notif) {
196
+ // Send to renderer as a permission prompt
197
+ this._sendToRenderer("notification:permission-request", notif);
198
+
199
+ // Show OS toast (click-to-focus only)
200
+ this._showOsToast(notif);
201
+
202
+ // Set up auto-deny timeout if specified
203
+ if (notif.timeoutSeconds && notif.timeoutSeconds > 0) {
204
+ this._permissionTimers[notif.id] = setTimeout(() => {
205
+ if (!notif.responded) {
206
+ console.log(
207
+ `[notif] Permission request ${notif.id} timed out — auto-denying`
208
+ );
209
+ this._respondToPermission(notif.id, "deny", false);
210
+ }
211
+ }, notif.timeoutSeconds * 1000);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Respond to a permission request.
217
+ * @param {string} notifId
218
+ * @param {string} action — "allow" or "deny"
219
+ * @param {boolean} remember — whether to remember this choice
220
+ */
221
+ _respondToPermission(notifId, action, remember) {
222
+ const notif = this.notifications.find((n) => n.id === notifId);
223
+ if (!notif) {
224
+ console.warn(`[notif] Permission response for unknown notification: ${notifId}`);
225
+ return;
226
+ }
227
+
228
+ if (notif.responded) {
229
+ console.warn(`[notif] Permission ${notifId} already responded`);
230
+ return;
231
+ }
232
+
233
+ notif.responded = true;
234
+ notif.response = { action, remember };
235
+
236
+ // Clear timeout timer if exists
237
+ if (this._permissionTimers[notifId]) {
238
+ clearTimeout(this._permissionTimers[notifId]);
239
+ delete this._permissionTimers[notifId];
240
+ }
241
+
242
+ // Send response back to the agent via JSON-RPC notification (no id, no response expected).
243
+ // We use _sendJsonRpcRaw (not sendJsonRpc) because this is a notification TO the agent,
244
+ // not a request — the agent doesn't reply, so we must not wait for one.
245
+ if (this.agentProcessManager) {
246
+ try {
247
+ this.agentProcessManager._sendJsonRpcRaw(
248
+ notif.agentId,
249
+ "notification/response",
250
+ {
251
+ notification_id: notifId,
252
+ action,
253
+ remember,
254
+ }
255
+ );
256
+ } catch (err) {
257
+ console.error(
258
+ `[notif] Failed to send permission response to ${notif.agentId}:`,
259
+ err.message
260
+ );
261
+ }
262
+ }
263
+
264
+ console.log(
265
+ `[notif] Permission ${notifId}: ${action} (remember=${remember})`
266
+ );
267
+
268
+ this._saveNotifications();
269
+ }
270
+
271
+ // ── Private: OS native toasts ────────────────────────────────────────
272
+
273
+ _showOsToast(notif) {
274
+ if (!OS_TOAST_TYPES.has(notif.type)) return;
275
+
276
+ try {
277
+ // Check if Notification is supported
278
+ if (!Notification.isSupported()) {
279
+ console.warn("[notif] OS notifications not supported on this platform");
280
+ return;
281
+ }
282
+
283
+ const osNotif = new Notification({
284
+ title: notif.title,
285
+ body: notif.message,
286
+ icon: path.join(__dirname, "..", "assets", "icon.png"),
287
+ urgency: notif.type === "security_alert" ? "critical" : "normal",
288
+ // No action buttons — click-to-focus only (S5 fix)
289
+ });
290
+
291
+ // Click → show and focus the main window
292
+ osNotif.on("click", () => {
293
+ this._showAndFocusWindow(notif);
294
+ });
295
+
296
+ osNotif.show();
297
+ } catch (err) {
298
+ console.warn("[notif] Failed to show OS notification:", err.message);
299
+ }
300
+ }
301
+
302
+ _showAndFocusWindow(notif) {
303
+ if (!this.mainWindow || this.mainWindow.isDestroyed()) return;
304
+
305
+ if (this.mainWindow.isMinimized()) {
306
+ this.mainWindow.restore();
307
+ }
308
+ this.mainWindow.show();
309
+ this.mainWindow.focus();
310
+
311
+ // Tell the renderer which notification to focus on.
312
+ // Note: for permission_request, the notification was already sent to the renderer
313
+ // via _handlePermissionRequest — we just navigate to it here, don't re-send.
314
+ this._sendToRenderer("tray:navigate", `notification:${notif.id}`);
315
+ }
316
+
317
+ // ── Private: Tray badge ──────────────────────────────────────────────
318
+
319
+ _updateTrayBadge() {
320
+ if (this.trayManager) {
321
+ this.trayManager.setNotificationCount(this.getUnreadCount());
322
+ }
323
+ }
324
+
325
+ // ── Private: Event listeners ─────────────────────────────────────────
326
+
327
+ _listenToAgentEvents() {
328
+ if (!this.agentProcessManager) return;
329
+
330
+ // Listen for agent notifications via the EventEmitter
331
+ this.agentProcessManager.on(
332
+ "agent-notification",
333
+ (agentId, params) => {
334
+ this.handleAgentNotification(agentId, params);
335
+ }
336
+ );
337
+
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
+ });
349
+
350
+ // Crash limit reached → generate error notification
351
+ this.agentProcessManager.on(
352
+ "agent-crash-limit",
353
+ (agentId, crashCount) => {
354
+ this.handleAgentNotification(agentId, {
355
+ type: "error",
356
+ title: "Agent Crash Limit Reached",
357
+ message: `Agent ${agentId} crashed ${crashCount} times — automatic restart disabled`,
358
+ });
359
+ }
360
+ );
361
+ }
362
+
363
+ // ── Private: Persistence ─────────────────────────────────────────────
364
+
365
+ _loadNotifications() {
366
+ try {
367
+ if (fs.existsSync(NOTIFICATIONS_PATH)) {
368
+ const raw = fs.readFileSync(NOTIFICATIONS_PATH, "utf8");
369
+ return JSON.parse(raw);
370
+ }
371
+ } catch (err) {
372
+ console.warn("[notif] Could not load notifications:", err.message);
373
+ }
374
+ return [];
375
+ }
376
+
377
+ _saveNotifications() {
378
+ try {
379
+ if (!fs.existsSync(GAIA_DIR)) {
380
+ fs.mkdirSync(GAIA_DIR, { recursive: true });
381
+ }
382
+
383
+ // Only persist the last MAX_PERSISTED entries
384
+ const toSave = this.notifications.slice(-MAX_PERSISTED);
385
+ fs.writeFileSync(
386
+ NOTIFICATIONS_PATH,
387
+ JSON.stringify(toSave, null, 2),
388
+ "utf8"
389
+ );
390
+ } catch (err) {
391
+ console.warn("[notif] Could not save notifications:", err.message);
392
+ }
393
+ }
394
+
395
+ // ── Private: IPC handlers ────────────────────────────────────────────
396
+
397
+ _registerIpcHandlers() {
398
+ ipcMain.handle(
399
+ "notification:respond",
400
+ (_event, notifId, action, remember) => {
401
+ this._respondToPermission(notifId, action, remember);
402
+ }
403
+ );
404
+ }
405
+
406
+ // ── Private: Helpers ─────────────────────────────────────────────────
407
+
408
+ _sendToRenderer(channel, data) {
409
+ try {
410
+ if (this.mainWindow && !this.mainWindow.isDestroyed()) {
411
+ this.mainWindow.webContents.send(channel, data);
412
+ }
413
+ } catch (err) {
414
+ console.warn("[notif] Could not send to renderer:", err.message);
415
+ }
416
+ }
417
+ }
418
+
419
+ module.exports = NotificationService;
@@ -0,0 +1,239 @@
1
+ // Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * GAIA Agent UI — Tray Manager (T1)
6
+ *
7
+ * Manages the Electron system tray icon, context menu, and minimize-to-tray
8
+ * behaviour. Co-located alongside main.cjs per the T0 co-location decision.
9
+ *
10
+ * Responsibilities:
11
+ * - Create Tray instance with GAIA icon on app startup
12
+ * - Build context menu (Show Window, Open in Browser, Quit)
13
+ * - Handle "minimize to tray" on window close (configurable)
14
+ * - Handle "show window" on tray click / double-click
15
+ * - Expose IPC handlers for renderer to query/update tray config
16
+ */
17
+
18
+ const { Tray, Menu, nativeImage, ipcMain, app, shell } = require("electron");
19
+ const path = require("path");
20
+ const fs = require("fs");
21
+ const os = require("os");
22
+
23
+ // ── Constants ────────────────────────────────────────────────────────────
24
+
25
+ const GAIA_DIR = path.join(os.homedir(), ".gaia");
26
+ const CONFIG_PATH = path.join(GAIA_DIR, "tray-config.json");
27
+
28
+ // ── Default config ───────────────────────────────────────────────────────
29
+
30
+ const DEFAULT_CONFIG = {
31
+ tray: {
32
+ minimizeToTray: true,
33
+ startMinimized: false,
34
+ startOnLogin: false,
35
+ },
36
+ };
37
+
38
+ // ── TrayManager ──────────────────────────────────────────────────────────
39
+
40
+ class TrayManager {
41
+ /**
42
+ * @param {Electron.BrowserWindow} mainWindow
43
+ * @param {object} [options]
44
+ * @param {number} [options.backendPort=4200] - Backend port for "Open in Browser"
45
+ */
46
+ constructor(mainWindow, options = {}) {
47
+ /** @type {Electron.BrowserWindow} */
48
+ this.mainWindow = mainWindow;
49
+
50
+ /** @type {number} */
51
+ this._backendPort = options.backendPort || 4200;
52
+
53
+ /** @type {Electron.Tray | null} */
54
+ this.tray = null;
55
+
56
+ /** @type {object} */
57
+ this.config = this._loadConfig();
58
+
59
+ /** @type {Electron.NativeImage} */
60
+ const trayIconFile = process.platform === "win32" ? "icon.ico" : "icon.png";
61
+ this._icon = this._loadIcon(trayIconFile);
62
+
63
+ this._registerIpcHandlers();
64
+ }
65
+
66
+ // ── Public API ───────────────────────────────────────────────────────
67
+
68
+ /** Create the tray icon and wire up events. Call once after app.whenReady(). */
69
+ create() {
70
+ if (this.tray) return;
71
+
72
+ this.tray = new Tray(this._icon);
73
+ this.tray.setToolTip("GAIA Agent UI");
74
+
75
+ // Single-click: show/focus window
76
+ this.tray.on("click", () => this._showWindow());
77
+
78
+ // Double-click (Windows): show/focus window
79
+ this.tray.on("double-click", () => this._showWindow());
80
+
81
+ this._rebuildContextMenu();
82
+ console.log("[tray] System tray icon created");
83
+ }
84
+
85
+ /** Destroy the tray icon. Call before app.quit(). */
86
+ destroy() {
87
+ if (this.tray) {
88
+ this.tray.destroy();
89
+ this.tray = null;
90
+ }
91
+ console.log("[tray] System tray icon destroyed");
92
+ }
93
+
94
+ /** Update the context menu. */
95
+ refresh() {
96
+ this._rebuildContextMenu();
97
+ }
98
+
99
+ /** @returns {boolean} Whether minimize-to-tray is enabled. */
100
+ get minimizeToTray() {
101
+ return this.config.tray.minimizeToTray;
102
+ }
103
+
104
+ /** @returns {boolean} Whether app should start minimized. */
105
+ get startMinimized() {
106
+ return this.config.tray.startMinimized;
107
+ }
108
+
109
+ /** @returns {boolean} Whether app should start on login. */
110
+ get startOnLogin() {
111
+ return this.config.tray.startOnLogin;
112
+ }
113
+
114
+ // ── Private: Context Menu ────────────────────────────────────────────
115
+
116
+ _rebuildContextMenu() {
117
+ if (!this.tray) return;
118
+
119
+ const contextMenu = Menu.buildFromTemplate([
120
+ {
121
+ label: "Show Window",
122
+ click: () => this._showWindow(),
123
+ },
124
+ {
125
+ label: "Open in Browser",
126
+ click: () => shell.openExternal(`http://localhost:${this._backendPort}`),
127
+ },
128
+ { type: "separator" },
129
+ {
130
+ label: "Quit",
131
+ click: () => this._quit(),
132
+ },
133
+ ]);
134
+ this.tray.setContextMenu(contextMenu);
135
+ }
136
+
137
+ // ── Private: Window management ───────────────────────────────────────
138
+
139
+ _showWindow() {
140
+ if (!this.mainWindow || this.mainWindow.isDestroyed()) return;
141
+
142
+ if (this.mainWindow.isMinimized()) {
143
+ this.mainWindow.restore();
144
+ }
145
+ this.mainWindow.show();
146
+ this.mainWindow.focus();
147
+ }
148
+
149
+ async _quit() {
150
+ console.log("[tray] Quit requested");
151
+ app.quit();
152
+ }
153
+
154
+ // ── Private: Icon loading ────────────────────────────────────────────
155
+
156
+ _loadIcon(filename) {
157
+ // __dirname is services/, assets/ is one level up alongside main.cjs
158
+ const iconPath = path.join(__dirname, "..", "assets", filename);
159
+ try {
160
+ if (fs.existsSync(iconPath)) {
161
+ return nativeImage.createFromPath(iconPath);
162
+ }
163
+ } catch (err) {
164
+ console.warn(`[tray] Could not load icon ${filename}:`, err.message);
165
+ }
166
+ // Return empty image as fallback (Electron will show a default)
167
+ return nativeImage.createEmpty();
168
+ }
169
+
170
+ // ── Private: Config persistence ──────────────────────────────────────
171
+
172
+ _loadConfig() {
173
+ try {
174
+ if (fs.existsSync(CONFIG_PATH)) {
175
+ const raw = fs.readFileSync(CONFIG_PATH, "utf8");
176
+ const loaded = JSON.parse(raw);
177
+ return {
178
+ ...DEFAULT_CONFIG,
179
+ ...loaded,
180
+ tray: { ...DEFAULT_CONFIG.tray, ...(loaded.tray || {}) },
181
+ };
182
+ }
183
+ } catch (err) {
184
+ console.warn("[tray] Could not load tray config:", err.message);
185
+ }
186
+ return { ...DEFAULT_CONFIG };
187
+ }
188
+
189
+ _saveConfig() {
190
+ try {
191
+ if (!fs.existsSync(GAIA_DIR)) {
192
+ fs.mkdirSync(GAIA_DIR, { recursive: true });
193
+ }
194
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(this.config, null, 2), "utf8");
195
+ console.log("[tray] Config saved to", CONFIG_PATH);
196
+ } catch (err) {
197
+ console.error("[tray] Could not save tray config:", err.message);
198
+ }
199
+ }
200
+
201
+ // ── Private: IPC handlers ────────────────────────────────────────────
202
+
203
+ _registerIpcHandlers() {
204
+ ipcMain.handle("tray:get-config", () => {
205
+ return this.config;
206
+ });
207
+
208
+ ipcMain.handle("tray:set-config", (_event, cfg) => {
209
+ if (cfg.tray) {
210
+ this.config.tray = { ...this.config.tray, ...cfg.tray };
211
+ }
212
+
213
+ this._saveConfig();
214
+
215
+ // Apply login-item setting if changed
216
+ if (cfg.tray && "startOnLogin" in cfg.tray) {
217
+ this._applyLoginItemSetting(cfg.tray.startOnLogin);
218
+ }
219
+
220
+ return this.config;
221
+ });
222
+ }
223
+
224
+ /** Register/unregister the app from OS login startup. */
225
+ _applyLoginItemSetting(enabled) {
226
+ try {
227
+ app.setLoginItemSettings({
228
+ openAtLogin: enabled,
229
+ path: app.getPath("exe"),
230
+ args: enabled ? ["--minimized"] : [],
231
+ });
232
+ console.log(`[tray] Login item ${enabled ? "enabled" : "disabled"}`);
233
+ } catch (err) {
234
+ console.warn("[tray] Could not set login item:", err.message);
235
+ }
236
+ }
237
+ }
238
+
239
+ module.exports = TrayManager;