ccdeck 3.29.11 → 3.31.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.
Files changed (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,520 @@
1
+ // The thermal section's sources: where each platform's degrees and its
2
+ // throttle come from, and the parsers that read them.
3
+ //
4
+ // Moved out of system-metrics.mjs unchanged. The sampler in thermal-sampler.mjs
5
+ // decides how often the machine is asked and when one that has never answered
6
+ // stops being asked (sampleThermal). This file is the asking: readThermal
7
+ // answers once per call, per platform, and nothing here keeps state between two
8
+ // calls. WARN_C and CRIT_C are exported because the history's thermal series
9
+ // fall back to them.
10
+ import { readdir, readFile } from "node:fs/promises";
11
+ import { run } from "./metrics-run.mjs";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Thermal: is this machine getting hot, and is it being held back for it.
15
+ //
16
+ // The section the load average cannot answer. A saturated machine that is cool
17
+ // is a machine doing work; a saturated machine that is thermally limited is one
18
+ // where the next agent you launch makes everything slower, and `67.27 82.98
19
+ // 74.19` reads identically in both cases.
20
+ //
21
+ // THREE PLATFORMS ANSWER THREE DIFFERENT QUESTIONS, and on one of them the
22
+ // honest answer is not a temperature at all. Everything below was measured on
23
+ // the machines available rather than taken from documentation, and the negative
24
+ // results are recorded here because they are the reason the shape is what it
25
+ // is:
26
+ //
27
+ // Linux /sys/class/hwmon/hwmon*/temp*_input, millidegrees Celsius, with
28
+ // the chip in `name` and the sensor in `temp*_label`. A plain file
29
+ // read, exactly like /proc/meminfo — no subprocess, and the chip
30
+ // publishes its own `temp*_max` and `temp*_crit`, so the warning
31
+ // bands are the hardware's rather than ones invented here.
32
+ // /sys/class/thermal/thermal_zone*/ is the coarser fallback.
33
+ //
34
+ // macOS No CPU degrees without root, verified: `powermetrics --samplers
35
+ // smc` answers "powermetrics must be invoked as the superuser", and
36
+ // `ioreg -c AppleSMC -r -d 1` publishes no temperature key at all to
37
+ // an unprivileged process. Asking a dashboard for a password every
38
+ // ten seconds is not an option, and this deck does not ship a
39
+ // kernel driver.
40
+ //
41
+ // But the GPU driver does publish one, and nothing said so: the
42
+ // accelerator's PerformanceStatistics carries "Temperature(C)"
43
+ // beside its clock, its activity and its power. Read live on an
44
+ // Intel Mac with an AMD card — 60, 60, 61 over four seconds, from
45
+ // `ioreg -r -k PerformanceStatistics` in 51ms. Apple Silicon's
46
+ // AGXAccelerator publishes the same dictionary WITHOUT that key, so
47
+ // there the parser finds nothing and no row is drawn, which is the
48
+ // correct outcome rather than a special case.
49
+ //
50
+ // And `pmset -g therm` is unprivileged, instant, and present on
51
+ // both architectures. What it reports is not heat but the
52
+ // consequence of heat: CPU_Speed_Limit, the share of the CPU's speed
53
+ // the thermal manager is currently allowing. That is arguably the
54
+ // more useful of the two readings — a temperature is a number you
55
+ // have to interpret, a speed limit is the thing you were trying to
56
+ // interpret it into.
57
+ //
58
+ // `sysctl machdep.xcpm.cpu_thermal_level` is deliberately unused. It
59
+ // is live (33, then 42, then 41 over three seconds) but it is
60
+ // Intel-only and an undocumented scale, and printing it as though it
61
+ // were degrees would be exactly the lie this module refuses.
62
+ //
63
+ // ON APPLE SILICON ioreg and pmset both come back empty, and the
64
+ // degrees come from macmon instead: the sensors sit behind a HID
65
+ // sensor hub that only native code reaches without root. It is
66
+ // asked only when those two were silent, so an Intel Mac never pays
67
+ // for it, and the deck fetches its release binary itself — see
68
+ // macmon.mjs.
69
+ //
70
+ // Windows The performance counter `\Thermal Zone Information(*)\High
71
+ // Precision Temperature`, in TENTHS OF A KELVIN, read through
72
+ // Get-Counter.
73
+ //
74
+ // It is read through a counter rather than through WMI for one
75
+ // reason, and it is the reason this section never worked on Windows
76
+ // for anybody: MSAcpi_ThermalZoneTemperature lives in root\wmi and
77
+ // that namespace REQUIRES ADMINISTRATOR. A deck started from an
78
+ // ordinary terminal — which is every deck, since v1 must never need
79
+ // admin rights — got Access Denied, three times, and then gave up
80
+ // for the life of the process. The section was not missing because
81
+ // the hardware was silent; it was missing because we were asking
82
+ // somewhere we were not allowed to look.
83
+ //
84
+ // MSAcpi is still asked, second, because a deck that IS elevated can
85
+ // read it and because the two do not always agree — some boards
86
+ // publish a zone to ACPI and no counter.
87
+ //
88
+ // Genuinely absent on a large share of machines either way: a
89
+ // desktop board with no zone, and every virtual machine, which has
90
+ // no thermal hardware to report. Measured on a QEMU/SeaBIOS guest:
91
+ // the counter set is registered and answers "The specified instance
92
+ // is not present", and MSAcpi answers "Not supported" even to an
93
+ // administrator. Absent is ordinary here rather than an error.
94
+ //
95
+ // THE COUNTER PATH IS LOCALISED. `Thermal Zone Information` is the
96
+ // English name and a German or French Windows publishes its own, so
97
+ // when the English path answers nothing the local spelling is
98
+ // resolved through Perflib's index tables (WIN_THERMAL_PS below).
99
+ // Reasoned from the documented layout, not yet run on a localised
100
+ // Windows.
101
+ //
102
+ // A MODERN INTEL LAPTOP declares no zone at all: Intel DTT manages
103
+ // its sensors and publishes them only to an administrator. There the
104
+ // degrees come from LibreHardwareMonitor's local web server when the
105
+ // user already runs it — see hwmonitor.mjs.
106
+ //
107
+ // NEVER INVENT A READING. No sensor means no row, and no rows at all means the
108
+ // section is not rendered: not 0°C, not a dash, not a grey empty bar. Same rule
109
+ // that keeps `cpu` null until two samples exist.
110
+
111
+ /** Bands used where the hardware publishes none of its own. Linux sensors
112
+ * carry `temp*_max` and `temp*_crit` and those win: a laptop package sensor
113
+ * and an NVMe drive do not share a comfortable range, and one scale for both
114
+ * would be a number this module made up. */
115
+ export const WARN_C = 75;
116
+ export const CRIT_C = 90;
117
+
118
+ /** A reading that is not a temperature is not a misparse to be shown anyway.
119
+ * Silicon does not run below freezing or above 130°C, and both ends of that
120
+ * have been produced by reading the right file with the wrong unit. */
121
+ const plausible = c => Number.isFinite(c) && c > 0 && c < 130;
122
+
123
+ /**
124
+ * Millidegrees Celsius out of a hwmon `temp*_input`, or null.
125
+ *
126
+ * The kernel writes an integer; the divide is the whole conversion. Exported
127
+ * and pure for the reason every parser here is: a Linux answer has to be
128
+ * checkable from a Mac.
129
+ */
130
+ export function celsiusFromMilli(text) {
131
+ const n = Number(String(text ?? "").trim());
132
+ const c = Math.round(n / 1000);
133
+ return plausible(c) ? c : null;
134
+ }
135
+
136
+ /**
137
+ * Which of a machine's sensors the panel names, out of every sensor found.
138
+ *
139
+ * A real machine publishes a lot of them: the package, one per core, the NVMe
140
+ * drive, the wireless card, the chipset. Two rows is what the panel has room
141
+ * for and two rows is what somebody watching a build wants, so this picks the
142
+ * CPU and the GPU by the chip that published them and leaves the rest alone.
143
+ *
144
+ * Preference inside a chip matters as much as the chip does. coretemp exposes
145
+ * `Package id 0` beside `Core 0`..`Core N`, and the package is the reading
146
+ * — a single core's number is noisier and lower than the die it sits on.
147
+ * k10temp exposes `Tctl` and, on parts that have it, `Tdie`: Tctl is Tdie plus
148
+ * a vendor offset that exists for fan control, so Tdie is the temperature and
149
+ * Tctl is the fallback. amdgpu's `edge` is the die edge and `junction` is the
150
+ * hotspot; edge is what every other tool calls the GPU temperature.
151
+ *
152
+ * Where a chip publishes nothing recognisable, the hottest of its sensors is
153
+ * taken, because the question is "is it getting hot" and the hottest sensor is
154
+ * the one that answers it.
155
+ */
156
+ const CPU_CHIPS = ["coretemp", "k10temp", "zenpower", "cpu_thermal", "soc_thermal"];
157
+ const GPU_CHIPS = ["amdgpu", "nouveau", "i915", "xe", "radeon"];
158
+
159
+ export function pickThermalRows(sensors) {
160
+ const hottest = rows => rows.reduce((a, b) => (b.celsius > a.celsius ? b : a));
161
+ const pick = (chips, prefer, label) => {
162
+ const mine = (sensors ?? []).filter(s => chips.includes(s.chip) && plausible(s.celsius));
163
+ if (!mine.length) return null;
164
+ for (const re of prefer) {
165
+ const hit = mine.find(s => re.test(s.label ?? ""));
166
+ if (hit) return { ...hit, label };
167
+ }
168
+ return { ...hottest(mine), label };
169
+ };
170
+ return [
171
+ pick(CPU_CHIPS, [/^package id/i, /^tdie$/i, /^tctl$/i], "CPU"),
172
+ pick(GPU_CHIPS, [/^edge$/i, /^junction$/i], "GPU"),
173
+ ].filter(Boolean);
174
+ }
175
+
176
+ /**
177
+ * Every temperature sensor under /sys/class/hwmon, with the chip that owns it
178
+ * and the bands that chip publishes for it.
179
+ *
180
+ * `root` is a parameter so this can be pointed at a tree on disk. There is no
181
+ * Linux machine here and no container runtime, so the alternative would be a
182
+ * directory walk nobody has ever run — and a walk is exactly the kind of code
183
+ * that a fixture of its OUTPUT cannot check, because the walk is the part that
184
+ * is wrong.
185
+ */
186
+ export async function readHwmon(root = "/sys/class/hwmon", deps = {}) {
187
+ const dir = deps.readdir ?? readdir;
188
+ const file = deps.readFile ?? readFile;
189
+ const read = async path => { try { return String(await file(path, "utf8")).trim(); } catch { return null; } };
190
+ let chips;
191
+ try { chips = await dir(root); } catch { return []; }
192
+ const out = [];
193
+ for (const hwmon of chips) {
194
+ const base = `${root}/${hwmon}`;
195
+ const chip = (await read(`${base}/name`)) ?? hwmon;
196
+ let entries;
197
+ try { entries = await dir(base); } catch { continue; }
198
+ for (const entry of entries) {
199
+ const m = /^(temp\d+)_input$/.exec(entry);
200
+ if (!m) continue;
201
+ const celsius = celsiusFromMilli(await read(`${base}/${entry}`));
202
+ if (celsius == null) continue;
203
+ const chipMax = celsiusFromMilli(await read(`${base}/${m[1]}_max`));
204
+ const chipCrit = celsiusFromMilli(await read(`${base}/${m[1]}_crit`));
205
+ out.push({
206
+ chip,
207
+ label: await read(`${base}/${m[1]}_label`),
208
+ celsius,
209
+ // The hardware's own bands where it has them. `max` is where the chip
210
+ // says it is unhappy and `crit` is where it says it will act.
211
+ //
212
+ // `max` ONLY WHEN IT IS BELOW `crit`, because on Intel coretemp the two
213
+ // files carry the same number. Measured on a live machine:
214
+ //
215
+ // coretemp temp1_input 88000 temp1_max 100000 temp1_crit 100000
216
+ // nvme temp1_input 45850 temp1_max 84850 temp1_crit 85850
217
+ //
218
+ // The nvme row is a real band and works. The coretemp row is not: with
219
+ // max === crit the amber band is zero degrees wide, so thermalTone goes
220
+ // from calm straight to hot at 100C — a number a CPU reaches only as it
221
+ // shuts the machine down. A CPU at 89C was painted the same grey as one
222
+ // at 45C, on the one row anybody reads, and the 75/90 fallback that
223
+ // would have said so was discarded precisely BECAUSE the chip published
224
+ // its own. A chip that publishes one usable threshold is telling us
225
+ // where it will act, not where to start worrying.
226
+ warnAt: chipMax != null && chipCrit != null && chipMax >= chipCrit
227
+ ? Math.min(WARN_C, Math.round(chipCrit * 0.9))
228
+ : chipMax ?? WARN_C,
229
+ critAt: chipCrit ?? CRIT_C,
230
+ });
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /**
237
+ * The coarser Linux fallback, for a machine whose sensors have no hwmon driver.
238
+ *
239
+ * One row, and it is labelled with the zone's own `type` rather than "CPU",
240
+ * because a thermal zone is not a claim about what was measured. `acpitz` is
241
+ * the motherboard's idea of ambient on a lot of hardware and calling that the
242
+ * CPU would be the same lie in a different place.
243
+ */
244
+ const ZONE_ORDER = ["x86_pkg_temp", "cpu-thermal", "cpu_thermal", "soc_thermal"];
245
+
246
+ export async function readThermalZones(root = "/sys/class/thermal", deps = {}) {
247
+ const dir = deps.readdir ?? readdir;
248
+ const file = deps.readFile ?? readFile;
249
+ const read = async path => { try { return String(await file(path, "utf8")).trim(); } catch { return null; } };
250
+ let zones;
251
+ try { zones = (await dir(root)).filter(n => /^thermal_zone\d+$/.test(n)); } catch { return []; }
252
+ const found = [];
253
+ for (const zone of zones) {
254
+ const celsius = celsiusFromMilli(await read(`${root}/${zone}/temp`));
255
+ if (celsius == null) continue;
256
+ found.push({ label: (await read(`${root}/${zone}/type`)) ?? zone, celsius, warnAt: WARN_C, critAt: CRIT_C });
257
+ }
258
+ if (!found.length) return [];
259
+ const known = found.find(z => ZONE_ORDER.includes(z.label));
260
+ return [known ?? found.reduce((a, b) => (b.celsius > a.celsius ? b : a))];
261
+ }
262
+
263
+ /**
264
+ * GPU degrees out of `ioreg -r -k PerformanceStatistics`.
265
+ *
266
+ * The macOS reading nothing documented: the accelerator publishes
267
+ * "Temperature(C)" in the same dictionary as its clock and its power. The
268
+ * maximum across accelerators, because a machine with two cards is asking
269
+ * whether it is getting hot, and the hotter card is the answer.
270
+ */
271
+ export function gpuFromIoreg(text) {
272
+ let best = null;
273
+ for (const m of String(text ?? "").matchAll(/"Temperature\(C\)"\s*=\s*(-?\d+)/g)) {
274
+ const c = Number(m[1]);
275
+ if (plausible(c) && (best == null || c > best)) best = c;
276
+ }
277
+ return best;
278
+ }
279
+
280
+ /**
281
+ * The share of the CPU's speed the thermal manager is allowing, out of
282
+ * `pmset -g therm`, or null when this Mac has never recorded one.
283
+ *
284
+ * `CPU_Scheduler_Limit` sits beside it and is deliberately not read: it limits
285
+ * scheduling rather than clock, so folding the two into one percentage would
286
+ * produce a number that is neither. If scheduler throttling turns out to matter
287
+ * it is a second row, not a redefinition of this one.
288
+ */
289
+ export function throttleFromPmset(text) {
290
+ const m = /CPU_Speed_Limit\s*=\s*(\d+)/.exec(String(text ?? ""));
291
+ if (!m) return null;
292
+ const pct = Number(m[1]);
293
+ if (!Number.isFinite(pct) || pct < 0 || pct > 100) return null;
294
+ return { speedLimit: pct };
295
+ }
296
+
297
+
298
+ /**
299
+ * Windows thermal zones out of the `Thermal Zone Information` counter set.
300
+ *
301
+ * `High Precision Temperature` is in TENTHS OF A KELVIN, which is the single
302
+ * detail this branch turns on — the same unit MSAcpi uses below, and reading it
303
+ * as anything else gives a number that is plausible-looking and wrong.
304
+ *
305
+ * This is the source that works WITHOUT ADMINISTRATOR, which is the whole point
306
+ * of it: root\wmi needs elevation and a deck never has it. See the note at the
307
+ * top of this section.
308
+ *
309
+ * Shape is `[{ i: instanceName, v: cookedValue }]` — the projection the
310
+ * PowerShell one-liner makes, so this parser never has to know what a
311
+ * CounterSample looks like.
312
+ */
313
+ export const WIN_THERMAL_PS = [
314
+ "$r = [ordered]@{}",
315
+ // Get-Counter, not Get-CimInstance: this is the half that works unelevated.
316
+ "try { $r.perf = @((Get-Counter -Counter '\\Thermal Zone Information(*)\\High Precision Temperature' -EA Stop).CounterSamples | ForEach-Object { @{ i = $_.InstanceName; v = $_.CookedValue } }) } catch {}",
317
+ // THE SAME COUNTER UNDER THE NAME THIS WINDOWS CALLS IT.
318
+ //
319
+ // Performance-counter set and counter names are LOCALIZED. That is why PDH
320
+ // ships `PdhAddEnglishCounter` beside `PdhAddCounter` at all, and
321
+ // `Get-Counter -Counter` takes a localized path: hand it the English one on a
322
+ // German, French, Japanese or Russian Windows and it answers "The specified
323
+ // object was not found on the computer." `-EA Stop` plus `catch {}` turns
324
+ // that into silence, so the line above falls straight through to MSAcpi —
325
+ // which this module's own header says needs elevation a deck never has — and
326
+ // then to LibreHardwareMonitor, which is only there if the user installed it.
327
+ // A machine with real ACPI thermal zones and a non-English display language
328
+ // therefore drew no Thermal section at all, indistinguishable from the
329
+ // modern-Intel-laptop case the section is written to tolerate.
330
+ //
331
+ // This is the class #552 fixed one module over: exec.mjs spends two screens
332
+ // on "Windows ships cmd.exe in every language it ships in, and these
333
+ // sentences are translated with it", and the C_LOCALE block in
334
+ // metrics-run.mjs is there for the same reason.
335
+ //
336
+ // Perflib is the documented map between the two spellings. `…\Perflib\009`
337
+ // holds a REG_MULTI_SZ of alternating index and ENGLISH name; the parallel
338
+ // `…\Perflib\CurrentLanguage` holds index and LOCAL name. Look the two
339
+ // English names up to get their indices, read the local names at the same
340
+ // indices, and build the path out of those.
341
+ //
342
+ // SECOND, NOT FIRST, and that is what makes it safe to add: an English
343
+ // Windows never reaches this line, because `$r.perf` is already filled. It
344
+ // can only turn a machine that was reporting nothing into one that reports
345
+ // something.
346
+ //
347
+ // NOT ONE DOUBLE QUOTE, for the reason the whole of this string has none: it
348
+ // travels as a single `-Command` argument on a Windows command line. The
349
+ // counter path is concatenated rather than interpolated so that no quoting
350
+ // form beyond the single quote is needed anywhere in it.
351
+ //
352
+ // REASONED, NOT REPRODUCED. The localization of counter names is documented
353
+ // Microsoft behaviour and the Perflib layout is documented with it, but no
354
+ // localized Windows was available to run this against, and CI's runners are
355
+ // English — so what CI proves about this line is that it parses and runs
356
+ // clean on a real Windows, not that it resolves a German counter name.
357
+ "if (-not $r.perf) { try { "
358
+ + "$en = (Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Perflib\\009' -EA Stop).Counter; "
359
+ + "$lo = (Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Perflib\\CurrentLanguage' -EA Stop).Counter; "
360
+ + "$byIndex = @{}; for ($i = 1; $i -lt $lo.Count; $i += 2) { $byIndex[$lo[$i-1]] = $lo[$i] }; "
361
+ + "$set = $null; $ctr = $null; "
362
+ + "for ($i = 1; $i -lt $en.Count; $i += 2) { "
363
+ + "if ($en[$i] -eq 'Thermal Zone Information') { $set = $byIndex[$en[$i-1]] } "
364
+ + "elseif ($en[$i] -eq 'High Precision Temperature') { $ctr = $byIndex[$en[$i-1]] } }; "
365
+ + "if ($set -and $ctr) { $r.perf = @((Get-Counter -Counter ('\\' + $set + '(*)\\' + $ctr) -EA Stop).CounterSamples "
366
+ + "| ForEach-Object { @{ i = $_.InstanceName; v = $_.CookedValue } }) } "
367
+ + "} catch {} }",
368
+ "if (-not $r.perf) { try { $r.acpi = @(Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -EA Stop | ForEach-Object { @{ i = $_.InstanceName; v = $_.CurrentTemperature } }) } catch {} }",
369
+ // Depth matters: the default of 2 turns the inner hashtables into the string
370
+ // "System.Collections.Hashtable" and this parser would see nothing at all.
371
+ "$r | ConvertTo-Json -Compress -Depth 4",
372
+ ].join("; ");
373
+
374
+ /**
375
+ * Whichever of the two Windows sources answered, as thermal rows.
376
+ *
377
+ * The shape is `{ perf: [...] }` or `{ acpi: [...] }` or `{}` — the PowerShell
378
+ * above only ever fills one, and fills neither on the machines where there is
379
+ * nothing to fill it with. Both lists carry the same two fields and the same
380
+ * unit, so the only thing that differs is which key they arrived under.
381
+ */
382
+ export function parseWinThermal(json) {
383
+ let r;
384
+ try { r = typeof json === "string" ? JSON.parse(json) : json; }
385
+ catch { return []; }
386
+ if (!r || typeof r !== "object") return [];
387
+ const perf = tempFromPerfCounterJson(r.perf ?? []);
388
+ if (perf.length) return perf;
389
+ return tempFromPerfCounterJson(
390
+ // MSAcpi's projection uses the same two keys, so the one parser reads both.
391
+ Array.isArray(r.acpi) ? r.acpi : (r.acpi ? [r.acpi] : []),
392
+ );
393
+ }
394
+
395
+ export function tempFromPerfCounterJson(json) {
396
+ let rows;
397
+ try { rows = typeof json === "string" ? JSON.parse(json) : json; }
398
+ catch { return []; }
399
+ if (!rows) return [];
400
+ if (!Array.isArray(rows)) rows = [rows];
401
+ const found = [];
402
+ for (const r of rows) {
403
+ const tenths = Number(r?.v);
404
+ if (!Number.isFinite(tenths)) continue;
405
+ const celsius = Math.round(tenths / 10 - 273.15);
406
+ if (!plausible(celsius)) continue;
407
+ found.push({ label: zoneLabel(r?.i), celsius, warnAt: WARN_C, critAt: CRIT_C });
408
+ }
409
+ if (found.length === 1) found[0].label = "Thermal zone";
410
+ return found.slice(0, 2);
411
+ }
412
+
413
+ /**
414
+ * A thermal zone's name, out of whatever spelling the source used.
415
+ *
416
+ * The counter names its instances `\_tz.tz00` and WMI names the same zone
417
+ * `ACPI\ThermalZone\TZ00_0`, so the tail after the last separator is the only
418
+ * part the two agree on. Upper-cased because the counter lower-cases it and a
419
+ * panel that showed `tz00` beside a `TZ01` from the other source would be
420
+ * showing one machine as two.
421
+ */
422
+ export function zoneLabel(raw) {
423
+ const tail = String(raw ?? "").split(/[\\.]/).pop() ?? "";
424
+ const name = tail.replace(/_\d+$/, "").toUpperCase();
425
+ return name || "Thermal zone";
426
+ }
427
+
428
+
429
+ /**
430
+ * The macOS rows, from whatever the three sources answered.
431
+ *
432
+ * Pure, and exported, for the reason sampleThermal's `deps.read` is: the branch
433
+ * that matters only fires on a machine that answers with nothing, and the
434
+ * machine this was written on answers with something. There is no other way to
435
+ * run it.
436
+ *
437
+ * The ordering rule is the whole content. ioreg's GPU degrees and pmset's
438
+ * throttle are what macOS itself gives up, and they win — they cost one cheap
439
+ * subprocess each and they are the same numbers this deck has always shown.
440
+ * macmon is consulted only when both were silent, and then its CPU row comes
441
+ * first, because on the machine that reaches here the CPU is the reading
442
+ * somebody opened the panel for.
443
+ */
444
+ export function darwinThermal({ gpuC = null, throttle = null, macmon = {} } = {}) {
445
+ const celsius = [];
446
+ if (gpuC != null) celsius.push({ label: "GPU", celsius: gpuC, warnAt: WARN_C, critAt: CRIT_C });
447
+ else {
448
+ if (macmon.cpu != null) celsius.push({ label: "CPU", celsius: macmon.cpu, warnAt: WARN_C, critAt: CRIT_C });
449
+ if (macmon.gpu != null) celsius.push({ label: "GPU", celsius: macmon.gpu, warnAt: WARN_C, critAt: CRIT_C });
450
+ }
451
+ return celsius.length || throttle ? { celsius, throttle } : null;
452
+ }
453
+
454
+ /**
455
+ * What /api/system carries, or null when this machine says nothing at all.
456
+ *
457
+ * Two fields rather than one list, because they are two different readings and
458
+ * collapsing them would let a throttle percentage be drawn under a °C heading
459
+ * — the thing the label rule exists to prevent. `swapLabel` earned that rule
460
+ * once already.
461
+ */
462
+ export async function readThermal(platform = process.platform) {
463
+ if (platform === "linux") {
464
+ const sensors = await readHwmon();
465
+ const celsius = pickThermalRows(sensors);
466
+ const rows = celsius.length ? celsius : await readThermalZones();
467
+ return rows.length ? { celsius: rows, throttle: null } : null;
468
+ }
469
+
470
+ if (platform === "darwin") {
471
+ // Scoped by key rather than dumped whole: `ioreg -l` is 217KB and just
472
+ // under two seconds on this machine, `-r -k PerformanceStatistics` is 83KB
473
+ // and 51ms for the same number.
474
+ const [gpu, therm] = await Promise.all([
475
+ run("ioreg", ["-r", "-k", "PerformanceStatistics", "-w", "0"], 3_000),
476
+ run("pmset", ["-g", "therm"]),
477
+ ]);
478
+ const gpuC = gpu ? gpuFromIoreg(gpu) : null;
479
+ const throttle = therm ? throttleFromPmset(therm) : null;
480
+
481
+ // Nothing from either is every Apple Silicon Mac, and only those: the AGX
482
+ // driver does not publish the key ioreg reads, and pmset records no speed
483
+ // limit on M-series. Asking macmon is the only thing left, and it is asked
484
+ // ONLY here — an Intel Mac answers above and never spawns it. See
485
+ // macmon.mjs for why a tool the user installed is the whole of the answer.
486
+ const macmon = gpuC == null && !throttle
487
+ ? await (await import("./macmon.mjs")).readMacmonTemps()
488
+ : {};
489
+
490
+ return darwinThermal({ gpuC, throttle, macmon });
491
+ }
492
+
493
+ if (platform === "win32") {
494
+ // One child for both sources rather than two, because the cost here is the
495
+ // PowerShell start and not the queries: the counter is tried first because
496
+ // it needs no administrator, and MSAcpi only when the counter said nothing.
497
+ // Both are wrapped in their own try — "no thermal zone on this machine" is
498
+ // the ordinary answer and arrives as a throw from either.
499
+ const out = await run("powershell.exe", [
500
+ "-NoProfile", "-NonInteractive", "-Command", WIN_THERMAL_PS,
501
+ ], 6_000);
502
+ const answer = out ? parseWinThermal(out.trim()) : [];
503
+ if (answer.length) return { celsius: answer, throttle: null };
504
+
505
+ // Windows itself had nothing, which on a modern Intel laptop is every time:
506
+ // the firmware declares no ACPI thermal zone and the sensors sit behind
507
+ // Intel DTT, which an ordinary process may not read. If something on this
508
+ // machine has already gone and got them — LibreHardwareMonitor, with its
509
+ // web server on — they are a plain HTTP read away. Never installed, never
510
+ // asked for. See hwmonitor.mjs.
511
+ const { readHwMonitorTemps } = await import("./hwmonitor.mjs");
512
+ const t = await readHwMonitorTemps();
513
+ const rows = [];
514
+ if (t.cpu != null) rows.push({ label: "CPU", celsius: t.cpu, warnAt: WARN_C, critAt: CRIT_C });
515
+ if (t.gpu != null) rows.push({ label: "GPU", celsius: t.gpu, warnAt: WARN_C, critAt: CRIT_C });
516
+ return rows.length ? { celsius: rows, throttle: null } : null;
517
+ }
518
+
519
+ return null;
520
+ }