@openagentsinc/pylon 0.1.0 → 0.1.2

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 (4) hide show
  1. package/README.md +20 -4
  2. package/package.json +1 -1
  3. package/src/cli.js +108 -8
  4. package/src/index.js +1050 -82
package/src/index.js CHANGED
@@ -4,18 +4,42 @@ import { createReadStream } from "node:fs";
4
4
  import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import readline from "node:readline/promises";
7
8
 
8
9
  export const DEFAULT_RELEASE_REPO = "OpenAgentsInc/openagents";
9
10
  export const DEFAULT_RELEASE_API_BASE = "https://api.github.com";
11
+ export const DEFAULT_RELEASE_GIT_BASE = "https://github.com";
12
+ export const DEFAULT_RUSTUP_INIT_URL = "https://sh.rustup.rs";
10
13
  export const DEFAULT_MODEL_ID = "gemma-4-e4b";
11
14
  export const DEFAULT_DIAGNOSTIC_REPEATS = 3;
12
15
  export const DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS = 96;
16
+ export const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
13
17
  const PYLON_RELEASE_TAG_PREFIX = "pylon-v";
18
+ const RELEASE_ASSET_INSTALL_METHOD = "release_asset";
19
+ const SOURCE_BUILD_INSTALL_METHOD = "source_build";
20
+
21
+ function emitStatus(onStatus, message, detail = null) {
22
+ if (typeof onStatus === "function") {
23
+ onStatus({ message, detail });
24
+ }
25
+ }
26
+
27
+ function emitVerboseStatus(onStatus, verbose, message, detail = null) {
28
+ if (verbose) {
29
+ emitStatus(onStatus, message, detail);
30
+ }
31
+ }
14
32
 
15
33
  function normalizeVersion(value) {
16
34
  return value.replace(/^pylon-v/, "").replace(/^v/, "");
17
35
  }
18
36
 
37
+ function createBootstrapError(message, context = {}) {
38
+ const error = new Error(message);
39
+ Object.assign(error, context);
40
+ return error;
41
+ }
42
+
19
43
  async function pathExists(value) {
20
44
  try {
21
45
  await fs.access(value);
@@ -29,6 +53,28 @@ function defaultInstallRoot() {
29
53
  return path.join(os.homedir(), ".openagents", "pylon", "bootstrap");
30
54
  }
31
55
 
56
+ class MissingReleaseAssetsError extends Error {
57
+ constructor({
58
+ tagName,
59
+ version,
60
+ target,
61
+ archiveBasename,
62
+ archiveName,
63
+ checksumName,
64
+ targetCommitish,
65
+ }) {
66
+ super(`Release ${tagName} is missing ${archiveName} or ${checksumName}.`);
67
+ this.name = "MissingReleaseAssetsError";
68
+ this.tagName = tagName;
69
+ this.version = version;
70
+ this.target = target;
71
+ this.archiveBasename = archiveBasename;
72
+ this.archiveName = archiveName;
73
+ this.checksumName = checksumName;
74
+ this.targetCommitish = targetCommitish ?? null;
75
+ }
76
+ }
77
+
32
78
  function requestHeaders() {
33
79
  const headers = {
34
80
  accept: "application/vnd.github+json",
@@ -40,6 +86,166 @@ function requestHeaders() {
40
86
  return headers;
41
87
  }
42
88
 
89
+ function formatRequestHeaders(headers = requestHeaders()) {
90
+ return Object.entries(headers).flatMap(([key, value]) => [
91
+ "--header",
92
+ `${key}: ${value}`,
93
+ ]);
94
+ }
95
+
96
+ function timedSignal(timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
97
+ if (typeof AbortSignal?.timeout === "function") {
98
+ return {
99
+ signal: AbortSignal.timeout(timeoutMs),
100
+ dispose() {},
101
+ };
102
+ }
103
+
104
+ const controller = new AbortController();
105
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
106
+ return {
107
+ signal: controller.signal,
108
+ dispose() {
109
+ clearTimeout(timer);
110
+ },
111
+ };
112
+ }
113
+
114
+ function classifyNetworkError(error) {
115
+ const cause = error?.cause ?? null;
116
+ const code = cause?.code ?? cause?.errno ?? null;
117
+ const detail = [error?.message, cause?.message].filter(Boolean).join(" :: ");
118
+ const lowered = detail.toLowerCase();
119
+ let classification = "network";
120
+
121
+ if (
122
+ error?.name === "AbortError" ||
123
+ code === "ETIMEDOUT" ||
124
+ lowered.includes("timeout")
125
+ ) {
126
+ classification = "timeout";
127
+ } else if (
128
+ code === "ENOTFOUND" ||
129
+ code === "EAI_AGAIN" ||
130
+ lowered.includes("getaddrinfo")
131
+ ) {
132
+ classification = "dns";
133
+ } else if (
134
+ code === "CERT_HAS_EXPIRED" ||
135
+ code === "DEPTH_ZERO_SELF_SIGNED_CERT" ||
136
+ lowered.includes("certificate") ||
137
+ lowered.includes("tls") ||
138
+ lowered.includes("ssl")
139
+ ) {
140
+ classification = "tls";
141
+ } else if (
142
+ code === "ECONNREFUSED" ||
143
+ code === "ECONNRESET" ||
144
+ code === "EHOSTUNREACH" ||
145
+ code === "ENETUNREACH" ||
146
+ code === "EADDRNOTAVAIL" ||
147
+ lowered.includes("connect")
148
+ ) {
149
+ classification = "connect";
150
+ }
151
+
152
+ const endpoint =
153
+ cause?.address || cause?.hostname || cause?.port
154
+ ? [
155
+ cause?.hostname ?? cause?.address ?? null,
156
+ cause?.port != null ? String(cause.port) : null,
157
+ ]
158
+ .filter(Boolean)
159
+ .join(":")
160
+ : null;
161
+
162
+ return {
163
+ classification,
164
+ code,
165
+ endpoint,
166
+ detail: detail || "network request failed",
167
+ };
168
+ }
169
+
170
+ function isRetryableFetchError(error) {
171
+ if (!(error instanceof Error)) {
172
+ return false;
173
+ }
174
+ return classifyNetworkError(error).classification !== "tls";
175
+ }
176
+
177
+ function renderNetworkFailure({ stage, url, error, curlError = null }) {
178
+ const details = classifyNetworkError(error);
179
+ const parts = [
180
+ `${stage} failed for ${url}.`,
181
+ `classification=${details.classification}`,
182
+ ];
183
+ if (details.code) {
184
+ parts.push(`code=${details.code}`);
185
+ }
186
+ if (details.endpoint) {
187
+ parts.push(`endpoint=${details.endpoint}`);
188
+ }
189
+ parts.push(`detail=${details.detail}`);
190
+ if (curlError) {
191
+ parts.push(
192
+ `curl_fallback=${curlError instanceof Error ? curlError.message : String(curlError)}`,
193
+ );
194
+ }
195
+ return parts.join(" ");
196
+ }
197
+
198
+ async function fetchResponse(fetchImpl, url, headers = requestHeaders()) {
199
+ const timeout = timedSignal();
200
+ try {
201
+ return await fetchImpl(url, {
202
+ headers,
203
+ signal: timeout.signal,
204
+ });
205
+ } finally {
206
+ timeout.dispose();
207
+ }
208
+ }
209
+
210
+ async function runCurlText(runProcessImpl, url, headers = requestHeaders()) {
211
+ const { stdout } = await runProcessImpl("curl", [
212
+ "--fail",
213
+ "--silent",
214
+ "--show-error",
215
+ "--location",
216
+ "--connect-timeout",
217
+ "15",
218
+ "--max-time",
219
+ "60",
220
+ ...formatRequestHeaders(headers),
221
+ url,
222
+ ]);
223
+ return stdout;
224
+ }
225
+
226
+ async function runCurlDownload(
227
+ runProcessImpl,
228
+ url,
229
+ destination,
230
+ headers = requestHeaders(),
231
+ ) {
232
+ await fs.mkdir(path.dirname(destination), { recursive: true });
233
+ await runProcessImpl("curl", [
234
+ "--fail",
235
+ "--silent",
236
+ "--show-error",
237
+ "--location",
238
+ "--connect-timeout",
239
+ "15",
240
+ "--max-time",
241
+ "300",
242
+ "--output",
243
+ destination,
244
+ ...formatRequestHeaders(headers),
245
+ url,
246
+ ]);
247
+ }
248
+
43
249
  export function resolvePlatformTarget(
44
250
  platform = process.platform,
45
251
  arch = process.arch,
@@ -106,42 +312,164 @@ export function parseSha256File(payload, expectedAssetName) {
106
312
  return sha256.toLowerCase();
107
313
  }
108
314
 
109
- async function fetchJson(fetchImpl, url) {
110
- const response = await fetchImpl(url, {
111
- headers: requestHeaders(),
112
- });
113
- if (!response.ok) {
114
- throw new Error(
115
- `GitHub release lookup failed for ${url} (${response.status} ${response.statusText}).`,
116
- );
315
+ async function fetchJson(
316
+ fetchImpl,
317
+ url,
318
+ {
319
+ headers = requestHeaders(),
320
+ runProcessImpl = runProcess,
321
+ onStatus = null,
322
+ stage = "GitHub release lookup",
323
+ verbose = false,
324
+ } = {},
325
+ ) {
326
+ try {
327
+ emitVerboseStatus(onStatus, verbose, stage, url);
328
+ const response = await fetchResponse(fetchImpl, url, headers);
329
+ if (!response.ok) {
330
+ throw createBootstrapError(
331
+ `GitHub release lookup failed for ${url} (${response.status} ${response.statusText}).`,
332
+ {
333
+ stage,
334
+ url,
335
+ httpStatus: response.status,
336
+ },
337
+ );
338
+ }
339
+ return response.json();
340
+ } catch (error) {
341
+ if (
342
+ !error?.httpStatus &&
343
+ isRetryableFetchError(error) &&
344
+ typeof runProcessImpl === "function"
345
+ ) {
346
+ emitStatus(onStatus, "Retrying with curl transport", `${stage} ${url}`);
347
+ try {
348
+ return JSON.parse(await runCurlText(runProcessImpl, url, headers));
349
+ } catch (curlError) {
350
+ throw createBootstrapError(
351
+ renderNetworkFailure({ stage, url, error, curlError }),
352
+ { stage, url, cause: error },
353
+ );
354
+ }
355
+ }
356
+ if (error?.httpStatus) {
357
+ throw error;
358
+ }
359
+ throw createBootstrapError(renderNetworkFailure({ stage, url, error }), {
360
+ stage,
361
+ url,
362
+ cause: error,
363
+ });
117
364
  }
118
- return response.json();
119
365
  }
120
366
 
121
- async function fetchText(fetchImpl, url) {
122
- const response = await fetchImpl(url, {
123
- headers: requestHeaders(),
124
- });
125
- if (!response.ok) {
126
- throw new Error(
127
- `Download failed for ${url} (${response.status} ${response.statusText}).`,
128
- );
367
+ async function fetchText(
368
+ fetchImpl,
369
+ url,
370
+ {
371
+ headers = requestHeaders(),
372
+ runProcessImpl = runProcess,
373
+ onStatus = null,
374
+ stage = "download text",
375
+ verbose = false,
376
+ } = {},
377
+ ) {
378
+ try {
379
+ emitVerboseStatus(onStatus, verbose, stage, url);
380
+ const response = await fetchResponse(fetchImpl, url, headers);
381
+ if (!response.ok) {
382
+ throw createBootstrapError(
383
+ `Download failed for ${url} (${response.status} ${response.statusText}).`,
384
+ {
385
+ stage,
386
+ url,
387
+ httpStatus: response.status,
388
+ },
389
+ );
390
+ }
391
+ return response.text();
392
+ } catch (error) {
393
+ if (
394
+ !error?.httpStatus &&
395
+ isRetryableFetchError(error) &&
396
+ typeof runProcessImpl === "function"
397
+ ) {
398
+ emitStatus(onStatus, "Retrying with curl transport", `${stage} ${url}`);
399
+ try {
400
+ return await runCurlText(runProcessImpl, url, headers);
401
+ } catch (curlError) {
402
+ throw createBootstrapError(
403
+ renderNetworkFailure({ stage, url, error, curlError }),
404
+ { stage, url, cause: error },
405
+ );
406
+ }
407
+ }
408
+ if (error?.httpStatus) {
409
+ throw error;
410
+ }
411
+ throw createBootstrapError(renderNetworkFailure({ stage, url, error }), {
412
+ stage,
413
+ url,
414
+ cause: error,
415
+ });
129
416
  }
130
- return response.text();
131
417
  }
132
418
 
133
- async function downloadFile(fetchImpl, url, destination) {
134
- const response = await fetchImpl(url, {
135
- headers: requestHeaders(),
136
- });
137
- if (!response.ok) {
138
- throw new Error(
139
- `Download failed for ${url} (${response.status} ${response.statusText}).`,
140
- );
419
+ async function downloadFile(
420
+ fetchImpl,
421
+ url,
422
+ destination,
423
+ {
424
+ headers = requestHeaders(),
425
+ runProcessImpl = runProcess,
426
+ onStatus = null,
427
+ stage = "download file",
428
+ verbose = false,
429
+ } = {},
430
+ ) {
431
+ try {
432
+ emitVerboseStatus(onStatus, verbose, stage, url);
433
+ const response = await fetchResponse(fetchImpl, url, headers);
434
+ if (!response.ok) {
435
+ throw createBootstrapError(
436
+ `Download failed for ${url} (${response.status} ${response.statusText}).`,
437
+ {
438
+ stage,
439
+ url,
440
+ httpStatus: response.status,
441
+ },
442
+ );
443
+ }
444
+ const payload = Buffer.from(await response.arrayBuffer());
445
+ await fs.mkdir(path.dirname(destination), { recursive: true });
446
+ await fs.writeFile(destination, payload);
447
+ } catch (error) {
448
+ if (
449
+ !error?.httpStatus &&
450
+ isRetryableFetchError(error) &&
451
+ typeof runProcessImpl === "function"
452
+ ) {
453
+ emitStatus(onStatus, "Retrying with curl transport", `${stage} ${url}`);
454
+ try {
455
+ await runCurlDownload(runProcessImpl, url, destination, headers);
456
+ return;
457
+ } catch (curlError) {
458
+ throw createBootstrapError(
459
+ renderNetworkFailure({ stage, url, error, curlError }),
460
+ { stage, url, cause: error },
461
+ );
462
+ }
463
+ }
464
+ if (error?.httpStatus) {
465
+ throw error;
466
+ }
467
+ throw createBootstrapError(renderNetworkFailure({ stage, url, error }), {
468
+ stage,
469
+ url,
470
+ cause: error,
471
+ });
141
472
  }
142
- const payload = Buffer.from(await response.arrayBuffer());
143
- await fs.mkdir(path.dirname(destination), { recursive: true });
144
- await fs.writeFile(destination, payload);
145
473
  }
146
474
 
147
475
  async function sha256File(filePath) {
@@ -187,6 +515,9 @@ export function selectLatestPylonRelease(releases) {
187
515
 
188
516
  export async function fetchReleaseMetadata({
189
517
  fetchImpl = globalThis.fetch,
518
+ runProcessImpl = runProcess,
519
+ onStatus = null,
520
+ verbose = false,
190
521
  apiBase = DEFAULT_RELEASE_API_BASE,
191
522
  repo = DEFAULT_RELEASE_REPO,
192
523
  version = null,
@@ -198,7 +529,14 @@ export async function fetchReleaseMetadata({
198
529
  )}`
199
530
  : `/repos/${repo}/releases?per_page=100`;
200
531
  const url = `${apiBase.replace(/\/$/, "")}${endpoint}`;
201
- const payload = await fetchJson(fetchImpl, url);
532
+ const payload = await fetchJson(fetchImpl, url, {
533
+ runProcessImpl,
534
+ onStatus,
535
+ verbose,
536
+ stage: normalizedVersion
537
+ ? "GitHub tagged release lookup"
538
+ : "GitHub release list lookup",
539
+ });
202
540
  return normalizedVersion ? payload : selectLatestPylonRelease(payload);
203
541
  }
204
542
 
@@ -217,9 +555,15 @@ export function selectReleaseAssets(release, target) {
217
555
  const checksumAsset = assets.find((asset) => asset.name === checksumName);
218
556
 
219
557
  if (!archiveAsset || !checksumAsset) {
220
- throw new Error(
221
- `Release ${tagName} is missing ${archiveName} or ${checksumName}.`,
222
- );
558
+ throw new MissingReleaseAssetsError({
559
+ tagName,
560
+ version,
561
+ target,
562
+ archiveBasename,
563
+ archiveName,
564
+ checksumName,
565
+ targetCommitish: release?.target_commitish ?? null,
566
+ });
223
567
  }
224
568
 
225
569
  return {
@@ -261,25 +605,447 @@ export function buildInstallPaths(installRoot, version, target) {
261
605
  };
262
606
  }
263
607
 
608
+ async function readInstallManifest(manifestPath) {
609
+ try {
610
+ const payload = await fs.readFile(manifestPath, "utf8");
611
+ return JSON.parse(payload);
612
+ } catch {
613
+ return null;
614
+ }
615
+ }
616
+
617
+ async function writeInstallManifest(manifestPath, payload) {
618
+ await fs.mkdir(path.dirname(manifestPath), { recursive: true });
619
+ await fs.writeFile(manifestPath, `${JSON.stringify(payload, null, 2)}\n`);
620
+ }
621
+
622
+ export function deriveReleaseGitBase(apiBase = DEFAULT_RELEASE_API_BASE) {
623
+ const normalized = (apiBase ?? DEFAULT_RELEASE_API_BASE).replace(/\/$/, "");
624
+ if (normalized === DEFAULT_RELEASE_API_BASE) {
625
+ return DEFAULT_RELEASE_GIT_BASE;
626
+ }
627
+ return normalized.replace(/\/api(?:\/v3)?$/i, "");
628
+ }
629
+
630
+ export function buildReleaseCloneUrl(
631
+ repo,
632
+ {
633
+ apiBase = DEFAULT_RELEASE_API_BASE,
634
+ gitBase = null,
635
+ cloneUrl = null,
636
+ } = {},
637
+ ) {
638
+ if (cloneUrl) {
639
+ return cloneUrl;
640
+ }
641
+ return `${(gitBase ?? deriveReleaseGitBase(apiBase)).replace(/\/$/, "")}/${repo}.git`;
642
+ }
643
+
644
+ function withPrependedPath(env, entry) {
645
+ const normalizedEntry = path.resolve(entry);
646
+ const parts = (env.PATH ?? process.env.PATH ?? "")
647
+ .split(path.delimiter)
648
+ .filter(Boolean);
649
+ if (!parts.includes(normalizedEntry)) {
650
+ parts.unshift(normalizedEntry);
651
+ }
652
+ return {
653
+ ...env,
654
+ PATH: parts.join(path.delimiter),
655
+ };
656
+ }
657
+
658
+ async function commandExists(command, env = process.env) {
659
+ const pathValue = env.PATH ?? process.env.PATH ?? "";
660
+ const directories = pathValue.split(path.delimiter).filter(Boolean);
661
+ const suffixes =
662
+ process.platform === "win32"
663
+ ? (env.PATHEXT ?? process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
664
+ .split(";")
665
+ .filter(Boolean)
666
+ : [""];
667
+
668
+ for (const directory of directories) {
669
+ for (const suffix of suffixes) {
670
+ const candidate = path.join(directory, `${command}${suffix}`);
671
+ if (await pathExists(candidate)) {
672
+ return true;
673
+ }
674
+ }
675
+ }
676
+
677
+ return false;
678
+ }
679
+
680
+ async function promptForApproval(message) {
681
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
682
+ throw new Error(
683
+ `${message}\nInteractive approval is required, but this terminal is not interactive.`,
684
+ );
685
+ }
686
+ const terminal = readline.createInterface({
687
+ input: process.stdin,
688
+ output: process.stdout,
689
+ });
690
+ try {
691
+ const answer = await terminal.question(`${message} [y/N] `);
692
+ return /^y(?:es)?$/i.test(answer.trim());
693
+ } finally {
694
+ terminal.close();
695
+ }
696
+ }
697
+
698
+ function manualSourceBuildCommands(tagName, cloneUrl) {
699
+ return [
700
+ `git clone --depth 1 --branch ${tagName} ${cloneUrl}`,
701
+ "cd openagents",
702
+ "cargo build --release -p pylon -p pylon-tui",
703
+ ].join("\n");
704
+ }
705
+
706
+ function rustInstallCommand() {
707
+ return "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y";
708
+ }
709
+
710
+ async function ensureRustToolchain({
711
+ target,
712
+ fetchImpl,
713
+ runProcessImpl,
714
+ onStatus,
715
+ promptImpl = promptForApproval,
716
+ commandExistsImpl = commandExists,
717
+ env = process.env,
718
+ rustupInitUrl = DEFAULT_RUSTUP_INIT_URL,
719
+ }) {
720
+ let toolchainEnv = withPrependedPath(env, path.join(os.homedir(), ".cargo", "bin"));
721
+ const hasCargo = await commandExistsImpl("cargo", toolchainEnv);
722
+ const hasRustc = await commandExistsImpl("rustc", toolchainEnv);
723
+ if (hasCargo && hasRustc) {
724
+ return toolchainEnv;
725
+ }
726
+
727
+ emitStatus(
728
+ onStatus,
729
+ "Rust toolchain required for source build",
730
+ `${target.os}-${target.arch}`,
731
+ );
732
+
733
+ const approved = await promptImpl(
734
+ `Rust is required to build Pylon from source for ${target.os}-${target.arch}. Install the official Rust toolchain now via rustup?`,
735
+ );
736
+ if (!approved) {
737
+ throw new Error(
738
+ `Rust is required to build Pylon from source.\nInstall it manually and rerun:\n${rustInstallCommand()}`,
739
+ );
740
+ }
741
+
742
+ emitStatus(onStatus, "Installing Rust toolchain", "official rustup installer");
743
+ const scriptPayload = await fetchText(fetchImpl, rustupInitUrl, {
744
+ headers: {
745
+ accept: "text/plain",
746
+ "user-agent": "@openagentsinc/pylon bootstrap",
747
+ },
748
+ runProcessImpl,
749
+ onStatus,
750
+ stage: "Rust toolchain installer download",
751
+ });
752
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-rustup-"));
753
+ const scriptPath = path.join(tempDir, "rustup-init.sh");
754
+
755
+ try {
756
+ await fs.writeFile(scriptPath, scriptPayload);
757
+ await fs.chmod(scriptPath, 0o755);
758
+ await runProcessImpl("sh", [scriptPath, "-y"], {
759
+ cwd: tempDir,
760
+ env: toolchainEnv,
761
+ stdio: "inherit",
762
+ });
763
+ } finally {
764
+ await fs.rm(tempDir, { recursive: true, force: true });
765
+ }
766
+
767
+ toolchainEnv = withPrependedPath(env, path.join(os.homedir(), ".cargo", "bin"));
768
+ const cargoInstalled = await commandExistsImpl("cargo", toolchainEnv);
769
+ const rustcInstalled = await commandExistsImpl("rustc", toolchainEnv);
770
+ if (!cargoInstalled || !rustcInstalled) {
771
+ throw new Error(
772
+ `Rust install completed, but \`cargo\` and \`rustc\` were not found on PATH.\nInstall them manually and rerun:\n${rustInstallCommand()}`,
773
+ );
774
+ }
775
+
776
+ emitStatus(
777
+ onStatus,
778
+ "Rust toolchain installed",
779
+ path.join(os.homedir(), ".cargo", "bin"),
780
+ );
781
+ return toolchainEnv;
782
+ }
783
+
784
+ async function installSourceBuild(
785
+ {
786
+ selected,
787
+ options,
788
+ paths,
789
+ target,
790
+ },
791
+ {
792
+ fetchImpl,
793
+ runProcessImpl,
794
+ onStatus,
795
+ promptImpl = promptForApproval,
796
+ commandExistsImpl = commandExists,
797
+ },
798
+ ) {
799
+ const cloneUrl = buildReleaseCloneUrl(options.repo ?? DEFAULT_RELEASE_REPO, {
800
+ apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
801
+ cloneUrl: options.sourceRepoUrl ?? null,
802
+ gitBase: options.gitBase ?? null,
803
+ });
804
+ const manualBuildInstructions = manualSourceBuildCommands(
805
+ selected.tagName,
806
+ cloneUrl,
807
+ );
808
+
809
+ emitStatus(
810
+ onStatus,
811
+ "Prebuilt asset missing; falling back to source build",
812
+ `${selected.tagName} for ${target.os}-${target.arch}`,
813
+ );
814
+
815
+ if (!(await commandExistsImpl("git", process.env))) {
816
+ throw new Error(
817
+ `Source build fallback requires \`git\`.\nInstall it and rerun \`npx @openagentsinc/pylon\`, or build manually:\n${manualBuildInstructions}`,
818
+ );
819
+ }
820
+
821
+ const buildEnv = await ensureRustToolchain({
822
+ target,
823
+ fetchImpl,
824
+ runProcessImpl,
825
+ onStatus,
826
+ promptImpl,
827
+ commandExistsImpl,
828
+ });
829
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-source-build-"));
830
+ const repoDir = path.join(tempDir, "openagents");
831
+ const buildCommand = [
832
+ "cargo",
833
+ "build",
834
+ "--release",
835
+ "-p",
836
+ "pylon",
837
+ "-p",
838
+ "pylon-tui",
839
+ ];
840
+
841
+ try {
842
+ await fs.mkdir(repoDir, { recursive: true });
843
+ emitStatus(onStatus, "Fetching source checkout", selected.tagName);
844
+ await runProcessImpl("git", ["init"], {
845
+ cwd: repoDir,
846
+ env: buildEnv,
847
+ });
848
+ await runProcessImpl("git", ["remote", "add", "origin", cloneUrl], {
849
+ cwd: repoDir,
850
+ env: buildEnv,
851
+ });
852
+ await runProcessImpl(
853
+ "git",
854
+ [
855
+ "fetch",
856
+ "--depth",
857
+ "1",
858
+ "origin",
859
+ `refs/tags/${selected.tagName}:refs/tags/${selected.tagName}`,
860
+ ],
861
+ {
862
+ cwd: repoDir,
863
+ env: buildEnv,
864
+ },
865
+ );
866
+ await runProcessImpl(
867
+ "git",
868
+ ["checkout", "--detach", `refs/tags/${selected.tagName}`],
869
+ {
870
+ cwd: repoDir,
871
+ env: buildEnv,
872
+ },
873
+ );
874
+
875
+ const { stdout: commitStdout } = await runProcessImpl(
876
+ "git",
877
+ ["rev-parse", "HEAD"],
878
+ {
879
+ cwd: repoDir,
880
+ env: buildEnv,
881
+ },
882
+ );
883
+ const sourceCommit = commitStdout.trim();
884
+ if (
885
+ selected.targetCommitish &&
886
+ /^[a-f0-9]{40}$/i.test(selected.targetCommitish) &&
887
+ sourceCommit !== selected.targetCommitish
888
+ ) {
889
+ throw new Error(
890
+ `Resolved release tag ${selected.tagName} checked out ${sourceCommit}, expected ${selected.targetCommitish}.`,
891
+ );
892
+ }
893
+
894
+ emitStatus(
895
+ onStatus,
896
+ "Building Pylon from source",
897
+ `${selected.tagName} (${sourceCommit.slice(0, 12)})`,
898
+ );
899
+ await runProcessImpl(buildCommand[0], buildCommand.slice(1), {
900
+ cwd: repoDir,
901
+ env: buildEnv,
902
+ stdio: "inherit",
903
+ });
904
+
905
+ const builtPylonPath = path.join(repoDir, "target", "release", "pylon");
906
+ const builtPylonTuiPath = path.join(repoDir, "target", "release", "pylon-tui");
907
+ if (!(await pathExists(builtPylonPath)) || !(await pathExists(builtPylonTuiPath))) {
908
+ throw new Error(
909
+ `Source build completed without the expected binaries at ${path.join(repoDir, "target", "release")}.`,
910
+ );
911
+ }
912
+
913
+ await fs.rm(paths.installDir, { recursive: true, force: true });
914
+ await fs.mkdir(paths.installDir, { recursive: true });
915
+ await Promise.all([
916
+ fs.copyFile(builtPylonPath, paths.pylonPath),
917
+ fs.copyFile(builtPylonTuiPath, paths.pylonTuiPath),
918
+ ]);
919
+ await Promise.allSettled([
920
+ fs.chmod(paths.pylonPath, 0o755),
921
+ fs.chmod(paths.pylonTuiPath, 0o755),
922
+ ]);
923
+
924
+ await writeInstallManifest(paths.manifestPath, {
925
+ version: selected.version,
926
+ tagName: selected.tagName,
927
+ target,
928
+ installMethod: SOURCE_BUILD_INSTALL_METHOD,
929
+ sourceCloneUrl: cloneUrl,
930
+ sourceCommit,
931
+ sourceTargetCommitish: selected.targetCommitish ?? null,
932
+ buildCommand: buildCommand.join(" "),
933
+ });
934
+
935
+ emitStatus(
936
+ onStatus,
937
+ "Installed source-built binaries",
938
+ `${selected.tagName} for ${target.os}-${target.arch}`,
939
+ );
940
+
941
+ return {
942
+ ...selected,
943
+ ...paths,
944
+ target,
945
+ cached: false,
946
+ expectedSha256: null,
947
+ installMethod: SOURCE_BUILD_INSTALL_METHOD,
948
+ sourceCloneUrl: cloneUrl,
949
+ sourceCommit,
950
+ };
951
+ } catch (error) {
952
+ const message = error instanceof Error ? error.message : String(error);
953
+ throw new Error(
954
+ `${message}\nManual source-build fallback:\n${manualBuildInstructions}`,
955
+ );
956
+ } finally {
957
+ await fs.rm(tempDir, { recursive: true, force: true });
958
+ }
959
+ }
960
+
961
+ async function findLatestCachedInstall(installRoot, target) {
962
+ const normalizedRoot = path.resolve(installRoot ?? defaultInstallRoot());
963
+ const versionsDir = path.join(normalizedRoot, "versions");
964
+ let entries;
965
+ try {
966
+ entries = await fs.readdir(versionsDir, { withFileTypes: true });
967
+ } catch {
968
+ return null;
969
+ }
970
+
971
+ const candidates = [];
972
+ for (const entry of entries) {
973
+ if (!entry.isDirectory()) {
974
+ continue;
975
+ }
976
+ if (!entry.name.endsWith(`-${target.os}-${target.arch}`)) {
977
+ continue;
978
+ }
979
+
980
+ const installDir = path.join(versionsDir, entry.name);
981
+ const manifestPath = path.join(installDir, "install.json");
982
+ const pylonPath = path.join(installDir, "pylon");
983
+ const pylonTuiPath = path.join(installDir, "pylon-tui");
984
+ if (
985
+ !(await pathExists(manifestPath)) ||
986
+ !(await pathExists(pylonPath)) ||
987
+ !(await pathExists(pylonTuiPath))
988
+ ) {
989
+ continue;
990
+ }
991
+
992
+ try {
993
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
994
+ const manifestStat = await fs.stat(manifestPath);
995
+ candidates.push({
996
+ version: manifest.version,
997
+ tagName: manifest.tagName,
998
+ target,
999
+ installRoot: normalizedRoot,
1000
+ versionsDir,
1001
+ downloadsDir: path.join(
1002
+ normalizedRoot,
1003
+ "downloads",
1004
+ `pylon-v${normalizeVersion(manifest.version)}`,
1005
+ ),
1006
+ installDir,
1007
+ archiveBasename: entry.name,
1008
+ archivePath: null,
1009
+ checksumPath: null,
1010
+ manifestPath,
1011
+ pylonPath,
1012
+ pylonTuiPath,
1013
+ expectedSha256: manifest.sha256 ?? null,
1014
+ cached: true,
1015
+ mtimeMs: manifestStat.mtimeMs,
1016
+ });
1017
+ } catch {
1018
+ // Ignore malformed cache entries and keep scanning.
1019
+ }
1020
+ }
1021
+
1022
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
1023
+ return candidates[0] ?? null;
1024
+ }
1025
+
264
1026
  export async function runProcess(
265
1027
  command,
266
1028
  args,
267
- { cwd, env } = {},
1029
+ { cwd, env, stdio = ["ignore", "pipe", "pipe"] } = {},
268
1030
  ) {
269
1031
  return new Promise((resolve, reject) => {
270
1032
  const child = spawn(command, args, {
271
1033
  cwd,
272
1034
  env,
273
- stdio: ["ignore", "pipe", "pipe"],
1035
+ stdio,
274
1036
  });
275
1037
  let stdout = "";
276
1038
  let stderr = "";
277
- child.stdout.on("data", (chunk) => {
278
- stdout += chunk.toString();
279
- });
280
- child.stderr.on("data", (chunk) => {
281
- stderr += chunk.toString();
282
- });
1039
+ if (child.stdout) {
1040
+ child.stdout.on("data", (chunk) => {
1041
+ stdout += chunk.toString();
1042
+ });
1043
+ }
1044
+ if (child.stderr) {
1045
+ child.stderr.on("data", (chunk) => {
1046
+ stderr += chunk.toString();
1047
+ });
1048
+ }
283
1049
  child.on("error", (error) => {
284
1050
  reject(
285
1051
  new Error(
@@ -343,44 +1109,172 @@ async function runPylonJson(pylonPath, args, options, runProcessImpl) {
343
1109
  }
344
1110
  }
345
1111
 
1112
+ function isUnsupportedGemmaDiagnoseError(error) {
1113
+ const message = error instanceof Error ? error.message : String(error);
1114
+ return (
1115
+ message.includes("unknown gemma command: diagnose") ||
1116
+ message.includes("unknown command: diagnose")
1117
+ );
1118
+ }
1119
+
346
1120
  export async function ensureReleaseInstall(
347
1121
  options = {},
348
1122
  {
349
1123
  fetchImpl = globalThis.fetch,
350
1124
  runProcessImpl = runProcess,
1125
+ onStatus = null,
1126
+ promptImpl = promptForApproval,
1127
+ commandExistsImpl = commandExists,
351
1128
  } = {},
352
1129
  ) {
353
1130
  if (typeof fetchImpl !== "function") {
354
1131
  throw new Error("A global fetch implementation is required to bootstrap Pylon.");
355
1132
  }
356
1133
 
1134
+ emitStatus(
1135
+ onStatus,
1136
+ "Resolving latest tagged Pylon release",
1137
+ options.version ? `requested ${options.version}` : "default release track",
1138
+ );
357
1139
  const target = resolvePlatformTarget(options.platform, options.arch);
358
1140
  const installRoot = options.installRoot ?? defaultInstallRoot();
359
- const release = await fetchReleaseMetadata({
360
- fetchImpl,
361
- apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
362
- repo: options.repo ?? DEFAULT_RELEASE_REPO,
363
- version: options.version ?? null,
364
- });
365
- const selected = selectReleaseAssets(release, target);
1141
+ if (options.version) {
1142
+ const requestedPaths = buildInstallPaths(installRoot, options.version, target);
1143
+ const requestedCached =
1144
+ (await pathExists(requestedPaths.pylonPath)) &&
1145
+ (await pathExists(requestedPaths.pylonTuiPath));
1146
+ if (requestedCached) {
1147
+ emitStatus(
1148
+ onStatus,
1149
+ "Using cached standalone binaries",
1150
+ `pylon-v${normalizeVersion(options.version)} for ${target.os}-${target.arch}`,
1151
+ );
1152
+ return {
1153
+ version: normalizeVersion(options.version),
1154
+ tagName: `pylon-v${normalizeVersion(options.version)}`,
1155
+ target,
1156
+ ...requestedPaths,
1157
+ expectedSha256: await fs
1158
+ .readFile(requestedPaths.manifestPath, "utf8")
1159
+ .then((payload) => JSON.parse(payload).sha256)
1160
+ .catch(() => null),
1161
+ cached: true,
1162
+ };
1163
+ }
1164
+ }
1165
+
1166
+ let release;
1167
+ try {
1168
+ release = await fetchReleaseMetadata({
1169
+ fetchImpl,
1170
+ runProcessImpl,
1171
+ onStatus,
1172
+ verbose: Boolean(options.verbose),
1173
+ apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
1174
+ repo: options.repo ?? DEFAULT_RELEASE_REPO,
1175
+ version: options.version ?? null,
1176
+ });
1177
+ } catch (error) {
1178
+ const cached = !options.version
1179
+ ? await findLatestCachedInstall(installRoot, target)
1180
+ : null;
1181
+ if (cached) {
1182
+ emitStatus(
1183
+ onStatus,
1184
+ "Using cached standalone binaries",
1185
+ `release lookup failed; falling back to ${cached.tagName}`,
1186
+ );
1187
+ return cached;
1188
+ }
1189
+
1190
+ const requestedVersion = normalizeRequestedVersion(options.version);
1191
+ const recovery = [
1192
+ error instanceof Error ? error.message : String(error),
1193
+ `Retry with verbose diagnostics: npx @openagentsinc/pylon --verbose${requestedVersion ? ` --version ${requestedVersion}` : ""}`,
1194
+ "Tagged Pylon releases: https://github.com/OpenAgentsInc/openagents/releases?q=pylon-v",
1195
+ ];
1196
+ if (requestedVersion) {
1197
+ recovery.push(
1198
+ `Expected asset: ${buildAssetNames(requestedVersion, target).archiveName}`,
1199
+ );
1200
+ }
1201
+ throw createBootstrapError(recovery.join("\n"), { cause: error });
1202
+ }
1203
+ let selected;
1204
+ let missingAssetsError = null;
1205
+ try {
1206
+ selected = selectReleaseAssets(release, target);
1207
+ } catch (error) {
1208
+ if (!(error instanceof MissingReleaseAssetsError)) {
1209
+ throw error;
1210
+ }
1211
+ missingAssetsError = error;
1212
+ selected = {
1213
+ tagName: error.tagName,
1214
+ version: error.version,
1215
+ archiveBasename: error.archiveBasename,
1216
+ targetCommitish: error.targetCommitish,
1217
+ };
1218
+ }
366
1219
  const paths = buildInstallPaths(installRoot, selected.version, target);
1220
+ const manifest = await readInstallManifest(paths.manifestPath);
367
1221
 
368
1222
  const binariesPresent =
369
1223
  (await pathExists(paths.pylonPath)) && (await pathExists(paths.pylonTuiPath));
370
1224
  if (binariesPresent) {
1225
+ const installMethod =
1226
+ manifest?.installMethod ??
1227
+ (missingAssetsError
1228
+ ? SOURCE_BUILD_INSTALL_METHOD
1229
+ : RELEASE_ASSET_INSTALL_METHOD);
1230
+ emitStatus(
1231
+ onStatus,
1232
+ installMethod === SOURCE_BUILD_INSTALL_METHOD
1233
+ ? "Using cached source-built binaries"
1234
+ : "Using cached standalone binaries",
1235
+ `${selected.tagName} for ${target.os}-${target.arch}`,
1236
+ );
371
1237
  return {
372
1238
  ...selected,
373
1239
  ...paths,
374
1240
  target,
375
- expectedSha256: await fs
376
- .readFile(paths.manifestPath, "utf8")
377
- .then((payload) => JSON.parse(payload).sha256)
378
- .catch(() => null),
1241
+ expectedSha256: manifest?.sha256 ?? null,
379
1242
  cached: true,
1243
+ installMethod,
1244
+ sourceCloneUrl: manifest?.sourceCloneUrl ?? null,
1245
+ sourceCommit: manifest?.sourceCommit ?? null,
380
1246
  };
381
1247
  }
382
1248
 
383
- const checksumPayload = await fetchText(fetchImpl, selected.checksumAsset.url);
1249
+ if (missingAssetsError) {
1250
+ return installSourceBuild(
1251
+ {
1252
+ selected,
1253
+ options,
1254
+ paths,
1255
+ target,
1256
+ },
1257
+ {
1258
+ fetchImpl,
1259
+ runProcessImpl,
1260
+ onStatus,
1261
+ promptImpl,
1262
+ commandExistsImpl,
1263
+ },
1264
+ );
1265
+ }
1266
+
1267
+ emitStatus(
1268
+ onStatus,
1269
+ "Fetching release checksum",
1270
+ selected.checksumAsset.name,
1271
+ );
1272
+ const checksumPayload = await fetchText(fetchImpl, selected.checksumAsset.url, {
1273
+ runProcessImpl,
1274
+ onStatus,
1275
+ verbose: Boolean(options.verbose),
1276
+ stage: "Release checksum download",
1277
+ });
384
1278
  const expectedSha256 = parseSha256File(
385
1279
  checksumPayload,
386
1280
  selected.archiveAsset.name,
@@ -393,7 +1287,17 @@ export async function ensureReleaseInstall(
393
1287
  archiveReady = (await sha256File(paths.archivePath)) === expectedSha256;
394
1288
  }
395
1289
  if (!archiveReady) {
396
- await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath);
1290
+ emitStatus(
1291
+ onStatus,
1292
+ "Downloading standalone binaries",
1293
+ selected.archiveAsset.name,
1294
+ );
1295
+ await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath, {
1296
+ runProcessImpl,
1297
+ onStatus,
1298
+ verbose: Boolean(options.verbose),
1299
+ stage: "Release archive download",
1300
+ });
397
1301
  }
398
1302
 
399
1303
  const actualSha256 = await sha256File(paths.archivePath);
@@ -403,6 +1307,11 @@ export async function ensureReleaseInstall(
403
1307
  );
404
1308
  }
405
1309
 
1310
+ emitStatus(
1311
+ onStatus,
1312
+ "Extracting standalone binaries",
1313
+ paths.installDir,
1314
+ );
406
1315
  await fs.rm(paths.installDir, { recursive: true, force: true });
407
1316
  await extractArchive(paths.archivePath, paths.versionsDir, runProcessImpl);
408
1317
 
@@ -417,19 +1326,19 @@ export async function ensureReleaseInstall(
417
1326
  fs.chmod(paths.pylonTuiPath, 0o755),
418
1327
  ]);
419
1328
 
420
- await fs.writeFile(
421
- paths.manifestPath,
422
- `${JSON.stringify(
423
- {
424
- version: selected.version,
425
- tagName: selected.tagName,
426
- target,
427
- archive: selected.archiveAsset.name,
428
- sha256: expectedSha256,
429
- },
430
- null,
431
- 2,
432
- )}\n`,
1329
+ await writeInstallManifest(paths.manifestPath, {
1330
+ version: selected.version,
1331
+ tagName: selected.tagName,
1332
+ target,
1333
+ archive: selected.archiveAsset.name,
1334
+ sha256: expectedSha256,
1335
+ installMethod: RELEASE_ASSET_INSTALL_METHOD,
1336
+ });
1337
+
1338
+ emitStatus(
1339
+ onStatus,
1340
+ "Installed standalone binaries",
1341
+ `${selected.tagName} for ${target.os}-${target.arch}`,
433
1342
  );
434
1343
 
435
1344
  return {
@@ -445,6 +1354,7 @@ export async function bootstrapInstalledPylon(
445
1354
  options,
446
1355
  {
447
1356
  runProcessImpl = runProcess,
1357
+ onStatus = null,
448
1358
  } = {},
449
1359
  ) {
450
1360
  const pylonPath = path.resolve(options.pylonPath);
@@ -455,14 +1365,18 @@ export async function bootstrapInstalledPylon(
455
1365
  const diagnosticMaxOutputTokens =
456
1366
  options.diagnosticMaxOutputTokens ?? DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS;
457
1367
 
1368
+ emitStatus(onStatus, "Verifying Pylon binary", path.basename(pylonPath));
458
1369
  await runPylonCommand(pylonPath, ["--help"], options, runProcessImpl);
1370
+ emitStatus(onStatus, "Bootstrapping local Pylon identity");
459
1371
  const init = await runPylonJson(pylonPath, ["init"], options, runProcessImpl);
1372
+ emitStatus(onStatus, "Checking runtime health");
460
1373
  const status = await runPylonJson(
461
1374
  pylonPath,
462
1375
  ["status", "--json"],
463
1376
  options,
464
1377
  runProcessImpl,
465
1378
  );
1379
+ emitStatus(onStatus, "Scanning for local models");
466
1380
  const inventory = await runPylonJson(
467
1381
  pylonPath,
468
1382
  ["inventory", "--json"],
@@ -472,31 +1386,52 @@ export async function bootstrapInstalledPylon(
472
1386
 
473
1387
  let download = null;
474
1388
  if (!options.skipModelDownload) {
1389
+ emitStatus(onStatus, "Downloading curated model bundle", model);
475
1390
  download = await runPylonJson(
476
1391
  pylonPath,
477
1392
  ["gemma", "download", model, "--json"],
478
1393
  options,
479
1394
  runProcessImpl,
480
1395
  );
1396
+ } else {
1397
+ emitStatus(
1398
+ onStatus,
1399
+ "Skipping optional curated GGUF cache",
1400
+ "use --download-curated-cache to prefetch Hugging Face weights",
1401
+ );
481
1402
  }
482
1403
 
483
1404
  let diagnostic = null;
484
1405
  if (!options.skipDiagnostics) {
485
- diagnostic = await runPylonJson(
486
- pylonPath,
487
- [
488
- "gemma",
489
- "diagnose",
490
- model,
491
- "--max-output-tokens",
492
- String(diagnosticMaxOutputTokens),
493
- "--repeats",
494
- String(diagnosticRepeats),
495
- "--json",
496
- ],
497
- options,
498
- runProcessImpl,
499
- );
1406
+ emitStatus(onStatus, "Running first-run diagnostic", model);
1407
+ try {
1408
+ diagnostic = await runPylonJson(
1409
+ pylonPath,
1410
+ [
1411
+ "gemma",
1412
+ "diagnose",
1413
+ model,
1414
+ "--max-output-tokens",
1415
+ String(diagnosticMaxOutputTokens),
1416
+ "--repeats",
1417
+ String(diagnosticRepeats),
1418
+ "--json",
1419
+ ],
1420
+ options,
1421
+ runProcessImpl,
1422
+ );
1423
+ } catch (error) {
1424
+ if (!isUnsupportedGemmaDiagnoseError(error)) {
1425
+ throw error;
1426
+ }
1427
+ emitStatus(
1428
+ onStatus,
1429
+ "Skipping first-run diagnostic",
1430
+ "installed Pylon release does not expose gemma diagnose",
1431
+ );
1432
+ }
1433
+ } else {
1434
+ emitStatus(onStatus, "Skipping first-run diagnostic", model);
500
1435
  }
501
1436
 
502
1437
  const diagnosticResult =
@@ -504,6 +1439,14 @@ export async function bootstrapInstalledPylon(
504
1439
  diagnostic?.results?.[0] ??
505
1440
  null;
506
1441
 
1442
+ emitStatus(
1443
+ onStatus,
1444
+ "Bootstrap complete",
1445
+ diagnosticResult?.status
1446
+ ? `diagnostic ${diagnosticResult.status}`
1447
+ : "smoke path complete",
1448
+ );
1449
+
507
1450
  return {
508
1451
  version: options.version,
509
1452
  tagName: options.tagName ?? `pylon-v${options.version}`,
@@ -525,6 +1468,21 @@ export async function bootstrapInstalledPylon(
525
1468
  };
526
1469
  }
527
1470
 
1471
+ export async function launchInstalledPylonTui(
1472
+ options,
1473
+ {
1474
+ runProcessImpl = runProcess,
1475
+ onStatus = null,
1476
+ } = {},
1477
+ ) {
1478
+ const pylonTuiPath = path.resolve(options.pylonTuiPath);
1479
+ emitStatus(onStatus, "Opening Pylon terminal UI", path.basename(pylonTuiPath));
1480
+ return runProcessImpl(pylonTuiPath, [], {
1481
+ env: buildPylonEnv(options),
1482
+ stdio: "inherit",
1483
+ });
1484
+ }
1485
+
528
1486
  export function renderBootstrapSummary(summary) {
529
1487
  const lines = [
530
1488
  `Pylon release: ${summary.version} (${summary.target.os}-${summary.target.arch})`,
@@ -551,6 +1509,16 @@ export function renderBootstrapSummary(summary) {
551
1509
  lines.push(
552
1510
  `Model download (${summary.model}): ${result?.status ?? "completed"}`,
553
1511
  );
1512
+ } else {
1513
+ lines.push(
1514
+ "Curated GGUF cache: skipped by default (pass --download-curated-cache to prefetch optional Hugging Face weights)",
1515
+ );
1516
+ }
1517
+
1518
+ const localGemmaError =
1519
+ summary.status?.snapshot?.availability?.local_gemma?.last_error ?? null;
1520
+ if (localGemmaError) {
1521
+ lines.push(`Local runtime note: ${localGemmaError}`);
554
1522
  }
555
1523
 
556
1524
  if (summary.diagnostic) {