@cohortapp/agent-sdk 2.6.2 → 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, //
|
|
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,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,
|
|
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
|
-
//
|
|
283
|
-
//
|
|
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
|
-
* ──
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
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}
|
|
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
|
-
//
|
|
429
|
-
//
|
|
430
|
-
const
|
|
431
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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": {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* responder.test.mjs — conversation history behind holding messages / quick replies.
|
|
3
|
+
* Run: node --test scripts/daemon/responder.test.mjs
|
|
4
|
+
*
|
|
5
|
+
* `loadConversationHistory` used to build its candidate directories as
|
|
6
|
+
* `memory/interactions/slack/<id>` unconditionally, and its writer
|
|
7
|
+
* (`logInteraction`) early-returns for any service that is not Slack. A Cohort
|
|
8
|
+
* DM therefore had nothing written and nothing to read, so every holding
|
|
9
|
+
* message for Cohort was generated from the current message alone. A bare "?"
|
|
10
|
+
* produced "What's on your mind?" — indistinguishable from amnesia, and the
|
|
11
|
+
* CEO read it as exactly that (2026-08-13, dm-h001-a016).
|
|
12
|
+
*
|
|
13
|
+
* Cohort has an authoritative `messaging.history`. These tests pin that the
|
|
14
|
+
* cohort branch reads it, orders it oldest-first, and NEVER throws — the
|
|
15
|
+
* holding path must degrade to "no history" rather than take the send down.
|
|
16
|
+
*/
|
|
17
|
+
"use strict";
|
|
18
|
+
|
|
19
|
+
import { test } from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { loadConversationHistory } from "./responder.mjs";
|
|
26
|
+
|
|
27
|
+
const cohortItem = {
|
|
28
|
+
sender: "Dana Okafor",
|
|
29
|
+
service: "cohort",
|
|
30
|
+
channel: "dm-h001-a016",
|
|
31
|
+
channel_id: "cmql1802601iihhzpf774vwbk",
|
|
32
|
+
content: "?",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Newest-first, the way messaging.history actually returns it.
|
|
36
|
+
const HISTORY = [
|
|
37
|
+
{ id: "m3", createdAt: "2026-08-13T05:47:51Z", authorName: "Dana Okafor", body: "with that in mind, fix these issues you just noted please" },
|
|
38
|
+
{ id: "m2", createdAt: "2026-08-12T13:52:53Z", authorName: "Isla Roselli", body: "Both are merged. #19 went in as 94f227c4." },
|
|
39
|
+
{ id: "m1", createdAt: "2026-08-12T13:47:30Z", authorName: "Dana Okafor", body: "why haven't you merged? please go ahead" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
test("cohort: reads messaging.history and renders it oldest-first", async () => {
|
|
43
|
+
const seen = [];
|
|
44
|
+
const text = await loadConversationHistory(cohortItem, {
|
|
45
|
+
fetchHistory: async (params, o) => {
|
|
46
|
+
seen.push({ params, hasCfg: Boolean(o && o.cfg) });
|
|
47
|
+
return HISTORY;
|
|
48
|
+
},
|
|
49
|
+
loadOrgConfig: () => ({ org: { cohort: { enabled: true, base: "https://x", token: "t" } } }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.ok(text, "expected a transcript, got null");
|
|
53
|
+
assert.match(text, /Dana Okafor: why haven't you merged/);
|
|
54
|
+
assert.match(text, /Isla Roselli: Both are merged/);
|
|
55
|
+
assert.match(text, /Dana Okafor: with that in mind/);
|
|
56
|
+
|
|
57
|
+
// Oldest-first: the model reads a conversation, not a reversed one.
|
|
58
|
+
assert.ok(
|
|
59
|
+
text.indexOf("why haven't you merged") < text.indexOf("with that in mind"),
|
|
60
|
+
"history must be rendered oldest-first",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// The channel it asks for is the item's channel_id, and cfg is ALWAYS passed:
|
|
64
|
+
// fetchHistory() fails open to [] when cfg is omitted, which is silently
|
|
65
|
+
// indistinguishable from an empty channel. That is the bug this file exists for.
|
|
66
|
+
assert.equal(seen.length, 1);
|
|
67
|
+
assert.equal(seen[0].params.channel, "cmql1802601iihhzpf774vwbk");
|
|
68
|
+
assert.equal(seen[0].hasCfg, true, "cfg must be passed or fetchHistory returns [] silently");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("cohort: an empty channel is null, not an empty transcript", async () => {
|
|
72
|
+
const text = await loadConversationHistory(cohortItem, {
|
|
73
|
+
fetchHistory: async () => [],
|
|
74
|
+
loadOrgConfig: () => ({}),
|
|
75
|
+
});
|
|
76
|
+
assert.equal(text, null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("cohort: a failing history call degrades to null and never throws", async () => {
|
|
80
|
+
const text = await loadConversationHistory(cohortItem, {
|
|
81
|
+
fetchHistory: async () => { throw new Error("cohort unreachable"); },
|
|
82
|
+
loadOrgConfig: () => ({}),
|
|
83
|
+
});
|
|
84
|
+
assert.equal(text, null);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("cohort: the current message is not fed back as its own history", async () => {
|
|
88
|
+
const text = await loadConversationHistory(
|
|
89
|
+
{ ...cohortItem, message_id: "m3" },
|
|
90
|
+
{
|
|
91
|
+
fetchHistory: async () => HISTORY,
|
|
92
|
+
loadOrgConfig: () => ({}),
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
assert.ok(text);
|
|
96
|
+
assert.doesNotMatch(text, /with that in mind/, "the inbound message itself must be excluded");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("slack: still reads the local interaction mirror", async () => {
|
|
100
|
+
const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
|
|
101
|
+
const dir = join(root, "memory", "interactions", "slack", "D099N1JGKRQ");
|
|
102
|
+
mkdirSync(dir, { recursive: true });
|
|
103
|
+
writeFileSync(
|
|
104
|
+
join(dir, "2026-08-13.jsonl"),
|
|
105
|
+
[
|
|
106
|
+
JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "Dana Okafor", content: "ship it" }),
|
|
107
|
+
JSON.stringify({ ts: "2026-08-13T05:01:00Z", from: "agent", content: "shipped" }),
|
|
108
|
+
].join("\n"),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const text = await loadConversationHistory(
|
|
112
|
+
{ sender: "Dana Okafor", service: "slack", channel: "D099N1JGKRQ", content: "?" },
|
|
113
|
+
{ agentRepoDir: root },
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
assert.ok(text, "expected the slack mirror to still be read");
|
|
117
|
+
assert.match(text, /Dana Okafor: ship it/);
|
|
118
|
+
assert.match(text, /agent: shipped/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("cohort: falls back to the local mirror when the server is unreachable", async () => {
|
|
122
|
+
// Isla's original returned null outright for Cohort when messaging.history
|
|
123
|
+
// failed. Falling through to the mirror means a transient API blip degrades
|
|
124
|
+
// to cached context instead of to none — the mirror is a poor source of
|
|
125
|
+
// truth but a perfectly good backstop.
|
|
126
|
+
const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
|
|
127
|
+
const dir = join(root, "memory", "interactions", "cohort", "chan-1");
|
|
128
|
+
mkdirSync(dir, { recursive: true });
|
|
129
|
+
writeFileSync(
|
|
130
|
+
join(dir, "2026-08-13.jsonl"),
|
|
131
|
+
JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "Dana Okafor", content: "cached line" }),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const text = await loadConversationHistory(
|
|
135
|
+
{ sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
|
|
136
|
+
{
|
|
137
|
+
agentRepoDir: root,
|
|
138
|
+
loadOrgConfig: () => ({}),
|
|
139
|
+
fetchHistory: async () => { throw new Error("network down"); },
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
assert.ok(text, "a server failure must not erase locally cached context");
|
|
144
|
+
assert.match(text, /Dana Okafor: cached line/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("cohort: server history wins over the local mirror", async () => {
|
|
148
|
+
const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
|
|
149
|
+
const dir = join(root, "memory", "interactions", "cohort", "chan-1");
|
|
150
|
+
mkdirSync(dir, { recursive: true });
|
|
151
|
+
writeFileSync(
|
|
152
|
+
join(dir, "2026-08-13.jsonl"),
|
|
153
|
+
JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "stale", content: "outdated mirror line" }),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const text = await loadConversationHistory(
|
|
157
|
+
{ sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
|
|
158
|
+
{
|
|
159
|
+
agentRepoDir: root,
|
|
160
|
+
loadOrgConfig: () => ({}),
|
|
161
|
+
fetchHistory: async () => ([
|
|
162
|
+
{ id: "m2", authorName: "Dana Okafor", body: "authoritative line", createdAt: "2026-08-13T06:00:00Z" },
|
|
163
|
+
]),
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
assert.match(text, /authoritative line/, "the server is the source of truth");
|
|
168
|
+
assert.doesNotMatch(text, /outdated mirror line/, "the stale mirror must not be mixed in");
|
|
169
|
+
});
|
|
@@ -523,23 +523,105 @@ function loadUserProfile(sender) {
|
|
|
523
523
|
// Load recent conversation history for context
|
|
524
524
|
// ---------------------------------------------------------------------------
|
|
525
525
|
|
|
526
|
+
/** Cohort's message ids as this item carries them, so history can drop itself. */
|
|
527
|
+
function inboundMessageIds(item) {
|
|
528
|
+
const ids = new Set();
|
|
529
|
+
const add = (v) => { if (v) ids.add(String(v)); };
|
|
530
|
+
add(item.message_id);
|
|
531
|
+
// Inbox filenames prefix the service: "cohort-cmsr45u91…" → "cmsr45u91…".
|
|
532
|
+
if (item.id) { add(item.id); add(String(item.id).replace(/^[a-z]+-/, "")); }
|
|
533
|
+
// raw_ref is "cohort:dm:<messageId>:<seq>".
|
|
534
|
+
const m = typeof item.raw_ref === "string" ? item.raw_ref.match(/^cohort:[^:]+:([^:]+)/) : null;
|
|
535
|
+
if (m) add(m[1]);
|
|
536
|
+
return ids;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const COHORT_HISTORY_LIMIT = 12;
|
|
540
|
+
const COHORT_HISTORY_CHARS = 400;
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Cohort history straight from the server, via the same `messaging.history` the
|
|
544
|
+
* replies are posted to.
|
|
545
|
+
*
|
|
546
|
+
* The local mirror below is a cache the agent populates itself, so it carries
|
|
547
|
+
* context only if the agent has already been talking AND logging correctly.
|
|
548
|
+
* When the write half broke (logInteraction early-returned for every non-Slack
|
|
549
|
+
* service), the read half silently returned nothing, memory/interactions/ was
|
|
550
|
+
* never even created, and holding messages were composed from the inbound
|
|
551
|
+
* message alone — a bare "?" came back as "What's on your mind?" mid-thread.
|
|
552
|
+
*
|
|
553
|
+
* Asking the authority that stores the message removes that coupling: a fresh
|
|
554
|
+
* agent, or one whose mirror was wiped, still answers with full context.
|
|
555
|
+
*
|
|
556
|
+
* Fails open to null on every path. No amount of history is worth failing a send.
|
|
557
|
+
*/
|
|
558
|
+
async function loadCohortHistory(item, deps = {}) {
|
|
559
|
+
const channel = item.channel_id || item.channel || "";
|
|
560
|
+
if (!channel) return null;
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const fetchHistory = deps.fetchHistory
|
|
564
|
+
|| (await import("../../lib/org/messaging.mjs")).fetchHistory;
|
|
565
|
+
const loadOrgConfig = deps.loadOrgConfig
|
|
566
|
+
|| (await import("../../lib/org/client.mjs")).loadOrgConfig;
|
|
567
|
+
const agentRoot = deps.agentRepoDir || process.env.AGENT_ROOT || process.env.AGENT_DIR || AGENT_REPO_DIR;
|
|
568
|
+
|
|
569
|
+
// cfg is mandatory. configFromAgent() reads base+token ONLY from the object
|
|
570
|
+
// handed in — it never loads config/org.yaml — so omitting it returns a
|
|
571
|
+
// fail-open [] that looks exactly like an empty channel.
|
|
572
|
+
const messages = await fetchHistory(
|
|
573
|
+
{ channel, limit: COHORT_HISTORY_LIMIT + 1 },
|
|
574
|
+
{ cfg: loadOrgConfig(agentRoot), agentRoot },
|
|
575
|
+
);
|
|
576
|
+
if (!Array.isArray(messages) || messages.length === 0) return null;
|
|
577
|
+
|
|
578
|
+
const self = inboundMessageIds(item);
|
|
579
|
+
const rows = messages
|
|
580
|
+
.filter((m) => m && !self.has(String(m.id)))
|
|
581
|
+
.slice(0, COHORT_HISTORY_LIMIT)
|
|
582
|
+
.reverse() // messaging.history is newest-first; a conversation reads the other way
|
|
583
|
+
.map((m) => {
|
|
584
|
+
const from = m.authorName || m.author?.name || m.authorId || "unknown";
|
|
585
|
+
const text = String(m.body ?? m.content ?? "").trim();
|
|
586
|
+
return `[${m.createdAt || ""}] ${from}: ${text.substring(0, COHORT_HISTORY_CHARS)}`;
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
return rows.length > 0 ? rows.join("\n") : null;
|
|
590
|
+
} catch (err) {
|
|
591
|
+
console.warn(`[responder] cohort history unavailable for ${item.sender}: ${err.message}`);
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
526
596
|
/**
|
|
527
597
|
* The reader half of `logInteraction`. It looked ONLY under
|
|
528
598
|
* `memory/interactions/slack/…`, so even once a Cohort exchange was written it
|
|
529
599
|
* would not have been found. Both halves are now keyed on the item's service.
|
|
600
|
+
*
|
|
601
|
+
* Cohort asks the server first (see loadCohortHistory) and falls back to the
|
|
602
|
+
* local mirror, so a transient API failure degrades to cached context rather
|
|
603
|
+
* than to none.
|
|
530
604
|
*/
|
|
531
|
-
function loadConversationHistory(item) {
|
|
605
|
+
export async function loadConversationHistory(item, deps = {}) {
|
|
532
606
|
if (!item || !item.sender || !item.service) return null;
|
|
533
607
|
|
|
534
608
|
const service = String(item.service);
|
|
609
|
+
|
|
610
|
+
if (service === "cohort") {
|
|
611
|
+
const remote = await loadCohortHistory(item, deps);
|
|
612
|
+
if (remote) return remote;
|
|
613
|
+
}
|
|
614
|
+
// Test seam: the mirror's location is a parameter, so a test can point at a
|
|
615
|
+
// tmpdir instead of mutating process.env for the whole worker.
|
|
616
|
+
const repoDir = deps.agentRepoDir || AGENT_REPO_DIR;
|
|
535
617
|
const senderSlug = item.sender.replace(/\s+/g, "-").toLowerCase();
|
|
536
618
|
const channelId = service === "slack"
|
|
537
619
|
? resolveSlackChannel(item)
|
|
538
620
|
: (item.channel_id || item.channel || null);
|
|
539
621
|
|
|
540
622
|
const candidateDirs = [];
|
|
541
|
-
if (channelId) candidateDirs.push(join(
|
|
542
|
-
candidateDirs.push(join(
|
|
623
|
+
if (channelId) candidateDirs.push(join(repoDir, "memory", "interactions", service, String(channelId)));
|
|
624
|
+
candidateDirs.push(join(repoDir, "memory", "interactions", service, `dm-${senderSlug}`));
|
|
543
625
|
|
|
544
626
|
const entries = [];
|
|
545
627
|
for (const dir of candidateDirs) {
|
|
@@ -621,7 +703,7 @@ Keep responses focused — 1-4 sentences for simple items, up to a short paragra
|
|
|
621
703
|
Match the sender's tone and urgency level.
|
|
622
704
|
${profile ? `\nSender profile:\n${profile}` : ""}`;
|
|
623
705
|
|
|
624
|
-
const conversationHistory = loadConversationHistory(item);
|
|
706
|
+
const conversationHistory = await loadConversationHistory(item);
|
|
625
707
|
|
|
626
708
|
const userContent = [
|
|
627
709
|
`From: ${item.sender} (${item.sender_privilege || "unknown"})`,
|