@cohortapp/agent-sdk 2.7.0 → 2.8.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.
@@ -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,7 +236,8 @@ 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
  */
@@ -279,15 +283,37 @@ export async function collectMachine(o = {}) {
279
283
  if (Number.isFinite(secs) && secs >= 0) machine.uptimeDays = round1(secs / 86400);
280
284
  } catch { /* no uptimeDays */ }
281
285
 
282
- // Best-effort temp + cpu via powermetrics (guarded, injected, never blocks).
283
- // ALWAYS records why it has no answer — see collectPowermetrics.
286
+ // ── THERMAL ────────────────────────────────────────────────────────────────
287
+ // Real °C where the hardware gives it up unprivileged, a coarse state word
288
+ // everywhere else, and `tempDetail` as an EXPLANATORY NOTE that rides
289
+ // ALONGSIDE the reading rather than standing in for it. See collectThermal
290
+ // for why this no longer runs through powermetrics.
291
+ // `powermetrics: false` has always been the caller's "fork no probes" switch
292
+ // (it is how every hermetic test disables the exec seam), so it still governs
293
+ // thermal unless a caller opts in or out of thermal explicitly.
294
+ const thermalEnabled = o.thermal !== undefined ? o.thermal !== false : o.powermetrics !== false;
295
+ if (thermalEnabled) {
296
+ const th = await collectThermal(o);
297
+ if (Number.isFinite(th.tempC)) {
298
+ machine.tempC = round1(th.tempC);
299
+ if (th.tempSource) machine.tempSource = th.tempSource;
300
+ }
301
+ if (th.thermalState) {
302
+ machine.thermalState = th.thermalState;
303
+ if (th.thermalSource) machine.thermalSource = th.thermalSource;
304
+ }
305
+ if (th.detail) machine.tempDetail = th.detail;
306
+ }
307
+
308
+ // powermetrics is now asked for CPU utilisation only — plus `tempC` on Intel,
309
+ // where the `smc` sampler genuinely carries a die temperature and the IOHID
310
+ // path above does not apply. On Apple Silicon it has no temperature to give
311
+ // at ANY privilege level, so it never gets to answer the thermal question.
284
312
  if (o.powermetrics !== false) {
285
313
  const pm = await collectPowermetrics(o);
286
- if (Number.isFinite(pm.tempC)) {
314
+ if (machine.tempC === undefined && Number.isFinite(pm.tempC)) {
287
315
  machine.tempC = round1(pm.tempC);
288
316
  machine.tempSource = pm.source || "powermetrics";
289
- } else if (pm.detail) {
290
- machine.tempDetail = pm.detail;
291
317
  }
292
318
  if (Number.isFinite(pm.cpuPct)) {
293
319
  machine.cpuPct = pct(pm.cpuPct);
@@ -320,6 +346,212 @@ export async function collectMachine(o = {}) {
320
346
  return machine;
321
347
  }
322
348
 
349
+ // ---------------------------------------------------------------------------
350
+ // Thermal — real °C without root, and a coarse state word that never fails
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /** The four thermal pressure levels macOS reports, coldest → hottest. */
354
+ export const THERMAL_STATES = Object.freeze(["nominal", "fair", "serious", "critical"]);
355
+
356
+ /** Normalise any casing/spelling of a pressure level to our enum, or undefined. */
357
+ function normalizeThermalState(raw) {
358
+ const v = String(raw || "").trim().toLowerCase();
359
+ return THERMAL_STATES.includes(v) ? v : undefined;
360
+ }
361
+
362
+ /**
363
+ * Pure parser for `thermal-probe.swift` stdout → { thermalState?, tempC?, sensors? }.
364
+ * Format is `key=value` lines; unknown keys are ignored so the probe can grow.
365
+ */
366
+ export function parseThermalProbe(text) {
367
+ const out = {};
368
+ for (const line of String(text || "").split("\n")) {
369
+ const m = line.match(/^\s*(\w+)\s*=\s*(.+?)\s*$/);
370
+ if (!m) continue;
371
+ const [, k, v] = m;
372
+ if (k === "thermalState") {
373
+ const st = normalizeThermalState(v);
374
+ if (st) out.thermalState = st;
375
+ } else if (k === "tempC") {
376
+ const n = Number(v);
377
+ // Band-check AGAIN on this side. The probe filters, but a collector that
378
+ // trusts a subprocess's arithmetic is how "-9201.14 °C" reaches a dashboard.
379
+ if (Number.isFinite(n) && n > 0 && n < 150) out.tempC = n;
380
+ } else if (k === "tempSensors") {
381
+ const n = Number(v);
382
+ if (Number.isFinite(n)) out.sensors = n;
383
+ }
384
+ }
385
+ return out;
386
+ }
387
+
388
+ /**
389
+ * Pure parser for the `**** Thermal pressure ****` block that `powermetrics
390
+ * --samplers thermal` prints — the ONE thermal fact that binary has on Apple
391
+ * Silicon. Root-only, so this is a fallback, not the primary path.
392
+ */
393
+ export function parseThermalPressure(text) {
394
+ const m = String(text || "").match(/Current pressure level:\s*(\w+)/i);
395
+ return m ? normalizeThermalState(m[1]) : undefined;
396
+ }
397
+
398
+ /**
399
+ * Pure parser for `pmset -g therm`. Unprivileged and always present, but it
400
+ * reports a HISTORY of recorded warnings, not a live reading: on a healthy
401
+ * machine every line is "No … has been recorded". That absence is a weak signal
402
+ * and this returns `undefined` for it rather than inventing "nominal" — see
403
+ * collectThermal, which is where the inference is made explicit and labelled.
404
+ */
405
+ export function parsePmsetTherm(text) {
406
+ const s = String(text || "");
407
+ const m = s.match(/CPU_Scheduler_Limit\s*=\s*(\d+)/i) || s.match(/CPU_Speed_Limit\s*=\s*(\d+)/i);
408
+ if (m) {
409
+ const limit = Number(m[1]);
410
+ // 100 = no throttling. Anything below it means the SMC is actively pulling
411
+ // the CPU back, which is a REAL thermal event, not a recorded memory of one.
412
+ if (Number.isFinite(limit)) {
413
+ if (limit >= 100) return "nominal";
414
+ if (limit >= 75) return "fair";
415
+ if (limit >= 50) return "serious";
416
+ return "critical";
417
+ }
418
+ }
419
+ if (/No thermal warning level has been recorded/i.test(s)) return "no-events";
420
+ return undefined;
421
+ }
422
+
423
+ /**
424
+ * Collect the machine's thermal signal — REAL °C when the hardware will give it
425
+ * up unprivileged, a coarse state word when it will not.
426
+ *
427
+ * ── THE ADVICE THIS REPLACES WAS FUTILE ─────────────────────────────────────
428
+ * This used to emit, on every Apple-Silicon seat: "powermetrics requires root
429
+ * (agent runs unprivileged); allowlist it NOPASSWD in sudoers and set
430
+ * TELEMETRY_SUDO_POWERMETRICS=1 to enable". Both halves of that were wrong.
431
+ *
432
+ * - `powermetrics` produces NO die temperature on Apple Silicon at any
433
+ * privilege level. Verified on Mac16,11 / macOS 24D70: `sudo powermetrics
434
+ * -A` (every sampler, 242 lines) contains no `temperature` line at all. An
435
+ * operator who did exactly what the message asked would STILL see a dash.
436
+ * - °C does not need root. It needs IOHIDEventSystem, which any uid can read.
437
+ *
438
+ * ── THE LADDER ──────────────────────────────────────────────────────────────
439
+ * 1. `swift thermal-probe.swift` — unprivileged. Yields BOTH a real `tempC`
440
+ * (private IOHID sensors) and `thermalState` (public ProcessInfo). This is
441
+ * the answer on any Mac with the Xcode command-line tools present.
442
+ * 2. `sudo -n powermetrics --samplers thermal` — ONLY if the operator opted
443
+ * in. No °C, but a true `Current pressure level:`. State-only.
444
+ * 3. `pmset -g therm` — unprivileged, always present, but only reports a
445
+ * live level while the SMC is actually throttling.
446
+ *
447
+ * Every rung records WHY it has no better answer in `detail`. Crucially
448
+ * `detail` is now a NOTE that ships ALONGSIDE a reading, never instead of one:
449
+ * the old code made them mutually exclusive, so hq had nothing to print in the
450
+ * value slot and rendered the 118-character sudoers sentence there instead.
451
+ *
452
+ * @returns {Promise<{ tempC?:number, tempSource?:string, thermalState?:string,
453
+ * thermalSource?:string, detail?:string }>}
454
+ */
455
+ export async function collectThermal(o = {}) {
456
+ const platform = o.platform || (o.os && o.os.platform && o.os.platform()) || process.platform;
457
+ const execFileImpl = o.execFile || execFile;
458
+ if (platform !== "darwin" && !o.execFile) {
459
+ return { detail: `no thermal probe on platform ${platform}` };
460
+ }
461
+ const timeoutMs = Number.isFinite(o.thermalTimeoutMs) ? o.thermalTimeoutMs : 4000;
462
+
463
+ // ── 1. The unprivileged probe: real °C + public thermal state ─────────────
464
+ const swiftBin = o.swiftBin || "/usr/bin/swift";
465
+ const probePath = o.thermalProbePath || defaultThermalProbePath();
466
+ const haveSwift = Boolean(o.execFile) || (existsSync(swiftBin) && existsSync(probePath));
467
+ if (haveSwift) {
468
+ const out = await execFileSafe(execFileImpl, swiftBin, [probePath], timeoutMs);
469
+ const parsed = parseThermalProbe(out);
470
+ if (Number.isFinite(parsed.tempC)) {
471
+ return {
472
+ tempC: parsed.tempC,
473
+ tempSource: "iohid",
474
+ thermalState: parsed.thermalState,
475
+ thermalSource: parsed.thermalState ? "processinfo" : undefined,
476
+ detail: parsed.sensors
477
+ ? `die temperature: hottest of ${parsed.sensors} on-SoC sensors (IOHIDEventSystem, unprivileged)`
478
+ : "die temperature via IOHIDEventSystem (unprivileged)",
479
+ };
480
+ }
481
+ if (parsed.thermalState) {
482
+ return {
483
+ thermalState: parsed.thermalState,
484
+ thermalSource: "processinfo",
485
+ detail:
486
+ "no numeric °C: the IOHID temperature sensors returned nothing on this hardware; " +
487
+ "showing the OS thermal pressure level instead",
488
+ };
489
+ }
490
+ }
491
+
492
+ // ── 2. Opt-in sudo powermetrics — pressure level only, never °C ───────────
493
+ const sudoEnabled = o.sudoPowermetrics !== undefined
494
+ ? Boolean(o.sudoPowermetrics)
495
+ : String(process.env.TELEMETRY_SUDO_POWERMETRICS || "") === "1";
496
+ if (sudoEnabled) {
497
+ const sudoBin = o.sudoBin || "/usr/bin/sudo";
498
+ const pmBin = o.powermetricsBin || "/usr/bin/powermetrics";
499
+ const arch = o.arch || (o.os && o.os.arch && o.os.arch()) || process.arch;
500
+ const out = await execFileSafe(
501
+ execFileImpl, sudoBin,
502
+ ["-n", pmBin, "-n", "1", "-i", "200", "--samplers", powermetricsSamplers(arch)],
503
+ timeoutMs
504
+ );
505
+ const level = parseThermalPressure(out);
506
+ if (level) {
507
+ return {
508
+ thermalState: level,
509
+ thermalSource: "sudo-powermetrics",
510
+ detail:
511
+ "no numeric °C: powermetrics reports thermal PRESSURE only — Apple Silicon " +
512
+ "exposes no die temperature to it even as root",
513
+ };
514
+ }
515
+ }
516
+
517
+ // ── 3. pmset — unprivileged, but only speaks up while actually throttling ──
518
+ const pmsetBin = o.pmsetBin || "/usr/bin/pmset";
519
+ const out = await execFileSafe(execFileImpl, pmsetBin, ["-g", "therm"], timeoutMs);
520
+ const therm = parsePmsetTherm(out);
521
+ if (therm && therm !== "no-events") {
522
+ return {
523
+ thermalState: therm,
524
+ thermalSource: "pmset",
525
+ detail: "derived from the SMC CPU speed limit reported by `pmset -g therm`",
526
+ };
527
+ }
528
+ if (therm === "no-events") {
529
+ // NOT reported as `nominal`. "macOS has never recorded a thermal warning"
530
+ // is a statement about history, and dressing it up as a live reading is
531
+ // exactly the kind of invented number this rewrite exists to remove.
532
+ return {
533
+ detail:
534
+ "no thermal reading: install the Xcode command-line tools (`xcode-select --install`) " +
535
+ "to enable the unprivileged °C probe. `pmset` reports no thermal events on record.",
536
+ };
537
+ }
538
+
539
+ return {
540
+ detail:
541
+ "no thermal reading available: the unprivileged °C probe needs `/usr/bin/swift` " +
542
+ "(Xcode command-line tools) and no fallback thermal source responded",
543
+ };
544
+ }
545
+
546
+ /** Absolute path to the shipped swift probe, resolved next to this module. */
547
+ function defaultThermalProbePath() {
548
+ try {
549
+ return fileURLToPath(new URL("./thermal-probe.swift", import.meta.url));
550
+ } catch {
551
+ return "";
552
+ }
553
+ }
554
+
323
555
  /**
324
556
  * The `powermetrics` sampler that carries die temperature, BY CPU ARCHITECTURE.
325
557
  *
@@ -352,16 +584,20 @@ export function powermetricsSamplers(arch) {
352
584
  * exit now carries a `detail` sentence that the snapshot ships and the drawer
353
585
  * prints.
354
586
  *
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.
587
+ * ── THIS IS NO LONGER THE TEMPERATURE PATH ───────────────────────────────────
588
+ * It used to be, and the comment here used to assert that no unprivileged
589
+ * numeric temperature source existed on Apple Silicon. That was wrong twice
590
+ * over: powermetrics has no die temperature to give on Apple Silicon EVEN AS
591
+ * ROOT (`sudo powermetrics -A` contains no `temperature` line), and °C is
592
+ * readable by any uid through IOHIDEventSystem. `collectThermal` owns that now;
593
+ * this function is kept for CPU utilisation, for the Intel `smc` sampler that
594
+ * genuinely does carry a temperature, and for the thermal-pressure line.
595
+ *
596
+ * `powermetrics` still requires superuser, and the daemon still runs from a user
597
+ * LaunchAgent, so the OPT-IN sudo path remains: set
598
+ * `TELEMETRY_SUDO_POWERMETRICS=1` once the operator has allowlisted the binary
599
+ * NOPASSWD in sudoers. `sudo -n` never prompts — with no allowlist it fails
600
+ * instantly rather than hanging a beat on a password prompt no one will see.
365
601
  *
366
602
  * @returns {Promise<{ tempC?:number, cpuPct?:number, source?:string, detail?:string }>}
367
603
  */
@@ -407,14 +643,12 @@ export async function collectPowermetrics(o = {}) {
407
643
  }
408
644
  }
409
645
  return {
410
- detail: `${bin} yielded nothing under 'sudo -n' — check the NOPASSWD sudoers allowlist`,
646
+ detail: `${bin} ran under 'sudo -n' but reported no CPU utilisation this platform can parse`,
411
647
  };
412
648
  }
413
649
 
414
650
  return {
415
- detail:
416
- `${bin} requires root (agent runs unprivileged); ` +
417
- "allowlist it NOPASSWD in sudoers and set TELEMETRY_SUDO_POWERMETRICS=1 to enable",
651
+ detail: `${bin} requires root (agent runs unprivileged); CPU% falls back to the loadavg estimate`,
418
652
  };
419
653
  }
420
654
 
@@ -425,10 +659,33 @@ export function parsePowermetrics(text) {
425
659
  // Temperature: "CPU die temperature: 54.21 C" or "GPU die temperature: ..."
426
660
  const temp = s.match(/CPU die temperature:\s*([\d.]+)\s*C/i) || s.match(/die temperature:\s*([\d.]+)\s*C/i);
427
661
  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); }
662
+ // Thermal pressure — on Apple Silicon this is the ONLY thermal fact in the
663
+ // whole of powermetrics' output, at any privilege level.
664
+ const pressure = parseThermalPressure(s);
665
+ if (pressure) out.pressure = pressure;
666
+ // Active CPU% = 100 - idle residency.
667
+ //
668
+ // ── THE arm64 SHAPE, WHICH NEVER MATCHED ──────────────────────────────────
669
+ // This only ever looked for a bare `CPU idle residency:` line. Apple Silicon
670
+ // does not print one: it reports PER-CLUSTER (`E-Cluster idle residency:
671
+ // 15.69%`, `P-Cluster …`) and per-core (`CPU 0 idle residency: 46.81%`)
672
+ // lines and no total. `grep -cE "^CPU\s+(Average\s+)?idle residency"` on real
673
+ // M4 Pro output returns 0, so with sudo enabled this fell through to a detail
674
+ // string blaming the operator's sudoers file when sudo had worked perfectly.
675
+ // Fall back to averaging the per-CORE lines, which is the true whole-CPU idle.
676
+ const idle = s.match(/^CPU\s+(?:Average\s+)?idle\s+residency:\s*([\d.]+)\s*%/im);
677
+ if (idle) {
678
+ const v = Number(idle[1]);
679
+ if (Number.isFinite(v)) out.cpuPct = Math.max(0, 100 - v);
680
+ } else {
681
+ const perCore = [...s.matchAll(/^CPU\s+\d+\s+idle\s+residency:\s*([\d.]+)\s*%/gim)]
682
+ .map((m) => Number(m[1]))
683
+ .filter((n) => Number.isFinite(n));
684
+ if (perCore.length > 0) {
685
+ const avgIdle = perCore.reduce((a, b) => a + b, 0) / perCore.length;
686
+ out.cpuPct = Math.max(0, 100 - avgIdle);
687
+ }
688
+ }
432
689
  return out;
433
690
  }
434
691
 
@@ -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,
@@ -319,21 +322,25 @@ test("collectPowermetrics passes the arch-correct sampler to the binary", async
319
322
  assert.ok(!samplerArg.includes("smc"), "must not send the Intel-only sampler to arm64");
320
323
  });
321
324
 
322
- test("collectPowermetrics ALWAYS says why it has no temperature (fail-open, never silent)", async () => {
325
+ test("collectPowermetrics ALWAYS says why it has no answer (fail-open, never silent)", async () => {
323
326
  // The unprivileged reality on every seat: the probe yields nothing.
324
327
  const pm = await collectPowermetrics({
325
328
  execFile: fakeExecFile({}), platform: "darwin", arch: "arm64", sudoPowermetrics: false,
326
329
  });
327
330
  assert.equal(pm.tempC, undefined);
328
331
  assert.match(pm.detail, /requires root/);
329
- assert.match(pm.detail, /TELEMETRY_SUDO_POWERMETRICS/);
332
+ // It no longer tells the operator to edit sudoers to get a temperature. That
333
+ // advice was futile: powermetrics has no die temperature on Apple Silicon at
334
+ // ANY privilege level, so following it to the letter still yielded nothing.
335
+ assert.ok(!/TELEMETRY_SUDO_POWERMETRICS/.test(pm.detail));
336
+ assert.ok(!/sudoers/.test(pm.detail));
330
337
 
331
338
  // A non-darwin host is a DIFFERENT absence and says so.
332
339
  const linux = await collectPowermetrics({ platform: "linux" });
333
340
  assert.match(linux.detail, /platform linux/);
334
341
  });
335
342
 
336
- test("collectMachine surfaces the temperature failure as machine.tempDetail", async () => {
343
+ test("collectMachine surfaces a thermal failure as a NOTE, never as the value", async () => {
337
344
  const m = await collectMachine({
338
345
  os: fakeOs({}),
339
346
  execFile: fakeExecFile({}),
@@ -346,7 +353,11 @@ test("collectMachine surfaces the temperature failure as machine.tempDetail", as
346
353
  });
347
354
  assert.equal(m.tempC, undefined);
348
355
  assert.equal(m.tempSource, undefined);
349
- assert.match(m.tempDetail, /requires root/);
356
+ assert.equal(m.thermalState, undefined);
357
+ assert.equal(typeof m.tempDetail, "string");
358
+ // The note must not blame the operator's sudoers file for a limitation of the
359
+ // hardware — that is the message this whole rewrite exists to delete.
360
+ assert.ok(!/sudoers/.test(m.tempDetail));
350
361
  // And the cpuPct it DOES have is labelled as the estimate it is.
351
362
  assert.equal(m.cpuSource, "loadavg");
352
363
  });
@@ -380,6 +391,191 @@ test("collectPowermetrics tries the opt-in sudo path only when enabled", async (
380
391
  assert.ok(sudoCall >= 0);
381
392
  });
382
393
 
394
+ // ---------------------------------------------------------------------------
395
+ // Thermal — real °C without root, and a coarse state word that never fails.
396
+ //
397
+ // Every test here stubs the exec seam. NOTHING in this file forks `swift`,
398
+ // `powermetrics` or `pmset`; the probe's real output is pinned as fixture text.
399
+ // ---------------------------------------------------------------------------
400
+
401
+ /** Verbatim stdout of `swift lib/telemetry/thermal-probe.swift` on a Mac16,11. */
402
+ const PROBE_OK = "thermalState=nominal\ntempC=60.76\ntempSensors=42\n";
403
+
404
+ /** Verbatim `sudo powermetrics --samplers thermal` — note: NO temperature line. */
405
+ const PM_THERMAL = [
406
+ "Machine model: Mac16,11",
407
+ "",
408
+ "**** Thermal pressure ****",
409
+ "",
410
+ "Current pressure level: Nominal",
411
+ "",
412
+ ].join("\n");
413
+
414
+ /** Route stdout by binary, so a test can say which rung of the ladder answers. */
415
+ const SWIFT = "/usr/bin/swift";
416
+ const SUDO = "/usr/bin/sudo";
417
+ const PMSET = "/usr/bin/pmset";
418
+
419
+ test("collectThermal reads a REAL °C unprivileged, via the IOHID probe", async () => {
420
+ // The headline fix. powermetrics cannot produce a die temperature on Apple
421
+ // Silicon even as root; IOHIDEventSystem hands it over to any uid.
422
+ const seen = [];
423
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
424
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
425
+ seen.push(cmd);
426
+ queueMicrotask(() => cb(null, cmd === SWIFT ? PROBE_OK : "", ""));
427
+ return { on() {} };
428
+ };
429
+ const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
430
+ assert.equal(th.tempC, 60.76);
431
+ assert.equal(th.tempSource, "iohid");
432
+ assert.equal(th.thermalState, "nominal");
433
+ assert.equal(th.thermalSource, "processinfo");
434
+ // No root, no sudo, on the happy path.
435
+ assert.ok(!seen.includes(SUDO), "the °C path must never need sudo");
436
+ // The detail is an EXPLANATION riding alongside a real reading, not a
437
+ // substitute for one — the drawer prints the number and demotes this.
438
+ assert.equal(typeof th.detail, "string");
439
+ assert.match(th.detail, /IOHIDEventSystem/);
440
+ });
441
+
442
+ test("collectThermal falls back to the coarse STATE when there is no °C", async () => {
443
+ // The probe ran but the sensors gave nothing: state alone is still a useful,
444
+ // honest answer, and the field semantics say so rather than faking a number.
445
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
446
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
447
+ queueMicrotask(() => cb(null, cmd === SWIFT ? "thermalState=serious\n" : "", ""));
448
+ return { on() {} };
449
+ };
450
+ const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
451
+ assert.equal(th.tempC, undefined);
452
+ assert.equal(th.thermalState, "serious");
453
+ assert.equal(th.thermalSource, "processinfo");
454
+ assert.match(th.detail, /no numeric °C/);
455
+ });
456
+
457
+ test("collectThermal uses sudo powermetrics for PRESSURE only, and only when opted in", async () => {
458
+ const seen = [];
459
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
460
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
461
+ seen.push(cmd);
462
+ // swift is absent on this host; sudo powermetrics answers.
463
+ queueMicrotask(() => cb(null, cmd === SUDO ? PM_THERMAL : "", ""));
464
+ return { on() {} };
465
+ };
466
+ const off = await collectThermal({
467
+ execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: false,
468
+ });
469
+ assert.ok(!seen.includes(SUDO), "must not shell out to sudo unless asked");
470
+ assert.equal(off.thermalState, undefined);
471
+
472
+ seen.length = 0;
473
+ const on = await collectThermal({
474
+ execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: true,
475
+ });
476
+ assert.ok(seen.includes(SUDO));
477
+ assert.equal(on.thermalState, "nominal");
478
+ assert.equal(on.thermalSource, "sudo-powermetrics");
479
+ // Root buys a pressure LEVEL and nothing more. If this ever starts asserting a
480
+ // tempC from powermetrics on arm64, the fixture is lying.
481
+ assert.equal(on.tempC, undefined);
482
+ assert.match(on.detail, /pressure/i);
483
+ });
484
+
485
+ test("collectThermal will NOT dress 'no thermal events on record' up as a live reading", async () => {
486
+ // `pmset -g therm` on a healthy Mac reports history, not temperature. Calling
487
+ // that "nominal" would be inventing a measurement nobody took.
488
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
489
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
490
+ const out = cmd === PMSET ? "Note: No thermal warning level has been recorded\n" : "";
491
+ queueMicrotask(() => cb(null, out, ""));
492
+ return { on() {} };
493
+ };
494
+ const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
495
+ assert.equal(th.thermalState, undefined);
496
+ assert.equal(th.tempC, undefined);
497
+ assert.match(th.detail, /xcode-select --install/);
498
+
499
+ // But a REAL throttle event — a speed limit below 100 — is a live signal.
500
+ const throttling = (cmd, args, optsOrCb, maybeCb) => {
501
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
502
+ const out = cmd === PMSET ? "CPU_Speed_Limit \t= 60\n" : "";
503
+ queueMicrotask(() => cb(null, out, ""));
504
+ return { on() {} };
505
+ };
506
+ const hot = await collectThermal({ execFile: throttling, platform: "darwin", arch: "arm64" });
507
+ assert.equal(hot.thermalState, "serious");
508
+ assert.equal(hot.thermalSource, "pmset");
509
+ });
510
+
511
+ test("parseThermalProbe rejects the sensor that lies (-9201.14 °C)", () => {
512
+ // `PMU tdev2` returns garbage on real hardware. The probe filters it, and the
513
+ // collector band-checks AGAIN — trusting a subprocess's arithmetic is how an
514
+ // absurd number reaches a dashboard.
515
+ assert.deepEqual(parseThermalProbe("thermalState=nominal\ntempC=-9201.14\n"), {
516
+ thermalState: "nominal",
517
+ });
518
+ assert.equal(parseThermalProbe("tempC=900\n").tempC, undefined);
519
+ assert.equal(parseThermalProbe("tempC=61.2\n").tempC, 61.2);
520
+ // Unknown keys are ignored so the probe can grow a field without breaking us.
521
+ assert.equal(parseThermalProbe("tempC=61.2\nfuture=x\n").tempC, 61.2);
522
+ assert.deepEqual(parseThermalProbe(""), {});
523
+ assert.deepEqual(parseThermalProbe(null), {});
524
+ // Only the four real levels are accepted.
525
+ assert.equal(parseThermalProbe("thermalState=toasty\n").thermalState, undefined);
526
+ });
527
+
528
+ test("parseThermalPressure reads the only thermal fact powermetrics has on arm64", () => {
529
+ assert.equal(parseThermalPressure(PM_THERMAL), "nominal");
530
+ assert.equal(parseThermalPressure("Current pressure level: Critical"), "critical");
531
+ assert.equal(parseThermalPressure("no such block"), undefined);
532
+ assert.equal(parseThermalPressure(""), undefined);
533
+ });
534
+
535
+ test("parsePowermetrics computes CPU% from the PER-CLUSTER arm64 shape", () => {
536
+ // The old regex wanted a bare `CPU idle residency:` line. Apple Silicon never
537
+ // prints one — only per-core and per-cluster lines — so on every seat in the
538
+ // fleet this silently fell through and then blamed the operator's sudoers.
539
+ const arm = [
540
+ "**** Processor usage ****",
541
+ "E-Cluster idle residency: 78.34%",
542
+ "CPU 0 idle residency: 90.00%",
543
+ "CPU 1 idle residency: 80.00%",
544
+ "CPU 2 idle residency: 70.00%",
545
+ "CPU 3 idle residency: 60.00%",
546
+ ].join("\n");
547
+ const parsed = parsePowermetrics(arm);
548
+ assert.equal(parsed.cpuPct, 25); // 100 - mean(90,80,70,60)
549
+ // The Intel/total shape still wins outright where it exists.
550
+ assert.equal(parsePowermetrics("CPU Average idle residency: 78.0 %").cpuPct, 22);
551
+ });
552
+
553
+ test("collectMachine ships tempC + thermalState + the note TOGETHER", async () => {
554
+ // The old collector made the reading and the explanation MUTUALLY EXCLUSIVE
555
+ // (`else if`), which left hq with nothing to put in the value slot — so it
556
+ // printed a 118-character sudoers sentence in a column of "62°C" and "41%".
557
+ const spy = (cmd, args, optsOrCb, maybeCb) => {
558
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
559
+ queueMicrotask(() => cb(null, cmd === SWIFT ? PROBE_OK : "", ""));
560
+ return { on() {} };
561
+ };
562
+ const m = await collectMachine({
563
+ os: fakeOs({}),
564
+ execFile: spy,
565
+ platform: "darwin",
566
+ arch: "arm64",
567
+ sudoPowermetrics: false,
568
+ disk: false,
569
+ inventory: false,
570
+ spend: false,
571
+ });
572
+ assert.equal(m.tempC, 60.8); // round1
573
+ assert.equal(m.tempSource, "iohid");
574
+ assert.equal(m.thermalState, "nominal");
575
+ assert.equal(m.thermalSource, "processinfo");
576
+ assert.equal(typeof m.tempDetail, "string");
577
+ });
578
+
383
579
  // ---------------------------------------------------------------------------
384
580
  // spend24h
385
581
  // ---------------------------------------------------------------------------
@@ -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.0",
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": {