@indigoai-us/hq-cloud 6.14.29 → 6.14.31

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.
@@ -7,9 +7,8 @@
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/projects/**\/*.jsonl`, diffs each
11
- * file against a persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`,
12
- * sanitizes new rows through a tight allowlist that matches the server's
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
13
12
  * KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
14
13
  * batches into server-sized POST bodies, and ships them to `/v1/usage`.
15
14
  *
@@ -58,6 +57,12 @@ export interface CollectTelemetryOptions {
58
57
  hqRoot?: string;
59
58
  /** Override `~/.claude/projects` for tests. */
60
59
  claudeProjectsRoot?: string;
60
+ /** Override `~/.codex` for tests. Both live and archived rollouts are scanned. */
61
+ codexRoot?: string;
62
+ /** Override the per-runtime scan budget for deterministic tests. */
63
+ maxScanBytesPerSource?: number;
64
+ /** Override the upload batch cap for deterministic tests. */
65
+ maxBatchesPerRun?: number;
61
66
  /** Override `~/.hq/telemetry-cursor.json` for tests. */
62
67
  cursorPath?: string;
63
68
  /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
@@ -124,7 +129,7 @@ export declare function extractServices(message: unknown): Record<string, number
124
129
  */
125
130
  export declare function sanitizeRow(row: unknown, companyUid?: string): Record<string, unknown> | null;
126
131
  /**
127
- * Scan, sanitize, and POST any new Claude Code session rows.
132
+ * Scan, sanitize, and POST new Claude Code and Codex usage rows.
128
133
  *
129
134
  * Fire-and-forget from the caller's perspective: errors are caught internally
130
135
  * and surfaced only via `log`. The returned summary lets observers (e.g.
@@ -1 +1 @@
1
- {"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAWH,OAAO,KAAK,EACV,sBAAsB,EACtB,UAAU,EACV,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAI3B;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,iBAAiB,IAAI,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACrD,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACzD;;;OAGG;IACH,iBAAiB,CAAC,CAChB,OAAO,EAAE,OAAO,EAChB,IAAI,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,OAAO,CAAA;KAAE,GAC/B,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,sBAAsB,CAAC;IAC/B,oJAAoJ;IACpJ,SAAS,EAAE,MAAM,CAAC;IAClB,+HAA+H;IAC/H,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yCAAyC;IACzC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,sBAAsB;IACrC,gIAAgI;IAChI,OAAO,EAAE,OAAO,CAAC;IACjB,wFAAwF;IACxF,WAAW,EAAE,QAAQ,GAAG,kBAAkB,GAAG,oBAAoB,GAAG,SAAS,CAAC;IAC9E,sEAAsE;IACtE,YAAY,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,UAAU,EAAE,MAAM,CAAC;IACnB,gDAAgD;IAChD,WAAW,EAAE,MAAM,CAAC;CACrB;AAsID;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,OAAO,GACf,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAyBpC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,OAAO,EACZ,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CA4ChC;AA0GD;;;;;;;GAOG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,uBAAuB,GAC5B,OAAO,CAAC,sBAAsB,CAAC,CAsRjC"}
1
+ {"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAYH,OAAO,KAAK,EACV,sBAAsB,EACtB,UAAU,EACV,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAI3B;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,iBAAiB,IAAI,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACrD,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACzD;;;OAGG;IACH,iBAAiB,CAAC,CAChB,OAAO,EAAE,OAAO,EAChB,IAAI,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,OAAO,CAAA;KAAE,GAC/B,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,sBAAsB,CAAC;IAC/B,oJAAoJ;IACpJ,SAAS,EAAE,MAAM,CAAC;IAClB,+HAA+H;IAC/H,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yCAAyC;IACzC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,sBAAsB;IACrC,gIAAgI;IAChI,OAAO,EAAE,OAAO,CAAC;IACjB,wFAAwF;IACxF,WAAW,EAAE,QAAQ,GAAG,kBAAkB,GAAG,oBAAoB,GAAG,SAAS,CAAC;IAC9E,sEAAsE;IACtE,YAAY,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,UAAU,EAAE,MAAM,CAAC;IACnB,gDAAgD;IAChD,WAAW,EAAE,MAAM,CAAC;CACrB;AA0JD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,OAAO,GACf,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAyBpC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,OAAO,EACZ,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CA4ChC;AA8RD;;;;;;;GAOG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,uBAAuB,GAC5B,OAAO,CAAC,sBAAsB,CAAC,CAwajC"}
package/dist/telemetry.js CHANGED
@@ -7,9 +7,8 @@
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/projects/**\/*.jsonl`, diffs each
11
- * file against a persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`,
12
- * sanitizes new rows through a tight allowlist that matches the server's
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
13
12
  * KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
14
13
  * batches into server-sized POST bodies, and ships them to `/v1/usage`.
15
14
  *
@@ -22,6 +21,7 @@
22
21
  * sync. The cursor is only advanced for batches the server 2xx'd, so a
23
22
  * transient outage retries automatically on the next sync.
24
23
  */
24
+ import { createHash } from "node:crypto";
25
25
  import { promises as fs } from "node:fs";
26
26
  import * as os from "node:os";
27
27
  import * as path from "node:path";
@@ -34,7 +34,13 @@ async function loadCursor(cursorPath) {
34
34
  const raw = await fs.readFile(cursorPath, "utf-8");
35
35
  const parsed = JSON.parse(raw);
36
36
  if (parsed && typeof parsed === "object" && parsed.files && typeof parsed.files === "object") {
37
- return { version: parsed.version ?? "1", files: parsed.files };
37
+ return {
38
+ version: parsed.version ?? "1",
39
+ files: parsed.files,
40
+ ...(typeof parsed.codex_next_rollout === "string"
41
+ ? { codex_next_rollout: parsed.codex_next_rollout }
42
+ : {}),
43
+ };
38
44
  }
39
45
  }
40
46
  catch {
@@ -274,9 +280,162 @@ async function listJsonlFiles(root) {
274
280
  await walk(root);
275
281
  return out;
276
282
  }
283
+ // ── Codex rollout adapter ────────────────────────────────────────────────────
284
+ const MAX_ID_BYTES = 256;
285
+ const MAX_TIMESTAMP_BYTES = 128;
286
+ const MAX_PATH_BYTES = 4 * 1024;
287
+ const MAX_MODEL_BYTES = 256;
288
+ function boundedString(value, maxBytes) {
289
+ return typeof value === "string" &&
290
+ value.length > 0 &&
291
+ Buffer.byteLength(value, "utf-8") <= maxBytes
292
+ ? value
293
+ : undefined;
294
+ }
295
+ function tokenCount(value) {
296
+ return typeof value === "number" &&
297
+ Number.isSafeInteger(value) &&
298
+ value >= 0
299
+ ? value
300
+ : 0;
301
+ }
302
+ function cloneCodexContext(context) {
303
+ return { ...context };
304
+ }
305
+ function codexModel(context) {
306
+ return context.turn_model ?? context.collaboration_model ?? context.session_model;
307
+ }
308
+ function codexGitBranch(payload) {
309
+ const direct = boundedString(payload.gitBranch, MAX_PATH_BYTES) ??
310
+ boundedString(payload.git_branch, MAX_PATH_BYTES);
311
+ if (direct)
312
+ return direct;
313
+ const git = payload.git;
314
+ return git && typeof git === "object" && !Array.isArray(git)
315
+ ? boundedString(git.branch, MAX_PATH_BYTES)
316
+ : undefined;
317
+ }
318
+ function collaborationModel(payload) {
319
+ const settings = payload.settings;
320
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
321
+ return undefined;
322
+ }
323
+ return boundedString(settings.model, MAX_MODEL_BYTES);
324
+ }
325
+ function stableCodexEventId(rolloutIdentity, startOffset, endOffset) {
326
+ const offsets = Buffer.alloc(16);
327
+ offsets.writeBigUInt64BE(BigInt(startOffset), 0);
328
+ offsets.writeBigUInt64BE(BigInt(endOffset), 8);
329
+ return "codex-" + createHash("sha256")
330
+ .update(rolloutIdentity)
331
+ .update(Buffer.from([0]))
332
+ .update(offsets)
333
+ .digest("hex");
334
+ }
335
+ /** Adapt a Codex rollout record to the Claude-shaped sanitizer input. */
336
+ function codexUsageRow(row, context, rolloutIdentity, startOffset, endOffset) {
337
+ if (!row || typeof row !== "object" || Array.isArray(row))
338
+ return null;
339
+ const obj = row;
340
+ const kind = obj.type;
341
+ const rawPayload = obj.payload;
342
+ const payload = rawPayload && typeof rawPayload === "object" && !Array.isArray(rawPayload)
343
+ ? rawPayload
344
+ : undefined;
345
+ if (kind === "session_meta") {
346
+ if (!payload)
347
+ return null;
348
+ context.session_id = boundedString(payload.id, MAX_ID_BYTES) ?? context.session_id;
349
+ context.cwd = boundedString(payload.cwd, MAX_PATH_BYTES);
350
+ context.git_branch = codexGitBranch(payload);
351
+ context.session_model = boundedString(payload.model, MAX_MODEL_BYTES);
352
+ return null;
353
+ }
354
+ if (kind === "turn_context") {
355
+ context.turn_model = payload
356
+ ? boundedString(payload.model, MAX_MODEL_BYTES)
357
+ : undefined;
358
+ const mode = payload?.collaboration_mode;
359
+ if (mode && typeof mode === "object" && !Array.isArray(mode)) {
360
+ const model = collaborationModel(mode);
361
+ if (model)
362
+ context.collaboration_model = model;
363
+ }
364
+ return null;
365
+ }
366
+ if (kind === "collaboration_mode") {
367
+ context.collaboration_model = payload
368
+ ? collaborationModel(payload)
369
+ : undefined;
370
+ return null;
371
+ }
372
+ if (kind !== "event_msg" || !payload)
373
+ return null;
374
+ if (payload.type === "collaboration_mode") {
375
+ context.collaboration_model = collaborationModel(payload);
376
+ return null;
377
+ }
378
+ if (payload.type !== "token_count")
379
+ return null;
380
+ const info = payload.info;
381
+ if (!info || typeof info !== "object" || Array.isArray(info))
382
+ return null;
383
+ const last = info.last_token_usage;
384
+ if (!last || typeof last !== "object" || Array.isArray(last))
385
+ return null;
386
+ const usage = last;
387
+ const outputTokens = tokenCount(usage.output_tokens) + tokenCount(usage.reasoning_output_tokens);
388
+ const model = codexModel(context);
389
+ const normalized = {
390
+ uuid: boundedString(obj.uuid, MAX_ID_BYTES) ??
391
+ stableCodexEventId(rolloutIdentity, startOffset, endOffset),
392
+ message: {
393
+ usage: {
394
+ input_tokens: tokenCount(usage.input_tokens),
395
+ output_tokens: outputTokens,
396
+ ...(typeof usage.cached_input_tokens === "number"
397
+ ? { cache_read_input_tokens: tokenCount(usage.cached_input_tokens) }
398
+ : {}),
399
+ },
400
+ ...(model ? { model } : {}),
401
+ },
402
+ };
403
+ if (context.session_id)
404
+ normalized.sessionId = context.session_id;
405
+ const timestamp = boundedString(obj.timestamp, MAX_TIMESTAMP_BYTES);
406
+ if (timestamp)
407
+ normalized.timestamp = timestamp;
408
+ if (context.cwd)
409
+ normalized.cwd = context.cwd;
410
+ if (context.git_branch)
411
+ normalized.gitBranch = context.git_branch;
412
+ return normalized;
413
+ }
414
+ async function sortFilesFreshestFirst(files) {
415
+ const ranked = await Promise.all(files.map(async (filePath) => {
416
+ try {
417
+ return { filePath, mtime: (await fs.stat(filePath)).mtimeMs };
418
+ }
419
+ catch {
420
+ return { filePath, mtime: 0 };
421
+ }
422
+ }));
423
+ ranked.sort((left, right) => right.mtime - left.mtime || left.filePath.localeCompare(right.filePath));
424
+ return ranked.map(({ filePath }) => filePath);
425
+ }
426
+ async function listCodexRolloutFiles(codexRoot) {
427
+ const [live, archived] = await Promise.all([
428
+ listJsonlFiles(path.join(codexRoot, "sessions")),
429
+ listJsonlFiles(path.join(codexRoot, "archived_sessions")),
430
+ ]);
431
+ return sortFilesFreshestFirst([...live, ...archived].filter((file) => path.basename(file).startsWith("rollout-")));
432
+ }
277
433
  // ── Batching primitives ───────────────────────────────────────────────────────
278
434
  const MAX_BATCH_EVENTS = 100;
279
435
  const MAX_BATCH_BYTES = 240 * 1024;
436
+ const MAX_SCAN_BYTES_PER_SOURCE = 4 * 1024 * 1024;
437
+ const MAX_BATCHES_PER_RUN = 4;
438
+ const MAX_PARTIAL_LINE_BYTES = 64 * 1024;
280
439
  const ROW_TRUNCATION_SUFFIX = "...[truncated]";
281
440
  /**
282
441
  * Byte cost of the fixed wire-payload skeleton:
@@ -335,7 +494,7 @@ function boundRowForPost(row, maxRowBytes) {
335
494
  }
336
495
  // ── Main entry point ──────────────────────────────────────────────────────────
337
496
  /**
338
- * Scan, sanitize, and POST any new Claude Code session rows.
497
+ * Scan, sanitize, and POST new Claude Code and Codex usage rows.
339
498
  *
340
499
  * Fire-and-forget from the caller's perspective: errors are caught internally
341
500
  * and surfaced only via `log`. The returned summary lets observers (e.g.
@@ -345,9 +504,12 @@ function boundRowForPost(row, maxRowBytes) {
345
504
  export async function collectAndSendTelemetry(opts) {
346
505
  const home = os.homedir();
347
506
  const claudeProjectsRoot = opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
507
+ const codexRoot = opts.codexRoot ?? path.join(home, ".codex");
348
508
  const cursorPath = opts.cursorPath ?? path.join(home, ".hq", "telemetry-cursor.json");
349
509
  const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
350
510
  const log = opts.log ?? (() => { });
511
+ const maxScanBytesPerSource = opts.maxScanBytesPerSource ?? MAX_SCAN_BYTES_PER_SOURCE;
512
+ const maxBatchesPerRun = opts.maxBatchesPerRun ?? MAX_BATCHES_PER_RUN;
351
513
  // Company attribution (US-002): parse the manifest ONCE per run and reuse the
352
514
  // repo-path→companyUid map for every event below. No per-event manifest read.
353
515
  // When `hqRoot` is omitted the map is empty → every event stays unattributed.
@@ -431,7 +593,17 @@ export async function collectAndSendTelemetry(opts) {
431
593
  const loadedFiles = { ...cursor.files };
432
594
  const rotationResets = {};
433
595
  const newlyCommitted = {};
434
- const files = await listJsonlFiles(claudeProjectsRoot);
596
+ const [claudeFilesUnsorted, codexFiles] = await Promise.all([
597
+ listJsonlFiles(claudeProjectsRoot),
598
+ listCodexRolloutFiles(codexRoot),
599
+ ]);
600
+ const claudeFiles = await sortFilesFreshestFirst(claudeFilesUnsorted);
601
+ // Codex goes first so a fresh Linux install cannot spend its entire bounded
602
+ // pass replaying an older Claude backlog before reporting current Codex use.
603
+ const files = [
604
+ ...codexFiles.map((filePath) => ({ filePath, kind: "codex" })),
605
+ ...claudeFiles.map((filePath) => ({ filePath, kind: "claude" })),
606
+ ];
435
607
  // 3. Walk each file, sanitize new rows, batch, flush at the server contract.
436
608
  //
437
609
  // Byte accounting is incremental: we track `batchBytes` as the projected
@@ -446,14 +618,40 @@ export async function collectAndSendTelemetry(opts) {
446
618
  let batchBytes = ENVELOPE_BYTES;
447
619
  let eventsSent = 0;
448
620
  let batchesSent = 0;
621
+ let uploadFailed = false;
622
+ let batchLimitReached = false;
623
+ const scannedBytes = { claude: 0, codex: 0 };
624
+ const commitSources = (sources) => {
625
+ const maxPerFile = new Map();
626
+ for (const source of sources) {
627
+ const current = maxPerFile.get(source.filePath);
628
+ if (!current || source.endOffset > current.offset) {
629
+ maxPerFile.set(source.filePath, {
630
+ mtime: source.mtime,
631
+ offset: source.endOffset,
632
+ ...(source.context
633
+ ? { context: cloneCodexContext(source.context) }
634
+ : {}),
635
+ });
636
+ }
637
+ }
638
+ for (const [filePath, entry] of maxPerFile) {
639
+ const current = newlyCommitted[filePath];
640
+ if (!current || entry.offset > current.offset) {
641
+ newlyCommitted[filePath] = entry;
642
+ }
643
+ }
644
+ };
449
645
  const flush = async () => {
450
- if (batchEvents.length === 0)
451
- return;
452
646
  const events = batchEvents;
453
647
  const sources = batchSources;
454
648
  batchEvents = [];
455
649
  batchSources = [];
456
650
  batchBytes = ENVELOPE_BYTES;
651
+ if (events.length === 0) {
652
+ commitSources(sources);
653
+ return true;
654
+ }
457
655
  try {
458
656
  await opts.client.postUsage({
459
657
  machineId: opts.machineId,
@@ -462,24 +660,24 @@ export async function collectAndSendTelemetry(opts) {
462
660
  });
463
661
  batchesSent++;
464
662
  eventsSent += events.length;
465
- // Advance cursor to max(endOffset) per file in this batch.
466
- const maxPerFile = new Map();
467
- for (const src of sources) {
468
- const cur = maxPerFile.get(src.filePath);
469
- if (!cur || src.endOffset > cur.offset) {
470
- maxPerFile.set(src.filePath, { mtime: src.mtime, offset: src.endOffset });
471
- }
472
- }
473
- for (const [fp, entry] of maxPerFile) {
474
- newlyCommitted[fp] = { offset: entry.offset, mtime: entry.mtime };
475
- }
663
+ commitSources(sources);
664
+ return true;
476
665
  }
477
666
  catch (err) {
478
- log(`[telemetry] postUsage failed (${err.message ?? err}) — cursor not advanced for ${sources.length} rows`);
479
- // Cursor intentionally left un-advanced — next sync retries.
667
+ log("[telemetry] postUsage failed (" +
668
+ (err.message ?? err) +
669
+ ") — cursor not advanced for " +
670
+ sources.length +
671
+ " rows");
672
+ return false;
480
673
  }
481
674
  };
482
- for (const filePath of files) {
675
+ filesLoop: for (const { filePath, kind } of files) {
676
+ if (uploadFailed || batchLimitReached)
677
+ break;
678
+ const sourceBudgetRemaining = maxScanBytesPerSource - scannedBytes[kind];
679
+ if (sourceBudgetRemaining <= 0)
680
+ continue;
483
681
  let stat;
484
682
  try {
485
683
  stat = await fs.stat(filePath);
@@ -491,102 +689,189 @@ export async function collectAndSendTelemetry(opts) {
491
689
  const currentMtime = Math.floor(stat.mtimeMs / 1000);
492
690
  const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
493
691
  let offset = stored.offset;
692
+ let codexContext = kind === "codex" ? cloneCodexContext(stored.context ?? {}) : undefined;
494
693
  const rotated = currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
495
694
  if (rotated) {
496
695
  offset = 0;
696
+ if (kind === "codex")
697
+ codexContext = {};
497
698
  rotationResets[filePath] = { offset: 0, mtime: currentMtime };
498
699
  }
499
700
  if (offset >= currentSize && !rotated)
500
701
  continue;
501
702
  let content;
703
+ let bytesRead = 0;
704
+ let readBuffer = Buffer.alloc(0);
502
705
  try {
503
- const fh = await fs.open(filePath, "r");
706
+ const fileHandle = await fs.open(filePath, "r");
504
707
  try {
505
- const length = Math.max(0, currentSize - offset);
506
- const buf = Buffer.alloc(length);
507
- await fh.read(buf, 0, length, offset);
508
- content = buf.toString("utf-8");
708
+ const length = Math.max(0, Math.min(currentSize - offset, sourceBudgetRemaining));
709
+ readBuffer = Buffer.alloc(length);
710
+ ({ bytesRead } = await fileHandle.read(readBuffer, 0, length, offset));
711
+ content = readBuffer.toString("utf-8", 0, bytesRead);
509
712
  }
510
713
  finally {
511
- await fh.close();
714
+ await fileHandle.close();
512
715
  }
513
716
  }
514
717
  catch {
515
718
  continue;
516
719
  }
517
- if (content.length === 0)
720
+ if (bytesRead === 0)
518
721
  continue;
722
+ // Resume a previously discarded oversized Codex row using raw byte
723
+ // positions. Decoding from the middle of a multibyte UTF-8 codepoint would
724
+ // otherwise skew byte offsets and could skip or replay later rows.
725
+ if (codexContext?.discarding_partial_line) {
726
+ const newlineIndex = readBuffer.subarray(0, bytesRead).indexOf(0x0a);
727
+ const discardedBytes = newlineIndex >= 0 ? newlineIndex + 1 : bytesRead;
728
+ codexContext.discarding_partial_line = newlineIndex < 0;
729
+ scannedBytes[kind] += discardedBytes;
730
+ batchSources.push({
731
+ filePath,
732
+ endOffset: offset + discardedBytes,
733
+ mtime: currentMtime,
734
+ context: cloneCodexContext(codexContext),
735
+ });
736
+ if (newlineIndex < 0)
737
+ continue;
738
+ offset += discardedBytes;
739
+ bytesRead -= discardedBytes;
740
+ readBuffer = readBuffer.subarray(discardedBytes);
741
+ content = readBuffer.toString("utf-8", 0, bytesRead);
742
+ if (bytesRead === 0)
743
+ continue;
744
+ }
745
+ const reachedPhysicalEof = offset + bytesRead >= currentSize;
519
746
  const segments = content.split("\n");
520
747
  const lineEndOffsets = [];
521
748
  let cumulative = 0;
522
749
  for (let i = 0; i < segments.length; i++) {
523
750
  cumulative += Buffer.byteLength(segments[i], "utf-8");
524
751
  if (i < segments.length - 1)
525
- cumulative += 1; // newline byte
752
+ cumulative += 1;
526
753
  lineEndOffsets.push(offset + cumulative);
527
754
  }
528
755
  for (let i = 0; i < segments.length; i++) {
756
+ const startOffset = i === 0 ? offset : lineEndOffsets[i - 1];
757
+ const hasNewline = i < segments.length - 1;
758
+ const endOffset = !hasNewline && !reachedPhysicalEof
759
+ ? offset + bytesRead
760
+ : lineEndOffsets[i];
761
+ const lineBytes = endOffset - startOffset;
762
+ if (!hasNewline && !reachedPhysicalEof) {
763
+ const discarding = codexContext?.discarding_partial_line === true;
764
+ if (discarding || lineBytes >= MAX_PARTIAL_LINE_BYTES) {
765
+ if (codexContext)
766
+ codexContext.discarding_partial_line = true;
767
+ scannedBytes[kind] += lineBytes;
768
+ batchSources.push({
769
+ filePath,
770
+ endOffset,
771
+ mtime: currentMtime,
772
+ ...(codexContext
773
+ ? { context: cloneCodexContext(codexContext) }
774
+ : {}),
775
+ });
776
+ }
777
+ break;
778
+ }
779
+ scannedBytes[kind] += lineBytes;
780
+ const source = () => ({
781
+ filePath,
782
+ endOffset,
783
+ mtime: currentMtime,
784
+ ...(codexContext
785
+ ? { context: cloneCodexContext(codexContext) }
786
+ : {}),
787
+ });
788
+ if (codexContext?.discarding_partial_line) {
789
+ codexContext.discarding_partial_line = false;
790
+ batchSources.push(source());
791
+ continue;
792
+ }
529
793
  const trimmed = segments[i].trim();
530
- if (trimmed.length === 0)
794
+ if (trimmed.length === 0) {
795
+ batchSources.push(source());
531
796
  continue;
797
+ }
532
798
  let parsed;
533
799
  try {
534
800
  parsed = JSON.parse(trimmed);
535
801
  }
536
802
  catch {
803
+ batchSources.push(source());
537
804
  continue;
538
805
  }
539
- // Resolve this row's cwd → owning company (cmp_* uid) before sanitizing,
540
- // using the per-run map. Unresolved → undefined → companyUid omitted.
541
- const rowCwd = parsed && typeof parsed === "object" && !Array.isArray(parsed)
542
- ? parsed.cwd
806
+ const sourceRow = kind === "codex"
807
+ ? codexUsageRow(parsed, codexContext, filePath, startOffset, endOffset)
808
+ : parsed;
809
+ const rowCwd = sourceRow && typeof sourceRow === "object" && !Array.isArray(sourceRow)
810
+ ? sourceRow.cwd
543
811
  : undefined;
544
812
  const companyUid = resolveCompanyForCwd(typeof rowCwd === "string" ? rowCwd : undefined, repoCompanyMap);
545
- const sanitized = sanitizeRow(parsed, companyUid);
546
- if (!sanitized)
813
+ const sanitized = sanitizeRow(sourceRow, companyUid);
814
+ if (!sanitized) {
815
+ batchSources.push(source());
547
816
  continue;
817
+ }
548
818
  const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
549
819
  const wasOversized = jsonBytes(sanitized) > maxRowBytes;
550
820
  const bounded = boundRowForPost(sanitized, maxRowBytes);
551
821
  if (!bounded) {
552
- log(`[telemetry] oversized row dropped before send (${filePath}:${i + 1})`);
822
+ log("[telemetry] oversized row dropped before send (" +
823
+ filePath +
824
+ ":" +
825
+ (i + 1) +
826
+ ")");
827
+ batchSources.push(source());
553
828
  continue;
554
829
  }
555
830
  if (wasOversized) {
556
- log(`[telemetry] oversized row truncated before send (${filePath}:${i + 1})`);
831
+ log("[telemetry] oversized row truncated before send (" +
832
+ filePath +
833
+ ":" +
834
+ (i + 1) +
835
+ ")");
557
836
  }
558
- // Cost of appending this row to the current batch: the row's JSON
559
- // length plus 1 byte for the leading comma when there's already at
560
- // least one row. (No comma when the batch is empty — the row sits
561
- // alone inside the events array.)
562
- const rowJsonBytes = Buffer.byteLength(JSON.stringify(bounded), "utf-8");
837
+ const rowJsonBytes = jsonBytes(bounded);
563
838
  const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
564
839
  if (batchEvents.length > 0 &&
565
840
  (batchEvents.length >= MAX_BATCH_EVENTS ||
566
841
  batchBytes + addCost > MAX_BATCH_BYTES)) {
567
- await flush();
568
- // After flush, batchEvents is empty → no comma needed for the first row.
842
+ if (!(await flush())) {
843
+ uploadFailed = true;
844
+ break filesLoop;
845
+ }
846
+ if (batchesSent >= maxBatchesPerRun) {
847
+ batchLimitReached = true;
848
+ break filesLoop;
849
+ }
569
850
  batchBytes = ENVELOPE_BYTES + rowJsonBytes;
570
851
  }
571
852
  else {
572
853
  batchBytes += addCost;
573
854
  }
574
855
  batchEvents.push(bounded);
575
- batchSources.push({
576
- filePath,
577
- endOffset: lineEndOffsets[i],
578
- mtime: currentMtime,
579
- });
856
+ batchSources.push(source());
580
857
  }
581
858
  }
582
- await flush();
859
+ if (!uploadFailed && !batchLimitReached) {
860
+ uploadFailed = !(await flush());
861
+ }
583
862
  // 4. Persist cursor: loaded < rotation_resets < newly_committed.
584
863
  const finalFiles = { ...loadedFiles };
585
864
  for (const [fp, entry] of Object.entries(rotationResets))
586
865
  finalFiles[fp] = entry;
587
866
  for (const [fp, entry] of Object.entries(newlyCommitted))
588
867
  finalFiles[fp] = entry;
589
- await saveCursor(cursorPath, { version: "1", files: finalFiles });
868
+ await saveCursor(cursorPath, {
869
+ version: "1",
870
+ files: finalFiles,
871
+ ...(cursor.codex_next_rollout
872
+ ? { codex_next_rollout: cursor.codex_next_rollout }
873
+ : {}),
874
+ });
590
875
  return {
591
876
  enabled: true,
592
877
  optInSource,