@cohortapp/agent-sdk 2.5.1 → 2.6.0

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 (106) hide show
  1. package/bin/maestro.mjs +185 -88
  2. package/bin/maestro.test.mjs +175 -48
  3. package/docs/runbooks/backup-restore.md +65 -33
  4. package/framework-features.json +4 -4
  5. package/lib/backup/policy.mjs +710 -0
  6. package/lib/backup/policy.test.mjs +305 -0
  7. package/lib/budget-escalate.mjs +133 -0
  8. package/lib/budget-escalate.test.mjs +232 -0
  9. package/lib/budget-guard.envelope.test.mjs +476 -0
  10. package/lib/budget-guard.mjs +853 -75
  11. package/lib/budget-guard.test.mjs +91 -42
  12. package/lib/cadences.mjs +33 -0
  13. package/lib/channels/orgmail/adapter.mjs +88 -3
  14. package/lib/channels/orgmail/adapter.test.mjs +137 -0
  15. package/lib/channels/repeat-suppressor.mjs +198 -0
  16. package/lib/channels/repeat-suppressor.test.mjs +134 -0
  17. package/lib/comms/receipts.mjs +297 -0
  18. package/lib/cost/ledger-row.mjs +333 -0
  19. package/lib/cost/ledger-row.test.mjs +183 -0
  20. package/lib/execution/drive.mjs +28 -1
  21. package/lib/execution/effects.mjs +191 -12
  22. package/lib/execution/effects.test.mjs +50 -11
  23. package/lib/goals/admission.mjs +13 -1
  24. package/lib/goals/admission.test.mjs +26 -1
  25. package/lib/goals/loop.mjs +13 -0
  26. package/lib/kpi-sensors.test.mjs +3 -0
  27. package/lib/mandate/cache.mjs +13 -5
  28. package/lib/mandate/derive.mjs +146 -21
  29. package/lib/mandate/derive.test.mjs +50 -6
  30. package/lib/mandate/model.mjs +32 -4
  31. package/lib/mandate/refresh.test.mjs +16 -2
  32. package/lib/mcp/server.test.mjs +12 -3
  33. package/lib/model-router/economics.mjs +107 -76
  34. package/lib/model-router/economics.test.mjs +64 -46
  35. package/lib/model-router/integration-coverage.test.mjs +39 -37
  36. package/lib/model-router/ledger.mjs +75 -22
  37. package/lib/model-router/ledger.test.mjs +35 -2
  38. package/lib/org/client.mjs +14 -0
  39. package/lib/org/cost-sync.mjs +16 -2
  40. package/lib/org/doctor.mjs +62 -1
  41. package/lib/org/doctor.test.mjs +36 -3
  42. package/lib/org/email-remedy.mjs +49 -0
  43. package/lib/org/engagement-ledger.mjs +376 -0
  44. package/lib/org/engagement-ledger.test.mjs +112 -0
  45. package/lib/org/engagement.mjs +1056 -0
  46. package/lib/org/engagement.test.mjs +739 -0
  47. package/lib/org/messaging.mjs +230 -3
  48. package/lib/org/messaging.test.mjs +110 -1
  49. package/lib/org/param-contract.mjs +56 -2
  50. package/lib/org/param-contract.test.mjs +26 -0
  51. package/lib/org/protocol.checksum +1 -1
  52. package/lib/org/protocol.mjs +5 -0
  53. package/lib/org/protocol.test.mjs +7 -1
  54. package/lib/org/tool-surface.mjs +506 -10
  55. package/lib/org/tool-surface.test.mjs +191 -7
  56. package/lib/org/ui-parity.mjs +333 -6
  57. package/lib/org/ui-parity.test.mjs +96 -3
  58. package/lib/org/work-ledger.mjs +241 -0
  59. package/lib/org/work-ledger.test.mjs +237 -0
  60. package/lib/plan/adoption-e2e.test.mjs +366 -0
  61. package/lib/plan/budget-enforcement.test.mjs +400 -0
  62. package/lib/plan/budget-runtime.mjs +215 -0
  63. package/lib/plan/compile.mjs +201 -5
  64. package/lib/plan/compile.test.mjs +19 -5
  65. package/lib/plan/emit.mjs +8 -0
  66. package/lib/plan/emit.test.mjs +18 -0
  67. package/lib/resource-governor.mjs +58 -12
  68. package/lib/resource-governor.test.mjs +41 -1
  69. package/lib/security/audit-engine.mjs +45 -8
  70. package/lib/security/audit-engine.test.mjs +35 -0
  71. package/lib/setup/enroll-from-cohort.mjs +14 -1
  72. package/lib/setup/sections/mandate.mjs +48 -7
  73. package/lib/setup/sections/mandate.test.mjs +17 -2
  74. package/lib/setup/sections/orgmail.mjs +10 -2
  75. package/lib/setup/state.mjs +83 -2
  76. package/lib/telemetry/collect.mjs +360 -20
  77. package/lib/telemetry/collect.test.mjs +266 -0
  78. package/package.json +1 -1
  79. package/scripts/cost/track-claude-usage.mjs +207 -48
  80. package/scripts/cost/track-claude-usage.test.mjs +148 -0
  81. package/scripts/daemon/agent-daemon.mjs +315 -17
  82. package/scripts/daemon/assurance-e2e.test.mjs +421 -0
  83. package/scripts/daemon/assurance.mjs +944 -0
  84. package/scripts/daemon/assurance.test.mjs +668 -0
  85. package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
  86. package/scripts/daemon/cadence-consumer.mjs +147 -9
  87. package/scripts/daemon/cadence-consumer.test.mjs +6 -0
  88. package/scripts/daemon/cadence-handlers.mjs +158 -0
  89. package/scripts/daemon/cadence-handlers.test.mjs +64 -0
  90. package/scripts/daemon/deliver.mjs +314 -0
  91. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  92. package/scripts/daemon/dispatcher.mjs +64 -6
  93. package/scripts/daemon/responder-cost.test.mjs +68 -0
  94. package/scripts/daemon/responder.mjs +351 -298
  95. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  96. package/scripts/maintenance/backup-run.mjs +415 -0
  97. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  98. package/scripts/org/send-orgmail.mjs +16 -0
  99. package/scripts/record-receipt.sh +63 -0
  100. package/scripts/restore-from-backup.sh +14 -3
  101. package/scripts/restore-from-backup.test.mjs +8 -5
  102. package/scripts/send-email-threaded.py +47 -0
  103. package/scripts/send-sms.sh +4 -0
  104. package/scripts/send-whatsapp.sh +4 -0
  105. package/scripts/setup/init-backup.mjs +93 -38
  106. package/scripts/slack-send.sh +12 -0
@@ -14,11 +14,21 @@
14
14
  * subAgentsRunning: number, // running claude child processes (best-effort)
15
15
  * session: { id?, taskClass?, model? } | null,
16
16
  * machine: {
17
+ * // MOMENTARY — true at `ts`, expires with the snapshot
17
18
  * loadavg: [n,n,n],
18
19
  * memUsedPct: number, // 1 - free/total
19
- * cpuPct?: number, // best-effort (powermetrics)
20
- * tempC?: number, // best-effort (powermetrics)
20
+ * cpuPct?: number, // powermetrics if root, else loadavg/cores
21
+ * cpuSource?: "powermetrics"|"sudo-powermetrics"|"loadavg",
22
+ * tempC?: number, // best-effort (powermetrics; needs root)
23
+ * tempSource?: "powermetrics"|"sudo-powermetrics",
24
+ * tempDetail?: string, // why there is no tempC (never silent)
21
25
  * diskUsedPct?: number,
26
+ * uptimeDays?: number,
27
+ * spend24h?: number, // rolling 24h billable USD off the cost ledger
28
+ * // INVENTORY — describes the machine; does not perish with the snapshot
29
+ * device?: string, // "Mac mini M4 Pro"
30
+ * ram?: string, // "24 GB"
31
+ * cores?: number,
22
32
  * },
23
33
  * claudeAuth: "ok"|"relogin_required"|"unknown",
24
34
  * alerts: [{ id, severity, kind, detail }],
@@ -48,10 +58,11 @@
48
58
 
49
59
  import os from "node:os";
50
60
  import { execFile } from "node:child_process";
51
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
61
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
52
62
  import { join, resolve } from "node:path";
53
63
 
54
64
  import { list as listPresence } from "../collective/presence.mjs";
65
+ import { billableUsd } from "../cost/ledger-row.mjs";
55
66
  import { liveClaudeStats } from "../resource-governor.mjs";
56
67
 
57
68
  /** Default temperature probe ceiling — used only as a guard in alerts; here we just report. */
@@ -191,10 +202,40 @@ function humanActivity(intent, taskClass) {
191
202
 
192
203
  /**
193
204
  * Gather machine stats from node:os plus best-effort `powermetrics` (temp/cpu)
194
- * and `df` (disk). The exec probes are guarded + injected; on macOS powermetrics
195
- * needs root so a non-root agent simply gets no temp/cpu (those fields drop out).
205
+ * and `df` (disk). The exec probes are guarded + injected.
196
206
  *
197
- * @returns {Promise<{loadavg:number[], memUsedPct:number, cpuPct?:number, tempC?:number, diskUsedPct?:number}>}
207
+ * ── THE VOCABULARY IS THE CONTRACT ───────────────────────────────────────────
208
+ * Every key below is READ BY NAME on the other side of the beat
209
+ * (`hq/src/components/fleet/machine.ts#buildFleetMachine`). This collector and
210
+ * that reader once shared ZERO keys in production — it emitted
211
+ * `loadavg/memUsedPct/cpuPct/diskUsedPct` and the Fleet row read
212
+ * `tempC/uptimeDays/spend24h/ram/device`, so four measured numbers were
213
+ * persisted and rendered nowhere while five columns em-dashed for want of a
214
+ * producer. Adding a key here without adding its reader there (or vice versa)
215
+ * re-opens exactly that hole; `machine.test.ts` asserts the two sets agree.
216
+ *
217
+ * ── MOMENTARY vs INVENTORY ───────────────────────────────────────────────────
218
+ * MOMENTARY (describes the machine AT `status.ts`, and expires with it):
219
+ * loadavg, memUsedPct, cpuPct, tempC, diskUsedPct, uptimeDays, spend24h
220
+ * INVENTORY (describes the machine, does not perish):
221
+ * device, ram, cores
222
+ * The reader nulls the momentary set once the snapshot goes stale and keeps the
223
+ * inventory set. That split is why they are documented as one list here.
224
+ *
225
+ * ── EVERY BEST-EFFORT FIELD CARRIES ITS PROVENANCE ───────────────────────────
226
+ * `cpuSource` and `tempSource` say WHERE the number came from, and `tempDetail`
227
+ * says why there is none. A `cpuPct` derived from loadavg/cores is a different
228
+ * claim from one measured by powermetrics, and a missing `tempC` because the
229
+ * probe needs root is a different fact from a machine with no sensor. Both used
230
+ * to be indistinguishable from success: `collectPowermetrics` returned `{}` on
231
+ * every failure path and the field simply vanished. Fail-open is fine; silent
232
+ * is not.
233
+ *
234
+ * @returns {Promise<{
235
+ * loadavg:number[], memUsedPct:number, cpuPct?:number, cpuSource?:string,
236
+ * tempC?:number, tempSource?:string, tempDetail?:string, diskUsedPct?:number,
237
+ * uptimeDays?:number, spend24h?:number, device?:string, ram?:string, cores?:number
238
+ * }>}
198
239
  */
199
240
  export async function collectMachine(o = {}) {
200
241
  const osImpl = o.os || os;
@@ -224,15 +265,34 @@ export async function collectMachine(o = {}) {
224
265
  const n = Array.isArray(cpus) ? cpus.length : 0;
225
266
  if (n > 0 && Number.isFinite(machine.loadavg[0])) {
226
267
  const est = (machine.loadavg[0] / n) * 100;
227
- if (Number.isFinite(est)) machine.cpuPct = pct(est);
268
+ if (Number.isFinite(est)) {
269
+ machine.cpuPct = pct(est);
270
+ machine.cpuSource = "loadavg";
271
+ }
228
272
  }
229
273
  } catch { /* no cpuPct */ }
230
274
 
275
+ // uptimeDays — os.uptime() is seconds since boot. Momentary, and one of the
276
+ // four fields the Fleet row read from a producer that never wrote them.
277
+ try {
278
+ const secs = Number(osImpl.uptime ? osImpl.uptime() : NaN);
279
+ if (Number.isFinite(secs) && secs >= 0) machine.uptimeDays = round1(secs / 86400);
280
+ } catch { /* no uptimeDays */ }
281
+
231
282
  // Best-effort temp + cpu via powermetrics (guarded, injected, never blocks).
283
+ // ALWAYS records why it has no answer — see collectPowermetrics.
232
284
  if (o.powermetrics !== false) {
233
285
  const pm = await collectPowermetrics(o);
234
- if (Number.isFinite(pm.tempC)) machine.tempC = round1(pm.tempC);
235
- if (Number.isFinite(pm.cpuPct)) machine.cpuPct = pct(pm.cpuPct);
286
+ if (Number.isFinite(pm.tempC)) {
287
+ machine.tempC = round1(pm.tempC);
288
+ machine.tempSource = pm.source || "powermetrics";
289
+ } else if (pm.detail) {
290
+ machine.tempDetail = pm.detail;
291
+ }
292
+ if (Number.isFinite(pm.cpuPct)) {
293
+ machine.cpuPct = pct(pm.cpuPct);
294
+ machine.cpuSource = pm.source || "powermetrics";
295
+ }
236
296
  }
237
297
 
238
298
  // Best-effort diskUsedPct via df.
@@ -241,30 +301,121 @@ export async function collectMachine(o = {}) {
241
301
  if (Number.isFinite(disk)) machine.diskUsedPct = pct(disk);
242
302
  }
243
303
 
304
+ // INVENTORY — device / ram / cores. Cached on disk (see collectInventory):
305
+ // the chip and the installed RAM do not change between beats, so the
306
+ // system_profiler fork happens once per machine, not every 30 seconds.
307
+ if (o.inventory !== false) {
308
+ const inv = await collectInventory(o);
309
+ if (inv.device) machine.device = inv.device;
310
+ if (inv.ram) machine.ram = inv.ram;
311
+ if (Number.isFinite(inv.cores)) machine.cores = inv.cores;
312
+ }
313
+
314
+ // spend24h — the agent's own rolling 24h LLM spend, off the cost ledger.
315
+ if (o.spend !== false) {
316
+ const spend = collectSpend24h(o);
317
+ if (Number.isFinite(spend)) machine.spend24h = spend;
318
+ }
319
+
244
320
  return machine;
245
321
  }
246
322
 
323
+ /**
324
+ * The `powermetrics` sampler that carries die temperature, BY CPU ARCHITECTURE.
325
+ *
326
+ * ── A BUG THAT MADE THE PROBE UNREACHABLE ON EVERY APPLE-SILICON MAC ─────────
327
+ * This used to be the constant `"smc,cpu_power"`. `smc` is an INTEL-ONLY
328
+ * sampler: on arm64 `powermetrics` rejects the argument list outright —
329
+ * `powermetrics: unrecognized sampler: smc` — and exits non-zero BEFORE it ever
330
+ * reaches the root check. So on the only hardware the fleet actually runs on,
331
+ * the probe failed at argument parsing, `execFileSafe` swallowed the non-zero
332
+ * exit into `""`, and the caller could not tell that apart from a machine with
333
+ * no thermal sensor. The arm64 spelling is `thermal`.
334
+ *
335
+ * `cpu_power` is valid on both and is what yields the idle-residency line the
336
+ * parser reads for `cpuPct`, so it stays in either list.
337
+ */
338
+ export function powermetricsSamplers(arch) {
339
+ return String(arch || "") === "arm64" ? "thermal,cpu_power" : "smc,cpu_power";
340
+ }
341
+
247
342
  /**
248
343
  * Parse `powermetrics` for CPU temperature + active CPU%. Guarded: only attempts
249
344
  * the exec on darwin (unless an explicit `deps.execFile` + `deps.platform` say
250
- * otherwise for tests). Returns {} on any failure. NEVER blocks past timeoutMs.
345
+ * otherwise for tests). NEVER blocks past timeoutMs, NEVER throws.
251
346
  *
252
- * @returns {Promise<{ tempC?:number, cpuPct?:number }>}
347
+ * ── IT ALWAYS SAYS WHY IT HAS NOTHING ────────────────────────────────────────
348
+ * This returned a bare `{}` for all four of its failure modes — wrong sampler,
349
+ * missing binary, not root, no sensor — and the caller dropped the field. The
350
+ * Fleet view then printed an em-dash that meant "we do not know, and we cannot
351
+ * tell you why", which is how a two-month-dead probe stayed invisible. Every
352
+ * exit now carries a `detail` sentence that the snapshot ships and the drawer
353
+ * prints.
354
+ *
355
+ * ── ROOT, AND THE ONE HONEST WAY AROUND IT ───────────────────────────────────
356
+ * `powermetrics` requires superuser on macOS, full stop; the daemon runs from a
357
+ * user LaunchAgent. There is NO non-root numeric temperature source on Apple
358
+ * Silicon without a compiled IOKit helper (`ioreg` exposes the PMU/NVMe sensors
359
+ * as HID services whose values are only readable through IOHIDEventSystem, not
360
+ * through the registry), so this does not pretend to have one. Instead it
361
+ * supports an OPT-IN sudo path: set `TELEMETRY_SUDO_POWERMETRICS=1` once the
362
+ * operator has allowlisted the binary NOPASSWD in sudoers. `sudo -n` never
363
+ * prompts — with no allowlist it fails instantly rather than hanging a beat on
364
+ * a password prompt no one will ever see.
365
+ *
366
+ * @returns {Promise<{ tempC?:number, cpuPct?:number, source?:string, detail?:string }>}
253
367
  */
254
368
  export async function collectPowermetrics(o = {}) {
255
369
  const platform = o.platform || (o.os && o.os.platform && o.os.platform()) || process.platform;
256
370
  const execFileImpl = o.execFile || execFile;
257
- if (platform !== "darwin" && !o.execFile) return {}; // only darwin in the wild; tests inject execFile
371
+ // only darwin in the wild; tests inject execFile
372
+ if (platform !== "darwin" && !o.execFile) {
373
+ return { detail: `no temperature probe on platform ${platform}` };
374
+ }
258
375
  const timeoutMs = Number.isFinite(o.pmTimeoutMs) ? o.pmTimeoutMs : 1500;
259
376
  const bin = o.powermetricsBin || "/usr/bin/powermetrics";
260
- if (!o.execFile && !existsSync(bin)) return {};
261
- const out = await execFileSafe(
262
- execFileImpl, bin,
263
- ["-n", "1", "-i", "200", "--samplers", "smc,cpu_power", "--show-process-energy"],
264
- timeoutMs,
265
- );
266
- if (!out) return {};
267
- return parsePowermetrics(out);
377
+ if (!o.execFile && !existsSync(bin)) {
378
+ return { detail: `${bin} not present` };
379
+ }
380
+ const arch = o.arch || (o.os && o.os.arch && o.os.arch()) || process.arch;
381
+ const args = [
382
+ "-n", "1", "-i", "200",
383
+ "--samplers", powermetricsSamplers(arch),
384
+ "--show-process-energy",
385
+ ];
386
+
387
+ // 1. Direct. Works when the daemon happens to run as root.
388
+ const direct = await execFileSafe(execFileImpl, bin, args, timeoutMs);
389
+ if (direct) {
390
+ const parsed = parsePowermetrics(direct);
391
+ if (Number.isFinite(parsed.tempC) || Number.isFinite(parsed.cpuPct)) {
392
+ return { ...parsed, source: "powermetrics" };
393
+ }
394
+ }
395
+
396
+ // 2. Opt-in sudo, only when the operator has allowlisted it. `-n` = never prompt.
397
+ const sudoEnabled = o.sudoPowermetrics !== undefined
398
+ ? Boolean(o.sudoPowermetrics)
399
+ : String(process.env.TELEMETRY_SUDO_POWERMETRICS || "") === "1";
400
+ if (sudoEnabled) {
401
+ const sudoBin = o.sudoBin || "/usr/bin/sudo";
402
+ const out = await execFileSafe(execFileImpl, sudoBin, ["-n", bin, ...args], timeoutMs);
403
+ if (out) {
404
+ const parsed = parsePowermetrics(out);
405
+ if (Number.isFinite(parsed.tempC) || Number.isFinite(parsed.cpuPct)) {
406
+ return { ...parsed, source: "sudo-powermetrics" };
407
+ }
408
+ }
409
+ return {
410
+ detail: `${bin} yielded nothing under 'sudo -n' — check the NOPASSWD sudoers allowlist`,
411
+ };
412
+ }
413
+
414
+ return {
415
+ detail:
416
+ `${bin} requires root (agent runs unprivileged); ` +
417
+ "allowlist it NOPASSWD in sudoers and set TELEMETRY_SUDO_POWERMETRICS=1 to enable",
418
+ };
268
419
  }
269
420
 
270
421
  /** Pure parser for powermetrics text → { tempC?, cpuPct? }. Tolerant of format drift. */
@@ -307,6 +458,190 @@ export function parseDf(text) {
307
458
  function round1(n) { return Math.round(Number(n) * 10) / 10; }
308
459
  function round2(n) { return Math.round(Number(n) * 100) / 100; }
309
460
 
461
+ // ---------------------------------------------------------------------------
462
+ // Inventory (device / ram / cores) — what the machine IS, not what it is doing
463
+ // ---------------------------------------------------------------------------
464
+
465
+ /**
466
+ * Human-readable installed RAM, e.g. `24 GB`. Binary GB (the unit every Apple
467
+ * spec sheet and Activity Monitor uses), rounded to whole units — 25769803776
468
+ * bytes is "24 GB", never "25.8 GB".
469
+ */
470
+ export function ramLabel(totalBytes) {
471
+ const b = Number(totalBytes);
472
+ if (!Number.isFinite(b) || b <= 0) return "";
473
+ const gb = b / 1024 ** 3;
474
+ return `${gb >= 10 ? Math.round(gb) : round1(gb)} GB`;
475
+ }
476
+
477
+ /**
478
+ * Pure parser for `system_profiler SPHardwareDataType` text → the inventory
479
+ * triple. Tolerant of format drift and of a machine that reports only some of
480
+ * the three (an absent key simply drops out).
481
+ *
482
+ * @returns {{device?:string, ram?:string, cores?:number}}
483
+ */
484
+ export function parseSystemProfilerHardware(text) {
485
+ const s = String(text || "");
486
+ const out = {};
487
+ const grab = (label) => {
488
+ const m = s.match(new RegExp(`^\\s*${label}:\\s*(.+)$`, "im"));
489
+ return m ? m[1].trim() : "";
490
+ };
491
+ // "Mac mini" + "Apple M4 Pro" → "Mac mini M4 Pro", matching the design's
492
+ // "Mac Studio M3 Ultra" idiom. Either half alone is still an honest answer.
493
+ const model = grab("Model Name");
494
+ const chip = grab("Chip") || grab("Processor Name");
495
+ const chipShort = chip.replace(/^Apple\s+/i, "");
496
+ const device = [model, model && chipShort && chipShort !== model ? chipShort : chip && !model ? chip : ""]
497
+ .filter(Boolean)
498
+ .join(" ")
499
+ .trim();
500
+ if (device) out.device = device;
501
+
502
+ const mem = grab("Memory");
503
+ if (mem) out.ram = mem;
504
+
505
+ const cores = grab("Total Number of Cores");
506
+ const n = Number(String(cores).match(/^\d+/)?.[0]);
507
+ if (Number.isFinite(n) && n > 0) out.cores = n;
508
+
509
+ return out;
510
+ }
511
+
512
+ /**
513
+ * Resolve device / ram / cores, CACHED ON DISK.
514
+ *
515
+ * WHY CACHED. `system_profiler` costs ~200ms and forks a privileged-ish helper;
516
+ * the chip and the installed RAM do not change between two beats 30 seconds
517
+ * apart. Probing once per machine and reading a JSON file thereafter keeps the
518
+ * beat's cost where it belongs. The cache is KEYED on `os.cpus()[0].model` +
519
+ * total bytes, so swapping the machine (or an agent root moved to different
520
+ * hardware) invalidates it rather than reporting the old box forever.
521
+ *
522
+ * Fail-open in both directions: no agentRoot → probe every time; probe fails →
523
+ * fall back to what node:os alone can prove (`Apple M4 Pro`, `24 GB`, `12`),
524
+ * which is never nothing.
525
+ *
526
+ * @returns {Promise<{device?:string, ram?:string, cores?:number}>}
527
+ */
528
+ export async function collectInventory(o = {}) {
529
+ const osImpl = o.os || os;
530
+ let chip = "";
531
+ let totalBytes = 0;
532
+ let cores = 0;
533
+ try {
534
+ const cpus = osImpl.cpus();
535
+ cores = Array.isArray(cpus) ? cpus.length : 0;
536
+ chip = (Array.isArray(cpus) && cpus[0] && cpus[0].model) || "";
537
+ } catch { /* fall through */ }
538
+ try { totalBytes = Number(osImpl.totalmem()) || 0; } catch { /* fall through */ }
539
+
540
+ const key = `${chip}|${totalBytes}`;
541
+ const cachePath = o.agentRoot
542
+ ? join(resolve(o.agentRoot), "state", "telemetry", "inventory.json")
543
+ : null;
544
+
545
+ if (cachePath && o.inventoryCache !== false) {
546
+ const cached = safeReadJson(cachePath);
547
+ if (cached && cached.key === key && cached.inv && typeof cached.inv === "object") {
548
+ return cached.inv;
549
+ }
550
+ }
551
+
552
+ // What node:os alone can prove — the floor, never overwritten by a worse probe.
553
+ const inv = {};
554
+ if (chip) inv.device = chip;
555
+ const ram = ramLabel(totalBytes);
556
+ if (ram) inv.ram = ram;
557
+ if (cores > 0) inv.cores = cores;
558
+
559
+ const platform = o.platform || (osImpl.platform && osImpl.platform()) || process.platform;
560
+ if (platform === "darwin" || o.execFile) {
561
+ const execFileImpl = o.execFile || execFile;
562
+ const bin = o.systemProfilerBin || "/usr/sbin/system_profiler";
563
+ const timeoutMs = Number.isFinite(o.spTimeoutMs) ? o.spTimeoutMs : 3000;
564
+ if (o.execFile || existsSync(bin)) {
565
+ const out = await execFileSafe(execFileImpl, bin, ["SPHardwareDataType"], timeoutMs);
566
+ if (out) Object.assign(inv, parseSystemProfilerHardware(out));
567
+ }
568
+ }
569
+
570
+ if (cachePath && Object.keys(inv).length > 0) {
571
+ try {
572
+ mkdirSync(join(resolve(o.agentRoot), "state", "telemetry"), { recursive: true });
573
+ writeFileSync(cachePath, JSON.stringify({ key, inv }, null, 2));
574
+ } catch { /* cache is an optimisation; never fatal */ }
575
+ }
576
+ return inv;
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // spend24h — the agent's own rolling LLM spend
581
+ // ---------------------------------------------------------------------------
582
+
583
+ /**
584
+ * Sum the agent's BILLABLE spend over the trailing 24 hours from the cost
585
+ * ledger (`state/cost-tracking/<UTC date>.jsonl`).
586
+ *
587
+ * Reads today's and yesterday's files only — a 24h window can never span three
588
+ * UTC dates — and filters by each row's own `ts`, so the number is a true
589
+ * rolling window rather than "today so far".
590
+ *
591
+ * PRICING IS NOT RE-IMPLEMENTED HERE. `lib/cost/ledger-row.mjs#billableUsd` is
592
+ * the one definition of what a row cost and whether we actually know; it is
593
+ * imported rather than approximated, because the two ledger row shapes (v1
594
+ * tracker rows, v2 model-router rows) and the cache-tier pricing hole are
595
+ * exactly the things a second summing loop would get wrong again. A row whose
596
+ * cost is UNKNOWN (`usd === null`) contributes nothing and is not counted as
597
+ * zero — an unmeasured session is not a free one.
598
+ *
599
+ * Returns `undefined` (field drops out) when there is no ledger to read, which
600
+ * is honestly different from `0` ("the ledger says nothing was spent").
601
+ *
602
+ * @returns {number|undefined} USD, 2dp
603
+ */
604
+ export function collectSpend24h(o = {}) {
605
+ const agentRoot = o.agentRoot;
606
+ if (!agentRoot) return undefined;
607
+ const dir = o.costDir || join(resolve(agentRoot), "state", "cost-tracking");
608
+ let existsDir = false;
609
+ try { existsDir = existsSync(dir); } catch { existsDir = false; }
610
+ if (!existsDir) return undefined;
611
+
612
+ const nowMs = nowMsFrom(o.now);
613
+ const cutoff = nowMs - 24 * 60 * 60 * 1000;
614
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
615
+ const files = [...new Set([day(cutoff), day(nowMs)])];
616
+
617
+ let total = 0;
618
+ let sawAny = false;
619
+ for (const name of files) {
620
+ let body = "";
621
+ try {
622
+ const p = join(dir, `${name}.jsonl`);
623
+ if (!existsSync(p)) continue;
624
+ body = readFileSync(p, "utf8");
625
+ } catch { continue; }
626
+ sawAny = true;
627
+ for (const line of body.split("\n")) {
628
+ const trimmed = line.trim();
629
+ if (!trimmed) continue;
630
+ let row;
631
+ try { row = JSON.parse(trimmed); } catch { continue; }
632
+ const ts = Date.parse(row && row.ts);
633
+ if (!Number.isFinite(ts) || ts < cutoff || ts > nowMs) continue;
634
+ let billed;
635
+ try { billed = billableUsd(row); } catch { continue; }
636
+ // `usd === null` means UNKNOWN, not free. Skipping it under-reports on
637
+ // purpose rather than inventing a zero — the same call budget-guard makes.
638
+ if (billed && Number.isFinite(billed.usd)) total += billed.usd;
639
+ }
640
+ }
641
+ if (!sawAny) return undefined;
642
+ return Math.round(total * 100) / 100;
643
+ }
644
+
310
645
  // ---------------------------------------------------------------------------
311
646
  // claudeAuth detection
312
647
  // ---------------------------------------------------------------------------
@@ -499,9 +834,14 @@ export const _internals = {
499
834
  humanActivity,
500
835
  collectMachine,
501
836
  collectPowermetrics,
837
+ powermetricsSamplers,
502
838
  parsePowermetrics,
503
839
  collectDisk,
504
840
  parseDf,
841
+ collectInventory,
842
+ parseSystemProfilerHardware,
843
+ ramLabel,
844
+ collectSpend24h,
505
845
  detectClaudeAuth,
506
846
  scanForRelogin,
507
847
  countSubAgents,