@amd-gaia/agent-ui 0.25.4-rc.2 → 0.25.4-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/bin/gaia-ui.cjs +2 -23
  2. package/dist/assets/AdvancedSettings-DoIDd09m.js +1 -0
  3. package/dist/assets/ConnectorsPane-CK3h7C7E.js +1 -0
  4. package/dist/assets/ConnectorsPane-CzLYXGMl.css +1 -0
  5. package/dist/assets/DocumentLibrary-BWvkvxTu.js +4 -0
  6. package/dist/assets/FileBrowser-DhqWGirJ.js +4 -0
  7. package/dist/assets/MemoryDashboard-BF-o8284.js +1 -0
  8. package/dist/assets/MobileAccessModal-51t2kQaJ.js +2 -0
  9. package/dist/assets/MobileAccessModal-BkVDm3YG.css +1 -0
  10. package/dist/assets/ScheduleManager-2xjZsnLK.js +2 -0
  11. package/dist/assets/api-BzlFpP4T.js +4 -0
  12. package/dist/assets/apiBase-BMkGbdXt.js +1 -0
  13. package/dist/assets/index-C_Cx5IlD.css +1 -0
  14. package/dist/assets/index-hAcJijAU.js +78 -0
  15. package/dist/assets/{zap-BdhDcjhB.js → zap-D7aLu_f0.js} +1 -1
  16. package/dist/icons/apple-touch-icon.png +0 -0
  17. package/dist/icons/icon-192.png +0 -0
  18. package/dist/icons/icon-512.png +0 -0
  19. package/dist/icons/icon-maskable-192.png +0 -0
  20. package/dist/icons/icon-maskable-512.png +0 -0
  21. package/dist/index.html +8 -3
  22. package/dist/manifest.json +16 -0
  23. package/dist/sw.js +49 -0
  24. package/main-safety-net.cjs +106 -8
  25. package/main.cjs +139 -159
  26. package/package.json +2 -1
  27. package/preload.cjs +16 -9
  28. package/services/auto-updater.cjs +780 -216
  29. package/services/backend-installer.cjs +233 -344
  30. package/services/engine-install.cjs +142 -0
  31. package/services/index-query.cjs +7 -1
  32. package/services/quit-guard.cjs +10 -3
  33. package/services/tray-manager.cjs +12 -7
  34. package/dist/assets/AdvancedSettings-B4MY5XPH.js +0 -1
  35. package/dist/assets/ConnectorsPane-B71QL-Kl.js +0 -1
  36. package/dist/assets/ConnectorsPane-Dzu2DRTc.css +0 -1
  37. package/dist/assets/DocumentLibrary-c78Y6arz.js +0 -4
  38. package/dist/assets/FileBrowser-DwEPODjV.js +0 -4
  39. package/dist/assets/MemoryDashboard-BQhg3t2B.js +0 -1
  40. package/dist/assets/MobileAccessModal-DuVy9F1u.css +0 -1
  41. package/dist/assets/MobileAccessModal-RI4HtcYe.js +0 -2
  42. package/dist/assets/ScheduleManager-tcyjbGY9.js +0 -2
  43. package/dist/assets/apiBase-DRtaTd8u.js +0 -1
  44. package/dist/assets/index-C9zLn8zo.css +0 -1
  45. package/dist/assets/index-CKOG_8jk.js +0 -78
  46. package/services/backend-installer-progress-dialog.cjs +0 -510
@@ -16,10 +16,12 @@
16
16
  * - ensureUv() → Promise<void>
17
17
  * - installBackend(opts) → Promise<void>
18
18
  * - ensureBackend(opts) → Promise<string> (returns gaia bin path)
19
+ * - isBackendCurrent() → boolean (nothing to install or refresh)
19
20
  * - getInstalledVersion(gaiaBin) → string | null
20
21
  * - findGaiaBin() → string | null
21
22
  * - getState() / setState() → state machine helpers
22
23
  * - getLogPath() / getStatePath() → path helpers
24
+ * - readLogTail(maxLines) → string[]
23
25
  * - runPreChecks(opts) → Promise<PreCheckResult>
24
26
  * - STATES → state name constants
25
27
  *
@@ -54,10 +56,16 @@ const GAIA_PYTHON_BIN = IS_WINDOWS
54
56
 
55
57
  const STATE_FILE = path.join(GAIA_HOME, "electron-install-state.json");
56
58
  const LOG_FILE = path.join(GAIA_HOME, "electron-install.log");
57
-
58
- // Floor for uv, Python and the GAIA package only. The models, often 30 GB,
59
- // are checked by `gaia init` against Lemonade's model store, which it sizes.
60
- const MIN_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
59
+ // hub/installer.py writes this once the flagship is installed; first-run setup installs it.
60
+ const FLAGSHIP_SENTINEL = path.join(GAIA_HOME, "agents", "gaia", ".installed");
61
+ /** `gaia init --agent-only` kept the installed agent: the hub couldn't be reached. */
62
+ const AGENT_KEPT_EXIT = 5;
63
+
64
+ // Room for what this installer writes: Python 3.12, uv's wheel cache and the
65
+ // `amd-gaia[ui]` venv (0.3 GB on macOS arm64 for 0.25.1rc5, under 1 GB in
66
+ // all). Models are not installed here; first-run setup checks the disk for
67
+ // the download the user picks.
68
+ const MIN_DISK_SPACE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB
61
69
  const NETWORK_CHECK_HOSTS = Object.freeze([
62
70
  "https://pypi.org/simple/",
63
71
  "https://astral.sh",
@@ -131,24 +139,30 @@ const STAGES = Object.freeze({
131
139
  ENSURE_UV: "ensure-uv",
132
140
  CREATE_VENV: "create-venv",
133
141
  INSTALL_PACKAGE: "install-package",
134
- GAIA_INIT: "gaia-init",
135
142
  VERIFY: "verify",
143
+ // After the package, and outside the weighted bar: it has no percent to report.
144
+ AGENT_REFRESH: "agent-refresh",
145
+ });
146
+
147
+ // Why an install failed, for what the app tells the user to do about it.
148
+ const FAILURE_REASONS = Object.freeze({
149
+ DISK: "disk",
150
+ NETWORK: "network",
151
+ TLS: "tls",
152
+ LOCKED: "locked",
136
153
  });
137
154
 
138
- const GAIA_INIT_MESSAGE =
139
- "Setting up Lemonade Server and downloading models (this can take several minutes)";
140
- const WAITING_FOR_LEMONADE_CHOICE_MESSAGE =
141
- "Waiting for your answer: GAIA found a Lemonade Server already running " +
142
- "and is asking whether to use it. Setup continues once you choose.";
155
+ // Stage an older installer recorded while it ran `gaia init`; the package was
156
+ // already in place by then.
157
+ const LEGACY_SETUP_STAGE = "gaia-init";
143
158
 
144
159
  // Weight each stage contributes to the overall 0-100 progress.
145
160
  // Sum must equal 100.
146
161
  const STAGE_WEIGHTS = {
147
162
  [STAGES.PRE_CHECKS]: 2,
148
163
  [STAGES.ENSURE_UV]: 8,
149
- [STAGES.CREATE_VENV]: 10,
150
- [STAGES.INSTALL_PACKAGE]: 50,
151
- [STAGES.GAIA_INIT]: 28,
164
+ [STAGES.CREATE_VENV]: 15,
165
+ [STAGES.INSTALL_PACKAGE]: 73,
152
166
  [STAGES.VERIFY]: 2,
153
167
  };
154
168
 
@@ -157,7 +171,6 @@ const STAGE_ORDER = [
157
171
  STAGES.ENSURE_UV,
158
172
  STAGES.CREATE_VENV,
159
173
  STAGES.INSTALL_PACKAGE,
160
- STAGES.GAIA_INIT,
161
174
  STAGES.VERIFY,
162
175
  ];
163
176
 
@@ -175,10 +188,6 @@ let logStream = null;
175
188
  */
176
189
  let logRotatedThisSession = false;
177
190
 
178
- function isTruthyEnv(value) {
179
- return /^(1|true|yes|on)$/i.test(String(value || ""));
180
- }
181
-
182
191
  function ensureGaiaHome() {
183
192
  try {
184
193
  if (!fs.existsSync(GAIA_HOME)) {
@@ -321,6 +330,16 @@ function getStatePath() {
321
330
  return STATE_FILE;
322
331
  }
323
332
 
333
+ /** The end of the install log, or nothing when no install has written one. */
334
+ function readLogTail(maxLines = 40) {
335
+ try {
336
+ return fs.readFileSync(LOG_FILE, "utf8").split(/\r?\n/).filter(Boolean).slice(-maxLines);
337
+ } catch (err) {
338
+ if (err.code === "ENOENT") return [];
339
+ throw err;
340
+ }
341
+ }
342
+
324
343
  // ── State machine ────────────────────────────────────────────────────────────
325
344
 
326
345
  /**
@@ -396,7 +415,8 @@ function computeOverallPercent(stage, withinStagePercent) {
396
415
  function makeProgressReporter(onProgress) {
397
416
  const safe = typeof onProgress === "function" ? onProgress : () => {};
398
417
  return function report(stage, withinStagePercent, message) {
399
- const percent = computeOverallPercent(stage, withinStagePercent);
418
+ const percent =
419
+ withinStagePercent === null ? null : computeOverallPercent(stage, withinStagePercent);
400
420
  try {
401
421
  safe(stage, percent, message || "");
402
422
  } catch (err) {
@@ -405,148 +425,6 @@ function makeProgressReporter(onProgress) {
405
425
  };
406
426
  }
407
427
 
408
- // ── gaia init progress (`gaia init --progress-json`) ─────────────────────────
409
-
410
- // Where the plan and the end of the downloads land in the GAIA_INIT slice; the
411
- // rest of the slice is Lemonade setup before and verification after.
412
- const INIT_PLAN_PERCENT = 5;
413
- const INIT_DOWNLOADED_PERCENT = 95;
414
-
415
- /**
416
- * Read one line of `gaia init --progress-json` output. Returns `{ event }` for
417
- * a progress event, `{ error }` for a line that looks like one but is not, and
418
- * null for human output.
419
- */
420
- function parseInitEventLine(line) {
421
- const text = String(line || "").trim();
422
- if (!text.startsWith("{")) return null;
423
- let value;
424
- try {
425
- value = JSON.parse(text);
426
- } catch (err) {
427
- return { error: err.message };
428
- }
429
- if (!value || typeof value !== "object" || typeof value.event !== "string") {
430
- return { error: 'no "event" field' };
431
- }
432
- return { event: value };
433
- }
434
-
435
- function formatGB(bytes) {
436
- return `${(Math.max(0, bytes || 0) / 1e9).toFixed(1)} GB`;
437
- }
438
-
439
- function formatSpeed(bytesPerSecond) {
440
- return `${(bytesPerSecond / 1e6).toFixed(1)} MB/s`;
441
- }
442
-
443
- function formatTimeLeft(seconds) {
444
- if (seconds < 60) return "less than a minute left";
445
- const minutes = Math.round(seconds / 60);
446
- if (minutes < 60) return `about ${minutes} min left`;
447
- return `about ${Math.floor(minutes / 60)} h ${minutes % 60} min left`;
448
- }
449
-
450
- /**
451
- * Map `gaia init --progress-json` events onto the GAIA_INIT slice of the bar.
452
- * `report(withinStagePercent, message)` receives a percent that never goes
453
- * back. `onLine` returns true for the lines too frequent to log.
454
- */
455
- function createInitProgress(report, logFn = log) {
456
- const models = new Map();
457
- let percent = 0;
458
-
459
- const advance = (within, message) => {
460
- percent = Math.max(percent, Math.round(within));
461
- report(percent, message);
462
- };
463
- const totals = () => {
464
- let done = 0;
465
- let total = 0;
466
- for (const m of models.values()) {
467
- total += m.total;
468
- done += Math.min(m.done, m.total);
469
- }
470
- return { done, total };
471
- };
472
- const downloadPercent = () => {
473
- const { done, total } = totals();
474
- const share = total > 0 ? done / total : 0;
475
- return INIT_PLAN_PERCENT + (INIT_DOWNLOADED_PERCENT - INIT_PLAN_PERCENT) * share;
476
- };
477
- const model = (id) => {
478
- if (!models.has(id)) models.set(id, { done: 0, total: 0 });
479
- return models.get(id);
480
- };
481
-
482
- function handle(event) {
483
- switch (event.event) {
484
- case "step":
485
- advance(percent, `Step ${event.step} of ${event.total}: ${event.message}`);
486
- return true;
487
- case "plan":
488
- for (const m of event.models || []) {
489
- if (!m.downloaded) model(m.id).total = m.bytes_total || 0;
490
- }
491
- advance(
492
- INIT_PLAN_PERCENT,
493
- event.bytes_total > 0
494
- ? `Downloading ${formatGB(event.bytes_total)} of models`
495
- : "Models already downloaded"
496
- );
497
- return true;
498
- case "progress": {
499
- const m = model(event.model);
500
- m.done = event.bytes_done || 0;
501
- m.total = Math.max(event.bytes_total || 0, m.done);
502
- const parts = [
503
- `Downloading ${event.model} (${event.index} of ${event.count}): ` +
504
- `${formatGB(m.done)} of ${formatGB(m.total)}`,
505
- ];
506
- if (event.speed_bps > 0) {
507
- const { done, total } = totals();
508
- parts.push(
509
- `at ${formatSpeed(event.speed_bps)}, ` +
510
- formatTimeLeft((total - done) / event.speed_bps)
511
- );
512
- }
513
- advance(downloadPercent(), parts.join(" "));
514
- return true;
515
- }
516
- case "model_done": {
517
- const m = model(event.model);
518
- m.done = m.total;
519
- advance(downloadPercent(), `${event.model} ready`);
520
- return true;
521
- }
522
- case "done":
523
- advance(
524
- event.ok ? INIT_DOWNLOADED_PERCENT : percent,
525
- event.ok ? "Models downloaded, finishing setup" : "Model download did not finish"
526
- );
527
- return true;
528
- case "error":
529
- advance(percent, String(event.message || "gaia init reported an error").trim());
530
- return true;
531
- default:
532
- return false;
533
- }
534
- }
535
-
536
- function onLine(line) {
537
- const parsed = parseInitEventLine(line);
538
- if (!parsed) return false;
539
- if (parsed.error) {
540
- logFn(`Malformed gaia init progress line (${parsed.error}): ${line}`);
541
- return false;
542
- }
543
- handle(parsed.event);
544
- return parsed.event.event === "progress";
545
- }
546
-
547
- return { onLine, handle };
548
- }
549
-
550
428
  // ── Command helpers ──────────────────────────────────────────────────────────
551
429
 
552
430
  /**
@@ -1078,15 +956,24 @@ async function runPreChecks(opts = {}) {
1078
956
  // ── uv install ───────────────────────────────────────────────────────────────
1079
957
 
1080
958
  class InstallError extends Error {
1081
- constructor(message, { stage, code, suggestion } = {}) {
959
+ constructor(message, { stage, code, suggestion, reason } = {}) {
1082
960
  super(message);
1083
961
  this.name = "InstallError";
1084
962
  this.stage = stage || null;
1085
963
  this.code = code || null;
1086
964
  this.suggestion = suggestion || null;
965
+ this.reason = reason || null;
1087
966
  }
1088
967
  }
1089
968
 
969
+ /** Which FAILURE_REASONS a uv failure's output shows, or null. */
970
+ function failureReason(output) {
971
+ if (isFileLockedError(output)) return FAILURE_REASONS.LOCKED;
972
+ if (isTlsCertError(output)) return FAILURE_REASONS.TLS;
973
+ if (isTransientNetworkError(output)) return FAILURE_REASONS.NETWORK;
974
+ return null;
975
+ }
976
+
1090
977
  /**
1091
978
  * Which `extraResources` subdirectory holds the bundled uv for this host.
1092
979
  * Returns null for platforms we don't yet ship a binary for (falls through
@@ -1545,6 +1432,11 @@ function resolveBackendVersion(opts = {}) {
1545
1432
  }
1546
1433
  }
1547
1434
 
1435
+ /** The version the app ships with, whatever backend this run was asked to install. */
1436
+ function appVersion() {
1437
+ return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).version;
1438
+ }
1439
+
1548
1440
  /**
1549
1441
  * The PEP 440 spelling of the app's npm version: `0.25.0-rc.1` -> `0.25.0rc1`.
1550
1442
  * pip pins and `gaia --version` use this form; a final version is unchanged.
@@ -1554,142 +1446,16 @@ function toPep440(version) {
1554
1446
  return version.replace(/^(\d+\.\d+\.\d+)-rc\.(\d+)$/, "$1rc$2");
1555
1447
  }
1556
1448
 
1557
- function shouldSkipGaiaInit(opts) {
1558
- return Boolean(opts.skipGaiaInit) || isTruthyEnv(process.env.GAIA_SKIP_GAIA_INIT);
1559
- }
1560
-
1561
- /**
1562
- * Run `gaia init` for the profile the app's own setup check asks about
1563
- * (setup_runner.PROFILE), so the first launch finds setup done instead of
1564
- * running it a second time.
1565
- *
1566
- * `chooseLemonade` is asked about a Lemonade Server already running here; see
1567
- * lemonadeInitArgs.
1568
- *
1569
- * Returns `{ init }`, "done", "failed" or "skipped", plus `initError` when it
1570
- * failed. The state file records both: a backend whose setup never finished is
1571
- * not installed, so the next launch runs it, and the app shows the error.
1572
- */
1573
- async function runGaiaInit(gaiaBin, report, version, skipGaiaInit, chooseLemonade) {
1574
- if (skipGaiaInit) {
1575
- log("Skipping gaia init (skipGaiaInit=true or GAIA_SKIP_GAIA_INIT set)");
1576
- return { init: "skipped" };
1577
- }
1578
- setState(STATES.INSTALLING, { stage: STAGES.GAIA_INIT, version });
1579
- report(STAGES.GAIA_INIT, 0, GAIA_INIT_MESSAGE);
1580
- const lemonadeArgs = await lemonadeInitArgs(gaiaBin, chooseLemonade, runCommand, report);
1581
- const progress = createInitProgress((within, message) =>
1582
- report(STAGES.GAIA_INIT, within, message)
1583
- );
1584
- const initResult = await runCommand(
1585
- gaiaBin,
1586
- ["init", "--profile", "gaia", "--yes", "--skip-webui-build", "--progress-json", ...lemonadeArgs],
1587
- // Unwrapped, so an error line reaches initFailureReason whole.
1588
- { stageLabel: "gaia-init", env: { ...process.env, COLUMNS: "1000" }, onLine: progress.onLine }
1589
- );
1590
- if (initResult.code !== 0) {
1591
- // Non-fatal: the app opens and its start screen shows initError, and the
1592
- // next launch runs setup again, because the state file records the failure.
1593
- log(
1594
- `Warning: gaia init exited with code ${initResult.code}. Continuing; setup runs again next launch.`
1595
- );
1596
- report(STAGES.GAIA_INIT, 100, "Lemonade Server setup did not finish");
1597
- return { init: "failed", initError: initFailureReason(initResult) };
1598
- }
1599
- report(STAGES.GAIA_INIT, 100, "Lemonade Server setup complete");
1600
- return { init: "done" };
1601
- }
1602
-
1603
- /** Why `gaia init` failed, in its own words: its last ❌ line, else its last line. */
1604
- function initFailureReason({ code, stdout, stderr }) {
1605
- const lines = `${stdout || ""}\n${stderr || ""}`
1606
- .split(/\r?\n/)
1607
- .map((line) => line.trim())
1608
- // --progress-json event lines are for the progress bar, not the user.
1609
- .filter((line) => line && !line.startsWith('{"event"'));
1610
- const marked = lines.filter((line) => line.startsWith("❌"));
1611
- const reason = marked.length
1612
- ? marked[marked.length - 1].replace(/^❌\s*/, "")
1613
- : lines[lines.length - 1];
1614
- return reason || `gaia init exited with code ${code} and printed nothing.`;
1615
- }
1616
-
1617
- /**
1618
- * Run a `gaia ... --json` command and parse what it printed.
1619
- * Returns undefined after logging a warning that names the failure.
1620
- */
1621
- async function readGaiaJson(run, gaiaBin, args) {
1622
- const shown = `gaia ${args.join(" ")}`;
1623
- const result = await run(gaiaBin, args, { stageLabel: "lemonade" });
1624
- if (result.code !== 0) {
1625
- log(`Warning: ${shown} exited with code ${result.code}: ${(result.stderr || result.stdout || "").trim().slice(-400)}`);
1626
- return undefined;
1627
- }
1628
- const lines = (result.stdout || "").trim().split(/\r?\n/);
1629
- try {
1630
- return JSON.parse(lines[lines.length - 1]);
1631
- } catch (err) {
1632
- log(`Warning: ${shown} printed no JSON (${err.message}): ${(result.stdout || "").trim().slice(-400)}`);
1633
- return undefined;
1634
- }
1635
- }
1636
-
1637
- /**
1638
- * Extra `gaia init` args for a Lemonade Server the user already runs.
1639
- *
1640
- * Asks `chooseLemonade(found)` only on a first setup (nothing saved, no
1641
- * LEMONADE_BASE_URL) when a usable server answers. "external" adds
1642
- * `--lemonade-url <url>`, "gaia" adds `--gaia-lemonade`, null adds nothing:
1643
- * init then sets up GAIA's own and prints how to switch, as it always has.
1644
- * A server that needs a key or is too old is left to the app's setup screen.
1645
- *
1646
- * `report` is the progress reporter: while the question is open the progress
1647
- * window says setup is waiting on the user, then goes back to GAIA_INIT_MESSAGE.
1648
- */
1649
- async function lemonadeInitArgs(gaiaBin, chooseLemonade, run = runCommand, report = () => {}) {
1650
- if (typeof chooseLemonade !== "function") return [];
1651
- const unchanged = "gaia init will set up GAIA's own Lemonade Server; to use yours later run `gaia lemonade connect <url>`.";
1652
- const status = await readGaiaJson(run, gaiaBin, ["lemonade", "status", "--json"]);
1653
- if (status === undefined || status === null) {
1654
- log(`Warning: could not read which Lemonade Server GAIA uses; ${unchanged}`);
1655
- return [];
1656
- }
1657
- if (status.saved_mode != null || status.source === "env") return [];
1658
- const found = await readGaiaJson(run, gaiaBin, ["lemonade", "detect", "--json"]);
1659
- if (found === undefined) {
1660
- log(`Warning: could not look for a Lemonade Server already running; ${unchanged}`);
1661
- return [];
1662
- }
1663
- if (!found) return [];
1664
- if (found.status !== "ok") {
1665
- log(`Found a Lemonade Server at ${found.url} (${found.status}); the app's setup screen offers it.`);
1666
- return [];
1667
- }
1668
- let answer;
1669
- report(STAGES.GAIA_INIT, 0, WAITING_FOR_LEMONADE_CHOICE_MESSAGE);
1670
- try {
1671
- answer = await chooseLemonade(found);
1672
- } catch (err) {
1673
- log(`Warning: could not ask whether to use the Lemonade Server at ${found.url}: ${err.message}; ${unchanged}`);
1674
- return [];
1675
- } finally {
1676
- report(STAGES.GAIA_INIT, 0, GAIA_INIT_MESSAGE);
1677
- }
1678
- log(`Lemonade Server at ${found.url}: user chose ${answer || "not to answer"}`);
1679
- if (answer === "external") return ["--lemonade-url", found.url];
1680
- if (answer === "gaia") return ["--gaia-lemonade"];
1681
- return [];
1682
- }
1683
-
1684
1449
  /**
1685
1450
  * Install the GAIA Python backend from scratch.
1686
1451
  *
1452
+ * Installs no model and runs no `gaia init`: the Lemonade Server and the models
1453
+ * are set up by the app's first-run setup, where the user chooses cloud or
1454
+ * local and sees what downloads.
1455
+ *
1687
1456
  * opts:
1688
1457
  * - onProgress(stage, percent, message)
1689
1458
  * - version: string — override the pinned version
1690
- * - skipGaiaInit: boolean — skip `gaia init` (for testing)
1691
- * - chooseLemonade(found): Promise<"external"|"gaia"|null> — asked when a
1692
- * Lemonade Server already runs here; see lemonadeInitArgs
1693
1459
  *
1694
1460
  * Throws InstallError on failure. The state file is updated to reflect
1695
1461
  * the current stage so a subsequent launch can recover.
@@ -1707,7 +1473,6 @@ async function installBackend(opts = {}) {
1707
1473
  const pipPackage = localWheel
1708
1474
  ? `${localWheel}[ui]`
1709
1475
  : `amd-gaia[ui]==${toPep440(version)}`;
1710
- const skipGaiaInit = shouldSkipGaiaInit(opts);
1711
1476
 
1712
1477
  log("================================================");
1713
1478
  log(" Installing GAIA backend");
@@ -1775,6 +1540,7 @@ async function installBackend(opts = {}) {
1775
1540
  {
1776
1541
  stage: STAGES.CREATE_VENV,
1777
1542
  code: venvResult.code,
1543
+ reason: failureReason(`${venvResult.stdout || ""}\n${venvResult.stderr || ""}`),
1778
1544
  suggestion: `Try creating it manually:\n ${manual}\nThen restart GAIA.`,
1779
1545
  }
1780
1546
  );
@@ -1804,6 +1570,21 @@ async function installBackend(opts = {}) {
1804
1570
  // would otherwise fail the whole bootstrap (and block a release). File-lock
1805
1571
  // failures (Windows os-error-32) are NOT retried — they need user action.
1806
1572
  let installResult;
1573
+ if (!localWheel && isBackendPrefetched(version)) {
1574
+ report(STAGES.INSTALL_PACKAGE, 0, `Installing ${pipPackage} from the downloaded update`);
1575
+ const offline = await runCommand(
1576
+ "uv",
1577
+ ["pip", "install", pipPackage, "--offline", "--compile-bytecode", "--python", GAIA_PYTHON_BIN],
1578
+ { stageLabel: "pip" }
1579
+ );
1580
+ fs.rmSync(_prefetchMarker(toPep440(version)), { force: true });
1581
+ if (offline.code === 0) {
1582
+ installResult = offline;
1583
+ } else {
1584
+ log(`The prefetched backend is no longer all in uv's cache (${_lastLines(offline)}) — installing from PyPI`);
1585
+ }
1586
+ }
1587
+ const installedOffline = Boolean(installResult);
1807
1588
  let useNativeTls = false;
1808
1589
  let nativeTlsAttempted = false;
1809
1590
  // The TLS branch flips useNativeTls and `continue`s, consuming an iteration.
@@ -1811,7 +1592,7 @@ async function installBackend(opts = {}) {
1811
1592
  // so the promised --native-tls retry actually runs (issue #1693 review).
1812
1593
  for (
1813
1594
  let attempt = 1;
1814
- attempt <= INSTALL_MAX_ATTEMPTS || (useNativeTls && !nativeTlsAttempted);
1595
+ !installedOffline && (attempt <= INSTALL_MAX_ATTEMPTS || (useNativeTls && !nativeTlsAttempted));
1815
1596
  attempt++
1816
1597
  ) {
1817
1598
  const attemptArgs = useNativeTls ? [...pipArgs, "--native-tls"] : pipArgs;
@@ -1871,6 +1652,14 @@ async function installBackend(opts = {}) {
1871
1652
  : "") +
1872
1653
  "Ask IT to install the proxy's root CA in your system's trusted certificate store, then relaunch GAIA. " +
1873
1654
  "See https://amd-gaia.ai/docs/quickstart#cli-install";
1655
+ } else if (isTransientNetworkError(output)) {
1656
+ // The log file keeps pip's output; the box says what to do about it.
1657
+ throw new InstallError("GAIA couldn't reach the internet to finish installing.", {
1658
+ stage: STAGES.INSTALL_PACKAGE,
1659
+ code: installResult.code,
1660
+ suggestion:
1661
+ "Check your connection, then choose Retry. If it keeps failing, the log file below says why. Your chats and settings are safe.",
1662
+ });
1874
1663
  } else {
1875
1664
  suggestion = `Try installing manually:\n uv pip install ${pipPackage} --python ${
1876
1665
  IS_WINDOWS ? `${GAIA_VENV_DISPLAY}/Scripts/python.exe` : `${GAIA_VENV_DISPLAY}/bin/python`
@@ -1891,6 +1680,7 @@ async function installBackend(opts = {}) {
1891
1680
  {
1892
1681
  stage: STAGES.INSTALL_PACKAGE,
1893
1682
  code: installResult.code,
1683
+ reason: failureReason(output),
1894
1684
  suggestion,
1895
1685
  }
1896
1686
  );
@@ -1907,16 +1697,7 @@ async function installBackend(opts = {}) {
1907
1697
  }
1908
1698
  report(STAGES.INSTALL_PACKAGE, 100, "GAIA package installed");
1909
1699
 
1910
- // Stage 4: gaia init
1911
- const setup = await runGaiaInit(
1912
- GAIA_BIN,
1913
- report,
1914
- version,
1915
- skipGaiaInit,
1916
- opts.chooseLemonade
1917
- );
1918
-
1919
- // Stage 5: verify
1700
+ // Stage 4: verify
1920
1701
  setState(STATES.INSTALLING, { stage: STAGES.VERIFY, version });
1921
1702
  report(STAGES.VERIFY, 0, "Verifying installation");
1922
1703
 
@@ -1974,12 +1755,141 @@ async function installBackend(opts = {}) {
1974
1755
  log(`Shim creation check failed: ${err.message}`);
1975
1756
  }
1976
1757
 
1977
- setState(STATES.READY, { stage: null, version, installedVersion, ...setup });
1758
+ setState(STATES.READY, { stage: null, version, installedVersion });
1978
1759
  log("Backend install complete");
1979
1760
  }
1980
1761
 
1762
+ // ── Agent refresh ────────────────────────────────────────────────────────────
1763
+
1764
+ /**
1765
+ * Update the flagship to this release, without Lemonade or models. A PC with
1766
+ * no flagship is skipped: first-run setup installs it.
1767
+ *
1768
+ * The stage stays recorded until this succeeds, so an update that failed or was
1769
+ * quit part way through refreshes again on the next launch.
1770
+ */
1771
+ async function refreshAgent(gaiaBin, opts = {}) {
1772
+ const version = resolveBackendVersion(opts);
1773
+ if (!fs.existsSync(FLAGSHIP_SENTINEL)) {
1774
+ log("No GAIA agent installed yet — first-run setup installs it");
1775
+ // Nothing to refresh: a refresh recorded earlier is done, not pending forever.
1776
+ setState(STATES.READY, { version, installedVersion: getInstalledVersion(gaiaBin) });
1777
+ return;
1778
+ }
1779
+ setState(STATES.INSTALLING, { stage: STAGES.AGENT_REFRESH, version });
1780
+ makeProgressReporter(opts.onProgress)(STAGES.AGENT_REFRESH, null, "Updating the GAIA agent");
1781
+
1782
+ const result = await runCommand(gaiaBin, ["init", "--agent-only"], {
1783
+ stageLabel: "agent",
1784
+ env: { ...process.env, COLUMNS: "1000" },
1785
+ });
1786
+ const lastLine = `${result.stderr || ""}`.trim().split(/\r?\n/).pop();
1787
+ if (result.code === AGENT_KEPT_EXIT) {
1788
+ // Offline: the working agent stays, and the next launch tries the update again.
1789
+ log(`Kept the installed GAIA agent; the update runs again next launch: ${lastLine}`);
1790
+ setState(STATES.PARTIAL, { stage: STAGES.AGENT_REFRESH, version, message: lastLine });
1791
+ return;
1792
+ }
1793
+ if (result.code !== 0) {
1794
+ throw new InstallError(
1795
+ lastLine ? lastLine.replace(/^Error:\s*/, "") : `The GAIA agent update exited ${result.code}.`,
1796
+ { stage: STAGES.AGENT_REFRESH, code: result.code }
1797
+ );
1798
+ }
1799
+ setState(STATES.READY, { version, installedVersion: getInstalledVersion(gaiaBin) });
1800
+ }
1801
+
1802
+ // ── Backend prefetch for an app update ──────────────────────────────────────
1803
+
1804
+ const PREFETCH_DIR = path.join(GAIA_HOME, "cache", "backend-prefetch");
1805
+
1806
+ function _prefetchMarker(pep440) {
1807
+ return path.join(PREFETCH_DIR, `${pep440}.ready`);
1808
+ }
1809
+
1810
+ /** True when an earlier prefetch left everything `version` needs in uv's cache. */
1811
+ function isBackendPrefetched(version) {
1812
+ return Boolean(version) && fs.existsSync(_prefetchMarker(toPep440(version)));
1813
+ }
1814
+
1815
+ /**
1816
+ * Before an app update restarts, put the backend it will need into uv's cache
1817
+ * by installing it into a throwaway environment. The first launch then
1818
+ * installs offline, so it neither waits on nor depends on PyPI.
1819
+ * Throws with uv's own words when the download fails.
1820
+ */
1821
+ async function prefetchBackend(appVersion, { isPackaged } = {}) {
1822
+ if (process.env.GAIA_LOCAL_WHEEL) {
1823
+ log("GAIA_LOCAL_WHEEL is set: the next launch installs that wheel, so there is nothing to prefetch");
1824
+ return;
1825
+ }
1826
+ const version = toPep440(appVersion);
1827
+ const pipPackage = `amd-gaia[ui]==${version}`;
1828
+ const workDir = path.join(PREFETCH_DIR, version);
1829
+ const venv = path.join(workDir, "venv");
1830
+ const python = IS_WINDOWS ? path.join(venv, "Scripts", "python.exe") : path.join(venv, "bin", "python");
1831
+ log(`Prefetching ${pipPackage} for the update`);
1832
+ await ensureUv({ isPackaged });
1833
+ _removeScratchVenvs();
1834
+ fs.mkdirSync(workDir, { recursive: true });
1835
+ try {
1836
+ const venvResult = await runCommand("uv", ["venv", venv, "--python", "3.12"], { stageLabel: "prefetch" });
1837
+ if (venvResult.code !== 0) {
1838
+ throw new Error(`uv venv exited ${venvResult.code}: ${_lastLines(venvResult)}`);
1839
+ }
1840
+ const args = ["pip", "install", pipPackage, "--refresh", "--python", python];
1841
+ let result = await runCommand("uv", args, { stageLabel: "prefetch" });
1842
+ if (result.code !== 0 && isTlsCertError(`${result.stdout || ""}\n${result.stderr || ""}`)) {
1843
+ result = await runCommand("uv", [...args, "--native-tls"], { stageLabel: "prefetch" });
1844
+ }
1845
+ if (result.code !== 0) {
1846
+ throw new Error(`Couldn't download ${pipPackage} (uv exit ${result.code}): ${_lastLines(result)}`);
1847
+ }
1848
+ } finally {
1849
+ fs.rmSync(workDir, { recursive: true, force: true });
1850
+ }
1851
+ fs.writeFileSync(_prefetchMarker(version), new Date().toISOString());
1852
+ log(`Prefetched ${pipPackage}: the next launch installs it offline`);
1853
+ }
1854
+
1855
+ /** Scratch venvs a quit cut short; the .ready markers beside them stay. */
1856
+ function _removeScratchVenvs() {
1857
+ let entries = [];
1858
+ try {
1859
+ entries = fs.readdirSync(PREFETCH_DIR, { withFileTypes: true });
1860
+ } catch (err) {
1861
+ if (err.code !== "ENOENT") throw err;
1862
+ }
1863
+ for (const entry of entries) {
1864
+ if (entry.isDirectory()) fs.rmSync(path.join(PREFETCH_DIR, entry.name), { recursive: true, force: true });
1865
+ }
1866
+ }
1867
+
1868
+ function _lastLines({ stdout, stderr }) {
1869
+ return `${stdout || ""}\n${stderr || ""}`
1870
+ .split("\n")
1871
+ .map((line) => line.trimEnd())
1872
+ .filter(Boolean)
1873
+ .slice(-5)
1874
+ .join(" | ");
1875
+ }
1876
+
1981
1877
  // ── Version-aware ensure ─────────────────────────────────────────────────────
1982
1878
 
1879
+ /**
1880
+ * Whether the backend is finished at the app's version. A recorded failure or
1881
+ * unfinished stage says no, so it reaches ensureBackend and the user.
1882
+ */
1883
+ function isBackendCurrent() {
1884
+ // A local wheel is reinstalled on every launch, as ensureBackend says.
1885
+ if (process.env.GAIA_LOCAL_WHEEL) return false;
1886
+ const bin = findGaiaBin();
1887
+ if (!bin) return false;
1888
+ const prev = getState();
1889
+ if (prev && prev.state !== STATES.READY) return false;
1890
+ return getInstalledVersion(bin) === toPep440(appVersion());
1891
+ }
1892
+
1983
1893
  /**
1984
1894
  * The installed backend version, or null when it is not installed.
1985
1895
  * For the app's own venv this reads the package record instead of running
@@ -2049,8 +1959,6 @@ function venvPackageVersion() {
2049
1959
  * opts:
2050
1960
  * - onProgress(stage, percent, message)
2051
1961
  * - version: override the pinned version
2052
- * - skipGaiaInit: bool
2053
- * - chooseLemonade: see installBackend
2054
1962
  * - allowPartialRestart: bool (default true) — restart from scratch if
2055
1963
  * the state file indicates a `partial` install.
2056
1964
  *
@@ -2097,6 +2005,7 @@ async function ensureBackend(opts = {}) {
2097
2005
  }.`,
2098
2006
  {
2099
2007
  stage: STAGES.PRE_CHECKS,
2008
+ reason: FAILURE_REASONS.DISK,
2100
2009
  suggestion: `Free at least ${requiredMb} MB at ${path.dirname(
2101
2010
  GAIA_HOME
2102
2011
  )} and try again.`,
@@ -2132,47 +2041,28 @@ async function ensureBackend(opts = {}) {
2132
2041
  // reinstall from the local wheel so CI gets a fresh install each run.
2133
2042
  const expectedVersion = resolveBackendVersion(opts);
2134
2043
  const existingBin = findGaiaBin();
2135
- // Installed means setup finished too, not just that the package is there.
2136
- // No state file at all is an install from before it existed.
2044
+ // Installed means the package finished installing. No state file at all
2045
+ // is an install from before it existed.
2137
2046
  const prev = preChecks.previousState;
2138
- const setupFinished =
2139
- !prev || (prev.state === STATES.READY && prev.init !== "failed");
2140
2047
  const packageFinished =
2141
- setupFinished ||
2142
- (prev &&
2143
- (prev.state === STATES.READY ||
2144
- prev.stage === STAGES.GAIA_INIT ||
2145
- prev.stage === STAGES.VERIFY));
2048
+ !prev ||
2049
+ prev.state === STATES.READY ||
2050
+ prev.stage === LEGACY_SETUP_STAGE ||
2051
+ prev.stage === STAGES.VERIFY ||
2052
+ prev.stage === STAGES.AGENT_REFRESH;
2146
2053
  if (existingBin) {
2147
2054
  const installedVersion = getInstalledVersion(existingBin);
2148
2055
  const versionMatches =
2149
2056
  expectedVersion !== null && installedVersion === toPep440(expectedVersion);
2150
- if (versionMatches && !setupFinished && packageFinished) {
2151
- log(
2152
- `GAIA ${installedVersion} is installed but its setup did not finish ` +
2153
- `(state=${prev.state}, stage=${prev.stage || "-"}) — running gaia init`
2154
- );
2155
- const report = makeProgressReporter(opts.onProgress);
2156
- const setup = await runGaiaInit(
2157
- existingBin,
2158
- report,
2159
- expectedVersion,
2160
- shouldSkipGaiaInit(opts),
2161
- opts.chooseLemonade
2162
- );
2163
- setState(STATES.READY, { version: expectedVersion, installedVersion, ...setup });
2164
- report(STAGES.VERIFY, 100, `GAIA ${installedVersion} ready`);
2057
+ if (versionMatches && packageFinished && prev && prev.stage === STAGES.AGENT_REFRESH) {
2058
+ await refreshAgent(existingBin, opts);
2165
2059
  return existingBin;
2166
2060
  }
2167
- if (versionMatches && setupFinished) {
2061
+ if (versionMatches && packageFinished) {
2168
2062
  log(
2169
2063
  `GAIA backend already installed at version ${installedVersion} — nothing to do`
2170
2064
  );
2171
- setState(STATES.READY, {
2172
- version: expectedVersion,
2173
- installedVersion,
2174
- init: prev ? prev.init : undefined,
2175
- });
2065
+ setState(STATES.READY, { version: expectedVersion, installedVersion });
2176
2066
  // Tell the UI we are instantly ready.
2177
2067
  const report = makeProgressReporter(opts.onProgress);
2178
2068
  report(STAGES.VERIFY, 100, `GAIA ${installedVersion} ready`);
@@ -2205,6 +2095,7 @@ async function ensureBackend(opts = {}) {
2205
2095
  throw err;
2206
2096
  }
2207
2097
 
2098
+ await refreshAgent(verified, opts);
2208
2099
  return verified;
2209
2100
  } catch (err) {
2210
2101
  if (err instanceof InstallError) {
@@ -2232,11 +2123,12 @@ module.exports = {
2232
2123
  ensureUv,
2233
2124
  installBackend,
2234
2125
  ensureBackend,
2126
+ isBackendCurrent,
2127
+ prefetchBackend,
2128
+ isBackendPrefetched,
2235
2129
  getInstalledVersion,
2236
2130
  findGaiaBin,
2237
2131
  toPep440,
2238
- lemonadeInitArgs,
2239
- initFailureReason,
2240
2132
 
2241
2133
  // Pre-checks
2242
2134
  runPreChecks,
@@ -2255,9 +2147,6 @@ module.exports = {
2255
2147
  setState,
2256
2148
  clearState,
2257
2149
 
2258
- // gaia init progress
2259
- parseInitEventLine,
2260
- createInitProgress,
2261
2150
  computeOverallPercent,
2262
2151
 
2263
2152
  // Logging
@@ -2267,12 +2156,12 @@ module.exports = {
2267
2156
  logError,
2268
2157
  getLogPath,
2269
2158
  getStatePath,
2159
+ readLogTail,
2270
2160
 
2271
2161
  // Constants
2272
2162
  STATES,
2273
2163
  STAGES,
2274
- GAIA_INIT_MESSAGE,
2275
- WAITING_FOR_LEMONADE_CHOICE_MESSAGE,
2164
+ FAILURE_REASONS,
2276
2165
  GAIA_HOME,
2277
2166
  GAIA_VENV,
2278
2167
  GAIA_BIN,