@indigoai-us/hq-cloud 6.15.0 → 6.15.2

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 (329) hide show
  1. package/dist/bin/sync-mutation.d.ts +16 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +165 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/sync.d.ts +35 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +100 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +85 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/skill-telemetry.d.ts +6 -0
  44. package/dist/skill-telemetry.d.ts.map +1 -1
  45. package/dist/skill-telemetry.js +14 -2
  46. package/dist/skill-telemetry.js.map +1 -1
  47. package/dist/skill-telemetry.test.js +79 -0
  48. package/dist/skill-telemetry.test.js.map +1 -1
  49. package/dist/sync/candidate-uploader.d.ts +88 -0
  50. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  51. package/dist/sync/candidate-uploader.js +212 -0
  52. package/dist/sync/candidate-uploader.js.map +1 -0
  53. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  54. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  55. package/dist/sync/candidate-uploader.test.js +132 -0
  56. package/dist/sync/candidate-uploader.test.js.map +1 -0
  57. package/dist/sync/delta-client.d.ts +73 -0
  58. package/dist/sync/delta-client.d.ts.map +1 -0
  59. package/dist/sync/delta-client.js +201 -0
  60. package/dist/sync/delta-client.js.map +1 -0
  61. package/dist/sync/delta-client.test.d.ts +2 -0
  62. package/dist/sync/delta-client.test.d.ts.map +1 -0
  63. package/dist/sync/delta-client.test.js +97 -0
  64. package/dist/sync/delta-client.test.js.map +1 -0
  65. package/dist/sync/durable-apply.d.ts +76 -0
  66. package/dist/sync/durable-apply.d.ts.map +1 -0
  67. package/dist/sync/durable-apply.js +530 -0
  68. package/dist/sync/durable-apply.js.map +1 -0
  69. package/dist/sync/durable-apply.test.d.ts +2 -0
  70. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  71. package/dist/sync/durable-apply.test.js +180 -0
  72. package/dist/sync/durable-apply.test.js.map +1 -0
  73. package/dist/sync/event-sync.d.ts +33 -1
  74. package/dist/sync/event-sync.d.ts.map +1 -1
  75. package/dist/sync/event-sync.js +149 -1
  76. package/dist/sync/event-sync.js.map +1 -1
  77. package/dist/sync/event-sync.test.js +142 -1
  78. package/dist/sync/event-sync.test.js.map +1 -1
  79. package/dist/sync/index.d.ts +2 -0
  80. package/dist/sync/index.d.ts.map +1 -1
  81. package/dist/sync/index.js +1 -0
  82. package/dist/sync/index.js.map +1 -1
  83. package/dist/sync/multipart-uploader.d.ts +99 -0
  84. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  85. package/dist/sync/multipart-uploader.js +447 -0
  86. package/dist/sync/multipart-uploader.js.map +1 -0
  87. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  88. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  89. package/dist/sync/multipart-uploader.test.js +119 -0
  90. package/dist/sync/multipart-uploader.test.js.map +1 -0
  91. package/dist/sync/mutation-client.d.ts +85 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +245 -0
  94. package/dist/sync/mutation-client.js.map +1 -0
  95. package/dist/sync/mutation-client.test.d.ts +2 -0
  96. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  97. package/dist/sync/mutation-client.test.js +51 -0
  98. package/dist/sync/mutation-client.test.js.map +1 -0
  99. package/dist/sync/push-receiver.d.ts +45 -0
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +101 -0
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +54 -2
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/sync/scope-inventory-client.d.ts +69 -0
  106. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  107. package/dist/sync/scope-inventory-client.js +210 -0
  108. package/dist/sync/scope-inventory-client.js.map +1 -0
  109. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  110. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  111. package/dist/sync/scope-inventory-client.test.js +94 -0
  112. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  113. package/dist/sync/snapshot-client.d.ts +98 -0
  114. package/dist/sync/snapshot-client.d.ts.map +1 -0
  115. package/dist/sync/snapshot-client.js +402 -0
  116. package/dist/sync/snapshot-client.js.map +1 -0
  117. package/dist/sync/snapshot-client.test.d.ts +2 -0
  118. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  119. package/dist/sync/snapshot-client.test.js +169 -0
  120. package/dist/sync/snapshot-client.test.js.map +1 -0
  121. package/dist/sync/uploader-finalization.d.ts +97 -0
  122. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  123. package/dist/sync/uploader-finalization.js +273 -0
  124. package/dist/sync/uploader-finalization.js.map +1 -0
  125. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  126. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  127. package/dist/sync/uploader-finalization.test.js +92 -0
  128. package/dist/sync/uploader-finalization.test.js.map +1 -0
  129. package/dist/telemetry.d.ts +11 -1
  130. package/dist/telemetry.d.ts.map +1 -1
  131. package/dist/telemetry.js +21 -2
  132. package/dist/telemetry.js.map +1 -1
  133. package/dist/telemetry.test.js +80 -0
  134. package/dist/telemetry.test.js.map +1 -1
  135. package/package.json +6 -1
  136. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  137. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  138. package/.github/workflows/ci.yml +0 -84
  139. package/.github/workflows/publish.yml +0 -56
  140. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  141. package/eslint.config.js +0 -67
  142. package/pnpm-workspace.yaml +0 -2
  143. package/scripts/presign-transport-e2e.mjs +0 -250
  144. package/scripts/vault-rebaseline.sh +0 -323
  145. package/scripts/vault-rescue.sh +0 -332
  146. package/src/active-company.test.ts +0 -188
  147. package/src/active-company.ts +0 -168
  148. package/src/agent-codex-instructions.test.ts +0 -332
  149. package/src/agent-codex-instructions.ts +0 -309
  150. package/src/auth.ts +0 -146
  151. package/src/backup-prune.test.ts +0 -98
  152. package/src/backup-prune.ts +0 -182
  153. package/src/bin/backup-prune-runner.ts +0 -33
  154. package/src/bin/rescue-runner.ts +0 -25
  155. package/src/bin/sync-runner-company.ts +0 -695
  156. package/src/bin/sync-runner-events.test.ts +0 -143
  157. package/src/bin/sync-runner-events.ts +0 -55
  158. package/src/bin/sync-runner-planning.test.ts +0 -311
  159. package/src/bin/sync-runner-planning.ts +0 -258
  160. package/src/bin/sync-runner-rollup.test.ts +0 -37
  161. package/src/bin/sync-runner-rollup.ts +0 -97
  162. package/src/bin/sync-runner-telemetry.ts +0 -15
  163. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  164. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  165. package/src/bin/sync-runner-watch-routes.ts +0 -184
  166. package/src/bin/sync-runner.test.ts +0 -8767
  167. package/src/bin/sync-runner.ts +0 -2190
  168. package/src/cli/accept.ts +0 -124
  169. package/src/cli/conflict.ts +0 -119
  170. package/src/cli/doctor.test.ts +0 -581
  171. package/src/cli/doctor.ts +0 -642
  172. package/src/cli/index.ts +0 -49
  173. package/src/cli/invite.test.ts +0 -250
  174. package/src/cli/invite.ts +0 -214
  175. package/src/cli/promote.ts +0 -157
  176. package/src/cli/reindex-knowledge.test.ts +0 -307
  177. package/src/cli/reindex-knowledge.ts +0 -450
  178. package/src/cli/reindex.test.ts +0 -957
  179. package/src/cli/reindex.ts +0 -979
  180. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  181. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  182. package/src/cli/rescue-core.ts +0 -3011
  183. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  184. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  185. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  186. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  187. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  188. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  189. package/src/cli/rescue-settings-reconcile.test.ts +0 -637
  190. package/src/cli/rescue-snapshot.test.ts +0 -57
  191. package/src/cli/rescue-snapshot.ts +0 -51
  192. package/src/cli/rescue.reindex.test.ts +0 -63
  193. package/src/cli/rescue.test.ts +0 -131
  194. package/src/cli/rescue.ts +0 -182
  195. package/src/cli/share.test.ts +0 -7843
  196. package/src/cli/share.ts +0 -3663
  197. package/src/cli/sync-scope.test.ts +0 -652
  198. package/src/cli/sync.test.ts +0 -5207
  199. package/src/cli/sync.ts +0 -3470
  200. package/src/cli/tombstones.ts +0 -106
  201. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  202. package/src/client-info.test.ts +0 -214
  203. package/src/client-info.ts +0 -121
  204. package/src/cognito-auth.test.ts +0 -712
  205. package/src/cognito-auth.ts +0 -1422
  206. package/src/company-resolver.test.ts +0 -618
  207. package/src/company-resolver.ts +0 -521
  208. package/src/context.test.ts +0 -583
  209. package/src/context.ts +0 -378
  210. package/src/daemon-worker.ts +0 -26
  211. package/src/daemon.ts +0 -99
  212. package/src/entity-resolver.test.ts +0 -315
  213. package/src/entity-resolver.ts +0 -180
  214. package/src/ignore.test.ts +0 -466
  215. package/src/ignore.ts +0 -469
  216. package/src/index.ts +0 -439
  217. package/src/journal.test.ts +0 -968
  218. package/src/journal.ts +0 -765
  219. package/src/lib/cloud-authoritative.test.ts +0 -45
  220. package/src/lib/cloud-authoritative.ts +0 -59
  221. package/src/lib/conflict-file.ts +0 -86
  222. package/src/lib/conflict-index.ts +0 -289
  223. package/src/lib/conflict.test.ts +0 -348
  224. package/src/lib/describe-error.test.ts +0 -100
  225. package/src/lib/describe-error.ts +0 -58
  226. package/src/lib/exit-codes.ts +0 -24
  227. package/src/lib/machine-id.test.ts +0 -231
  228. package/src/lib/machine-id.ts +0 -175
  229. package/src/lib/net-errors.test.ts +0 -65
  230. package/src/lib/net-errors.ts +0 -86
  231. package/src/lib/readlink-safe.test.ts +0 -43
  232. package/src/lib/readlink-safe.ts +0 -29
  233. package/src/local-path-codec.test.ts +0 -138
  234. package/src/local-path-codec.ts +0 -161
  235. package/src/machine-auth.test.ts +0 -1323
  236. package/src/manifest-reconcile.test.ts +0 -1123
  237. package/src/manifest-reconcile.ts +0 -518
  238. package/src/object-io.test.ts +0 -1221
  239. package/src/object-io.ts +0 -1306
  240. package/src/operation-lock.test.ts +0 -484
  241. package/src/operation-lock.ts +0 -680
  242. package/src/outcome-telemetry.test.ts +0 -498
  243. package/src/outcome-telemetry.ts +0 -639
  244. package/src/personal-vault-exclusions.test.ts +0 -308
  245. package/src/personal-vault-exclusions.ts +0 -354
  246. package/src/personal-vault.test.ts +0 -756
  247. package/src/personal-vault.ts +0 -496
  248. package/src/prefix-coalesce.test.ts +0 -240
  249. package/src/prefix-coalesce.ts +0 -273
  250. package/src/public-surface.test.ts +0 -117
  251. package/src/qmd-reindex.test.ts +0 -877
  252. package/src/qmd-reindex.ts +0 -842
  253. package/src/read-only-state-dir.test.ts +0 -188
  254. package/src/remote-pull.test.ts +0 -1130
  255. package/src/remote-pull.ts +0 -618
  256. package/src/s3.symlink-materialize.test.ts +0 -492
  257. package/src/s3.test.ts +0 -1789
  258. package/src/s3.ts +0 -1532
  259. package/src/schemas/signal-types.test.ts +0 -82
  260. package/src/schemas/signal-types.ts +0 -38
  261. package/src/schemas/source-channels.test.ts +0 -82
  262. package/src/schemas/source-channels.ts +0 -53
  263. package/src/scope-shrink.test.ts +0 -633
  264. package/src/scope-shrink.ts +0 -481
  265. package/src/signals/get.test.ts +0 -310
  266. package/src/signals/get.ts +0 -75
  267. package/src/signals/internals.ts +0 -195
  268. package/src/signals/list.test.ts +0 -420
  269. package/src/signals/list.ts +0 -79
  270. package/src/signals/parse.ts +0 -8
  271. package/src/signals/types.ts +0 -91
  272. package/src/skill-telemetry.test.ts +0 -1825
  273. package/src/skill-telemetry.ts +0 -1439
  274. package/src/sources/get.test.ts +0 -293
  275. package/src/sources/get.ts +0 -66
  276. package/src/sources/internals.ts +0 -198
  277. package/src/sources/list.test.ts +0 -402
  278. package/src/sources/list.ts +0 -84
  279. package/src/sources/parse.ts +0 -43
  280. package/src/sources/types.ts +0 -84
  281. package/src/sync/event-sync.test.ts +0 -594
  282. package/src/sync/event-sync.ts +0 -545
  283. package/src/sync/feature-flags.test.ts +0 -378
  284. package/src/sync/feature-flags.ts +0 -62
  285. package/src/sync/index.ts +0 -76
  286. package/src/sync/lease-client.test.ts +0 -128
  287. package/src/sync/lease-client.ts +0 -207
  288. package/src/sync/logger.test.ts +0 -242
  289. package/src/sync/logger.ts +0 -79
  290. package/src/sync/metrics.test.ts +0 -462
  291. package/src/sync/metrics.ts +0 -213
  292. package/src/sync/pull-scope.ts +0 -265
  293. package/src/sync/push-event.test.ts +0 -266
  294. package/src/sync/push-event.ts +0 -224
  295. package/src/sync/push-receiver.test.ts +0 -566
  296. package/src/sync/push-receiver.ts +0 -1048
  297. package/src/sync/push-transport.ts +0 -231
  298. package/src/sync/realtime-rollout.test.ts +0 -86
  299. package/src/sync/realtime-rollout.ts +0 -262
  300. package/src/sync/state-store.test.ts +0 -194
  301. package/src/sync/state-store.ts +0 -727
  302. package/src/sync-core.ts +0 -58
  303. package/src/sync-progress.test.ts +0 -94
  304. package/src/sync-progress.ts +0 -140
  305. package/src/telemetry-events.test.ts +0 -88
  306. package/src/telemetry-events.ts +0 -205
  307. package/src/telemetry.test.ts +0 -1280
  308. package/src/telemetry.ts +0 -1109
  309. package/src/types.ts +0 -314
  310. package/src/vault-client.test.ts +0 -1380
  311. package/src/vault-client.ts +0 -1694
  312. package/src/version.ts +0 -24
  313. package/src/watch-roots.test.ts +0 -278
  314. package/src/watch-roots.ts +0 -162
  315. package/src/watcher-event-gate.test.ts +0 -212
  316. package/src/watcher.test.ts +0 -1079
  317. package/src/watcher.ts +0 -1741
  318. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  319. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  320. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  321. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  322. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  323. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  324. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  325. package/test/invite-flow.integration.test.ts +0 -244
  326. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  327. package/test/share-sync.integration.test.ts +0 -213
  328. package/tsconfig.json +0 -19
  329. package/vitest.config.ts +0 -22
package/src/telemetry.ts DELETED
@@ -1,1109 +0,0 @@
1
- /**
2
- * Usage telemetry collector — TypeScript port of the Tauri Rust collector that
3
- * used to live at `hq-workspace/apps/hq-sync/src-tauri/src/commands/telemetry.rs`.
4
- *
5
- * Why it moved: the Rust copy only ran inside the macOS menubar app. By moving
6
- * the logic into `@indigoai-us/hq-cloud`, every consumer of the package
7
- * (`hq-sync-runner`, `hq-cli`, mobile wrappers) emits telemetry uniformly.
8
- *
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`.
14
- *
15
- * Trust model: the caller's `personUid` is resolved on the server from the
16
- * Cognito JWT — never from the body. `sanitizeRow` strips prompt bodies,
17
- * thinking content, tool inputs/outputs, and any nested `message` object so
18
- * the wire payload contains only token-accounting fields.
19
- *
20
- * Errors are swallowed by design — telemetry must never abort or delay a
21
- * sync. The cursor is only advanced for batches the server 2xx'd, so a
22
- * transient outage retries automatically on the next sync.
23
- */
24
-
25
- import { createHash } from "node:crypto";
26
- import { promises as fs } from "node:fs";
27
- import * as os from "node:os";
28
- import * as path from "node:path";
29
-
30
- import {
31
- buildRepoCompanyMap,
32
- resolveCompanyForCwd,
33
- type RepoCompanyMap,
34
- } from "./company-resolver.js";
35
- import type {
36
- TelemetryOptInResponse,
37
- UsageBatch,
38
- UsageIngestResult,
39
- } from "./vault-client.js";
40
-
41
- // ── Public surface ────────────────────────────────────────────────────────────
42
-
43
- /**
44
- * Minimal subset of `VaultClient` the collector needs. Declared as an
45
- * interface so tests can inject a stub without spinning up a fetch mock.
46
- * The real `VaultClient` from `./vault-client.js` satisfies this structurally.
47
- */
48
- export interface TelemetryClientSurface {
49
- getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
50
- postUsage(batch: UsageBatch): Promise<UsageIngestResult>;
51
- /**
52
- * Optional so an older client (or a narrow test stub) still satisfies the
53
- * surface — when it is absent the consent self-heal is simply skipped.
54
- */
55
- setTelemetryOptIn?(
56
- enabled: boolean,
57
- opts?: { onlyIfUnset?: boolean },
58
- ): Promise<{ applied: boolean } | void>;
59
- }
60
-
61
- export interface CollectTelemetryOptions {
62
- client: TelemetryClientSurface;
63
- /** Stable per-machine id. The Tauri menubar reads this from `~/.hq/menubar.json`; the runner can pass it through or generate one once and cache. */
64
- machineId: string;
65
- /** Version of the wrapping caller (menubar app, CLI, etc.). Reaches CloudWatch metrics as the `installerVersion` dimension. */
66
- installerVersion: string;
67
- /**
68
- * HQ root, used to resolve each event's `cwd` → owning repo → owning company
69
- * via `<hqRoot>/companies/manifest.yaml` and stamp `companyUid` on the event
70
- * (surface-hq-console-telemetry US-002). The manifest is parsed ONCE per run
71
- * (see `buildRepoCompanyMap`); the resulting map is reused for every event.
72
- * When omitted (or when no repo matches), `companyUid` is left UNSET and the
73
- * server treats the event as unattributed/personal.
74
- */
75
- hqRoot?: string;
76
- /**
77
- * Explicit single-company sync scope (`--company <slug-or-uid>`). Used only
78
- * when an event cwd does not resolve through the manifest. This is intentionally
79
- * absent for multi-company and personal runs to prevent cross-tenant attribution.
80
- */
81
- fallbackCompany?: string;
82
- /** Override `~/.claude/projects` for tests. */
83
- claudeProjectsRoot?: string;
84
- /** Override `~/.codex` for tests. Both live and archived rollouts are scanned. */
85
- codexRoot?: string;
86
- /** Override the per-runtime scan budget for deterministic tests. */
87
- maxScanBytesPerSource?: number;
88
- /** Override the upload batch cap for deterministic tests. */
89
- maxBatchesPerRun?: number;
90
- /** Override `~/.hq/telemetry-cursor.json` for tests. */
91
- cursorPath?: string;
92
- /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
93
- menubarPath?: string;
94
- /** Diagnostic sink. No-op by default. */
95
- log?: (msg: string) => void;
96
- }
97
-
98
- export interface CollectTelemetryResult {
99
- /** Whether the opt-in check resolved to true (either server-side or via the menubar fallback). When false, nothing else ran. */
100
- enabled: boolean;
101
- /** Source for the `enabled` decision — useful for diagnosing missing-events reports. */
102
- optInSource: "server" | "menubar-fallback" | "menubar-reasserted" | "skipped";
103
- /** How many `.jsonl` files we considered (before the cursor diff). */
104
- filesScanned: number;
105
- /** Total events successfully POSTed across all batches. */
106
- eventsSent: number;
107
- /** Number of `POST /v1/usage` requests made. */
108
- batchesSent: number;
109
- }
110
-
111
- // ── Cursor schema ─────────────────────────────────────────────────────────────
112
-
113
- interface CodexUsageContext {
114
- session_id?: string;
115
- cwd?: string;
116
- git_branch?: string;
117
- session_model?: string;
118
- collaboration_model?: string;
119
- turn_model?: string;
120
- discarding_partial_line?: boolean;
121
- }
122
-
123
- interface CursorEntry {
124
- offset: number;
125
- mtime: number;
126
- /** Runtime context needed to resume a Codex rollout from a byte offset. */
127
- context?: CodexUsageContext;
128
- }
129
-
130
- interface TelemetryCursor {
131
- version: string;
132
- files: Record<string, CursorEntry>;
133
- /** Preserved for cursor compatibility with the desktop collector. */
134
- codex_next_rollout?: string;
135
- }
136
-
137
- function emptyCursor(): TelemetryCursor {
138
- return { version: "1", files: {} };
139
- }
140
-
141
- async function loadCursor(cursorPath: string): Promise<TelemetryCursor> {
142
- try {
143
- const raw = await fs.readFile(cursorPath, "utf-8");
144
- const parsed = JSON.parse(raw) as Partial<TelemetryCursor>;
145
- if (parsed && typeof parsed === "object" && parsed.files && typeof parsed.files === "object") {
146
- return {
147
- version: parsed.version ?? "1",
148
- files: parsed.files as Record<string, CursorEntry>,
149
- ...(typeof parsed.codex_next_rollout === "string"
150
- ? { codex_next_rollout: parsed.codex_next_rollout }
151
- : {}),
152
- };
153
- }
154
- } catch {
155
- // Missing / unparseable — start fresh.
156
- }
157
- return emptyCursor();
158
- }
159
-
160
- async function saveCursor(cursorPath: string, cursor: TelemetryCursor): Promise<void> {
161
- // Atomic write: tmp + rename. The Rust impl uses the same .tmp suffix; we
162
- // keep it for cross-implementation grep-ability.
163
- await fs.mkdir(path.dirname(cursorPath), { recursive: true });
164
- const tmp = `${cursorPath}.tmp`;
165
- await fs.writeFile(tmp, JSON.stringify(cursor, null, 2), "utf-8");
166
- await fs.rename(tmp, cursorPath);
167
- }
168
-
169
- // ── Local opt-in fallback ─────────────────────────────────────────────────────
170
-
171
- async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
172
- return (await readLocalTelemetryPreference(menubarPath)) === true;
173
- }
174
-
175
- /**
176
- * Tri-state read of the locally-stored consent.
177
- *
178
- * `~/.hq/menubar.json` → `telemetryEnabled` is written by the installer the
179
- * moment the user answers the prompt, and that local write ALWAYS succeeds —
180
- * unlike the paired server write, which fires before the person entity exists
181
- * and 404s. So this file is frequently the ONLY durable record of the user's
182
- * actual choice.
183
- *
184
- * `undefined` means absent / unreadable / not a boolean — "we hold no answer" —
185
- * as distinct from `false`, which is a real opt-out. The self-heal path must
186
- * never conflate the two: it replays an answer, it does not invent one.
187
- */
188
- async function readLocalTelemetryPreference(
189
- menubarPath: string,
190
- ): Promise<boolean | undefined> {
191
- return (await readLocalConsentRecord(menubarPath)).enabled;
192
- }
193
-
194
- /**
195
- * The locally-cached consent plus the account it belongs to.
196
- *
197
- * `telemetryOptInPersonUid` binds the answer to the `prs_*` that gave it.
198
- * `menubar.json` is a per-MACHINE file, so when two people sign in under the
199
- * same OS user it holds whoever answered LAST — replaying it unconditionally
200
- * would opt in an account that never consented. The binding is what makes the
201
- * replay safe; an unbound (legacy) record cannot be proven to belong to the
202
- * current caller and is therefore never replayed.
203
- */
204
- async function readLocalConsentRecord(
205
- menubarPath: string,
206
- ): Promise<{ enabled?: boolean; personUid?: string }> {
207
- try {
208
- const raw = await fs.readFile(menubarPath, "utf-8");
209
- const parsed = JSON.parse(raw) as {
210
- telemetryEnabled?: unknown;
211
- telemetryOptInPersonUid?: unknown;
212
- };
213
- return {
214
- enabled:
215
- typeof parsed.telemetryEnabled === "boolean" ? parsed.telemetryEnabled : undefined,
216
- personUid:
217
- typeof parsed.telemetryOptInPersonUid === "string" &&
218
- parsed.telemetryOptInPersonUid.length > 0
219
- ? parsed.telemetryOptInPersonUid
220
- : undefined,
221
- };
222
- } catch {
223
- return {};
224
- }
225
- }
226
-
227
- // ── Sanitizer ─────────────────────────────────────────────────────────────────
228
-
229
- /** Top-level fields the server accepts. Keep aligned with `KEEP_FIELDS` in
230
- * `apps/hq-pro/src/vault-service/handlers/usage.ts` — any drift will surface
231
- * as an `unexpected-event-field` rejection in `UsageIngestResult.skipped`. */
232
- const KEEP_TOP_LEVEL = [
233
- "sessionId",
234
- "timestamp",
235
- "uuid",
236
- "cwd",
237
- "gitBranch",
238
- "userType",
239
- ] as const;
240
-
241
- /**
242
- * MCP tool_use blocks name their tool `mcp__<server>__<tool>` in a Claude Code
243
- * session row's `message.content[]`. This recovers the SERVER segment only —
244
- * the server/connector name (e.g. `slack`, `figma`). The tool segment and the
245
- * block's `input` (args/URLs/payloads) are deliberately NEVER read.
246
- *
247
- * The `__` (double underscore) is the server↔tool delimiter, so the server is
248
- * everything between the `mcp__` prefix and the LAST `__`. Using `lastIndexOf`
249
- * (rather than a first-`__` split or a single-underscore-only regex) preserves
250
- * legal underscores in server names — `_foo`, `foo_`, and `foo__bar` all parse
251
- * correctly instead of being truncated or dropped. Returns `undefined` for a
252
- * name that is not an `mcp__<server>__<tool>` with a non-empty server segment.
253
- */
254
- const MCP_PREFIX = "mcp__";
255
- function mcpServerName(toolName: string): string | undefined {
256
- if (!toolName.startsWith(MCP_PREFIX)) return undefined;
257
- const rest = toolName.slice(MCP_PREFIX.length);
258
- const delim = rest.lastIndexOf("__");
259
- if (delim <= 0) return undefined; // no tool segment, or empty server
260
- return rest.slice(0, delim);
261
- }
262
-
263
- /**
264
- * SECURITY-CRITICAL (owner-telemetry-v2 US-007). Extract the MCP servers /
265
- * connectors / integrations a session row touched as a NAMES-AND-COUNTS-ONLY map
266
- * `{ <serverName>: <count> }` — e.g. `{ slack: 2, figma: 1 }`.
267
- *
268
- * This mirrors the `hasArgs` redaction precedent in skill-telemetry.ts: we look
269
- * ONLY at the tool-use block's `name` (to recover the server segment of
270
- * `mcp__<server>__<tool>`) and count occurrences. We NEVER read the block's
271
- * `input` (which carries the args/URLs/payloads), never emit the tool name, and
272
- * never emit any argument text. So no args, URLs, payloads, or secrets can reach
273
- * the wire — only a service name and how many times it was invoked in this row.
274
- *
275
- * Returns `undefined` when the row touched no MCP server, so the caller omits the
276
- * `services` field entirely (backward-compatible: old-shaped rows carry nothing
277
- * new, and the server treats absence as "no services").
278
- */
279
- export function extractServices(
280
- message: unknown,
281
- ): Record<string, number> | undefined {
282
- if (!message || typeof message !== "object" || Array.isArray(message)) {
283
- return undefined;
284
- }
285
- const content = (message as Record<string, unknown>).content;
286
- if (!Array.isArray(content)) return undefined;
287
-
288
- // Null-prototype map: server names come from untrusted session content and can
289
- // legally be `constructor`, `toString`, etc. A plain `{}` would read those off
290
- // Object.prototype, so `counts[server] ?? 0` would yield a function and the
291
- // increment would produce a non-numeric count — poisoning the whole row (the
292
- // backend then drops it as `invalid-services`, losing its token/session data).
293
- const counts: Record<string, number> = Object.create(null);
294
- for (const blk of content as unknown[]) {
295
- if (!blk || typeof blk !== "object" || Array.isArray(blk)) continue;
296
- const b = blk as Record<string, unknown>;
297
- // Only tool_use blocks name an MCP tool. We read `name` and NOTHING ELSE —
298
- // the block's `input` (args/URLs/payloads) is never touched.
299
- if (b.type !== "tool_use" || typeof b.name !== "string") continue;
300
- const server = mcpServerName(b.name);
301
- if (!server) continue;
302
- counts[server] = (counts[server] ?? 0) + 1;
303
- }
304
-
305
- return Object.keys(counts).length > 0 ? counts : undefined;
306
- }
307
-
308
- /**
309
- * Build an outgoing event row matching the server's KEEP allowlist.
310
- *
311
- * Two transforms:
312
- * 1. Top-level fields are copied straight through (string identity).
313
- * 2. `message.model` and `message.usage.{input_tokens, output_tokens,
314
- * cache_creation_input_tokens, cache_read_input_tokens}` are promoted to
315
- * camelCase top-level fields. The original `message` object — which
316
- * carries prompt/response text, thinking, and tool data — is dropped.
317
- *
318
- * `services` (US-007): the MCP servers / connectors / integrations this row
319
- * touched, as a names-and-counts-only map (see `extractServices`). NAMES + COUNTS
320
- * ONLY — never args/URLs/payloads/secrets, matching the `hasArgs` precedent.
321
- * Omitted when the row touched no MCP server (the server treats absence as none).
322
- *
323
- * `companyUid` (US-002): when the caller has resolved the row's `cwd` to an
324
- * owning company (`resolveCompanyForCwd`), it passes that `cmp_*` uid here and
325
- * it is stamped on the wire row. It is on the server's KEEP allowlist
326
- * (`apps/hq-pro/src/vault-service/handlers/usage.ts`). When `companyUid` is
327
- * undefined (cwd maps to no company repo) the field is OMITTED — the server
328
- * treats absence as unattributed/personal. The reserved value `unattributed`
329
- * is never produced (it can only come from a resolved manifest `cmp_*` uid).
330
- *
331
- * Returns `null` when the input isn't an object. Empty results (e.g. a row
332
- * with no recognised fields) are still returned as `{}` and emitted; the
333
- * server accepts empty rows and they're useful as a "Claude Code was run at
334
- * this time" heartbeat.
335
- */
336
- export function sanitizeRow(
337
- row: unknown,
338
- companyUid?: string,
339
- ): Record<string, unknown> | null {
340
- if (!row || typeof row !== "object" || Array.isArray(row)) return null;
341
- const obj = row as Record<string, unknown>;
342
- const out: Record<string, unknown> = {};
343
-
344
- for (const key of KEEP_TOP_LEVEL) {
345
- if (key in obj) {
346
- out[key] = obj[key];
347
- }
348
- }
349
-
350
- // Stamp the resolved company attribution. Omit when unresolved so the server
351
- // reads it as unattributed/personal. Never the reserved `unattributed`.
352
- if (companyUid !== undefined) {
353
- out.companyUid = companyUid;
354
- }
355
-
356
- const message = obj.message;
357
- if (message && typeof message === "object" && !Array.isArray(message)) {
358
- const m = message as Record<string, unknown>;
359
- if ("model" in m) out.model = m.model;
360
- const usage = m.usage;
361
- if (usage && typeof usage === "object" && !Array.isArray(usage)) {
362
- const u = usage as Record<string, unknown>;
363
- if ("input_tokens" in u) out.inputTokens = u.input_tokens;
364
- if ("output_tokens" in u) out.outputTokens = u.output_tokens;
365
- if ("cache_creation_input_tokens" in u) {
366
- out.cacheCreationInputTokens = u.cache_creation_input_tokens;
367
- }
368
- if ("cache_read_input_tokens" in u) {
369
- out.cacheReadInputTokens = u.cache_read_input_tokens;
370
- }
371
- }
372
- }
373
-
374
- // services/integrations touched (US-007). Names + counts only — extracted from
375
- // the message's tool_use block NAMES, never their inputs. Omitted when none, so
376
- // the wire row is unchanged for rows that touched no MCP server.
377
- const services = extractServices(message);
378
- if (services !== undefined) {
379
- out.services = services;
380
- }
381
-
382
- return out;
383
- }
384
-
385
- // ── File walker ───────────────────────────────────────────────────────────────
386
-
387
- /** Recursively collect every `.jsonl` file under `root`. Skips errors silently
388
- * (missing dir, EACCES on a stray subdir) — anything we can't enter is
389
- * treated as absent rather than fatal, matching the Rust glob behavior. */
390
- async function listJsonlFiles(root: string): Promise<string[]> {
391
- const out: string[] = [];
392
- async function walk(dir: string): Promise<void> {
393
- let entries;
394
- try {
395
- entries = await fs.readdir(dir, { withFileTypes: true });
396
- } catch {
397
- return;
398
- }
399
- for (const ent of entries) {
400
- const full = path.join(dir, ent.name);
401
- if (ent.isDirectory()) {
402
- await walk(full);
403
- } else if (ent.isFile() && ent.name.endsWith(".jsonl")) {
404
- out.push(full);
405
- }
406
- }
407
- }
408
- await walk(root);
409
- return out;
410
- }
411
-
412
- // ── Codex rollout adapter ────────────────────────────────────────────────────
413
-
414
- const MAX_ID_BYTES = 256;
415
- const MAX_TIMESTAMP_BYTES = 128;
416
- const MAX_PATH_BYTES = 4 * 1024;
417
- const MAX_MODEL_BYTES = 256;
418
-
419
- function boundedString(value: unknown, maxBytes: number): string | undefined {
420
- return typeof value === "string" &&
421
- value.length > 0 &&
422
- Buffer.byteLength(value, "utf-8") <= maxBytes
423
- ? value
424
- : undefined;
425
- }
426
-
427
- function tokenCount(value: unknown): number {
428
- return typeof value === "number" &&
429
- Number.isSafeInteger(value) &&
430
- value >= 0
431
- ? value
432
- : 0;
433
- }
434
-
435
- function cloneCodexContext(context: CodexUsageContext): CodexUsageContext {
436
- return { ...context };
437
- }
438
-
439
- function codexModel(context: CodexUsageContext): string | undefined {
440
- return context.turn_model ?? context.collaboration_model ?? context.session_model;
441
- }
442
-
443
- function codexGitBranch(payload: Record<string, unknown>): string | undefined {
444
- const direct =
445
- boundedString(payload.gitBranch, MAX_PATH_BYTES) ??
446
- boundedString(payload.git_branch, MAX_PATH_BYTES);
447
- if (direct) return direct;
448
- const git = payload.git;
449
- return git && typeof git === "object" && !Array.isArray(git)
450
- ? boundedString((git as Record<string, unknown>).branch, MAX_PATH_BYTES)
451
- : undefined;
452
- }
453
-
454
- function collaborationModel(
455
- payload: Record<string, unknown>,
456
- ): string | undefined {
457
- const settings = payload.settings;
458
- if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
459
- return undefined;
460
- }
461
- return boundedString((settings as Record<string, unknown>).model, MAX_MODEL_BYTES);
462
- }
463
-
464
- function stableCodexEventId(
465
- rolloutIdentity: string,
466
- startOffset: number,
467
- endOffset: number,
468
- ): string {
469
- const offsets = Buffer.alloc(16);
470
- offsets.writeBigUInt64BE(BigInt(startOffset), 0);
471
- offsets.writeBigUInt64BE(BigInt(endOffset), 8);
472
- return "codex-" + createHash("sha256")
473
- .update(rolloutIdentity)
474
- .update(Buffer.from([0]))
475
- .update(offsets)
476
- .digest("hex");
477
- }
478
-
479
- /** Adapt a Codex rollout record to the Claude-shaped sanitizer input. */
480
- function codexUsageRow(
481
- row: unknown,
482
- context: CodexUsageContext,
483
- rolloutIdentity: string,
484
- startOffset: number,
485
- endOffset: number,
486
- ): Record<string, unknown> | null {
487
- if (!row || typeof row !== "object" || Array.isArray(row)) return null;
488
- const obj = row as Record<string, unknown>;
489
- const kind = obj.type;
490
- const rawPayload = obj.payload;
491
- const payload =
492
- rawPayload && typeof rawPayload === "object" && !Array.isArray(rawPayload)
493
- ? (rawPayload as Record<string, unknown>)
494
- : undefined;
495
-
496
- if (kind === "session_meta") {
497
- if (!payload) return null;
498
- context.session_id = boundedString(payload.id, MAX_ID_BYTES) ?? context.session_id;
499
- context.cwd = boundedString(payload.cwd, MAX_PATH_BYTES);
500
- context.git_branch = codexGitBranch(payload);
501
- context.session_model = boundedString(payload.model, MAX_MODEL_BYTES);
502
- return null;
503
- }
504
- if (kind === "turn_context") {
505
- context.turn_model = payload
506
- ? boundedString(payload.model, MAX_MODEL_BYTES)
507
- : undefined;
508
- const mode = payload?.collaboration_mode;
509
- if (mode && typeof mode === "object" && !Array.isArray(mode)) {
510
- const model = collaborationModel(mode as Record<string, unknown>);
511
- if (model) context.collaboration_model = model;
512
- }
513
- return null;
514
- }
515
- if (kind === "collaboration_mode") {
516
- context.collaboration_model = payload
517
- ? collaborationModel(payload)
518
- : undefined;
519
- return null;
520
- }
521
- if (kind !== "event_msg" || !payload) return null;
522
- if (payload.type === "collaboration_mode") {
523
- context.collaboration_model = collaborationModel(payload);
524
- return null;
525
- }
526
- if (payload.type !== "token_count") return null;
527
-
528
- const info = payload.info;
529
- if (!info || typeof info !== "object" || Array.isArray(info)) return null;
530
- const last = (info as Record<string, unknown>).last_token_usage;
531
- if (!last || typeof last !== "object" || Array.isArray(last)) return null;
532
- const usage = last as Record<string, unknown>;
533
- const outputTokens =
534
- tokenCount(usage.output_tokens) + tokenCount(usage.reasoning_output_tokens);
535
- const model = codexModel(context);
536
- const normalized: Record<string, unknown> = {
537
- uuid:
538
- boundedString(obj.uuid, MAX_ID_BYTES) ??
539
- stableCodexEventId(rolloutIdentity, startOffset, endOffset),
540
- message: {
541
- usage: {
542
- input_tokens: tokenCount(usage.input_tokens),
543
- output_tokens: outputTokens,
544
- ...(typeof usage.cached_input_tokens === "number"
545
- ? { cache_read_input_tokens: tokenCount(usage.cached_input_tokens) }
546
- : {}),
547
- },
548
- ...(model ? { model } : {}),
549
- },
550
- };
551
- if (context.session_id) normalized.sessionId = context.session_id;
552
- const timestamp = boundedString(obj.timestamp, MAX_TIMESTAMP_BYTES);
553
- if (timestamp) normalized.timestamp = timestamp;
554
- if (context.cwd) normalized.cwd = context.cwd;
555
- if (context.git_branch) normalized.gitBranch = context.git_branch;
556
- return normalized;
557
- }
558
-
559
- async function sortFilesFreshestFirst(files: string[]): Promise<string[]> {
560
- const ranked = await Promise.all(
561
- files.map(async (filePath) => {
562
- try {
563
- return { filePath, mtime: (await fs.stat(filePath)).mtimeMs };
564
- } catch {
565
- return { filePath, mtime: 0 };
566
- }
567
- }),
568
- );
569
- ranked.sort(
570
- (left, right) =>
571
- right.mtime - left.mtime || left.filePath.localeCompare(right.filePath),
572
- );
573
- return ranked.map(({ filePath }) => filePath);
574
- }
575
-
576
- async function listCodexRolloutFiles(codexRoot: string): Promise<string[]> {
577
- const [live, archived] = await Promise.all([
578
- listJsonlFiles(path.join(codexRoot, "sessions")),
579
- listJsonlFiles(path.join(codexRoot, "archived_sessions")),
580
- ]);
581
- return sortFilesFreshestFirst(
582
- [...live, ...archived].filter((file) =>
583
- path.basename(file).startsWith("rollout-"),
584
- ),
585
- );
586
- }
587
-
588
- // ── Batching primitives ───────────────────────────────────────────────────────
589
-
590
- const MAX_BATCH_EVENTS = 100;
591
- const MAX_BATCH_BYTES = 240 * 1024;
592
- const MAX_SCAN_BYTES_PER_SOURCE = 4 * 1024 * 1024;
593
- const MAX_BATCHES_PER_RUN = 4;
594
- const MAX_PARTIAL_LINE_BYTES = 64 * 1024;
595
- const ROW_TRUNCATION_SUFFIX = "...[truncated]";
596
-
597
- interface RowSource {
598
- filePath: string;
599
- endOffset: number;
600
- mtime: number;
601
- context?: CodexUsageContext;
602
- }
603
-
604
- /**
605
- * Byte cost of the fixed wire-payload skeleton:
606
- * {"machineId":"…","installerVersion":"…","events":[]}
607
- *
608
- * The Rust implementation re-serializes the entire growing batch on every
609
- * row to check size — O(n²) bytes of JSON.stringify work per batch, which
610
- * for 60K events takes ~4 minutes wall-clock in V8. This computes the same
611
- * payload size incrementally instead: skeleton + Σ(per-row JSON length) +
612
- * commas between rows. Same threshold semantics, O(n) total cost.
613
- */
614
- function envelopeBytes(machineId: string, installerVersion: string): number {
615
- // Serialize the empty-events envelope once and measure it. Captures the
616
- // exact JSON whitespace / escaping V8 produces so we match what would
617
- // actually go over the wire.
618
- return Buffer.byteLength(
619
- JSON.stringify({ machineId, installerVersion, events: [] }),
620
- "utf-8",
621
- );
622
- }
623
-
624
- function jsonBytes(value: unknown): number {
625
- return Buffer.byteLength(JSON.stringify(value), "utf-8");
626
- }
627
-
628
- function truncateLongestStringField(row: Record<string, unknown>): boolean {
629
- let longestKey: string | undefined;
630
- let longestBytes = 0;
631
- for (const [key, value] of Object.entries(row)) {
632
- if (typeof value !== "string" || value.length === 0) continue;
633
- const bytes = Buffer.byteLength(value, "utf-8");
634
- if (bytes > longestBytes) {
635
- longestBytes = bytes;
636
- longestKey = key;
637
- }
638
- }
639
- if (longestKey === undefined) return false;
640
-
641
- const value = row[longestKey] as string;
642
- const keepChars =
643
- value.length > ROW_TRUNCATION_SUFFIX.length
644
- ? Math.floor((value.length - ROW_TRUNCATION_SUFFIX.length) / 2)
645
- : 0;
646
- const next =
647
- keepChars > 0
648
- ? `${value.slice(0, keepChars)}${ROW_TRUNCATION_SUFFIX}`
649
- : "";
650
- if (next === value) return false;
651
- row[longestKey] = next;
652
- return true;
653
- }
654
-
655
- function boundRowForPost(
656
- row: Record<string, unknown>,
657
- maxRowBytes: number,
658
- ): Record<string, unknown> | null {
659
- if (maxRowBytes < 0) return null;
660
- const bounded = { ...row };
661
- while (jsonBytes(bounded) > maxRowBytes) {
662
- if (!truncateLongestStringField(bounded)) return null;
663
- }
664
- return bounded;
665
- }
666
-
667
- // ── Main entry point ──────────────────────────────────────────────────────────
668
-
669
- /**
670
- * Scan, sanitize, and POST new Claude Code and Codex usage rows.
671
- *
672
- * Fire-and-forget from the caller's perspective: errors are caught internally
673
- * and surfaced only via `log`. The returned summary lets observers (e.g.
674
- * sync-runner) decide whether to record a "telemetry attempted" breadcrumb,
675
- * but no consumer is expected to react to it.
676
- */
677
- export async function collectAndSendTelemetry(
678
- opts: CollectTelemetryOptions,
679
- ): Promise<CollectTelemetryResult> {
680
- const home = os.homedir();
681
- const claudeProjectsRoot = opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
682
- const codexRoot = opts.codexRoot ?? path.join(home, ".codex");
683
- const cursorPath = opts.cursorPath ?? path.join(home, ".hq", "telemetry-cursor.json");
684
- const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
685
- const log = opts.log ?? (() => {});
686
- const maxScanBytesPerSource =
687
- opts.maxScanBytesPerSource ?? MAX_SCAN_BYTES_PER_SOURCE;
688
- const maxBatchesPerRun = opts.maxBatchesPerRun ?? MAX_BATCHES_PER_RUN;
689
-
690
- // Company attribution (US-002): parse the manifest ONCE per run and reuse the
691
- // repo-path→companyUid map for every event below. No per-event manifest read.
692
- // When `hqRoot` is omitted the map is empty → every event stays unattributed.
693
- const repoCompanyMap: RepoCompanyMap = opts.hqRoot
694
- ? await buildRepoCompanyMap(opts.hqRoot)
695
- : { entries: [], bySlug: new Map(), foldsCase: false, ambiguous: new Set<string>() };
696
- const fallbackCompanyUid = opts.fallbackCompany?.startsWith("cmp_")
697
- ? opts.fallbackCompany
698
- : opts.fallbackCompany
699
- ? repoCompanyMap.bySlug.get(opts.fallbackCompany)
700
- : undefined;
701
-
702
- // 1. Opt-in check (server-authoritative, with local fallback + self-heal).
703
- let enabled: boolean;
704
- let optInSource: CollectTelemetryResult["optInSource"];
705
- try {
706
- const resp = await opts.client.getTelemetryOptIn();
707
- enabled = resp.enabled === true;
708
- optInSource = "server";
709
-
710
- // Self-heal a consent the user gave but that never reached the server.
711
- //
712
- // The installer writes the answer to `~/.hq/menubar.json` (always succeeds)
713
- // AND posts it to `/v1/usage/opt-in` — but that post fires before the
714
- // person entity exists, so it 404s and the attribute is never written.
715
- // Absence then reads as `false`, the emitter goes silent, and the person
716
- // shows as "not opted in" forever. Measured: 22 of 33 active Indigo members
717
- // had no attribute at all, against only 2 genuine opt-outs.
718
- //
719
- // `unset` (never answered) is the ONLY state we heal. An explicit server
720
- // `false` is a real opt-out and is left strictly alone. We also require a
721
- // local answer to actually exist — we never invent consent, we only replay
722
- // the answer the user already gave.
723
- //
724
- // And it must be THIS account's answer. `menubar.json` is per-MACHINE, so
725
- // when two people sign in under the same OS user it holds whoever answered
726
- // last; replaying that for the second account would opt in someone who
727
- // never consented. The replay therefore requires the cached record to name
728
- // the same `prs_*` the server says we are. A legacy record with no binding
729
- // cannot be proven to belong to this caller and is skipped — for those
730
- // machines the installer's own post-sign-in upload is the recovery path,
731
- // and it is correctly account-scoped because it runs right after that
732
- // person authenticates.
733
- if (resp.unset === true && opts.client.setTelemetryOptIn) {
734
- const record = await readLocalConsentRecord(menubarPath);
735
- const local = record.enabled;
736
- const boundToCaller =
737
- record.personUid !== undefined &&
738
- resp.personUid !== undefined &&
739
- record.personUid === resp.personUid;
740
- if (local !== undefined && !boundToCaller) {
741
- log(
742
- "[telemetry] skipping consent re-assert: the locally cached answer is not bound to the signed-in account",
743
- );
744
- }
745
- if (local !== undefined && boundToCaller) {
746
- try {
747
- // `onlyIfUnset` keeps the replay atomic. Reading `unset` and writing
748
- // are two requests, so without it another device could record a real
749
- // opt-out in between and this stale local answer would overwrite it.
750
- // The server tests "still unset?" as part of the write instead.
751
- const ack = await opts.client.setTelemetryOptIn(local, { onlyIfUnset: true });
752
- if (ack && ack.applied === false) {
753
- // Lost the race — a real answer was recorded first and it stands.
754
- // Keep the server's answer for this run rather than the local one.
755
- log(
756
- "[telemetry] consent was recorded elsewhere before the re-assert landed; deferring to the server",
757
- );
758
- } else {
759
- enabled = local;
760
- optInSource = "menubar-reasserted";
761
- log(
762
- `[telemetry] server had no recorded consent; re-asserted the local install-time answer (enabled=${local})`,
763
- );
764
- }
765
- } catch (err) {
766
- // Non-fatal: fall through on the server's answer. Next run retries.
767
- log(
768
- `[telemetry] failed to re-assert local opt-in (${(err as Error).message ?? err})`,
769
- );
770
- }
771
- }
772
- }
773
- } catch (err) {
774
- log(`[telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`);
775
- enabled = await readLocalTelemetryEnabled(menubarPath);
776
- optInSource = "menubar-fallback";
777
- }
778
-
779
- if (!enabled) {
780
- return { enabled: false, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
781
- }
782
-
783
- // 2. Cursor + file enumeration.
784
- const cursor = await loadCursor(cursorPath);
785
- const loadedFiles = { ...cursor.files };
786
- const rotationResets: Record<string, CursorEntry> = {};
787
- const newlyCommitted: Record<string, CursorEntry> = {};
788
-
789
- const [claudeFilesUnsorted, codexFiles] = await Promise.all([
790
- listJsonlFiles(claudeProjectsRoot),
791
- listCodexRolloutFiles(codexRoot),
792
- ]);
793
- const claudeFiles = await sortFilesFreshestFirst(claudeFilesUnsorted);
794
- // Codex goes first so a fresh Linux install cannot spend its entire bounded
795
- // pass replaying an older Claude backlog before reporting current Codex use.
796
- const files: Array<{ filePath: string; kind: "claude" | "codex" }> = [
797
- ...codexFiles.map((filePath) => ({ filePath, kind: "codex" as const })),
798
- ...claudeFiles.map((filePath) => ({ filePath, kind: "claude" as const })),
799
- ];
800
-
801
- // 3. Walk each file, sanitize new rows, batch, flush at the server contract.
802
- //
803
- // Byte accounting is incremental: we track `batchBytes` as the projected
804
- // serialized size of the current batch (envelope + per-row JSON + commas).
805
- // Each row contributes its own JSON.stringify length once; we never
806
- // re-serialize the growing batch. This is the O(n) replacement for the
807
- // O(n²) projected-payload check the Rust impl uses (which spent ~4 min
808
- // on a 60K-event first-run in the E2E smoke against hq-prod).
809
- const ENVELOPE_BYTES = envelopeBytes(opts.machineId, opts.installerVersion);
810
- let batchEvents: Array<Record<string, unknown>> = [];
811
- let batchSources: RowSource[] = [];
812
- let batchBytes = ENVELOPE_BYTES;
813
- let eventsSent = 0;
814
- let batchesSent = 0;
815
- let uploadFailed = false;
816
- let batchLimitReached = false;
817
- const scannedBytes = { claude: 0, codex: 0 };
818
-
819
- const commitSources = (sources: RowSource[]): void => {
820
- const maxPerFile = new Map<
821
- string,
822
- { mtime: number; offset: number; context?: CodexUsageContext }
823
- >();
824
- for (const source of sources) {
825
- const current = maxPerFile.get(source.filePath);
826
- if (!current || source.endOffset > current.offset) {
827
- maxPerFile.set(source.filePath, {
828
- mtime: source.mtime,
829
- offset: source.endOffset,
830
- ...(source.context
831
- ? { context: cloneCodexContext(source.context) }
832
- : {}),
833
- });
834
- }
835
- }
836
- for (const [filePath, entry] of maxPerFile) {
837
- const current = newlyCommitted[filePath];
838
- if (!current || entry.offset > current.offset) {
839
- newlyCommitted[filePath] = entry;
840
- }
841
- }
842
- };
843
-
844
- const flush = async (): Promise<boolean> => {
845
- const events = batchEvents;
846
- const sources = batchSources;
847
- batchEvents = [];
848
- batchSources = [];
849
- batchBytes = ENVELOPE_BYTES;
850
- if (events.length === 0) {
851
- commitSources(sources);
852
- return true;
853
- }
854
- try {
855
- await opts.client.postUsage({
856
- machineId: opts.machineId,
857
- installerVersion: opts.installerVersion,
858
- events,
859
- });
860
- batchesSent++;
861
- eventsSent += events.length;
862
- commitSources(sources);
863
- return true;
864
- } catch (err) {
865
- log(
866
- "[telemetry] postUsage failed (" +
867
- ((err as Error).message ?? err) +
868
- ") — cursor not advanced for " +
869
- sources.length +
870
- " rows",
871
- );
872
- return false;
873
- }
874
- };
875
-
876
- filesLoop: for (const { filePath, kind } of files) {
877
- if (uploadFailed || batchLimitReached) break;
878
- const sourceBudgetRemaining =
879
- maxScanBytesPerSource - scannedBytes[kind];
880
- if (sourceBudgetRemaining <= 0) continue;
881
-
882
- let stat;
883
- try {
884
- stat = await fs.stat(filePath);
885
- } catch {
886
- continue;
887
- }
888
- const currentSize = stat.size;
889
- const currentMtime = Math.floor(stat.mtimeMs / 1000);
890
- const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
891
- let offset = stored.offset;
892
- let codexContext: CodexUsageContext | undefined =
893
- kind === "codex" ? cloneCodexContext(stored.context ?? {}) : undefined;
894
- const rotated =
895
- currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
896
- if (rotated) {
897
- offset = 0;
898
- if (kind === "codex") codexContext = {};
899
- rotationResets[filePath] = { offset: 0, mtime: currentMtime };
900
- }
901
- if (offset >= currentSize && !rotated) continue;
902
-
903
- let content: string;
904
- let bytesRead = 0;
905
- let readBuffer = Buffer.alloc(0);
906
- try {
907
- const fileHandle = await fs.open(filePath, "r");
908
- try {
909
- const length = Math.max(
910
- 0,
911
- Math.min(currentSize - offset, sourceBudgetRemaining),
912
- );
913
- readBuffer = Buffer.alloc(length);
914
- ({ bytesRead } = await fileHandle.read(
915
- readBuffer,
916
- 0,
917
- length,
918
- offset,
919
- ));
920
- content = readBuffer.toString("utf-8", 0, bytesRead);
921
- } finally {
922
- await fileHandle.close();
923
- }
924
- } catch {
925
- continue;
926
- }
927
- if (bytesRead === 0) continue;
928
-
929
- // Resume a previously discarded oversized Codex row using raw byte
930
- // positions. Decoding from the middle of a multibyte UTF-8 codepoint would
931
- // otherwise skew byte offsets and could skip or replay later rows.
932
- if (codexContext?.discarding_partial_line) {
933
- const newlineIndex = readBuffer.subarray(0, bytesRead).indexOf(0x0a);
934
- const discardedBytes = newlineIndex >= 0 ? newlineIndex + 1 : bytesRead;
935
- codexContext.discarding_partial_line = newlineIndex < 0;
936
- scannedBytes[kind] += discardedBytes;
937
- batchSources.push({
938
- filePath,
939
- endOffset: offset + discardedBytes,
940
- mtime: currentMtime,
941
- context: cloneCodexContext(codexContext),
942
- });
943
- if (newlineIndex < 0) continue;
944
- offset += discardedBytes;
945
- bytesRead -= discardedBytes;
946
- readBuffer = readBuffer.subarray(discardedBytes);
947
- content = readBuffer.toString("utf-8", 0, bytesRead);
948
- if (bytesRead === 0) continue;
949
- }
950
-
951
- const reachedPhysicalEof = offset + bytesRead >= currentSize;
952
- const segments = content.split("\n");
953
- const lineEndOffsets: number[] = [];
954
- let cumulative = 0;
955
- for (let i = 0; i < segments.length; i++) {
956
- cumulative += Buffer.byteLength(segments[i], "utf-8");
957
- if (i < segments.length - 1) cumulative += 1;
958
- lineEndOffsets.push(offset + cumulative);
959
- }
960
-
961
- for (let i = 0; i < segments.length; i++) {
962
- const startOffset = i === 0 ? offset : lineEndOffsets[i - 1];
963
- const hasNewline = i < segments.length - 1;
964
- const endOffset =
965
- !hasNewline && !reachedPhysicalEof
966
- ? offset + bytesRead
967
- : lineEndOffsets[i];
968
- const lineBytes = endOffset - startOffset;
969
- if (!hasNewline && !reachedPhysicalEof) {
970
- const discarding = codexContext?.discarding_partial_line === true;
971
- if (discarding || lineBytes >= MAX_PARTIAL_LINE_BYTES) {
972
- if (codexContext) codexContext.discarding_partial_line = true;
973
- scannedBytes[kind] += lineBytes;
974
- batchSources.push({
975
- filePath,
976
- endOffset,
977
- mtime: currentMtime,
978
- ...(codexContext
979
- ? { context: cloneCodexContext(codexContext) }
980
- : {}),
981
- });
982
- }
983
- break;
984
- }
985
-
986
- scannedBytes[kind] += lineBytes;
987
- const source = (): RowSource => ({
988
- filePath,
989
- endOffset,
990
- mtime: currentMtime,
991
- ...(codexContext
992
- ? { context: cloneCodexContext(codexContext) }
993
- : {}),
994
- });
995
- if (codexContext?.discarding_partial_line) {
996
- codexContext.discarding_partial_line = false;
997
- batchSources.push(source());
998
- continue;
999
- }
1000
- const trimmed = segments[i].trim();
1001
- if (trimmed.length === 0) {
1002
- batchSources.push(source());
1003
- continue;
1004
- }
1005
- let parsed: unknown;
1006
- try {
1007
- parsed = JSON.parse(trimmed);
1008
- } catch {
1009
- batchSources.push(source());
1010
- continue;
1011
- }
1012
- const sourceRow =
1013
- kind === "codex"
1014
- ? codexUsageRow(
1015
- parsed,
1016
- codexContext!,
1017
- filePath,
1018
- startOffset,
1019
- endOffset,
1020
- )
1021
- : parsed;
1022
- const rowCwd =
1023
- sourceRow && typeof sourceRow === "object" && !Array.isArray(sourceRow)
1024
- ? (sourceRow as Record<string, unknown>).cwd
1025
- : undefined;
1026
- const companyUid =
1027
- resolveCompanyForCwd(
1028
- typeof rowCwd === "string" ? rowCwd : undefined,
1029
- repoCompanyMap,
1030
- ) ?? fallbackCompanyUid;
1031
- const sanitized = sanitizeRow(sourceRow, companyUid);
1032
- if (!sanitized) {
1033
- batchSources.push(source());
1034
- continue;
1035
- }
1036
-
1037
- const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
1038
- const wasOversized = jsonBytes(sanitized) > maxRowBytes;
1039
- const bounded = boundRowForPost(sanitized, maxRowBytes);
1040
- if (!bounded) {
1041
- log(
1042
- "[telemetry] oversized row dropped before send (" +
1043
- filePath +
1044
- ":" +
1045
- (i + 1) +
1046
- ")",
1047
- );
1048
- batchSources.push(source());
1049
- continue;
1050
- }
1051
- if (wasOversized) {
1052
- log(
1053
- "[telemetry] oversized row truncated before send (" +
1054
- filePath +
1055
- ":" +
1056
- (i + 1) +
1057
- ")",
1058
- );
1059
- }
1060
-
1061
- const rowJsonBytes = jsonBytes(bounded);
1062
- const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
1063
- if (
1064
- batchEvents.length > 0 &&
1065
- (batchEvents.length >= MAX_BATCH_EVENTS ||
1066
- batchBytes + addCost > MAX_BATCH_BYTES)
1067
- ) {
1068
- if (!(await flush())) {
1069
- uploadFailed = true;
1070
- break filesLoop;
1071
- }
1072
- if (batchesSent >= maxBatchesPerRun) {
1073
- batchLimitReached = true;
1074
- break filesLoop;
1075
- }
1076
- batchBytes = ENVELOPE_BYTES + rowJsonBytes;
1077
- } else {
1078
- batchBytes += addCost;
1079
- }
1080
- batchEvents.push(bounded);
1081
- batchSources.push(source());
1082
- }
1083
- }
1084
-
1085
- if (!uploadFailed && !batchLimitReached) {
1086
- uploadFailed = !(await flush());
1087
- }
1088
-
1089
- // 4. Persist cursor: loaded < rotation_resets < newly_committed.
1090
- const finalFiles: Record<string, CursorEntry> = { ...loadedFiles };
1091
- for (const [fp, entry] of Object.entries(rotationResets)) finalFiles[fp] = entry;
1092
- for (const [fp, entry] of Object.entries(newlyCommitted)) finalFiles[fp] = entry;
1093
-
1094
- await saveCursor(cursorPath, {
1095
- version: "1",
1096
- files: finalFiles,
1097
- ...(cursor.codex_next_rollout
1098
- ? { codex_next_rollout: cursor.codex_next_rollout }
1099
- : {}),
1100
- });
1101
-
1102
- return {
1103
- enabled: true,
1104
- optInSource,
1105
- filesScanned: files.length,
1106
- eventsSent,
1107
- batchesSent,
1108
- };
1109
- }