@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.
- package/lib/telemetry/collect.mjs +340 -35
- package/lib/telemetry/collect.test.mjs +277 -5
- package/lib/telemetry/thermal-probe.swift +119 -0
- package/package.json +1 -1
|
@@ -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, //
|
|
23
|
-
* tempSource?: "powermetrics"|"sudo-powermetrics",
|
|
24
|
-
*
|
|
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,
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
//
|
|
283
|
-
//
|
|
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
|
-
* ──
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
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}
|
|
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
|
-
//
|
|
429
|
-
//
|
|
430
|
-
const
|
|
431
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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": {
|