@amd-gaia/agent-ui 0.21.1 → 0.22.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/dist/index.html CHANGED
@@ -4,9 +4,9 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="./favicon.png" />
7
- <title>GAIA</title>
8
- <script type="module" crossorigin src="./assets/index-Dj-STgr2.js"></script>
9
- <link rel="stylesheet" crossorigin href="./assets/index-DRnhDSoO.css">
7
+ <title>GAIA Agent UI</title>
8
+ <script type="module" crossorigin src="./assets/index-lScW7mUY.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./assets/index-B31DMoxr.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -134,12 +134,22 @@ function installSafetyNet({ logPath, dialogModule, appModule, homedirFn }) {
134
134
 
135
135
  appModule.on("child-process-gone", (_event, details) => {
136
136
  const reason = details && details.reason;
137
+ const type = details && details.type;
137
138
  // Ignore expected terminations during shutdown so the crash dialog
138
139
  // doesn't flash on a clean quit.
139
140
  if (reason === "clean-exit" || reason === "killed") return;
140
- fatal(new Error(
141
- `child-process-gone: type=${details && details.type} reason=${reason}`
142
- ));
141
+ // A GPU-process crash is recoverable — Chromium relaunches the GPU process
142
+ // and falls back to software rendering. It fires routinely in GPU-less
143
+ // environments (Windows Sandbox, VMs, RDP), so log it and keep running
144
+ // instead of killing an otherwise-healthy app.
145
+ if (type === "GPU") {
146
+ appendLog(
147
+ logPath,
148
+ `[${new Date().toISOString()}] GPU_PROCESS_GONE reason=${reason}`
149
+ );
150
+ return;
151
+ }
152
+ fatal(new Error(`child-process-gone: type=${type} reason=${reason}`));
143
153
  });
144
154
 
145
155
  return { fatal };
package/main.cjs CHANGED
@@ -783,6 +783,9 @@ app.whenReady().then(async () => {
783
783
  if (seedResult.seeded.length > 0) {
784
784
  console.log("[main] Seeded agents:", seedResult.seeded);
785
785
  }
786
+ if (seedResult.cleaned.length > 0) {
787
+ console.log("[main] Cleaned legacy agents:", seedResult.cleaned);
788
+ }
786
789
  if (seedResult.errors.length > 0) {
787
790
  console.warn(
788
791
  "[main] Agent seeding errors:",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amd-gaia/agent-ui",
3
- "version": "0.21.1",
3
+ "version": "0.22.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",
@@ -70,7 +70,7 @@
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
75
  "jsdom": "^29.1.1",
76
76
  "lucide-react": "^1.17.0",
@@ -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
@@ -85,7 +85,7 @@ contextBridge.exposeInMainWorld("gaiaInstall", {
85
85
  // available" chip without pulling in the full gaiaAPI surface. Mirrors
86
86
  // the naming convention (gaiaInstall, gaiaUpdater, gaiaAPI).
87
87
  contextBridge.exposeInMainWorld("gaiaUpdater", {
88
- /** Get the current update state (status, version, progress, error). */
88
+ /** Get the current update state (status, version, progress, error, currentVersion, pinnedVersion). */
89
89
  getStatus: () => ipcRenderer.invoke("gaia:update:get-status"),
90
90
 
91
91
  /** Manually trigger a check. Resolves with the post-check state. */
@@ -100,4 +100,25 @@ contextBridge.exposeInMainWorld("gaiaUpdater", {
100
100
  ipcRenderer.on("gaia:update:status", handler);
101
101
  return () => ipcRenderer.removeListener("gaia:update:status", handler);
102
102
  },
103
+
104
+ /**
105
+ * Fetch available GitHub releases for this platform. Resolves with an
106
+ * array of ReleaseInfo objects (newest first) or { error: string } when
107
+ * the network call fails.
108
+ */
109
+ listReleases: () => ipcRenderer.invoke("gaia:update:list-releases"),
110
+
111
+ /**
112
+ * Download and install a specific tagged release, then prompt restart.
113
+ * Persists a version pin so auto-update stays paused (AC2).
114
+ *
115
+ * @param {string} tag e.g. "v0.20.0"
116
+ */
117
+ installVersion: (tag) => ipcRenderer.invoke("gaia:update:install-version", tag),
118
+
119
+ /**
120
+ * Resume normal auto-updates — clears the version pin set by installVersion.
121
+ * Resolves with the updated state.
122
+ */
123
+ resumeUpdates: () => ipcRenderer.invoke("gaia:update:resume"),
103
124
  });
@@ -16,20 +16,40 @@
16
16
  * - macOS: <Bundle>.app/Contents/Resources/agents/<id>/
17
17
  * - Linux: /opt/<AppName>/resources/agents/<id>/
18
18
  * - Target: path.join(os.homedir(), ".gaia", "agents", "<id>")
19
- * - Sentinel: <target>/.seeded (exists → already seeded → skip)
19
+ * - Sentinel: <target>/.seeded (this copy completed)
20
+ * - Marker: ~/.gaia/seeder/<id>.seeded ("this machine seeded <id> once";
21
+ * EXISTENCE is the signal — content is informational, never parsed for
22
+ * decisions. Lives outside ~/.gaia/agents/ so deleting an agent, or the
23
+ * whole agents dir, never resurrects it.)
20
24
  *
21
25
  * Write protocol (atomic-ish, crash-safe):
22
26
  * 1. Remove any stale `<id>.partial/` sibling from a prior failed run.
23
27
  * 2. Copy source → `<id>.partial/`.
24
28
  * 3. `fs.renameSync(<id>.partial, <id>)` — atomic on the same filesystem.
25
- * 4. Write `<id>/.seeded` last, so a partial seed never looks complete.
29
+ * 4. Write `<id>/.seeded`, so a partial seed never looks complete.
30
+ * 5. Write the marker LAST — a crash between 4 and 5 self-heals next
31
+ * launch via the marker back-fill (rule 2 below).
32
+ *
33
+ * Per-agent decision order (markers read fresh on every call, no caching):
34
+ * 1. Marker exists → skip, full stop. A deleted agent is never re-seeded;
35
+ * a dir the user re-created under the same id is never touched.
36
+ * 2. No marker, target has `.seeded` → back-fill the marker, skip.
37
+ * 3. No marker, target exists WITHOUT `.seeded` → user-owned data,
38
+ * log a warning, skip, write no marker.
39
+ * 4. No marker, no target → seed via the write protocol above.
40
+ *
41
+ * Recovery: to get a deleted bundled agent back, remove
42
+ * `~/.gaia/seeder/<id>.seeded` and relaunch.
43
+ *
44
+ * Legacy cleanup: the retired `zoo-agent` demo is removed from
45
+ * ~/.gaia/agents/ when it is provably an unmodified seeded copy — sentinel
46
+ * present, no file newer than its seededAt (+5s slack), never through a
47
+ * symlink. Removed ids are reported via the additive `cleaned` array.
26
48
  *
27
49
  * Behaviour:
28
- * - Target `<id>/` exists WITH `.seeded` → already seeded, skip.
29
- * - Target `<id>/` exists WITHOUT `.seeded` → treat as user-owned data,
30
- * log a warning, and skip (never clobber a hand-authored agent).
31
- * - `process.resourcesPath` unset (dev / Jest) or source dir missing →
32
- * empty result, no error.
50
+ * - `process.resourcesPath` unset (dev / Jest) → empty result, no error,
51
+ * no cleanup (never touch the real HOME from dev/test contexts).
52
+ * - Source dir missing → legacy cleanup still runs, then empty result.
33
53
  * - Per-agent failures are isolated: they go into `errors[]` but do not
34
54
  * stop the next agent from being seeded.
35
55
  *
@@ -53,6 +73,10 @@ function agentsTargetRoot() {
53
73
  return path.join(gaiaHome(), "agents");
54
74
  }
55
75
 
76
+ function seederMarkersDir() {
77
+ return path.join(gaiaHome(), "seeder");
78
+ }
79
+
56
80
  function logsDir() {
57
81
  return path.join(gaiaHome(), "logs");
58
82
  }
@@ -124,6 +148,48 @@ function isDirectory(p) {
124
148
  }
125
149
  }
126
150
 
151
+ // ── Marker helpers ───────────────────────────────────────────────────────
152
+
153
+ /**
154
+ * Defense-in-depth: an agent id must be a single plain path segment before
155
+ * it is used in any path.join or marker filename.
156
+ */
157
+ function isSinglePathSegment(id) {
158
+ return (
159
+ typeof id === "string" &&
160
+ id.length > 0 &&
161
+ id !== "." &&
162
+ id !== ".." &&
163
+ !id.includes("/") &&
164
+ !id.includes("\\") &&
165
+ !id.includes(path.sep)
166
+ );
167
+ }
168
+
169
+ function markerPath(id) {
170
+ if (!isSinglePathSegment(id)) {
171
+ throw new Error(`Invalid agent id for marker path: ${JSON.stringify(id)}`);
172
+ }
173
+ return path.join(seederMarkersDir(), `${id}.seeded`);
174
+ }
175
+
176
+ function hasMarker(id) {
177
+ return fs.existsSync(markerPath(id));
178
+ }
179
+
180
+ function writeMarker(id, source) {
181
+ fs.mkdirSync(seederMarkersDir(), { recursive: true });
182
+ fs.writeFileSync(
183
+ markerPath(id),
184
+ JSON.stringify(
185
+ { seededAt: new Date().toISOString(), source },
186
+ null,
187
+ 2
188
+ ),
189
+ { encoding: "utf8" }
190
+ );
191
+ }
192
+
127
193
  // ── Seeding core ─────────────────────────────────────────────────────────
128
194
 
129
195
  /**
@@ -135,18 +201,42 @@ function isDirectory(p) {
135
201
  * Throws only on programmer error. All IO errors are caught and logged.
136
202
  */
137
203
  function seedOneAgent(sourceDir, targetRoot, id) {
204
+ if (!isSinglePathSegment(id)) {
205
+ log("WARN", `Skipping invalid agent id ${JSON.stringify(id)}`);
206
+ return { status: "skipped" };
207
+ }
208
+
138
209
  const src = path.join(sourceDir, id);
139
210
  const target = path.join(targetRoot, id);
140
211
  const partial = path.join(targetRoot, `${id}.partial`);
141
212
  const sentinel = path.join(target, ".seeded");
142
213
 
143
- // Already seeded?
214
+ // Rule 1: marker wins, full stop — honors deletion and leaves any
215
+ // user-recreated dir under the same id untouched.
216
+ if (hasMarker(id)) {
217
+ log("INFO", `Skipping "${id}" — already seeded once on this machine (marker present)`);
218
+ return { status: "skipped" };
219
+ }
220
+
221
+ // Rule 2: seeded before markers existed → back-fill the marker.
222
+ // Runs every launch, so it also heals a crash between sentinel and marker.
144
223
  if (fs.existsSync(sentinel)) {
145
- log("INFO", `Skipping "${id}" — already seeded (sentinel present)`);
224
+ try {
225
+ writeMarker(id, src);
226
+ log("INFO", `Skipping "${id}" — already seeded (sentinel present); marker back-filled`);
227
+ } catch (err) {
228
+ log(
229
+ "ERROR",
230
+ `Failed to back-fill marker for "${id}": ${
231
+ err && err.message ? err.message : err
232
+ } — will retry next launch`
233
+ );
234
+ }
146
235
  return { status: "skipped" };
147
236
  }
148
237
 
149
- // Target exists but no sentinel → user-owned data. Do not touch.
238
+ // Rule 3: target exists but no sentinel → user-owned data. Do not touch,
239
+ // write no marker.
150
240
  if (fs.existsSync(target)) {
151
241
  log(
152
242
  "WARN",
@@ -176,7 +266,7 @@ function seedOneAgent(sourceDir, targetRoot, id) {
176
266
  copyDirRecursive(src, partial);
177
267
  fs.renameSync(partial, target);
178
268
 
179
- // Write sentinel LAST — its presence means "copy completed".
269
+ // Write sentinel — its presence means "copy completed".
180
270
  fs.writeFileSync(
181
271
  sentinel,
182
272
  JSON.stringify(
@@ -190,6 +280,9 @@ function seedOneAgent(sourceDir, targetRoot, id) {
190
280
  { encoding: "utf8" }
191
281
  );
192
282
 
283
+ // Marker LAST — a crash here self-heals via the rule-2 back-fill.
284
+ writeMarker(id, src);
285
+
193
286
  log("INFO", `Seeded "${id}" from ${src} to ${target}`);
194
287
  return { status: "seeded" };
195
288
  } catch (err) {
@@ -214,15 +307,156 @@ function seedOneAgent(sourceDir, targetRoot, id) {
214
307
  }
215
308
  }
216
309
 
310
+ // ── Legacy cleanup ───────────────────────────────────────────────────────
311
+
312
+ // Retired bundled ids removed from user machines when provably unmodified.
313
+ const LEGACY_CLEANUP_IDS = ["zoo-agent"];
314
+
315
+ // Filesystem mtime granularity + copy latency headroom for the
316
+ // modified-since-seed check.
317
+ const MODIFIED_SLACK_MS = 5000;
318
+
319
+ function listFilesRecursive(dir) {
320
+ const out = [];
321
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
322
+ // __pycache__/*.pyc is regenerated by the registry on every load — it
323
+ // postdates seededAt but is not a user modification.
324
+ if (entry.isDirectory() && entry.name === "__pycache__") continue;
325
+ if (!entry.isDirectory() && entry.name.endsWith(".pyc")) continue;
326
+ const p = path.join(dir, entry.name);
327
+ if (entry.isDirectory()) {
328
+ out.push(...listFilesRecursive(p));
329
+ } else {
330
+ out.push(p);
331
+ }
332
+ }
333
+ return out;
334
+ }
335
+
336
+ /**
337
+ * Remove retired seeded demo agents (currently `zoo-agent`) from
338
+ * ~/.gaia/agents/. Deletes ONLY when the dir is provably an unmodified
339
+ * seeded copy; anything ambiguous is left in place. Failures are logged
340
+ * and retried on the next launch (the sentinel keys the retry — no marker
341
+ * is involved, since a retired id can never be re-seeded anyway).
342
+ *
343
+ * @returns {string[]} ids actually removed (verified gone from disk).
344
+ */
345
+ function cleanupLegacyAgents() {
346
+ const cleaned = [];
347
+
348
+ for (const id of LEGACY_CLEANUP_IDS) {
349
+ const target = path.join(agentsTargetRoot(), id);
350
+
351
+ let st;
352
+ try {
353
+ st = fs.lstatSync(target);
354
+ } catch {
355
+ continue; // not present — nothing to clean
356
+ }
357
+
358
+ // Never rm through a symlink — it would delete the link target.
359
+ if (st.isSymbolicLink()) {
360
+ log(
361
+ "WARN",
362
+ `Legacy "${id}" at ${target} is a symlink — leaving it untouched`
363
+ );
364
+ continue;
365
+ }
366
+
367
+ if (!st.isDirectory()) {
368
+ log("WARN", `Legacy "${id}" at ${target} is not a directory — leaving it untouched`);
369
+ continue;
370
+ }
371
+
372
+ // No sentinel → user-authored, not ours to remove.
373
+ const sentinel = path.join(target, ".seeded");
374
+ if (!fs.existsSync(sentinel)) {
375
+ continue;
376
+ }
377
+
378
+ let seededAtMs;
379
+ try {
380
+ seededAtMs = Date.parse(
381
+ JSON.parse(fs.readFileSync(sentinel, "utf8")).seededAt
382
+ );
383
+ if (!Number.isFinite(seededAtMs)) {
384
+ throw new Error("seededAt missing or not a valid timestamp");
385
+ }
386
+ } catch (err) {
387
+ log(
388
+ "WARN",
389
+ `Cannot determine seededAt for legacy "${id}" (${
390
+ err && err.message ? err.message : err
391
+ }) — treating as user-modified, leaving it untouched`
392
+ );
393
+ continue;
394
+ }
395
+
396
+ // Any file newer than the seed time means the user customized it —
397
+ // it is their work now, never delete it.
398
+ let modified = false;
399
+ try {
400
+ for (const file of listFilesRecursive(target)) {
401
+ if (fs.lstatSync(file).mtimeMs > seededAtMs + MODIFIED_SLACK_MS) {
402
+ log(
403
+ "WARN",
404
+ `Legacy "${id}" was modified after seeding (${file}) — leaving it untouched`
405
+ );
406
+ modified = true;
407
+ break;
408
+ }
409
+ }
410
+ } catch (err) {
411
+ log(
412
+ "WARN",
413
+ `Cannot inspect legacy "${id}" for modifications (${
414
+ err && err.message ? err.message : err
415
+ }) — leaving it untouched`
416
+ );
417
+ continue;
418
+ }
419
+ if (modified) {
420
+ continue;
421
+ }
422
+
423
+ try {
424
+ fs.rmSync(target, { recursive: true, force: true });
425
+ } catch (err) {
426
+ log(
427
+ "ERROR",
428
+ `Failed to remove legacy "${id}" at ${target}: ${
429
+ err && err.message ? err.message : err
430
+ } — will retry next launch`
431
+ );
432
+ continue;
433
+ }
434
+
435
+ // Report success only after verifying it is actually gone.
436
+ if (fs.existsSync(target)) {
437
+ log(
438
+ "ERROR",
439
+ `Legacy "${id}" still present after removal attempt: ${target} — will retry next launch`
440
+ );
441
+ continue;
442
+ }
443
+
444
+ log("INFO", `Removed legacy seeded agent "${id}" from ${target}`);
445
+ cleaned.push(id);
446
+ }
447
+
448
+ return cleaned;
449
+ }
450
+
217
451
  /**
218
452
  * Seed all bundled agents found under `<resourcesPath>/agents/`.
219
453
  *
220
454
  * Idempotent — safe to call on every app launch.
221
455
  *
222
- * @returns {Promise<{seeded: string[], skipped: string[], errors: {id: string, error: Error}[]}>}
456
+ * @returns {Promise<{seeded: string[], skipped: string[], errors: {id: string, error: Error}[], cleaned: string[]}>}
223
457
  */
224
458
  async function seedBundledAgents() {
225
- const result = { seeded: [], skipped: [], errors: [] };
459
+ const result = { seeded: [], skipped: [], errors: [], cleaned: [] };
226
460
 
227
461
  // Guard against dev / test environments where resourcesPath is unset.
228
462
  if (!process.resourcesPath) {
@@ -233,6 +467,11 @@ async function seedBundledAgents() {
233
467
  return result;
234
468
  }
235
469
 
470
+ // Legacy cleanup runs before the missing-source guard: an upgraded
471
+ // install must shed the retired zoo-agent even if this build ships no
472
+ // bundled agents.
473
+ result.cleaned = cleanupLegacyAgents();
474
+
236
475
  const sourceDir = path.join(process.resourcesPath, "agents");
237
476
 
238
477
  if (!fs.existsSync(sourceDir) || !isDirectory(sourceDir)) {
@@ -284,7 +523,8 @@ async function seedBundledAgents() {
284
523
  log(
285
524
  "INFO",
286
525
  `Seeding complete — seeded=${result.seeded.length} ` +
287
- `skipped=${result.skipped.length} errors=${result.errors.length}`
526
+ `skipped=${result.skipped.length} errors=${result.errors.length} ` +
527
+ `cleaned=${result.cleaned.length}`
288
528
  );
289
529
 
290
530
  return result;
@@ -295,7 +535,9 @@ module.exports = {
295
535
  // Exposed for tests — do not rely on these from production code.
296
536
  _internals: {
297
537
  seedOneAgent,
538
+ cleanupLegacyAgents,
298
539
  agentsTargetRoot,
540
+ seederMarkersDir,
299
541
  logFilePath,
300
542
  },
301
543
  };