agent-dag 1.48.0 → 3.1.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.
@@ -18,7 +18,7 @@
18
18
  // readout the loudest producer in the application. So this is a plain poll
19
19
  // endpoint, exactly like /api/quota and /api/codex-usage already are.
20
20
  import { spawn } from "node:child_process";
21
- import { readFile } from "node:fs/promises";
21
+ import { readdir, readFile } from "node:fs/promises";
22
22
  import os from "node:os";
23
23
 
24
24
  /** CPU is the metric with spikes, so it is sampled often enough to catch one. */
@@ -41,6 +41,18 @@ let swap = null;
41
41
  const cpuHistory = [];
42
42
  let memory = null;
43
43
  let memInFlight = false;
44
+ let thermal = null;
45
+ let thermalTimer = null;
46
+ let thermalInFlight = false;
47
+ /** Consecutive readings that came back with nothing. See THERMAL_GIVE_UP. */
48
+ let thermalMisses = 0;
49
+ /** Whether this machine has EVER answered. See sampleThermal. */
50
+ let thermalEverAnswered = false;
51
+ /** Minute buckets, oldest first, for every section that keeps a history.
52
+ * See HISTORY_MINUTES. */
53
+ const history = [];
54
+ /** When sampling started, so a modal can say what "since" means. */
55
+ let historySince = 0;
44
56
 
45
57
  /** Total and idle jiffies across every core, as one pair. */
46
58
  function readTicks() {
@@ -112,13 +124,14 @@ function cpuPercent() {
112
124
  }
113
125
 
114
126
  /**
115
- * The locale every child of this module is parsed in.
127
+ * The locale every child of this module runs in — and only the part of it that
128
+ * had to be forced.
116
129
  *
117
- * Not a preference — a correctness requirement. Every command spawned here has
118
- * its output read by a regex, and every one of those regexes reads a number
119
- * with a `.` in it. `ps` and `sysctl` honour LC_NUMERIC, so on a machine set to
120
- * de_DE, fr_FR, ru_RU or pt_BR — comma is the decimal separator for most of
121
- * Europe and Latin America — the same commands print:
130
+ * Not a preference. Every command spawned here has its output read by a regex
131
+ * and every one of those regexes reads a number with a `.` in it. `ps` and
132
+ * `sysctl` honour LC_NUMERIC, so on a machine set to de_DE, fr_FR, ru_RU or
133
+ * pt_BR — comma is the decimal separator for most of Europe and Latin America —
134
+ * the same commands print:
122
135
  *
123
136
  * ps 1 0,2 0,0 /sbin/launchd
124
137
  * sysctl total = 8192,00M used = 7189,75M free = 1002,25M
@@ -126,20 +139,42 @@ function cpuPercent() {
126
139
  * and the parsers matched nothing at all. Not partially: `parsePsProcesses`
127
140
  * `continue`s on every row, so the process panel was permanently empty, and
128
141
  * `swapFromSysctl` returned null, so the macOS swap meter was permanently
129
- * blank. Silently, with nothing in the log, on a machine where everything else
130
- * worked.
142
+ * blank. Silently, on a machine where everything else worked.
131
143
  *
132
- * Forcing the locale rather than teaching the parsers to read a comma is the
133
- * fix that scales: it makes the OUTPUT invariant, which is what every parser
134
- * here was written against and what every parser added later will assume. The
135
- * comma tolerance below is defence in depth for the case where a sandbox strips
136
- * the environment, not the primary answer.
144
+ * THIS USED TO FORCE THE WHOLE LOCALE, and forcing the whole locale also forces
145
+ * the CHARACTER SET. Under `LC_ALL=C`, `ps` escapes every byte it cannot render
146
+ * as ASCII, so an application named in Cyrillic came back as
147
+ *
148
+ * M-PM-/M-PM-=M-PM-4M-PM-5M-PM-:M-QM^A M-PM^\M-QM^CM-PM-7M-QM^KM-PM-:M-PM-0
149
+ *
150
+ * where the name is `Яндекс Музыка`. The panel truncates at 164px so it read as
151
+ * noise; the process list added in #738 has room for all ninety-one characters
152
+ * of it, which is how it was found. Every reader with a non-Latin application
153
+ * on their machine was being shown that.
154
+ *
155
+ * So only the numeric is forced now, and the character set is inherited. All
156
+ * four measured on this machine:
157
+ *
158
+ * LC_ALL=C name mangled, 0.7
159
+ * LC_ALL="" LC_NUMERIC=C Яндекс Музыка, 0.7
160
+ * LANG=de_DE + our override Яндекс Музыка, 0.7 ← the comma case
161
+ * LC_ALL=de_DE + our empty LC_ALL 0.7 ← a hostile env
162
+ *
163
+ * The empty `LC_ALL` is doing real work in the last of those: POSIX ignores an
164
+ * empty LC_ALL, so clearing it is what stops a user's own LC_ALL from
165
+ * outranking the LC_NUMERIC below. Setting LC_NUMERIC alone would not survive
166
+ * it.
167
+ *
168
+ * A reader whose own locale is C still sees the escaped form — but so does
169
+ * their terminal, so that is their machine being consistent rather than this
170
+ * module choosing for them.
137
171
  *
138
172
  * Meaningless on Windows, where the branches are PowerShell piped through
139
173
  * ConvertTo-Json and already culture-invariant — and harmless there for the
140
- * same reason.
174
+ * same reason. The comma tolerance in the parsers stays as defence in depth for
175
+ * a sandbox that strips the environment, not as the primary answer.
141
176
  */
142
- const C_LOCALE = { LC_ALL: "C", LANG: "C" };
177
+ const C_LOCALE = { LC_ALL: "", LC_NUMERIC: "C" };
143
178
 
144
179
  /** Run a command and resolve its stdout, or null. Never rejects, never inherits
145
180
  * a shell, never inherits a locale, and is killed rather than allowed to hang
@@ -299,9 +334,48 @@ async function readSwap(platform = process.platform) {
299
334
  return null;
300
335
  }
301
336
 
302
- /** How many rows the process list keeps. Enough to see what is eating the
303
- * machine, few enough that the panel never becomes a scroll. */
304
- const TOP_N = 8;
337
+ /**
338
+ * How far down each ranking the payload reaches.
339
+ *
340
+ * The panel draws eight rows and decides their order on the client (#739), so
341
+ * whichever column it ranks by has to be rankable from the rows it was handed.
342
+ * `ps` returns a CPU-sorted list, and cutting that at eight and then sorting
343
+ * those eight by memory produced a table that was honest about its rows and
344
+ * wrong about its question: the machine's heaviest memory consumer need never
345
+ * have appeared in a CPU top eight at all. Same shape as #492 — a list that is
346
+ * never empty, never errors, and is not the rows being asked for.
347
+ *
348
+ * So what goes over the wire is a candidate SET rather than a ranking.
349
+ * pickCandidates takes this many by CPU and this many by memory and sends the
350
+ * union, which makes the true top eight of either column present by
351
+ * construction. Wide enough that drawing more rows later cannot quietly
352
+ * re-break that, small enough that the whole thing stays a few kilobytes of
353
+ * four-field rows, fetched only while the panel is open.
354
+ */
355
+ const CANDIDATE_N = 40;
356
+
357
+ /**
358
+ * The rows worth sending, given that the ordering happens on the client.
359
+ *
360
+ * A union of two rankings, deduplicated by object identity rather than by pid:
361
+ * parseGetProcessJson falls back to pid 0 for a row whose `Id` did not parse,
362
+ * more than one row can do that, and a pid-keyed set would drop real processes
363
+ * in order to deduplicate a placeholder.
364
+ *
365
+ * An unknown CPU sorts last here, for the same reason the column prints a dash
366
+ * rather than a zero — a Windows first reading has no percentage yet, and
367
+ * unknown is not idle and is not busiest either. Such a row still reaches the
368
+ * payload through the memory half, which is the reading that does exist on that
369
+ * pass.
370
+ */
371
+ export function pickCandidates(rows, limit = CANDIDATE_N) {
372
+ const byCpu = [...rows].sort((a, b) => (b.cpu ?? -1) - (a.cpu ?? -1) || b.mem - a.mem);
373
+ const byMem = [...rows].sort((a, b) => b.mem - a.mem || (b.cpu ?? -1) - (a.cpu ?? -1));
374
+ const out = byCpu.slice(0, limit);
375
+ const seen = new Set(out);
376
+ for (const r of byMem.slice(0, limit)) if (!seen.has(r)) out.push(r);
377
+ return out;
378
+ }
305
379
 
306
380
  /**
307
381
  * The `ps` argument list, which is not the same list on both Unixes.
@@ -349,10 +423,17 @@ export function psArgs(platform = process.platform) {
349
423
  *
350
424
  * There is no row limit in the query because neither `ps` has one and `run`
351
425
  * deliberately never inherits a shell, so there is no `| head` to pipe into.
352
- * The loop below stops at `limit` instead, which costs one parse of a string
353
- * we have already paid to read.
426
+ *
427
+ * And there is none by default here either, which is a change: the loop used to
428
+ * stop at eight, and stopping at eight is what made the memory ranking a lie
429
+ * once the panel could ask for one (#739). Selection belongs to pickCandidates,
430
+ * which cannot rank a column out of rows this function has already thrown away.
431
+ * The cost is a regex over every line of `ps` — a few hundred on a busy
432
+ * machine, once every four seconds and only while the panel is open — against a
433
+ * string that has already been read and allocated. `limit` stays for callers
434
+ * that do want a truncation, which is now only the tests.
354
435
  */
355
- export function parsePsProcesses(text, limit = TOP_N) {
436
+ export function parsePsProcesses(text, limit = Infinity) {
356
437
  const lines = String(text ?? "").trim().split("\n");
357
438
  const out = [];
358
439
  for (const line of lines.slice(1)) { // drop the header row
@@ -430,7 +511,7 @@ export function parseGetProcessJson(json, totalMem) {
430
511
  * 0-100 convention (see cpuPercent) is a different question with a different
431
512
  * answer, and the only way this drifts back is if a core count is in reach.
432
513
  */
433
- export function cpuFromDeltas(rows, prev, elapsedMs, limit = TOP_N) {
514
+ export function cpuFromDeltas(rows, prev, elapsedMs, limit = Infinity) {
434
515
  const secs = elapsedMs / 1000;
435
516
  const out = rows.map(r => {
436
517
  const before = prev instanceof Map ? prev.get(r.pid) : undefined;
@@ -460,7 +541,7 @@ let prevProcAt = 0;
460
541
 
461
542
  /** The run producing the next list, and the last one that finished. */
462
543
  let procInFlight = null;
463
- let procLast = null; // { at, procs }
544
+ let procLast = null; // { at, read: { procs, total } }
464
545
 
465
546
  /**
466
547
  * How old a finished reading may be and still answer a caller.
@@ -494,7 +575,7 @@ const PROC_MIN_GAP_MS = 1_500;
494
575
  */
495
576
  export async function readProcesses(platform = process.platform) {
496
577
  const now = Date.now();
497
- if (procLast && now - procLast.at < PROC_MIN_GAP_MS) return procLast.procs;
578
+ if (procLast && now - procLast.at < PROC_MIN_GAP_MS) return procLast.read;
498
579
  if (procInFlight) return procInFlight;
499
580
  // Only a real reading is remembered. Every failure inside readProcessesNow —
500
581
  // a spawn that never started, a non-zero exit, the timeout — resolves to an
@@ -504,9 +585,9 @@ export async function readProcesses(platform = process.platform) {
504
585
  // instead. The in-flight share still applies, so a burst arriving during a
505
586
  // failing read is one failing child, not a burst of them.
506
587
  procInFlight = readProcessesNow(platform)
507
- .then(procs => {
508
- if (procs.length) procLast = { at: Date.now(), procs };
509
- return procs;
588
+ .then(read => {
589
+ if (read.procs.length) procLast = { at: Date.now(), read };
590
+ return read;
510
591
  })
511
592
  .finally(() => { procInFlight = null; });
512
593
  return procInFlight;
@@ -521,13 +602,774 @@ async function readProcessesNow(platform) {
521
602
  if (!out) return [];
522
603
  const rows = parseGetProcessJson(out.trim(), os.totalmem());
523
604
  const now = Date.now();
524
- const result = cpuFromDeltas(rows, prevProcCpu, now - prevProcAt);
605
+ const ranked = cpuFromDeltas(rows, prevProcCpu, now - prevProcAt);
525
606
  prevProcCpu = new Map(rows.filter(r => r.cpuSec != null).map(r => [r.pid, r.cpuSec]));
526
607
  prevProcAt = now;
527
- return result;
608
+ return { procs: pickCandidates(ranked), total: ranked.length };
528
609
  }
529
610
  const out = await run("ps", psArgs(platform), 4_000);
530
- return out ? parsePsProcesses(out) : [];
611
+ if (!out) return { procs: [], total: 0 };
612
+ const all = parsePsProcesses(out);
613
+ return { procs: pickCandidates(all), total: all.length };
614
+ }
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // Thermal: is this machine getting hot, and is it being held back for it.
618
+ //
619
+ // The section the load average cannot answer. A saturated machine that is cool
620
+ // is a machine doing work; a saturated machine that is thermally limited is one
621
+ // where the next agent you launch makes everything slower, and `67.27 82.98
622
+ // 74.19` reads identically in both cases.
623
+ //
624
+ // THREE PLATFORMS ANSWER THREE DIFFERENT QUESTIONS, and on one of them the
625
+ // honest answer is not a temperature at all. Everything below was measured on
626
+ // the machines available rather than taken from documentation, and the negative
627
+ // results are recorded here because they are the reason the shape is what it
628
+ // is:
629
+ //
630
+ // Linux /sys/class/hwmon/hwmon*/temp*_input, millidegrees Celsius, with
631
+ // the chip in `name` and the sensor in `temp*_label`. A plain file
632
+ // read, exactly like /proc/meminfo — no subprocess, and the chip
633
+ // publishes its own `temp*_max` and `temp*_crit`, so the warning
634
+ // bands are the hardware's rather than ones invented here.
635
+ // /sys/class/thermal/thermal_zone*/ is the coarser fallback.
636
+ //
637
+ // macOS No CPU degrees without root, verified: `powermetrics --samplers
638
+ // smc` answers "powermetrics must be invoked as the superuser", and
639
+ // `ioreg -c AppleSMC -r -d 1` publishes no temperature key at all to
640
+ // an unprivileged process. Asking a dashboard for a password every
641
+ // ten seconds is not an option, and this deck does not ship a
642
+ // kernel driver.
643
+ //
644
+ // But the GPU driver does publish one, and nothing said so: the
645
+ // accelerator's PerformanceStatistics carries "Temperature(C)"
646
+ // beside its clock, its activity and its power. Read live on an
647
+ // Intel Mac with an AMD card — 60, 60, 61 over four seconds, from
648
+ // `ioreg -r -k PerformanceStatistics` in 51ms. Apple Silicon's
649
+ // AGXAccelerator publishes the same dictionary WITHOUT that key, so
650
+ // there the parser finds nothing and no row is drawn, which is the
651
+ // correct outcome rather than a special case.
652
+ //
653
+ // And `pmset -g therm` is unprivileged, instant, and present on
654
+ // both architectures. What it reports is not heat but the
655
+ // consequence of heat: CPU_Speed_Limit, the share of the CPU's speed
656
+ // the thermal manager is currently allowing. That is arguably the
657
+ // more useful of the two readings — a temperature is a number you
658
+ // have to interpret, a speed limit is the thing you were trying to
659
+ // interpret it into.
660
+ //
661
+ // `sysctl machdep.xcpm.cpu_thermal_level` is deliberately unused. It
662
+ // is live (33, then 42, then 41 over three seconds) but it is
663
+ // Intel-only and an undocumented scale, and printing it as though it
664
+ // were degrees would be exactly the lie this module refuses.
665
+ //
666
+ // Windows The performance counter `\Thermal Zone Information(*)\High
667
+ // Precision Temperature`, in TENTHS OF A KELVIN, read through
668
+ // Get-Counter.
669
+ //
670
+ // It is read through a counter rather than through WMI for one
671
+ // reason, and it is the reason this section never worked on Windows
672
+ // for anybody: MSAcpi_ThermalZoneTemperature lives in root\wmi and
673
+ // that namespace REQUIRES ADMINISTRATOR. A deck started from an
674
+ // ordinary terminal — which is every deck, since v1 must never need
675
+ // admin rights — got Access Denied, three times, and then gave up
676
+ // for the life of the process. The section was not missing because
677
+ // the hardware was silent; it was missing because we were asking
678
+ // somewhere we were not allowed to look.
679
+ //
680
+ // MSAcpi is still asked, second, because a deck that IS elevated can
681
+ // read it and because the two do not always agree — some boards
682
+ // publish a zone to ACPI and no counter.
683
+ //
684
+ // Genuinely absent on a large share of machines either way: a
685
+ // desktop board with no zone, and every virtual machine, which has
686
+ // no thermal hardware to report. Measured on a QEMU/SeaBIOS guest:
687
+ // the counter set is registered and answers "The specified instance
688
+ // is not present", and MSAcpi answers "Not supported" even to an
689
+ // administrator. Absent is ordinary here rather than an error.
690
+ //
691
+ // THE COUNTER PATH IS LOCALISED. `Thermal Zone Information` is the
692
+ // English name and a German or French Windows publishes its own, so
693
+ // this reaches the counter on an English install and falls through
694
+ // to MSAcpi elsewhere. Translating it means resolving a numeric
695
+ // index through the registry, which is a change with no way to be
696
+ // tested from here — see #747.
697
+ //
698
+ // NEVER INVENT A READING. No sensor means no row, and no rows at all means the
699
+ // section is not rendered: not 0°C, not a dash, not a grey empty bar. Same rule
700
+ // that keeps `cpu` null until two samples exist.
701
+
702
+ /** How often the thermal reading is refreshed. Heat moves on the scale of
703
+ * seconds, and the two platforms that cost a subprocess to ask cost 51ms
704
+ * (`ioreg`) and rather less (`pmset`), measured. Linux costs a file read. */
705
+ const THERMAL_INTERVAL_MS = 10_000;
706
+
707
+ /**
708
+ * How many consecutive empty readings before this machine is left alone.
709
+ *
710
+ * The reason is Windows, where MSAcpi_ThermalZoneTemperature is absent on a
711
+ * large share of desktops: without this, every one of those machines would pay
712
+ * a `Get-CimInstance` child every ten seconds, forever, to render a section it
713
+ * can never render. Three rather than one because a single failure can be a
714
+ * hiccup — a timeout, a machine mid-wake — and giving up on a hiccup would lose
715
+ * a reading the machine does have.
716
+ *
717
+ * Not persisted. A restart asks again, which is what should happen after the
718
+ * user installs a driver or changes a firmware setting.
719
+ */
720
+ const THERMAL_GIVE_UP = 3;
721
+
722
+ /** Bands used where the hardware publishes none of its own. Linux sensors
723
+ * carry `temp*_max` and `temp*_crit` and those win: a laptop package sensor
724
+ * and an NVMe drive do not share a comfortable range, and one scale for both
725
+ * would be a number this module made up. */
726
+ const WARN_C = 75;
727
+ const CRIT_C = 90;
728
+
729
+ /**
730
+ * How much history the panel keeps, and why it is bucketed by minute.
731
+ *
732
+ * Each section answers "what is it now"; the chart behind it answers "what did
733
+ * it do while that build was running", which is a different question and the
734
+ * reason a section opens one at all. 1440 minutes is a day, which covers "since
735
+ * the deck started" for every session anybody actually has.
736
+ *
737
+ * A bucket holds the MAXIMUM of its minute, never the mean, and that choice is
738
+ * the same one for every series here. A machine that touched 94°C for twenty
739
+ * seconds and sat at 60 for the rest of the minute averages to 66 and reads as
740
+ * calm; a load average that spiked to 114 between two quiet stretches averages
741
+ * away entirely. The spike is what somebody opens a chart to find.
742
+ *
743
+ * Kept out of systemSnapshot deliberately. That endpoint is polled every three
744
+ * seconds by a topbar meter that draws none of this; a day of buckets on every
745
+ * one of those responses would be the largest thing the deck sends, for charts
746
+ * that are usually closed. It has its own route, like the process list.
747
+ */
748
+ const HISTORY_MINUTES = 1440;
749
+ const BUCKET_MS = 60_000;
750
+
751
+ /** A reading that is not a temperature is not a misparse to be shown anyway.
752
+ * Silicon does not run below freezing or above 130°C, and both ends of that
753
+ * have been produced by reading the right file with the wrong unit. */
754
+ const plausible = c => Number.isFinite(c) && c > 0 && c < 130;
755
+
756
+ /**
757
+ * Millidegrees Celsius out of a hwmon `temp*_input`, or null.
758
+ *
759
+ * The kernel writes an integer; the divide is the whole conversion. Exported
760
+ * and pure for the reason every parser here is: a Linux answer has to be
761
+ * checkable from a Mac.
762
+ */
763
+ export function celsiusFromMilli(text) {
764
+ const n = Number(String(text ?? "").trim());
765
+ const c = Math.round(n / 1000);
766
+ return plausible(c) ? c : null;
767
+ }
768
+
769
+ /**
770
+ * Which of a machine's sensors the panel names, out of every sensor found.
771
+ *
772
+ * A real machine publishes a lot of them: the package, one per core, the NVMe
773
+ * drive, the wireless card, the chipset. Two rows is what the panel has room
774
+ * for and two rows is what somebody watching a build wants, so this picks the
775
+ * CPU and the GPU by the chip that published them and leaves the rest alone.
776
+ *
777
+ * Preference inside a chip matters as much as the chip does. coretemp exposes
778
+ * `Package id 0` beside `Core 0`..`Core N`, and the package is the reading
779
+ * — a single core's number is noisier and lower than the die it sits on.
780
+ * k10temp exposes `Tctl` and, on parts that have it, `Tdie`: Tctl is Tdie plus
781
+ * a vendor offset that exists for fan control, so Tdie is the temperature and
782
+ * Tctl is the fallback. amdgpu's `edge` is the die edge and `junction` is the
783
+ * hotspot; edge is what every other tool calls the GPU temperature.
784
+ *
785
+ * Where a chip publishes nothing recognisable, the hottest of its sensors is
786
+ * taken, because the question is "is it getting hot" and the hottest sensor is
787
+ * the one that answers it.
788
+ */
789
+ const CPU_CHIPS = ["coretemp", "k10temp", "zenpower", "cpu_thermal", "soc_thermal"];
790
+ const GPU_CHIPS = ["amdgpu", "nouveau", "i915", "xe", "radeon"];
791
+
792
+ export function pickThermalRows(sensors) {
793
+ const hottest = rows => rows.reduce((a, b) => (b.celsius > a.celsius ? b : a));
794
+ const pick = (chips, prefer, label) => {
795
+ const mine = (sensors ?? []).filter(s => chips.includes(s.chip) && plausible(s.celsius));
796
+ if (!mine.length) return null;
797
+ for (const re of prefer) {
798
+ const hit = mine.find(s => re.test(s.label ?? ""));
799
+ if (hit) return { ...hit, label };
800
+ }
801
+ return { ...hottest(mine), label };
802
+ };
803
+ return [
804
+ pick(CPU_CHIPS, [/^package id/i, /^tdie$/i, /^tctl$/i], "CPU"),
805
+ pick(GPU_CHIPS, [/^edge$/i, /^junction$/i], "GPU"),
806
+ ].filter(Boolean);
807
+ }
808
+
809
+ /**
810
+ * Every temperature sensor under /sys/class/hwmon, with the chip that owns it
811
+ * and the bands that chip publishes for it.
812
+ *
813
+ * `root` is a parameter so this can be pointed at a tree on disk. There is no
814
+ * Linux machine here and no container runtime, so the alternative would be a
815
+ * directory walk nobody has ever run — and a walk is exactly the kind of code
816
+ * that a fixture of its OUTPUT cannot check, because the walk is the part that
817
+ * is wrong.
818
+ */
819
+ export async function readHwmon(root = "/sys/class/hwmon", deps = {}) {
820
+ const dir = deps.readdir ?? readdir;
821
+ const file = deps.readFile ?? readFile;
822
+ const read = async path => { try { return String(await file(path, "utf8")).trim(); } catch { return null; } };
823
+ let chips;
824
+ try { chips = await dir(root); } catch { return []; }
825
+ const out = [];
826
+ for (const hwmon of chips) {
827
+ const base = `${root}/${hwmon}`;
828
+ const chip = (await read(`${base}/name`)) ?? hwmon;
829
+ let entries;
830
+ try { entries = await dir(base); } catch { continue; }
831
+ for (const entry of entries) {
832
+ const m = /^(temp\d+)_input$/.exec(entry);
833
+ if (!m) continue;
834
+ const celsius = celsiusFromMilli(await read(`${base}/${entry}`));
835
+ if (celsius == null) continue;
836
+ out.push({
837
+ chip,
838
+ label: await read(`${base}/${m[1]}_label`),
839
+ celsius,
840
+ // The hardware's own bands where it has them. `max` is where the chip
841
+ // says it is unhappy and `crit` is where it says it will act.
842
+ warnAt: celsiusFromMilli(await read(`${base}/${m[1]}_max`)) ?? WARN_C,
843
+ critAt: celsiusFromMilli(await read(`${base}/${m[1]}_crit`)) ?? CRIT_C,
844
+ });
845
+ }
846
+ }
847
+ return out;
848
+ }
849
+
850
+ /**
851
+ * The coarser Linux fallback, for a machine whose sensors have no hwmon driver.
852
+ *
853
+ * One row, and it is labelled with the zone's own `type` rather than "CPU",
854
+ * because a thermal zone is not a claim about what was measured. `acpitz` is
855
+ * the motherboard's idea of ambient on a lot of hardware and calling that the
856
+ * CPU would be the same lie in a different place.
857
+ */
858
+ const ZONE_ORDER = ["x86_pkg_temp", "cpu-thermal", "cpu_thermal", "soc_thermal"];
859
+
860
+ export async function readThermalZones(root = "/sys/class/thermal", deps = {}) {
861
+ const dir = deps.readdir ?? readdir;
862
+ const file = deps.readFile ?? readFile;
863
+ const read = async path => { try { return String(await file(path, "utf8")).trim(); } catch { return null; } };
864
+ let zones;
865
+ try { zones = (await dir(root)).filter(n => /^thermal_zone\d+$/.test(n)); } catch { return []; }
866
+ const found = [];
867
+ for (const zone of zones) {
868
+ const celsius = celsiusFromMilli(await read(`${root}/${zone}/temp`));
869
+ if (celsius == null) continue;
870
+ found.push({ label: (await read(`${root}/${zone}/type`)) ?? zone, celsius, warnAt: WARN_C, critAt: CRIT_C });
871
+ }
872
+ if (!found.length) return [];
873
+ const known = found.find(z => ZONE_ORDER.includes(z.label));
874
+ return [known ?? found.reduce((a, b) => (b.celsius > a.celsius ? b : a))];
875
+ }
876
+
877
+ /**
878
+ * GPU degrees out of `ioreg -r -k PerformanceStatistics`.
879
+ *
880
+ * The macOS reading nothing documented: the accelerator publishes
881
+ * "Temperature(C)" in the same dictionary as its clock and its power. The
882
+ * maximum across accelerators, because a machine with two cards is asking
883
+ * whether it is getting hot, and the hotter card is the answer.
884
+ */
885
+ export function gpuFromIoreg(text) {
886
+ let best = null;
887
+ for (const m of String(text ?? "").matchAll(/"Temperature\(C\)"\s*=\s*(-?\d+)/g)) {
888
+ const c = Number(m[1]);
889
+ if (plausible(c) && (best == null || c > best)) best = c;
890
+ }
891
+ return best;
892
+ }
893
+
894
+ /**
895
+ * The share of the CPU's speed the thermal manager is allowing, out of
896
+ * `pmset -g therm`, or null when this Mac has never recorded one.
897
+ *
898
+ * `CPU_Scheduler_Limit` sits beside it and is deliberately not read: it limits
899
+ * scheduling rather than clock, so folding the two into one percentage would
900
+ * produce a number that is neither. If scheduler throttling turns out to matter
901
+ * it is a second row, not a redefinition of this one.
902
+ */
903
+ export function throttleFromPmset(text) {
904
+ const m = /CPU_Speed_Limit\s*=\s*(\d+)/.exec(String(text ?? ""));
905
+ if (!m) return null;
906
+ const pct = Number(m[1]);
907
+ if (!Number.isFinite(pct) || pct < 0 || pct > 100) return null;
908
+ return { speedLimit: pct };
909
+ }
910
+
911
+
912
+ /**
913
+ * Windows thermal zones out of the `Thermal Zone Information` counter set.
914
+ *
915
+ * `High Precision Temperature` is in TENTHS OF A KELVIN, which is the single
916
+ * detail this branch turns on — the same unit MSAcpi uses below, and reading it
917
+ * as anything else gives a number that is plausible-looking and wrong.
918
+ *
919
+ * This is the source that works WITHOUT ADMINISTRATOR, which is the whole point
920
+ * of it: root\wmi needs elevation and a deck never has it. See the note at the
921
+ * top of this section.
922
+ *
923
+ * Shape is `[{ i: instanceName, v: cookedValue }]` — the projection the
924
+ * PowerShell one-liner makes, so this parser never has to know what a
925
+ * CounterSample looks like.
926
+ */
927
+ export const WIN_THERMAL_PS = [
928
+ "$r = [ordered]@{}",
929
+ // Get-Counter, not Get-CimInstance: this is the half that works unelevated.
930
+ "try { $r.perf = @((Get-Counter -Counter '\\Thermal Zone Information(*)\\High Precision Temperature' -EA Stop).CounterSamples | ForEach-Object { @{ i = $_.InstanceName; v = $_.CookedValue } }) } catch {}",
931
+ "if (-not $r.perf) { try { $r.acpi = @(Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -EA Stop | ForEach-Object { @{ i = $_.InstanceName; v = $_.CurrentTemperature } }) } catch {} }",
932
+ // Depth matters: the default of 2 turns the inner hashtables into the string
933
+ // "System.Collections.Hashtable" and this parser would see nothing at all.
934
+ "$r | ConvertTo-Json -Compress -Depth 4",
935
+ ].join("; ");
936
+
937
+ /**
938
+ * Whichever of the two Windows sources answered, as thermal rows.
939
+ *
940
+ * The shape is `{ perf: [...] }` or `{ acpi: [...] }` or `{}` — the PowerShell
941
+ * above only ever fills one, and fills neither on the machines where there is
942
+ * nothing to fill it with. Both lists carry the same two fields and the same
943
+ * unit, so the only thing that differs is which key they arrived under.
944
+ */
945
+ export function parseWinThermal(json) {
946
+ let r;
947
+ try { r = typeof json === "string" ? JSON.parse(json) : json; }
948
+ catch { return []; }
949
+ if (!r || typeof r !== "object") return [];
950
+ const perf = tempFromPerfCounterJson(r.perf ?? []);
951
+ if (perf.length) return perf;
952
+ return tempFromPerfCounterJson(
953
+ // MSAcpi's projection uses the same two keys, so the one parser reads both.
954
+ Array.isArray(r.acpi) ? r.acpi : (r.acpi ? [r.acpi] : []),
955
+ );
956
+ }
957
+
958
+ export function tempFromPerfCounterJson(json) {
959
+ let rows;
960
+ try { rows = typeof json === "string" ? JSON.parse(json) : json; }
961
+ catch { return []; }
962
+ if (!rows) return [];
963
+ if (!Array.isArray(rows)) rows = [rows];
964
+ const found = [];
965
+ for (const r of rows) {
966
+ const tenths = Number(r?.v);
967
+ if (!Number.isFinite(tenths)) continue;
968
+ const celsius = Math.round(tenths / 10 - 273.15);
969
+ if (!plausible(celsius)) continue;
970
+ found.push({ label: zoneLabel(r?.i), celsius, warnAt: WARN_C, critAt: CRIT_C });
971
+ }
972
+ if (found.length === 1) found[0].label = "Thermal zone";
973
+ return found.slice(0, 2);
974
+ }
975
+
976
+ /**
977
+ * A thermal zone's name, out of whatever spelling the source used.
978
+ *
979
+ * The counter names its instances `\_tz.tz00` and WMI names the same zone
980
+ * `ACPI\ThermalZone\TZ00_0`, so the tail after the last separator is the only
981
+ * part the two agree on. Upper-cased because the counter lower-cases it and a
982
+ * panel that showed `tz00` beside a `TZ01` from the other source would be
983
+ * showing one machine as two.
984
+ */
985
+ export function zoneLabel(raw) {
986
+ const tail = String(raw ?? "").split(/[\\.]/).pop() ?? "";
987
+ const name = tail.replace(/_\d+$/, "").toUpperCase();
988
+ return name || "Thermal zone";
989
+ }
990
+
991
+ /**
992
+ * Windows thermal zones out of MSAcpi_ThermalZoneTemperature.
993
+ *
994
+ * The SECOND source, and only reachable by a deck that happens to be elevated:
995
+ * root\\wmi requires administrator and refuses an ordinary terminal outright.
996
+ * Kept because some boards publish a zone to ACPI and no counter, and because a
997
+ * deck launched from an elevated shell can read it.
998
+ *
999
+ * CurrentTemperature is in tenths of a Kelvin, the same unit as the counter
1000
+ * above, and reading it as anything else gives a number that is
1001
+ * plausible-looking and wrong.
1002
+ *
1003
+ * The zone is labelled "Thermal zone" when there is one and by its own name
1004
+ * when there are several, because ACPI does not say which zone is the CPU and
1005
+ * this module does not guess. `TZ00` is not a friendly label; it is an honest
1006
+ * one, and it only appears on a machine that has more than one.
1007
+ */
1008
+ export function tempFromMsAcpiJson(json) {
1009
+ let rows;
1010
+ try { rows = typeof json === "string" ? JSON.parse(json) : json; }
1011
+ catch { return []; }
1012
+ if (!rows) return [];
1013
+ if (!Array.isArray(rows)) rows = [rows];
1014
+ const found = [];
1015
+ for (const r of rows) {
1016
+ const k = Number(r?.CurrentTemperature);
1017
+ if (!Number.isFinite(k)) continue;
1018
+ const celsius = Math.round(k / 10 - 273.15);
1019
+ if (!plausible(celsius)) continue;
1020
+ found.push({ label: zoneLabel(r?.InstanceName), celsius, warnAt: WARN_C, critAt: CRIT_C });
1021
+ }
1022
+ if (found.length === 1) found[0].label = "Thermal zone";
1023
+ return found.slice(0, 2);
1024
+ }
1025
+
1026
+ /**
1027
+ * The macOS rows, from whatever the three sources answered.
1028
+ *
1029
+ * Pure, and exported, for the reason sampleThermal's `deps.read` is: the branch
1030
+ * that matters only fires on a machine that answers with nothing, and the
1031
+ * machine this was written on answers with something. There is no other way to
1032
+ * run it.
1033
+ *
1034
+ * The ordering rule is the whole content. ioreg's GPU degrees and pmset's
1035
+ * throttle are what macOS itself gives up, and they win — they cost one cheap
1036
+ * subprocess each and they are the same numbers this deck has always shown.
1037
+ * macmon is consulted only when both were silent, and then its CPU row comes
1038
+ * first, because on the machine that reaches here the CPU is the reading
1039
+ * somebody opened the panel for.
1040
+ */
1041
+ export function darwinThermal({ gpuC = null, throttle = null, macmon = {} } = {}) {
1042
+ const celsius = [];
1043
+ if (gpuC != null) celsius.push({ label: "GPU", celsius: gpuC, warnAt: WARN_C, critAt: CRIT_C });
1044
+ else {
1045
+ if (macmon.cpu != null) celsius.push({ label: "CPU", celsius: macmon.cpu, warnAt: WARN_C, critAt: CRIT_C });
1046
+ if (macmon.gpu != null) celsius.push({ label: "GPU", celsius: macmon.gpu, warnAt: WARN_C, critAt: CRIT_C });
1047
+ }
1048
+ return celsius.length || throttle ? { celsius, throttle } : null;
1049
+ }
1050
+
1051
+ /**
1052
+ * What /api/system carries, or null when this machine says nothing at all.
1053
+ *
1054
+ * Two fields rather than one list, because they are two different readings and
1055
+ * collapsing them would let a throttle percentage be drawn under a °C heading
1056
+ * — the thing the label rule exists to prevent. `swapLabel` earned that rule
1057
+ * once already.
1058
+ */
1059
+ export async function readThermal(platform = process.platform) {
1060
+ if (platform === "linux") {
1061
+ const sensors = await readHwmon();
1062
+ const celsius = pickThermalRows(sensors);
1063
+ const rows = celsius.length ? celsius : await readThermalZones();
1064
+ return rows.length ? { celsius: rows, throttle: null } : null;
1065
+ }
1066
+
1067
+ if (platform === "darwin") {
1068
+ // Scoped by key rather than dumped whole: `ioreg -l` is 217KB and just
1069
+ // under two seconds on this machine, `-r -k PerformanceStatistics` is 83KB
1070
+ // and 51ms for the same number.
1071
+ const [gpu, therm] = await Promise.all([
1072
+ run("ioreg", ["-r", "-k", "PerformanceStatistics", "-w", "0"], 3_000),
1073
+ run("pmset", ["-g", "therm"]),
1074
+ ]);
1075
+ const gpuC = gpu ? gpuFromIoreg(gpu) : null;
1076
+ const throttle = therm ? throttleFromPmset(therm) : null;
1077
+
1078
+ // Nothing from either is every Apple Silicon Mac, and only those: the AGX
1079
+ // driver does not publish the key ioreg reads, and pmset records no speed
1080
+ // limit on M-series. Asking macmon is the only thing left, and it is asked
1081
+ // ONLY here — an Intel Mac answers above and never spawns it. See
1082
+ // macmon.mjs for why a tool the user installed is the whole of the answer.
1083
+ const macmon = gpuC == null && !throttle
1084
+ ? await (await import("./macmon.mjs")).readMacmonTemps()
1085
+ : {};
1086
+
1087
+ return darwinThermal({ gpuC, throttle, macmon });
1088
+ }
1089
+
1090
+ if (platform === "win32") {
1091
+ // One child for both sources rather than two, because the cost here is the
1092
+ // PowerShell start and not the queries: the counter is tried first because
1093
+ // it needs no administrator, and MSAcpi only when the counter said nothing.
1094
+ // Both are wrapped in their own try — "no thermal zone on this machine" is
1095
+ // the ordinary answer and arrives as a throw from either.
1096
+ const out = await run("powershell.exe", [
1097
+ "-NoProfile", "-NonInteractive", "-Command", WIN_THERMAL_PS,
1098
+ ], 6_000);
1099
+ if (!out) return null;
1100
+ const answer = parseWinThermal(out.trim());
1101
+ return answer.length ? { celsius: answer, throttle: null } : null;
1102
+ }
1103
+
1104
+ return null;
1105
+ }
1106
+
1107
+ /**
1108
+ * Fold one reading into the minute it belongs to, under a namespaced key.
1109
+ *
1110
+ * Keys are namespaced by section (`thermal:GPU`, `cpu:all`, `mem:swap`) rather
1111
+ * than kept in four rings, because they all share one clock: a bucket is a
1112
+ * minute of this machine, and every series that has something to say about that
1113
+ * minute says it in the same place. The sections sample at different rates —
1114
+ * CPU every three seconds, thermal every ten, memory every thirty — and folding
1115
+ * by maximum makes that difference invisible to the reader, which is what it
1116
+ * should be.
1117
+ */
1118
+ function record(key, value, nowMs = Date.now()) {
1119
+ if (!Number.isFinite(value)) return;
1120
+ const minute = Math.floor(nowMs / BUCKET_MS);
1121
+ let last = history[history.length - 1];
1122
+ if (!last || last.m !== minute) {
1123
+ last = { m: minute, v: {} };
1124
+ history.push(last);
1125
+ while (history.length > HISTORY_MINUTES) history.shift();
1126
+ }
1127
+ const prev = last.v[key];
1128
+ last.v[key] = prev == null ? value : Math.max(prev, value);
1129
+ }
1130
+
1131
+ /**
1132
+ * The thermal reading, whose series are not known until the machine answers.
1133
+ *
1134
+ * Keyed by the row's own label rather than by position, because the rows are
1135
+ * not the same on every platform and a machine can start reporting a sensor it
1136
+ * was not reporting before — a GPU driver loads, a laptop is docked. A series
1137
+ * that appears late simply has no points before it appeared, which is the truth
1138
+ * and draws correctly.
1139
+ */
1140
+ function recordThermal(reading, nowMs = Date.now()) {
1141
+ if (!reading) return;
1142
+ for (const r of reading.celsius ?? []) record(`thermal:${r.label}`, r.celsius, nowMs);
1143
+ // Stored as the share TAKEN AWAY, the same way the panel draws it, so the
1144
+ // chart and the row cannot disagree about which direction is bad.
1145
+ if (reading.throttle) {
1146
+ record(`thermal:${THROTTLE_LABEL}`, Math.max(0, 100 - reading.throttle.speedLimit), nowMs);
1147
+ }
1148
+ }
1149
+
1150
+ /** The one thermal row that is not degrees. Named once so the recorder, the
1151
+ * route and the panel cannot drift apart on the spelling. */
1152
+ export const THROTTLE_LABEL = "Throttling";
1153
+
1154
+ /**
1155
+ * The scale a series is drawn against.
1156
+ *
1157
+ * Fixed at 100 wherever the PANEL draws the same number against a 0-100 track,
1158
+ * because two pictures of one reading that disagree about how alarming it is
1159
+ * would be worse than either alone. Load average is the exception and gets a
1160
+ * fitted top: it is genuinely unbounded — measured at 114 on a twelve-core
1161
+ * machine — and the section that shows it draws no track at all, so there is no
1162
+ * competing picture for a fitted scale to contradict. Rounded up to something a
1163
+ * person would choose, and floored at one and a half times the core count so a
1164
+ * quiet machine is not drawn as a dramatic climb.
1165
+ */
1166
+ function loadTop(points, coreCount) {
1167
+ const peak = points.reduce((a, p) => Math.max(a, p.v), 0);
1168
+ const floor = Math.max(4, Math.ceil(coreCount * 1.5));
1169
+ const want = Math.max(floor, peak * 1.15);
1170
+ const step = want <= 20 ? 5 : want <= 100 ? 10 : 50;
1171
+ return Math.ceil(want / step) * step;
1172
+ }
1173
+
1174
+ /**
1175
+ * What each section's chart is made of.
1176
+ *
1177
+ * One entry per series, each carrying its own unit, its own bands and its own
1178
+ * scale, because a percentage, a temperature and a queue depth share nothing —
1179
+ * drawing them against one axis would invite a reading of one shape against
1180
+ * another that means nothing.
1181
+ */
1182
+ function seriesFor(group) {
1183
+ const at = key => history.filter(b => b.v[key] != null)
1184
+ // Timestamps rather than indices: a bucket only exists for a minute that was
1185
+ // sampled, so a gap — the machine asleep, the process paused — stays a gap
1186
+ // rather than becoming a straight line across it.
1187
+ .map(b => ({ t: b.m * BUCKET_MS, v: b.v[key] }));
1188
+ const coreCount = os.cpus().length;
1189
+
1190
+ if (group === "thermal") {
1191
+ const bands = new Map((thermal?.celsius ?? []).map(r => [r.label, r]));
1192
+ const labels = [];
1193
+ for (const b of history) {
1194
+ for (const k of Object.keys(b.v)) {
1195
+ if (!k.startsWith("thermal:")) continue;
1196
+ const label = k.slice("thermal:".length);
1197
+ if (!labels.includes(label)) labels.push(label);
1198
+ }
1199
+ }
1200
+ return labels.map(label => ({
1201
+ label,
1202
+ unit: label === THROTTLE_LABEL ? "%" : "C",
1203
+ top: 100,
1204
+ // Throttling is the one reading here whose normal value is zero, so it
1205
+ // is the one that does not need a full-height box to be read. Said by the
1206
+ // series rather than inferred from "has no bands", which was the first
1207
+ // rule and was wrong: CPU has no bands DELIBERATELY and uses the whole
1208
+ // scale, so it was getting the short box for a reason that is not true
1209
+ // of it.
1210
+ restsAtZero: label === THROTTLE_LABEL,
1211
+ warnAt: label === THROTTLE_LABEL ? null : (bands.get(label)?.warnAt ?? WARN_C),
1212
+ critAt: label === THROTTLE_LABEL ? null : (bands.get(label)?.critAt ?? CRIT_C),
1213
+ points: at(`thermal:${label}`),
1214
+ }));
1215
+ }
1216
+
1217
+ if (group === "cores") {
1218
+ // Not one line per core: twelve lines in a 620px dialog is a picture nobody
1219
+ // can read. These two answer what the columns cannot answer over time —
1220
+ // "all cores" at 20 with "busiest" at 100 is ONE core pinned, which is a
1221
+ // different machine from twelve at 20.
1222
+ //
1223
+ // No bands, deliberately, and the reason is written at the top of
1224
+ // SystemMeter: a CPU at 90% is the machine doing the work you asked for. An
1225
+ // indicator that alarms during the normal case teaches you to stop reading
1226
+ // it.
1227
+ return [
1228
+ { label: "All cores", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:all"), restsAtZero: false },
1229
+ { label: "Busiest core", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:busiest"), restsAtZero: false },
1230
+ ].filter(s => s.points.length);
1231
+ }
1232
+
1233
+ if (group === "memory") {
1234
+ const swapLabel = process.platform === "win32" ? "Commit" : "Swap";
1235
+ return [
1236
+ // One band, not two. A `critAt` of 100 draws a rule along the top of a
1237
+ // chart whose scale ends at 100 — it is the ceiling, drawn again in red,
1238
+ // and it says nothing the edge did not.
1239
+ { label: "Physical", unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:physical") },
1240
+ { label: swapLabel, unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:swap") },
1241
+ ].filter(s => s.points.length);
1242
+ }
1243
+
1244
+ if (group === "load") {
1245
+ // One series, not three. 1m, 5m and 15m are three views of one number —
1246
+ // the longer two are the short one smoothed — so charting the 1m over an
1247
+ // hour says everything the other two would, at the resolution they hide.
1248
+ const points = at("load:1m");
1249
+ if (!points.length) return [];
1250
+ return [{
1251
+ label: "Queued work",
1252
+ unit: "",
1253
+ top: loadTop(points, coreCount),
1254
+ // Where the queue exceeds the cores there are to run it, which is the one
1255
+ // number the section's own note already draws the line at.
1256
+ warnAt: coreCount,
1257
+ critAt: null,
1258
+ restsAtZero: false,
1259
+ points,
1260
+ }];
1261
+ }
1262
+
1263
+ return [];
1264
+ }
1265
+
1266
+ /**
1267
+ * Whether this machine has been held back AT ALL since the deck started, and
1268
+ * when it last was.
1269
+ *
1270
+ * The row reports the current sample, and on a desktop that current sample is
1271
+ * `0%` essentially always — measured here: ninety seconds of AES-NI on twelve
1272
+ * cores never moved `CPU_Speed_Limit` off 100. Which is the truth, and which
1273
+ * reads as "this readout does not work" the second time somebody looks at it.
1274
+ * It was reported that way twice.
1275
+ *
1276
+ * So the note under the row gets to say the other thing. Nothing new is
1277
+ * sampled for it: the minute buckets already hold the peak of every minute, and
1278
+ * this is a scan of what is already there. A machine that has never been
1279
+ * throttled says so; one that was at lunchtime says when.
1280
+ */
1281
+ function heldBackSoFar() {
1282
+ const key = `thermal:${THROTTLE_LABEL}`;
1283
+ let peak = 0;
1284
+ let lastMs = 0;
1285
+ for (const b of history) {
1286
+ const v = b.v[key];
1287
+ if (v == null || v <= 0) continue;
1288
+ if (v > peak) peak = v;
1289
+ lastMs = b.m * BUCKET_MS;
1290
+ }
1291
+ return peak > 0 ? { peak, lastMs } : null;
1292
+ }
1293
+
1294
+ /** What /api/system/history answers, for one section. */
1295
+ export function historySnapshot(group) {
1296
+ return { ok: true, sinceMs: historySince, stepMs: BUCKET_MS, series: seriesFor(group) };
1297
+ }
1298
+
1299
+ /**
1300
+ * One reading at a time, and a machine that cannot answer is asked three times
1301
+ * rather than for the life of the process.
1302
+ *
1303
+ * `deps.read` is a seam rather than a convenience: the rule this function
1304
+ * exists for only fires on a machine that answers with nothing, and the machine
1305
+ * this was written on answers with something, so there is no other way to run
1306
+ * the branch that matters.
1307
+ */
1308
+ export async function sampleThermal(deps = {}) {
1309
+ if (thermalInFlight) return;
1310
+ // Giving up is only ever for a machine that has NEVER answered — the Windows
1311
+ // desktop with no MSAcpi class, which would otherwise pay a PowerShell child
1312
+ // every ten seconds for the life of the process. A machine that answered once
1313
+ // has a sensor, and it keeps being asked however long the silence runs.
1314
+ if (!thermalEverAnswered && thermalMisses >= THERMAL_GIVE_UP) return;
1315
+ const read = deps.read ?? readThermal;
1316
+ thermalInFlight = true;
1317
+ try {
1318
+ const next = await read();
1319
+ if (next) { thermal = next; thermalMisses = 0; thermalEverAnswered = true; recordThermal(next); }
1320
+ else if (++thermalMisses >= THERMAL_GIVE_UP) {
1321
+ // DROP THE LAST READING. It used to be kept, and that is a number from
1322
+ // four minutes ago printed as though it were now — the one thing this
1323
+ // whole section refuses. A GPU driver unloads, a laptop is docked, a
1324
+ // sensor goes away: the honest answer is that the section stops being
1325
+ // drawn, not that it freezes.
1326
+ thermal = null;
1327
+ // But keep ASKING on a machine that has answered before. The cost
1328
+ // argument for giving up was only ever about a machine that can never
1329
+ // answer — a Windows desktop with no MSAcpi class paying a PowerShell
1330
+ // child every ten seconds forever. One that answered has a sensor, and a
1331
+ // silence is a gap rather than an absence.
1332
+ if (!thermalEverAnswered && thermalTimer) {
1333
+ clearInterval(thermalTimer);
1334
+ thermalTimer = null;
1335
+ }
1336
+ // The one machine where "nothing" is worth doing something about: an
1337
+ // Apple Silicon Mac has sensors and no way to read them, and the tool
1338
+ // that can is a 746 KB signed binary this deck can fetch. Started HERE
1339
+ // rather than at boot on purpose — the boot was just taught not to wait
1340
+ // for an install (#742) and nothing waits for this one either. One
1341
+ // attempt per process, and only after the give-up, so a machine that
1342
+ // does have a sensor never downloads anything. See macmon.mjs.
1343
+ if (!thermalEverAnswered && process.platform === "darwin") fetchMacmon(deps);
1344
+ }
1345
+ } catch { thermalMisses++; }
1346
+ finally { thermalInFlight = false; }
1347
+ }
1348
+
1349
+ /**
1350
+ * Fetch macmon, then ask again — floating, on purpose.
1351
+ *
1352
+ * Not awaited by sampleThermal, which is itself not awaited by anything: this
1353
+ * is a download that may take a minute on a slow line, and the panel it serves
1354
+ * is optional. When it lands, the give-up above has already stopped the timer,
1355
+ * so the retry has to be made here rather than waited for.
1356
+ */
1357
+ function fetchMacmon(deps = {}) {
1358
+ const boot = deps.bootstrap ?? (async () => (await import("./macmon.mjs")).bootstrapMacmon());
1359
+ Promise.resolve(boot()).then(r => {
1360
+ if (!r?.ok) return;
1361
+ // A sensor exists after all. Clear the give-up and let the timer run again,
1362
+ // which is what turns a downloaded binary into a section on screen without
1363
+ // the user restarting anything.
1364
+ thermalMisses = 0;
1365
+ if (!thermalTimer) {
1366
+ thermalTimer = setInterval(() => { sampleThermal(deps); }, THERMAL_INTERVAL_MS);
1367
+ // Unref'd like the one startSystemMetrics creates: a poll for an optional
1368
+ // panel must not be the reason a process refuses to exit.
1369
+ thermalTimer.unref?.();
1370
+ }
1371
+ sampleThermal(deps);
1372
+ }).catch(() => {});
531
1373
  }
532
1374
 
533
1375
  async function sampleMemory() {
@@ -544,6 +1386,8 @@ async function sampleMemory() {
544
1386
  // Same 30s cadence as memory, and for the same reason: it moves in minutes
545
1387
  // and costs a subprocess on two of the three platforms.
546
1388
  swap = await readSwap();
1389
+ record("mem:physical", memory.usedPct);
1390
+ if (swap && swap.total > 0) record("mem:swap", Math.round((swap.used / swap.total) * 1000) / 10);
547
1391
  } catch { /* keep the previous reading rather than blanking the meter */ }
548
1392
  finally { memInFlight = false; }
549
1393
  }
@@ -555,6 +1399,13 @@ function sampleCpu() {
555
1399
  cores = per;
556
1400
  cpuHistory.push(pct);
557
1401
  while (cpuHistory.length > HISTORY) cpuHistory.shift();
1402
+ record("cpu:all", pct);
1403
+ if (per?.length) record("cpu:busiest", Math.max(...per));
1404
+ // Free — os.loadavg() reads a kernel value, no syscall worth the name — so it
1405
+ // rides the CPU tick rather than earning a timer. Windows returns [0,0,0],
1406
+ // which is not a reading and is not recorded as one.
1407
+ const load = os.loadavg();
1408
+ if (process.platform !== "win32" && load.some(n => n > 0)) record("load:1m", Math.round(load[0] * 100) / 100);
558
1409
  }
559
1410
 
560
1411
  /**
@@ -566,16 +1417,26 @@ export function startSystemMetrics() {
566
1417
  prevTicks = readTicks(); // baseline, so the first tick has a delta
567
1418
  prevCoreTicks = readCoreTicks();
568
1419
  sampleMemory();
1420
+ historySince = Date.now();
1421
+ sampleThermal();
569
1422
  cpuTimer = setInterval(sampleCpu, CPU_INTERVAL_MS);
570
1423
  memTimer = setInterval(sampleMemory, MEM_INTERVAL_MS);
1424
+ thermalTimer = setInterval(sampleThermal, THERMAL_INTERVAL_MS);
571
1425
  cpuTimer.unref?.();
572
1426
  memTimer.unref?.();
1427
+ thermalTimer.unref?.();
573
1428
  }
574
1429
 
575
1430
  export function stopSystemMetrics() {
576
1431
  if (cpuTimer) clearInterval(cpuTimer);
577
1432
  if (memTimer) clearInterval(memTimer);
578
- cpuTimer = memTimer = null;
1433
+ if (thermalTimer) clearInterval(thermalTimer);
1434
+ cpuTimer = memTimer = thermalTimer = null;
1435
+ thermal = null;
1436
+ thermalMisses = 0;
1437
+ thermalEverAnswered = false;
1438
+ history.length = 0;
1439
+ historySince = 0;
579
1440
  prevTicks = null;
580
1441
  prevCoreTicks = null;
581
1442
  cpuHistory.length = 0;
@@ -610,6 +1471,9 @@ export function systemSnapshot() {
610
1471
  memory,
611
1472
  swap,
612
1473
  perCore: cores,
1474
+ // Null on a machine that publishes nothing, and the panel draws no section
1475
+ // at all for it rather than an empty one.
1476
+ thermal: thermal ? { ...thermal, heldBack: heldBackSoFar() } : null,
613
1477
  uptimeSec: Math.round(os.uptime()),
614
1478
  platform: process.platform,
615
1479
  loadavg: hasLoad ? load.map(n => Math.round(n * 100) / 100) : null,