@openagentsinc/pylon 0.1.2 → 0.1.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.
package/README.md CHANGED
@@ -10,7 +10,10 @@ available.
10
10
 
11
11
  ```bash
12
12
  npx @openagentsinc/pylon
13
- npx @openagentsinc/pylon --version 0.0.1-rc4
13
+ bunx @openagentsinc/pylon
14
+ npm install -g @openagentsinc/pylon && pylon
15
+ bun install -g @openagentsinc/pylon && pylon
16
+ npx @openagentsinc/pylon --version 0.0.1-rc10
14
17
  npx @openagentsinc/pylon --no-launch
15
18
  npx @openagentsinc/pylon --download-curated-cache --model gemma-4-e2b --diagnostic-repeats 2
16
19
  npx @openagentsinc/pylon --verbose
@@ -18,19 +21,29 @@ npx @openagentsinc/pylon --verbose
18
21
 
19
22
  The launcher:
20
23
 
21
- - resolves the latest tagged `pylon-v...` release by default, or a specific
22
- tagged `Pylon` version when `--version` is provided
24
+ - supports direct `npx` / `bunx` execution plus global `npm install -g` /
25
+ `bun install -g` installs with the same `pylon` command
26
+ - checks GitHub for the latest tagged `pylon-v...` release on each default run,
27
+ or resolves a specific tagged `Pylon` version when `--version` is provided
23
28
  - resolves the correct `pylon-v<version>-<os>-<arch>.tar.gz` asset for the
24
29
  current machine
25
30
  - falls back to the exact tagged source checkout and builds `pylon` plus
26
31
  `pylon-tui` locally when no matching release asset exists for the machine
27
32
  - prompts before installing the Rust toolchain via `rustup` if a source build
28
33
  is needed and `cargo` / `rustc` are missing
34
+ - emits best-effort anonymous installer telemetry to `openagents.com` so the
35
+ public stats page can show install starts, completions, source-build fallbacks,
36
+ Rust prompts, and smoke-test outcomes
29
37
  - downloads the archive and published SHA-256 checksum
30
38
  - verifies the checksum before extracting
31
39
  - caches the unpacked binaries under `~/.openagents/pylon/bootstrap/`
40
+ - never links or copies those cached standalone binaries into a shared global
41
+ bin directory, so the package-managed `pylon` launcher remains the command on
42
+ `PATH`
32
43
  - prints status lines such as release resolution, runtime checks, and local
33
44
  model scanning while it runs
45
+ - ends first run with an explicit verdict such as `fully online`, `runtime
46
+ ready`, or `installed but runtime missing`, plus exact next-step guidance
34
47
  - runs `pylon --help`, `init`, `status --json`, and `inventory --json`
35
48
  - runs `pylon gemma diagnose <model> --json`
36
49
  - only runs `pylon gemma download <model>` when `--download-curated-cache` is
@@ -39,6 +52,13 @@ The launcher:
39
52
  - falls back to `curl` for release metadata and asset downloads when the Node
40
53
  fetch path fails in constrained network contexts
41
54
  - opens `pylon-tui` by default after the smoke path unless `--no-launch` is set
55
+ - does not try to install or register a local runtime automatically; the
56
+ bootstrap stays honest about the separate Ollama-compatible runtime
57
+ prerequisite instead of mutating the host behind the user's back
58
+
59
+ Set `OPENAGENTS_DISABLE_TELEMETRY=1` to disable installer telemetry, or
60
+ `OPENAGENTS_TELEMETRY_URL=http://127.0.0.1:8000/api/telemetry/events` to point
61
+ the launcher at a non-production telemetry endpoint.
42
62
 
43
63
  ## Publish
44
64
 
@@ -46,5 +66,6 @@ Publish directly from this package directory:
46
66
 
47
67
  ```bash
48
68
  cd packages/pylon-bootstrap
69
+ npm pack --dry-run
49
70
  npm publish
50
71
  ```
package/bin/pylon ADDED
@@ -0,0 +1,26 @@
1
+ #!/bin/sh
2
+
3
+ set -eu
4
+
5
+ SCRIPT_PATH="$0"
6
+ while [ -L "$SCRIPT_PATH" ]; do
7
+ LINK_TARGET=$(readlink "$SCRIPT_PATH")
8
+ case "$LINK_TARGET" in
9
+ /*) SCRIPT_PATH="$LINK_TARGET" ;;
10
+ *) SCRIPT_PATH="$(dirname -- "$SCRIPT_PATH")/$LINK_TARGET" ;;
11
+ esac
12
+ done
13
+
14
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)
15
+ LAUNCHER_JS="${SCRIPT_DIR}/pylon.js"
16
+
17
+ if command -v node >/dev/null 2>&1; then
18
+ exec node "$LAUNCHER_JS" "$@"
19
+ fi
20
+
21
+ if command -v bun >/dev/null 2>&1; then
22
+ exec bun "$LAUNCHER_JS" "$@"
23
+ fi
24
+
25
+ echo "pylon launcher requires Node.js or Bun on PATH." >&2
26
+ exit 1
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@openagentsinc/pylon",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Bootstrap the standalone OpenAgents Pylon release asset and run first-run smoke checks.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "pylon": "./bin/pylon.js"
7
+ "pylon": "./bin/pylon"
8
8
  },
9
9
  "files": [
10
10
  "bin",
package/src/cli.js CHANGED
@@ -7,8 +7,16 @@ import {
7
7
  bootstrapInstalledPylon,
8
8
  ensureReleaseInstall,
9
9
  launchInstalledPylonTui,
10
+ resolveBootstrapOutcome,
11
+ resolvePlatformTarget,
10
12
  renderBootstrapSummary,
11
13
  } from "./index.js";
14
+ import {
15
+ createTelemetryClient,
16
+ detectPackageInvoker,
17
+ installSourceForTelemetry,
18
+ telemetryFailureContext,
19
+ } from "./telemetry.js";
12
20
 
13
21
  function parseIntegerFlag(value, label) {
14
22
  const parsed = Number.parseInt(value, 10);
@@ -67,6 +75,8 @@ function createReporter({ enableColor = process.stdout.isTTY && !process.env.NO_
67
75
  export function usage() {
68
76
  return `Usage:
69
77
  npx @openagentsinc/pylon [options]
78
+ bunx @openagentsinc/pylon [options]
79
+ pylon [options]
70
80
 
71
81
  Description:
72
82
  Download the latest tagged standalone Pylon release asset for this machine,
@@ -74,7 +84,9 @@ Description:
74
84
  asset exists for the local platform, fetch the exact tagged source checkout
75
85
  and build it locally instead. Cache the binaries, run the first-run smoke
76
86
  path, and then open the Pylon terminal UI by default with live status
77
- updates.
87
+ updates. The launcher checks GitHub for newer tagged pylon-v... releases on
88
+ each default run, but only caches the standalone binaries under the local
89
+ bootstrap root; it does not replace your global npm or bun pylon command.
78
90
 
79
91
  Options:
80
92
  --version <x.y.z> Resolve a specific Pylon release.
@@ -216,6 +228,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
216
228
  ensureReleaseInstallImpl = ensureReleaseInstall,
217
229
  bootstrapInstalledPylonImpl = bootstrapInstalledPylon,
218
230
  launchInstalledPylonTuiImpl = launchInstalledPylonTui,
231
+ createTelemetryClientImpl = createTelemetryClient,
219
232
  } = dependencies;
220
233
  const options = parseArgs(argv);
221
234
  if (options.help) {
@@ -224,46 +237,109 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
224
237
  }
225
238
 
226
239
  const reporter = options.json ? null : createReporter();
240
+ const startedAt = Date.now();
241
+ const target = (() => {
242
+ try {
243
+ return resolvePlatformTarget(options.platform, options.arch);
244
+ } catch {
245
+ return {
246
+ os: process.platform,
247
+ arch: process.arch,
248
+ };
249
+ }
250
+ })();
251
+ const telemetryClient =
252
+ dependencies.telemetryClient ??
253
+ createTelemetryClientImpl({
254
+ fetchImpl: dependencies.fetchImpl ?? globalThis.fetch,
255
+ });
256
+ const sharedTelemetry = {
257
+ requested_version: options.version ?? "latest",
258
+ os: target.os,
259
+ arch: target.arch,
260
+ platform_key: `${target.os}-${target.arch}`,
261
+ npm_or_bun_invoker: detectPackageInvoker(),
262
+ };
227
263
 
228
- const install = await ensureReleaseInstallImpl(options, {
229
- ...dependencies,
230
- onStatus: reporter?.status,
231
- });
232
- const summary = await bootstrapInstalledPylonImpl(
233
- {
234
- ...options,
235
- ...install,
236
- version: install.version,
237
- },
238
- {
264
+ telemetryClient?.emit?.("installer_started", sharedTelemetry);
265
+
266
+ let install = null;
267
+
268
+ try {
269
+ install = await ensureReleaseInstallImpl(options, {
239
270
  ...dependencies,
240
271
  onStatus: reporter?.status,
241
- },
242
- );
272
+ telemetryClient,
273
+ });
274
+ const summary = await bootstrapInstalledPylonImpl(
275
+ {
276
+ ...options,
277
+ ...install,
278
+ version: install.version,
279
+ },
280
+ {
281
+ ...dependencies,
282
+ onStatus: reporter?.status,
283
+ telemetryClient,
284
+ },
285
+ );
286
+
287
+ telemetryClient?.emit?.("installer_finished", {
288
+ ...sharedTelemetry,
289
+ release_tag: summary.tagName,
290
+ release_commit: install.sourceCommit ?? null,
291
+ duration_ms: Date.now() - startedAt,
292
+ result: "success",
293
+ install_source: installSourceForTelemetry(
294
+ summary.installMethod ?? install.installMethod,
295
+ Boolean(summary.cached),
296
+ ),
297
+ });
298
+ await telemetryClient?.flush?.();
243
299
 
244
- if (options.json) {
245
- console.log(JSON.stringify(summary, null, 2));
246
- } else {
247
- reporter?.success("Pylon bootstrap complete");
248
- console.log(renderBootstrapSummary(summary));
249
- if (!options.noLaunch) {
250
- await launchInstalledPylonTuiImpl(
251
- {
252
- ...options,
253
- ...install,
254
- version: install.version,
255
- },
256
- {
257
- ...dependencies,
258
- onStatus: reporter?.status,
259
- },
260
- );
300
+ if (options.json) {
301
+ console.log(JSON.stringify(summary, null, 2));
261
302
  } else {
262
- reporter?.warning(
263
- "Skipped Pylon terminal UI launch",
264
- "pass no flag to open pylon-tui by default",
265
- );
303
+ const outcome = resolveBootstrapOutcome(summary);
304
+ if (outcome.level === "success") {
305
+ reporter?.success(`Pylon ${outcome.verdict}`, outcome.detail);
306
+ } else {
307
+ reporter?.warning(`Pylon ${outcome.verdict}`, outcome.detail);
308
+ }
309
+ console.log(renderBootstrapSummary(summary));
310
+ if (!options.noLaunch) {
311
+ await launchInstalledPylonTuiImpl(
312
+ {
313
+ ...options,
314
+ ...install,
315
+ version: install.version,
316
+ },
317
+ {
318
+ ...dependencies,
319
+ onStatus: reporter?.status,
320
+ },
321
+ );
322
+ } else {
323
+ reporter?.warning(
324
+ "Skipped Pylon terminal UI launch",
325
+ "pass no flag to open pylon-tui by default",
326
+ );
327
+ }
266
328
  }
329
+ return summary;
330
+ } catch (error) {
331
+ telemetryClient?.emit?.("installer_finished", {
332
+ ...sharedTelemetry,
333
+ release_tag: install?.tagName ?? null,
334
+ release_commit: install?.sourceCommit ?? null,
335
+ duration_ms: Date.now() - startedAt,
336
+ result: "failed",
337
+ install_source: install
338
+ ? installSourceForTelemetry(install.installMethod, Boolean(install.cached))
339
+ : null,
340
+ ...telemetryFailureContext(error, "launcher"),
341
+ });
342
+ await telemetryClient?.flush?.();
343
+ throw error;
267
344
  }
268
- return summary;
269
345
  }
package/src/index.js CHANGED
@@ -5,6 +5,12 @@ import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import readline from "node:readline/promises";
8
+ import {
9
+ installSourceForTelemetry,
10
+ telemetryFailureContext,
11
+ } from "./telemetry.js";
12
+
13
+ export { createTelemetryClient } from "./telemetry.js";
8
14
 
9
15
  export const DEFAULT_RELEASE_REPO = "OpenAgentsInc/openagents";
10
16
  export const DEFAULT_RELEASE_API_BASE = "https://api.github.com";
@@ -17,6 +23,7 @@ export const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
17
23
  const PYLON_RELEASE_TAG_PREFIX = "pylon-v";
18
24
  const RELEASE_ASSET_INSTALL_METHOD = "release_asset";
19
25
  const SOURCE_BUILD_INSTALL_METHOD = "source_build";
26
+ const PREFERRED_RUNTIME_MODEL_NAME = "gemma4:e4b";
20
27
 
21
28
  function emitStatus(onStatus, message, detail = null) {
22
29
  if (typeof onStatus === "function") {
@@ -30,10 +37,82 @@ function emitVerboseStatus(onStatus, verbose, message, detail = null) {
30
37
  }
31
38
  }
32
39
 
40
+ function emitTelemetry(telemetryClient, eventName, properties = {}) {
41
+ if (typeof telemetryClient?.emit === "function") {
42
+ void telemetryClient.emit(eventName, properties);
43
+ }
44
+ }
45
+
33
46
  function normalizeVersion(value) {
34
47
  return value.replace(/^pylon-v/, "").replace(/^v/, "");
35
48
  }
36
49
 
50
+ function parseComparableVersion(value) {
51
+ const normalized = normalizeVersion(value).trim();
52
+ const match = normalized.match(
53
+ /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+?)(\d+)?)?$/,
54
+ );
55
+ if (!match) {
56
+ return null;
57
+ }
58
+
59
+ return {
60
+ normalized,
61
+ major: Number.parseInt(match[1], 10),
62
+ minor: Number.parseInt(match[2], 10),
63
+ patch: Number.parseInt(match[3], 10),
64
+ prereleaseLabel: match[4] ?? null,
65
+ prereleaseNumber:
66
+ match[5] != null ? Number.parseInt(match[5], 10) : null,
67
+ };
68
+ }
69
+
70
+ function comparePylonReleaseTags(leftTagName, rightTagName) {
71
+ const left = parseComparableVersion(leftTagName);
72
+ const right = parseComparableVersion(rightTagName);
73
+
74
+ if (!left && !right) {
75
+ return String(leftTagName).localeCompare(String(rightTagName));
76
+ }
77
+ if (!left) {
78
+ return -1;
79
+ }
80
+ if (!right) {
81
+ return 1;
82
+ }
83
+
84
+ for (const key of ["major", "minor", "patch"]) {
85
+ if (left[key] !== right[key]) {
86
+ return left[key] > right[key] ? 1 : -1;
87
+ }
88
+ }
89
+
90
+ if (left.prereleaseLabel == null && right.prereleaseLabel == null) {
91
+ return 0;
92
+ }
93
+ if (left.prereleaseLabel == null) {
94
+ return 1;
95
+ }
96
+ if (right.prereleaseLabel == null) {
97
+ return -1;
98
+ }
99
+
100
+ const labelComparison = left.prereleaseLabel.localeCompare(
101
+ right.prereleaseLabel,
102
+ );
103
+ if (labelComparison !== 0) {
104
+ return labelComparison;
105
+ }
106
+
107
+ const leftNumber = left.prereleaseNumber ?? 0;
108
+ const rightNumber = right.prereleaseNumber ?? 0;
109
+ if (leftNumber !== rightNumber) {
110
+ return leftNumber > rightNumber ? 1 : -1;
111
+ }
112
+
113
+ return left.normalized.localeCompare(right.normalized);
114
+ }
115
+
37
116
  function createBootstrapError(message, context = {}) {
38
117
  const error = new Error(message);
39
118
  Object.assign(error, context);
@@ -496,14 +575,31 @@ export function isPylonReleaseTag(tagName) {
496
575
  );
497
576
  }
498
577
 
499
- export function selectLatestPylonRelease(releases) {
578
+ function releaseHasTargetAssets(release, target) {
579
+ if (!target || !release?.tag_name) {
580
+ return false;
581
+ }
582
+
583
+ const { archiveName, checksumName } = buildAssetNames(release.tag_name, target);
584
+ const assetNames = new Set(
585
+ (Array.isArray(release.assets) ? release.assets : [])
586
+ .map((asset) => asset?.name)
587
+ .filter(Boolean),
588
+ );
589
+ return assetNames.has(archiveName) && assetNames.has(checksumName);
590
+ }
591
+
592
+ export function selectLatestPylonRelease(releases, target = null) {
500
593
  if (!Array.isArray(releases)) {
501
594
  throw new Error("GitHub release lookup did not return a release list.");
502
595
  }
503
596
 
504
- const release = releases.find(
505
- (candidate) => !candidate?.draft && isPylonReleaseTag(candidate?.tag_name),
506
- );
597
+ const candidates = releases
598
+ .filter((candidate) => !candidate?.draft && isPylonReleaseTag(candidate?.tag_name))
599
+ .sort((left, right) => comparePylonReleaseTags(right.tag_name, left.tag_name));
600
+ const release =
601
+ candidates.find((candidate) => releaseHasTargetAssets(candidate, target)) ??
602
+ candidates[0];
507
603
  if (!release) {
508
604
  throw new Error(
509
605
  `GitHub release lookup did not find any published ${PYLON_RELEASE_TAG_PREFIX} releases.`,
@@ -521,6 +617,7 @@ export async function fetchReleaseMetadata({
521
617
  apiBase = DEFAULT_RELEASE_API_BASE,
522
618
  repo = DEFAULT_RELEASE_REPO,
523
619
  version = null,
620
+ target = null,
524
621
  } = {}) {
525
622
  const normalizedVersion = normalizeRequestedVersion(version);
526
623
  const endpoint = normalizedVersion
@@ -537,7 +634,7 @@ export async function fetchReleaseMetadata({
537
634
  ? "GitHub tagged release lookup"
538
635
  : "GitHub release list lookup",
539
636
  });
540
- return normalizedVersion ? payload : selectLatestPylonRelease(payload);
637
+ return normalizedVersion ? payload : selectLatestPylonRelease(payload, target);
541
638
  }
542
639
 
543
640
  export function selectReleaseAssets(release, target) {
@@ -712,6 +809,7 @@ async function ensureRustToolchain({
712
809
  fetchImpl,
713
810
  runProcessImpl,
714
811
  onStatus,
812
+ telemetryClient,
715
813
  promptImpl = promptForApproval,
716
814
  commandExistsImpl = commandExists,
717
815
  env = process.env,
@@ -724,44 +822,70 @@ async function ensureRustToolchain({
724
822
  return toolchainEnv;
725
823
  }
726
824
 
825
+ emitTelemetry(telemetryClient, "installer_rust_missing", {
826
+ os: target.os,
827
+ arch: target.arch,
828
+ });
829
+
727
830
  emitStatus(
728
831
  onStatus,
729
832
  "Rust toolchain required for source build",
730
833
  `${target.os}-${target.arch}`,
731
834
  );
732
835
 
836
+ emitTelemetry(telemetryClient, "installer_rust_install_prompt_shown", {
837
+ os: target.os,
838
+ arch: target.arch,
839
+ });
733
840
  const approved = await promptImpl(
734
841
  `Rust is required to build Pylon from source for ${target.os}-${target.arch}. Install the official Rust toolchain now via rustup?`,
735
842
  );
736
843
  if (!approved) {
844
+ emitTelemetry(telemetryClient, "installer_rust_install_declined", {
845
+ os: target.os,
846
+ arch: target.arch,
847
+ });
737
848
  throw new Error(
738
849
  `Rust is required to build Pylon from source.\nInstall it manually and rerun:\n${rustInstallCommand()}`,
739
850
  );
740
851
  }
741
852
 
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",
853
+ emitTelemetry(telemetryClient, "installer_rust_install_approved", {
854
+ os: target.os,
855
+ arch: target.arch,
751
856
  });
752
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-rustup-"));
753
- const scriptPath = path.join(tempDir, "rustup-init.sh");
754
857
 
858
+ emitStatus(onStatus, "Installing Rust toolchain", "official rustup installer");
755
859
  try {
860
+ const scriptPayload = await fetchText(fetchImpl, rustupInitUrl, {
861
+ headers: {
862
+ accept: "text/plain",
863
+ "user-agent": "@openagentsinc/pylon bootstrap",
864
+ },
865
+ runProcessImpl,
866
+ onStatus,
867
+ stage: "Rust toolchain installer download",
868
+ });
869
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-rustup-"));
870
+ const scriptPath = path.join(tempDir, "rustup-init.sh");
756
871
  await fs.writeFile(scriptPath, scriptPayload);
757
872
  await fs.chmod(scriptPath, 0o755);
758
- await runProcessImpl("sh", [scriptPath, "-y"], {
759
- cwd: tempDir,
760
- env: toolchainEnv,
761
- stdio: "inherit",
873
+ try {
874
+ await runProcessImpl("sh", [scriptPath, "-y"], {
875
+ cwd: tempDir,
876
+ env: toolchainEnv,
877
+ stdio: "inherit",
878
+ });
879
+ } finally {
880
+ await fs.rm(tempDir, { recursive: true, force: true });
881
+ }
882
+ } catch (error) {
883
+ emitTelemetry(telemetryClient, "installer_rust_install_failed", {
884
+ os: target.os,
885
+ arch: target.arch,
886
+ ...telemetryFailureContext(error, "rust_install"),
762
887
  });
763
- } finally {
764
- await fs.rm(tempDir, { recursive: true, force: true });
888
+ throw error;
765
889
  }
766
890
 
767
891
  toolchainEnv = withPrependedPath(env, path.join(os.homedir(), ".cargo", "bin"));
@@ -778,6 +902,10 @@ async function ensureRustToolchain({
778
902
  "Rust toolchain installed",
779
903
  path.join(os.homedir(), ".cargo", "bin"),
780
904
  );
905
+ emitTelemetry(telemetryClient, "installer_rust_install_completed", {
906
+ os: target.os,
907
+ arch: target.arch,
908
+ });
781
909
  return toolchainEnv;
782
910
  }
783
911
 
@@ -792,6 +920,7 @@ async function installSourceBuild(
792
920
  fetchImpl,
793
921
  runProcessImpl,
794
922
  onStatus,
923
+ telemetryClient,
795
924
  promptImpl = promptForApproval,
796
925
  commandExistsImpl = commandExists,
797
926
  },
@@ -811,6 +940,13 @@ async function installSourceBuild(
811
940
  "Prebuilt asset missing; falling back to source build",
812
941
  `${selected.tagName} for ${target.os}-${target.arch}`,
813
942
  );
943
+ emitTelemetry(telemetryClient, "installer_prebuilt_asset_missing", {
944
+ release_tag: selected.tagName,
945
+ release_commit: selected.targetCommitish ?? null,
946
+ os: target.os,
947
+ arch: target.arch,
948
+ platform_key: `${target.os}-${target.arch}`,
949
+ });
814
950
 
815
951
  if (!(await commandExistsImpl("git", process.env))) {
816
952
  throw new Error(
@@ -823,6 +959,7 @@ async function installSourceBuild(
823
959
  fetchImpl,
824
960
  runProcessImpl,
825
961
  onStatus,
962
+ telemetryClient,
826
963
  promptImpl,
827
964
  commandExistsImpl,
828
965
  });
@@ -896,6 +1033,13 @@ async function installSourceBuild(
896
1033
  "Building Pylon from source",
897
1034
  `${selected.tagName} (${sourceCommit.slice(0, 12)})`,
898
1035
  );
1036
+ emitTelemetry(telemetryClient, "installer_source_build_started", {
1037
+ release_tag: selected.tagName,
1038
+ release_commit: sourceCommit,
1039
+ os: target.os,
1040
+ arch: target.arch,
1041
+ platform_key: `${target.os}-${target.arch}`,
1042
+ });
899
1043
  await runProcessImpl(buildCommand[0], buildCommand.slice(1), {
900
1044
  cwd: repoDir,
901
1045
  env: buildEnv,
@@ -937,6 +1081,17 @@ async function installSourceBuild(
937
1081
  "Installed source-built binaries",
938
1082
  `${selected.tagName} for ${target.os}-${target.arch}`,
939
1083
  );
1084
+ emitTelemetry(telemetryClient, "installer_source_build_completed", {
1085
+ release_tag: selected.tagName,
1086
+ release_commit: sourceCommit,
1087
+ os: target.os,
1088
+ arch: target.arch,
1089
+ platform_key: `${target.os}-${target.arch}`,
1090
+ install_source: installSourceForTelemetry(
1091
+ SOURCE_BUILD_INSTALL_METHOD,
1092
+ false,
1093
+ ),
1094
+ });
940
1095
 
941
1096
  return {
942
1097
  ...selected,
@@ -949,6 +1104,14 @@ async function installSourceBuild(
949
1104
  sourceCommit,
950
1105
  };
951
1106
  } catch (error) {
1107
+ emitTelemetry(telemetryClient, "installer_source_build_failed", {
1108
+ release_tag: selected.tagName,
1109
+ release_commit: selected.targetCommitish ?? null,
1110
+ os: target.os,
1111
+ arch: target.arch,
1112
+ platform_key: `${target.os}-${target.arch}`,
1113
+ ...telemetryFailureContext(error, "source_build"),
1114
+ });
952
1115
  const message = error instanceof Error ? error.message : String(error);
953
1116
  throw new Error(
954
1117
  `${message}\nManual source-build fallback:\n${manualBuildInstructions}`,
@@ -1012,6 +1175,9 @@ async function findLatestCachedInstall(installRoot, target) {
1012
1175
  pylonTuiPath,
1013
1176
  expectedSha256: manifest.sha256 ?? null,
1014
1177
  cached: true,
1178
+ installMethod: manifest.installMethod ?? RELEASE_ASSET_INSTALL_METHOD,
1179
+ sourceCloneUrl: manifest.sourceCloneUrl ?? null,
1180
+ sourceCommit: manifest.sourceCommit ?? null,
1015
1181
  mtimeMs: manifestStat.mtimeMs,
1016
1182
  });
1017
1183
  } catch {
@@ -1123,6 +1289,7 @@ export async function ensureReleaseInstall(
1123
1289
  fetchImpl = globalThis.fetch,
1124
1290
  runProcessImpl = runProcess,
1125
1291
  onStatus = null,
1292
+ telemetryClient = null,
1126
1293
  promptImpl = promptForApproval,
1127
1294
  commandExistsImpl = commandExists,
1128
1295
  } = {},
@@ -1133,32 +1300,45 @@ export async function ensureReleaseInstall(
1133
1300
 
1134
1301
  emitStatus(
1135
1302
  onStatus,
1136
- "Resolving latest tagged Pylon release",
1303
+ "Checking for newer tagged Pylon releases",
1137
1304
  options.version ? `requested ${options.version}` : "default release track",
1138
1305
  );
1139
1306
  const target = resolvePlatformTarget(options.platform, options.arch);
1140
1307
  const installRoot = options.installRoot ?? defaultInstallRoot();
1141
1308
  if (options.version) {
1142
1309
  const requestedPaths = buildInstallPaths(installRoot, options.version, target);
1310
+ const requestedManifest = await readInstallManifest(requestedPaths.manifestPath);
1143
1311
  const requestedCached =
1144
1312
  (await pathExists(requestedPaths.pylonPath)) &&
1145
1313
  (await pathExists(requestedPaths.pylonTuiPath));
1146
1314
  if (requestedCached) {
1315
+ const installMethod =
1316
+ requestedManifest?.installMethod ?? RELEASE_ASSET_INSTALL_METHOD;
1147
1317
  emitStatus(
1148
1318
  onStatus,
1149
- "Using cached standalone binaries",
1319
+ installMethod === SOURCE_BUILD_INSTALL_METHOD
1320
+ ? "Using cached source-built binaries"
1321
+ : "Using cached standalone binaries",
1150
1322
  `pylon-v${normalizeVersion(options.version)} for ${target.os}-${target.arch}`,
1151
1323
  );
1324
+ emitTelemetry(telemetryClient, "installer_cached_install_reused", {
1325
+ release_tag: `pylon-v${normalizeVersion(options.version)}`,
1326
+ release_commit: requestedManifest?.sourceCommit ?? null,
1327
+ os: target.os,
1328
+ arch: target.arch,
1329
+ platform_key: `${target.os}-${target.arch}`,
1330
+ install_source: installSourceForTelemetry(installMethod, true),
1331
+ });
1152
1332
  return {
1153
1333
  version: normalizeVersion(options.version),
1154
1334
  tagName: `pylon-v${normalizeVersion(options.version)}`,
1155
1335
  target,
1156
1336
  ...requestedPaths,
1157
- expectedSha256: await fs
1158
- .readFile(requestedPaths.manifestPath, "utf8")
1159
- .then((payload) => JSON.parse(payload).sha256)
1160
- .catch(() => null),
1337
+ expectedSha256: requestedManifest?.sha256 ?? null,
1161
1338
  cached: true,
1339
+ installMethod,
1340
+ sourceCloneUrl: requestedManifest?.sourceCloneUrl ?? null,
1341
+ sourceCommit: requestedManifest?.sourceCommit ?? null,
1162
1342
  };
1163
1343
  }
1164
1344
  }
@@ -1173,6 +1353,14 @@ export async function ensureReleaseInstall(
1173
1353
  apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
1174
1354
  repo: options.repo ?? DEFAULT_RELEASE_REPO,
1175
1355
  version: options.version ?? null,
1356
+ target,
1357
+ });
1358
+ emitTelemetry(telemetryClient, "installer_release_resolved", {
1359
+ release_tag: release?.tag_name ?? null,
1360
+ release_commit: release?.target_commitish ?? null,
1361
+ os: target.os,
1362
+ arch: target.arch,
1363
+ platform_key: `${target.os}-${target.arch}`,
1176
1364
  });
1177
1365
  } catch (error) {
1178
1366
  const cached = !options.version
@@ -1181,9 +1369,19 @@ export async function ensureReleaseInstall(
1181
1369
  if (cached) {
1182
1370
  emitStatus(
1183
1371
  onStatus,
1184
- "Using cached standalone binaries",
1372
+ cached.installMethod === SOURCE_BUILD_INSTALL_METHOD
1373
+ ? "Using cached source-built binaries"
1374
+ : "Using cached standalone binaries",
1185
1375
  `release lookup failed; falling back to ${cached.tagName}`,
1186
1376
  );
1377
+ emitTelemetry(telemetryClient, "installer_cached_install_reused", {
1378
+ release_tag: cached.tagName,
1379
+ release_commit: cached.sourceCommit ?? null,
1380
+ os: target.os,
1381
+ arch: target.arch,
1382
+ platform_key: `${target.os}-${target.arch}`,
1383
+ install_source: installSourceForTelemetry(cached.installMethod, true),
1384
+ });
1187
1385
  return cached;
1188
1386
  }
1189
1387
 
@@ -1204,6 +1402,14 @@ export async function ensureReleaseInstall(
1204
1402
  let missingAssetsError = null;
1205
1403
  try {
1206
1404
  selected = selectReleaseAssets(release, target);
1405
+ emitTelemetry(telemetryClient, "installer_prebuilt_asset_found", {
1406
+ release_tag: selected.tagName,
1407
+ release_commit: release?.target_commitish ?? null,
1408
+ asset_name: selected.archiveAsset.name,
1409
+ os: target.os,
1410
+ arch: target.arch,
1411
+ platform_key: `${target.os}-${target.arch}`,
1412
+ });
1207
1413
  } catch (error) {
1208
1414
  if (!(error instanceof MissingReleaseAssetsError)) {
1209
1415
  throw error;
@@ -1234,6 +1440,14 @@ export async function ensureReleaseInstall(
1234
1440
  : "Using cached standalone binaries",
1235
1441
  `${selected.tagName} for ${target.os}-${target.arch}`,
1236
1442
  );
1443
+ emitTelemetry(telemetryClient, "installer_cached_install_reused", {
1444
+ release_tag: selected.tagName,
1445
+ release_commit: manifest?.sourceCommit ?? release?.target_commitish ?? null,
1446
+ os: target.os,
1447
+ arch: target.arch,
1448
+ platform_key: `${target.os}-${target.arch}`,
1449
+ install_source: installSourceForTelemetry(installMethod, true),
1450
+ });
1237
1451
  return {
1238
1452
  ...selected,
1239
1453
  ...paths,
@@ -1258,6 +1472,7 @@ export async function ensureReleaseInstall(
1258
1472
  fetchImpl,
1259
1473
  runProcessImpl,
1260
1474
  onStatus,
1475
+ telemetryClient,
1261
1476
  promptImpl,
1262
1477
  commandExistsImpl,
1263
1478
  },
@@ -1292,20 +1507,62 @@ export async function ensureReleaseInstall(
1292
1507
  "Downloading standalone binaries",
1293
1508
  selected.archiveAsset.name,
1294
1509
  );
1295
- await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath, {
1296
- runProcessImpl,
1297
- onStatus,
1298
- verbose: Boolean(options.verbose),
1299
- stage: "Release archive download",
1510
+ emitTelemetry(telemetryClient, "installer_prebuilt_download_started", {
1511
+ release_tag: selected.tagName,
1512
+ asset_name: selected.archiveAsset.name,
1513
+ os: target.os,
1514
+ arch: target.arch,
1515
+ platform_key: `${target.os}-${target.arch}`,
1300
1516
  });
1517
+ try {
1518
+ await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath, {
1519
+ runProcessImpl,
1520
+ onStatus,
1521
+ verbose: Boolean(options.verbose),
1522
+ stage: "Release archive download",
1523
+ });
1524
+ emitTelemetry(telemetryClient, "installer_prebuilt_download_completed", {
1525
+ release_tag: selected.tagName,
1526
+ asset_name: selected.archiveAsset.name,
1527
+ os: target.os,
1528
+ arch: target.arch,
1529
+ platform_key: `${target.os}-${target.arch}`,
1530
+ });
1531
+ } catch (error) {
1532
+ emitTelemetry(telemetryClient, "installer_prebuilt_download_failed", {
1533
+ release_tag: selected.tagName,
1534
+ asset_name: selected.archiveAsset.name,
1535
+ os: target.os,
1536
+ arch: target.arch,
1537
+ platform_key: `${target.os}-${target.arch}`,
1538
+ ...telemetryFailureContext(error, "prebuilt_download"),
1539
+ });
1540
+ throw error;
1541
+ }
1301
1542
  }
1302
1543
 
1303
1544
  const actualSha256 = await sha256File(paths.archivePath);
1304
1545
  if (actualSha256 !== expectedSha256) {
1546
+ emitTelemetry(telemetryClient, "installer_checksum_failed", {
1547
+ release_tag: selected.tagName,
1548
+ asset_name: selected.archiveAsset.name,
1549
+ os: target.os,
1550
+ arch: target.arch,
1551
+ platform_key: `${target.os}-${target.arch}`,
1552
+ error_stage: "checksum_verify",
1553
+ error_code: "sha256_mismatch",
1554
+ });
1305
1555
  throw new Error(
1306
1556
  `SHA-256 verification failed for ${selected.archiveAsset.name}: expected ${expectedSha256}, got ${actualSha256}.`,
1307
1557
  );
1308
1558
  }
1559
+ emitTelemetry(telemetryClient, "installer_checksum_verified", {
1560
+ release_tag: selected.tagName,
1561
+ asset_name: selected.archiveAsset.name,
1562
+ os: target.os,
1563
+ arch: target.arch,
1564
+ platform_key: `${target.os}-${target.arch}`,
1565
+ });
1309
1566
 
1310
1567
  emitStatus(
1311
1568
  onStatus,
@@ -1347,6 +1604,9 @@ export async function ensureReleaseInstall(
1347
1604
  target,
1348
1605
  expectedSha256,
1349
1606
  cached: false,
1607
+ installMethod: RELEASE_ASSET_INSTALL_METHOD,
1608
+ sourceCloneUrl: null,
1609
+ sourceCommit: null,
1350
1610
  };
1351
1611
  }
1352
1612
 
@@ -1355,6 +1615,7 @@ export async function bootstrapInstalledPylon(
1355
1615
  {
1356
1616
  runProcessImpl = runProcess,
1357
1617
  onStatus = null,
1618
+ telemetryClient = null,
1358
1619
  } = {},
1359
1620
  ) {
1360
1621
  const pylonPath = path.resolve(options.pylonPath);
@@ -1364,108 +1625,157 @@ export async function bootstrapInstalledPylon(
1364
1625
  options.diagnosticRepeats ?? DEFAULT_DIAGNOSTIC_REPEATS;
1365
1626
  const diagnosticMaxOutputTokens =
1366
1627
  options.diagnosticMaxOutputTokens ?? DEFAULT_DIAGNOSTIC_MAX_OUTPUT_TOKENS;
1628
+ emitTelemetry(telemetryClient, "installer_smoke_test_started", {
1629
+ release_tag: options.tagName ?? `pylon-v${options.version}`,
1630
+ release_commit: options.sourceCommit ?? null,
1631
+ os: options.target?.os ?? null,
1632
+ arch: options.target?.arch ?? null,
1633
+ platform_key:
1634
+ options.target?.os && options.target?.arch
1635
+ ? `${options.target.os}-${options.target.arch}`
1636
+ : null,
1637
+ install_source: installSourceForTelemetry(
1638
+ options.installMethod,
1639
+ Boolean(options.cached),
1640
+ ),
1641
+ });
1367
1642
 
1368
- emitStatus(onStatus, "Verifying Pylon binary", path.basename(pylonPath));
1369
- await runPylonCommand(pylonPath, ["--help"], options, runProcessImpl);
1370
- emitStatus(onStatus, "Bootstrapping local Pylon identity");
1371
- const init = await runPylonJson(pylonPath, ["init"], options, runProcessImpl);
1372
- emitStatus(onStatus, "Checking runtime health");
1373
- const status = await runPylonJson(
1374
- pylonPath,
1375
- ["status", "--json"],
1376
- options,
1377
- runProcessImpl,
1378
- );
1379
- emitStatus(onStatus, "Scanning for local models");
1380
- const inventory = await runPylonJson(
1381
- pylonPath,
1382
- ["inventory", "--json"],
1383
- options,
1384
- runProcessImpl,
1385
- );
1386
-
1387
- let download = null;
1388
- if (!options.skipModelDownload) {
1389
- emitStatus(onStatus, "Downloading curated model bundle", model);
1390
- download = await runPylonJson(
1643
+ try {
1644
+ emitStatus(onStatus, "Verifying Pylon binary", path.basename(pylonPath));
1645
+ await runPylonCommand(pylonPath, ["--help"], options, runProcessImpl);
1646
+ emitStatus(onStatus, "Bootstrapping local Pylon identity");
1647
+ const init = await runPylonJson(pylonPath, ["init"], options, runProcessImpl);
1648
+ emitStatus(onStatus, "Checking runtime health");
1649
+ const status = await runPylonJson(
1391
1650
  pylonPath,
1392
- ["gemma", "download", model, "--json"],
1651
+ ["status", "--json"],
1393
1652
  options,
1394
1653
  runProcessImpl,
1395
1654
  );
1396
- } else {
1397
- emitStatus(
1398
- onStatus,
1399
- "Skipping optional curated GGUF cache",
1400
- "use --download-curated-cache to prefetch Hugging Face weights",
1655
+ emitStatus(onStatus, "Scanning for local models");
1656
+ const inventory = await runPylonJson(
1657
+ pylonPath,
1658
+ ["inventory", "--json"],
1659
+ options,
1660
+ runProcessImpl,
1401
1661
  );
1402
- }
1403
1662
 
1404
- let diagnostic = null;
1405
- if (!options.skipDiagnostics) {
1406
- emitStatus(onStatus, "Running first-run diagnostic", model);
1407
- try {
1408
- diagnostic = await runPylonJson(
1663
+ let download = null;
1664
+ if (!options.skipModelDownload) {
1665
+ emitStatus(onStatus, "Downloading curated model bundle", model);
1666
+ download = await runPylonJson(
1409
1667
  pylonPath,
1410
- [
1411
- "gemma",
1412
- "diagnose",
1413
- model,
1414
- "--max-output-tokens",
1415
- String(diagnosticMaxOutputTokens),
1416
- "--repeats",
1417
- String(diagnosticRepeats),
1418
- "--json",
1419
- ],
1668
+ ["gemma", "download", model, "--json"],
1420
1669
  options,
1421
1670
  runProcessImpl,
1422
1671
  );
1423
- } catch (error) {
1424
- if (!isUnsupportedGemmaDiagnoseError(error)) {
1425
- throw error;
1426
- }
1672
+ } else {
1427
1673
  emitStatus(
1428
1674
  onStatus,
1429
- "Skipping first-run diagnostic",
1430
- "installed Pylon release does not expose gemma diagnose",
1675
+ "Skipping optional curated GGUF cache",
1676
+ "use --download-curated-cache to prefetch Hugging Face weights",
1431
1677
  );
1432
1678
  }
1433
- } else {
1434
- emitStatus(onStatus, "Skipping first-run diagnostic", model);
1435
- }
1436
1679
 
1437
- const diagnosticResult =
1438
- diagnostic?.results?.find((result) => result.model_id === model) ??
1439
- diagnostic?.results?.[0] ??
1440
- null;
1680
+ let diagnostic = null;
1681
+ if (!options.skipDiagnostics) {
1682
+ emitStatus(onStatus, "Running first-run diagnostic", model);
1683
+ try {
1684
+ diagnostic = await runPylonJson(
1685
+ pylonPath,
1686
+ [
1687
+ "gemma",
1688
+ "diagnose",
1689
+ model,
1690
+ "--max-output-tokens",
1691
+ String(diagnosticMaxOutputTokens),
1692
+ "--repeats",
1693
+ String(diagnosticRepeats),
1694
+ "--json",
1695
+ ],
1696
+ options,
1697
+ runProcessImpl,
1698
+ );
1699
+ } catch (error) {
1700
+ if (!isUnsupportedGemmaDiagnoseError(error)) {
1701
+ throw error;
1702
+ }
1703
+ emitStatus(
1704
+ onStatus,
1705
+ "Skipping first-run diagnostic",
1706
+ "installed Pylon release does not expose gemma diagnose",
1707
+ );
1708
+ }
1709
+ } else {
1710
+ emitStatus(onStatus, "Skipping first-run diagnostic", model);
1711
+ }
1441
1712
 
1442
- emitStatus(
1443
- onStatus,
1444
- "Bootstrap complete",
1445
- diagnosticResult?.status
1446
- ? `diagnostic ${diagnosticResult.status}`
1447
- : "smoke path complete",
1448
- );
1713
+ const diagnosticResult =
1714
+ diagnostic?.results?.find((result) => result.model_id === model) ??
1715
+ diagnostic?.results?.[0] ??
1716
+ null;
1449
1717
 
1450
- return {
1451
- version: options.version,
1452
- tagName: options.tagName ?? `pylon-v${options.version}`,
1453
- target: options.target,
1454
- cached: Boolean(options.cached),
1455
- binaries: {
1456
- pylon: pylonPath,
1457
- pylonTui: pylonTuiPath,
1458
- },
1459
- configPath: init?.config_path ?? options.configPath ?? null,
1460
- pylonHome: options.pylonHome ? path.resolve(options.pylonHome) : null,
1461
- init,
1462
- status,
1463
- inventory,
1464
- model,
1465
- download,
1466
- diagnostic,
1467
- diagnosticResult,
1468
- };
1718
+ emitStatus(
1719
+ onStatus,
1720
+ "Bootstrap complete",
1721
+ diagnosticResult?.status
1722
+ ? `diagnostic ${diagnosticResult.status}`
1723
+ : "smoke path complete",
1724
+ );
1725
+ emitTelemetry(telemetryClient, "installer_smoke_test_completed", {
1726
+ release_tag: options.tagName ?? `pylon-v${options.version}`,
1727
+ release_commit: options.sourceCommit ?? null,
1728
+ os: options.target?.os ?? null,
1729
+ arch: options.target?.arch ?? null,
1730
+ platform_key:
1731
+ options.target?.os && options.target?.arch
1732
+ ? `${options.target.os}-${options.target.arch}`
1733
+ : null,
1734
+ install_source: installSourceForTelemetry(
1735
+ options.installMethod,
1736
+ Boolean(options.cached),
1737
+ ),
1738
+ diagnostic_status: diagnosticResult?.status ?? null,
1739
+ });
1740
+
1741
+ return {
1742
+ version: options.version,
1743
+ tagName: options.tagName ?? `pylon-v${options.version}`,
1744
+ target: options.target,
1745
+ cached: Boolean(options.cached),
1746
+ installMethod: options.installMethod ?? RELEASE_ASSET_INSTALL_METHOD,
1747
+ binaries: {
1748
+ pylon: pylonPath,
1749
+ pylonTui: pylonTuiPath,
1750
+ },
1751
+ configPath: init?.config_path ?? options.configPath ?? null,
1752
+ pylonHome: options.pylonHome ? path.resolve(options.pylonHome) : null,
1753
+ init,
1754
+ status,
1755
+ inventory,
1756
+ model,
1757
+ download,
1758
+ diagnostic,
1759
+ diagnosticResult,
1760
+ };
1761
+ } catch (error) {
1762
+ emitTelemetry(telemetryClient, "installer_smoke_test_failed", {
1763
+ release_tag: options.tagName ?? `pylon-v${options.version}`,
1764
+ release_commit: options.sourceCommit ?? null,
1765
+ os: options.target?.os ?? null,
1766
+ arch: options.target?.arch ?? null,
1767
+ platform_key:
1768
+ options.target?.os && options.target?.arch
1769
+ ? `${options.target.os}-${options.target.arch}`
1770
+ : null,
1771
+ install_source: installSourceForTelemetry(
1772
+ options.installMethod,
1773
+ Boolean(options.cached),
1774
+ ),
1775
+ ...telemetryFailureContext(error, "smoke_test"),
1776
+ });
1777
+ throw error;
1778
+ }
1469
1779
  }
1470
1780
 
1471
1781
  export async function launchInstalledPylonTui(
@@ -1483,14 +1793,100 @@ export async function launchInstalledPylonTui(
1483
1793
  });
1484
1794
  }
1485
1795
 
1796
+ export function resolveBootstrapOutcome(summary) {
1797
+ const runtimeState =
1798
+ summary.status?.snapshot?.runtime?.authoritative_status ?? "unknown";
1799
+ const localGemma = summary.status?.snapshot?.availability?.local_gemma ?? {};
1800
+ const readyModel = localGemma.ready_model ?? null;
1801
+ const localGemmaError = localGemma.last_error ?? null;
1802
+ const diagnosticStatus = summary.diagnosticResult?.status ?? null;
1803
+
1804
+ if (runtimeState === "online") {
1805
+ return {
1806
+ level: "success",
1807
+ verdict: "fully online",
1808
+ detail: readyModel
1809
+ ? `loaded runtime model ${readyModel}`
1810
+ : "eligible local Gemma supply is online",
1811
+ };
1812
+ }
1813
+
1814
+ if (readyModel) {
1815
+ return {
1816
+ level: "success",
1817
+ verdict: "runtime ready",
1818
+ detail: `loaded runtime model ${readyModel}`,
1819
+ };
1820
+ }
1821
+
1822
+ const loweredError = (localGemmaError ?? "").toLowerCase();
1823
+ if (loweredError.includes("/api/tags") || loweredError.includes("not reachable")) {
1824
+ return {
1825
+ level: "warning",
1826
+ verdict: "installed but runtime missing",
1827
+ detail:
1828
+ "no Ollama-compatible local runtime is answering /api/tags yet",
1829
+ };
1830
+ }
1831
+
1832
+ if (
1833
+ diagnosticStatus &&
1834
+ diagnosticStatus !== "completed" &&
1835
+ diagnosticStatus !== "passed" &&
1836
+ diagnosticStatus !== "healthy"
1837
+ ) {
1838
+ return {
1839
+ level: "warning",
1840
+ verdict: "installed but runtime not yet usable",
1841
+ detail: diagnosticStatus,
1842
+ };
1843
+ }
1844
+
1845
+ return {
1846
+ level: "warning",
1847
+ verdict: "installed",
1848
+ detail: "complete the local runtime setup before bringing the node online",
1849
+ };
1850
+ }
1851
+
1852
+ function renderBootstrapNextSteps(summary, outcome) {
1853
+ const lines = [
1854
+ "Launcher path: use the same npx/bunx command again, or install globally and run `pylon`.",
1855
+ ];
1856
+
1857
+ if (outcome.verdict === "fully online" || outcome.verdict === "runtime ready") {
1858
+ lines.push("Next step: open the TUI with `pylon`, or keep using the package-managed launcher.");
1859
+ return lines;
1860
+ }
1861
+
1862
+ if (summary.target?.os === "darwin") {
1863
+ lines.push(
1864
+ "Runtime setup (macOS default): `brew install ollama`, `brew services start ollama`, `ollama pull gemma4:e4b`.",
1865
+ );
1866
+ } else {
1867
+ lines.push(
1868
+ "Runtime setup: start an Ollama-compatible local runtime at `local_gemma_base_url` and load `gemma4:e4b`.",
1869
+ );
1870
+ }
1871
+ lines.push(
1872
+ "Persistent PATH command: `npm install -g @openagentsinc/pylon` or `bun install -g @openagentsinc/pylon`, then run `pylon`.",
1873
+ );
1874
+ return lines;
1875
+ }
1876
+
1486
1877
  export function renderBootstrapSummary(summary) {
1878
+ const outcome = resolveBootstrapOutcome(summary);
1487
1879
  const lines = [
1880
+ `Onboarding verdict: ${outcome.verdict}`,
1881
+ `Verdict detail: ${outcome.detail}`,
1488
1882
  `Pylon release: ${summary.version} (${summary.target.os}-${summary.target.arch})`,
1489
1883
  `Archive source: ${summary.tagName}`,
1884
+ `Install source: ${installSourceForTelemetry(summary.installMethod, summary.cached).replaceAll("_", " ")}`,
1490
1885
  `Installed from cache: ${summary.cached ? "yes" : "no"}`,
1491
1886
  `Pylon binary: ${summary.binaries.pylon}`,
1492
1887
  `Pylon TUI: ${summary.binaries.pylonTui}`,
1493
1888
  `Config path: ${summary.configPath ?? "unknown"}`,
1889
+ `Preferred runtime model name: ${PREFERRED_RUNTIME_MODEL_NAME}`,
1494
1890
  ];
1495
1891
 
1496
1892
  const statusState =
@@ -1549,5 +1945,7 @@ export function renderBootstrapSummary(summary) {
1549
1945
  }
1550
1946
  }
1551
1947
 
1948
+ lines.push(...renderBootstrapNextSteps(summary, outcome));
1949
+
1552
1950
  return lines.join("\n");
1553
1951
  }
@@ -0,0 +1,160 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export const DEFAULT_TELEMETRY_ENDPOINT =
4
+ "https://openagents.com/api/telemetry/events";
5
+ const DEFAULT_TELEMETRY_TIMEOUT_MS = 2_000;
6
+
7
+ function timedSignal(timeoutMs = DEFAULT_TELEMETRY_TIMEOUT_MS) {
8
+ if (typeof AbortSignal?.timeout === "function") {
9
+ return {
10
+ signal: AbortSignal.timeout(timeoutMs),
11
+ dispose() {},
12
+ };
13
+ }
14
+
15
+ const controller = new AbortController();
16
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
17
+ return {
18
+ signal: controller.signal,
19
+ dispose() {
20
+ clearTimeout(timer);
21
+ },
22
+ };
23
+ }
24
+
25
+ function cleanString(value, fallback = null) {
26
+ if (typeof value !== "string") {
27
+ return fallback;
28
+ }
29
+
30
+ const trimmed = value.trim();
31
+ return trimmed.length > 0 ? trimmed : fallback;
32
+ }
33
+
34
+ function normalizeProperties(properties = {}) {
35
+ try {
36
+ return JSON.parse(JSON.stringify(properties ?? {}));
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ export function detectPackageInvoker(env = process.env) {
43
+ const userAgent = cleanString(env.npm_config_user_agent);
44
+ if (userAgent?.includes("bun/")) {
45
+ return "bun";
46
+ }
47
+ if (userAgent?.includes("npm/")) {
48
+ return "npm";
49
+ }
50
+
51
+ const execPath = cleanString(env.npm_execpath);
52
+ if (execPath?.includes("bun")) {
53
+ return "bun";
54
+ }
55
+ if (execPath?.includes("npm")) {
56
+ return "npm";
57
+ }
58
+
59
+ return "unknown";
60
+ }
61
+
62
+ export function installSourceForTelemetry(installMethod, cached) {
63
+ if (installMethod === "source_build") {
64
+ return cached ? "cached_source_build" : "source_build";
65
+ }
66
+
67
+ return cached ? "cached_prebuilt" : "prebuilt";
68
+ }
69
+
70
+ export function telemetryFailureContext(error, fallbackStage = "unknown") {
71
+ const cause = error?.cause ?? null;
72
+ const stage = cleanString(error?.stage)?.toLowerCase().replace(/[^a-z0-9]+/g, "_");
73
+ const code =
74
+ cleanString(error?.code) ??
75
+ cleanString(error?.errno) ??
76
+ cleanString(cause?.code) ??
77
+ cleanString(cause?.errno) ??
78
+ (typeof error?.httpStatus === "number" ? `http_${error.httpStatus}` : null) ??
79
+ "unknown";
80
+ const message =
81
+ (error instanceof Error ? error.message : String(error)).split("\n")[0] ??
82
+ "unknown error";
83
+
84
+ return {
85
+ error_stage: stage || fallbackStage,
86
+ error_code: code,
87
+ error_message: message.slice(0, 240),
88
+ };
89
+ }
90
+
91
+ export function createTelemetryClient({
92
+ endpoint = process.env.OPENAGENTS_TELEMETRY_URL ?? DEFAULT_TELEMETRY_ENDPOINT,
93
+ fetchImpl = globalThis.fetch,
94
+ anonymousActorId = randomUUID(),
95
+ sessionId = anonymousActorId,
96
+ installId = anonymousActorId,
97
+ appVersion = null,
98
+ sourceSurface = "installer",
99
+ } = {}) {
100
+ const pending = new Set();
101
+ const enabled =
102
+ typeof fetchImpl === "function" &&
103
+ Boolean(cleanString(endpoint)) &&
104
+ process.env.OPENAGENTS_DISABLE_TELEMETRY !== "1";
105
+
106
+ async function post(payload) {
107
+ if (!enabled) {
108
+ return false;
109
+ }
110
+
111
+ const timeout = timedSignal();
112
+ try {
113
+ const response = await fetchImpl(endpoint, {
114
+ method: "POST",
115
+ headers: {
116
+ accept: "application/json",
117
+ "content-type": "application/json",
118
+ },
119
+ body: JSON.stringify(payload),
120
+ signal: timeout.signal,
121
+ });
122
+ return response.ok;
123
+ } catch {
124
+ return false;
125
+ } finally {
126
+ timeout.dispose();
127
+ }
128
+ }
129
+
130
+ return {
131
+ endpoint,
132
+ anonymousActorId,
133
+ sessionId,
134
+ installId,
135
+ emit(eventName, properties = {}) {
136
+ const promise = post({
137
+ event_name: eventName,
138
+ source_surface: sourceSurface,
139
+ occurred_at: new Date().toISOString(),
140
+ anonymous_actor_id: anonymousActorId,
141
+ session_id: sessionId,
142
+ install_id: installId,
143
+ app_version: cleanString(appVersion),
144
+ properties: normalizeProperties(properties),
145
+ });
146
+ pending.add(promise);
147
+ promise.finally(() => {
148
+ pending.delete(promise);
149
+ });
150
+ return promise;
151
+ },
152
+ async flush() {
153
+ const current = [...pending];
154
+ if (current.length === 0) {
155
+ return;
156
+ }
157
+ await Promise.allSettled(current);
158
+ },
159
+ };
160
+ }