@cohortapp/agent-sdk 2.7.0 → 2.8.1

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.
@@ -19,9 +19,11 @@
19
19
  * memUsedPct: number, // 1 - free/total
20
20
  * cpuPct?: number, // powermetrics if root, else loadavg/cores
21
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)
22
+ * tempC?: number, // real die °C — unprivileged, via IOHIDEventSystem
23
+ * tempSource?: "iohid"|"powermetrics"|"sudo-powermetrics",
24
+ * thermalState?: "nominal"|"fair"|"serious"|"critical", // coarse; ALWAYS available on darwin
25
+ * thermalSource?: "processinfo"|"sudo-powermetrics"|"pmset",
26
+ * tempDetail?: string, // NOTE ONLY — never the value (see collectThermal)
25
27
  * diskUsedPct?: number,
26
28
  * uptimeDays?: number,
27
29
  * spend24h?: number, // rolling 24h billable USD off the cost ledger
@@ -60,6 +62,7 @@ import os from "node:os";
60
62
  import { execFile } from "node:child_process";
61
63
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
62
64
  import { join, resolve } from "node:path";
65
+ import { fileURLToPath } from "node:url";
63
66
 
64
67
  import { list as listPresence } from "../collective/presence.mjs";
65
68
  import { billableUsd } from "../cost/ledger-row.mjs";
@@ -233,10 +236,52 @@ function humanActivity(intent, taskClass) {
233
236
  *
234
237
  * @returns {Promise<{
235
238
  * loadavg:number[], memUsedPct:number, cpuPct?:number, cpuSource?:string,
236
- * tempC?:number, tempSource?:string, tempDetail?:string, diskUsedPct?:number,
239
+ * tempC?:number, tempSource?:string, thermalState?:string, thermalSource?:string,
240
+ * tempDetail?:string, diskUsedPct?:number,
237
241
  * uptimeDays?:number, spend24h?:number, device?:string, ram?:string, cores?:number
238
242
  * }>}
239
243
  */
244
+ /**
245
+ * Percentage of memory genuinely unavailable.
246
+ *
247
+ * On macOS this is `100 - (memory_pressure's System-wide memory free
248
+ * percentage)`. That is the same source `scripts/watchdog/memory-watchdog.sh`
249
+ * thresholds on, so the number in the Fleet view and the number the watchdog
250
+ * acts on can no longer disagree — they used to disagree by ~66 points, which
251
+ * is how a healthy machine came to show a standing CRITICAL.
252
+ *
253
+ * Anywhere else (or if memory_pressure yields nothing) falls back to
254
+ * `1 - free/total`, which on Linux does mean what it says.
255
+ *
256
+ * @param {object} o collector options; `o.execFile` is the test seam
257
+ * @param {object} osImpl
258
+ * @returns {Promise<number>}
259
+ */
260
+ async function collectMemUsedPct(o, osImpl) {
261
+ const fallback = () => {
262
+ try {
263
+ const total = Number(osImpl.totalmem());
264
+ const free = Number(osImpl.freemem());
265
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(free)) {
266
+ return pct((1 - free / total) * 100);
267
+ }
268
+ } catch { /* fall through */ }
269
+ return 0;
270
+ };
271
+
272
+ const platform = o.platform || process.platform;
273
+ if (platform !== "darwin" && !o.execFile) return fallback();
274
+
275
+ const execFileImpl = o.execFile || execFile;
276
+ const out = await execFileSafe(execFileImpl, "/usr/bin/memory_pressure", [], 4000);
277
+ const m = /System-wide memory free percentage:\s*([\d.]+)%/.exec(out || "");
278
+ if (!m) return fallback();
279
+
280
+ const freePct = Number(m[1]);
281
+ if (!Number.isFinite(freePct) || freePct < 0 || freePct > 100) return fallback();
282
+ return pct(100 - freePct);
283
+ }
284
+
240
285
  export async function collectMachine(o = {}) {
241
286
  const osImpl = o.os || os;
242
287
  const machine = { loadavg: [0, 0, 0], memUsedPct: 0 };
@@ -249,14 +294,21 @@ export async function collectMachine(o = {}) {
249
294
  }
250
295
  } catch { /* keep [0,0,0] */ }
251
296
 
252
- // memUsedPct = 1 - free/total
253
- try {
254
- const total = Number(osImpl.totalmem());
255
- const free = Number(osImpl.freemem());
256
- if (Number.isFinite(total) && total > 0 && Number.isFinite(free)) {
257
- machine.memUsedPct = pct((1 - free / total) * 100);
258
- }
259
- } catch { /* keep 0 */ }
297
+ // memUsedPct — how much memory is genuinely UNAVAILABLE, not how much is
298
+ // merely occupied.
299
+ //
300
+ // This used to be `1 - os.freemem()/os.totalmem()`, which is not a pressure
301
+ // measure on macOS. os.freemem() counts only entirely-free pages, and macOS
302
+ // deliberately keeps almost all RAM occupied with file cache, purgeable and
303
+ // compressed pages that are reclaimable on demand. Measured on a healthy
304
+ // idle Mac mini M4 Pro at the same instant: os.freemem() → 99.2% "used",
305
+ // `memory_pressure` → 68% free. The old number sat permanently near 100 and
306
+ // fired CRITICAL alerts on machines with nothing wrong with them, while the
307
+ // memory watchdog — which reads memory_pressure — correctly stayed quiet.
308
+ //
309
+ // Prefer macOS's own answer; fall back to the free/total ratio only where
310
+ // memory_pressure does not exist (Linux, where the figure means what it says).
311
+ machine.memUsedPct = await collectMemUsedPct(o, osImpl);
260
312
 
261
313
  // cpuPct — coarse instantaneous estimate from loadavg/cpus when powermetrics
262
314
  // is unavailable. powermetrics (if it yields) overrides below.
@@ -279,15 +331,37 @@ export async function collectMachine(o = {}) {
279
331
  if (Number.isFinite(secs) && secs >= 0) machine.uptimeDays = round1(secs / 86400);
280
332
  } catch { /* no uptimeDays */ }
281
333
 
282
- // Best-effort temp + cpu via powermetrics (guarded, injected, never blocks).
283
- // ALWAYS records why it has no answer — see collectPowermetrics.
334
+ // ── THERMAL ────────────────────────────────────────────────────────────────
335
+ // Real °C where the hardware gives it up unprivileged, a coarse state word
336
+ // everywhere else, and `tempDetail` as an EXPLANATORY NOTE that rides
337
+ // ALONGSIDE the reading rather than standing in for it. See collectThermal
338
+ // for why this no longer runs through powermetrics.
339
+ // `powermetrics: false` has always been the caller's "fork no probes" switch
340
+ // (it is how every hermetic test disables the exec seam), so it still governs
341
+ // thermal unless a caller opts in or out of thermal explicitly.
342
+ const thermalEnabled = o.thermal !== undefined ? o.thermal !== false : o.powermetrics !== false;
343
+ if (thermalEnabled) {
344
+ const th = await collectThermal(o);
345
+ if (Number.isFinite(th.tempC)) {
346
+ machine.tempC = round1(th.tempC);
347
+ if (th.tempSource) machine.tempSource = th.tempSource;
348
+ }
349
+ if (th.thermalState) {
350
+ machine.thermalState = th.thermalState;
351
+ if (th.thermalSource) machine.thermalSource = th.thermalSource;
352
+ }
353
+ if (th.detail) machine.tempDetail = th.detail;
354
+ }
355
+
356
+ // powermetrics is now asked for CPU utilisation only — plus `tempC` on Intel,
357
+ // where the `smc` sampler genuinely carries a die temperature and the IOHID
358
+ // path above does not apply. On Apple Silicon it has no temperature to give
359
+ // at ANY privilege level, so it never gets to answer the thermal question.
284
360
  if (o.powermetrics !== false) {
285
361
  const pm = await collectPowermetrics(o);
286
- if (Number.isFinite(pm.tempC)) {
362
+ if (machine.tempC === undefined && Number.isFinite(pm.tempC)) {
287
363
  machine.tempC = round1(pm.tempC);
288
364
  machine.tempSource = pm.source || "powermetrics";
289
- } else if (pm.detail) {
290
- machine.tempDetail = pm.detail;
291
365
  }
292
366
  if (Number.isFinite(pm.cpuPct)) {
293
367
  machine.cpuPct = pct(pm.cpuPct);
@@ -320,6 +394,212 @@ export async function collectMachine(o = {}) {
320
394
  return machine;
321
395
  }
322
396
 
397
+ // ---------------------------------------------------------------------------
398
+ // Thermal — real °C without root, and a coarse state word that never fails
399
+ // ---------------------------------------------------------------------------
400
+
401
+ /** The four thermal pressure levels macOS reports, coldest → hottest. */
402
+ export const THERMAL_STATES = Object.freeze(["nominal", "fair", "serious", "critical"]);
403
+
404
+ /** Normalise any casing/spelling of a pressure level to our enum, or undefined. */
405
+ function normalizeThermalState(raw) {
406
+ const v = String(raw || "").trim().toLowerCase();
407
+ return THERMAL_STATES.includes(v) ? v : undefined;
408
+ }
409
+
410
+ /**
411
+ * Pure parser for `thermal-probe.swift` stdout → { thermalState?, tempC?, sensors? }.
412
+ * Format is `key=value` lines; unknown keys are ignored so the probe can grow.
413
+ */
414
+ export function parseThermalProbe(text) {
415
+ const out = {};
416
+ for (const line of String(text || "").split("\n")) {
417
+ const m = line.match(/^\s*(\w+)\s*=\s*(.+?)\s*$/);
418
+ if (!m) continue;
419
+ const [, k, v] = m;
420
+ if (k === "thermalState") {
421
+ const st = normalizeThermalState(v);
422
+ if (st) out.thermalState = st;
423
+ } else if (k === "tempC") {
424
+ const n = Number(v);
425
+ // Band-check AGAIN on this side. The probe filters, but a collector that
426
+ // trusts a subprocess's arithmetic is how "-9201.14 °C" reaches a dashboard.
427
+ if (Number.isFinite(n) && n > 0 && n < 150) out.tempC = n;
428
+ } else if (k === "tempSensors") {
429
+ const n = Number(v);
430
+ if (Number.isFinite(n)) out.sensors = n;
431
+ }
432
+ }
433
+ return out;
434
+ }
435
+
436
+ /**
437
+ * Pure parser for the `**** Thermal pressure ****` block that `powermetrics
438
+ * --samplers thermal` prints — the ONE thermal fact that binary has on Apple
439
+ * Silicon. Root-only, so this is a fallback, not the primary path.
440
+ */
441
+ export function parseThermalPressure(text) {
442
+ const m = String(text || "").match(/Current pressure level:\s*(\w+)/i);
443
+ return m ? normalizeThermalState(m[1]) : undefined;
444
+ }
445
+
446
+ /**
447
+ * Pure parser for `pmset -g therm`. Unprivileged and always present, but it
448
+ * reports a HISTORY of recorded warnings, not a live reading: on a healthy
449
+ * machine every line is "No … has been recorded". That absence is a weak signal
450
+ * and this returns `undefined` for it rather than inventing "nominal" — see
451
+ * collectThermal, which is where the inference is made explicit and labelled.
452
+ */
453
+ export function parsePmsetTherm(text) {
454
+ const s = String(text || "");
455
+ const m = s.match(/CPU_Scheduler_Limit\s*=\s*(\d+)/i) || s.match(/CPU_Speed_Limit\s*=\s*(\d+)/i);
456
+ if (m) {
457
+ const limit = Number(m[1]);
458
+ // 100 = no throttling. Anything below it means the SMC is actively pulling
459
+ // the CPU back, which is a REAL thermal event, not a recorded memory of one.
460
+ if (Number.isFinite(limit)) {
461
+ if (limit >= 100) return "nominal";
462
+ if (limit >= 75) return "fair";
463
+ if (limit >= 50) return "serious";
464
+ return "critical";
465
+ }
466
+ }
467
+ if (/No thermal warning level has been recorded/i.test(s)) return "no-events";
468
+ return undefined;
469
+ }
470
+
471
+ /**
472
+ * Collect the machine's thermal signal — REAL °C when the hardware will give it
473
+ * up unprivileged, a coarse state word when it will not.
474
+ *
475
+ * ── THE ADVICE THIS REPLACES WAS FUTILE ─────────────────────────────────────
476
+ * This used to emit, on every Apple-Silicon seat: "powermetrics requires root
477
+ * (agent runs unprivileged); allowlist it NOPASSWD in sudoers and set
478
+ * TELEMETRY_SUDO_POWERMETRICS=1 to enable". Both halves of that were wrong.
479
+ *
480
+ * - `powermetrics` produces NO die temperature on Apple Silicon at any
481
+ * privilege level. Verified on Mac16,11 / macOS 24D70: `sudo powermetrics
482
+ * -A` (every sampler, 242 lines) contains no `temperature` line at all. An
483
+ * operator who did exactly what the message asked would STILL see a dash.
484
+ * - °C does not need root. It needs IOHIDEventSystem, which any uid can read.
485
+ *
486
+ * ── THE LADDER ──────────────────────────────────────────────────────────────
487
+ * 1. `swift thermal-probe.swift` — unprivileged. Yields BOTH a real `tempC`
488
+ * (private IOHID sensors) and `thermalState` (public ProcessInfo). This is
489
+ * the answer on any Mac with the Xcode command-line tools present.
490
+ * 2. `sudo -n powermetrics --samplers thermal` — ONLY if the operator opted
491
+ * in. No °C, but a true `Current pressure level:`. State-only.
492
+ * 3. `pmset -g therm` — unprivileged, always present, but only reports a
493
+ * live level while the SMC is actually throttling.
494
+ *
495
+ * Every rung records WHY it has no better answer in `detail`. Crucially
496
+ * `detail` is now a NOTE that ships ALONGSIDE a reading, never instead of one:
497
+ * the old code made them mutually exclusive, so hq had nothing to print in the
498
+ * value slot and rendered the 118-character sudoers sentence there instead.
499
+ *
500
+ * @returns {Promise<{ tempC?:number, tempSource?:string, thermalState?:string,
501
+ * thermalSource?:string, detail?:string }>}
502
+ */
503
+ export async function collectThermal(o = {}) {
504
+ const platform = o.platform || (o.os && o.os.platform && o.os.platform()) || process.platform;
505
+ const execFileImpl = o.execFile || execFile;
506
+ if (platform !== "darwin" && !o.execFile) {
507
+ return { detail: `no thermal probe on platform ${platform}` };
508
+ }
509
+ const timeoutMs = Number.isFinite(o.thermalTimeoutMs) ? o.thermalTimeoutMs : 4000;
510
+
511
+ // ── 1. The unprivileged probe: real °C + public thermal state ─────────────
512
+ const swiftBin = o.swiftBin || "/usr/bin/swift";
513
+ const probePath = o.thermalProbePath || defaultThermalProbePath();
514
+ const haveSwift = Boolean(o.execFile) || (existsSync(swiftBin) && existsSync(probePath));
515
+ if (haveSwift) {
516
+ const out = await execFileSafe(execFileImpl, swiftBin, [probePath], timeoutMs);
517
+ const parsed = parseThermalProbe(out);
518
+ if (Number.isFinite(parsed.tempC)) {
519
+ return {
520
+ tempC: parsed.tempC,
521
+ tempSource: "iohid",
522
+ thermalState: parsed.thermalState,
523
+ thermalSource: parsed.thermalState ? "processinfo" : undefined,
524
+ detail: parsed.sensors
525
+ ? `die temperature: hottest of ${parsed.sensors} on-SoC sensors (IOHIDEventSystem, unprivileged)`
526
+ : "die temperature via IOHIDEventSystem (unprivileged)",
527
+ };
528
+ }
529
+ if (parsed.thermalState) {
530
+ return {
531
+ thermalState: parsed.thermalState,
532
+ thermalSource: "processinfo",
533
+ detail:
534
+ "no numeric °C: the IOHID temperature sensors returned nothing on this hardware; " +
535
+ "showing the OS thermal pressure level instead",
536
+ };
537
+ }
538
+ }
539
+
540
+ // ── 2. Opt-in sudo powermetrics — pressure level only, never °C ───────────
541
+ const sudoEnabled = o.sudoPowermetrics !== undefined
542
+ ? Boolean(o.sudoPowermetrics)
543
+ : String(process.env.TELEMETRY_SUDO_POWERMETRICS || "") === "1";
544
+ if (sudoEnabled) {
545
+ const sudoBin = o.sudoBin || "/usr/bin/sudo";
546
+ const pmBin = o.powermetricsBin || "/usr/bin/powermetrics";
547
+ const arch = o.arch || (o.os && o.os.arch && o.os.arch()) || process.arch;
548
+ const out = await execFileSafe(
549
+ execFileImpl, sudoBin,
550
+ ["-n", pmBin, "-n", "1", "-i", "200", "--samplers", powermetricsSamplers(arch)],
551
+ timeoutMs
552
+ );
553
+ const level = parseThermalPressure(out);
554
+ if (level) {
555
+ return {
556
+ thermalState: level,
557
+ thermalSource: "sudo-powermetrics",
558
+ detail:
559
+ "no numeric °C: powermetrics reports thermal PRESSURE only — Apple Silicon " +
560
+ "exposes no die temperature to it even as root",
561
+ };
562
+ }
563
+ }
564
+
565
+ // ── 3. pmset — unprivileged, but only speaks up while actually throttling ──
566
+ const pmsetBin = o.pmsetBin || "/usr/bin/pmset";
567
+ const out = await execFileSafe(execFileImpl, pmsetBin, ["-g", "therm"], timeoutMs);
568
+ const therm = parsePmsetTherm(out);
569
+ if (therm && therm !== "no-events") {
570
+ return {
571
+ thermalState: therm,
572
+ thermalSource: "pmset",
573
+ detail: "derived from the SMC CPU speed limit reported by `pmset -g therm`",
574
+ };
575
+ }
576
+ if (therm === "no-events") {
577
+ // NOT reported as `nominal`. "macOS has never recorded a thermal warning"
578
+ // is a statement about history, and dressing it up as a live reading is
579
+ // exactly the kind of invented number this rewrite exists to remove.
580
+ return {
581
+ detail:
582
+ "no thermal reading: install the Xcode command-line tools (`xcode-select --install`) " +
583
+ "to enable the unprivileged °C probe. `pmset` reports no thermal events on record.",
584
+ };
585
+ }
586
+
587
+ return {
588
+ detail:
589
+ "no thermal reading available: the unprivileged °C probe needs `/usr/bin/swift` " +
590
+ "(Xcode command-line tools) and no fallback thermal source responded",
591
+ };
592
+ }
593
+
594
+ /** Absolute path to the shipped swift probe, resolved next to this module. */
595
+ function defaultThermalProbePath() {
596
+ try {
597
+ return fileURLToPath(new URL("./thermal-probe.swift", import.meta.url));
598
+ } catch {
599
+ return "";
600
+ }
601
+ }
602
+
323
603
  /**
324
604
  * The `powermetrics` sampler that carries die temperature, BY CPU ARCHITECTURE.
325
605
  *
@@ -352,16 +632,20 @@ export function powermetricsSamplers(arch) {
352
632
  * exit now carries a `detail` sentence that the snapshot ships and the drawer
353
633
  * prints.
354
634
  *
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.
635
+ * ── THIS IS NO LONGER THE TEMPERATURE PATH ───────────────────────────────────
636
+ * It used to be, and the comment here used to assert that no unprivileged
637
+ * numeric temperature source existed on Apple Silicon. That was wrong twice
638
+ * over: powermetrics has no die temperature to give on Apple Silicon EVEN AS
639
+ * ROOT (`sudo powermetrics -A` contains no `temperature` line), and °C is
640
+ * readable by any uid through IOHIDEventSystem. `collectThermal` owns that now;
641
+ * this function is kept for CPU utilisation, for the Intel `smc` sampler that
642
+ * genuinely does carry a temperature, and for the thermal-pressure line.
643
+ *
644
+ * `powermetrics` still requires superuser, and the daemon still runs from a user
645
+ * LaunchAgent, so the OPT-IN sudo path remains: set
646
+ * `TELEMETRY_SUDO_POWERMETRICS=1` once the operator has allowlisted the binary
647
+ * NOPASSWD in sudoers. `sudo -n` never prompts — with no allowlist it fails
648
+ * instantly rather than hanging a beat on a password prompt no one will see.
365
649
  *
366
650
  * @returns {Promise<{ tempC?:number, cpuPct?:number, source?:string, detail?:string }>}
367
651
  */
@@ -407,14 +691,12 @@ export async function collectPowermetrics(o = {}) {
407
691
  }
408
692
  }
409
693
  return {
410
- detail: `${bin} yielded nothing under 'sudo -n' — check the NOPASSWD sudoers allowlist`,
694
+ detail: `${bin} ran under 'sudo -n' but reported no CPU utilisation this platform can parse`,
411
695
  };
412
696
  }
413
697
 
414
698
  return {
415
- detail:
416
- `${bin} requires root (agent runs unprivileged); ` +
417
- "allowlist it NOPASSWD in sudoers and set TELEMETRY_SUDO_POWERMETRICS=1 to enable",
699
+ detail: `${bin} requires root (agent runs unprivileged); CPU% falls back to the loadavg estimate`,
418
700
  };
419
701
  }
420
702
 
@@ -425,10 +707,33 @@ export function parsePowermetrics(text) {
425
707
  // Temperature: "CPU die temperature: 54.21 C" or "GPU die temperature: ..."
426
708
  const temp = s.match(/CPU die temperature:\s*([\d.]+)\s*C/i) || s.match(/die temperature:\s*([\d.]+)\s*C/i);
427
709
  if (temp) { const v = Number(temp[1]); if (Number.isFinite(v)) out.tempC = v; }
428
- // Active CPU%: "CPU Average frequency as fraction of nominal: ..." isn't util;
429
- // prefer an explicit "... active residency" total or a "CPU usage" line.
430
- const idle = s.match(/CPU\s+(?:Average\s+)?idle\s+residency:\s*([\d.]+)\s*%/i);
431
- if (idle) { const v = Number(idle[1]); if (Number.isFinite(v)) out.cpuPct = Math.max(0, 100 - v); }
710
+ // Thermal pressure — on Apple Silicon this is the ONLY thermal fact in the
711
+ // whole of powermetrics' output, at any privilege level.
712
+ const pressure = parseThermalPressure(s);
713
+ if (pressure) out.pressure = pressure;
714
+ // Active CPU% = 100 - idle residency.
715
+ //
716
+ // ── THE arm64 SHAPE, WHICH NEVER MATCHED ──────────────────────────────────
717
+ // This only ever looked for a bare `CPU idle residency:` line. Apple Silicon
718
+ // does not print one: it reports PER-CLUSTER (`E-Cluster idle residency:
719
+ // 15.69%`, `P-Cluster …`) and per-core (`CPU 0 idle residency: 46.81%`)
720
+ // lines and no total. `grep -cE "^CPU\s+(Average\s+)?idle residency"` on real
721
+ // M4 Pro output returns 0, so with sudo enabled this fell through to a detail
722
+ // string blaming the operator's sudoers file when sudo had worked perfectly.
723
+ // Fall back to averaging the per-CORE lines, which is the true whole-CPU idle.
724
+ const idle = s.match(/^CPU\s+(?:Average\s+)?idle\s+residency:\s*([\d.]+)\s*%/im);
725
+ if (idle) {
726
+ const v = Number(idle[1]);
727
+ if (Number.isFinite(v)) out.cpuPct = Math.max(0, 100 - v);
728
+ } else {
729
+ const perCore = [...s.matchAll(/^CPU\s+\d+\s+idle\s+residency:\s*([\d.]+)\s*%/gim)]
730
+ .map((m) => Number(m[1]))
731
+ .filter((n) => Number.isFinite(n));
732
+ if (perCore.length > 0) {
733
+ const avgIdle = perCore.reduce((a, b) => a + b, 0) / perCore.length;
734
+ out.cpuPct = Math.max(0, 100 - avgIdle);
735
+ }
736
+ }
432
737
  return out;
433
738
  }
434
739
 
@@ -25,6 +25,9 @@ import {
25
25
  parseDf,
26
26
  collectMachine,
27
27
  collectPowermetrics,
28
+ collectThermal,
29
+ parseThermalProbe,
30
+ parseThermalPressure,
28
31
  powermetricsSamplers,
29
32
  parseSystemProfilerHardware,
30
33
  ramLabel,
@@ -170,7 +173,11 @@ test("detectClaudeAuth: scans recent log files under agentRoot", () => {
170
173
  });
171
174
 
172
175
  test("collectMachine: derives memUsedPct + loadavg without powermetrics/df", async () => {
173
- const m = await collectMachine({ os: fakeOs({ totalmem: 10e9, freemem: 2e9, loadavg: [4, 2, 1], cpus: 4 }), powermetrics: false, disk: false });
176
+ // memUsedPct now prefers macOS's memory_pressure, so "no external tools" has
177
+ // to include that one too — otherwise this test reads the REAL machine and
178
+ // asserts against the fake os, which is how it started failing.
179
+ const noTools = (cmd, args, opts, cb) => { cb(new Error("ENOENT")); return { on() {} }; };
180
+ const m = await collectMachine({ os: fakeOs({ totalmem: 10e9, freemem: 2e9, loadavg: [4, 2, 1], cpus: 4 }), execFile: noTools, powermetrics: false, disk: false });
174
181
  assert.equal(m.memUsedPct, 80);
175
182
  assert.deepEqual(m.loadavg, [4, 2, 1]);
176
183
  assert.equal(m.cpuPct, 100); // load 4 / 4 cpus = 100%
@@ -319,21 +326,25 @@ test("collectPowermetrics passes the arch-correct sampler to the binary", async
319
326
  assert.ok(!samplerArg.includes("smc"), "must not send the Intel-only sampler to arm64");
320
327
  });
321
328
 
322
- test("collectPowermetrics ALWAYS says why it has no temperature (fail-open, never silent)", async () => {
329
+ test("collectPowermetrics ALWAYS says why it has no answer (fail-open, never silent)", async () => {
323
330
  // The unprivileged reality on every seat: the probe yields nothing.
324
331
  const pm = await collectPowermetrics({
325
332
  execFile: fakeExecFile({}), platform: "darwin", arch: "arm64", sudoPowermetrics: false,
326
333
  });
327
334
  assert.equal(pm.tempC, undefined);
328
335
  assert.match(pm.detail, /requires root/);
329
- assert.match(pm.detail, /TELEMETRY_SUDO_POWERMETRICS/);
336
+ // It no longer tells the operator to edit sudoers to get a temperature. That
337
+ // advice was futile: powermetrics has no die temperature on Apple Silicon at
338
+ // ANY privilege level, so following it to the letter still yielded nothing.
339
+ assert.ok(!/TELEMETRY_SUDO_POWERMETRICS/.test(pm.detail));
340
+ assert.ok(!/sudoers/.test(pm.detail));
330
341
 
331
342
  // A non-darwin host is a DIFFERENT absence and says so.
332
343
  const linux = await collectPowermetrics({ platform: "linux" });
333
344
  assert.match(linux.detail, /platform linux/);
334
345
  });
335
346
 
336
- test("collectMachine surfaces the temperature failure as machine.tempDetail", async () => {
347
+ test("collectMachine surfaces a thermal failure as a NOTE, never as the value", async () => {
337
348
  const m = await collectMachine({
338
349
  os: fakeOs({}),
339
350
  execFile: fakeExecFile({}),
@@ -346,7 +357,11 @@ test("collectMachine surfaces the temperature failure as machine.tempDetail", as
346
357
  });
347
358
  assert.equal(m.tempC, undefined);
348
359
  assert.equal(m.tempSource, undefined);
349
- assert.match(m.tempDetail, /requires root/);
360
+ assert.equal(m.thermalState, undefined);
361
+ assert.equal(typeof m.tempDetail, "string");
362
+ // The note must not blame the operator's sudoers file for a limitation of the
363
+ // hardware — that is the message this whole rewrite exists to delete.
364
+ assert.ok(!/sudoers/.test(m.tempDetail));
350
365
  // And the cpuPct it DOES have is labelled as the estimate it is.
351
366
  assert.equal(m.cpuSource, "loadavg");
352
367
  });
@@ -380,6 +395,191 @@ test("collectPowermetrics tries the opt-in sudo path only when enabled", async (
380
395
  assert.ok(sudoCall >= 0);
381
396
  });
382
397
 
398
+ // ---------------------------------------------------------------------------
399
+ // Thermal — real °C without root, and a coarse state word that never fails.
400
+ //
401
+ // Every test here stubs the exec seam. NOTHING in this file forks `swift`,
402
+ // `powermetrics` or `pmset`; the probe's real output is pinned as fixture text.
403
+ // ---------------------------------------------------------------------------
404
+
405
+ /** Verbatim stdout of `swift lib/telemetry/thermal-probe.swift` on a Mac16,11. */
406
+ const PROBE_OK = "thermalState=nominal\ntempC=60.76\ntempSensors=42\n";
407
+
408
+ /** Verbatim `sudo powermetrics --samplers thermal` — note: NO temperature line. */
409
+ const PM_THERMAL = [
410
+ "Machine model: Mac16,11",
411
+ "",
412
+ "**** Thermal pressure ****",
413
+ "",
414
+ "Current pressure level: Nominal",
415
+ "",
416
+ ].join("\n");
417
+
418
+ /** Route stdout by binary, so a test can say which rung of the ladder answers. */
419
+ const SWIFT = "/usr/bin/swift";
420
+ const SUDO = "/usr/bin/sudo";
421
+ const PMSET = "/usr/bin/pmset";
422
+
423
+ test("collectThermal reads a REAL °C unprivileged, via the IOHID probe", async () => {
424
+ // The headline fix. powermetrics cannot produce a die temperature on Apple
425
+ // Silicon even as root; IOHIDEventSystem hands it over to any uid.
426
+ const seen = [];
427
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
428
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
429
+ seen.push(cmd);
430
+ queueMicrotask(() => cb(null, cmd === SWIFT ? PROBE_OK : "", ""));
431
+ return { on() {} };
432
+ };
433
+ const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
434
+ assert.equal(th.tempC, 60.76);
435
+ assert.equal(th.tempSource, "iohid");
436
+ assert.equal(th.thermalState, "nominal");
437
+ assert.equal(th.thermalSource, "processinfo");
438
+ // No root, no sudo, on the happy path.
439
+ assert.ok(!seen.includes(SUDO), "the °C path must never need sudo");
440
+ // The detail is an EXPLANATION riding alongside a real reading, not a
441
+ // substitute for one — the drawer prints the number and demotes this.
442
+ assert.equal(typeof th.detail, "string");
443
+ assert.match(th.detail, /IOHIDEventSystem/);
444
+ });
445
+
446
+ test("collectThermal falls back to the coarse STATE when there is no °C", async () => {
447
+ // The probe ran but the sensors gave nothing: state alone is still a useful,
448
+ // honest answer, and the field semantics say so rather than faking a number.
449
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
450
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
451
+ queueMicrotask(() => cb(null, cmd === SWIFT ? "thermalState=serious\n" : "", ""));
452
+ return { on() {} };
453
+ };
454
+ const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
455
+ assert.equal(th.tempC, undefined);
456
+ assert.equal(th.thermalState, "serious");
457
+ assert.equal(th.thermalSource, "processinfo");
458
+ assert.match(th.detail, /no numeric °C/);
459
+ });
460
+
461
+ test("collectThermal uses sudo powermetrics for PRESSURE only, and only when opted in", async () => {
462
+ const seen = [];
463
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
464
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
465
+ seen.push(cmd);
466
+ // swift is absent on this host; sudo powermetrics answers.
467
+ queueMicrotask(() => cb(null, cmd === SUDO ? PM_THERMAL : "", ""));
468
+ return { on() {} };
469
+ };
470
+ const off = await collectThermal({
471
+ execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: false,
472
+ });
473
+ assert.ok(!seen.includes(SUDO), "must not shell out to sudo unless asked");
474
+ assert.equal(off.thermalState, undefined);
475
+
476
+ seen.length = 0;
477
+ const on = await collectThermal({
478
+ execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: true,
479
+ });
480
+ assert.ok(seen.includes(SUDO));
481
+ assert.equal(on.thermalState, "nominal");
482
+ assert.equal(on.thermalSource, "sudo-powermetrics");
483
+ // Root buys a pressure LEVEL and nothing more. If this ever starts asserting a
484
+ // tempC from powermetrics on arm64, the fixture is lying.
485
+ assert.equal(on.tempC, undefined);
486
+ assert.match(on.detail, /pressure/i);
487
+ });
488
+
489
+ test("collectThermal will NOT dress 'no thermal events on record' up as a live reading", async () => {
490
+ // `pmset -g therm` on a healthy Mac reports history, not temperature. Calling
491
+ // that "nominal" would be inventing a measurement nobody took.
492
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
493
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
494
+ const out = cmd === PMSET ? "Note: No thermal warning level has been recorded\n" : "";
495
+ queueMicrotask(() => cb(null, out, ""));
496
+ return { on() {} };
497
+ };
498
+ const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
499
+ assert.equal(th.thermalState, undefined);
500
+ assert.equal(th.tempC, undefined);
501
+ assert.match(th.detail, /xcode-select --install/);
502
+
503
+ // But a REAL throttle event — a speed limit below 100 — is a live signal.
504
+ const throttling = (cmd, args, optsOrCb, maybeCb) => {
505
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
506
+ const out = cmd === PMSET ? "CPU_Speed_Limit \t= 60\n" : "";
507
+ queueMicrotask(() => cb(null, out, ""));
508
+ return { on() {} };
509
+ };
510
+ const hot = await collectThermal({ execFile: throttling, platform: "darwin", arch: "arm64" });
511
+ assert.equal(hot.thermalState, "serious");
512
+ assert.equal(hot.thermalSource, "pmset");
513
+ });
514
+
515
+ test("parseThermalProbe rejects the sensor that lies (-9201.14 °C)", () => {
516
+ // `PMU tdev2` returns garbage on real hardware. The probe filters it, and the
517
+ // collector band-checks AGAIN — trusting a subprocess's arithmetic is how an
518
+ // absurd number reaches a dashboard.
519
+ assert.deepEqual(parseThermalProbe("thermalState=nominal\ntempC=-9201.14\n"), {
520
+ thermalState: "nominal",
521
+ });
522
+ assert.equal(parseThermalProbe("tempC=900\n").tempC, undefined);
523
+ assert.equal(parseThermalProbe("tempC=61.2\n").tempC, 61.2);
524
+ // Unknown keys are ignored so the probe can grow a field without breaking us.
525
+ assert.equal(parseThermalProbe("tempC=61.2\nfuture=x\n").tempC, 61.2);
526
+ assert.deepEqual(parseThermalProbe(""), {});
527
+ assert.deepEqual(parseThermalProbe(null), {});
528
+ // Only the four real levels are accepted.
529
+ assert.equal(parseThermalProbe("thermalState=toasty\n").thermalState, undefined);
530
+ });
531
+
532
+ test("parseThermalPressure reads the only thermal fact powermetrics has on arm64", () => {
533
+ assert.equal(parseThermalPressure(PM_THERMAL), "nominal");
534
+ assert.equal(parseThermalPressure("Current pressure level: Critical"), "critical");
535
+ assert.equal(parseThermalPressure("no such block"), undefined);
536
+ assert.equal(parseThermalPressure(""), undefined);
537
+ });
538
+
539
+ test("parsePowermetrics computes CPU% from the PER-CLUSTER arm64 shape", () => {
540
+ // The old regex wanted a bare `CPU idle residency:` line. Apple Silicon never
541
+ // prints one — only per-core and per-cluster lines — so on every seat in the
542
+ // fleet this silently fell through and then blamed the operator's sudoers.
543
+ const arm = [
544
+ "**** Processor usage ****",
545
+ "E-Cluster idle residency: 78.34%",
546
+ "CPU 0 idle residency: 90.00%",
547
+ "CPU 1 idle residency: 80.00%",
548
+ "CPU 2 idle residency: 70.00%",
549
+ "CPU 3 idle residency: 60.00%",
550
+ ].join("\n");
551
+ const parsed = parsePowermetrics(arm);
552
+ assert.equal(parsed.cpuPct, 25); // 100 - mean(90,80,70,60)
553
+ // The Intel/total shape still wins outright where it exists.
554
+ assert.equal(parsePowermetrics("CPU Average idle residency: 78.0 %").cpuPct, 22);
555
+ });
556
+
557
+ test("collectMachine ships tempC + thermalState + the note TOGETHER", async () => {
558
+ // The old collector made the reading and the explanation MUTUALLY EXCLUSIVE
559
+ // (`else if`), which left hq with nothing to put in the value slot — so it
560
+ // printed a 118-character sudoers sentence in a column of "62°C" and "41%".
561
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
562
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
563
+ queueMicrotask(() => cb(null, cmd === SWIFT ? PROBE_OK : "", ""));
564
+ return { on() {} };
565
+ };
566
+ const m = await collectMachine({
567
+ os: fakeOs({}),
568
+ execFile: spy,
569
+ platform: "darwin",
570
+ arch: "arm64",
571
+ sudoPowermetrics: false,
572
+ disk: false,
573
+ inventory: false,
574
+ spend: false,
575
+ });
576
+ assert.equal(m.tempC, 60.8); // round1
577
+ assert.equal(m.tempSource, "iohid");
578
+ assert.equal(m.thermalState, "nominal");
579
+ assert.equal(m.thermalSource, "processinfo");
580
+ assert.equal(typeof m.tempDetail, "string");
581
+ });
582
+
383
583
  // ---------------------------------------------------------------------------
384
584
  // spend24h
385
585
  // ---------------------------------------------------------------------------
@@ -466,3 +666,75 @@ test("collectStatus ships the FULL machine vocabulary the Fleet row reads", asyn
466
666
  rmSync(root, { recursive: true, force: true });
467
667
  }
468
668
  });
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // memUsedPct — pressure, not occupancy
672
+ //
673
+ // This was `1 - os.freemem()/os.totalmem()`, which is not a pressure measure on
674
+ // macOS: os.freemem() counts only entirely-free pages, and macOS keeps nearly
675
+ // all RAM occupied with file cache, purgeable and compressed pages that are
676
+ // reclaimable on demand. Measured on a healthy idle Mac mini M4 Pro at one
677
+ // instant: os.freemem() → 99.2% "used", memory_pressure → 68% free. The seat
678
+ // showed a standing CRITICAL while the watchdog, reading memory_pressure,
679
+ // correctly did nothing.
680
+ // ---------------------------------------------------------------------------
681
+
682
+ test("memUsedPct comes from memory_pressure, not from free/total", async () => {
683
+ const seen = [];
684
+ const execFile = (cmd, args, opts, cb) => {
685
+ seen.push(cmd);
686
+ if (cmd === "/usr/bin/memory_pressure") {
687
+ cb(null, "System-wide memory free percentage: 68%\n");
688
+ } else {
689
+ cb(new Error("not stubbed"));
690
+ }
691
+ return { on() {} };
692
+ };
693
+ // An os whose free/total ratio would say 99% used — the old, wrong answer.
694
+ const osImpl = {
695
+ loadavg: () => [1, 1, 1],
696
+ totalmem: () => 24 * 1024 ** 3,
697
+ freemem: () => 0.2 * 1024 ** 3,
698
+ cpus: () => [],
699
+ uptime: () => 3600,
700
+ };
701
+
702
+ const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
703
+
704
+ assert.equal(m.memUsedPct, 32, "100 - 68 free, i.e. what the watchdog thresholds on");
705
+ assert.notEqual(m.memUsedPct, 99, "the free/total ratio must not be the answer on darwin");
706
+ assert.ok(seen.includes("/usr/bin/memory_pressure"), "memory_pressure is consulted");
707
+ });
708
+
709
+ test("memUsedPct falls back to free/total when memory_pressure yields nothing", async () => {
710
+ // Linux, or a macOS where the binary is missing/erroring. There the ratio
711
+ // does mean what it says, so a fallback is right — silence is not.
712
+ const execFile = (cmd, args, opts, cb) => { cb(new Error("ENOENT")); return { on() {} }; };
713
+ const osImpl = {
714
+ loadavg: () => [0, 0, 0],
715
+ totalmem: () => 1000,
716
+ freemem: () => 250,
717
+ cpus: () => [],
718
+ uptime: () => 1,
719
+ };
720
+
721
+ const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
722
+ assert.equal(m.memUsedPct, 75, "1 - 250/1000");
723
+ });
724
+
725
+ test("memUsedPct ignores an out-of-range memory_pressure reading", async () => {
726
+ const execFile = (cmd, args, opts, cb) => {
727
+ cb(null, "System-wide memory free percentage: 8000%\n");
728
+ return { on() {} };
729
+ };
730
+ const osImpl = {
731
+ loadavg: () => [0, 0, 0],
732
+ totalmem: () => 1000,
733
+ freemem: () => 400,
734
+ cpus: () => [],
735
+ uptime: () => 1,
736
+ };
737
+
738
+ const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
739
+ assert.equal(m.memUsedPct, 60, "a nonsense percentage falls back rather than propagating");
740
+ });
@@ -0,0 +1,119 @@
1
+ // thermal-probe.swift — the ONLY unprivileged thermal reading on Apple Silicon.
2
+ //
3
+ // ── WHY THIS FILE EXISTS ────────────────────────────────────────────────────
4
+ // The telemetry collector spent two months telling every operator that
5
+ // `powermetrics` "requires root … allowlist it NOPASSWD in sudoers". That
6
+ // advice was both futile and wrong:
7
+ //
8
+ // 1. `powermetrics` emits NO die temperature on Apple Silicon AT ALL — not
9
+ // unprivileged, not as root, not with `-A` (every sampler). Verified on a
10
+ // Mac mini M4 Pro (Mac16,11, macOS 24D70): `sudo powermetrics -A` returns
11
+ // 242 lines and `grep -i temperature` finds nothing. The only thermal fact
12
+ // in that output is `**** Thermal pressure **** / Current pressure level:
13
+ // Nominal`. So an operator who followed the advice to the letter would
14
+ // still get an em-dash.
15
+ // 2. Real °C IS available WITHOUT root — through IOHIDEventSystem, which is
16
+ // where the SoC's PMU temperature sensors actually publish. `ioreg` shows
17
+ // those sensors as HID service nodes but carries no values, which is why
18
+ // the registry looked like a dead end.
19
+ //
20
+ // This probe is invoked as `/usr/bin/swift thermal-probe.swift` and prints two
21
+ // `key=value` lines that `parseThermalProbe()` reads. It requires no root, no
22
+ // entitlement, and triggers no TCC prompt; verified working under `env -i` and
23
+ // detached from any tty, which is the LaunchAgent case the daemon actually runs
24
+ // in.
25
+ //
26
+ // ── THE TWO SIGNALS, AND WHY BOTH ───────────────────────────────────────────
27
+ // `thermalState` comes from ProcessInfo — a PUBLIC, always-present API. It is
28
+ // the floor: it cannot fail, needs no Xcode toolchain beyond running this
29
+ // script, and is a genuinely useful coarse answer (nominal/fair/serious/
30
+ // critical). `tempC` comes from the PRIVATE IOHIDEventSystem symbols; it is the
31
+ // precise answer when it is available. If the private path ever stops working
32
+ // on a future macOS, the state line still prints and the collector degrades to
33
+ // a coarse-but-honest reading rather than to nothing.
34
+ //
35
+ // Output (tempC/tempSensors omitted entirely when the sensor read yields nothing):
36
+ // thermalState=nominal
37
+ // tempC=61.24
38
+ // tempSensors=42
39
+
40
+ import Foundation
41
+ import IOKit
42
+
43
+ // ── 1. Thermal state: public API, cannot fail ───────────────────────────────
44
+ let word: String
45
+ switch ProcessInfo.processInfo.thermalState {
46
+ case .nominal: word = "nominal"
47
+ case .fair: word = "fair"
48
+ case .serious: word = "serious"
49
+ case .critical: word = "critical"
50
+ @unknown default: word = "unknown"
51
+ }
52
+ print("thermalState=\(word)")
53
+
54
+ // ── 2. Die temperature: private IOHIDEventSystem, unprivileged ──────────────
55
+ // Matching dictionary {PrimaryUsagePage: 0xff00, PrimaryUsage: 5} selects the
56
+ // AppleARMPMUTempSensor HID services; kIOHIDEventTypeTemperature = 15 and the
57
+ // float field is (type << 16).
58
+ let kTemperatureEvent: Int32 = 15
59
+ let kHIDPageAppleVendor = 0xff00
60
+ let kHIDUsageAppleVendorTemperatureSensor = 5
61
+
62
+ guard let iokit = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW) else {
63
+ exit(0) // state line already printed; that is a complete, honest answer
64
+ }
65
+
66
+ typealias ClientCreateFn = @convention(c) (CFAllocator?) -> Unmanaged<AnyObject>?
67
+ typealias SetMatchingFn = @convention(c) (AnyObject, CFDictionary) -> Void
68
+ typealias CopyServicesFn = @convention(c) (AnyObject) -> Unmanaged<CFArray>?
69
+ typealias CopyEventFn = @convention(c) (AnyObject, Int32, Int32, Int32) -> Unmanaged<AnyObject>?
70
+ typealias FloatValueFn = @convention(c) (AnyObject, Int32) -> Double
71
+ typealias CopyPropertyFn = @convention(c) (AnyObject, CFString) -> Unmanaged<AnyObject>?
72
+
73
+ guard
74
+ let pCreate = dlsym(iokit, "IOHIDEventSystemClientCreate"),
75
+ let pSetMatching = dlsym(iokit, "IOHIDEventSystemClientSetMatching"),
76
+ let pCopyServices = dlsym(iokit, "IOHIDEventSystemClientCopyServices"),
77
+ let pCopyEvent = dlsym(iokit, "IOHIDServiceClientCopyEvent"),
78
+ let pFloatValue = dlsym(iokit, "IOHIDEventGetFloatValue"),
79
+ let pCopyProperty = dlsym(iokit, "IOHIDServiceClientCopyProperty")
80
+ else { exit(0) }
81
+
82
+ let clientCreate = unsafeBitCast(pCreate, to: ClientCreateFn.self)
83
+ let setMatching = unsafeBitCast(pSetMatching, to: SetMatchingFn.self)
84
+ let copyServices = unsafeBitCast(pCopyServices, to: CopyServicesFn.self)
85
+ let copyEvent = unsafeBitCast(pCopyEvent, to: CopyEventFn.self)
86
+ let floatValue = unsafeBitCast(pFloatValue, to: FloatValueFn.self)
87
+ let copyProperty = unsafeBitCast(pCopyProperty, to: CopyPropertyFn.self)
88
+
89
+ guard let client = clientCreate(kCFAllocatorDefault)?.takeRetainedValue() else { exit(0) }
90
+ setMatching(client, [
91
+ "PrimaryUsagePage": kHIDPageAppleVendor,
92
+ "PrimaryUsage": kHIDUsageAppleVendorTemperatureSensor,
93
+ ] as CFDictionary)
94
+ guard let services = copyServices(client)?.takeRetainedValue() as? [AnyObject] else { exit(0) }
95
+
96
+ // SOME SENSORS LIE. `PMU tdev2` reliably returns -9201.14 on this hardware, so
97
+ // every reading is band-checked before it is allowed to influence the answer.
98
+ // Of 70 matching services, 67 return a plausible value.
99
+ var dieTemps: [Double] = []
100
+ var anyTemps: [Double] = []
101
+ for service in services {
102
+ guard let event = copyEvent(service, kTemperatureEvent, 0, 0)?.takeRetainedValue() else { continue }
103
+ let value = floatValue(event, kTemperatureEvent << 16)
104
+ guard value.isFinite, value > 0, value < 150 else { continue }
105
+ anyTemps.append(value)
106
+ let name = (copyProperty(service, "Product" as CFString)?.takeRetainedValue() as? String) ?? ""
107
+ if name.contains("tdie") { dieTemps.append(value) }
108
+ }
109
+
110
+ // Prefer the SoC die sensors ("PMU tdie<n>"); fall back to whatever plausible
111
+ // sensors exist so a future chip with different naming still reports something.
112
+ let pool = dieTemps.isEmpty ? anyTemps : dieTemps
113
+ guard let hottest = pool.max() else { exit(0) }
114
+
115
+ // The MAX, not the mean: a fleet dashboard wants the worst core, because that
116
+ // is what throttles. Averaging 42 die sensors hides exactly the spike the
117
+ // operator is looking for.
118
+ print(String(format: "tempC=%.2f", hottest))
119
+ print("tempSensors=\(pool.count)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.7.0",
3
+ "version": "2.8.1",
4
4
  "description": "Cohort Agent SDK \u2014 autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {