@amd-gaia/agent-ui 0.21.2 → 0.23.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.
package/main.cjs CHANGED
@@ -63,6 +63,13 @@ const backendInstaller = require("./services/backend-installer.cjs");
63
63
  const installerProgressDialog = require("./services/backend-installer-progress-dialog.cjs");
64
64
  const autoUpdater = require("./services/auto-updater.cjs");
65
65
  const agentSeeder = require("./services/agent-seeder.cjs");
66
+ const systemMetrics = require("./services/system-metrics.cjs");
67
+ const {
68
+ parseDeepLink,
69
+ extractDeepLinkFromArgv,
70
+ dispatchDeepLink,
71
+ buildInstallPrompt,
72
+ } = require("./services/deep-link.cjs");
66
73
 
67
74
  // ── F7: Ozone hint (issue #782) ─────────────────────────────────────────────
68
75
  // Electron-recommended switch for distro-agnostic Linux behaviour: picks
@@ -586,7 +593,12 @@ function initializeServices() {
586
593
  console.log("[main] Initializing services...");
587
594
 
588
595
  // T2: Agent Process Manager (manages OS agent subprocesses)
589
- agentProcessManager = new AgentProcessManager(mainWindow);
596
+ // getBackendPort lets its install/uninstall handlers proxy to the live
597
+ // Python backend's install runtime (issue #1721) on whatever random port
598
+ // port-manager picked this session.
599
+ agentProcessManager = new AgentProcessManager(mainWindow, {
600
+ getBackendPort: () => backendPort,
601
+ });
590
602
 
591
603
  // T1: Tray Manager (system tray icon + context menu)
592
604
  trayManager = new TrayManager(mainWindow, { backendPort });
@@ -599,6 +611,9 @@ function initializeServices() {
599
611
  trayManager
600
612
  );
601
613
 
614
+ // #2007: System metrics for the observability dashboard
615
+ systemMetrics.registerIpcHandlers();
616
+
602
617
  console.log("[main] Services initialized");
603
618
  }
604
619
 
@@ -723,6 +738,178 @@ async function bootstrapBackend() {
723
738
  return false;
724
739
  }
725
740
 
741
+ // ── gaia:// deep-link install bridge (issue #1725) ──────────────────────────
742
+ //
743
+ // The website's "Open in GAIA" button opens a `gaia://hub/install/<id>` URL.
744
+ // The OS routes it to this app (registered as the gaia:// protocol client);
745
+ // we parse it and hand the agent id to the install runtime (issue #1721).
746
+ //
747
+ // Sources of the URL differ per OS:
748
+ // • macOS — the `open-url` app event (registered before whenReady).
749
+ // • Win/Linux — a command-line argument, either at cold start (process.argv)
750
+ // or on a second launch (the `second-instance` event argv).
751
+ // A malformed/unsupported link surfaces a loud error dialog — never a silent
752
+ // no-op (deep-link.cjs throws with an actionable message).
753
+
754
+ /** Holds a deep link that arrived before services were ready. */
755
+ let pendingDeepLinkUrl = null;
756
+
757
+ /**
758
+ * Register this app as the handler for the gaia:// scheme. In dev (`electron .`)
759
+ * the launcher is Electron itself, so the app path must be threaded through
760
+ * explicitly; packaged builds register their own executable.
761
+ */
762
+ function registerProtocolHandler() {
763
+ try {
764
+ let registered;
765
+ if (process.defaultApp && process.argv.length >= 2) {
766
+ // Dev: `electron . <args>` — point the scheme at this script.
767
+ registered = app.setAsDefaultProtocolClient("gaia", process.execPath, [
768
+ path.resolve(process.argv[1]),
769
+ ]);
770
+ } else {
771
+ registered = app.setAsDefaultProtocolClient("gaia");
772
+ }
773
+ if (registered) {
774
+ console.log("[main] Registered as gaia:// protocol client");
775
+ } else {
776
+ console.warn(
777
+ "[main] Could not register gaia:// protocol client — deep-link installs may not route to this app"
778
+ );
779
+ }
780
+ } catch (err) {
781
+ console.warn(
782
+ `[main] setAsDefaultProtocolClient failed: ${err && err.message ? err.message : err}`
783
+ );
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Entry point for any inbound deep link. Queues the URL if services aren't up
789
+ * yet, otherwise dispatches immediately. Parse failures are surfaced loudly.
790
+ */
791
+ function handleDeepLink(rawUrl) {
792
+ let command;
793
+ try {
794
+ command = parseDeepLink(rawUrl);
795
+ } catch (err) {
796
+ const message = err && err.message ? err.message : String(err);
797
+ console.error(`[main] Rejected deep link: ${message}`);
798
+ try {
799
+ dialog.showErrorBox("Invalid GAIA link", message);
800
+ } catch {
801
+ /* dialog may be unavailable pre-ready — the log line above still fires */
802
+ }
803
+ return;
804
+ }
805
+
806
+ // If services aren't wired yet (cold start still bootstrapping), stash it.
807
+ if (!agentProcessManager) {
808
+ console.log("[main] Deep link received before services ready — queuing");
809
+ pendingDeepLinkUrl = rawUrl;
810
+ return;
811
+ }
812
+
813
+ void runDeepLink(command);
814
+ }
815
+
816
+ /**
817
+ * Explicit, INFORMED per-agent confirmation for a web-triggered install. This
818
+ * is the security gate: the user must confirm the SPECIFIC agent — and see
819
+ * its real trust tier and permissions, not just its bare id — before any
820
+ * download happens (issue #2196 review; hardened so the shown facts match
821
+ * the backend's non-verified gate, which now covers every non-verified
822
+ * agent, not just native ones). Defaults to the safe (Cancel) option.
823
+ *
824
+ * Fails closed: if the agent can't be found in the catalog, or the backend
825
+ * is unreachable, the install is refused with an explanatory error — never
826
+ * installed blind.
827
+ * @returns {Promise<boolean>}
828
+ */
829
+ async function confirmDeepLinkInstall(command) {
830
+ let entry;
831
+ try {
832
+ entry = await agentProcessManager.fetchCatalogEntry(command.agentId);
833
+ } catch (err) {
834
+ const message = err && err.message ? err.message : String(err);
835
+ console.error(
836
+ `[main] Refusing deep-link install of "${command.agentId}": ${message}`
837
+ );
838
+ try {
839
+ dialog.showErrorBox("Could not verify this agent", message);
840
+ } catch {
841
+ /* best-effort */
842
+ }
843
+ return false;
844
+ }
845
+
846
+ const prompt = buildInstallPrompt(entry);
847
+ const choice = await dialog.showMessageBox(
848
+ mainWindow && !mainWindow.isDestroyed() ? mainWindow : null,
849
+ {
850
+ type: "question",
851
+ buttons: [prompt.requiresTrust ? "Trust & Install" : "Install", "Cancel"],
852
+ defaultId: 1, // Enter selects the safe option
853
+ cancelId: 1,
854
+ title: prompt.title,
855
+ message: prompt.message,
856
+ detail: prompt.detail,
857
+ noLink: true,
858
+ }
859
+ );
860
+ return !!choice && choice.response === 0;
861
+ }
862
+
863
+ /** Wire the injected deep-link dispatcher to this process's runtime effects. */
864
+ async function runDeepLink(command) {
865
+ console.log(`[main] Deep-link install request: ${command.agentId}`);
866
+ try {
867
+ await dispatchDeepLink(command, {
868
+ confirm: confirmDeepLinkInstall,
869
+ installAgent: (agentId, opts) => agentProcessManager.installAgent(agentId, opts),
870
+ focusWindow: () => {
871
+ if (mainWindow && !mainWindow.isDestroyed()) {
872
+ if (mainWindow.isMinimized()) mainWindow.restore();
873
+ if (!mainWindow.isVisible()) mainWindow.show();
874
+ mainWindow.focus();
875
+ }
876
+ },
877
+ logger: { log: console.log.bind(console), error: console.error.bind(console) },
878
+ });
879
+ } catch (err) {
880
+ const message = err && err.message ? err.message : String(err);
881
+ console.error(
882
+ `[main] Deep-link install of "${command.agentId}" failed: ${message}`
883
+ );
884
+ try {
885
+ dialog.showErrorBox(`Could not install "${command.agentId}"`, message);
886
+ } catch {
887
+ /* best-effort */
888
+ }
889
+ }
890
+ }
891
+
892
+ /** Drain a queued deep link, plus any gaia:// URL present in the cold-start argv. */
893
+ function processStartupDeepLinks() {
894
+ if (pendingDeepLinkUrl) {
895
+ const url = pendingDeepLinkUrl;
896
+ pendingDeepLinkUrl = null;
897
+ handleDeepLink(url);
898
+ return;
899
+ }
900
+ const fromArgv = extractDeepLinkFromArgv(process.argv);
901
+ if (fromArgv) handleDeepLink(fromArgv);
902
+ }
903
+
904
+ // macOS delivers deep links via this event; it can fire before whenReady, so
905
+ // register it at module load and let handleDeepLink queue as needed.
906
+ app.on("open-url", (event, url) => {
907
+ event.preventDefault();
908
+ handleDeepLink(url);
909
+ });
910
+
911
+ registerProtocolHandler();
912
+
726
913
  // ── App Lifecycle ──────────────────────────────────────────────────────────
727
914
 
728
915
  // Note: electron-squirrel-startup was removed in Phase C of the
@@ -760,7 +947,7 @@ if (!gotTheSingleInstanceLock) {
760
947
  process.exit(0);
761
948
  }
762
949
 
763
- app.on("second-instance", (_event, _argv, _cwd) => {
950
+ app.on("second-instance", (_event, argv, _cwd) => {
764
951
  // A second launch happened while we were running. Surface our window
765
952
  // (the user almost certainly wanted to see it). mainWindow may be null
766
953
  // if we're still in the bootstrap phase — in that case the first
@@ -771,6 +958,11 @@ app.on("second-instance", (_event, _argv, _cwd) => {
771
958
  if (!mainWindow.isVisible()) mainWindow.show();
772
959
  mainWindow.focus();
773
960
  }
961
+
962
+ // Win/Linux: a `gaia://…` deep link from the second launch arrives as an argv
963
+ // entry. Route it into the already-running first instance (issue #1725).
964
+ const deepLink = extractDeepLinkFromArgv(argv);
965
+ if (deepLink) handleDeepLink(deepLink);
774
966
  });
775
967
 
776
968
  app.whenReady().then(async () => {
@@ -783,6 +975,9 @@ app.whenReady().then(async () => {
783
975
  if (seedResult.seeded.length > 0) {
784
976
  console.log("[main] Seeded agents:", seedResult.seeded);
785
977
  }
978
+ if (seedResult.cleaned.length > 0) {
979
+ console.log("[main] Cleaned legacy agents:", seedResult.cleaned);
980
+ }
786
981
  if (seedResult.errors.length > 0) {
787
982
  console.warn(
788
983
  "[main] Agent seeding errors:",
@@ -834,6 +1029,10 @@ app.whenReady().then(async () => {
834
1029
  // Setup Windows Jump List (T11)
835
1030
  setupJumpList();
836
1031
 
1032
+ // Act on any gaia:// deep link that arrived during bootstrap or via the
1033
+ // cold-start command line (issue #1725). Services are now wired.
1034
+ processStartupDeepLinks();
1035
+
837
1036
  // Show loading state
838
1037
  await loadApp();
839
1038
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amd-gaia/agent-ui",
3
- "version": "0.21.2",
3
+ "version": "0.23.0",
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",
@@ -64,15 +64,15 @@
64
64
  "electron-updater": "^6.8.3"
65
65
  },
66
66
  "devDependencies": {
67
- "@testing-library/jest-dom": "^6.6.3",
67
+ "@testing-library/jest-dom": "^7.0.0",
68
68
  "@testing-library/react": "^16.1.0",
69
69
  "@testing-library/user-event": "^14.5.2",
70
70
  "@types/react": "^19.2.17",
71
71
  "@types/react-dom": "^19.2.3",
72
72
  "@vitejs/plugin-react": "^6.0.2",
73
- "electron": "^42.2.0",
73
+ "electron": "^43.0.0",
74
74
  "electron-builder": "^26.8.1",
75
- "jsdom": "^29.1.1",
75
+ "jsdom": "^30.0.1",
76
76
  "lucide-react": "^1.17.0",
77
77
  "msw": "^2.7.0",
78
78
  "qrcode": "^1.5.4",
@@ -81,7 +81,7 @@
81
81
  "react-markdown": "^10.1.0",
82
82
  "rehype-sanitize": "^6.0.0",
83
83
  "remark-gfm": "^4.0.1",
84
- "typescript": "^6.0.3",
84
+ "typescript": "^7.0.2",
85
85
  "vite": "^8.0.16",
86
86
  "vitest": "^4.1.8",
87
87
  "zustand": "^5.0.14"
package/preload.cjs CHANGED
@@ -13,6 +13,7 @@
13
13
  * notification:* — Desktop notifications & permission prompts (T5)
14
14
  * install:* — First-run backend install progress (Phase A)
15
15
  * gaia:update:* — Auto-update status & manual check (Phase F)
16
+ * system:* — System metrics for the observability dashboard (#2007)
16
17
  */
17
18
 
18
19
  const { contextBridge, ipcRenderer } = require("electron");
@@ -35,7 +36,9 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
35
36
  sendRpc: (id, method, params) =>
36
37
  ipcRenderer.invoke("agent:send-rpc", id, method, params),
37
38
  getManifest: () => ipcRenderer.invoke("agent:get-manifest"),
38
- install: (id) => ipcRenderer.invoke("agent:install", id),
39
+ // install(id, opts?) — opts: { trustNative?, version? }. Proxies to the
40
+ // backend install runtime; progress arrives via onInstallProgress (#1721).
41
+ install: (id, opts) => ipcRenderer.invoke("agent:install", id, opts),
39
42
  uninstall: (id) => ipcRenderer.invoke("agent:uninstall", id),
40
43
 
41
44
  // Event streams (return unsubscribe functions)
@@ -43,6 +46,7 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
43
46
  onStderr: (cb) => onEvent("agent:stderr", cb),
44
47
  onStatusChange: (cb) => onEvent("agent:status-change", cb),
45
48
  onCrashed: (cb) => onEvent("agent:crashed", cb),
49
+ onInstallProgress: (cb) => onEvent("agent:install-progress", cb),
46
50
  },
47
51
 
48
52
  // ── Tray configuration (T1) ───────────────────────────────────────────
@@ -60,6 +64,11 @@ contextBridge.exposeInMainWorld("gaiaAPI", {
60
64
  ipcRenderer.invoke("notification:respond", id, action, remember),
61
65
  onNotification: (cb) => onEvent("notification:new", cb),
62
66
  },
67
+
68
+ // ── System metrics for the observability dashboard (#2007) ────────────
69
+ system: {
70
+ getMetrics: () => ipcRenderer.invoke("system:get-metrics"),
71
+ },
63
72
  });
64
73
 
65
74
  // ── Install progress (Phase A) ──────────────────────────────────────────
@@ -59,18 +59,42 @@ const STDERR_BUFFER_MAX = 10000;
59
59
  /** Max bytes kept in stdout buffer per agent (protects against malformed output without newlines) */
60
60
  const STDOUT_BUFFER_MAX = 1024 * 1024; // 1 MB
61
61
 
62
+ /** Poll interval while waiting on the backend install-status endpoint (ms) */
63
+ const INSTALL_POLL_INTERVAL = 750;
64
+
65
+ /**
66
+ * Hard ceiling on a single install poll loop (ms). A `uv pip install` of a
67
+ * large wheel over a slow link can legitimately take minutes; we cap at 15 to
68
+ * turn a wedged backend into a loud timeout instead of an infinite spinner.
69
+ */
70
+ const INSTALL_TIMEOUT = 15 * 60 * 1000;
71
+
72
+ /** Header the UI backend requires on state-changing hub routes (see hub.py). */
73
+ const UI_HEADER = { "X-Gaia-UI": "1" };
74
+
62
75
  // ── AgentProcessManager ──────────────────────────────────────────────────
63
76
 
64
77
  class AgentProcessManager extends EventEmitter {
65
78
  /**
66
79
  * @param {Electron.BrowserWindow} mainWindow — for sending IPC events to renderer
80
+ * @param {{ getBackendPort?: () => (number | null) }} [options]
81
+ * getBackendPort resolves the live Python-backend port. The install/uninstall
82
+ * handlers proxy to that backend's `/api/agents/*` routes (the real install
83
+ * runtime), so without a resolver those handlers fail loudly rather than
84
+ * duplicating download/verify/uv logic in the main process (issue #1721).
67
85
  */
68
- constructor(mainWindow) {
86
+ constructor(mainWindow, options = {}) {
69
87
  super();
70
88
 
71
89
  /** @type {Electron.BrowserWindow} */
72
90
  this.mainWindow = mainWindow;
73
91
 
92
+ /** @type {(() => (number | null)) | null} */
93
+ this._getBackendPort =
94
+ typeof options.getBackendPort === "function"
95
+ ? options.getBackendPort
96
+ : null;
97
+
74
98
  /**
75
99
  * Running processes keyed by agentId.
76
100
  * @type {Record<string, {
@@ -251,6 +275,265 @@ class AgentProcessManager extends EventEmitter {
251
275
  return this.startAgent(agentId);
252
276
  }
253
277
 
278
+ // ── Public API: Install / Uninstall (issue #1721) ────────────────────
279
+ //
280
+ // The install RUNTIME lives in the Python backend (src/gaia/hub/installer.py,
281
+ // exposed by src/gaia/ui/routers/hub.py): R2 download, SHA-256 verify,
282
+ // `uv pip install` into ~/.gaia/agents/<id>/, hot-register, backup/rollback,
283
+ // and the native-trust gate. Rather than reimplement that in the main
284
+ // process, these handlers proxy to those routes so there is exactly one
285
+ // tested install path. Progress streams to the renderer over the existing
286
+ // `agent:install-progress` channel.
287
+
288
+ /**
289
+ * Resolve the live backend base URL, or throw an actionable error.
290
+ * @returns {string}
291
+ */
292
+ _backendBaseUrl() {
293
+ const port = this._getBackendPort ? this._getBackendPort() : null;
294
+ if (!port) {
295
+ throw new Error(
296
+ "GAIA backend is not running — cannot install or uninstall agents. " +
297
+ "The Python backend exposes the install runtime at /api/agents/install; " +
298
+ "wait for it to finish starting, or restart GAIA if this persists."
299
+ );
300
+ }
301
+ // 127.0.0.1 (not localhost) matches the backend's _require_localhost allowlist
302
+ // and avoids a DNS/IPv6 hop on Windows.
303
+ return `http://127.0.0.1:${port}`;
304
+ }
305
+
306
+ /**
307
+ * Fetch a single agent's catalog entry (tier, permissions, verified status)
308
+ * from the live backend. Used by the gaia:// deep-link install gate
309
+ * (main.cjs) to build an informed trust prompt — the confirmation must show
310
+ * the agent's real tier/permissions, not just its bare id.
311
+ *
312
+ * Throws with an actionable message when the backend is unreachable or the
313
+ * agent id isn't in the catalog. Callers MUST treat that as a refusal —
314
+ * never install without having shown the user what they're installing.
315
+ *
316
+ * @param {string} agentId
317
+ * @returns {Promise<object>} the matching catalog entry
318
+ */
319
+ async fetchCatalogEntry(agentId) {
320
+ if (typeof agentId !== "string" || !agentId.trim()) {
321
+ throw new Error("fetchCatalogEntry requires a non-empty agent id");
322
+ }
323
+ const base = this._backendBaseUrl();
324
+
325
+ let resp;
326
+ try {
327
+ resp = await fetch(`${base}/api/agents/catalog`, {
328
+ headers: { ...UI_HEADER },
329
+ });
330
+ } catch (err) {
331
+ throw new Error(
332
+ `Could not reach the GAIA backend to look up "${agentId}" in the agent catalog: ${err.message}`
333
+ );
334
+ }
335
+
336
+ if (!resp.ok) {
337
+ const detail = await this._readErrorDetail(resp);
338
+ throw new Error(
339
+ `Agent catalog request failed (HTTP ${resp.status}): ${detail}`
340
+ );
341
+ }
342
+
343
+ const body = await resp.json();
344
+ const agents = Array.isArray(body && body.agents) ? body.agents : [];
345
+ const entry = agents.find((a) => a && a.id === agentId);
346
+ if (!entry) {
347
+ throw new Error(`"${agentId}" was not found in the agent catalog.`);
348
+ }
349
+ return entry;
350
+ }
351
+
352
+ /**
353
+ * Install a published agent via the backend runtime, streaming progress.
354
+ *
355
+ * @param {string} agentId
356
+ * @param {{ trustNative?: boolean, version?: string | null }} [opts]
357
+ * @returns {Promise<{ id: string, status: string, version: string | null }>}
358
+ */
359
+ async installAgent(agentId, opts = {}) {
360
+ if (typeof agentId !== "string" || !agentId.trim()) {
361
+ throw new Error("installAgent requires a non-empty agent id");
362
+ }
363
+ const base = this._backendBaseUrl();
364
+ const trustNative = opts.trustNative === true;
365
+ const version =
366
+ typeof opts.version === "string" && opts.version ? opts.version : null;
367
+
368
+ this._emitInstallProgress(agentId, {
369
+ status: "queued",
370
+ phase: "queued",
371
+ percent: 0,
372
+ });
373
+
374
+ // ── Kick off the background install (202) ──
375
+ let resp;
376
+ try {
377
+ resp = await fetch(`${base}/api/agents/install`, {
378
+ method: "POST",
379
+ headers: { "Content-Type": "application/json", ...UI_HEADER },
380
+ body: JSON.stringify({ id: agentId, trust_native: trustNative, version }),
381
+ });
382
+ } catch (err) {
383
+ throw new Error(
384
+ `Could not reach the GAIA backend to install "${agentId}": ${err.message}`
385
+ );
386
+ }
387
+
388
+ if (!resp.ok) {
389
+ const detail = await this._readErrorDetail(resp);
390
+ if (resp.status === 409) {
391
+ throw new Error(`An install for "${agentId}" is already in progress.`);
392
+ }
393
+ if (resp.status === 403) {
394
+ // Native agents require explicit trust — surface the backend's reason.
395
+ throw new Error(
396
+ `Install of "${agentId}" was refused: ${detail} ` +
397
+ `(pass trustNative once the user accepts the trust prompt).`
398
+ );
399
+ }
400
+ throw new Error(
401
+ `Install request for "${agentId}" failed (HTTP ${resp.status}): ${detail}`
402
+ );
403
+ }
404
+
405
+ // ── Poll install-status until terminal ──
406
+ const deadline = Date.now() + INSTALL_TIMEOUT;
407
+ let lastState = null;
408
+ while (Date.now() < deadline) {
409
+ await this._sleep(INSTALL_POLL_INTERVAL);
410
+
411
+ let statusResp;
412
+ try {
413
+ statusResp = await fetch(
414
+ `${base}/api/agents/${encodeURIComponent(agentId)}/install-status`,
415
+ { headers: { ...UI_HEADER } }
416
+ );
417
+ } catch (err) {
418
+ throw new Error(
419
+ `Lost contact with the GAIA backend while installing "${agentId}": ${err.message}`
420
+ );
421
+ }
422
+
423
+ // A brief 404 window is possible between the 202 and the worker seeding
424
+ // progress; keep polling until the deadline rather than failing early.
425
+ if (statusResp.status === 404) continue;
426
+ if (!statusResp.ok) {
427
+ const detail = await this._readErrorDetail(statusResp);
428
+ throw new Error(
429
+ `Install-status poll for "${agentId}" failed (HTTP ${statusResp.status}): ${detail}`
430
+ );
431
+ }
432
+
433
+ lastState = await statusResp.json();
434
+ this._emitInstallProgress(agentId, {
435
+ status: lastState.status,
436
+ phase: lastState.phase,
437
+ percent: lastState.percent,
438
+ version: lastState.version,
439
+ error: lastState.error,
440
+ });
441
+
442
+ if (lastState.status === "completed") {
443
+ // Hot-register already happened backend-side; refresh our manifest so
444
+ // getAgentStatus / startAgent see the new binary paths.
445
+ this.reloadManifest();
446
+ return {
447
+ id: agentId,
448
+ status: "completed",
449
+ version: lastState.version || null,
450
+ };
451
+ }
452
+ if (lastState.status === "failed") {
453
+ throw new Error(
454
+ `Install of "${agentId}" failed: ${lastState.error || "unknown error"}`
455
+ );
456
+ }
457
+ }
458
+
459
+ throw new Error(
460
+ `Install of "${agentId}" timed out after ${Math.round(
461
+ INSTALL_TIMEOUT / 1000
462
+ )}s (last phase: ${lastState ? lastState.phase : "unknown"}).`
463
+ );
464
+ }
465
+
466
+ /**
467
+ * Uninstall a hub-installed agent: stop any running sidecar, then delegate
468
+ * removal + de-registration to the backend runtime.
469
+ *
470
+ * @param {string} agentId
471
+ * @returns {Promise<{ id: string, status: string }>}
472
+ */
473
+ async uninstallAgent(agentId) {
474
+ if (typeof agentId !== "string" || !agentId.trim()) {
475
+ throw new Error("uninstallAgent requires a non-empty agent id");
476
+ }
477
+ const base = this._backendBaseUrl();
478
+
479
+ // Stop a running sidecar first so its dir/executable isn't held open.
480
+ if (this.processes[agentId]) {
481
+ await this.stopAgent(agentId);
482
+ }
483
+
484
+ let resp;
485
+ try {
486
+ resp = await fetch(`${base}/api/agents/${encodeURIComponent(agentId)}`, {
487
+ method: "DELETE",
488
+ headers: { ...UI_HEADER },
489
+ });
490
+ } catch (err) {
491
+ throw new Error(
492
+ `Could not reach the GAIA backend to uninstall "${agentId}": ${err.message}`
493
+ );
494
+ }
495
+
496
+ if (!resp.ok) {
497
+ const detail = await this._readErrorDetail(resp);
498
+ if (resp.status === 404) {
499
+ throw new Error(`"${agentId}" is not installed.`);
500
+ }
501
+ if (resp.status === 400) {
502
+ // e.g. refusing to remove a builtin — surface the backend's reason.
503
+ throw new Error(`Cannot uninstall "${agentId}": ${detail}`);
504
+ }
505
+ throw new Error(
506
+ `Uninstall of "${agentId}" failed (HTTP ${resp.status}): ${detail}`
507
+ );
508
+ }
509
+
510
+ this.reloadManifest();
511
+ return { id: agentId, status: "uninstalled" };
512
+ }
513
+
514
+ /** Read a FastAPI-style `{detail}` error body without throwing. */
515
+ async _readErrorDetail(resp) {
516
+ try {
517
+ const body = await resp.json();
518
+ if (body && typeof body.detail === "string") return body.detail;
519
+ return JSON.stringify(body);
520
+ } catch {
521
+ return resp.statusText || `HTTP ${resp.status}`;
522
+ }
523
+ }
524
+
525
+ _emitInstallProgress(agentId, progress) {
526
+ this._sendToRenderer("agent:install-progress", {
527
+ agentId,
528
+ ...progress,
529
+ timestamp: Date.now(),
530
+ });
531
+ }
532
+
533
+ _sleep(ms) {
534
+ return new Promise((resolve) => setTimeout(resolve, ms));
535
+ }
536
+
254
537
  // ── Public API: Monitoring ───────────────────────────────────────────
255
538
 
256
539
  /**
@@ -803,14 +1086,12 @@ class AgentProcessManager extends EventEmitter {
803
1086
  return this.getManifest();
804
1087
  });
805
1088
 
806
- ipcMain.handle("agent:install", async (_event, agentId) => {
807
- // TODO: T7 — agent installer integration
808
- throw new Error("Agent installation not yet implemented");
1089
+ ipcMain.handle("agent:install", async (_event, agentId, opts) => {
1090
+ return this.installAgent(agentId, opts || {});
809
1091
  });
810
1092
 
811
1093
  ipcMain.handle("agent:uninstall", async (_event, agentId) => {
812
- // TODO: T7 — agent uninstaller integration
813
- throw new Error("Agent uninstallation not yet implemented");
1094
+ return this.uninstallAgent(agentId);
814
1095
  });
815
1096
  }
816
1097
  }