@openagentsinc/pylon 0.1.1 → 0.1.3

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/src/index.js CHANGED
@@ -4,13 +4,20 @@ 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
+ const PREFERRED_RUNTIME_MODEL_NAME = "gemma4:e4b";
14
21
 
15
22
  function emitStatus(onStatus, message, detail = null) {
16
23
  if (typeof onStatus === "function") {
@@ -18,10 +25,22 @@ function emitStatus(onStatus, message, detail = null) {
18
25
  }
19
26
  }
20
27
 
28
+ function emitVerboseStatus(onStatus, verbose, message, detail = null) {
29
+ if (verbose) {
30
+ emitStatus(onStatus, message, detail);
31
+ }
32
+ }
33
+
21
34
  function normalizeVersion(value) {
22
35
  return value.replace(/^pylon-v/, "").replace(/^v/, "");
23
36
  }
24
37
 
38
+ function createBootstrapError(message, context = {}) {
39
+ const error = new Error(message);
40
+ Object.assign(error, context);
41
+ return error;
42
+ }
43
+
25
44
  async function pathExists(value) {
26
45
  try {
27
46
  await fs.access(value);
@@ -35,6 +54,28 @@ function defaultInstallRoot() {
35
54
  return path.join(os.homedir(), ".openagents", "pylon", "bootstrap");
36
55
  }
37
56
 
57
+ class MissingReleaseAssetsError extends Error {
58
+ constructor({
59
+ tagName,
60
+ version,
61
+ target,
62
+ archiveBasename,
63
+ archiveName,
64
+ checksumName,
65
+ targetCommitish,
66
+ }) {
67
+ super(`Release ${tagName} is missing ${archiveName} or ${checksumName}.`);
68
+ this.name = "MissingReleaseAssetsError";
69
+ this.tagName = tagName;
70
+ this.version = version;
71
+ this.target = target;
72
+ this.archiveBasename = archiveBasename;
73
+ this.archiveName = archiveName;
74
+ this.checksumName = checksumName;
75
+ this.targetCommitish = targetCommitish ?? null;
76
+ }
77
+ }
78
+
38
79
  function requestHeaders() {
39
80
  const headers = {
40
81
  accept: "application/vnd.github+json",
@@ -46,6 +87,166 @@ function requestHeaders() {
46
87
  return headers;
47
88
  }
48
89
 
90
+ function formatRequestHeaders(headers = requestHeaders()) {
91
+ return Object.entries(headers).flatMap(([key, value]) => [
92
+ "--header",
93
+ `${key}: ${value}`,
94
+ ]);
95
+ }
96
+
97
+ function timedSignal(timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
98
+ if (typeof AbortSignal?.timeout === "function") {
99
+ return {
100
+ signal: AbortSignal.timeout(timeoutMs),
101
+ dispose() {},
102
+ };
103
+ }
104
+
105
+ const controller = new AbortController();
106
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
107
+ return {
108
+ signal: controller.signal,
109
+ dispose() {
110
+ clearTimeout(timer);
111
+ },
112
+ };
113
+ }
114
+
115
+ function classifyNetworkError(error) {
116
+ const cause = error?.cause ?? null;
117
+ const code = cause?.code ?? cause?.errno ?? null;
118
+ const detail = [error?.message, cause?.message].filter(Boolean).join(" :: ");
119
+ const lowered = detail.toLowerCase();
120
+ let classification = "network";
121
+
122
+ if (
123
+ error?.name === "AbortError" ||
124
+ code === "ETIMEDOUT" ||
125
+ lowered.includes("timeout")
126
+ ) {
127
+ classification = "timeout";
128
+ } else if (
129
+ code === "ENOTFOUND" ||
130
+ code === "EAI_AGAIN" ||
131
+ lowered.includes("getaddrinfo")
132
+ ) {
133
+ classification = "dns";
134
+ } else if (
135
+ code === "CERT_HAS_EXPIRED" ||
136
+ code === "DEPTH_ZERO_SELF_SIGNED_CERT" ||
137
+ lowered.includes("certificate") ||
138
+ lowered.includes("tls") ||
139
+ lowered.includes("ssl")
140
+ ) {
141
+ classification = "tls";
142
+ } else if (
143
+ code === "ECONNREFUSED" ||
144
+ code === "ECONNRESET" ||
145
+ code === "EHOSTUNREACH" ||
146
+ code === "ENETUNREACH" ||
147
+ code === "EADDRNOTAVAIL" ||
148
+ lowered.includes("connect")
149
+ ) {
150
+ classification = "connect";
151
+ }
152
+
153
+ const endpoint =
154
+ cause?.address || cause?.hostname || cause?.port
155
+ ? [
156
+ cause?.hostname ?? cause?.address ?? null,
157
+ cause?.port != null ? String(cause.port) : null,
158
+ ]
159
+ .filter(Boolean)
160
+ .join(":")
161
+ : null;
162
+
163
+ return {
164
+ classification,
165
+ code,
166
+ endpoint,
167
+ detail: detail || "network request failed",
168
+ };
169
+ }
170
+
171
+ function isRetryableFetchError(error) {
172
+ if (!(error instanceof Error)) {
173
+ return false;
174
+ }
175
+ return classifyNetworkError(error).classification !== "tls";
176
+ }
177
+
178
+ function renderNetworkFailure({ stage, url, error, curlError = null }) {
179
+ const details = classifyNetworkError(error);
180
+ const parts = [
181
+ `${stage} failed for ${url}.`,
182
+ `classification=${details.classification}`,
183
+ ];
184
+ if (details.code) {
185
+ parts.push(`code=${details.code}`);
186
+ }
187
+ if (details.endpoint) {
188
+ parts.push(`endpoint=${details.endpoint}`);
189
+ }
190
+ parts.push(`detail=${details.detail}`);
191
+ if (curlError) {
192
+ parts.push(
193
+ `curl_fallback=${curlError instanceof Error ? curlError.message : String(curlError)}`,
194
+ );
195
+ }
196
+ return parts.join(" ");
197
+ }
198
+
199
+ async function fetchResponse(fetchImpl, url, headers = requestHeaders()) {
200
+ const timeout = timedSignal();
201
+ try {
202
+ return await fetchImpl(url, {
203
+ headers,
204
+ signal: timeout.signal,
205
+ });
206
+ } finally {
207
+ timeout.dispose();
208
+ }
209
+ }
210
+
211
+ async function runCurlText(runProcessImpl, url, headers = requestHeaders()) {
212
+ const { stdout } = await runProcessImpl("curl", [
213
+ "--fail",
214
+ "--silent",
215
+ "--show-error",
216
+ "--location",
217
+ "--connect-timeout",
218
+ "15",
219
+ "--max-time",
220
+ "60",
221
+ ...formatRequestHeaders(headers),
222
+ url,
223
+ ]);
224
+ return stdout;
225
+ }
226
+
227
+ async function runCurlDownload(
228
+ runProcessImpl,
229
+ url,
230
+ destination,
231
+ headers = requestHeaders(),
232
+ ) {
233
+ await fs.mkdir(path.dirname(destination), { recursive: true });
234
+ await runProcessImpl("curl", [
235
+ "--fail",
236
+ "--silent",
237
+ "--show-error",
238
+ "--location",
239
+ "--connect-timeout",
240
+ "15",
241
+ "--max-time",
242
+ "300",
243
+ "--output",
244
+ destination,
245
+ ...formatRequestHeaders(headers),
246
+ url,
247
+ ]);
248
+ }
249
+
49
250
  export function resolvePlatformTarget(
50
251
  platform = process.platform,
51
252
  arch = process.arch,
@@ -112,42 +313,164 @@ export function parseSha256File(payload, expectedAssetName) {
112
313
  return sha256.toLowerCase();
113
314
  }
114
315
 
115
- async function fetchJson(fetchImpl, url) {
116
- const response = await fetchImpl(url, {
117
- headers: requestHeaders(),
118
- });
119
- if (!response.ok) {
120
- throw new Error(
121
- `GitHub release lookup failed for ${url} (${response.status} ${response.statusText}).`,
122
- );
316
+ async function fetchJson(
317
+ fetchImpl,
318
+ url,
319
+ {
320
+ headers = requestHeaders(),
321
+ runProcessImpl = runProcess,
322
+ onStatus = null,
323
+ stage = "GitHub release lookup",
324
+ verbose = false,
325
+ } = {},
326
+ ) {
327
+ try {
328
+ emitVerboseStatus(onStatus, verbose, stage, url);
329
+ const response = await fetchResponse(fetchImpl, url, headers);
330
+ if (!response.ok) {
331
+ throw createBootstrapError(
332
+ `GitHub release lookup failed for ${url} (${response.status} ${response.statusText}).`,
333
+ {
334
+ stage,
335
+ url,
336
+ httpStatus: response.status,
337
+ },
338
+ );
339
+ }
340
+ return response.json();
341
+ } catch (error) {
342
+ if (
343
+ !error?.httpStatus &&
344
+ isRetryableFetchError(error) &&
345
+ typeof runProcessImpl === "function"
346
+ ) {
347
+ emitStatus(onStatus, "Retrying with curl transport", `${stage} ${url}`);
348
+ try {
349
+ return JSON.parse(await runCurlText(runProcessImpl, url, headers));
350
+ } catch (curlError) {
351
+ throw createBootstrapError(
352
+ renderNetworkFailure({ stage, url, error, curlError }),
353
+ { stage, url, cause: error },
354
+ );
355
+ }
356
+ }
357
+ if (error?.httpStatus) {
358
+ throw error;
359
+ }
360
+ throw createBootstrapError(renderNetworkFailure({ stage, url, error }), {
361
+ stage,
362
+ url,
363
+ cause: error,
364
+ });
123
365
  }
124
- return response.json();
125
366
  }
126
367
 
127
- async function fetchText(fetchImpl, url) {
128
- const response = await fetchImpl(url, {
129
- headers: requestHeaders(),
130
- });
131
- if (!response.ok) {
132
- throw new Error(
133
- `Download failed for ${url} (${response.status} ${response.statusText}).`,
134
- );
368
+ async function fetchText(
369
+ fetchImpl,
370
+ url,
371
+ {
372
+ headers = requestHeaders(),
373
+ runProcessImpl = runProcess,
374
+ onStatus = null,
375
+ stage = "download text",
376
+ verbose = false,
377
+ } = {},
378
+ ) {
379
+ try {
380
+ emitVerboseStatus(onStatus, verbose, stage, url);
381
+ const response = await fetchResponse(fetchImpl, url, headers);
382
+ if (!response.ok) {
383
+ throw createBootstrapError(
384
+ `Download failed for ${url} (${response.status} ${response.statusText}).`,
385
+ {
386
+ stage,
387
+ url,
388
+ httpStatus: response.status,
389
+ },
390
+ );
391
+ }
392
+ return response.text();
393
+ } catch (error) {
394
+ if (
395
+ !error?.httpStatus &&
396
+ isRetryableFetchError(error) &&
397
+ typeof runProcessImpl === "function"
398
+ ) {
399
+ emitStatus(onStatus, "Retrying with curl transport", `${stage} ${url}`);
400
+ try {
401
+ return await runCurlText(runProcessImpl, url, headers);
402
+ } catch (curlError) {
403
+ throw createBootstrapError(
404
+ renderNetworkFailure({ stage, url, error, curlError }),
405
+ { stage, url, cause: error },
406
+ );
407
+ }
408
+ }
409
+ if (error?.httpStatus) {
410
+ throw error;
411
+ }
412
+ throw createBootstrapError(renderNetworkFailure({ stage, url, error }), {
413
+ stage,
414
+ url,
415
+ cause: error,
416
+ });
135
417
  }
136
- return response.text();
137
418
  }
138
419
 
139
- async function downloadFile(fetchImpl, url, destination) {
140
- const response = await fetchImpl(url, {
141
- headers: requestHeaders(),
142
- });
143
- if (!response.ok) {
144
- throw new Error(
145
- `Download failed for ${url} (${response.status} ${response.statusText}).`,
146
- );
420
+ async function downloadFile(
421
+ fetchImpl,
422
+ url,
423
+ destination,
424
+ {
425
+ headers = requestHeaders(),
426
+ runProcessImpl = runProcess,
427
+ onStatus = null,
428
+ stage = "download file",
429
+ verbose = false,
430
+ } = {},
431
+ ) {
432
+ try {
433
+ emitVerboseStatus(onStatus, verbose, stage, url);
434
+ const response = await fetchResponse(fetchImpl, url, headers);
435
+ if (!response.ok) {
436
+ throw createBootstrapError(
437
+ `Download failed for ${url} (${response.status} ${response.statusText}).`,
438
+ {
439
+ stage,
440
+ url,
441
+ httpStatus: response.status,
442
+ },
443
+ );
444
+ }
445
+ const payload = Buffer.from(await response.arrayBuffer());
446
+ await fs.mkdir(path.dirname(destination), { recursive: true });
447
+ await fs.writeFile(destination, payload);
448
+ } catch (error) {
449
+ if (
450
+ !error?.httpStatus &&
451
+ isRetryableFetchError(error) &&
452
+ typeof runProcessImpl === "function"
453
+ ) {
454
+ emitStatus(onStatus, "Retrying with curl transport", `${stage} ${url}`);
455
+ try {
456
+ await runCurlDownload(runProcessImpl, url, destination, headers);
457
+ return;
458
+ } catch (curlError) {
459
+ throw createBootstrapError(
460
+ renderNetworkFailure({ stage, url, error, curlError }),
461
+ { stage, url, cause: error },
462
+ );
463
+ }
464
+ }
465
+ if (error?.httpStatus) {
466
+ throw error;
467
+ }
468
+ throw createBootstrapError(renderNetworkFailure({ stage, url, error }), {
469
+ stage,
470
+ url,
471
+ cause: error,
472
+ });
147
473
  }
148
- const payload = Buffer.from(await response.arrayBuffer());
149
- await fs.mkdir(path.dirname(destination), { recursive: true });
150
- await fs.writeFile(destination, payload);
151
474
  }
152
475
 
153
476
  async function sha256File(filePath) {
@@ -193,6 +516,9 @@ export function selectLatestPylonRelease(releases) {
193
516
 
194
517
  export async function fetchReleaseMetadata({
195
518
  fetchImpl = globalThis.fetch,
519
+ runProcessImpl = runProcess,
520
+ onStatus = null,
521
+ verbose = false,
196
522
  apiBase = DEFAULT_RELEASE_API_BASE,
197
523
  repo = DEFAULT_RELEASE_REPO,
198
524
  version = null,
@@ -204,7 +530,14 @@ export async function fetchReleaseMetadata({
204
530
  )}`
205
531
  : `/repos/${repo}/releases?per_page=100`;
206
532
  const url = `${apiBase.replace(/\/$/, "")}${endpoint}`;
207
- const payload = await fetchJson(fetchImpl, url);
533
+ const payload = await fetchJson(fetchImpl, url, {
534
+ runProcessImpl,
535
+ onStatus,
536
+ verbose,
537
+ stage: normalizedVersion
538
+ ? "GitHub tagged release lookup"
539
+ : "GitHub release list lookup",
540
+ });
208
541
  return normalizedVersion ? payload : selectLatestPylonRelease(payload);
209
542
  }
210
543
 
@@ -223,9 +556,15 @@ export function selectReleaseAssets(release, target) {
223
556
  const checksumAsset = assets.find((asset) => asset.name === checksumName);
224
557
 
225
558
  if (!archiveAsset || !checksumAsset) {
226
- throw new Error(
227
- `Release ${tagName} is missing ${archiveName} or ${checksumName}.`,
228
- );
559
+ throw new MissingReleaseAssetsError({
560
+ tagName,
561
+ version,
562
+ target,
563
+ archiveBasename,
564
+ archiveName,
565
+ checksumName,
566
+ targetCommitish: release?.target_commitish ?? null,
567
+ });
229
568
  }
230
569
 
231
570
  return {
@@ -267,6 +606,424 @@ export function buildInstallPaths(installRoot, version, target) {
267
606
  };
268
607
  }
269
608
 
609
+ async function readInstallManifest(manifestPath) {
610
+ try {
611
+ const payload = await fs.readFile(manifestPath, "utf8");
612
+ return JSON.parse(payload);
613
+ } catch {
614
+ return null;
615
+ }
616
+ }
617
+
618
+ async function writeInstallManifest(manifestPath, payload) {
619
+ await fs.mkdir(path.dirname(manifestPath), { recursive: true });
620
+ await fs.writeFile(manifestPath, `${JSON.stringify(payload, null, 2)}\n`);
621
+ }
622
+
623
+ export function deriveReleaseGitBase(apiBase = DEFAULT_RELEASE_API_BASE) {
624
+ const normalized = (apiBase ?? DEFAULT_RELEASE_API_BASE).replace(/\/$/, "");
625
+ if (normalized === DEFAULT_RELEASE_API_BASE) {
626
+ return DEFAULT_RELEASE_GIT_BASE;
627
+ }
628
+ return normalized.replace(/\/api(?:\/v3)?$/i, "");
629
+ }
630
+
631
+ export function buildReleaseCloneUrl(
632
+ repo,
633
+ {
634
+ apiBase = DEFAULT_RELEASE_API_BASE,
635
+ gitBase = null,
636
+ cloneUrl = null,
637
+ } = {},
638
+ ) {
639
+ if (cloneUrl) {
640
+ return cloneUrl;
641
+ }
642
+ return `${(gitBase ?? deriveReleaseGitBase(apiBase)).replace(/\/$/, "")}/${repo}.git`;
643
+ }
644
+
645
+ function withPrependedPath(env, entry) {
646
+ const normalizedEntry = path.resolve(entry);
647
+ const parts = (env.PATH ?? process.env.PATH ?? "")
648
+ .split(path.delimiter)
649
+ .filter(Boolean);
650
+ if (!parts.includes(normalizedEntry)) {
651
+ parts.unshift(normalizedEntry);
652
+ }
653
+ return {
654
+ ...env,
655
+ PATH: parts.join(path.delimiter),
656
+ };
657
+ }
658
+
659
+ async function commandExists(command, env = process.env) {
660
+ const pathValue = env.PATH ?? process.env.PATH ?? "";
661
+ const directories = pathValue.split(path.delimiter).filter(Boolean);
662
+ const suffixes =
663
+ process.platform === "win32"
664
+ ? (env.PATHEXT ?? process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
665
+ .split(";")
666
+ .filter(Boolean)
667
+ : [""];
668
+
669
+ for (const directory of directories) {
670
+ for (const suffix of suffixes) {
671
+ const candidate = path.join(directory, `${command}${suffix}`);
672
+ if (await pathExists(candidate)) {
673
+ return true;
674
+ }
675
+ }
676
+ }
677
+
678
+ return false;
679
+ }
680
+
681
+ async function promptForApproval(message) {
682
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
683
+ throw new Error(
684
+ `${message}\nInteractive approval is required, but this terminal is not interactive.`,
685
+ );
686
+ }
687
+ const terminal = readline.createInterface({
688
+ input: process.stdin,
689
+ output: process.stdout,
690
+ });
691
+ try {
692
+ const answer = await terminal.question(`${message} [y/N] `);
693
+ return /^y(?:es)?$/i.test(answer.trim());
694
+ } finally {
695
+ terminal.close();
696
+ }
697
+ }
698
+
699
+ function manualSourceBuildCommands(tagName, cloneUrl) {
700
+ return [
701
+ `git clone --depth 1 --branch ${tagName} ${cloneUrl}`,
702
+ "cd openagents",
703
+ "cargo build --release -p pylon -p pylon-tui",
704
+ ].join("\n");
705
+ }
706
+
707
+ function rustInstallCommand() {
708
+ return "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y";
709
+ }
710
+
711
+ async function ensureRustToolchain({
712
+ target,
713
+ fetchImpl,
714
+ runProcessImpl,
715
+ onStatus,
716
+ promptImpl = promptForApproval,
717
+ commandExistsImpl = commandExists,
718
+ env = process.env,
719
+ rustupInitUrl = DEFAULT_RUSTUP_INIT_URL,
720
+ }) {
721
+ let toolchainEnv = withPrependedPath(env, path.join(os.homedir(), ".cargo", "bin"));
722
+ const hasCargo = await commandExistsImpl("cargo", toolchainEnv);
723
+ const hasRustc = await commandExistsImpl("rustc", toolchainEnv);
724
+ if (hasCargo && hasRustc) {
725
+ return toolchainEnv;
726
+ }
727
+
728
+ emitStatus(
729
+ onStatus,
730
+ "Rust toolchain required for source build",
731
+ `${target.os}-${target.arch}`,
732
+ );
733
+
734
+ const approved = await promptImpl(
735
+ `Rust is required to build Pylon from source for ${target.os}-${target.arch}. Install the official Rust toolchain now via rustup?`,
736
+ );
737
+ if (!approved) {
738
+ throw new Error(
739
+ `Rust is required to build Pylon from source.\nInstall it manually and rerun:\n${rustInstallCommand()}`,
740
+ );
741
+ }
742
+
743
+ emitStatus(onStatus, "Installing Rust toolchain", "official rustup installer");
744
+ const scriptPayload = await fetchText(fetchImpl, rustupInitUrl, {
745
+ headers: {
746
+ accept: "text/plain",
747
+ "user-agent": "@openagentsinc/pylon bootstrap",
748
+ },
749
+ runProcessImpl,
750
+ onStatus,
751
+ stage: "Rust toolchain installer download",
752
+ });
753
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-rustup-"));
754
+ const scriptPath = path.join(tempDir, "rustup-init.sh");
755
+
756
+ try {
757
+ await fs.writeFile(scriptPath, scriptPayload);
758
+ await fs.chmod(scriptPath, 0o755);
759
+ await runProcessImpl("sh", [scriptPath, "-y"], {
760
+ cwd: tempDir,
761
+ env: toolchainEnv,
762
+ stdio: "inherit",
763
+ });
764
+ } finally {
765
+ await fs.rm(tempDir, { recursive: true, force: true });
766
+ }
767
+
768
+ toolchainEnv = withPrependedPath(env, path.join(os.homedir(), ".cargo", "bin"));
769
+ const cargoInstalled = await commandExistsImpl("cargo", toolchainEnv);
770
+ const rustcInstalled = await commandExistsImpl("rustc", toolchainEnv);
771
+ if (!cargoInstalled || !rustcInstalled) {
772
+ throw new Error(
773
+ `Rust install completed, but \`cargo\` and \`rustc\` were not found on PATH.\nInstall them manually and rerun:\n${rustInstallCommand()}`,
774
+ );
775
+ }
776
+
777
+ emitStatus(
778
+ onStatus,
779
+ "Rust toolchain installed",
780
+ path.join(os.homedir(), ".cargo", "bin"),
781
+ );
782
+ return toolchainEnv;
783
+ }
784
+
785
+ async function installSourceBuild(
786
+ {
787
+ selected,
788
+ options,
789
+ paths,
790
+ target,
791
+ },
792
+ {
793
+ fetchImpl,
794
+ runProcessImpl,
795
+ onStatus,
796
+ promptImpl = promptForApproval,
797
+ commandExistsImpl = commandExists,
798
+ },
799
+ ) {
800
+ const cloneUrl = buildReleaseCloneUrl(options.repo ?? DEFAULT_RELEASE_REPO, {
801
+ apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
802
+ cloneUrl: options.sourceRepoUrl ?? null,
803
+ gitBase: options.gitBase ?? null,
804
+ });
805
+ const manualBuildInstructions = manualSourceBuildCommands(
806
+ selected.tagName,
807
+ cloneUrl,
808
+ );
809
+
810
+ emitStatus(
811
+ onStatus,
812
+ "Prebuilt asset missing; falling back to source build",
813
+ `${selected.tagName} for ${target.os}-${target.arch}`,
814
+ );
815
+
816
+ if (!(await commandExistsImpl("git", process.env))) {
817
+ throw new Error(
818
+ `Source build fallback requires \`git\`.\nInstall it and rerun \`npx @openagentsinc/pylon\`, or build manually:\n${manualBuildInstructions}`,
819
+ );
820
+ }
821
+
822
+ const buildEnv = await ensureRustToolchain({
823
+ target,
824
+ fetchImpl,
825
+ runProcessImpl,
826
+ onStatus,
827
+ promptImpl,
828
+ commandExistsImpl,
829
+ });
830
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-source-build-"));
831
+ const repoDir = path.join(tempDir, "openagents");
832
+ const buildCommand = [
833
+ "cargo",
834
+ "build",
835
+ "--release",
836
+ "-p",
837
+ "pylon",
838
+ "-p",
839
+ "pylon-tui",
840
+ ];
841
+
842
+ try {
843
+ await fs.mkdir(repoDir, { recursive: true });
844
+ emitStatus(onStatus, "Fetching source checkout", selected.tagName);
845
+ await runProcessImpl("git", ["init"], {
846
+ cwd: repoDir,
847
+ env: buildEnv,
848
+ });
849
+ await runProcessImpl("git", ["remote", "add", "origin", cloneUrl], {
850
+ cwd: repoDir,
851
+ env: buildEnv,
852
+ });
853
+ await runProcessImpl(
854
+ "git",
855
+ [
856
+ "fetch",
857
+ "--depth",
858
+ "1",
859
+ "origin",
860
+ `refs/tags/${selected.tagName}:refs/tags/${selected.tagName}`,
861
+ ],
862
+ {
863
+ cwd: repoDir,
864
+ env: buildEnv,
865
+ },
866
+ );
867
+ await runProcessImpl(
868
+ "git",
869
+ ["checkout", "--detach", `refs/tags/${selected.tagName}`],
870
+ {
871
+ cwd: repoDir,
872
+ env: buildEnv,
873
+ },
874
+ );
875
+
876
+ const { stdout: commitStdout } = await runProcessImpl(
877
+ "git",
878
+ ["rev-parse", "HEAD"],
879
+ {
880
+ cwd: repoDir,
881
+ env: buildEnv,
882
+ },
883
+ );
884
+ const sourceCommit = commitStdout.trim();
885
+ if (
886
+ selected.targetCommitish &&
887
+ /^[a-f0-9]{40}$/i.test(selected.targetCommitish) &&
888
+ sourceCommit !== selected.targetCommitish
889
+ ) {
890
+ throw new Error(
891
+ `Resolved release tag ${selected.tagName} checked out ${sourceCommit}, expected ${selected.targetCommitish}.`,
892
+ );
893
+ }
894
+
895
+ emitStatus(
896
+ onStatus,
897
+ "Building Pylon from source",
898
+ `${selected.tagName} (${sourceCommit.slice(0, 12)})`,
899
+ );
900
+ await runProcessImpl(buildCommand[0], buildCommand.slice(1), {
901
+ cwd: repoDir,
902
+ env: buildEnv,
903
+ stdio: "inherit",
904
+ });
905
+
906
+ const builtPylonPath = path.join(repoDir, "target", "release", "pylon");
907
+ const builtPylonTuiPath = path.join(repoDir, "target", "release", "pylon-tui");
908
+ if (!(await pathExists(builtPylonPath)) || !(await pathExists(builtPylonTuiPath))) {
909
+ throw new Error(
910
+ `Source build completed without the expected binaries at ${path.join(repoDir, "target", "release")}.`,
911
+ );
912
+ }
913
+
914
+ await fs.rm(paths.installDir, { recursive: true, force: true });
915
+ await fs.mkdir(paths.installDir, { recursive: true });
916
+ await Promise.all([
917
+ fs.copyFile(builtPylonPath, paths.pylonPath),
918
+ fs.copyFile(builtPylonTuiPath, paths.pylonTuiPath),
919
+ ]);
920
+ await Promise.allSettled([
921
+ fs.chmod(paths.pylonPath, 0o755),
922
+ fs.chmod(paths.pylonTuiPath, 0o755),
923
+ ]);
924
+
925
+ await writeInstallManifest(paths.manifestPath, {
926
+ version: selected.version,
927
+ tagName: selected.tagName,
928
+ target,
929
+ installMethod: SOURCE_BUILD_INSTALL_METHOD,
930
+ sourceCloneUrl: cloneUrl,
931
+ sourceCommit,
932
+ sourceTargetCommitish: selected.targetCommitish ?? null,
933
+ buildCommand: buildCommand.join(" "),
934
+ });
935
+
936
+ emitStatus(
937
+ onStatus,
938
+ "Installed source-built binaries",
939
+ `${selected.tagName} for ${target.os}-${target.arch}`,
940
+ );
941
+
942
+ return {
943
+ ...selected,
944
+ ...paths,
945
+ target,
946
+ cached: false,
947
+ expectedSha256: null,
948
+ installMethod: SOURCE_BUILD_INSTALL_METHOD,
949
+ sourceCloneUrl: cloneUrl,
950
+ sourceCommit,
951
+ };
952
+ } catch (error) {
953
+ const message = error instanceof Error ? error.message : String(error);
954
+ throw new Error(
955
+ `${message}\nManual source-build fallback:\n${manualBuildInstructions}`,
956
+ );
957
+ } finally {
958
+ await fs.rm(tempDir, { recursive: true, force: true });
959
+ }
960
+ }
961
+
962
+ async function findLatestCachedInstall(installRoot, target) {
963
+ const normalizedRoot = path.resolve(installRoot ?? defaultInstallRoot());
964
+ const versionsDir = path.join(normalizedRoot, "versions");
965
+ let entries;
966
+ try {
967
+ entries = await fs.readdir(versionsDir, { withFileTypes: true });
968
+ } catch {
969
+ return null;
970
+ }
971
+
972
+ const candidates = [];
973
+ for (const entry of entries) {
974
+ if (!entry.isDirectory()) {
975
+ continue;
976
+ }
977
+ if (!entry.name.endsWith(`-${target.os}-${target.arch}`)) {
978
+ continue;
979
+ }
980
+
981
+ const installDir = path.join(versionsDir, entry.name);
982
+ const manifestPath = path.join(installDir, "install.json");
983
+ const pylonPath = path.join(installDir, "pylon");
984
+ const pylonTuiPath = path.join(installDir, "pylon-tui");
985
+ if (
986
+ !(await pathExists(manifestPath)) ||
987
+ !(await pathExists(pylonPath)) ||
988
+ !(await pathExists(pylonTuiPath))
989
+ ) {
990
+ continue;
991
+ }
992
+
993
+ try {
994
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
995
+ const manifestStat = await fs.stat(manifestPath);
996
+ candidates.push({
997
+ version: manifest.version,
998
+ tagName: manifest.tagName,
999
+ target,
1000
+ installRoot: normalizedRoot,
1001
+ versionsDir,
1002
+ downloadsDir: path.join(
1003
+ normalizedRoot,
1004
+ "downloads",
1005
+ `pylon-v${normalizeVersion(manifest.version)}`,
1006
+ ),
1007
+ installDir,
1008
+ archiveBasename: entry.name,
1009
+ archivePath: null,
1010
+ checksumPath: null,
1011
+ manifestPath,
1012
+ pylonPath,
1013
+ pylonTuiPath,
1014
+ expectedSha256: manifest.sha256 ?? null,
1015
+ cached: true,
1016
+ mtimeMs: manifestStat.mtimeMs,
1017
+ });
1018
+ } catch {
1019
+ // Ignore malformed cache entries and keep scanning.
1020
+ }
1021
+ }
1022
+
1023
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
1024
+ return candidates[0] ?? null;
1025
+ }
1026
+
270
1027
  export async function runProcess(
271
1028
  command,
272
1029
  args,
@@ -367,6 +1124,8 @@ export async function ensureReleaseInstall(
367
1124
  fetchImpl = globalThis.fetch,
368
1125
  runProcessImpl = runProcess,
369
1126
  onStatus = null,
1127
+ promptImpl = promptForApproval,
1128
+ commandExistsImpl = commandExists,
370
1129
  } = {},
371
1130
  ) {
372
1131
  if (typeof fetchImpl !== "function") {
@@ -375,46 +1134,148 @@ export async function ensureReleaseInstall(
375
1134
 
376
1135
  emitStatus(
377
1136
  onStatus,
378
- "Resolving latest tagged Pylon release",
1137
+ "Checking for newer tagged Pylon releases",
379
1138
  options.version ? `requested ${options.version}` : "default release track",
380
1139
  );
381
1140
  const target = resolvePlatformTarget(options.platform, options.arch);
382
1141
  const installRoot = options.installRoot ?? defaultInstallRoot();
383
- const release = await fetchReleaseMetadata({
384
- fetchImpl,
385
- apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
386
- repo: options.repo ?? DEFAULT_RELEASE_REPO,
387
- version: options.version ?? null,
388
- });
389
- const selected = selectReleaseAssets(release, target);
1142
+ if (options.version) {
1143
+ const requestedPaths = buildInstallPaths(installRoot, options.version, target);
1144
+ const requestedCached =
1145
+ (await pathExists(requestedPaths.pylonPath)) &&
1146
+ (await pathExists(requestedPaths.pylonTuiPath));
1147
+ if (requestedCached) {
1148
+ emitStatus(
1149
+ onStatus,
1150
+ "Using cached standalone binaries",
1151
+ `pylon-v${normalizeVersion(options.version)} for ${target.os}-${target.arch}`,
1152
+ );
1153
+ return {
1154
+ version: normalizeVersion(options.version),
1155
+ tagName: `pylon-v${normalizeVersion(options.version)}`,
1156
+ target,
1157
+ ...requestedPaths,
1158
+ expectedSha256: await fs
1159
+ .readFile(requestedPaths.manifestPath, "utf8")
1160
+ .then((payload) => JSON.parse(payload).sha256)
1161
+ .catch(() => null),
1162
+ cached: true,
1163
+ };
1164
+ }
1165
+ }
1166
+
1167
+ let release;
1168
+ try {
1169
+ release = await fetchReleaseMetadata({
1170
+ fetchImpl,
1171
+ runProcessImpl,
1172
+ onStatus,
1173
+ verbose: Boolean(options.verbose),
1174
+ apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
1175
+ repo: options.repo ?? DEFAULT_RELEASE_REPO,
1176
+ version: options.version ?? null,
1177
+ });
1178
+ } catch (error) {
1179
+ const cached = !options.version
1180
+ ? await findLatestCachedInstall(installRoot, target)
1181
+ : null;
1182
+ if (cached) {
1183
+ emitStatus(
1184
+ onStatus,
1185
+ "Using cached standalone binaries",
1186
+ `release lookup failed; falling back to ${cached.tagName}`,
1187
+ );
1188
+ return cached;
1189
+ }
1190
+
1191
+ const requestedVersion = normalizeRequestedVersion(options.version);
1192
+ const recovery = [
1193
+ error instanceof Error ? error.message : String(error),
1194
+ `Retry with verbose diagnostics: npx @openagentsinc/pylon --verbose${requestedVersion ? ` --version ${requestedVersion}` : ""}`,
1195
+ "Tagged Pylon releases: https://github.com/OpenAgentsInc/openagents/releases?q=pylon-v",
1196
+ ];
1197
+ if (requestedVersion) {
1198
+ recovery.push(
1199
+ `Expected asset: ${buildAssetNames(requestedVersion, target).archiveName}`,
1200
+ );
1201
+ }
1202
+ throw createBootstrapError(recovery.join("\n"), { cause: error });
1203
+ }
1204
+ let selected;
1205
+ let missingAssetsError = null;
1206
+ try {
1207
+ selected = selectReleaseAssets(release, target);
1208
+ } catch (error) {
1209
+ if (!(error instanceof MissingReleaseAssetsError)) {
1210
+ throw error;
1211
+ }
1212
+ missingAssetsError = error;
1213
+ selected = {
1214
+ tagName: error.tagName,
1215
+ version: error.version,
1216
+ archiveBasename: error.archiveBasename,
1217
+ targetCommitish: error.targetCommitish,
1218
+ };
1219
+ }
390
1220
  const paths = buildInstallPaths(installRoot, selected.version, target);
1221
+ const manifest = await readInstallManifest(paths.manifestPath);
391
1222
 
392
1223
  const binariesPresent =
393
1224
  (await pathExists(paths.pylonPath)) && (await pathExists(paths.pylonTuiPath));
394
1225
  if (binariesPresent) {
1226
+ const installMethod =
1227
+ manifest?.installMethod ??
1228
+ (missingAssetsError
1229
+ ? SOURCE_BUILD_INSTALL_METHOD
1230
+ : RELEASE_ASSET_INSTALL_METHOD);
395
1231
  emitStatus(
396
1232
  onStatus,
397
- "Using cached standalone binaries",
1233
+ installMethod === SOURCE_BUILD_INSTALL_METHOD
1234
+ ? "Using cached source-built binaries"
1235
+ : "Using cached standalone binaries",
398
1236
  `${selected.tagName} for ${target.os}-${target.arch}`,
399
1237
  );
400
1238
  return {
401
1239
  ...selected,
402
1240
  ...paths,
403
1241
  target,
404
- expectedSha256: await fs
405
- .readFile(paths.manifestPath, "utf8")
406
- .then((payload) => JSON.parse(payload).sha256)
407
- .catch(() => null),
1242
+ expectedSha256: manifest?.sha256 ?? null,
408
1243
  cached: true,
1244
+ installMethod,
1245
+ sourceCloneUrl: manifest?.sourceCloneUrl ?? null,
1246
+ sourceCommit: manifest?.sourceCommit ?? null,
409
1247
  };
410
1248
  }
411
1249
 
1250
+ if (missingAssetsError) {
1251
+ return installSourceBuild(
1252
+ {
1253
+ selected,
1254
+ options,
1255
+ paths,
1256
+ target,
1257
+ },
1258
+ {
1259
+ fetchImpl,
1260
+ runProcessImpl,
1261
+ onStatus,
1262
+ promptImpl,
1263
+ commandExistsImpl,
1264
+ },
1265
+ );
1266
+ }
1267
+
412
1268
  emitStatus(
413
1269
  onStatus,
414
1270
  "Fetching release checksum",
415
1271
  selected.checksumAsset.name,
416
1272
  );
417
- const checksumPayload = await fetchText(fetchImpl, selected.checksumAsset.url);
1273
+ const checksumPayload = await fetchText(fetchImpl, selected.checksumAsset.url, {
1274
+ runProcessImpl,
1275
+ onStatus,
1276
+ verbose: Boolean(options.verbose),
1277
+ stage: "Release checksum download",
1278
+ });
418
1279
  const expectedSha256 = parseSha256File(
419
1280
  checksumPayload,
420
1281
  selected.archiveAsset.name,
@@ -432,7 +1293,12 @@ export async function ensureReleaseInstall(
432
1293
  "Downloading standalone binaries",
433
1294
  selected.archiveAsset.name,
434
1295
  );
435
- await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath);
1296
+ await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath, {
1297
+ runProcessImpl,
1298
+ onStatus,
1299
+ verbose: Boolean(options.verbose),
1300
+ stage: "Release archive download",
1301
+ });
436
1302
  }
437
1303
 
438
1304
  const actualSha256 = await sha256File(paths.archivePath);
@@ -461,20 +1327,14 @@ export async function ensureReleaseInstall(
461
1327
  fs.chmod(paths.pylonTuiPath, 0o755),
462
1328
  ]);
463
1329
 
464
- await fs.writeFile(
465
- paths.manifestPath,
466
- `${JSON.stringify(
467
- {
468
- version: selected.version,
469
- tagName: selected.tagName,
470
- target,
471
- archive: selected.archiveAsset.name,
472
- sha256: expectedSha256,
473
- },
474
- null,
475
- 2,
476
- )}\n`,
477
- );
1330
+ await writeInstallManifest(paths.manifestPath, {
1331
+ version: selected.version,
1332
+ tagName: selected.tagName,
1333
+ target,
1334
+ archive: selected.archiveAsset.name,
1335
+ sha256: expectedSha256,
1336
+ installMethod: RELEASE_ASSET_INSTALL_METHOD,
1337
+ });
478
1338
 
479
1339
  emitStatus(
480
1340
  onStatus,
@@ -535,7 +1395,11 @@ export async function bootstrapInstalledPylon(
535
1395
  runProcessImpl,
536
1396
  );
537
1397
  } else {
538
- emitStatus(onStatus, "Skipping curated model download", model);
1398
+ emitStatus(
1399
+ onStatus,
1400
+ "Skipping optional curated GGUF cache",
1401
+ "use --download-curated-cache to prefetch Hugging Face weights",
1402
+ );
539
1403
  }
540
1404
 
541
1405
  let diagnostic = null;
@@ -620,14 +1484,99 @@ export async function launchInstalledPylonTui(
620
1484
  });
621
1485
  }
622
1486
 
1487
+ export function resolveBootstrapOutcome(summary) {
1488
+ const runtimeState =
1489
+ summary.status?.snapshot?.runtime?.authoritative_status ?? "unknown";
1490
+ const localGemma = summary.status?.snapshot?.availability?.local_gemma ?? {};
1491
+ const readyModel = localGemma.ready_model ?? null;
1492
+ const localGemmaError = localGemma.last_error ?? null;
1493
+ const diagnosticStatus = summary.diagnosticResult?.status ?? null;
1494
+
1495
+ if (runtimeState === "online") {
1496
+ return {
1497
+ level: "success",
1498
+ verdict: "fully online",
1499
+ detail: readyModel
1500
+ ? `loaded runtime model ${readyModel}`
1501
+ : "eligible local Gemma supply is online",
1502
+ };
1503
+ }
1504
+
1505
+ if (readyModel) {
1506
+ return {
1507
+ level: "success",
1508
+ verdict: "runtime ready",
1509
+ detail: `loaded runtime model ${readyModel}`,
1510
+ };
1511
+ }
1512
+
1513
+ const loweredError = (localGemmaError ?? "").toLowerCase();
1514
+ if (loweredError.includes("/api/tags") || loweredError.includes("not reachable")) {
1515
+ return {
1516
+ level: "warning",
1517
+ verdict: "installed but runtime missing",
1518
+ detail:
1519
+ "no Ollama-compatible local runtime is answering /api/tags yet",
1520
+ };
1521
+ }
1522
+
1523
+ if (
1524
+ diagnosticStatus &&
1525
+ diagnosticStatus !== "completed" &&
1526
+ diagnosticStatus !== "passed" &&
1527
+ diagnosticStatus !== "healthy"
1528
+ ) {
1529
+ return {
1530
+ level: "warning",
1531
+ verdict: "installed but runtime not yet usable",
1532
+ detail: diagnosticStatus,
1533
+ };
1534
+ }
1535
+
1536
+ return {
1537
+ level: "warning",
1538
+ verdict: "installed",
1539
+ detail: "complete the local runtime setup before bringing the node online",
1540
+ };
1541
+ }
1542
+
1543
+ function renderBootstrapNextSteps(summary, outcome) {
1544
+ const lines = [
1545
+ "Launcher path: use the same npx/bunx command again, or install globally and run `pylon`.",
1546
+ ];
1547
+
1548
+ if (outcome.verdict === "fully online" || outcome.verdict === "runtime ready") {
1549
+ lines.push("Next step: open the TUI with `pylon`, or keep using the package-managed launcher.");
1550
+ return lines;
1551
+ }
1552
+
1553
+ if (summary.target?.os === "darwin") {
1554
+ lines.push(
1555
+ "Runtime setup (macOS default): `brew install ollama`, `brew services start ollama`, `ollama pull gemma4:e4b`.",
1556
+ );
1557
+ } else {
1558
+ lines.push(
1559
+ "Runtime setup: start an Ollama-compatible local runtime at `local_gemma_base_url` and load `gemma4:e4b`.",
1560
+ );
1561
+ }
1562
+ lines.push(
1563
+ "Persistent PATH command: `npm install -g @openagentsinc/pylon` or `bun install -g @openagentsinc/pylon`, then run `pylon`.",
1564
+ );
1565
+ return lines;
1566
+ }
1567
+
623
1568
  export function renderBootstrapSummary(summary) {
1569
+ const outcome = resolveBootstrapOutcome(summary);
624
1570
  const lines = [
1571
+ `Onboarding verdict: ${outcome.verdict}`,
1572
+ `Verdict detail: ${outcome.detail}`,
625
1573
  `Pylon release: ${summary.version} (${summary.target.os}-${summary.target.arch})`,
626
1574
  `Archive source: ${summary.tagName}`,
627
1575
  `Installed from cache: ${summary.cached ? "yes" : "no"}`,
628
1576
  `Pylon binary: ${summary.binaries.pylon}`,
629
1577
  `Pylon TUI: ${summary.binaries.pylonTui}`,
630
1578
  `Config path: ${summary.configPath ?? "unknown"}`,
1579
+ `Preferred runtime model name: ${PREFERRED_RUNTIME_MODEL_NAME}`,
631
1580
  ];
632
1581
 
633
1582
  const statusState =
@@ -646,6 +1595,16 @@ export function renderBootstrapSummary(summary) {
646
1595
  lines.push(
647
1596
  `Model download (${summary.model}): ${result?.status ?? "completed"}`,
648
1597
  );
1598
+ } else {
1599
+ lines.push(
1600
+ "Curated GGUF cache: skipped by default (pass --download-curated-cache to prefetch optional Hugging Face weights)",
1601
+ );
1602
+ }
1603
+
1604
+ const localGemmaError =
1605
+ summary.status?.snapshot?.availability?.local_gemma?.last_error ?? null;
1606
+ if (localGemmaError) {
1607
+ lines.push(`Local runtime note: ${localGemmaError}`);
649
1608
  }
650
1609
 
651
1610
  if (summary.diagnostic) {
@@ -676,5 +1635,7 @@ export function renderBootstrapSummary(summary) {
676
1635
  }
677
1636
  }
678
1637
 
1638
+ lines.push(...renderBootstrapNextSteps(summary, outcome));
1639
+
679
1640
  return lines.join("\n");
680
1641
  }