@indigoai-us/hq-cloud 6.15.31 → 6.15.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/bandwidth.d.ts +128 -0
  2. package/dist/bandwidth.d.ts.map +1 -0
  3. package/dist/bandwidth.js +254 -0
  4. package/dist/bandwidth.js.map +1 -0
  5. package/dist/bandwidth.test.d.ts +2 -0
  6. package/dist/bandwidth.test.d.ts.map +1 -0
  7. package/dist/bandwidth.test.js +243 -0
  8. package/dist/bandwidth.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  10. package/dist/bin/sync-runner-company.js +13 -24
  11. package/dist/bin/sync-runner-company.js.map +1 -1
  12. package/dist/bin/sync-runner-company.test.js +71 -0
  13. package/dist/bin/sync-runner-company.test.js.map +1 -1
  14. package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
  15. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  16. package/dist/bin/sync-runner-watch-loop.js +181 -16
  17. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  18. package/dist/bin/sync-runner-watch-routes.d.ts +16 -1
  19. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  20. package/dist/bin/sync-runner-watch-routes.js +40 -2
  21. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  22. package/dist/bin/sync-runner-watch-routes.test.js +53 -1
  23. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -1
  24. package/dist/bin/sync-runner.d.ts +15 -3
  25. package/dist/bin/sync-runner.d.ts.map +1 -1
  26. package/dist/bin/sync-runner.js +66 -13
  27. package/dist/bin/sync-runner.js.map +1 -1
  28. package/dist/bin/sync-runner.test.js +433 -13
  29. package/dist/bin/sync-runner.test.js.map +1 -1
  30. package/dist/cli/conflict.d.ts +25 -2
  31. package/dist/cli/conflict.d.ts.map +1 -1
  32. package/dist/cli/conflict.js +29 -4
  33. package/dist/cli/conflict.js.map +1 -1
  34. package/dist/cli/share.d.ts +40 -4
  35. package/dist/cli/share.d.ts.map +1 -1
  36. package/dist/cli/share.js +478 -86
  37. package/dist/cli/share.js.map +1 -1
  38. package/dist/cli/share.test.js +845 -150
  39. package/dist/cli/share.test.js.map +1 -1
  40. package/dist/cli/sync-scope.test.js +147 -18
  41. package/dist/cli/sync-scope.test.js.map +1 -1
  42. package/dist/cli/sync.d.ts +20 -0
  43. package/dist/cli/sync.d.ts.map +1 -1
  44. package/dist/cli/sync.js +261 -29
  45. package/dist/cli/sync.js.map +1 -1
  46. package/dist/cli/sync.test.js +519 -90
  47. package/dist/cli/sync.test.js.map +1 -1
  48. package/dist/journal-delta-writes.test.d.ts +2 -0
  49. package/dist/journal-delta-writes.test.d.ts.map +1 -0
  50. package/dist/journal-delta-writes.test.js +140 -0
  51. package/dist/journal-delta-writes.test.js.map +1 -0
  52. package/dist/journal.d.ts +1 -0
  53. package/dist/journal.d.ts.map +1 -1
  54. package/dist/journal.js +155 -25
  55. package/dist/journal.js.map +1 -1
  56. package/dist/journal.test.js +29 -11
  57. package/dist/journal.test.js.map +1 -1
  58. package/dist/object-io.d.ts +34 -13
  59. package/dist/object-io.d.ts.map +1 -1
  60. package/dist/object-io.js +154 -29
  61. package/dist/object-io.js.map +1 -1
  62. package/dist/operation-lock.d.ts +12 -0
  63. package/dist/operation-lock.d.ts.map +1 -1
  64. package/dist/operation-lock.js +103 -21
  65. package/dist/operation-lock.js.map +1 -1
  66. package/dist/operation-lock.test.js +90 -0
  67. package/dist/operation-lock.test.js.map +1 -1
  68. package/dist/outcome-telemetry.d.ts +7 -1
  69. package/dist/outcome-telemetry.d.ts.map +1 -1
  70. package/dist/outcome-telemetry.js +15 -9
  71. package/dist/outcome-telemetry.js.map +1 -1
  72. package/dist/outcome-telemetry.test.js +15 -0
  73. package/dist/outcome-telemetry.test.js.map +1 -1
  74. package/dist/outposts/client.d.ts +2 -2
  75. package/dist/outposts/client.d.ts.map +1 -1
  76. package/dist/outposts/client.js +4 -3
  77. package/dist/outposts/client.js.map +1 -1
  78. package/dist/outposts/command.d.ts.map +1 -1
  79. package/dist/outposts/command.js +9 -6
  80. package/dist/outposts/command.js.map +1 -1
  81. package/dist/outposts/command.test.js +59 -2
  82. package/dist/outposts/command.test.js.map +1 -1
  83. package/dist/outposts/remote-command.d.ts +8 -8
  84. package/dist/outposts/remote-command.d.ts.map +1 -1
  85. package/dist/outposts/remote-command.js +10 -8
  86. package/dist/outposts/remote-command.js.map +1 -1
  87. package/dist/prefix-coalesce.d.ts +14 -0
  88. package/dist/prefix-coalesce.d.ts.map +1 -1
  89. package/dist/prefix-coalesce.js +29 -0
  90. package/dist/prefix-coalesce.js.map +1 -1
  91. package/dist/prefix-coalesce.test.js +22 -1
  92. package/dist/prefix-coalesce.test.js.map +1 -1
  93. package/dist/skill-telemetry.d.ts +6 -1
  94. package/dist/skill-telemetry.d.ts.map +1 -1
  95. package/dist/skill-telemetry.js +14 -8
  96. package/dist/skill-telemetry.js.map +1 -1
  97. package/dist/skill-telemetry.test.js +43 -0
  98. package/dist/skill-telemetry.test.js.map +1 -1
  99. package/dist/sts-credential-refresh.test.js +87 -2
  100. package/dist/sts-credential-refresh.test.js.map +1 -1
  101. package/dist/sync/state-store.d.ts.map +1 -1
  102. package/dist/sync/state-store.js +20 -1
  103. package/dist/sync/state-store.js.map +1 -1
  104. package/dist/sync/state-store.test.js +64 -0
  105. package/dist/sync/state-store.test.js.map +1 -1
  106. package/dist/sync-core.d.ts.map +1 -1
  107. package/dist/sync-core.js +7 -2
  108. package/dist/sync-core.js.map +1 -1
  109. package/dist/sync-core.test.js +36 -0
  110. package/dist/sync-core.test.js.map +1 -1
  111. package/dist/telemetry.d.ts +30 -6
  112. package/dist/telemetry.d.ts.map +1 -1
  113. package/dist/telemetry.js +331 -135
  114. package/dist/telemetry.js.map +1 -1
  115. package/dist/telemetry.test.js +403 -0
  116. package/dist/telemetry.test.js.map +1 -1
  117. package/dist/types.d.ts +26 -3
  118. package/dist/types.d.ts.map +1 -1
  119. package/dist/watcher.d.ts +76 -0
  120. package/dist/watcher.d.ts.map +1 -1
  121. package/dist/watcher.js +105 -3
  122. package/dist/watcher.js.map +1 -1
  123. package/dist/watcher.test.js +165 -1
  124. package/dist/watcher.test.js.map +1 -1
  125. package/package.json +1 -1
package/dist/telemetry.js CHANGED
@@ -7,10 +7,12 @@
7
7
  * (`hq-sync-runner`, `hq-cli`, mobile wrappers) emits telemetry uniformly.
8
8
  *
9
9
  * What it does: after each successful sync (`all-complete` arm of
10
- * `bin/sync-runner.ts`), walks Claude session logs plus live and archived Codex
11
- * rollouts, then diffs each file against the persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`, sanitizes new rows through a tight allowlist that matches the server's
12
- * KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
13
- * batches into server-sized POST bodies, and ships them to `/v1/usage`.
10
+ * `bin/sync-runner.ts`), walks Grok `updates.jsonl` sessions, live and archived
11
+ * Codex rollouts, and Claude session logs, then diffs each file against the
12
+ * persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`, sanitizes new
13
+ * rows through a tight allowlist that matches the server's KEEP_FIELDS set in
14
+ * `apps/hq-pro/src/vault-service/handlers/usage.ts`, batches into server-sized
15
+ * POST bodies, and ships them to `/v1/usage`.
14
16
  *
15
17
  * Trust model: the caller's `personUid` is resolved on the server from the
16
18
  * Cognito JWT — never from the body. `sanitizeRow` strips prompt bodies,
@@ -430,6 +432,167 @@ async function listCodexRolloutFiles(codexRoot) {
430
432
  ]);
431
433
  return sortFilesFreshestFirst([...live, ...archived].filter((file) => path.basename(file).startsWith("rollout-")));
432
434
  }
435
+ // ── Grok session adapter ─────────────────────────────────────────────────────
436
+ function grokTimestamp(value) {
437
+ if (typeof value === "string") {
438
+ return boundedString(value, MAX_TIMESTAMP_BYTES);
439
+ }
440
+ if (typeof value === "number" && Number.isFinite(value)) {
441
+ const ms = value > 1e12 ? value : value * 1000;
442
+ try {
443
+ return new Date(ms).toISOString();
444
+ }
445
+ catch {
446
+ return undefined;
447
+ }
448
+ }
449
+ return undefined;
450
+ }
451
+ function stableGrokEventId(sessionId, promptId, model) {
452
+ return ("grok-" +
453
+ createHash("sha256")
454
+ .update(sessionId)
455
+ .update(Buffer.from([0]))
456
+ .update(promptId)
457
+ .update(Buffer.from([0]))
458
+ .update(model)
459
+ .digest("hex"));
460
+ }
461
+ function grokUsageMessage(usage, model) {
462
+ const message = {
463
+ usage: {
464
+ input_tokens: tokenCount(usage.inputTokens),
465
+ output_tokens: tokenCount(usage.outputTokens) + tokenCount(usage.reasoningTokens),
466
+ cache_read_input_tokens: tokenCount(usage.cachedReadTokens),
467
+ cache_creation_input_tokens: tokenCount(usage.cacheCreationTokens),
468
+ },
469
+ };
470
+ if (model)
471
+ message.model = model;
472
+ return message;
473
+ }
474
+ /**
475
+ * Adapt a Grok `updates.jsonl` record to Claude-shaped sanitizer input(s).
476
+ * Emits only on `turn_completed` with `usage`. When `usage.modelUsage` is
477
+ * present, one row per model key (chart stacks by model id).
478
+ */
479
+ export function grokUsageRows(row, sessionMeta) {
480
+ if (!row || typeof row !== "object" || Array.isArray(row))
481
+ return [];
482
+ const obj = row;
483
+ const method = obj.method;
484
+ if (method !== "session/update" && method !== "_x.ai/session/update") {
485
+ return [];
486
+ }
487
+ const rawParams = obj.params;
488
+ if (!rawParams || typeof rawParams !== "object" || Array.isArray(rawParams)) {
489
+ return [];
490
+ }
491
+ const params = rawParams;
492
+ const rawUpdate = params.update;
493
+ if (!rawUpdate || typeof rawUpdate !== "object" || Array.isArray(rawUpdate)) {
494
+ return [];
495
+ }
496
+ const update = rawUpdate;
497
+ if (update.sessionUpdate !== "turn_completed")
498
+ return [];
499
+ const rawUsage = update.usage;
500
+ if (!rawUsage || typeof rawUsage !== "object" || Array.isArray(rawUsage)) {
501
+ return [];
502
+ }
503
+ const usage = rawUsage;
504
+ const sessionId = boundedString(params.sessionId, MAX_ID_BYTES);
505
+ const promptId = typeof update.prompt_id === "string" ? update.prompt_id : "";
506
+ const timestamp = grokTimestamp(obj.timestamp);
507
+ const updateMeta = update._meta;
508
+ const paramsMeta = params._meta;
509
+ const eventId = (updateMeta &&
510
+ typeof updateMeta === "object" &&
511
+ !Array.isArray(updateMeta)
512
+ ? boundedString(updateMeta.eventId, MAX_ID_BYTES)
513
+ : undefined) ??
514
+ (paramsMeta &&
515
+ typeof paramsMeta === "object" &&
516
+ !Array.isArray(paramsMeta)
517
+ ? boundedString(paramsMeta.eventId, MAX_ID_BYTES)
518
+ : undefined);
519
+ const buildRow = (model, modelUsage, preferEventId) => {
520
+ const normalized = {
521
+ uuid: preferEventId && eventId
522
+ ? eventId
523
+ : stableGrokEventId(sessionId ?? "", promptId, model ?? ""),
524
+ message: grokUsageMessage(modelUsage, model),
525
+ };
526
+ if (sessionId)
527
+ normalized.sessionId = sessionId;
528
+ if (timestamp)
529
+ normalized.timestamp = timestamp;
530
+ if (sessionMeta.cwd)
531
+ normalized.cwd = sessionMeta.cwd;
532
+ if (sessionMeta.gitBranch)
533
+ normalized.gitBranch = sessionMeta.gitBranch;
534
+ return normalized;
535
+ };
536
+ const modelUsage = usage.modelUsage;
537
+ if (modelUsage &&
538
+ typeof modelUsage === "object" &&
539
+ !Array.isArray(modelUsage)) {
540
+ const entries = Object.entries(modelUsage).filter(([, entry]) => !!entry && typeof entry === "object" && !Array.isArray(entry));
541
+ const single = entries.length === 1;
542
+ const rows = [];
543
+ for (const [modelKey, entry] of entries) {
544
+ const model = boundedString(modelKey, MAX_MODEL_BYTES);
545
+ if (!model)
546
+ continue;
547
+ rows.push(buildRow(model, entry, single));
548
+ }
549
+ return rows;
550
+ }
551
+ return [
552
+ buildRow(sessionMeta.fallback_model, usage, true),
553
+ ];
554
+ }
555
+ function decodeGrokCwdFolder(encoded) {
556
+ try {
557
+ return boundedString(decodeURIComponent(encoded), MAX_PATH_BYTES);
558
+ }
559
+ catch {
560
+ return boundedString(encoded, MAX_PATH_BYTES);
561
+ }
562
+ }
563
+ /** Load cwd / branch / fallback model from sibling `summary.json` once per file. */
564
+ export async function loadGrokSessionMeta(updatesPath) {
565
+ const sessionDir = path.dirname(updatesPath);
566
+ const encodedCwdDir = path.basename(path.dirname(sessionDir));
567
+ const meta = {};
568
+ try {
569
+ const raw = await fs.readFile(path.join(sessionDir, "summary.json"), "utf-8");
570
+ const parsed = JSON.parse(raw);
571
+ const info = parsed.info && typeof parsed.info === "object" && !Array.isArray(parsed.info)
572
+ ? parsed.info
573
+ : undefined;
574
+ meta.cwd =
575
+ boundedString(info?.cwd, MAX_PATH_BYTES) ??
576
+ boundedString(parsed.cwd, MAX_PATH_BYTES);
577
+ meta.gitBranch =
578
+ boundedString(parsed.head_branch, MAX_PATH_BYTES) ??
579
+ boundedString(info?.head_branch, MAX_PATH_BYTES);
580
+ meta.fallback_model =
581
+ boundedString(parsed.current_model_id, MAX_MODEL_BYTES) ??
582
+ boundedString(info?.current_model_id, MAX_MODEL_BYTES);
583
+ }
584
+ catch {
585
+ // Missing / unreadable summary — fall through to folder-name decode.
586
+ }
587
+ if (!meta.cwd) {
588
+ meta.cwd = decodeGrokCwdFolder(encodedCwdDir);
589
+ }
590
+ return meta;
591
+ }
592
+ async function listGrokUpdateFiles(grokRoot) {
593
+ const all = await listJsonlFiles(path.join(grokRoot, "sessions"));
594
+ return sortFilesFreshestFirst(all.filter((file) => path.basename(file) === "updates.jsonl"));
595
+ }
433
596
  // ── Batching primitives ───────────────────────────────────────────────────────
434
597
  const MAX_BATCH_EVENTS = 100;
435
598
  const MAX_BATCH_BYTES = 240 * 1024;
@@ -494,7 +657,7 @@ function boundRowForPost(row, maxRowBytes) {
494
657
  }
495
658
  // ── Main entry point ──────────────────────────────────────────────────────────
496
659
  /**
497
- * Scan, sanitize, and POST new Claude Code and Codex usage rows.
660
+ * Scan, sanitize, and POST new Grok, Codex, and Claude Code usage rows.
498
661
  *
499
662
  * Fire-and-forget from the caller's perspective: errors are caught internally
500
663
  * and surfaced only via `log`. The returned summary lets observers (e.g.
@@ -505,6 +668,7 @@ export async function collectAndSendTelemetry(opts) {
505
668
  const home = os.homedir();
506
669
  const claudeProjectsRoot = opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
507
670
  const codexRoot = opts.codexRoot ?? path.join(home, ".codex");
671
+ const grokRoot = opts.grokRoot ?? path.join(home, ".grok");
508
672
  const cursorPath = opts.cursorPath ?? path.join(home, ".hq", "telemetry-cursor.json");
509
673
  const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
510
674
  const log = opts.log ?? (() => { });
@@ -522,73 +686,80 @@ export async function collectAndSendTelemetry(opts) {
522
686
  ? repoCompanyMap.bySlug.get(opts.fallbackCompany)
523
687
  : undefined;
524
688
  // 1. Opt-in check (server-authoritative, with local fallback + self-heal).
689
+ // Fleet agents skip this: collection is always on (company + unattributed).
525
690
  let enabled;
526
691
  let optInSource;
527
- try {
528
- const resp = await opts.client.getTelemetryOptIn();
529
- enabled = resp.enabled === true;
530
- optInSource = "server";
531
- // Self-heal a consent the user gave but that never reached the server.
532
- //
533
- // The installer writes the answer to `~/.hq/menubar.json` (always succeeds)
534
- // AND posts it to `/v1/usage/opt-in` — but that post fires before the
535
- // person entity exists, so it 404s and the attribute is never written.
536
- // Absence then reads as `false`, the emitter goes silent, and the person
537
- // shows as "not opted in" forever. Measured: 22 of 33 active Indigo members
538
- // had no attribute at all, against only 2 genuine opt-outs.
539
- //
540
- // `unset` (never answered) is the ONLY state we heal. An explicit server
541
- // `false` is a real opt-out and is left strictly alone. We also require a
542
- // local answer to actually exist — we never invent consent, we only replay
543
- // the answer the user already gave.
544
- //
545
- // And it must be THIS account's answer. `menubar.json` is per-MACHINE, so
546
- // when two people sign in under the same OS user it holds whoever answered
547
- // last; replaying that for the second account would opt in someone who
548
- // never consented. The replay therefore requires the cached record to name
549
- // the same `prs_*` the server says we are. A legacy record with no binding
550
- // cannot be proven to belong to this caller and is skipped — for those
551
- // machines the installer's own post-sign-in upload is the recovery path,
552
- // and it is correctly account-scoped because it runs right after that
553
- // person authenticates.
554
- if (resp.unset === true && opts.client.setTelemetryOptIn) {
555
- const record = await readLocalConsentRecord(menubarPath);
556
- const local = record.enabled;
557
- const boundToCaller = record.personUid !== undefined &&
558
- resp.personUid !== undefined &&
559
- record.personUid === resp.personUid;
560
- if (local !== undefined && !boundToCaller) {
561
- log("[telemetry] skipping consent re-assert: the locally cached answer is not bound to the signed-in account");
562
- }
563
- if (local !== undefined && boundToCaller) {
564
- try {
565
- // `onlyIfUnset` keeps the replay atomic. Reading `unset` and writing
566
- // are two requests, so without it another device could record a real
567
- // opt-out in between and this stale local answer would overwrite it.
568
- // The server tests "still unset?" as part of the write instead.
569
- const ack = await opts.client.setTelemetryOptIn(local, { onlyIfUnset: true });
570
- if (ack && ack.applied === false) {
571
- // Lost the race — a real answer was recorded first and it stands.
572
- // Keep the server's answer for this run rather than the local one.
573
- log("[telemetry] consent was recorded elsewhere before the re-assert landed; deferring to the server");
692
+ if (opts.forceCollect) {
693
+ enabled = true;
694
+ optInSource = "agent-required";
695
+ }
696
+ else {
697
+ try {
698
+ const resp = await opts.client.getTelemetryOptIn();
699
+ enabled = resp.enabled === true;
700
+ optInSource = "server";
701
+ // Self-heal a consent the user gave but that never reached the server.
702
+ //
703
+ // The installer writes the answer to `~/.hq/menubar.json` (always succeeds)
704
+ // AND posts it to `/v1/usage/opt-in` — but that post fires before the
705
+ // person entity exists, so it 404s and the attribute is never written.
706
+ // Absence then reads as `false`, the emitter goes silent, and the person
707
+ // shows as "not opted in" forever. Measured: 22 of 33 active Indigo members
708
+ // had no attribute at all, against only 2 genuine opt-outs.
709
+ //
710
+ // `unset` (never answered) is the ONLY state we heal. An explicit server
711
+ // `false` is a real opt-out and is left strictly alone. We also require a
712
+ // local answer to actually exist — we never invent consent, we only replay
713
+ // the answer the user already gave.
714
+ //
715
+ // And it must be THIS account's answer. `menubar.json` is per-MACHINE, so
716
+ // when two people sign in under the same OS user it holds whoever answered
717
+ // last; replaying that for the second account would opt in someone who
718
+ // never consented. The replay therefore requires the cached record to name
719
+ // the same `prs_*` the server says we are. A legacy record with no binding
720
+ // cannot be proven to belong to this caller and is skipped — for those
721
+ // machines the installer's own post-sign-in upload is the recovery path,
722
+ // and it is correctly account-scoped because it runs right after that
723
+ // person authenticates.
724
+ if (resp.unset === true && opts.client.setTelemetryOptIn) {
725
+ const record = await readLocalConsentRecord(menubarPath);
726
+ const local = record.enabled;
727
+ const boundToCaller = record.personUid !== undefined &&
728
+ resp.personUid !== undefined &&
729
+ record.personUid === resp.personUid;
730
+ if (local !== undefined && !boundToCaller) {
731
+ log("[telemetry] skipping consent re-assert: the locally cached answer is not bound to the signed-in account");
732
+ }
733
+ if (local !== undefined && boundToCaller) {
734
+ try {
735
+ // `onlyIfUnset` keeps the replay atomic. Reading `unset` and writing
736
+ // are two requests, so without it another device could record a real
737
+ // opt-out in between and this stale local answer would overwrite it.
738
+ // The server tests "still unset?" as part of the write instead.
739
+ const ack = await opts.client.setTelemetryOptIn(local, { onlyIfUnset: true });
740
+ if (ack && ack.applied === false) {
741
+ // Lost the race — a real answer was recorded first and it stands.
742
+ // Keep the server's answer for this run rather than the local one.
743
+ log("[telemetry] consent was recorded elsewhere before the re-assert landed; deferring to the server");
744
+ }
745
+ else {
746
+ enabled = local;
747
+ optInSource = "menubar-reasserted";
748
+ log(`[telemetry] server had no recorded consent; re-asserted the local install-time answer (enabled=${local})`);
749
+ }
574
750
  }
575
- else {
576
- enabled = local;
577
- optInSource = "menubar-reasserted";
578
- log(`[telemetry] server had no recorded consent; re-asserted the local install-time answer (enabled=${local})`);
751
+ catch (err) {
752
+ // Non-fatal: fall through on the server's answer. Next run retries.
753
+ log(`[telemetry] failed to re-assert local opt-in (${err.message ?? err})`);
579
754
  }
580
755
  }
581
- catch (err) {
582
- // Non-fatal: fall through on the server's answer. Next run retries.
583
- log(`[telemetry] failed to re-assert local opt-in (${err.message ?? err})`);
584
- }
585
756
  }
586
757
  }
587
- }
588
- catch (err) {
589
- log(`[telemetry] opt-in check failed (${err.message ?? err}) — falling back to local menubar.json`);
590
- enabled = await readLocalTelemetryEnabled(menubarPath);
591
- optInSource = "menubar-fallback";
758
+ catch (err) {
759
+ log(`[telemetry] opt-in check failed (${err.message ?? err}) — falling back to local menubar.json`);
760
+ enabled = await readLocalTelemetryEnabled(menubarPath);
761
+ optInSource = "menubar-fallback";
762
+ }
592
763
  }
593
764
  // Consent split (company-scope mandatory capture): a FALSE/unset personal
594
765
  // opt-in silences PERSONAL/unattributed rows only. Rows produced inside a
@@ -608,14 +779,16 @@ export async function collectAndSendTelemetry(opts) {
608
779
  const loadedFiles = { ...cursor.files };
609
780
  const rotationResets = {};
610
781
  const newlyCommitted = {};
611
- const [claudeFilesUnsorted, codexFiles] = await Promise.all([
782
+ const [claudeFilesUnsorted, codexFiles, grokFiles] = await Promise.all([
612
783
  listJsonlFiles(claudeProjectsRoot),
613
784
  listCodexRolloutFiles(codexRoot),
785
+ listGrokUpdateFiles(grokRoot),
614
786
  ]);
615
787
  const claudeFiles = await sortFilesFreshestFirst(claudeFilesUnsorted);
616
- // Codex goes first so a fresh Linux install cannot spend its entire bounded
617
- // pass replaying an older Claude backlog before reporting current Codex use.
788
+ // Newest runtimes first so a bounded first pass reports current Grok/Codex
789
+ // use before replaying an older Claude backlog.
618
790
  const files = [
791
+ ...grokFiles.map((filePath) => ({ filePath, kind: "grok" })),
619
792
  ...codexFiles.map((filePath) => ({ filePath, kind: "codex" })),
620
793
  ...claudeFiles.map((filePath) => ({ filePath, kind: "claude" })),
621
794
  ];
@@ -635,7 +808,7 @@ export async function collectAndSendTelemetry(opts) {
635
808
  let batchesSent = 0;
636
809
  let uploadFailed = false;
637
810
  let batchLimitReached = false;
638
- const scannedBytes = { claude: 0, codex: 0 };
811
+ const scannedBytes = { claude: 0, codex: 0, grok: 0 };
639
812
  const commitSources = (sources) => {
640
813
  const maxPerFile = new Map();
641
814
  for (const source of sources) {
@@ -704,16 +877,21 @@ export async function collectAndSendTelemetry(opts) {
704
877
  const currentMtime = Math.floor(stat.mtimeMs / 1000);
705
878
  const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
706
879
  let offset = stored.offset;
707
- let codexContext = kind === "codex" ? cloneCodexContext(stored.context ?? {}) : undefined;
880
+ const needsScanContext = kind === "codex" || kind === "grok";
881
+ let scanContext = needsScanContext
882
+ ? cloneCodexContext(stored.context ?? {})
883
+ : undefined;
708
884
  const rotated = currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
709
885
  if (rotated) {
710
886
  offset = 0;
711
- if (kind === "codex")
712
- codexContext = {};
887
+ if (needsScanContext)
888
+ scanContext = {};
713
889
  rotationResets[filePath] = { offset: 0, mtime: currentMtime };
714
890
  }
715
891
  if (offset >= currentSize && !rotated)
716
892
  continue;
893
+ // Grok session meta is loaded once per updates.jsonl (not persisted in cursor).
894
+ const grokSessionMeta = kind === "grok" ? await loadGrokSessionMeta(filePath) : undefined;
717
895
  let content;
718
896
  let bytesRead = 0;
719
897
  let readBuffer = Buffer.alloc(0);
@@ -734,19 +912,19 @@ export async function collectAndSendTelemetry(opts) {
734
912
  }
735
913
  if (bytesRead === 0)
736
914
  continue;
737
- // Resume a previously discarded oversized Codex row using raw byte
915
+ // Resume a previously discarded oversized Codex/Grok row using raw byte
738
916
  // positions. Decoding from the middle of a multibyte UTF-8 codepoint would
739
917
  // otherwise skew byte offsets and could skip or replay later rows.
740
- if (codexContext?.discarding_partial_line) {
918
+ if (scanContext?.discarding_partial_line) {
741
919
  const newlineIndex = readBuffer.subarray(0, bytesRead).indexOf(0x0a);
742
920
  const discardedBytes = newlineIndex >= 0 ? newlineIndex + 1 : bytesRead;
743
- codexContext.discarding_partial_line = newlineIndex < 0;
921
+ scanContext.discarding_partial_line = newlineIndex < 0;
744
922
  scannedBytes[kind] += discardedBytes;
745
923
  batchSources.push({
746
924
  filePath,
747
925
  endOffset: offset + discardedBytes,
748
926
  mtime: currentMtime,
749
- context: cloneCodexContext(codexContext),
927
+ context: cloneCodexContext(scanContext),
750
928
  });
751
929
  if (newlineIndex < 0)
752
930
  continue;
@@ -775,17 +953,17 @@ export async function collectAndSendTelemetry(opts) {
775
953
  : lineEndOffsets[i];
776
954
  const lineBytes = endOffset - startOffset;
777
955
  if (!hasNewline && !reachedPhysicalEof) {
778
- const discarding = codexContext?.discarding_partial_line === true;
956
+ const discarding = scanContext?.discarding_partial_line === true;
779
957
  if (discarding || lineBytes >= MAX_PARTIAL_LINE_BYTES) {
780
- if (codexContext)
781
- codexContext.discarding_partial_line = true;
958
+ if (scanContext)
959
+ scanContext.discarding_partial_line = true;
782
960
  scannedBytes[kind] += lineBytes;
783
961
  batchSources.push({
784
962
  filePath,
785
963
  endOffset,
786
964
  mtime: currentMtime,
787
- ...(codexContext
788
- ? { context: cloneCodexContext(codexContext) }
965
+ ...(scanContext
966
+ ? { context: cloneCodexContext(scanContext) }
789
967
  : {}),
790
968
  });
791
969
  }
@@ -796,12 +974,12 @@ export async function collectAndSendTelemetry(opts) {
796
974
  filePath,
797
975
  endOffset,
798
976
  mtime: currentMtime,
799
- ...(codexContext
800
- ? { context: cloneCodexContext(codexContext) }
977
+ ...(scanContext
978
+ ? { context: cloneCodexContext(scanContext) }
801
979
  : {}),
802
980
  });
803
- if (codexContext?.discarding_partial_line) {
804
- codexContext.discarding_partial_line = false;
981
+ if (scanContext?.discarding_partial_line) {
982
+ scanContext.discarding_partial_line = false;
805
983
  batchSources.push(source());
806
984
  continue;
807
985
  }
@@ -818,65 +996,83 @@ export async function collectAndSendTelemetry(opts) {
818
996
  batchSources.push(source());
819
997
  continue;
820
998
  }
821
- const sourceRow = kind === "codex"
822
- ? codexUsageRow(parsed, codexContext, filePath, startOffset, endOffset)
823
- : parsed;
824
- const rowCwd = sourceRow && typeof sourceRow === "object" && !Array.isArray(sourceRow)
825
- ? sourceRow.cwd
826
- : undefined;
827
- const companyUid = resolveCompanyForCwd(typeof rowCwd === "string" ? rowCwd : undefined, repoCompanyMap) ?? fallbackCompanyUid;
828
- // Company-scope-only mode: a row that resolves to NO company is personal
829
- // work by an opted-out person — skipped, but still cursor-advanced (via
830
- // batchSources) exactly like a sanitize-rejected row, so it is not
831
- // re-read every run.
832
- if (companyScopeOnly && !companyUid) {
833
- batchSources.push(source());
834
- continue;
999
+ let adaptedRows;
1000
+ if (kind === "codex") {
1001
+ const adapted = codexUsageRow(parsed, scanContext, filePath, startOffset, endOffset);
1002
+ adaptedRows = adapted ? [adapted] : [];
835
1003
  }
836
- const sanitized = sanitizeRow(sourceRow, companyUid);
837
- if (!sanitized) {
838
- batchSources.push(source());
839
- continue;
1004
+ else if (kind === "grok") {
1005
+ adaptedRows = grokUsageRows(parsed, grokSessionMeta ?? {});
840
1006
  }
841
- const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
842
- const wasOversized = jsonBytes(sanitized) > maxRowBytes;
843
- const bounded = boundRowForPost(sanitized, maxRowBytes);
844
- if (!bounded) {
845
- log("[telemetry] oversized row dropped before send (" +
846
- filePath +
847
- ":" +
848
- (i + 1) +
849
- ")");
1007
+ else {
1008
+ adaptedRows = [parsed];
1009
+ }
1010
+ if (adaptedRows.length === 0) {
850
1011
  batchSources.push(source());
851
1012
  continue;
852
1013
  }
853
- if (wasOversized) {
854
- log("[telemetry] oversized row truncated before send (" +
855
- filePath +
856
- ":" +
857
- (i + 1) +
858
- ")");
859
- }
860
- const rowJsonBytes = jsonBytes(bounded);
861
- const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
862
- if (batchEvents.length > 0 &&
863
- (batchEvents.length >= MAX_BATCH_EVENTS ||
864
- batchBytes + addCost > MAX_BATCH_BYTES)) {
865
- if (!(await flush())) {
866
- uploadFailed = true;
867
- break filesLoop;
1014
+ let emittedFromLine = 0;
1015
+ for (const sourceRow of adaptedRows) {
1016
+ const rowCwd = sourceRow && typeof sourceRow === "object" && !Array.isArray(sourceRow)
1017
+ ? sourceRow.cwd
1018
+ : undefined;
1019
+ const companyUid = resolveCompanyForCwd(typeof rowCwd === "string" ? rowCwd : undefined, repoCompanyMap) ?? fallbackCompanyUid;
1020
+ // Company-scope-only mode: a row that resolves to NO company is personal
1021
+ // work by an opted-out person — skipped, but still cursor-advanced (via
1022
+ // batchSources) exactly like a sanitize-rejected row, so it is not
1023
+ // re-read every run.
1024
+ if (companyScopeOnly && !companyUid) {
1025
+ continue;
1026
+ }
1027
+ const sanitized = sanitizeRow(sourceRow, companyUid);
1028
+ if (!sanitized) {
1029
+ continue;
868
1030
  }
869
- if (batchesSent >= maxBatchesPerRun) {
870
- batchLimitReached = true;
871
- break filesLoop;
1031
+ const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
1032
+ const wasOversized = jsonBytes(sanitized) > maxRowBytes;
1033
+ const bounded = boundRowForPost(sanitized, maxRowBytes);
1034
+ if (!bounded) {
1035
+ log("[telemetry] oversized row dropped before send (" +
1036
+ filePath +
1037
+ ":" +
1038
+ (i + 1) +
1039
+ ")");
1040
+ continue;
872
1041
  }
873
- batchBytes = ENVELOPE_BYTES + rowJsonBytes;
1042
+ if (wasOversized) {
1043
+ log("[telemetry] oversized row truncated before send (" +
1044
+ filePath +
1045
+ ":" +
1046
+ (i + 1) +
1047
+ ")");
1048
+ }
1049
+ const rowJsonBytes = jsonBytes(bounded);
1050
+ const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
1051
+ if (batchEvents.length > 0 &&
1052
+ (batchEvents.length >= MAX_BATCH_EVENTS ||
1053
+ batchBytes + addCost > MAX_BATCH_BYTES)) {
1054
+ if (!(await flush())) {
1055
+ uploadFailed = true;
1056
+ break filesLoop;
1057
+ }
1058
+ if (batchesSent >= maxBatchesPerRun) {
1059
+ batchLimitReached = true;
1060
+ break filesLoop;
1061
+ }
1062
+ batchBytes = ENVELOPE_BYTES + rowJsonBytes;
1063
+ }
1064
+ else {
1065
+ batchBytes += addCost;
1066
+ }
1067
+ batchEvents.push(bounded);
1068
+ // Same endOffset for every model row from this JSONL line; commitSources
1069
+ // keeps the max, so the cursor advances once per line.
1070
+ batchSources.push(source());
1071
+ emittedFromLine++;
874
1072
  }
875
- else {
876
- batchBytes += addCost;
1073
+ if (emittedFromLine === 0) {
1074
+ batchSources.push(source());
877
1075
  }
878
- batchEvents.push(bounded);
879
- batchSources.push(source());
880
1076
  }
881
1077
  }
882
1078
  if (!uploadFailed && !batchLimitReached) {