@amd-gaia/agent-ui 0.22.0 → 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/bin/gaia-ui.cjs +1 -1
- package/dist/assets/{browser-CmSowSA-.js → browser-C-_jUAU-.js} +3 -3
- package/dist/assets/index-C4_mR67p.js +69 -0
- package/dist/assets/index-ChKtY1lF.css +1 -0
- package/dist/index.html +2 -2
- package/main.cjs +198 -2
- package/package.json +3 -3
- package/preload.cjs +10 -1
- package/services/agent-process-manager.cjs +287 -6
- package/services/auto-updater.cjs +132 -17
- package/services/backend-installer-progress-dialog.cjs +1 -1
- package/services/backend-installer.cjs +10 -6
- package/services/deep-link.cjs +230 -0
- package/services/system-metrics.cjs +154 -0
- package/dist/assets/index-B31DMoxr.css +0 -1
- package/dist/assets/index-lScW7mUY.js +0 -72
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
|
-
|
|
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,
|
|
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 () => {
|
|
@@ -837,6 +1029,10 @@ app.whenReady().then(async () => {
|
|
|
837
1029
|
// Setup Windows Jump List (T11)
|
|
838
1030
|
setupJumpList();
|
|
839
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
|
+
|
|
840
1036
|
// Show loading state
|
|
841
1037
|
await loadApp();
|
|
842
1038
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amd-gaia/agent-ui",
|
|
3
|
-
"version": "0.
|
|
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,7 +64,7 @@
|
|
|
64
64
|
"electron-updater": "^6.8.3"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@testing-library/jest-dom": "^
|
|
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",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@vitejs/plugin-react": "^6.0.2",
|
|
73
73
|
"electron": "^43.0.0",
|
|
74
74
|
"electron-builder": "^26.8.1",
|
|
75
|
-
"jsdom": "^
|
|
75
|
+
"jsdom": "^30.0.1",
|
|
76
76
|
"lucide-react": "^1.17.0",
|
|
77
77
|
"msw": "^2.7.0",
|
|
78
78
|
"qrcode": "^1.5.4",
|
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
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
throw new Error("Agent uninstallation not yet implemented");
|
|
1094
|
+
return this.uninstallAgent(agentId);
|
|
814
1095
|
});
|
|
815
1096
|
}
|
|
816
1097
|
}
|