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.
- package/README.md +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- 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
|
+
}
|