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
@@ -17,22 +17,25 @@
17
17
  // tool calls from the replay a reconnecting tab receives, and making an ambient
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
- import { spawn } from "node:child_process";
21
- import { lookup } from "node:dns/promises";
22
- import { access, readdir, readFile } from "node:fs/promises";
23
- import net from "node:net";
24
20
  import os from "node:os";
25
- import { hasClaudeInstalled } from "./claude-dir.mjs";
21
+ import { loadReading } from "./load-average.mjs";
22
+ import { BUCKET_MS, labelsUnder, oldestBucketMs, pointsOf, record, resetHistory } from "./metrics-history.mjs";
23
+ import { readAvailable, readSwap } from "./memory-metrics.mjs";
26
24
  import {
27
- classifyRoute, parseNetstatE, parseNetstatIbn, parseProcNetDev, rateBetween,
28
- routeIfaceDarwin, routeIfaceLinux, routeLabel, tailscaleExitFromStatus,
29
- } from "./network-metrics.mjs";
25
+ networkSnapshot, notePanelPoll, probeNetwork, routeChangesSince, sampleNetwork,
26
+ startNetworkTimers, stopNetwork,
27
+ } from "./network-sampler.mjs";
28
+ import { readProcesses, resetProcessList } from "./process-list.mjs";
29
+ import { CRIT_C, WARN_C } from "./thermal-metrics.mjs";
30
+ import {
31
+ lastThermal, sampleThermal, startThermalTimer, stopThermal, thermalSnapshot, THROTTLE_LABEL,
32
+ } from "./thermal-sampler.mjs";
30
33
 
31
34
  /** CPU is the metric with spikes, so it is sampled often enough to catch one. */
32
35
  const CPU_INTERVAL_MS = 3_000;
33
36
  /** Memory moves on the scale of minutes. Sampling it at the CPU cadence would
34
37
  * print the same number twenty times and, on macOS, cost a subprocess to do
35
- * it — see readMemory. */
38
+ * it — see readAvailable in memory-metrics.mjs. */
36
39
  const MEM_INTERVAL_MS = 30_000;
37
40
  /** 20 samples x 3s = the 60 seconds the sparkline draws. */
38
41
  const HISTORY = 20;
@@ -48,43 +51,41 @@ let swap = null;
48
51
  const cpuHistory = [];
49
52
  let memory = null;
50
53
  let memInFlight = false;
51
- let thermal = null;
52
- let thermalTimer = null;
53
- let thermalInFlight = false;
54
- /** Consecutive readings that came back with nothing. See THERMAL_GIVE_UP. */
55
- let thermalMisses = 0;
56
- /** Whether this machine has EVER answered. See sampleThermal. */
57
- let thermalEverAnswered = false;
58
- /** Minute buckets, oldest first, for every section that keeps a history.
59
- * See HISTORY_MINUTES. */
60
- const history = [];
61
54
  /** When sampling started, so a modal can say what "since" means. */
62
55
  let historySince = 0;
63
56
 
64
- /** Total and idle jiffies across every core, as one pair. */
57
+ /** Total and idle jiffies for one entry of `os.cpus()`. */
58
+ function ticksOf(cpu) {
59
+ let idle = 0;
60
+ let total = 0;
61
+ for (const [kind, ms] of Object.entries(cpu.times)) {
62
+ total += ms;
63
+ if (kind === "idle") idle += ms;
64
+ }
65
+ return { idle, total };
66
+ }
67
+
68
+ /**
69
+ * Total and idle jiffies across every core, as one pair.
70
+ *
71
+ * Summed core by core rather than field by field, and that is the same
72
+ * number: the counters are integer milliseconds, and a machine's total is
73
+ * nowhere near 2^53, where adding them in another order could round.
74
+ */
65
75
  function readTicks() {
66
76
  let idle = 0;
67
77
  let total = 0;
68
78
  for (const cpu of os.cpus()) {
69
- for (const [kind, ms] of Object.entries(cpu.times)) {
70
- total += ms;
71
- if (kind === "idle") idle += ms;
72
- }
79
+ const t = ticksOf(cpu);
80
+ idle += t.idle;
81
+ total += t.total;
73
82
  }
74
83
  return { idle, total };
75
84
  }
76
85
 
77
86
  /** The same pair, per core, in `os.cpus()` order. */
78
87
  function readCoreTicks() {
79
- return os.cpus().map(cpu => {
80
- let idle = 0;
81
- let total = 0;
82
- for (const [kind, ms] of Object.entries(cpu.times)) {
83
- total += ms;
84
- if (kind === "idle") idle += ms;
85
- }
86
- return { idle, total };
87
- });
88
+ return os.cpus().map(ticksOf);
88
89
  }
89
90
 
90
91
  /**
@@ -131,1456 +132,24 @@ function cpuPercent() {
131
132
  }
132
133
 
133
134
  /**
134
- * The locale every child of this module runs in — and only the part of it that
135
- * had to be forced.
136
- *
137
- * Not a preference. Every command spawned here has its output read by a regex
138
- * and every one of those regexes reads a number with a `.` in it. `ps` and
139
- * `sysctl` honour LC_NUMERIC, so on a machine set to de_DE, fr_FR, ru_RU or
140
- * pt_BR — comma is the decimal separator for most of Europe and Latin America —
141
- * the same commands print:
142
- *
143
- * ps 1 0,2 0,0 /sbin/launchd
144
- * sysctl total = 8192,00M used = 7189,75M free = 1002,25M
145
- *
146
- * and the parsers matched nothing at all. Not partially: `parsePsProcesses`
147
- * `continue`s on every row, so the process panel was permanently empty, and
148
- * `swapFromSysctl` returned null, so the macOS swap meter was permanently
149
- * blank. Silently, on a machine where everything else worked.
150
- *
151
- * THIS USED TO FORCE THE WHOLE LOCALE, and forcing the whole locale also forces
152
- * the CHARACTER SET. Under `LC_ALL=C`, `ps` escapes every byte it cannot render
153
- * as ASCII, so an application named in Cyrillic came back as
154
- *
155
- * 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
156
- *
157
- * where the name is `Яндекс Музыка`. The panel truncates at 164px so it read as
158
- * noise; the process list added in #738 has room for all ninety-one characters
159
- * of it, which is how it was found. Every reader with a non-Latin application
160
- * on their machine was being shown that.
161
- *
162
- * So only the numeric is forced now, and the character set is inherited. All
163
- * four measured on this machine:
164
- *
165
- * LC_ALL=C name mangled, 0.7
166
- * LC_ALL="" LC_NUMERIC=C Яндекс Музыка, 0.7
167
- * LANG=de_DE + our override Яндекс Музыка, 0.7 ← the comma case
168
- * LC_ALL=de_DE + our empty LC_ALL 0.7 ← a hostile env
169
- *
170
- * The empty `LC_ALL` is doing real work in the last of those: POSIX ignores an
171
- * empty LC_ALL, so clearing it is what stops a user's own LC_ALL from
172
- * outranking the LC_NUMERIC below. Setting LC_NUMERIC alone would not survive
173
- * it.
174
- *
175
- * A reader whose own locale is C still sees the escaped form — but so does
176
- * their terminal, so that is their machine being consistent rather than this
177
- * module choosing for them.
178
- *
179
- * Meaningless on Windows, where the branches are PowerShell piped through
180
- * ConvertTo-Json and already culture-invariant — and harmless there for the
181
- * same reason. The comma tolerance in the parsers stays as defence in depth for
182
- * a sandbox that strips the environment, not as the primary answer.
183
- */
184
- const C_LOCALE = { LC_ALL: "", LC_NUMERIC: "C" };
185
-
186
- /** Run a command and resolve its stdout, or null. Never rejects, never inherits
187
- * a shell, never inherits a locale, and is killed rather than allowed to hang
188
- * the sampler. */
189
- function run(file, args, timeoutMs = 2_000) {
190
- return new Promise(resolve => {
191
- let child;
192
- try {
193
- child = spawn(file, args, {
194
- windowsHide: true,
195
- env: { ...process.env, ...C_LOCALE },
196
- // stderr is PIPED AND NEVER READ, which is a deadlock waiting for a
197
- // chatty child: a pipe nobody drains fills at 64 KB and the writer
198
- // blocks there until this function's own deadline kills it. Nothing
199
- // here has ever looked at it — `run` resolves on stdout or null — so
200
- // the honest arrangement is not to open it.
201
- stdio: ["ignore", "pipe", "ignore"],
202
- });
203
- }
204
- catch { return resolve(null); }
205
- let out = "";
206
- const timer = setTimeout(() => { try { child.kill(); } catch {} resolve(null); }, timeoutMs);
207
- // DECODE THE STREAM, NOT EACH CHUNK. `out += d` on a Buffer calls toString()
208
- // per chunk, and a chunk boundary falls wherever the pipe happened to break
209
- // — measured on `ps` output as three chunks of 8192/8192/5718 — so a
210
- // multi-byte character split across two of them became two replacement
211
- // characters. An application called `Яндекс Музыка` in the process list
212
- // rendered as `Ян��екс Музыка`. setEncoding carries the partial sequence
213
- // across the boundary, which is the whole reason it exists.
214
- child.stdout?.setEncoding?.("utf8");
215
- child.stdout?.on("data", d => { out += d; });
216
- child.on("error", () => { clearTimeout(timer); resolve(null); });
217
- child.on("close", code => { clearTimeout(timer); resolve(code === 0 ? out : null); });
218
- });
219
- }
220
-
221
- /**
222
- * Bytes of memory a new process could actually get, per platform.
223
- *
224
- * `os.freemem()` is the obvious call and it is the wrong one on two of the three
225
- * platforms, because "free" and "available" are different questions. Pages
226
- * holding cached files or inactive anonymous memory are not free, but the kernel
227
- * will hand them over the moment something asks. Reporting them as used is what
228
- * makes the naive `(total - free) / total` read 99.5% on an idle 32 GB Mac — a
229
- * number that would send the reader straight to Activity Monitor, which is the
230
- * one outcome this readout exists to prevent.
231
- *
232
- * linux /proc/meminfo MemAvailable — the kernel's own answer, a file read
233
- * win32 os.freemem() already reports available physical memory
234
- * darwin vm_stat, because nothing in Node exposes the page classes
235
- *
236
- * Only darwin costs a subprocess, and only at MEM_INTERVAL_MS.
237
- */
238
- /**
239
- * Available bytes out of `/proc/meminfo` text, or null when the field is absent.
240
- *
241
- * Pure and exported for the same reason codexHome() takes a platform: a Linux
242
- * answer has to be checkable from a Mac, and the only alternative is trusting
243
- * that a regex nobody has run is right.
244
- */
245
- export function availableFromMeminfo(text) {
246
- const m = /^MemAvailable:\s+(\d+)\s*kB/m.exec(String(text ?? ""));
247
- return m ? Number(m[1]) * 1024 : null;
248
- }
249
-
250
- /**
251
- * Available bytes out of `vm_stat` output, or null when it does not parse.
252
- *
253
- * Everything the kernel can hand over without swapping: genuinely free pages,
254
- * read-ahead it can drop, inactive anonymous pages, and purgeable caches. This
255
- * is the number `os.freemem()` is missing — it reports only the first of the
256
- * four, which is why the naive formula reads ~99% on an idle 32 GB Mac.
257
- */
258
- export function availableFromVmStat(text, total) {
259
- const out = String(text ?? "");
260
- const pageSize = Number(/page size of (\d+) bytes/.exec(out)?.[1]) || 4096;
261
- const pages = name => {
262
- const m = new RegExp(`^Pages ${name}:\\s+(\\d+)`, "m").exec(out);
263
- return m ? Number(m[1]) : 0;
264
- };
265
- const reclaimable = pages("free") + pages("speculative")
266
- + pages("inactive") + pages("purgeable");
267
- if (reclaimable <= 0) return null;
268
- const avail = reclaimable * pageSize;
269
- return total != null && avail > total ? null : avail;
270
- }
271
-
272
- /**
273
- * How much memory is really available, or NULL when this machine could not be
274
- * asked.
275
- *
276
- * `os.freemem()` USED TO BE THE FALLBACK ON BOTH REAL PLATFORMS, and it is the
277
- * one number this function exists to avoid (#789). The header above says why:
278
- * counting only genuinely free pages makes the naive `(total - free) / total`
279
- * read 99.5% on an idle 32 GB Mac — "a number that would send the reader
280
- * straight to Activity Monitor, which is the one outcome this readout exists to
281
- * prevent". So a failed measurement produced exactly the reading the module was
282
- * written to suppress.
283
- *
284
- * And it did not merely flicker. `record` folds into the minute bucket by
285
- * MAXIMUM, so one failed poll painted a red 99% peak on the memory chart that
286
- * survived every good sample for the next twenty-four hours. The failure is
287
- * ordinary: `run` resolves null on a spawn error (EAGAIN/EMFILE under fork
288
- * pressure — a deck watching many agents is exactly that), on a non-zero exit,
289
- * and on its own 2s deadline. 2,880 chances a day.
290
- *
291
- * Null instead, and the caller keeps the previous reading and records nothing.
292
- * A gap in the chart is honest; a 99% peak is not.
293
- *
294
- * The last branch still answers `freemem()` because on Windows there is no
295
- * better source to have failed — it is the measurement, not a substitute for
296
- * one.
297
- */
298
- async function readAvailable(platform = process.platform) {
299
- const total = os.totalmem();
300
-
301
- if (platform === "linux") {
302
- try {
303
- const parsed = availableFromMeminfo(await readFile("/proc/meminfo", "utf8"));
304
- if (parsed != null) return parsed;
305
- } catch { /* unreadable /proc — say so rather than guessing */ }
306
- return null;
307
- }
308
-
309
- if (platform === "darwin") {
310
- const out = await run("vm_stat", []);
311
- return (out ? availableFromVmStat(out, total) : null) ?? null;
312
- }
313
-
314
- return os.freemem();
315
- }
316
-
317
- /**
318
- * Swap out of macOS `sysctl -n vm.swapusage`, which prints
319
- * `total = 14336.00M used = 12876.00M free = 1460.00M (encrypted)`.
320
- *
321
- * Swap is the reading a percentage cannot give you. A machine at "64% memory
322
- * used" that is quietly paging 12 GB to disk is not the same machine as one at
323
- * 64% with an empty swap file, and the difference is the one you can feel.
324
- */
325
- export function swapFromSysctl(text) {
326
- const unit = s => {
327
- // `,` as well as `.`: C_LOCALE should mean this never arrives, and a parser
328
- // that fails closed on a whole continent's default is not a thing to leave
329
- // resting on one environment variable. Safe to accept both here because
330
- // sysctl formats with printf's %f, which never groups thousands — so a
331
- // comma in this field can only ever be the decimal point.
332
- const m = /^([\d.,]+)([KMG])?$/i.exec(s);
333
- if (!m) return null;
334
- const mult = { k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[(m[2] || "M").toLowerCase()] ?? 1;
335
- return Math.round(Number(m[1].replace(",", ".")) * mult);
336
- };
337
- const total = unit(/total\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
338
- const used = unit(/used\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
339
- if (total == null || used == null) return null;
340
- return { total, used };
341
- }
342
-
343
- /** Swap out of `/proc/meminfo`, where it is two fields rather than one line. */
344
- export function swapFromMeminfo(text) {
345
- const s = String(text ?? "");
346
- const total = /^SwapTotal:\s+(\d+)\s*kB/m.exec(s);
347
- const free = /^SwapFree:\s+(\d+)\s*kB/m.exec(s);
348
- if (!total || !free) return null;
349
- const t = Number(total[1]) * 1024;
350
- return { total: t, used: Math.max(0, t - Number(free[1]) * 1024) };
351
- }
352
-
353
- /**
354
- * Windows has no swap file in the Unix sense; the comparable pressure signal is
355
- * commit charge, which `Win32_OperatingSystem` reports as total and free
356
- * virtual memory in KB. Labelled "commit" in the UI rather than "swap", because
357
- * calling it swap would be borrowing a word for a different mechanism.
358
- */
359
- export function swapFromWmicJson(json) {
360
- try {
361
- const o = typeof json === "string" ? JSON.parse(json) : json;
362
- const total = Number(o?.TotalVirtualMemorySize) * 1024;
363
- const free = Number(o?.FreeVirtualMemory) * 1024;
364
- if (!Number.isFinite(total) || !Number.isFinite(free) || total <= 0) return null;
365
- return { total, used: Math.max(0, total - free) };
366
- } catch { return null; }
367
- }
368
-
369
- async function readSwap(platform = process.platform) {
370
- if (platform === "darwin") {
371
- const out = await run("sysctl", ["-n", "vm.swapusage"]);
372
- return out ? swapFromSysctl(out) : null;
373
- }
374
- if (platform === "linux") {
375
- try { return swapFromMeminfo(await readFile("/proc/meminfo", "utf8")); }
376
- catch { return null; }
377
- }
378
- if (platform === "win32") {
379
- const out = await run("powershell.exe", [
380
- "-NoProfile", "-NonInteractive", "-Command",
381
- "Get-CimInstance Win32_OperatingSystem | Select-Object TotalVirtualMemorySize,FreeVirtualMemory | ConvertTo-Json -Compress",
382
- ], 4_000);
383
- return out ? swapFromWmicJson(out.trim()) : null;
384
- }
385
- return null;
386
- }
387
-
388
- /**
389
- * How far down each ranking the payload reaches.
390
- *
391
- * The panel draws eight rows and decides their order on the client (#739), so
392
- * whichever column it ranks by has to be rankable from the rows it was handed.
393
- * `ps` returns a CPU-sorted list, and cutting that at eight and then sorting
394
- * those eight by memory produced a table that was honest about its rows and
395
- * wrong about its question: the machine's heaviest memory consumer need never
396
- * have appeared in a CPU top eight at all. Same shape as #492 — a list that is
397
- * never empty, never errors, and is not the rows being asked for.
398
- *
399
- * So what goes over the wire is a candidate SET rather than a ranking.
400
- * pickCandidates takes this many by CPU and this many by memory and sends the
401
- * union, which makes the true top eight of either column present by
402
- * construction. Wide enough that drawing more rows later cannot quietly
403
- * re-break that, small enough that the whole thing stays a few kilobytes of
404
- * four-field rows, fetched only while the panel is open.
405
- */
406
- const CANDIDATE_N = 40;
407
-
408
- /**
409
- * The rows worth sending, given that the ordering happens on the client.
410
- *
411
- * A union of two rankings, deduplicated by object identity rather than by pid:
412
- * parseGetProcessJson falls back to pid 0 for a row whose `Id` did not parse,
413
- * more than one row can do that, and a pid-keyed set would drop real processes
414
- * in order to deduplicate a placeholder.
415
- *
416
- * An unknown CPU sorts last here, for the same reason the column prints a dash
417
- * rather than a zero — a Windows first reading has no percentage yet, and
418
- * unknown is not idle and is not busiest either. Such a row still reaches the
419
- * payload through the memory half, which is the reading that does exist on that
420
- * pass.
421
- */
422
- export function pickCandidates(rows, limit = CANDIDATE_N) {
423
- const byCpu = [...rows].sort((a, b) => (b.cpu ?? -1) - (a.cpu ?? -1) || b.mem - a.mem);
424
- const byMem = [...rows].sort((a, b) => b.mem - a.mem || (b.cpu ?? -1) - (a.cpu ?? -1));
425
- const out = byCpu.slice(0, limit);
426
- const seen = new Set(out);
427
- for (const r of byMem.slice(0, limit)) if (!seen.has(r)) out.push(r);
428
- return out;
429
- }
430
-
431
- /**
432
- * The `ps` argument list, which is not the same list on both Unixes.
433
- *
434
- * `-r` was shipped for both and means two different things. On BSD it sorts the
435
- * output by current CPU, which is the ordering the panel is built around. On
436
- * Linux procps it is *"restrict the selection to only running processes"* — a
437
- * filter on state `R`, applied in PID order. A Linux deck therefore listed
438
- * whatever happened to be on a CPU at the instant of the sample: usually one or
439
- * two rows on an idle machine, and never the busiest ones, since a process
440
- * pinning a core while blocked on I/O sits in `D` and one merely burning CPU
441
- * over time is normally caught in `S`. Nothing errored and nothing was empty,
442
- * which is why it survived two releases (#492).
443
- *
444
- * `--sort=-pcpu` is procps' own way to say what `-r` says on BSD. The column
445
- * order is deliberately identical on both so one parser reads both, and `comm`
446
- * stays last so a name containing a space survives intact.
447
- *
448
- * Keyed on linux rather than on darwin, because linux is the platform that is
449
- * wrong: `-r` sorts on every BSD, while `--sort` is a procps long option that
450
- * would make FreeBSD and OpenBSD exit non-zero. This way the only branch that
451
- * changes is the one that was broken.
452
- *
453
- * Pure and exported for the same reason the parsers are: the command
454
- * construction is the part that differs per platform, and a fixture cannot
455
- * prove which flags were passed to produce it.
456
- */
457
- export function psArgs(platform = process.platform) {
458
- // SEVEN FIELDS, THE SAME SEVEN ON BOTH, and `args` last because it is the one
459
- // that contains spaces. `etime` is `[[DD-]HH:]MM:SS` on both and `rss` is
460
- // kibibytes on both, so one parser still reads both — which is why the thread
461
- // count is fetched separately (readThreads) rather than as an eighth column:
462
- // procps spells it `nlwp` and BSD has no keyword for it at all, and one
463
- // divergent column would have cost the shared parser this list is built on.
464
- //
465
- // `args` REPLACES `comm`, which is a change with a cost, and redactCommand is
466
- // the payment. `comm` was chosen so that argv — and any token on it — could
467
- // not reach the panel; that made every `node`, `dotnet` and `python` on the
468
- // machine indistinguishable from every other one, which is most of what a
469
- // reader opens this list to tell apart. The argument vector is redacted and
470
- // capped before it leaves this module, and the short name is derived from
471
- // argv[0] rather than asked for a second time.
472
- if (platform === "linux") return ["-eo", "pid,pcpu,pmem,rss,etime,user:24,comm", "--sort=-pcpu"];
473
- // BSD/macOS: `-c` prints the accounting name rather than the argument vector,
474
- // and `-r` sorts by current CPU.
475
- return ["-Aceo", "pid,pcpu,pmem,rss,etime,user,comm", "-r"];
476
- }
477
-
478
- /**
479
- * The second call: how many threads each candidate has, and what it was
480
- * actually launched with.
481
- *
482
- * Two facts, one child, and it has to be a second call for two separate
483
- * reasons. The thread count has no shared spelling — procps says `nlwp` and BSD
484
- * has no keyword for it at all, only the `-M` listing — so an eighth column
485
- * would have cost the single parser psArgs is built around. And `args` cannot
486
- * sit beside `comm`: only one field in an `-o` list may contain spaces, because
487
- * only the last one is unambiguous.
488
- *
489
- * Scoped to the candidate pids rather than to the machine, which is what makes
490
- * it cheap: measured here, `ps -M -p` over forty pids is 0.15s against 0.10s
491
- * for the whole-machine call it follows. `top -stats th,ports` would have
492
- * answered both in one go and takes 4.25s on an idle machine — see readThreads'
493
- * note on why ports is not a column at all.
494
- */
495
- export function psDetailArgs(pids, platform = process.platform) {
496
- const list = pids.join(",");
497
- // procps: `nlwp` is free here, and `args` is last as ever.
498
- if (platform === "linux") return ["-o", "pid,nlwp,args", "-p", list];
499
- // BSD: `-M` lists each process followed by one line per thread, and the
500
- // process line carries the untruncated argument vector. Counting the lines is
501
- // the thread count; the process line is the argv. One child, both answers.
502
- return ["-M", "-p", list];
503
- }
504
-
505
- /**
506
- * `ps -M` output: a process line, then one line per thread, per pid.
507
- *
508
- * The two kinds are told apart by column one. A process line begins with the
509
- * owning user and a thread line begins with spaces — `ps` leaves USER, TT and
510
- * COMMAND blank on a thread because a thread has none of its own. So the count
511
- * is "lines mentioning this pid, less the one that named it", and the argv is
512
- * whatever the named line ended with.
513
- *
514
- * A kernel thread and an exiting process print a bracketed command
515
- * (`[kworker/0:1]`, `(ccusage)`); that is `ps` reporting a state and it is left
516
- * exactly as it came.
517
- */
518
- export function parsePsThreadsBsd(text) {
519
- const out = new Map();
520
- for (const line of String(text ?? "").split("\n")) {
521
- if (!line.trim()) continue;
522
- // USER PID TT %CPU STAT PRI STIME UTIME COMMAND — eight fixed fields, and
523
- // COMMAND is everything after them.
524
- const proc = /^(\S+)\s+(\d+)\s+\S+\s+[\d.,]+\s+\S+\s+\S+\s+\S+\s+\S+\s*(.*)$/.exec(line);
525
- if (proc) {
526
- if (proc[1] === "USER") continue; // the header
527
- const pid = Number(proc[2]);
528
- const row = out.get(pid) ?? { threads: 0, cmd: "" };
529
- row.cmd = proc[3].trim();
530
- out.set(pid, row);
531
- continue;
532
- }
533
- const thread = /^\s+(\d+)\s/.exec(line);
534
- if (!thread) continue;
535
- const pid = Number(thread[1]);
536
- const row = out.get(pid) ?? { threads: 0, cmd: "" };
537
- row.threads += 1;
538
- out.set(pid, row);
539
- }
540
- return out;
541
- }
542
-
543
- /** `ps -o pid,nlwp,args` output, which is two numbers and then the rest. */
544
- export function parsePsThreadsProcps(text) {
545
- const out = new Map();
546
- for (const line of String(text ?? "").split("\n")) {
547
- const m = /^\s*(\d+)\s+(\d+)\s*(.*)$/.exec(line);
548
- if (!m) continue;
549
- out.set(Number(m[1]), { threads: Number(m[2]), cmd: m[3].trim() });
550
- }
551
- return out;
552
- }
553
-
554
- /**
555
- * The pieces of a `ps` ELAPSED field, which is `[[DD-]HH:]MM:SS` on both Unixes.
556
- *
557
- * Returns seconds, or null for anything that is not that shape — a header line
558
- * that slipped through, a locale doing something unexpected. Null prints as a
559
- * dash rather than as "0s", because a process that has been up for no time and
560
- * a process whose uptime could not be read are different facts.
561
- */
562
- export function elapsedSeconds(text) {
563
- const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(String(text ?? "").trim());
564
- if (!m) return null;
565
- const [, d, h, min, sec] = m;
566
- return (Number(d ?? 0) * 86400) + (Number(h ?? 0) * 3600) + (Number(min) * 60) + Number(sec);
567
- }
568
-
569
- /**
570
- * An argument vector with the parts that must not be looked at taken out, and
571
- * the parts nobody reads cut off.
572
- *
573
- * THIS IS A BLOCKLIST AND A BLOCKLIST LEAKS. It is written down here rather
574
- * than implied, because the alternative — `comm`, which is what this replaced —
575
- * leaked nothing and told the reader nothing either. What it removes is the
576
- * shapes a secret actually takes on a command line; what it cannot remove is a
577
- * secret that looks like an ordinary word, and no rule here pretends otherwise.
578
- *
579
- * The cap is the other half, and it is not a nicety. Measured on this machine:
580
- * one Chrome helper's argv is 906 characters of `--field-trial-handle`,
581
- * base64 `--gpu-preferences` and shared-memory handles. Forty of those is 36 KB
582
- * on the wire every four seconds to render a column nobody can read. What
583
- * identifies a process is its first few arguments — `ng serve admin-portal
584
- * --port 44440` — and that is what fits.
135
+ * The process list, which lives in process-list.mjs: it is read on demand
136
+ * rather than on a timer here, and index.mjs asks this module for it.
585
137
  */
586
- export const CMD_MAX = 180;
587
-
588
- const SECRET_FLAG =
589
- /(-{1,2}[\w.-]*(?:token|password|passwd|secret|api[-_]?key|apikey|auth|credential|bearer|cookie|session[-_]?id|private[-_]?key)[\w.-]*)(=|\s+)(\S+)/gi;
590
- // The shapes that are a secret on their own, with no flag in front of them.
591
- const BARE_SECRET =
592
- /\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{12,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,})/g;
593
- // user:password@host, in any URL. The password goes; the rest is a location.
594
- const URL_CREDENTIALS = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+):[^\s@/]+@/gi;
595
-
596
- export function redactCommand(argv, home = "") {
597
- let s = String(argv ?? "").trim();
598
- if (!s) return "";
599
- s = s.replace(SECRET_FLAG, (_, flag, sep, value) => `${flag}${sep === "=" ? "=" : " "}\u2022\u2022\u2022`);
600
- s = s.replace(URL_CREDENTIALS, "$1:\u2022\u2022\u2022@");
601
- s = s.replace(BARE_SECRET, "\u2022\u2022\u2022");
602
- // `~` last, so a home path inside a redacted value is already gone. Only a
603
- // whole path segment, so a user called `con` cannot rewrite the middle of an
604
- // unrelated word.
605
- if (home && home.length > 1) s = s.split(home + "/").join("~/").split(home + " ").join("~ ");
606
- if (s.length > CMD_MAX) s = s.slice(0, CMD_MAX - 1).trimEnd() + "\u2026";
607
- return s;
608
- }
138
+ export { readProcesses };
609
139
 
610
140
  /**
611
- * The ARGUMENTS, with the executable that carries them taken off the front.
612
- *
613
- * The name column already says `Google Chrome Helper`; repeating
614
- * `/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome
615
- * Framework.framework/Versions/152.0.7977.76/Helpers/Google Chrome
616
- * Helper.app/Contents/MacOS/Google Chrome Helper` beside it spends the entire
617
- * 180-character budget on a path the reader can already see the end of, and
618
- * cuts off `--type=renderer`, which is the part that says WHICH helper this is.
619
- *
620
- * argv[0] cannot be found by splitting on whitespace — on macOS the executable
621
- * path routinely contains spaces, and `/Applications/Google Chrome.app/…` would
622
- * be cut at "Google". The name from the first `ps` call is what resolves it:
623
- * argv begins with a path whose last segment IS that name.
624
- *
625
- * "Last segment" is the whole of the rule, and the first version of this got it
626
- * wrong in a way only the render showed. A plain `indexOf` cuts at the FIRST
627
- * occurrence, and a macOS bundle contains the name twice — `…/Helpers/Google
628
- * Chrome Helper.app/Contents/MacOS/Google Chrome Helper` — so every browser row
629
- * read `.app/Contents/MacOS/Google Chrome Helper --type=gpu-process`, having
630
- * spent the budget on the half of the path it was supposed to remove. A plain
631
- * `lastIndexOf` is wrong the other way: `node /srv/node_modules/x` would cut
632
- * inside `node_modules`.
633
- *
634
- * So: the first occurrence that a path segment actually ends at — preceded by a
635
- * separator or nothing, followed by whitespace or nothing. `node /srv/app.js
636
- * --port 3000` leaves `/srv/app.js --port 3000`; `/usr/local/bin/dotnet exec
637
- * --runtimeconfig …` leaves `exec --runtimeconfig …`, which is what identifies
638
- * it.
639
- *
640
- * When the name is not in argv at all — a process that rewrote argv[0], or a
641
- * `comm` that Linux truncated at 15 characters — the first whitespace token
642
- * goes only if it looks like a path, because dropping the first word of
643
- * something already short would take the only word there was.
141
+ * The network probe, which lives in network-sampler.mjs with the rest of that
142
+ * section: re-exported because this module is where the suite reaches it.
644
143
  */
645
- export function commandTail(argv, name) {
646
- const s = String(argv ?? "").trim();
647
- if (!s) return "";
648
- const n = String(name ?? "").trim();
649
- if (n) {
650
- for (let at = s.indexOf(n); at >= 0; at = s.indexOf(n, at + 1)) {
651
- const before = at === 0 ? "" : s[at - 1];
652
- const after = s[at + n.length] ?? "";
653
- if ((before === "" || before === "/" || before === "\\" || /\s/.test(before))
654
- && (after === "" || /\s/.test(after))) {
655
- return s.slice(at + n.length).trim();
656
- }
657
- }
658
- }
659
- const first = s.split(/\s+/)[0] ?? "";
660
- return first.includes("/") ? s.slice(first.length).trim() : s;
661
- }
144
+ export { probeNetwork };
662
145
 
663
146
  /**
664
- * Rows out of `ps -o pid,pcpu,pmem,comm`, in that column order on both Unixes —
665
- * see psArgs for how each platform is asked for it.
666
- *
667
- * `pcpu` is a percentage of ONE core on both, so a multi-threaded process runs
668
- * past 100 and that is information rather than an error: 157 is one and a half
669
- * cores. cpuFromDeltas puts the Windows column on this same scale.
670
- *
671
- * There is no row limit in the query because neither `ps` has one and `run`
672
- * deliberately never inherits a shell, so there is no `| head` to pipe into.
673
- *
674
- * And there is none by default here either, which is a change: the loop used to
675
- * stop at eight, and stopping at eight is what made the memory ranking a lie
676
- * once the panel could ask for one (#739). Selection belongs to pickCandidates,
677
- * which cannot rank a column out of rows this function has already thrown away.
678
- * The cost is a regex over every line of `ps` — a few hundred on a busy
679
- * machine, once every four seconds and only while the panel is open — against a
680
- * string that has already been read and allocated. `limit` stays for callers
681
- * that do want a truncation, which is now only the tests.
147
+ * The thermal sampler, which lives in thermal-sampler.mjs with the rest of that
148
+ * section: sampleThermal re-exported because this module is where the suite
149
+ * reaches it, and THROTTLE_LABEL because the thermal chart here spells that row
150
+ * with it and the suite checks the panel's spelling against it.
682
151
  */
683
- export function parsePsProcesses(text, limit = Infinity) {
684
- const lines = String(text ?? "").trim().split("\n");
685
- const out = [];
686
- for (const line of lines.slice(1)) { // drop the header row
687
- // Both separators, for the reason swapFromSysctl gives: C_LOCALE is the
688
- // fix, and this is what keeps a stripped environment from emptying the
689
- // panel. `%CPU` and `%MEM` are percentages printed with %.1f, so a comma in
690
- // either can only be the decimal point.
691
- //
692
- // Seven fields, and only the last of them may contain a space — which is
693
- // why `comm` is last and why `args` is not here at all (psDetailArgs).
694
- // `rss` is an integer of kibibytes, `etime` and `user` cannot contain
695
- // whitespace, so the shape stays unambiguous with three more columns in it.
696
- const m = /^\s*(\d+)\s+([\d.,]+)\s+([\d.,]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.+?)\s*$/.exec(line);
697
- if (!m) continue;
698
- const num = v => Number(v.replace(",", "."));
699
- out.push({
700
- pid: Number(m[1]),
701
- cpu: num(m[2]),
702
- mem: num(m[3]),
703
- // RSS IS NOT WHAT ACTIVITY MONITOR CALLS MEMORY, and on macOS it is not
704
- // close. Measured here against `top`'s MEM on the same processes at the
705
- // same moment: `dotnet` 37 MB against 534, `rider` 1300 against 3833,
706
- // one Brave renderer 240 against 153 — ratios from 0.02 to 1.57. The
707
- // column Activity Monitor draws is `phys_footprint`, which no
708
- // unprivileged one-shot command reports; `top -stats mem` has it and
709
- // takes 4.25 seconds on an idle machine, against 0.10 for this call.
710
- // So the figure is resident set size, the panel says so, and it is the
711
- // same measurement on all three platforms rather than a different one
712
- // per OS. On Linux and Windows it is also the figure their own tools
713
- // show.
714
- rssBytes: Number(m[4]) * 1024,
715
- uptimeSec: elapsedSeconds(m[5]),
716
- user: m[6],
717
- name: m[7],
718
- });
719
- if (out.length >= limit) break;
720
- }
721
- return out;
722
- }
723
-
724
- /**
725
- * Rows out of `Get-Process`.
726
- *
727
- * NOT `Win32_PerfFormattedData_PerfProc_Process`, which is what this used and
728
- * which is not a class you may assume exists. It is published by perflib, and
729
- * perflib is deregistered often enough to matter — a corporate image, a bad
730
- * in-place upgrade, a half-run `lodctr`. On a machine reported from the field
731
- * the class was simply absent (`Get-CimInstance: Invalid class`), and `typeperf`
732
- * failed identically, which places the fault below WMI rather than in it. WMI
733
- * was only mirroring what perflib had stopped publishing.
734
- *
735
- * `Get-Process` reads through NtQuerySystemInformation instead, so it depends on
736
- * nothing that can be unregistered. The cost is that its `CPU` is total
737
- * processor SECONDS since the process started, not a rate — so a percentage has
738
- * to be derived from two readings, exactly the way the machine-wide figure is
739
- * already derived from two tick samples. Reliability is worth one extra poll:
740
- * an instant number from a class that may not exist is worth nothing at all.
741
- */
742
- export function parseGetProcessJson(json, totalMem) {
743
- let rows;
744
- try { rows = typeof json === "string" ? JSON.parse(json) : json; }
745
- catch { return []; }
746
- if (!rows) return [];
747
- if (!Array.isArray(rows)) rows = [rows];
748
- return rows
749
- .filter(r => r && r.ProcessName)
750
- .map(r => ({
751
- pid: Number(r.Id) || 0,
752
- name: String(r.ProcessName),
753
- // Null rather than 0 when the process denies the read: a system process
754
- // we cannot query has an unknown CPU time, and calling that zero would
755
- // rank it as idle.
756
- cpuSec: typeof r.CPU === "number" ? r.CPU : null,
757
- mem: totalMem > 0
758
- ? Math.round((Number(r.WorkingSetPrivate) || 0) / totalMem * 1000) / 10
759
- : 0,
760
- // The same three optional fields the POSIX branch attaches, so one client
761
- // shape covers both. Absent rather than zero when the value did not come
762
- // back: `StartTime` throws for a process this session may not open, and
763
- // `Threads.Count` is undefined on a row that failed to project.
764
- //
765
- // `typeof` rather than `Number.isFinite(Number(…))` on the uptime, and the
766
- // difference is the whole of that rule: `Number(null)` is 0, and 0 is
767
- // finite, so the projection's own `else{$null}` — the branch it takes for
768
- // every process whose StartTime this session may not read — arrived as an
769
- // uptime of ZERO SECONDS. Every protected process on the machine was
770
- // reported to the panel as having started this instant, which is the
771
- // reading the comment above says must never be invented. A genuine zero
772
- // is still kept: `[int]` of a sub-second TimeSpan is 0, and a process that
773
- // started this instant is a real answer when it is the real answer. Same
774
- // guard the CPU field two lines up already used, for the same reason.
775
- ...(Number.isFinite(Number(r.Threads)) && Number(r.Threads) > 0 ? { threads: Number(r.Threads) } : {}),
776
- ...(typeof r.StartedAt === "number" && Number.isFinite(r.StartedAt) ? { uptimeSec: r.StartedAt } : {}),
777
- rssBytes: Number(r.WorkingSet) || 0,
778
- }));
779
- }
780
-
781
- /**
782
- * Turn two `Get-Process` readings into a percentage per process.
783
- *
784
- * `prev` maps pid to the cpuSec of the previous reading. A pid absent from it —
785
- * a process that started since — has no delta and reports null rather than a
786
- * number invented from its whole lifetime, which would rank a freshly spawned
787
- * compiler as though it had been burning a core since boot.
788
- *
789
- * Per core, NOT per machine, because that is what the column beside it means:
790
- * `ps -o pcpu` is a percentage of one core on both Unixes and is reported
791
- * unmodified, so a row reading 157 there is a process using one and a half
792
- * cores. This used to divide by the core count and clamp to 100 on the reasoning
793
- * that Unix reported 0-100 — it does not, and never did, so the normalisation
794
- * corrected a scale that already matched and introduced the mismatch it was
795
- * written to prevent: on a 12-core machine six busy cores read 600 on macOS and
796
- * 50 on Windows (#493). One CPU-second burned per wall-second is 100 here, on
797
- * every platform.
798
- *
799
- * Core count is deliberately not a parameter any more. The aggregate meter's
800
- * 0-100 convention (see cpuPercent) is a different question with a different
801
- * answer, and the only way this drifts back is if a core count is in reach.
802
- *
803
- * THE ROW IS CARRIED, NOT REBUILT, and that is the half this used to get wrong
804
- * (#954). It listed the four fields it knew about — pid, cpu, mem, name — so
805
- * `threads`, `uptimeSec` and `rssBytes` were silently dropped on their way
806
- * through, and this is the only step the Windows reading takes: readProcessesNow
807
- * returns straight out of the win32 branch, on the other side of the
808
- * `attachDetail` call that puts those three back on POSIX. Every Windows row
809
- * therefore reached the panel with three of its seven columns missing, always,
810
- * and the memory header — which sorts on `rssBytes` and treats `undefined` as a
811
- * missing reading — reordered nothing when clicked, because every row was
812
- * missing it. Invisible to anyone developing on macOS or Linux.
813
- *
814
- * `cpuSec` is the one field deliberately removed: it is the raw counter this
815
- * function exists to turn into a rate, the POSIX rows never carry one, and
816
- * shipping it would put two spellings of the same quantity on the wire.
817
- */
818
- export function cpuFromDeltas(rows, prev, elapsedMs, limit = Infinity) {
819
- const secs = elapsedMs / 1000;
820
- const out = rows.map(r => {
821
- const before = prev instanceof Map ? prev.get(r.pid) : undefined;
822
- let cpu = null;
823
- if (r.cpuSec != null && before != null && secs > 0) {
824
- const d = r.cpuSec - before;
825
- // A counter that went backwards means the pid was reused by a different
826
- // process; report nothing rather than a negative or a wild number.
827
- if (d >= 0) cpu = Math.round((d / secs) * 1000) / 10;
828
- }
829
- const { cpuSec: _raw, ...rest } = r;
830
- return { ...rest, cpu };
831
- });
832
- // Until the second reading lands there is no CPU to sort on, so the list is
833
- // ordered by memory — which is a real answer to "what is this machine doing",
834
- // not a placeholder.
835
- const haveCpu = out.some(r => r.cpu != null);
836
- out.sort(haveCpu
837
- ? (a, b) => (b.cpu ?? -1) - (a.cpu ?? -1)
838
- : (a, b) => b.mem - a.mem);
839
- return out.slice(0, limit);
840
- }
841
-
842
- /** Previous Windows reading, so the next one can be a rate. Cleared with the
843
- * rest of the sampler state. */
844
- let prevProcCpu = null;
845
- let prevProcAt = 0;
846
-
847
- /** The run producing the next list, and the last one that finished. */
848
- let procInFlight = null;
849
- let procLast = null; // { at, read: { procs, total }, detail }
850
- let procInFlightDetail = false;
851
-
852
- /**
853
- * How old a finished reading may be and still answer a caller.
854
- *
855
- * Well under MachinePanel's PROC_POLL_MS of 4000, so the panel that this exists
856
- * for never once gets a cached list; long enough that a second tab, a second
857
- * browser, or anything else arriving between two of those polls is handed the
858
- * list the first tab is already looking at rather than starting its own child.
859
- */
860
- const PROC_MIN_GAP_MS = 1_500;
861
-
862
- /**
863
- * The process list, on demand only — never on the ambient timer, and never more
864
- * than one child at a time.
865
- *
866
- * #544: /api/system/processes is a GET with no cache, no dedupe and no
867
- * throttle, so the number of `powershell.exe Get-Process` children — about six
868
- * seconds each — was whatever the caller asked for. That is the cheap half of
869
- * the problem. The expensive half is that concurrent readers also overwrote
870
- * each other's baseline: cpuFromDeltas needs the PREVIOUS reading's cpuSec per
871
- * pid, prevProcCpu/prevProcAt are one shared pair, and two readers each stored
872
- * theirs over the other's, so the CPU column came back computed against a
873
- * baseline that belonged to somebody else's reading. That is a wrong number on
874
- * screen, not merely wasted work, and it needed no attacker at all: one
875
- * Get-Process takes longer than the panel's four-second poll, so a single tab
876
- * on Windows already overlapped itself.
877
- *
878
- * One in-flight run fixes both, because one reader means one baseline. Callers
879
- * that arrive while a run is going share its promise; callers that arrive just
880
- * after one finished are served that reading.
881
- */
882
- export async function readProcesses(platform = process.platform, detail = false) {
883
- const now = Date.now();
884
- // A cached reading serves a caller that wants LESS than it holds, never one
885
- // that wants more: the panel is happy with a detailed reading, and the modal
886
- // opening onto a plain one would show four empty columns for up to a poll.
887
- if (procLast && now - procLast.at < PROC_MIN_GAP_MS && (procLast.detail || !detail)) return procLast.read;
888
- if (procInFlight && (procInFlightDetail || !detail)) return procInFlight;
889
- // Only a real reading is remembered. Every failure inside readProcessesNow —
890
- // a spawn that never started, a non-zero exit, the timeout — resolves to an
891
- // empty array, and no machine has nothing running on it, so an empty list is
892
- // a failure by construction. Serving one for the next 1.5s would turn a
893
- // single hiccup into a blank panel that outlives it; the next caller retries
894
- // instead. The in-flight share still applies, so a burst arriving during a
895
- // failing read is one failing child, not a burst of them.
896
- procInFlightDetail = detail;
897
- procInFlight = readProcessesNow(platform, detail)
898
- .then(read => {
899
- if (read.procs.length) procLast = { at: Date.now(), read, detail };
900
- return read;
901
- })
902
- .finally(() => { procInFlight = null; procInFlightDetail = false; });
903
- return procInFlight;
904
- }
905
-
906
- /**
907
- * The one-liner the Windows process list is read with.
908
- *
909
- * Threads and StartTime ride along on the call that was already being made —
910
- * `Get-Process` has both, so the two columns cost nothing here. `WorkingSet64`
911
- * joins `PrivateMemorySize64` rather than replacing it: the percentage has
912
- * always been computed from private bytes and moving it would change a number
913
- * that is on screen today, while the new column wants the resident figure Task
914
- * Manager itself shows.
915
- *
916
- * NOT `-IncludeUserName`: it requires an elevated session, and no part of this
917
- * deck may ask for one. The user column is simply absent on Windows, which is
918
- * the same rule the thermal section keeps.
919
- *
920
- * A named export for the reason WIN_THERMAL_PS is one, and #954 is why it
921
- * became one: three of the seven columns this projection is built to fill were
922
- * being dropped after it, and every assertion in the suite was made against a
923
- * payload somebody typed. A constant can be handed to the real powershell.exe
924
- * on the Windows leg of the matrix, which is the only place the claim "Windows
925
- * really does answer with these seven keys" can be measured rather than
926
- * reasoned about.
927
- */
928
- export const WIN_PROCESS_PS =
929
- "Get-Process | Select-Object Id,ProcessName,CPU,@{n='Threads';e={$_.Threads.Count}},@{n='StartedAt';e={if($_.StartTime){[int]((Get-Date)-$_.StartTime).TotalSeconds}else{$null}}},@{n='WorkingSet';e={$_.WorkingSet64}},@{n='WorkingSetPrivate';e={$_.PrivateMemorySize64}} | ConvertTo-Json -Compress";
930
-
931
- async function readProcessesNow(platform, detail = false) {
932
- if (platform === "win32") {
933
- const out = await run("powershell.exe", [
934
- "-NoProfile", "-NonInteractive", "-Command", WIN_PROCESS_PS,
935
- ], 6_000);
936
- // The same shape the POSIX branch below returns, and not a bare array: the
937
- // caller reads `read.procs.length` to decide whether this was a real
938
- // reading, so an array meant a TypeError rather than an empty list. Every
939
- // Windows failure — a non-zero exit, a spawn that never started, the 6s
940
- // deadline this module's own comment says the runtime sits right on —
941
- // therefore answered `/api/system/processes` with a 500 and a logged stack,
942
- // every four seconds for as long as the machine panel was open.
943
- if (!out) return { procs: [], total: 0 };
944
- const rows = parseGetProcessJson(out.trim(), os.totalmem());
945
- const now = Date.now();
946
- const ranked = cpuFromDeltas(rows, prevProcCpu, now - prevProcAt);
947
- prevProcCpu = new Map(rows.filter(r => r.cpuSec != null).map(r => [r.pid, r.cpuSec]));
948
- prevProcAt = now;
949
- return { procs: pickCandidates(ranked), total: ranked.length };
950
- }
951
- const out = await run("ps", psArgs(platform), 4_000);
952
- if (!out) return { procs: [], total: 0 };
953
- const all = parsePsProcesses(out);
954
- const procs = pickCandidates(all);
955
- if (detail) await attachDetail(procs, platform);
956
- return { procs, total: all.length };
957
- }
958
-
959
- /**
960
- * Fill in threads and the command tail, for the candidates and no further.
961
- *
962
- * AFTER pickCandidates, deliberately: this is a per-pid listing, and asking it
963
- * about every process on the machine would be several hundred pids to answer a
964
- * question about forty. The candidates are what any surface can draw, so they
965
- * are what gets the second child.
966
- *
967
- * A failure here is not a failure of the reading. `ps -M` can lose a race with
968
- * a process that exited between the two calls, a hardened environment can
969
- * refuse it, and neither is a reason to blank a list whose CPU and memory
970
- * columns are already correct. The fields are simply absent, and the columns
971
- * that read them print a dash — the same rule the rest of this module keeps:
972
- * an unknown is never rendered as a zero.
973
- */
974
- async function attachDetail(procs, platform) {
975
- if (!procs.length) return;
976
- const pids = procs.map(p => p.pid).filter(n => Number.isInteger(n) && n > 0);
977
- if (!pids.length) return;
978
- let out;
979
- try { out = await run("ps", psDetailArgs(pids, platform), 4_000); }
980
- catch { return; }
981
- if (!out) return;
982
- const detail = platform === "linux" ? parsePsThreadsProcps(out) : parsePsThreadsBsd(out);
983
- const home = os.homedir?.() ?? "";
984
- for (const p of procs) {
985
- const d = detail.get(p.pid);
986
- if (!d) continue;
987
- // Zero threads is not a reading. Every process has at least one, so a zero
988
- // here means the listing named the pid and gave no thread lines for it —
989
- // a race with an exit — and a `0` in that column would be a claim.
990
- if (d.threads > 0) p.threads = d.threads;
991
- // REDACTED BEFORE IT LEAVES THIS MODULE, not on the way to the screen.
992
- // /api/system/processes is served to anything that can reach the loopback
993
- // port, and a token that only the client hides is a token that shipped.
994
- const cmd = redactCommand(commandTail(d.cmd, p.name), home);
995
- if (cmd) p.cmd = cmd;
996
- }
997
- }
998
-
999
- // ---------------------------------------------------------------------------
1000
- // Thermal: is this machine getting hot, and is it being held back for it.
1001
- //
1002
- // The section the load average cannot answer. A saturated machine that is cool
1003
- // is a machine doing work; a saturated machine that is thermally limited is one
1004
- // where the next agent you launch makes everything slower, and `67.27 82.98
1005
- // 74.19` reads identically in both cases.
1006
- //
1007
- // THREE PLATFORMS ANSWER THREE DIFFERENT QUESTIONS, and on one of them the
1008
- // honest answer is not a temperature at all. Everything below was measured on
1009
- // the machines available rather than taken from documentation, and the negative
1010
- // results are recorded here because they are the reason the shape is what it
1011
- // is:
1012
- //
1013
- // Linux /sys/class/hwmon/hwmon*/temp*_input, millidegrees Celsius, with
1014
- // the chip in `name` and the sensor in `temp*_label`. A plain file
1015
- // read, exactly like /proc/meminfo — no subprocess, and the chip
1016
- // publishes its own `temp*_max` and `temp*_crit`, so the warning
1017
- // bands are the hardware's rather than ones invented here.
1018
- // /sys/class/thermal/thermal_zone*/ is the coarser fallback.
1019
- //
1020
- // macOS No CPU degrees without root, verified: `powermetrics --samplers
1021
- // smc` answers "powermetrics must be invoked as the superuser", and
1022
- // `ioreg -c AppleSMC -r -d 1` publishes no temperature key at all to
1023
- // an unprivileged process. Asking a dashboard for a password every
1024
- // ten seconds is not an option, and this deck does not ship a
1025
- // kernel driver.
1026
- //
1027
- // But the GPU driver does publish one, and nothing said so: the
1028
- // accelerator's PerformanceStatistics carries "Temperature(C)"
1029
- // beside its clock, its activity and its power. Read live on an
1030
- // Intel Mac with an AMD card — 60, 60, 61 over four seconds, from
1031
- // `ioreg -r -k PerformanceStatistics` in 51ms. Apple Silicon's
1032
- // AGXAccelerator publishes the same dictionary WITHOUT that key, so
1033
- // there the parser finds nothing and no row is drawn, which is the
1034
- // correct outcome rather than a special case.
1035
- //
1036
- // And `pmset -g therm` is unprivileged, instant, and present on
1037
- // both architectures. What it reports is not heat but the
1038
- // consequence of heat: CPU_Speed_Limit, the share of the CPU's speed
1039
- // the thermal manager is currently allowing. That is arguably the
1040
- // more useful of the two readings — a temperature is a number you
1041
- // have to interpret, a speed limit is the thing you were trying to
1042
- // interpret it into.
1043
- //
1044
- // `sysctl machdep.xcpm.cpu_thermal_level` is deliberately unused. It
1045
- // is live (33, then 42, then 41 over three seconds) but it is
1046
- // Intel-only and an undocumented scale, and printing it as though it
1047
- // were degrees would be exactly the lie this module refuses.
1048
- //
1049
- // Windows The performance counter `\Thermal Zone Information(*)\High
1050
- // Precision Temperature`, in TENTHS OF A KELVIN, read through
1051
- // Get-Counter.
1052
- //
1053
- // It is read through a counter rather than through WMI for one
1054
- // reason, and it is the reason this section never worked on Windows
1055
- // for anybody: MSAcpi_ThermalZoneTemperature lives in root\wmi and
1056
- // that namespace REQUIRES ADMINISTRATOR. A deck started from an
1057
- // ordinary terminal — which is every deck, since v1 must never need
1058
- // admin rights — got Access Denied, three times, and then gave up
1059
- // for the life of the process. The section was not missing because
1060
- // the hardware was silent; it was missing because we were asking
1061
- // somewhere we were not allowed to look.
1062
- //
1063
- // MSAcpi is still asked, second, because a deck that IS elevated can
1064
- // read it and because the two do not always agree — some boards
1065
- // publish a zone to ACPI and no counter.
1066
- //
1067
- // Genuinely absent on a large share of machines either way: a
1068
- // desktop board with no zone, and every virtual machine, which has
1069
- // no thermal hardware to report. Measured on a QEMU/SeaBIOS guest:
1070
- // the counter set is registered and answers "The specified instance
1071
- // is not present", and MSAcpi answers "Not supported" even to an
1072
- // administrator. Absent is ordinary here rather than an error.
1073
- //
1074
- // THE COUNTER PATH IS LOCALISED. `Thermal Zone Information` is the
1075
- // English name and a German or French Windows publishes its own, so
1076
- // this reaches the counter on an English install and falls through
1077
- // to MSAcpi elsewhere. Translating it means resolving a numeric
1078
- // index through the registry, which is a change with no way to be
1079
- // tested from here — see #747.
1080
- //
1081
- // NEVER INVENT A READING. No sensor means no row, and no rows at all means the
1082
- // section is not rendered: not 0°C, not a dash, not a grey empty bar. Same rule
1083
- // that keeps `cpu` null until two samples exist.
1084
-
1085
- /** How often the thermal reading is refreshed. Heat moves on the scale of
1086
- * seconds, and the two platforms that cost a subprocess to ask cost 51ms
1087
- * (`ioreg`) and rather less (`pmset`), measured. Linux costs a file read. */
1088
- const THERMAL_INTERVAL_MS = 10_000;
1089
-
1090
- /**
1091
- * How many consecutive empty readings before this machine is left alone.
1092
- *
1093
- * The reason is Windows, where MSAcpi_ThermalZoneTemperature is absent on a
1094
- * large share of desktops: without this, every one of those machines would pay
1095
- * a `Get-CimInstance` child every ten seconds, forever, to render a section it
1096
- * can never render. Three rather than one because a single failure can be a
1097
- * hiccup — a timeout, a machine mid-wake — and giving up on a hiccup would lose
1098
- * a reading the machine does have.
1099
- *
1100
- * Not persisted. A restart asks again, which is what should happen after the
1101
- * user installs a driver or changes a firmware setting.
1102
- */
1103
- const THERMAL_GIVE_UP = 3;
1104
-
1105
- /** Bands used where the hardware publishes none of its own. Linux sensors
1106
- * carry `temp*_max` and `temp*_crit` and those win: a laptop package sensor
1107
- * and an NVMe drive do not share a comfortable range, and one scale for both
1108
- * would be a number this module made up. */
1109
- const WARN_C = 75;
1110
- const CRIT_C = 90;
1111
-
1112
- /**
1113
- * How much history the panel keeps, and why it is bucketed by minute.
1114
- *
1115
- * Each section answers "what is it now"; the chart behind it answers "what did
1116
- * it do while that build was running", which is a different question and the
1117
- * reason a section opens one at all. 1440 minutes is a day, which covers "since
1118
- * the deck started" for every session anybody actually has.
1119
- *
1120
- * A bucket holds the MAXIMUM of its minute, never the mean, and that choice is
1121
- * the same one for every series here. A machine that touched 94°C for twenty
1122
- * seconds and sat at 60 for the rest of the minute averages to 66 and reads as
1123
- * calm; a load average that spiked to 114 between two quiet stretches averages
1124
- * away entirely. The spike is what somebody opens a chart to find.
1125
- *
1126
- * Kept out of systemSnapshot deliberately. That endpoint is polled every three
1127
- * seconds by a topbar meter that draws none of this; a day of buckets on every
1128
- * one of those responses would be the largest thing the deck sends, for charts
1129
- * that are usually closed. It has its own route, like the process list.
1130
- */
1131
- const HISTORY_MINUTES = 1440;
1132
- const BUCKET_MS = 60_000;
1133
-
1134
- /** A reading that is not a temperature is not a misparse to be shown anyway.
1135
- * Silicon does not run below freezing or above 130°C, and both ends of that
1136
- * have been produced by reading the right file with the wrong unit. */
1137
- const plausible = c => Number.isFinite(c) && c > 0 && c < 130;
1138
-
1139
- /**
1140
- * Millidegrees Celsius out of a hwmon `temp*_input`, or null.
1141
- *
1142
- * The kernel writes an integer; the divide is the whole conversion. Exported
1143
- * and pure for the reason every parser here is: a Linux answer has to be
1144
- * checkable from a Mac.
1145
- */
1146
- export function celsiusFromMilli(text) {
1147
- const n = Number(String(text ?? "").trim());
1148
- const c = Math.round(n / 1000);
1149
- return plausible(c) ? c : null;
1150
- }
1151
-
1152
- /**
1153
- * Which of a machine's sensors the panel names, out of every sensor found.
1154
- *
1155
- * A real machine publishes a lot of them: the package, one per core, the NVMe
1156
- * drive, the wireless card, the chipset. Two rows is what the panel has room
1157
- * for and two rows is what somebody watching a build wants, so this picks the
1158
- * CPU and the GPU by the chip that published them and leaves the rest alone.
1159
- *
1160
- * Preference inside a chip matters as much as the chip does. coretemp exposes
1161
- * `Package id 0` beside `Core 0`..`Core N`, and the package is the reading
1162
- * — a single core's number is noisier and lower than the die it sits on.
1163
- * k10temp exposes `Tctl` and, on parts that have it, `Tdie`: Tctl is Tdie plus
1164
- * a vendor offset that exists for fan control, so Tdie is the temperature and
1165
- * Tctl is the fallback. amdgpu's `edge` is the die edge and `junction` is the
1166
- * hotspot; edge is what every other tool calls the GPU temperature.
1167
- *
1168
- * Where a chip publishes nothing recognisable, the hottest of its sensors is
1169
- * taken, because the question is "is it getting hot" and the hottest sensor is
1170
- * the one that answers it.
1171
- */
1172
- const CPU_CHIPS = ["coretemp", "k10temp", "zenpower", "cpu_thermal", "soc_thermal"];
1173
- const GPU_CHIPS = ["amdgpu", "nouveau", "i915", "xe", "radeon"];
1174
-
1175
- export function pickThermalRows(sensors) {
1176
- const hottest = rows => rows.reduce((a, b) => (b.celsius > a.celsius ? b : a));
1177
- const pick = (chips, prefer, label) => {
1178
- const mine = (sensors ?? []).filter(s => chips.includes(s.chip) && plausible(s.celsius));
1179
- if (!mine.length) return null;
1180
- for (const re of prefer) {
1181
- const hit = mine.find(s => re.test(s.label ?? ""));
1182
- if (hit) return { ...hit, label };
1183
- }
1184
- return { ...hottest(mine), label };
1185
- };
1186
- return [
1187
- pick(CPU_CHIPS, [/^package id/i, /^tdie$/i, /^tctl$/i], "CPU"),
1188
- pick(GPU_CHIPS, [/^edge$/i, /^junction$/i], "GPU"),
1189
- ].filter(Boolean);
1190
- }
1191
-
1192
- /**
1193
- * Every temperature sensor under /sys/class/hwmon, with the chip that owns it
1194
- * and the bands that chip publishes for it.
1195
- *
1196
- * `root` is a parameter so this can be pointed at a tree on disk. There is no
1197
- * Linux machine here and no container runtime, so the alternative would be a
1198
- * directory walk nobody has ever run — and a walk is exactly the kind of code
1199
- * that a fixture of its OUTPUT cannot check, because the walk is the part that
1200
- * is wrong.
1201
- */
1202
- export async function readHwmon(root = "/sys/class/hwmon", deps = {}) {
1203
- const dir = deps.readdir ?? readdir;
1204
- const file = deps.readFile ?? readFile;
1205
- const read = async path => { try { return String(await file(path, "utf8")).trim(); } catch { return null; } };
1206
- let chips;
1207
- try { chips = await dir(root); } catch { return []; }
1208
- const out = [];
1209
- for (const hwmon of chips) {
1210
- const base = `${root}/${hwmon}`;
1211
- const chip = (await read(`${base}/name`)) ?? hwmon;
1212
- let entries;
1213
- try { entries = await dir(base); } catch { continue; }
1214
- for (const entry of entries) {
1215
- const m = /^(temp\d+)_input$/.exec(entry);
1216
- if (!m) continue;
1217
- const celsius = celsiusFromMilli(await read(`${base}/${entry}`));
1218
- if (celsius == null) continue;
1219
- const chipMax = celsiusFromMilli(await read(`${base}/${m[1]}_max`));
1220
- const chipCrit = celsiusFromMilli(await read(`${base}/${m[1]}_crit`));
1221
- out.push({
1222
- chip,
1223
- label: await read(`${base}/${m[1]}_label`),
1224
- celsius,
1225
- // The hardware's own bands where it has them. `max` is where the chip
1226
- // says it is unhappy and `crit` is where it says it will act.
1227
- //
1228
- // `max` ONLY WHEN IT IS BELOW `crit`, because on Intel coretemp the two
1229
- // files carry the same number. Measured on a live machine:
1230
- //
1231
- // coretemp temp1_input 88000 temp1_max 100000 temp1_crit 100000
1232
- // nvme temp1_input 45850 temp1_max 84850 temp1_crit 85850
1233
- //
1234
- // The nvme row is a real band and works. The coretemp row is not: with
1235
- // max === crit the amber band is zero degrees wide, so thermalTone goes
1236
- // from calm straight to hot at 100C — a number a CPU reaches only as it
1237
- // shuts the machine down. A CPU at 89C was painted the same grey as one
1238
- // at 45C, on the one row anybody reads, and the 75/90 fallback that
1239
- // would have said so was discarded precisely BECAUSE the chip published
1240
- // its own. A chip that publishes one usable threshold is telling us
1241
- // where it will act, not where to start worrying.
1242
- warnAt: chipMax != null && chipCrit != null && chipMax >= chipCrit
1243
- ? Math.min(WARN_C, Math.round(chipCrit * 0.9))
1244
- : chipMax ?? WARN_C,
1245
- critAt: chipCrit ?? CRIT_C,
1246
- });
1247
- }
1248
- }
1249
- return out;
1250
- }
1251
-
1252
- /**
1253
- * The coarser Linux fallback, for a machine whose sensors have no hwmon driver.
1254
- *
1255
- * One row, and it is labelled with the zone's own `type` rather than "CPU",
1256
- * because a thermal zone is not a claim about what was measured. `acpitz` is
1257
- * the motherboard's idea of ambient on a lot of hardware and calling that the
1258
- * CPU would be the same lie in a different place.
1259
- */
1260
- const ZONE_ORDER = ["x86_pkg_temp", "cpu-thermal", "cpu_thermal", "soc_thermal"];
1261
-
1262
- export async function readThermalZones(root = "/sys/class/thermal", deps = {}) {
1263
- const dir = deps.readdir ?? readdir;
1264
- const file = deps.readFile ?? readFile;
1265
- const read = async path => { try { return String(await file(path, "utf8")).trim(); } catch { return null; } };
1266
- let zones;
1267
- try { zones = (await dir(root)).filter(n => /^thermal_zone\d+$/.test(n)); } catch { return []; }
1268
- const found = [];
1269
- for (const zone of zones) {
1270
- const celsius = celsiusFromMilli(await read(`${root}/${zone}/temp`));
1271
- if (celsius == null) continue;
1272
- found.push({ label: (await read(`${root}/${zone}/type`)) ?? zone, celsius, warnAt: WARN_C, critAt: CRIT_C });
1273
- }
1274
- if (!found.length) return [];
1275
- const known = found.find(z => ZONE_ORDER.includes(z.label));
1276
- return [known ?? found.reduce((a, b) => (b.celsius > a.celsius ? b : a))];
1277
- }
1278
-
1279
- /**
1280
- * GPU degrees out of `ioreg -r -k PerformanceStatistics`.
1281
- *
1282
- * The macOS reading nothing documented: the accelerator publishes
1283
- * "Temperature(C)" in the same dictionary as its clock and its power. The
1284
- * maximum across accelerators, because a machine with two cards is asking
1285
- * whether it is getting hot, and the hotter card is the answer.
1286
- */
1287
- export function gpuFromIoreg(text) {
1288
- let best = null;
1289
- for (const m of String(text ?? "").matchAll(/"Temperature\(C\)"\s*=\s*(-?\d+)/g)) {
1290
- const c = Number(m[1]);
1291
- if (plausible(c) && (best == null || c > best)) best = c;
1292
- }
1293
- return best;
1294
- }
1295
-
1296
- /**
1297
- * The share of the CPU's speed the thermal manager is allowing, out of
1298
- * `pmset -g therm`, or null when this Mac has never recorded one.
1299
- *
1300
- * `CPU_Scheduler_Limit` sits beside it and is deliberately not read: it limits
1301
- * scheduling rather than clock, so folding the two into one percentage would
1302
- * produce a number that is neither. If scheduler throttling turns out to matter
1303
- * it is a second row, not a redefinition of this one.
1304
- */
1305
- export function throttleFromPmset(text) {
1306
- const m = /CPU_Speed_Limit\s*=\s*(\d+)/.exec(String(text ?? ""));
1307
- if (!m) return null;
1308
- const pct = Number(m[1]);
1309
- if (!Number.isFinite(pct) || pct < 0 || pct > 100) return null;
1310
- return { speedLimit: pct };
1311
- }
1312
-
1313
-
1314
- /**
1315
- * Windows thermal zones out of the `Thermal Zone Information` counter set.
1316
- *
1317
- * `High Precision Temperature` is in TENTHS OF A KELVIN, which is the single
1318
- * detail this branch turns on — the same unit MSAcpi uses below, and reading it
1319
- * as anything else gives a number that is plausible-looking and wrong.
1320
- *
1321
- * This is the source that works WITHOUT ADMINISTRATOR, which is the whole point
1322
- * of it: root\wmi needs elevation and a deck never has it. See the note at the
1323
- * top of this section.
1324
- *
1325
- * Shape is `[{ i: instanceName, v: cookedValue }]` — the projection the
1326
- * PowerShell one-liner makes, so this parser never has to know what a
1327
- * CounterSample looks like.
1328
- */
1329
- export const WIN_THERMAL_PS = [
1330
- "$r = [ordered]@{}",
1331
- // Get-Counter, not Get-CimInstance: this is the half that works unelevated.
1332
- "try { $r.perf = @((Get-Counter -Counter '\\Thermal Zone Information(*)\\High Precision Temperature' -EA Stop).CounterSamples | ForEach-Object { @{ i = $_.InstanceName; v = $_.CookedValue } }) } catch {}",
1333
- // THE SAME COUNTER UNDER THE NAME THIS WINDOWS CALLS IT.
1334
- //
1335
- // Performance-counter set and counter names are LOCALIZED. That is why PDH
1336
- // ships `PdhAddEnglishCounter` beside `PdhAddCounter` at all, and
1337
- // `Get-Counter -Counter` takes a localized path: hand it the English one on a
1338
- // German, French, Japanese or Russian Windows and it answers "The specified
1339
- // object was not found on the computer." `-EA Stop` plus `catch {}` turns
1340
- // that into silence, so the line above falls straight through to MSAcpi —
1341
- // which this module's own header says needs elevation a deck never has — and
1342
- // then to LibreHardwareMonitor, which is only there if the user installed it.
1343
- // A machine with real ACPI thermal zones and a non-English display language
1344
- // therefore drew no Thermal section at all, indistinguishable from the
1345
- // modern-Intel-laptop case the section is written to tolerate.
1346
- //
1347
- // This is the class #552 fixed one module over: exec.mjs spends two screens
1348
- // on "Windows ships cmd.exe in every language it ships in, and these
1349
- // sentences are translated with it", and there is a C_LOCALE block in this
1350
- // very file for the same reason.
1351
- //
1352
- // Perflib is the documented map between the two spellings. `…\Perflib\009`
1353
- // holds a REG_MULTI_SZ of alternating index and ENGLISH name; the parallel
1354
- // `…\Perflib\CurrentLanguage` holds index and LOCAL name. Look the two
1355
- // English names up to get their indices, read the local names at the same
1356
- // indices, and build the path out of those.
1357
- //
1358
- // SECOND, NOT FIRST, and that is what makes it safe to add: an English
1359
- // Windows never reaches this line, because `$r.perf` is already filled. It
1360
- // can only turn a machine that was reporting nothing into one that reports
1361
- // something.
1362
- //
1363
- // NOT ONE DOUBLE QUOTE, for the reason the whole of this string has none: it
1364
- // travels as a single `-Command` argument on a Windows command line. The
1365
- // counter path is concatenated rather than interpolated so that no quoting
1366
- // form beyond the single quote is needed anywhere in it.
1367
- //
1368
- // REASONED, NOT REPRODUCED. The localization of counter names is documented
1369
- // Microsoft behaviour and the Perflib layout is documented with it, but no
1370
- // localized Windows was available to run this against, and CI's runners are
1371
- // English — so what CI proves about this line is that it parses and runs
1372
- // clean on a real Windows, not that it resolves a German counter name.
1373
- "if (-not $r.perf) { try { "
1374
- + "$en = (Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Perflib\\009' -EA Stop).Counter; "
1375
- + "$lo = (Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Perflib\\CurrentLanguage' -EA Stop).Counter; "
1376
- + "$byIndex = @{}; for ($i = 1; $i -lt $lo.Count; $i += 2) { $byIndex[$lo[$i-1]] = $lo[$i] }; "
1377
- + "$set = $null; $ctr = $null; "
1378
- + "for ($i = 1; $i -lt $en.Count; $i += 2) { "
1379
- + "if ($en[$i] -eq 'Thermal Zone Information') { $set = $byIndex[$en[$i-1]] } "
1380
- + "elseif ($en[$i] -eq 'High Precision Temperature') { $ctr = $byIndex[$en[$i-1]] } }; "
1381
- + "if ($set -and $ctr) { $r.perf = @((Get-Counter -Counter ('\\' + $set + '(*)\\' + $ctr) -EA Stop).CounterSamples "
1382
- + "| ForEach-Object { @{ i = $_.InstanceName; v = $_.CookedValue } }) } "
1383
- + "} catch {} }",
1384
- "if (-not $r.perf) { try { $r.acpi = @(Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -EA Stop | ForEach-Object { @{ i = $_.InstanceName; v = $_.CurrentTemperature } }) } catch {} }",
1385
- // Depth matters: the default of 2 turns the inner hashtables into the string
1386
- // "System.Collections.Hashtable" and this parser would see nothing at all.
1387
- "$r | ConvertTo-Json -Compress -Depth 4",
1388
- ].join("; ");
1389
-
1390
- /**
1391
- * Whichever of the two Windows sources answered, as thermal rows.
1392
- *
1393
- * The shape is `{ perf: [...] }` or `{ acpi: [...] }` or `{}` — the PowerShell
1394
- * above only ever fills one, and fills neither on the machines where there is
1395
- * nothing to fill it with. Both lists carry the same two fields and the same
1396
- * unit, so the only thing that differs is which key they arrived under.
1397
- */
1398
- export function parseWinThermal(json) {
1399
- let r;
1400
- try { r = typeof json === "string" ? JSON.parse(json) : json; }
1401
- catch { return []; }
1402
- if (!r || typeof r !== "object") return [];
1403
- const perf = tempFromPerfCounterJson(r.perf ?? []);
1404
- if (perf.length) return perf;
1405
- return tempFromPerfCounterJson(
1406
- // MSAcpi's projection uses the same two keys, so the one parser reads both.
1407
- Array.isArray(r.acpi) ? r.acpi : (r.acpi ? [r.acpi] : []),
1408
- );
1409
- }
1410
-
1411
- export function tempFromPerfCounterJson(json) {
1412
- let rows;
1413
- try { rows = typeof json === "string" ? JSON.parse(json) : json; }
1414
- catch { return []; }
1415
- if (!rows) return [];
1416
- if (!Array.isArray(rows)) rows = [rows];
1417
- const found = [];
1418
- for (const r of rows) {
1419
- const tenths = Number(r?.v);
1420
- if (!Number.isFinite(tenths)) continue;
1421
- const celsius = Math.round(tenths / 10 - 273.15);
1422
- if (!plausible(celsius)) continue;
1423
- found.push({ label: zoneLabel(r?.i), celsius, warnAt: WARN_C, critAt: CRIT_C });
1424
- }
1425
- if (found.length === 1) found[0].label = "Thermal zone";
1426
- return found.slice(0, 2);
1427
- }
1428
-
1429
- /**
1430
- * A thermal zone's name, out of whatever spelling the source used.
1431
- *
1432
- * The counter names its instances `\_tz.tz00` and WMI names the same zone
1433
- * `ACPI\ThermalZone\TZ00_0`, so the tail after the last separator is the only
1434
- * part the two agree on. Upper-cased because the counter lower-cases it and a
1435
- * panel that showed `tz00` beside a `TZ01` from the other source would be
1436
- * showing one machine as two.
1437
- */
1438
- export function zoneLabel(raw) {
1439
- const tail = String(raw ?? "").split(/[\\.]/).pop() ?? "";
1440
- const name = tail.replace(/_\d+$/, "").toUpperCase();
1441
- return name || "Thermal zone";
1442
- }
1443
-
1444
-
1445
- /**
1446
- * The macOS rows, from whatever the three sources answered.
1447
- *
1448
- * Pure, and exported, for the reason sampleThermal's `deps.read` is: the branch
1449
- * that matters only fires on a machine that answers with nothing, and the
1450
- * machine this was written on answers with something. There is no other way to
1451
- * run it.
1452
- *
1453
- * The ordering rule is the whole content. ioreg's GPU degrees and pmset's
1454
- * throttle are what macOS itself gives up, and they win — they cost one cheap
1455
- * subprocess each and they are the same numbers this deck has always shown.
1456
- * macmon is consulted only when both were silent, and then its CPU row comes
1457
- * first, because on the machine that reaches here the CPU is the reading
1458
- * somebody opened the panel for.
1459
- */
1460
- export function darwinThermal({ gpuC = null, throttle = null, macmon = {} } = {}) {
1461
- const celsius = [];
1462
- if (gpuC != null) celsius.push({ label: "GPU", celsius: gpuC, warnAt: WARN_C, critAt: CRIT_C });
1463
- else {
1464
- if (macmon.cpu != null) celsius.push({ label: "CPU", celsius: macmon.cpu, warnAt: WARN_C, critAt: CRIT_C });
1465
- if (macmon.gpu != null) celsius.push({ label: "GPU", celsius: macmon.gpu, warnAt: WARN_C, critAt: CRIT_C });
1466
- }
1467
- return celsius.length || throttle ? { celsius, throttle } : null;
1468
- }
1469
-
1470
- /**
1471
- * What /api/system carries, or null when this machine says nothing at all.
1472
- *
1473
- * Two fields rather than one list, because they are two different readings and
1474
- * collapsing them would let a throttle percentage be drawn under a °C heading
1475
- * — the thing the label rule exists to prevent. `swapLabel` earned that rule
1476
- * once already.
1477
- */
1478
- export async function readThermal(platform = process.platform) {
1479
- if (platform === "linux") {
1480
- const sensors = await readHwmon();
1481
- const celsius = pickThermalRows(sensors);
1482
- const rows = celsius.length ? celsius : await readThermalZones();
1483
- return rows.length ? { celsius: rows, throttle: null } : null;
1484
- }
1485
-
1486
- if (platform === "darwin") {
1487
- // Scoped by key rather than dumped whole: `ioreg -l` is 217KB and just
1488
- // under two seconds on this machine, `-r -k PerformanceStatistics` is 83KB
1489
- // and 51ms for the same number.
1490
- const [gpu, therm] = await Promise.all([
1491
- run("ioreg", ["-r", "-k", "PerformanceStatistics", "-w", "0"], 3_000),
1492
- run("pmset", ["-g", "therm"]),
1493
- ]);
1494
- const gpuC = gpu ? gpuFromIoreg(gpu) : null;
1495
- const throttle = therm ? throttleFromPmset(therm) : null;
1496
-
1497
- // Nothing from either is every Apple Silicon Mac, and only those: the AGX
1498
- // driver does not publish the key ioreg reads, and pmset records no speed
1499
- // limit on M-series. Asking macmon is the only thing left, and it is asked
1500
- // ONLY here — an Intel Mac answers above and never spawns it. See
1501
- // macmon.mjs for why a tool the user installed is the whole of the answer.
1502
- const macmon = gpuC == null && !throttle
1503
- ? await (await import("./macmon.mjs")).readMacmonTemps()
1504
- : {};
1505
-
1506
- return darwinThermal({ gpuC, throttle, macmon });
1507
- }
1508
-
1509
- if (platform === "win32") {
1510
- // One child for both sources rather than two, because the cost here is the
1511
- // PowerShell start and not the queries: the counter is tried first because
1512
- // it needs no administrator, and MSAcpi only when the counter said nothing.
1513
- // Both are wrapped in their own try — "no thermal zone on this machine" is
1514
- // the ordinary answer and arrives as a throw from either.
1515
- const out = await run("powershell.exe", [
1516
- "-NoProfile", "-NonInteractive", "-Command", WIN_THERMAL_PS,
1517
- ], 6_000);
1518
- const answer = out ? parseWinThermal(out.trim()) : [];
1519
- if (answer.length) return { celsius: answer, throttle: null };
1520
-
1521
- // Windows itself had nothing, which on a modern Intel laptop is every time:
1522
- // the firmware declares no ACPI thermal zone and the sensors sit behind
1523
- // Intel DTT, which an ordinary process may not read. If something on this
1524
- // machine has already gone and got them — LibreHardwareMonitor, with its
1525
- // web server on — they are a plain HTTP read away. Never installed, never
1526
- // asked for. See hwmonitor.mjs.
1527
- const { readHwMonitorTemps } = await import("./hwmonitor.mjs");
1528
- const t = await readHwMonitorTemps();
1529
- const rows = [];
1530
- if (t.cpu != null) rows.push({ label: "CPU", celsius: t.cpu, warnAt: WARN_C, critAt: CRIT_C });
1531
- if (t.gpu != null) rows.push({ label: "GPU", celsius: t.gpu, warnAt: WARN_C, critAt: CRIT_C });
1532
- return rows.length ? { celsius: rows, throttle: null } : null;
1533
- }
1534
-
1535
- return null;
1536
- }
1537
-
1538
- /**
1539
- * Fold one reading into the minute it belongs to, under a namespaced key.
1540
- *
1541
- * Keys are namespaced by section (`thermal:GPU`, `cpu:all`, `mem:swap`) rather
1542
- * than kept in four rings, because they all share one clock: a bucket is a
1543
- * minute of this machine, and every series that has something to say about that
1544
- * minute says it in the same place. The sections sample at different rates —
1545
- * CPU every three seconds, thermal every ten, memory every thirty — and folding
1546
- * by maximum makes that difference invisible to the reader, which is what it
1547
- * should be.
1548
- */
1549
- function record(key, value, nowMs = Date.now()) {
1550
- if (!Number.isFinite(value)) return;
1551
- const minute = Math.floor(nowMs / BUCKET_MS);
1552
- let last = history[history.length - 1];
1553
- if (!last || last.m !== minute) {
1554
- last = { m: minute, v: {} };
1555
- history.push(last);
1556
- while (history.length > HISTORY_MINUTES) history.shift();
1557
- }
1558
- const prev = last.v[key];
1559
- last.v[key] = prev == null ? value : Math.max(prev, value);
1560
- }
1561
-
1562
- /**
1563
- * The thermal reading, whose series are not known until the machine answers.
1564
- *
1565
- * Keyed by the row's own label rather than by position, because the rows are
1566
- * not the same on every platform and a machine can start reporting a sensor it
1567
- * was not reporting before — a GPU driver loads, a laptop is docked. A series
1568
- * that appears late simply has no points before it appeared, which is the truth
1569
- * and draws correctly.
1570
- */
1571
- function recordThermal(reading, nowMs = Date.now()) {
1572
- if (!reading) return;
1573
- for (const r of reading.celsius ?? []) record(`thermal:${r.label}`, r.celsius, nowMs);
1574
- // Stored as the share TAKEN AWAY, the same way the panel draws it, so the
1575
- // chart and the row cannot disagree about which direction is bad.
1576
- if (reading.throttle) {
1577
- record(`thermal:${THROTTLE_LABEL}`, Math.max(0, 100 - reading.throttle.speedLimit), nowMs);
1578
- }
1579
- }
1580
-
1581
- /** The one thermal row that is not degrees. Named once so the recorder, the
1582
- * route and the panel cannot drift apart on the spelling. */
1583
- export const THROTTLE_LABEL = "Throttling";
152
+ export { sampleThermal, THROTTLE_LABEL };
1584
153
 
1585
154
  /**
1586
155
  * The scale a series is drawn against.
@@ -1611,117 +180,117 @@ function loadTop(points, coreCount) {
1611
180
  * another that means nothing.
1612
181
  */
1613
182
  function seriesFor(group) {
1614
- const at = key => history.filter(b => b.v[key] != null)
1615
- // Timestamps rather than indices: a bucket only exists for a minute that was
1616
- // sampled, so a gap — the machine asleep, the process paused — stays a gap
1617
- // rather than becoming a straight line across it.
1618
- .map(b => ({ t: b.m * BUCKET_MS, v: b.v[key] }));
183
+ const at = pointsOf;
1619
184
  const coreCount = os.cpus().length;
1620
185
 
1621
- if (group === "thermal") {
1622
- const bands = new Map((thermal?.celsius ?? []).map(r => [r.label, r]));
1623
- const labels = [];
1624
- for (const b of history) {
1625
- for (const k of Object.keys(b.v)) {
1626
- if (!k.startsWith("thermal:")) continue;
1627
- const label = k.slice("thermal:".length);
1628
- if (!labels.includes(label)) labels.push(label);
1629
- }
1630
- }
1631
- return labels.map(label => ({
1632
- // The stable name of this reading, which the display label is not: `Swap`
1633
- // is `Commit` on Windows, and anything joining on what the eye sees
1634
- // breaks on the one platform nobody re-reads this on. It is the key the
1635
- // ring is already recorded under, published rather than invented.
1636
- key: `thermal:${label}`,
1637
- label,
1638
- unit: label === THROTTLE_LABEL ? "%" : "C",
1639
- top: 100,
1640
- // Throttling is the one reading here whose normal value is zero, so it
1641
- // is the one that does not need a full-height box to be read. Said by the
1642
- // series rather than inferred from "has no bands", which was the first
1643
- // rule and was wrong: CPU has no bands DELIBERATELY and uses the whole
1644
- // scale, so it was getting the short box for a reason that is not true
1645
- // of it.
1646
- restsAtZero: label === THROTTLE_LABEL,
1647
- warnAt: label === THROTTLE_LABEL ? null : (bands.get(label)?.warnAt ?? WARN_C),
1648
- critAt: label === THROTTLE_LABEL ? null : (bands.get(label)?.critAt ?? CRIT_C),
1649
- points: at(`thermal:${label}`),
1650
- }));
1651
- }
1652
-
1653
- if (group === "cores") {
1654
- // Not one line per core: twelve lines in a 620px dialog is a picture nobody
1655
- // can read. These two answer what the columns cannot answer over time —
1656
- // "all cores" at 20 with "busiest" at 100 is ONE core pinned, which is a
1657
- // different machine from twelve at 20.
1658
- //
1659
- // No bands, deliberately, and the reason is written at the top of
1660
- // MachinePanel: a CPU at 90% is the machine doing the work you asked for. An
1661
- // indicator that alarms during the normal case teaches you to stop reading
1662
- // it.
1663
- return [
1664
- { key: "cpu:all", label: "All cores", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:all"), restsAtZero: false },
1665
- { key: "cpu:busiest", label: "Busiest core", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:busiest"), restsAtZero: false },
1666
- ].filter(s => s.points.length);
1667
- }
1668
-
1669
- if (group === "memory") {
1670
- const swapLabel = process.platform === "win32" ? "Commit" : "Swap";
1671
- return [
1672
- // One band, not two. A `critAt` of 100 draws a rule along the top of a
1673
- // chart whose scale ends at 100 — it is the ceiling, drawn again in red,
1674
- // and it says nothing the edge did not.
1675
- { key: "mem:physical", label: "Physical", unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:physical") },
1676
- { key: "mem:swap", label: swapLabel, unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:swap") },
1677
- ].filter(s => s.points.length);
1678
- }
1679
-
1680
- if (group === "load") {
1681
- // One series, not three. 1m, 5m and 15m are three views of one number —
1682
- // the longer two are the short one smoothed — so charting the 1m over an
1683
- // hour says everything the other two would, at the resolution they hide.
1684
- const points = at("load:1m");
1685
- if (!points.length) return [];
1686
- return [{
1687
- key: "load:1m",
1688
- label: "Queued work",
1689
- unit: "",
1690
- top: loadTop(points, coreCount),
1691
- // Where the queue exceeds the cores there are to run it, which is the one
1692
- // number the section's own note already draws the line at.
1693
- warnAt: coreCount,
1694
- critAt: null,
1695
- restsAtZero: false,
1696
- points,
1697
- }];
1698
- }
186
+ if (group === "thermal") return thermalSeries(at);
187
+ if (group === "cores") return coreSeries(at);
188
+ if (group === "memory") return memorySeries(at);
189
+ if (group === "load") return loadSeries(at, coreCount);
190
+ if (group === "network") return networkSeries(at);
191
+ return [];
192
+ }
1699
193
 
1700
- if (group === "network") {
1701
- // Download and upload are two charts, not two lines on one: upload is
1702
- // usually a hundredth of download, and on a shared scale it is a line along
1703
- // the floor. Each gets a top fitted to its own peak. No bands: there is no
1704
- // throughput that is too much, only more than usual, and a threshold here
1705
- // would be a number this module made up.
1706
- const down = at("net:down");
1707
- const up = at("net:up");
1708
- const api = at("net:api");
1709
- const series = [
1710
- // Floors of 100 KB/s and 100 ms, so a quiet line is drawn as quiet.
1711
- { key: "net:down", label: "Download", unit: "B/s", top: niceTop(down, 100_000), warnAt: null, critAt: null, restsAtZero: false, points: down },
1712
- { key: "net:up", label: "Upload", unit: "B/s", top: niceTop(up, 100_000), warnAt: null, critAt: null, restsAtZero: false, points: up },
1713
- { key: "net:api", label: "Claude API latency", unit: "ms", top: niceTop(api, 100), warnAt: null, critAt: null, restsAtZero: false, points: api },
1714
- ].filter(s => s.points.length);
1715
- // The path the traffic took, as marks on the last chart — the question the
1716
- // section exists for is "was it the VPN", and a line that jumps where a
1717
- // mark sits answers it without a legend.
1718
- const since = history.length ? history[0].m * BUCKET_MS : 0;
1719
- const changes = routeLog.filter(c => c.t >= since).map(c => ({ t: c.t, label: c.label, from: c.from }));
1720
- if (series.length && changes.length) series[series.length - 1].changes = changes;
1721
- return series;
1722
- }
194
+ /** The thermal chart: a series per sensor the ring holds points for, in the
195
+ * order each first appeared — throttling among them, where it is recorded. */
196
+ function thermalSeries(at) {
197
+ const bands = new Map((lastThermal()?.celsius ?? []).map(r => [r.label, r]));
198
+ const labels = labelsUnder("thermal:");
199
+ return labels.map(label => ({
200
+ // The stable name of this reading, which the display label is not: `Swap`
201
+ // is `Commit` on Windows, and anything joining on what the eye sees
202
+ // breaks on the one platform nobody re-reads this on. It is the key the
203
+ // ring is already recorded under, published rather than invented.
204
+ key: `thermal:${label}`,
205
+ label,
206
+ unit: label === THROTTLE_LABEL ? "%" : "C",
207
+ top: 100,
208
+ // Throttling is the one reading here whose normal value is zero, so it
209
+ // is the one that does not need a full-height box to be read. Said by the
210
+ // series rather than inferred from "has no bands", which was the first
211
+ // rule and was wrong: CPU has no bands DELIBERATELY and uses the whole
212
+ // scale, so it was getting the short box for a reason that is not true
213
+ // of it.
214
+ restsAtZero: label === THROTTLE_LABEL,
215
+ warnAt: label === THROTTLE_LABEL ? null : (bands.get(label)?.warnAt ?? WARN_C),
216
+ critAt: label === THROTTLE_LABEL ? null : (bands.get(label)?.critAt ?? CRIT_C),
217
+ points: at(`thermal:${label}`),
218
+ }));
219
+ }
220
+
221
+ /** The cores chart: the whole machine, and its busiest core. */
222
+ function coreSeries(at) {
223
+ // Not one line per core: twelve lines in a 620px dialog is a picture nobody
224
+ // can read. These two answer what the columns cannot answer over time —
225
+ // "all cores" at 20 with "busiest" at 100 is ONE core pinned, which is a
226
+ // different machine from twelve at 20.
227
+ //
228
+ // No bands, deliberately, and the reason is written at the top of
229
+ // MachinePanel: a CPU at 90% is the machine doing the work you asked for. An
230
+ // indicator that alarms during the normal case teaches you to stop reading
231
+ // it.
232
+ return [
233
+ { key: "cpu:all", label: "All cores", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:all"), restsAtZero: false },
234
+ { key: "cpu:busiest", label: "Busiest core", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:busiest"), restsAtZero: false },
235
+ ].filter(s => s.points.length);
236
+ }
1723
237
 
1724
- return [];
238
+ /** The memory chart: physical memory, and swap — commit, on Windows. */
239
+ function memorySeries(at) {
240
+ const swapLabel = process.platform === "win32" ? "Commit" : "Swap";
241
+ return [
242
+ // One band, not two. A `critAt` of 100 draws a rule along the top of a
243
+ // chart whose scale ends at 100 — it is the ceiling, drawn again in red,
244
+ // and it says nothing the edge did not.
245
+ { key: "mem:physical", label: "Physical", unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:physical") },
246
+ { key: "mem:swap", label: swapLabel, unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:swap") },
247
+ ].filter(s => s.points.length);
248
+ }
249
+
250
+ /** The load chart: the one-minute load average, against the core count. */
251
+ function loadSeries(at, coreCount) {
252
+ // One series, not three. 1m, 5m and 15m are three views of one number —
253
+ // the longer two are the short one smoothed — so charting the 1m over an
254
+ // hour says everything the other two would, at the resolution they hide.
255
+ const points = at("load:1m");
256
+ if (!points.length) return [];
257
+ return [{
258
+ key: "load:1m",
259
+ label: "Queued work",
260
+ unit: "",
261
+ top: loadTop(points, coreCount),
262
+ // Where the queue exceeds the cores there are to run it, which is the one
263
+ // number the section's own note already draws the line at.
264
+ warnAt: coreCount,
265
+ critAt: null,
266
+ restsAtZero: false,
267
+ points,
268
+ }];
269
+ }
270
+
271
+ /** The network charts: download, upload and API latency, with each route
272
+ * change marked on the last of them. */
273
+ function networkSeries(at) {
274
+ // Download and upload are two charts, not two lines on one: upload is
275
+ // usually a hundredth of download, and on a shared scale it is a line along
276
+ // the floor. Each gets a top fitted to its own peak. No bands: there is no
277
+ // throughput that is too much, only more than usual, and a threshold here
278
+ // would be a number this module made up.
279
+ const down = at("net:down");
280
+ const up = at("net:up");
281
+ const api = at("net:api");
282
+ const series = [
283
+ // Floors of 100 KB/s and 100 ms, so a quiet line is drawn as quiet.
284
+ { key: "net:down", label: "Download", unit: "B/s", top: niceTop(down, 100_000), warnAt: null, critAt: null, restsAtZero: false, points: down },
285
+ { key: "net:up", label: "Upload", unit: "B/s", top: niceTop(up, 100_000), warnAt: null, critAt: null, restsAtZero: false, points: up },
286
+ { key: "net:api", label: "Claude API latency", unit: "ms", top: niceTop(api, 100), warnAt: null, critAt: null, restsAtZero: false, points: api },
287
+ ].filter(s => s.points.length);
288
+ // The path the traffic took, as marks on the last chart — the question the
289
+ // section exists for is "was it the VPN", and a line that jumps where a
290
+ // mark sits answers it without a legend.
291
+ const changes = routeChangesSince(oldestBucketMs());
292
+ if (series.length && changes.length) series[series.length - 1].changes = changes;
293
+ return series;
1725
294
  }
1726
295
 
1727
296
  /** A scale top a person would choose — 1, 2 or 5 times a power of ten — just
@@ -1735,115 +304,11 @@ function niceTop(points, floor) {
1735
304
  return want;
1736
305
  }
1737
306
 
1738
- /**
1739
- * Whether this machine has been held back AT ALL since the deck started, and
1740
- * when it last was.
1741
- *
1742
- * The row reports the current sample, and on a desktop that current sample is
1743
- * `0%` essentially always — measured here: ninety seconds of AES-NI on twelve
1744
- * cores never moved `CPU_Speed_Limit` off 100. Which is the truth, and which
1745
- * reads as "this readout does not work" the second time somebody looks at it.
1746
- * It was reported that way twice.
1747
- *
1748
- * So the note under the row gets to say the other thing. Nothing new is
1749
- * sampled for it: the minute buckets already hold the peak of every minute, and
1750
- * this is a scan of what is already there. A machine that has never been
1751
- * throttled says so; one that was at lunchtime says when.
1752
- */
1753
- function heldBackSoFar() {
1754
- const key = `thermal:${THROTTLE_LABEL}`;
1755
- let peak = 0;
1756
- let lastMs = 0;
1757
- for (const b of history) {
1758
- const v = b.v[key];
1759
- if (v == null || v <= 0) continue;
1760
- if (v > peak) peak = v;
1761
- lastMs = b.m * BUCKET_MS;
1762
- }
1763
- return peak > 0 ? { peak, lastMs } : null;
1764
- }
1765
-
1766
307
  /** What /api/system/history answers, for one section. */
1767
308
  export function historySnapshot(group) {
1768
309
  return { ok: true, sinceMs: historySince, stepMs: BUCKET_MS, series: seriesFor(group) };
1769
310
  }
1770
311
 
1771
- /**
1772
- * One reading at a time, and a machine that cannot answer is asked three times
1773
- * rather than for the life of the process.
1774
- *
1775
- * `deps.read` is a seam rather than a convenience: the rule this function
1776
- * exists for only fires on a machine that answers with nothing, and the machine
1777
- * this was written on answers with something, so there is no other way to run
1778
- * the branch that matters.
1779
- */
1780
- export async function sampleThermal(deps = {}) {
1781
- if (thermalInFlight) return;
1782
- // Giving up is only ever for a machine that has NEVER answered — the Windows
1783
- // desktop with no MSAcpi class, which would otherwise pay a PowerShell child
1784
- // every ten seconds for the life of the process. A machine that answered once
1785
- // has a sensor, and it keeps being asked however long the silence runs.
1786
- if (!thermalEverAnswered && thermalMisses >= THERMAL_GIVE_UP) return;
1787
- const read = deps.read ?? readThermal;
1788
- thermalInFlight = true;
1789
- try {
1790
- const next = await read();
1791
- if (next) { thermal = next; thermalMisses = 0; thermalEverAnswered = true; recordThermal(next); }
1792
- else if (++thermalMisses >= THERMAL_GIVE_UP) {
1793
- // DROP THE LAST READING. It used to be kept, and that is a number from
1794
- // four minutes ago printed as though it were now — the one thing this
1795
- // whole section refuses. A GPU driver unloads, a laptop is docked, a
1796
- // sensor goes away: the honest answer is that the section stops being
1797
- // drawn, not that it freezes.
1798
- thermal = null;
1799
- // But keep ASKING on a machine that has answered before. The cost
1800
- // argument for giving up was only ever about a machine that can never
1801
- // answer — a Windows desktop with no MSAcpi class paying a PowerShell
1802
- // child every ten seconds forever. One that answered has a sensor, and a
1803
- // silence is a gap rather than an absence.
1804
- if (!thermalEverAnswered && thermalTimer) {
1805
- clearInterval(thermalTimer);
1806
- thermalTimer = null;
1807
- }
1808
- // The one machine where "nothing" is worth doing something about: an
1809
- // Apple Silicon Mac has sensors and no way to read them, and the tool
1810
- // that can is a 746 KB signed binary this deck can fetch. Started HERE
1811
- // rather than at boot on purpose — the boot was just taught not to wait
1812
- // for an install (#742) and nothing waits for this one either. One
1813
- // attempt per process, and only after the give-up, so a machine that
1814
- // does have a sensor never downloads anything. See macmon.mjs.
1815
- if (!thermalEverAnswered && process.platform === "darwin") fetchMacmon(deps);
1816
- }
1817
- } catch { thermalMisses++; }
1818
- finally { thermalInFlight = false; }
1819
- }
1820
-
1821
- /**
1822
- * Fetch macmon, then ask again — floating, on purpose.
1823
- *
1824
- * Not awaited by sampleThermal, which is itself not awaited by anything: this
1825
- * is a download that may take a minute on a slow line, and the panel it serves
1826
- * is optional. When it lands, the give-up above has already stopped the timer,
1827
- * so the retry has to be made here rather than waited for.
1828
- */
1829
- function fetchMacmon(deps = {}) {
1830
- const boot = deps.bootstrap ?? (async () => (await import("./macmon.mjs")).bootstrapMacmon());
1831
- Promise.resolve(boot()).then(r => {
1832
- if (!r?.ok) return;
1833
- // A sensor exists after all. Clear the give-up and let the timer run again,
1834
- // which is what turns a downloaded binary into a section on screen without
1835
- // the user restarting anything.
1836
- thermalMisses = 0;
1837
- if (!thermalTimer) {
1838
- thermalTimer = setInterval(() => { sampleThermal(deps); }, THERMAL_INTERVAL_MS);
1839
- // Unref'd like the one startSystemMetrics creates: a poll for an optional
1840
- // panel must not be the reason a process refuses to exit.
1841
- thermalTimer.unref?.();
1842
- }
1843
- sampleThermal(deps);
1844
- }).catch(() => {});
1845
- }
1846
-
1847
312
  async function sampleMemory() {
1848
313
  if (memInFlight) return;
1849
314
  memInFlight = true;
@@ -1880,207 +345,11 @@ function sampleCpu() {
1880
345
  while (cpuHistory.length > HISTORY) cpuHistory.shift();
1881
346
  record("cpu:all", pct);
1882
347
  if (per?.length) record("cpu:busiest", Math.max(...per));
1883
- // Free — os.loadavg() reads a kernel value, no syscall worth the name — so it
1884
- // rides the CPU tick rather than earning a timer. Windows returns [0,0,0],
1885
- // which is not a reading and is not recorded as one.
1886
- //
1887
- // THE PLATFORM TEST IS THE WHOLE TEST. It used to be joined by
1888
- // `load.some(n => n > 0)`, which reads as belt and braces and is not: Node
1889
- // documents `os.loadavg()` as always [0,0,0] on Windows, so the platform test
1890
- // alone already excludes every non-reading — and the extra clause could only
1891
- // ever reject a reading that was REAL. `/proc/loadavg` on a genuinely quiet
1892
- // Linux box says `0.00 0.00 0.00`, so "Queued work" recorded no points at all
1893
- // for every quiet minute, and after the machine woke the chart read as though
1894
- // the deck had been switched off through them.
1895
- const load = os.loadavg();
1896
- if (process.platform !== "win32") record("load:1m", Math.round(load[0] * 100) / 100);
1897
- }
1898
-
1899
- // ── the network ────────────────────────────────────────────────────────────
1900
- //
1901
- // Three readings, taken at two very different costs (network-metrics.mjs says
1902
- // why these three). THROUGHPUT is two counters the kernel already keeps, so it
1903
- // rides its own five-second timer for the life of the process like CPU does,
1904
- // and its history is as complete as every other section's. LATENCY and ROUTE
1905
- // leave the machine or spawn a process, so they are taken only while somebody
1906
- // is looking: the panel is the only thing that polls /api/system, and a probe
1907
- // runs only within NET_ASKED_MS of that poll. A deck left open in a background
1908
- // tab all day makes no connection it was not asked for — the rule Claude FM's
1909
- // probe already follows ("makes no request until a page asks for one").
1910
-
1911
- /** Counters on Linux are a file read; on macOS and Windows one short command.
1912
- * Five seconds is often enough to catch a burst and rare enough that the
1913
- * command costs nothing anybody could measure. */
1914
- const NET_INTERVAL_MS = 5_000;
1915
- /** How often the far end is asked, while the panel is open. */
1916
- const NET_PROBE_MS = 30_000;
1917
- /** How recently a poll must have come for the probe to run at all. */
1918
- const NET_ASKED_MS = 20_000;
1919
- /** A probe older than this is from before the panel was last closed, and is
1920
- * not reported as the present. */
1921
- const NET_STALE_MS = NET_PROBE_MS * 2 + 5_000;
1922
- /** What "how far is Claude" is measured against: the host every request Claude
1923
- * Code makes goes to, and the one quota.mjs already reads from. */
1924
- const API_HOST = "api.anthropic.com";
1925
- const API_PORT = 443;
1926
- const CONNECT_TIMEOUT_MS = 4_000;
1927
- /** Where the route is asked for when there is no API address to ask about. A
1928
- * routing-table lookup sends no packet, so this contacts nobody. */
1929
- const ROUTE_FALLBACK = "1.1.1.1";
1930
- /** Route changes kept for the history's markers. A path changes a handful of
1931
- * times a day; forty is several days of it. */
1932
- const ROUTE_LOG = 40;
1933
-
1934
- let netTimer = null;
1935
- let netInFlight = false;
1936
- let prevNet = null;
1937
- let netRate = null;
1938
- let physical = null;
1939
- let physicalAt = 0;
1940
- let probeTimer = null;
1941
- /** Whether this process may reach out at all. Off unless the server says so:
1942
- * the suite starts this module in dozens of cases, and none of them should
1943
- * open a connection to Anthropic or spawn `ip` and `tailscale` to do it. */
1944
- let probeEnabled = false;
1945
- let probeInFlight = false;
1946
- let probeAt = 0;
1947
- let askedAt = 0;
1948
- /** Undefined until measured, null when the host could not be reached. */
1949
- let apiMs;
1950
- let route = null;
1951
- const routeLog = [];
1952
-
1953
- /** Interfaces that are hardware, by the one test the kernel offers: a
1954
- * `device` link under /sys/class/net. Rechecked each minute, because a dock or
1955
- * a phone tethered over USB is a new wire. */
1956
- async function physicalInterfaces(root = "/sys/class/net") {
1957
- try {
1958
- const names = await readdir(root);
1959
- const real = await Promise.all(names.map(n => access(`${root}/${n}/device`).then(() => n, () => null)));
1960
- return new Set(real.filter(Boolean));
1961
- } catch { return null; }
1962
- }
1963
-
1964
- async function readNetCounters(platform = process.platform) {
1965
- if (platform === "linux") {
1966
- if (!physical || Date.now() - physicalAt > 60_000) {
1967
- physical = await physicalInterfaces();
1968
- physicalAt = Date.now();
1969
- }
1970
- try {
1971
- const include = physical?.size ? name => physical.has(name) : name => name !== "lo";
1972
- return parseProcNetDev(await readFile("/proc/net/dev", "utf8"), include);
1973
- } catch { return null; }
1974
- }
1975
- if (platform === "darwin") return parseNetstatIbn(await run("netstat", ["-ibn"]));
1976
- if (platform === "win32") return parseNetstatE(await run("netstat", ["-e"]));
1977
- return null;
1978
- }
1979
-
1980
- async function sampleNetwork() {
1981
- if (netInFlight) return;
1982
- netInFlight = true;
1983
- try {
1984
- const counters = await readNetCounters();
1985
- if (!counters) return;
1986
- const next = { rx: counters.rx, tx: counters.tx, at: Date.now() };
1987
- const rate = rateBetween(prevNet, next);
1988
- prevNet = next;
1989
- if (!rate) return;
1990
- netRate = rate;
1991
- record("net:down", rate.down);
1992
- record("net:up", rate.up);
1993
- } finally { netInFlight = false; }
1994
- }
1995
-
1996
- /** Milliseconds for a TCP handshake with `address`, or null. The connection
1997
- * is closed the moment it opens: nothing is sent, so this is a round trip and
1998
- * nothing else — no TLS, no request, nothing the far end has to answer. */
1999
- function connectMs(address, port = API_PORT, timeoutMs = CONNECT_TIMEOUT_MS) {
2000
- return new Promise(resolve => {
2001
- const t0 = performance.now();
2002
- let done = false;
2003
- const socket = net.connect({ host: address, port });
2004
- const finish = ms => {
2005
- if (done) return;
2006
- done = true;
2007
- socket.destroy();
2008
- resolve(ms);
2009
- };
2010
- socket.setTimeout(timeoutMs, () => finish(null));
2011
- socket.once("connect", () => finish(Math.round(performance.now() - t0)));
2012
- socket.once("error", () => finish(null));
2013
- });
2014
- }
2015
-
2016
- /** Which way traffic to `address` leaves, and — for a Tailscale exit node —
2017
- * through which machine and whether through a relay. */
2018
- async function readRoute(address, platform, runner) {
2019
- let iface = null;
2020
- if (platform === "linux") iface = routeIfaceLinux(await runner("ip", ["-o", "route", "get", address]));
2021
- else if (platform === "darwin") iface = routeIfaceDarwin(await runner("route", ["-n", "get", address]));
2022
- const kind = classifyRoute(iface);
2023
- if (!kind || kind.kind === "direct") return kind;
2024
- // Tailscale on a Mac is a utun like every other VPN there, so the question is
2025
- // asked of any tunnel rather than only of one named tailscale0. A machine
2026
- // without the CLI answers null and keeps the generic label.
2027
- const exit = tailscaleExitFromStatus(await runner("tailscale", ["status", "--json"], 3_000));
2028
- if (exit) return { kind: "tailscale-exit", iface, ...exit };
2029
- return kind;
2030
- }
2031
-
2032
- /**
2033
- * One probe: how far the API is and which way the traffic to it goes.
2034
- *
2035
- * Guarded twice, because a GET can start it (see systemSnapshot) and a read's
2036
- * cost must have a ceiling (#544): never two at once, and never more often than
2037
- * half the probe interval however many tabs poll. `deps` is the seam the tests
2038
- * use — nothing else here can be made to answer "unreachable" on demand.
2039
- */
2040
- export async function probeNetwork(deps = {}) {
2041
- const now = deps.now ?? Date.now;
2042
- if (!probeEnabled && !deps.force) return;
2043
- if (probeInFlight || now() - probeAt < NET_PROBE_MS / 2) return;
2044
- if (!deps.force && now() - askedAt > NET_ASKED_MS) return;
2045
- probeInFlight = true;
2046
- probeAt = now();
2047
- try {
2048
- const platform = deps.platform ?? process.platform;
2049
- const runner = deps.run ?? run;
2050
- const measureApi = (deps.hasClaude ?? hasClaudeInstalled)();
2051
- let address = null;
2052
- if (measureApi) {
2053
- try { address = (await (deps.lookup ?? lookup)(API_HOST)).address; } catch { address = null; }
2054
- apiMs = address ? await (deps.connect ?? connectMs)(address) : null;
2055
- if (apiMs != null) record("net:api", apiMs);
2056
- }
2057
- const next = await readRoute(address ?? ROUTE_FALLBACK, platform, runner);
2058
- if (next) {
2059
- const label = routeLabel(next);
2060
- const prev = routeLog[routeLog.length - 1]?.label ?? null;
2061
- if (prev !== label) {
2062
- // `from` null is the first route this process saw — the state when it
2063
- // started looking, not a change, and the chart draws no mark for it.
2064
- routeLog.push({ t: now(), label, from: prev });
2065
- while (routeLog.length > ROUTE_LOG) routeLog.shift();
2066
- }
2067
- }
2068
- route = next;
2069
- } finally { probeInFlight = false; }
2070
- }
2071
-
2072
- /** What the panel shows for the network, or null before anything is known. */
2073
- function networkSnapshot(nowMs = Date.now()) {
2074
- const fresh = nowMs - probeAt <= NET_STALE_MS;
2075
- const api = fresh && apiMs !== undefined ? { host: API_HOST, ms: apiMs } : null;
2076
- // `to` says what the route was asked about: the API itself when Claude Code
2077
- // is here, the open internet otherwise — a split tunnel can send one and not
2078
- // the other, and the panel should not claim more than was measured.
2079
- const via = fresh && route && route.kind !== "direct"
2080
- ? { ...route, label: routeLabel(route), to: apiMs !== undefined ? "claude" : "internet" }
2081
- : null;
2082
- if (!netRate && !api && !via) return null;
2083
- return { down: netRate?.down ?? null, up: netRate?.up ?? null, api, route: via };
348
+ // Free, so it rides the CPU tick rather than earning a timer — and null on
349
+ // Windows, which publishes no load average. loadReading holds the rule and
350
+ // the #1028 story behind it; the snapshot's `loadavg` asks the same function.
351
+ const load = loadReading();
352
+ if (load) record("load:1m", load[0]);
2084
353
  }
2085
354
 
2086
355
  /**
@@ -2092,7 +361,6 @@ function networkSnapshot(nowMs = Date.now()) {
2092
361
  */
2093
362
  export function startSystemMetrics({ probe = false } = {}) {
2094
363
  if (cpuTimer) return;
2095
- probeEnabled = probe;
2096
364
  prevTicks = readTicks(); // baseline, so the first tick has a delta
2097
365
  prevCoreTicks = readCoreTicks();
2098
366
  sampleMemory();
@@ -2101,14 +369,10 @@ export function startSystemMetrics({ probe = false } = {}) {
2101
369
  sampleNetwork();
2102
370
  cpuTimer = setInterval(sampleCpu, CPU_INTERVAL_MS);
2103
371
  memTimer = setInterval(sampleMemory, MEM_INTERVAL_MS);
2104
- thermalTimer = setInterval(sampleThermal, THERMAL_INTERVAL_MS);
2105
- netTimer = setInterval(sampleNetwork, NET_INTERVAL_MS);
2106
- if (probeEnabled) probeTimer = setInterval(() => { probeNetwork(); }, NET_PROBE_MS);
372
+ startThermalTimer();
373
+ startNetworkTimers(probe);
2107
374
  cpuTimer.unref?.();
2108
375
  memTimer.unref?.();
2109
- thermalTimer.unref?.();
2110
- netTimer.unref?.();
2111
- probeTimer?.unref?.();
2112
376
  }
2113
377
 
2114
378
  /**
@@ -2131,24 +395,10 @@ export function startSystemMetrics({ probe = false } = {}) {
2131
395
  export function stopSystemMetrics() {
2132
396
  if (cpuTimer) clearInterval(cpuTimer);
2133
397
  if (memTimer) clearInterval(memTimer);
2134
- if (thermalTimer) clearInterval(thermalTimer);
2135
- if (netTimer) clearInterval(netTimer);
2136
- if (probeTimer) clearInterval(probeTimer);
2137
- cpuTimer = memTimer = thermalTimer = netTimer = probeTimer = null;
2138
- probeEnabled = false;
2139
- prevNet = null;
2140
- netRate = null;
2141
- physical = null;
2142
- physicalAt = 0;
2143
- probeAt = 0;
2144
- askedAt = 0;
2145
- apiMs = undefined;
2146
- route = null;
2147
- routeLog.length = 0;
2148
- thermal = null;
2149
- thermalMisses = 0;
2150
- thermalEverAnswered = false;
2151
- history.length = 0;
398
+ cpuTimer = memTimer = null;
399
+ stopThermal();
400
+ stopNetwork();
401
+ resetHistory();
2152
402
  historySince = 0;
2153
403
  prevTicks = null;
2154
404
  prevCoreTicks = null;
@@ -2156,12 +406,8 @@ export function stopSystemMetrics() {
2156
406
  memory = null;
2157
407
  cores = null;
2158
408
  swap = null;
2159
- prevProcCpu = null;
2160
- prevProcAt = 0;
2161
- // The last list goes with the baseline it was computed against. A sampler
2162
- // that stopped and started again must not answer the first caller with a
2163
- // reading from before the stop.
2164
- procLast = null;
409
+ // The process list's Windows baseline and its last list, which go together.
410
+ resetProcessList();
2165
411
  }
2166
412
 
2167
413
  /**
@@ -2174,20 +420,7 @@ export function stopSystemMetrics() {
2174
420
  */
2175
421
  export function systemSnapshot() {
2176
422
  const cpu = cpuHistory.length ? cpuHistory[cpuHistory.length - 1] : null;
2177
- // The panel is the only poller, so a poll is the signal somebody is looking.
2178
- // The first one after the panel was closed asks at once rather than waiting
2179
- // out the probe timer, so the latency is on screen by the second poll; the
2180
- // guards inside probeNetwork are what keep a busy poller from paying twice.
2181
- const idle = Date.now() - askedAt > NET_ASKED_MS;
2182
- askedAt = Date.now();
2183
- if (idle && probeEnabled) void probeNetwork();
2184
- const load = os.loadavg();
2185
- // Platform alone, for the reason spelled out beside the `load:1m` record in
2186
- // sampleCpu: Node's own contract makes the platform test sufficient, and the
2187
- // `load.some(n => n > 0)` that used to join it here rejected nothing except a
2188
- // real reading of zero. MachinePanel gates the whole section on `{loadavg &&
2189
- // …}`, so an idle Linux box had the section disappear from under it.
2190
- const hasLoad = process.platform !== "win32";
423
+ notePanelPoll();
2191
424
  return {
2192
425
  ok: true,
2193
426
  cpu,
@@ -2198,10 +431,11 @@ export function systemSnapshot() {
2198
431
  perCore: cores,
2199
432
  // Null on a machine that publishes nothing, and the panel draws no section
2200
433
  // at all for it rather than an empty one.
2201
- thermal: thermal ? { ...thermal, heldBack: heldBackSoFar() } : null,
434
+ thermal: thermalSnapshot(),
2202
435
  uptimeSec: Math.round(os.uptime()),
2203
436
  platform: process.platform,
2204
- loadavg: hasLoad ? load.map(n => Math.round(n * 100) / 100) : null,
437
+ // The rule the `load:1m` record in sampleCpu asks too — see loadReading.
438
+ loadavg: loadReading(),
2205
439
  network: networkSnapshot(),
2206
440
  intervalMs: CPU_INTERVAL_MS,
2207
441
  sampledAt: Date.now(),