ccdeck 3.37.1 → 3.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/web/assets/{BrowserWatchModal-njN0zg93.js → BrowserWatchModal-D2iFbRdJ.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-DYSZZZEz.js → UsageHistoryModal-DaH3RkMw.js} +1 -1
- package/dist/web/assets/index-D-HX6Mea.css +1 -0
- package/dist/web/assets/index-DnnkOgia.js +307 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/claude-jobs.mjs +152 -0
- package/src/server/deck-prefs.mjs +5 -2
- package/src/server/lan-engine.mjs +40 -34
- package/src/server/lan-reach.mjs +21 -0
- package/src/server/lan-routes.mjs +4 -1
- package/src/server/lan-sync.mjs +6 -5
- package/src/server/ring-bounds.mjs +9 -1
- package/src/server/session-activity.mjs +176 -0
- package/src/server/session-enrichment.mjs +69 -4
- package/src/server/session-tracking.mjs +22 -3
- package/src/server/thermal-metrics.mjs +113 -4
- package/src/server/thermal-sampler.mjs +10 -4
- package/dist/web/assets/index-B0SOa-XB.css +0 -1
- package/dist/web/assets/index-DDxnWaTa.js +0 -306
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// What the deck learns about a session that its hooks never say: the model,
|
|
2
|
-
// the token spend, the session's name and recap,
|
|
3
|
-
// carrying — each read off the session's transcript (or, for a
|
|
4
|
-
// memory files, off its working tree) and sent on as a
|
|
2
|
+
// the token spend, the session's name and recap, what it is doing now, and the
|
|
3
|
+
// context it is carrying — each read off the session's transcript (or, for a
|
|
4
|
+
// Codex session's memory files, off its working tree) and sent on as a
|
|
5
|
+
// synthetic event. And, for a background session, the line Claude Code's own
|
|
6
|
+
// agent view shows, read off its job folder (see claude-jobs.mjs).
|
|
5
7
|
//
|
|
6
8
|
// This lived in src/server/index.mjs, between the log rotation and the Codex
|
|
7
9
|
// rollout reader. pushEvent asks it to look at every live hook event, and it
|
|
@@ -18,6 +20,9 @@ import { join } from "node:path";
|
|
|
18
20
|
import { scanAgentsMdFiles, scanClaudeMdFiles } from "./memory-files.mjs";
|
|
19
21
|
// Claude Code's "※ recap:" line — see session-recap.mjs.
|
|
20
22
|
import { RECAP_MARK } from "./session-recap.mjs";
|
|
23
|
+
// What the session is doing now, folded off the same tail — see
|
|
24
|
+
// session-activity.mjs.
|
|
25
|
+
import { foldActivityLine, newActivityState } from "./session-activity.mjs";
|
|
21
26
|
// The shared transcript cursor every pass below reads through — see
|
|
22
27
|
// transcript-scan.mjs.
|
|
23
28
|
import { copyUsageBucket, hasSpend, mergeUsageByModel, newUsageTotals, scanTranscript } from "./transcript-scan.mjs";
|
|
@@ -495,6 +500,60 @@ function onRecapTail(sid, text, path) {
|
|
|
495
500
|
.catch(() => {});
|
|
496
501
|
}
|
|
497
502
|
|
|
503
|
+
// ─── Session activity ────────────────────────────────────────────────────
|
|
504
|
+
// What the session is doing now — the line Claude Code's agent view prints
|
|
505
|
+
// beside a working session, by the same rule and with no model call (see
|
|
506
|
+
// session-activity.mjs). Folded off the output watch's tail rather than the
|
|
507
|
+
// transcript cursor: the watch is what reads a file BETWEEN hook events, and
|
|
508
|
+
// between hook events is the whole of when this line changes. The cursor runs
|
|
509
|
+
// off hooks and would hold a sentence the model wrote until the next tool call.
|
|
510
|
+
//
|
|
511
|
+
// The fold is per session and survives across ticks, because one reply's blocks
|
|
512
|
+
// can land on either side of a tick and the sentence has to outrank the call it
|
|
513
|
+
// introduced whichever tick read it. The emit is gated on the text changing, so
|
|
514
|
+
// a reply that is still thinking says nothing.
|
|
515
|
+
const activityBySession = new Map(); // sid -> the fold, see newActivityState
|
|
516
|
+
|
|
517
|
+
function onActivityTail(sid, text) {
|
|
518
|
+
if (!sid || !text) return;
|
|
519
|
+
let st = activityBySession.get(sid);
|
|
520
|
+
if (!st) { st = newActivityState(); activityBySession.set(sid, st); }
|
|
521
|
+
const before = st.shown;
|
|
522
|
+
for (const line of text.split("\n")) foldActivityLine(st, line);
|
|
523
|
+
if (!st.shown || st.shown === before) return;
|
|
524
|
+
pushEvent({
|
|
525
|
+
hook_event_name: "ActivityObserved",
|
|
526
|
+
session_id: sid,
|
|
527
|
+
activity: { text: st.shown.text, source: st.shown.source, at: st.shown.at },
|
|
528
|
+
}, "internal");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Everything the output watch's tail feeds, in one tap: the recap and the
|
|
532
|
+
* activity line read the same bytes for different questions. */
|
|
533
|
+
function onTranscriptTail(sid, text, path) {
|
|
534
|
+
onRecapTail(sid, text, path);
|
|
535
|
+
onActivityTail(sid, text);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ─── Background job ──────────────────────────────────────────────────────
|
|
539
|
+
// Claude Code's own line for a background session — its state, what it is
|
|
540
|
+
// doing or asking, and what it got done — read from the job folder its agent
|
|
541
|
+
// view reads (see claude-jobs.mjs). Gated on a CHANGE like the recap, and on
|
|
542
|
+
// the words rather than the file's clock: the classifier rewrites `updatedAt`
|
|
543
|
+
// every fifteen seconds of a working turn, mostly to say the same thing. A
|
|
544
|
+
// session with no job emits nothing at all, and a job that goes away (deleted
|
|
545
|
+
// in the agent view) is said once, as null.
|
|
546
|
+
const jobBySession = new Map(); // sid -> signature last sent, "" once gone
|
|
547
|
+
|
|
548
|
+
function noteJob(sid, job) {
|
|
549
|
+
const sig = job ? JSON.stringify([job.state, job.detail, job.needs, job.suggestedReply, job.result]) : "";
|
|
550
|
+
const prev = jobBySession.get(sid);
|
|
551
|
+
if (prev === sig) return;
|
|
552
|
+
if (prev === undefined && !job) return;
|
|
553
|
+
jobBySession.set(sid, sig);
|
|
554
|
+
pushEvent({ hook_event_name: "JobObserved", session_id: sid, job: job ?? null }, "internal");
|
|
555
|
+
}
|
|
556
|
+
|
|
498
557
|
// ─── Context enrichment ──────────────────────────────────────────────────
|
|
499
558
|
// Approximation of `/context` since CC doesn't expose its breakdown via
|
|
500
559
|
// hooks. We scan the transcript JSONL for message counts (user / assistant
|
|
@@ -626,6 +685,10 @@ function forgetEnrichment(sid) {
|
|
|
626
685
|
nameBySession.delete(sid);
|
|
627
686
|
// The recap's gate, for the same reason — see noteRecap.
|
|
628
687
|
recapBySession.delete(sid);
|
|
688
|
+
// And the activity fold and the job's gate: a session heard from again
|
|
689
|
+
// after this says what it is doing and what its job says afresh.
|
|
690
|
+
activityBySession.delete(sid);
|
|
691
|
+
jobBySession.delete(sid);
|
|
629
692
|
nameReads.forget(sid);
|
|
630
693
|
modelReads.forget(sid);
|
|
631
694
|
usageReads.forget(sid);
|
|
@@ -641,6 +704,8 @@ function forgetEnrichment(sid) {
|
|
|
641
704
|
function clearEnrichmentGates() {
|
|
642
705
|
nameBySession.clear();
|
|
643
706
|
recapBySession.clear();
|
|
707
|
+
activityBySession.clear();
|
|
708
|
+
jobBySession.clear();
|
|
644
709
|
modelBySession.clear();
|
|
645
710
|
// The read stamps go with them. Clearing only the signatures would leave
|
|
646
711
|
// the next hook event inside MODEL_READ_THROTTLE_MS, so the transcript
|
|
@@ -658,5 +723,5 @@ function clearEnrichmentGates() {
|
|
|
658
723
|
export {
|
|
659
724
|
clearEnrichmentGates, forgetEnrichment, knownModelId, maybeResolveCodexMemory,
|
|
660
725
|
maybeResolveContext, maybeResolveModel, maybeResolveSessionName, maybeResolveUsage,
|
|
661
|
-
onRecapTail,
|
|
726
|
+
noteJob, onRecapTail, onTranscriptTail,
|
|
662
727
|
};
|
|
@@ -13,7 +13,10 @@ import { readBody, send } from "./http-io.mjs";
|
|
|
13
13
|
// What a session is producing between its tool calls — the 16.5% of measured
|
|
14
14
|
// time the hooks cannot see. See output-watch.mjs.
|
|
15
15
|
import { createOutputWatch } from "./output-watch.mjs";
|
|
16
|
-
|
|
16
|
+
// Claude Code's own line for a background session, from its job folder. See
|
|
17
|
+
// claude-jobs.mjs.
|
|
18
|
+
import { createJobWatch } from "./claude-jobs.mjs";
|
|
19
|
+
import { forgetEnrichment, noteJob, onTranscriptTail } from "./session-enrichment.mjs";
|
|
17
20
|
import { forgetCodexSession } from "./codex-enrichment.mjs";
|
|
18
21
|
// event-pipeline.mjs's pushEvent, reached without importing it — see
|
|
19
22
|
// event-sink.mjs.
|
|
@@ -96,6 +99,14 @@ const RECAP_WATCH_WINDOW_MS = 12 * 60 * 60_000;
|
|
|
96
99
|
const RECAP_WATCH_EVERY = 4;
|
|
97
100
|
let outputWatchTicks = 0;
|
|
98
101
|
|
|
102
|
+
/** The job folders, every other tick: three seconds, well inside the fifteen
|
|
103
|
+
* Claude Code itself waits between rewrites of a working job's line. The
|
|
104
|
+
* sessions asked about are the resting window's, not the live one's — a
|
|
105
|
+
* background job blocked on a question fires no hook for as long as nobody
|
|
106
|
+
* answers it, and that is the job whose line matters most. */
|
|
107
|
+
const jobWatch = createJobWatch();
|
|
108
|
+
const JOB_WATCH_EVERY = 2;
|
|
109
|
+
|
|
99
110
|
/** One tick: stat the recent sessions' transcripts, read only what grew, and
|
|
100
111
|
* say what landed. Everything expensive about this is guarded inside the watch
|
|
101
112
|
* — a session that wrote nothing costs one `stat`.
|
|
@@ -112,15 +123,23 @@ async function outputWatchOnce() {
|
|
|
112
123
|
const now = Date.now();
|
|
113
124
|
const cutoff = now - OUTPUT_WATCH_WINDOW_MS;
|
|
114
125
|
const restingCutoff = now - RECAP_WATCH_WINDOW_MS;
|
|
115
|
-
const
|
|
126
|
+
const tick = outputWatchTicks++;
|
|
127
|
+
const withResting = tick % RECAP_WATCH_EVERY === 0;
|
|
116
128
|
const live = new Set();
|
|
117
129
|
const polled = [];
|
|
130
|
+
const recent = [];
|
|
118
131
|
for (const [sid, at] of sessionTouchedAt) {
|
|
119
132
|
if (at >= cutoff) { live.add(sid); polled.push(sid); }
|
|
120
133
|
else if (withResting && at >= restingCutoff) polled.push(sid);
|
|
134
|
+
if (at >= restingCutoff) recent.push(sid);
|
|
135
|
+
}
|
|
136
|
+
if (recent.length && tick % JOB_WATCH_EVERY === 0) {
|
|
137
|
+
const jobs = await jobWatch.poll().catch(() => null);
|
|
138
|
+
if (jobs) for (const sid of recent) noteJob(sid, jobs.get(sid) ?? null);
|
|
121
139
|
}
|
|
122
140
|
if (!polled.length) return;
|
|
123
|
-
|
|
141
|
+
// The recap and the activity line, off the same tail — see onTranscriptTail.
|
|
142
|
+
const found = await outputWatch.poll(polled, onTranscriptTail);
|
|
124
143
|
for (const f of found) {
|
|
125
144
|
if (!live.has(f.sid)) continue;
|
|
126
145
|
pushEvent({
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// decides how often the machine is asked and when one that has never answered
|
|
6
6
|
// stops being asked (sampleThermal). This file is the asking: readThermal
|
|
7
7
|
// answers once per call, per platform, and nothing here keeps state between two
|
|
8
|
-
// calls
|
|
9
|
-
//
|
|
8
|
+
// calls — the one reading that needs the call before it, Linux throttling, is
|
|
9
|
+
// handed that call's answer back by the sampler. WARN_C and CRIT_C are exported
|
|
10
|
+
// because the history's thermal series fall back to them.
|
|
10
11
|
import { readdir, readFile } from "node:fs/promises";
|
|
11
12
|
import { run } from "./metrics-run.mjs";
|
|
12
13
|
|
|
@@ -31,6 +32,17 @@ import { run } from "./metrics-run.mjs";
|
|
|
31
32
|
// bands are the hardware's rather than ones invented here.
|
|
32
33
|
// /sys/class/thermal/thermal_zone*/ is the coarser fallback.
|
|
33
34
|
//
|
|
35
|
+
// Throttling comes from the kernel's own count of it, and for years
|
|
36
|
+
// it was not read at all: this branch answered `throttle: null`
|
|
37
|
+
// unconditionally, so a Linux machine never drew the row however
|
|
38
|
+
// hard it was being held back. Measured on an i5-12600H mini PC
|
|
39
|
+
// whose package sat at 90°C under a load of 41 — the panel showed
|
|
40
|
+
// the 90 and nothing else, while the kernel had logged 59 hours of
|
|
41
|
+
// package throttling in 17 days of uptime and was adding six seconds
|
|
42
|
+
// of it every ten. See readThrottleTime: Intel only, since the
|
|
43
|
+
// counters come from Intel's thermal-interrupt driver, and no row
|
|
44
|
+
// on a machine without them.
|
|
45
|
+
//
|
|
34
46
|
// macOS No CPU degrees without root, verified: `powermetrics --samplers
|
|
35
47
|
// smc` answers "powermetrics must be invoked as the superuser", and
|
|
36
48
|
// `ioreg -c AppleSMC -r -d 1` publishes no temperature key at all to
|
|
@@ -260,6 +272,91 @@ export async function readThermalZones(root = "/sys/class/thermal", deps = {}) {
|
|
|
260
272
|
return [known ?? found.reduce((a, b) => (b.celsius > a.celsius ? b : a))];
|
|
261
273
|
}
|
|
262
274
|
|
|
275
|
+
/**
|
|
276
|
+
* How long each CPU package has spent thermally throttled since boot, in
|
|
277
|
+
* milliseconds, keyed by package — or null on a machine that does not count it.
|
|
278
|
+
*
|
|
279
|
+
* Intel's thermal-interrupt driver publishes, per CPU,
|
|
280
|
+
* `cpuN/thermal_throttle/package_throttle_total_time_ms`: the time the
|
|
281
|
+
* package's thermal monitor has held the clock down, summed over every event.
|
|
282
|
+
* Measured on a live machine, ten seconds apart:
|
|
283
|
+
*
|
|
284
|
+
* cpu0 package_throttle_total_time_ms 212885052 → 212890804
|
|
285
|
+
* cpu15 package_throttle_total_time_ms 212885101 → 212891179
|
|
286
|
+
*
|
|
287
|
+
* Every CPU of a package carries its own copy of the package's count, and the
|
|
288
|
+
* copies differ by the few events each one saw late — so the package's figure
|
|
289
|
+
* is the largest of them, which is also the one that survives a CPU going
|
|
290
|
+
* offline. The `core_*` files beside them are one core's own throttling and are
|
|
291
|
+
* not read: the CPU row is the package, and a throttle row about one core of it
|
|
292
|
+
* would be a different reading under the same label.
|
|
293
|
+
*
|
|
294
|
+
* Absent on AMD and on every virtual machine, and on a kernel older than 5.12,
|
|
295
|
+
* which counts events but not their time. A count of events is not a share of
|
|
296
|
+
* anything, so those machines draw no row rather than a guess.
|
|
297
|
+
*/
|
|
298
|
+
export async function readThrottleTime(root = "/sys/devices/system/cpu", deps = {}) {
|
|
299
|
+
const dir = deps.readdir ?? readdir;
|
|
300
|
+
const file = deps.readFile ?? readFile;
|
|
301
|
+
const read = async path => { try { return String(await file(path, "utf8")).trim(); } catch { return null; } };
|
|
302
|
+
let cpus;
|
|
303
|
+
try { cpus = (await dir(root)).filter(n => /^cpu\d+$/.test(n)); } catch { return null; }
|
|
304
|
+
const held = {};
|
|
305
|
+
for (const cpu of cpus) {
|
|
306
|
+
const ms = Number(await read(`${root}/${cpu}/thermal_throttle/package_throttle_total_time_ms`) ?? NaN);
|
|
307
|
+
if (!Number.isFinite(ms) || ms < 0) continue;
|
|
308
|
+
const pkg = (await read(`${root}/${cpu}/topology/physical_package_id`)) ?? "0";
|
|
309
|
+
if (held[pkg] == null || ms > held[pkg]) held[pkg] = ms;
|
|
310
|
+
}
|
|
311
|
+
return Object.keys(held).length ? held : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The share of the time between two counts that the CPU spent throttled, as
|
|
316
|
+
* `{ timeHeld }`, or null when there are not two counts to compare.
|
|
317
|
+
*
|
|
318
|
+
* A SHARE OF TIME, NOT OF SPEED, and the shape says so rather than borrowing
|
|
319
|
+
* macOS's `speedLimit`. `pmset` reports how much of the clock the thermal
|
|
320
|
+
* manager allows; the kernel reports how long the clock was held down, and by
|
|
321
|
+
* how much it was held is not in the file. Writing 60% of the time as "held to
|
|
322
|
+
* 40% of full speed" would be a number this module made up.
|
|
323
|
+
*
|
|
324
|
+
* The worst package wins, for the reason the hottest sensor does: the question
|
|
325
|
+
* is whether this machine is being held back, and one package that is answers
|
|
326
|
+
* it. Any throttling at all is at least 1%, because a few milliseconds of it
|
|
327
|
+
* round to zero and the panel paints zero as "never held back".
|
|
328
|
+
*
|
|
329
|
+
* The kernel adds an event's time when the event ENDS, so one still running at
|
|
330
|
+
* a sample lands in the next. Events here run 5ms on average and 119ms at the
|
|
331
|
+
* longest, measured, so that is a sample's worth of lag at most — and the 100
|
|
332
|
+
* cap is for the one long event that lands whole in a single sample.
|
|
333
|
+
*/
|
|
334
|
+
export function throttleFromTime(prev, next) {
|
|
335
|
+
if (!prev || !next) return null;
|
|
336
|
+
const elapsed = next.atMs - prev.atMs;
|
|
337
|
+
if (!(elapsed > 0)) return null;
|
|
338
|
+
let worst = null;
|
|
339
|
+
for (const [pkg, ms] of Object.entries(next.held)) {
|
|
340
|
+
const before = prev.held[pkg];
|
|
341
|
+
if (before == null) continue;
|
|
342
|
+
const share = (Math.max(0, ms - before) / elapsed) * 100;
|
|
343
|
+
if (worst == null || share > worst) worst = share;
|
|
344
|
+
}
|
|
345
|
+
if (worst == null) return null;
|
|
346
|
+
return { timeHeld: worst > 0 ? Math.min(100, Math.max(1, Math.round(worst))) : 0 };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The share of the CPU the thermal manager took away, whichever way this
|
|
351
|
+
* platform measures it: the speed `pmset` says is no longer allowed, or the
|
|
352
|
+
* time the kernel says the clock was held down. The one number the chart, the
|
|
353
|
+
* strip and the row's bar all draw — mirrored by `heldShare` in machine-live.ts.
|
|
354
|
+
*/
|
|
355
|
+
export function heldShare(throttle) {
|
|
356
|
+
const held = "timeHeld" in throttle ? throttle.timeHeld : 100 - throttle.speedLimit;
|
|
357
|
+
return Math.max(0, Math.min(100, held));
|
|
358
|
+
}
|
|
359
|
+
|
|
263
360
|
/**
|
|
264
361
|
* GPU degrees out of `ioreg -r -k PerformanceStatistics`.
|
|
265
362
|
*
|
|
@@ -458,13 +555,25 @@ export function darwinThermal({ gpuC = null, throttle = null, macmon = {} } = {}
|
|
|
458
555
|
* collapsing them would let a throttle percentage be drawn under a °C heading
|
|
459
556
|
* — the thing the label rule exists to prevent. `swapLabel` earned that rule
|
|
460
557
|
* once already.
|
|
558
|
+
*
|
|
559
|
+
* `since` is the reading before this one. Only Linux uses it: its throttle is a
|
|
560
|
+
* difference between two counts, so the count rides along on the reading as
|
|
561
|
+
* `throttleCount` and the sampler hands it back on the next call. The first
|
|
562
|
+
* reading after a start therefore has no throttle yet, the same way `cpu` is
|
|
563
|
+
* null until two samples exist.
|
|
461
564
|
*/
|
|
462
|
-
export async function readThermal(platform = process.platform) {
|
|
565
|
+
export async function readThermal(platform = process.platform, since = null) {
|
|
463
566
|
if (platform === "linux") {
|
|
464
567
|
const sensors = await readHwmon();
|
|
465
568
|
const celsius = pickThermalRows(sensors);
|
|
466
569
|
const rows = celsius.length ? celsius : await readThermalZones();
|
|
467
|
-
|
|
570
|
+
const held = await readThrottleTime();
|
|
571
|
+
// performance.now rather than Date.now: a clock that NTP steps backwards
|
|
572
|
+
// between two samples would otherwise divide by a negative ten seconds.
|
|
573
|
+
const throttleCount = held ? { atMs: performance.now(), held } : null;
|
|
574
|
+
const throttle = throttleFromTime(since?.throttleCount, throttleCount);
|
|
575
|
+
if (!rows.length && !throttle) return null;
|
|
576
|
+
return { celsius: rows, throttle, ...(throttleCount ? { throttleCount } : {}) };
|
|
468
577
|
}
|
|
469
578
|
|
|
470
579
|
if (platform === "darwin") {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// thermalSnapshot on /api/system, and re-exports sampleThermal and
|
|
11
11
|
// THROTTLE_LABEL, which is where the suite reaches them.
|
|
12
12
|
import { pointsOf, record } from "./metrics-history.mjs";
|
|
13
|
-
import { readThermal } from "./thermal-metrics.mjs";
|
|
13
|
+
import { heldShare, readThermal } from "./thermal-metrics.mjs";
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
// Thermal: how often this machine is asked whether it is getting hot, and when
|
|
@@ -60,7 +60,7 @@ function recordThermal(reading, nowMs = Date.now()) {
|
|
|
60
60
|
// Stored as the share TAKEN AWAY, the same way the panel draws it, so the
|
|
61
61
|
// chart and the row cannot disagree about which direction is bad.
|
|
62
62
|
if (reading.throttle) {
|
|
63
|
-
record(`thermal:${THROTTLE_LABEL}`,
|
|
63
|
+
record(`thermal:${THROTTLE_LABEL}`, heldShare(reading.throttle), nowMs);
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -114,7 +114,9 @@ export async function sampleThermal(deps = {}) {
|
|
|
114
114
|
const read = deps.read ?? readThermal;
|
|
115
115
|
thermalInFlight = true;
|
|
116
116
|
try {
|
|
117
|
-
|
|
117
|
+
// The last reading goes back in because Linux throttling is the difference
|
|
118
|
+
// between its count and this one's. See readThermal.
|
|
119
|
+
const next = await read(process.platform, thermal);
|
|
118
120
|
if (next) { thermal = next; thermalMisses = 0; thermalEverAnswered = true; recordThermal(next); }
|
|
119
121
|
else if (++thermalMisses >= THERMAL_GIVE_UP) {
|
|
120
122
|
// DROP THE LAST READING. It used to be kept, and that is a number from
|
|
@@ -181,7 +183,11 @@ export function lastThermal() {
|
|
|
181
183
|
/** The thermal row /api/system answers: the last reading, with whether the
|
|
182
184
|
* machine has been held back since the deck started, or null without one. */
|
|
183
185
|
export function thermalSnapshot() {
|
|
184
|
-
|
|
186
|
+
if (!thermal) return null;
|
|
187
|
+
// The raw kernel count is the sampler's, for the next difference, and not a
|
|
188
|
+
// reading: the route answers what the panel draws.
|
|
189
|
+
const { throttleCount, ...shown } = thermal;
|
|
190
|
+
return { ...shown, heldBack: heldBackSoFar() };
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
/** The ten-second timer, made where startSystemMetrics in system-metrics.mjs
|