@openagentsinc/pylon 0.1.3 → 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
@@ -13,7 +13,7 @@ npx @openagentsinc/pylon
13
13
  bunx @openagentsinc/pylon
14
14
  npm install -g @openagentsinc/pylon && pylon
15
15
  bun install -g @openagentsinc/pylon && pylon
16
- npx @openagentsinc/pylon --version 0.0.1-rc4
16
+ npx @openagentsinc/pylon --version 0.0.1-rc10
17
17
  npx @openagentsinc/pylon --no-launch
18
18
  npx @openagentsinc/pylon --download-curated-cache --model gemma-4-e2b --diagnostic-repeats 2
19
19
  npx @openagentsinc/pylon --verbose
@@ -31,6 +31,9 @@ The launcher:
31
31
  `pylon-tui` locally when no matching release asset exists for the machine
32
32
  - prompts before installing the Rust toolchain via `rustup` if a source build
33
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
34
37
  - downloads the archive and published SHA-256 checksum
35
38
  - verifies the checksum before extracting
36
39
  - caches the unpacked binaries under `~/.openagents/pylon/bootstrap/`
@@ -53,6 +56,10 @@ The launcher:
53
56
  bootstrap stays honest about the separate Ollama-compatible runtime
54
57
  prerequisite instead of mutating the host behind the user's back
55
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.
62
+
56
63
  ## Publish
57
64
 
58
65
  Publish directly from this package directory:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagentsinc/pylon",
3
- "version": "0.1.3",
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": {
package/src/cli.js CHANGED
@@ -8,8 +8,15 @@ import {
8
8
  ensureReleaseInstall,
9
9
  launchInstalledPylonTui,
10
10
  resolveBootstrapOutcome,
11
+ resolvePlatformTarget,
11
12
  renderBootstrapSummary,
12
13
  } from "./index.js";
14
+ import {
15
+ createTelemetryClient,
16
+ detectPackageInvoker,
17
+ installSourceForTelemetry,
18
+ telemetryFailureContext,
19
+ } from "./telemetry.js";
13
20
 
14
21
  function parseIntegerFlag(value, label) {
15
22
  const parsed = Number.parseInt(value, 10);
@@ -221,6 +228,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
221
228
  ensureReleaseInstallImpl = ensureReleaseInstall,
222
229
  bootstrapInstalledPylonImpl = bootstrapInstalledPylon,
223
230
  launchInstalledPylonTuiImpl = launchInstalledPylonTui,
231
+ createTelemetryClientImpl = createTelemetryClient,
224
232
  } = dependencies;
225
233
  const options = parseArgs(argv);
226
234
  if (options.help) {
@@ -229,51 +237,109 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
229
237
  }
230
238
 
231
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
+ };
232
263
 
233
- const install = await ensureReleaseInstallImpl(options, {
234
- ...dependencies,
235
- onStatus: reporter?.status,
236
- });
237
- const summary = await bootstrapInstalledPylonImpl(
238
- {
239
- ...options,
240
- ...install,
241
- version: install.version,
242
- },
243
- {
264
+ telemetryClient?.emit?.("installer_started", sharedTelemetry);
265
+
266
+ let install = null;
267
+
268
+ try {
269
+ install = await ensureReleaseInstallImpl(options, {
244
270
  ...dependencies,
245
271
  onStatus: reporter?.status,
246
- },
247
- );
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
+ );
248
286
 
249
- if (options.json) {
250
- console.log(JSON.stringify(summary, null, 2));
251
- } else {
252
- const outcome = resolveBootstrapOutcome(summary);
253
- if (outcome.level === "success") {
254
- reporter?.success(`Pylon ${outcome.verdict}`, outcome.detail);
255
- } else {
256
- reporter?.warning(`Pylon ${outcome.verdict}`, outcome.detail);
257
- }
258
- console.log(renderBootstrapSummary(summary));
259
- if (!options.noLaunch) {
260
- await launchInstalledPylonTuiImpl(
261
- {
262
- ...options,
263
- ...install,
264
- version: install.version,
265
- },
266
- {
267
- ...dependencies,
268
- onStatus: reporter?.status,
269
- },
270
- );
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?.();
299
+
300
+ if (options.json) {
301
+ console.log(JSON.stringify(summary, null, 2));
271
302
  } else {
272
- reporter?.warning(
273
- "Skipped Pylon terminal UI launch",
274
- "pass no flag to open pylon-tui by default",
275
- );
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
+ }
276
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;
277
344
  }
278
- return summary;
279
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";
@@ -31,10 +37,82 @@ function emitVerboseStatus(onStatus, verbose, message, detail = null) {
31
37
  }
32
38
  }
33
39
 
40
+ function emitTelemetry(telemetryClient, eventName, properties = {}) {
41
+ if (typeof telemetryClient?.emit === "function") {
42
+ void telemetryClient.emit(eventName, properties);
43
+ }
44
+ }
45
+
34
46
  function normalizeVersion(value) {
35
47
  return value.replace(/^pylon-v/, "").replace(/^v/, "");
36
48
  }
37
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
+
38
116
  function createBootstrapError(message, context = {}) {
39
117
  const error = new Error(message);
40
118
  Object.assign(error, context);
@@ -497,14 +575,31 @@ export function isPylonReleaseTag(tagName) {
497
575
  );
498
576
  }
499
577
 
500
- 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) {
501
593
  if (!Array.isArray(releases)) {
502
594
  throw new Error("GitHub release lookup did not return a release list.");
503
595
  }
504
596
 
505
- const release = releases.find(
506
- (candidate) => !candidate?.draft && isPylonReleaseTag(candidate?.tag_name),
507
- );
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];
508
603
  if (!release) {
509
604
  throw new Error(
510
605
  `GitHub release lookup did not find any published ${PYLON_RELEASE_TAG_PREFIX} releases.`,
@@ -522,6 +617,7 @@ export async function fetchReleaseMetadata({
522
617
  apiBase = DEFAULT_RELEASE_API_BASE,
523
618
  repo = DEFAULT_RELEASE_REPO,
524
619
  version = null,
620
+ target = null,
525
621
  } = {}) {
526
622
  const normalizedVersion = normalizeRequestedVersion(version);
527
623
  const endpoint = normalizedVersion
@@ -538,7 +634,7 @@ export async function fetchReleaseMetadata({
538
634
  ? "GitHub tagged release lookup"
539
635
  : "GitHub release list lookup",
540
636
  });
541
- return normalizedVersion ? payload : selectLatestPylonRelease(payload);
637
+ return normalizedVersion ? payload : selectLatestPylonRelease(payload, target);
542
638
  }
543
639
 
544
640
  export function selectReleaseAssets(release, target) {
@@ -713,6 +809,7 @@ async function ensureRustToolchain({
713
809
  fetchImpl,
714
810
  runProcessImpl,
715
811
  onStatus,
812
+ telemetryClient,
716
813
  promptImpl = promptForApproval,
717
814
  commandExistsImpl = commandExists,
718
815
  env = process.env,
@@ -725,44 +822,70 @@ async function ensureRustToolchain({
725
822
  return toolchainEnv;
726
823
  }
727
824
 
825
+ emitTelemetry(telemetryClient, "installer_rust_missing", {
826
+ os: target.os,
827
+ arch: target.arch,
828
+ });
829
+
728
830
  emitStatus(
729
831
  onStatus,
730
832
  "Rust toolchain required for source build",
731
833
  `${target.os}-${target.arch}`,
732
834
  );
733
835
 
836
+ emitTelemetry(telemetryClient, "installer_rust_install_prompt_shown", {
837
+ os: target.os,
838
+ arch: target.arch,
839
+ });
734
840
  const approved = await promptImpl(
735
841
  `Rust is required to build Pylon from source for ${target.os}-${target.arch}. Install the official Rust toolchain now via rustup?`,
736
842
  );
737
843
  if (!approved) {
844
+ emitTelemetry(telemetryClient, "installer_rust_install_declined", {
845
+ os: target.os,
846
+ arch: target.arch,
847
+ });
738
848
  throw new Error(
739
849
  `Rust is required to build Pylon from source.\nInstall it manually and rerun:\n${rustInstallCommand()}`,
740
850
  );
741
851
  }
742
852
 
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",
853
+ emitTelemetry(telemetryClient, "installer_rust_install_approved", {
854
+ os: target.os,
855
+ arch: target.arch,
752
856
  });
753
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pylon-rustup-"));
754
- const scriptPath = path.join(tempDir, "rustup-init.sh");
755
857
 
858
+ emitStatus(onStatus, "Installing Rust toolchain", "official rustup installer");
756
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");
757
871
  await fs.writeFile(scriptPath, scriptPayload);
758
872
  await fs.chmod(scriptPath, 0o755);
759
- await runProcessImpl("sh", [scriptPath, "-y"], {
760
- cwd: tempDir,
761
- env: toolchainEnv,
762
- 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"),
763
887
  });
764
- } finally {
765
- await fs.rm(tempDir, { recursive: true, force: true });
888
+ throw error;
766
889
  }
767
890
 
768
891
  toolchainEnv = withPrependedPath(env, path.join(os.homedir(), ".cargo", "bin"));
@@ -779,6 +902,10 @@ async function ensureRustToolchain({
779
902
  "Rust toolchain installed",
780
903
  path.join(os.homedir(), ".cargo", "bin"),
781
904
  );
905
+ emitTelemetry(telemetryClient, "installer_rust_install_completed", {
906
+ os: target.os,
907
+ arch: target.arch,
908
+ });
782
909
  return toolchainEnv;
783
910
  }
784
911
 
@@ -793,6 +920,7 @@ async function installSourceBuild(
793
920
  fetchImpl,
794
921
  runProcessImpl,
795
922
  onStatus,
923
+ telemetryClient,
796
924
  promptImpl = promptForApproval,
797
925
  commandExistsImpl = commandExists,
798
926
  },
@@ -812,6 +940,13 @@ async function installSourceBuild(
812
940
  "Prebuilt asset missing; falling back to source build",
813
941
  `${selected.tagName} for ${target.os}-${target.arch}`,
814
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
+ });
815
950
 
816
951
  if (!(await commandExistsImpl("git", process.env))) {
817
952
  throw new Error(
@@ -824,6 +959,7 @@ async function installSourceBuild(
824
959
  fetchImpl,
825
960
  runProcessImpl,
826
961
  onStatus,
962
+ telemetryClient,
827
963
  promptImpl,
828
964
  commandExistsImpl,
829
965
  });
@@ -897,6 +1033,13 @@ async function installSourceBuild(
897
1033
  "Building Pylon from source",
898
1034
  `${selected.tagName} (${sourceCommit.slice(0, 12)})`,
899
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
+ });
900
1043
  await runProcessImpl(buildCommand[0], buildCommand.slice(1), {
901
1044
  cwd: repoDir,
902
1045
  env: buildEnv,
@@ -938,6 +1081,17 @@ async function installSourceBuild(
938
1081
  "Installed source-built binaries",
939
1082
  `${selected.tagName} for ${target.os}-${target.arch}`,
940
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
+ });
941
1095
 
942
1096
  return {
943
1097
  ...selected,
@@ -950,6 +1104,14 @@ async function installSourceBuild(
950
1104
  sourceCommit,
951
1105
  };
952
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
+ });
953
1115
  const message = error instanceof Error ? error.message : String(error);
954
1116
  throw new Error(
955
1117
  `${message}\nManual source-build fallback:\n${manualBuildInstructions}`,
@@ -1013,6 +1175,9 @@ async function findLatestCachedInstall(installRoot, target) {
1013
1175
  pylonTuiPath,
1014
1176
  expectedSha256: manifest.sha256 ?? null,
1015
1177
  cached: true,
1178
+ installMethod: manifest.installMethod ?? RELEASE_ASSET_INSTALL_METHOD,
1179
+ sourceCloneUrl: manifest.sourceCloneUrl ?? null,
1180
+ sourceCommit: manifest.sourceCommit ?? null,
1016
1181
  mtimeMs: manifestStat.mtimeMs,
1017
1182
  });
1018
1183
  } catch {
@@ -1124,6 +1289,7 @@ export async function ensureReleaseInstall(
1124
1289
  fetchImpl = globalThis.fetch,
1125
1290
  runProcessImpl = runProcess,
1126
1291
  onStatus = null,
1292
+ telemetryClient = null,
1127
1293
  promptImpl = promptForApproval,
1128
1294
  commandExistsImpl = commandExists,
1129
1295
  } = {},
@@ -1141,25 +1307,38 @@ export async function ensureReleaseInstall(
1141
1307
  const installRoot = options.installRoot ?? defaultInstallRoot();
1142
1308
  if (options.version) {
1143
1309
  const requestedPaths = buildInstallPaths(installRoot, options.version, target);
1310
+ const requestedManifest = await readInstallManifest(requestedPaths.manifestPath);
1144
1311
  const requestedCached =
1145
1312
  (await pathExists(requestedPaths.pylonPath)) &&
1146
1313
  (await pathExists(requestedPaths.pylonTuiPath));
1147
1314
  if (requestedCached) {
1315
+ const installMethod =
1316
+ requestedManifest?.installMethod ?? RELEASE_ASSET_INSTALL_METHOD;
1148
1317
  emitStatus(
1149
1318
  onStatus,
1150
- "Using cached standalone binaries",
1319
+ installMethod === SOURCE_BUILD_INSTALL_METHOD
1320
+ ? "Using cached source-built binaries"
1321
+ : "Using cached standalone binaries",
1151
1322
  `pylon-v${normalizeVersion(options.version)} for ${target.os}-${target.arch}`,
1152
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
+ });
1153
1332
  return {
1154
1333
  version: normalizeVersion(options.version),
1155
1334
  tagName: `pylon-v${normalizeVersion(options.version)}`,
1156
1335
  target,
1157
1336
  ...requestedPaths,
1158
- expectedSha256: await fs
1159
- .readFile(requestedPaths.manifestPath, "utf8")
1160
- .then((payload) => JSON.parse(payload).sha256)
1161
- .catch(() => null),
1337
+ expectedSha256: requestedManifest?.sha256 ?? null,
1162
1338
  cached: true,
1339
+ installMethod,
1340
+ sourceCloneUrl: requestedManifest?.sourceCloneUrl ?? null,
1341
+ sourceCommit: requestedManifest?.sourceCommit ?? null,
1163
1342
  };
1164
1343
  }
1165
1344
  }
@@ -1174,6 +1353,14 @@ export async function ensureReleaseInstall(
1174
1353
  apiBase: options.apiBase ?? DEFAULT_RELEASE_API_BASE,
1175
1354
  repo: options.repo ?? DEFAULT_RELEASE_REPO,
1176
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}`,
1177
1364
  });
1178
1365
  } catch (error) {
1179
1366
  const cached = !options.version
@@ -1182,9 +1369,19 @@ export async function ensureReleaseInstall(
1182
1369
  if (cached) {
1183
1370
  emitStatus(
1184
1371
  onStatus,
1185
- "Using cached standalone binaries",
1372
+ cached.installMethod === SOURCE_BUILD_INSTALL_METHOD
1373
+ ? "Using cached source-built binaries"
1374
+ : "Using cached standalone binaries",
1186
1375
  `release lookup failed; falling back to ${cached.tagName}`,
1187
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
+ });
1188
1385
  return cached;
1189
1386
  }
1190
1387
 
@@ -1205,6 +1402,14 @@ export async function ensureReleaseInstall(
1205
1402
  let missingAssetsError = null;
1206
1403
  try {
1207
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
+ });
1208
1413
  } catch (error) {
1209
1414
  if (!(error instanceof MissingReleaseAssetsError)) {
1210
1415
  throw error;
@@ -1235,6 +1440,14 @@ export async function ensureReleaseInstall(
1235
1440
  : "Using cached standalone binaries",
1236
1441
  `${selected.tagName} for ${target.os}-${target.arch}`,
1237
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
+ });
1238
1451
  return {
1239
1452
  ...selected,
1240
1453
  ...paths,
@@ -1259,6 +1472,7 @@ export async function ensureReleaseInstall(
1259
1472
  fetchImpl,
1260
1473
  runProcessImpl,
1261
1474
  onStatus,
1475
+ telemetryClient,
1262
1476
  promptImpl,
1263
1477
  commandExistsImpl,
1264
1478
  },
@@ -1293,20 +1507,62 @@ export async function ensureReleaseInstall(
1293
1507
  "Downloading standalone binaries",
1294
1508
  selected.archiveAsset.name,
1295
1509
  );
1296
- await downloadFile(fetchImpl, selected.archiveAsset.url, paths.archivePath, {
1297
- runProcessImpl,
1298
- onStatus,
1299
- verbose: Boolean(options.verbose),
1300
- 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}`,
1301
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
+ }
1302
1542
  }
1303
1543
 
1304
1544
  const actualSha256 = await sha256File(paths.archivePath);
1305
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
+ });
1306
1555
  throw new Error(
1307
1556
  `SHA-256 verification failed for ${selected.archiveAsset.name}: expected ${expectedSha256}, got ${actualSha256}.`,
1308
1557
  );
1309
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
+ });
1310
1566
 
1311
1567
  emitStatus(
1312
1568
  onStatus,
@@ -1348,6 +1604,9 @@ export async function ensureReleaseInstall(
1348
1604
  target,
1349
1605
  expectedSha256,
1350
1606
  cached: false,
1607
+ installMethod: RELEASE_ASSET_INSTALL_METHOD,
1608
+ sourceCloneUrl: null,
1609
+ sourceCommit: null,
1351
1610
  };
1352
1611
  }
1353
1612
 
@@ -1356,6 +1615,7 @@ export async function bootstrapInstalledPylon(
1356
1615
  {
1357
1616
  runProcessImpl = runProcess,
1358
1617
  onStatus = null,
1618
+ telemetryClient = null,
1359
1619
  } = {},
1360
1620
  ) {
1361
1621
  const pylonPath = path.resolve(options.pylonPath);
@@ -1365,108 +1625,157 @@ export async function bootstrapInstalledPylon(
1365
1625
  options.diagnosticRepeats ?? DEFAULT_DIAGNOSTIC_REPEATS;
1366
1626
  const diagnosticMaxOutputTokens =
1367
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
+ });
1368
1642
 
1369
- emitStatus(onStatus, "Verifying Pylon binary", path.basename(pylonPath));
1370
- await runPylonCommand(pylonPath, ["--help"], options, runProcessImpl);
1371
- emitStatus(onStatus, "Bootstrapping local Pylon identity");
1372
- const init = await runPylonJson(pylonPath, ["init"], options, runProcessImpl);
1373
- emitStatus(onStatus, "Checking runtime health");
1374
- const status = await runPylonJson(
1375
- pylonPath,
1376
- ["status", "--json"],
1377
- options,
1378
- runProcessImpl,
1379
- );
1380
- emitStatus(onStatus, "Scanning for local models");
1381
- const inventory = await runPylonJson(
1382
- pylonPath,
1383
- ["inventory", "--json"],
1384
- options,
1385
- runProcessImpl,
1386
- );
1387
-
1388
- let download = null;
1389
- if (!options.skipModelDownload) {
1390
- emitStatus(onStatus, "Downloading curated model bundle", model);
1391
- 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(
1392
1650
  pylonPath,
1393
- ["gemma", "download", model, "--json"],
1651
+ ["status", "--json"],
1394
1652
  options,
1395
1653
  runProcessImpl,
1396
1654
  );
1397
- } else {
1398
- emitStatus(
1399
- onStatus,
1400
- "Skipping optional curated GGUF cache",
1401
- "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,
1402
1661
  );
1403
- }
1404
1662
 
1405
- let diagnostic = null;
1406
- if (!options.skipDiagnostics) {
1407
- emitStatus(onStatus, "Running first-run diagnostic", model);
1408
- try {
1409
- diagnostic = await runPylonJson(
1663
+ let download = null;
1664
+ if (!options.skipModelDownload) {
1665
+ emitStatus(onStatus, "Downloading curated model bundle", model);
1666
+ download = await runPylonJson(
1410
1667
  pylonPath,
1411
- [
1412
- "gemma",
1413
- "diagnose",
1414
- model,
1415
- "--max-output-tokens",
1416
- String(diagnosticMaxOutputTokens),
1417
- "--repeats",
1418
- String(diagnosticRepeats),
1419
- "--json",
1420
- ],
1668
+ ["gemma", "download", model, "--json"],
1421
1669
  options,
1422
1670
  runProcessImpl,
1423
1671
  );
1424
- } catch (error) {
1425
- if (!isUnsupportedGemmaDiagnoseError(error)) {
1426
- throw error;
1427
- }
1672
+ } else {
1428
1673
  emitStatus(
1429
1674
  onStatus,
1430
- "Skipping first-run diagnostic",
1431
- "installed Pylon release does not expose gemma diagnose",
1675
+ "Skipping optional curated GGUF cache",
1676
+ "use --download-curated-cache to prefetch Hugging Face weights",
1432
1677
  );
1433
1678
  }
1434
- } else {
1435
- emitStatus(onStatus, "Skipping first-run diagnostic", model);
1436
- }
1437
1679
 
1438
- const diagnosticResult =
1439
- diagnostic?.results?.find((result) => result.model_id === model) ??
1440
- diagnostic?.results?.[0] ??
1441
- 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
+ }
1442
1712
 
1443
- emitStatus(
1444
- onStatus,
1445
- "Bootstrap complete",
1446
- diagnosticResult?.status
1447
- ? `diagnostic ${diagnosticResult.status}`
1448
- : "smoke path complete",
1449
- );
1713
+ const diagnosticResult =
1714
+ diagnostic?.results?.find((result) => result.model_id === model) ??
1715
+ diagnostic?.results?.[0] ??
1716
+ null;
1450
1717
 
1451
- return {
1452
- version: options.version,
1453
- tagName: options.tagName ?? `pylon-v${options.version}`,
1454
- target: options.target,
1455
- cached: Boolean(options.cached),
1456
- binaries: {
1457
- pylon: pylonPath,
1458
- pylonTui: pylonTuiPath,
1459
- },
1460
- configPath: init?.config_path ?? options.configPath ?? null,
1461
- pylonHome: options.pylonHome ? path.resolve(options.pylonHome) : null,
1462
- init,
1463
- status,
1464
- inventory,
1465
- model,
1466
- download,
1467
- diagnostic,
1468
- diagnosticResult,
1469
- };
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
+ }
1470
1779
  }
1471
1780
 
1472
1781
  export async function launchInstalledPylonTui(
@@ -1572,6 +1881,7 @@ export function renderBootstrapSummary(summary) {
1572
1881
  `Verdict detail: ${outcome.detail}`,
1573
1882
  `Pylon release: ${summary.version} (${summary.target.os}-${summary.target.arch})`,
1574
1883
  `Archive source: ${summary.tagName}`,
1884
+ `Install source: ${installSourceForTelemetry(summary.installMethod, summary.cached).replaceAll("_", " ")}`,
1575
1885
  `Installed from cache: ${summary.cached ? "yes" : "no"}`,
1576
1886
  `Pylon binary: ${summary.binaries.pylon}`,
1577
1887
  `Pylon TUI: ${summary.binaries.pylonTui}`,
@@ -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
+ }