agent-dag 3.0.0 → 3.2.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.
@@ -40,7 +40,7 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-6cAJDiBs.js"></script>
43
+ <script type="module" crossorigin src="/assets/index-7UsJp5Ht.js"></script>
44
44
  <link rel="stylesheet" crossorigin href="/assets/index-Bnn7d7u8.css">
45
45
  </head>
46
46
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,9 +54,6 @@
54
54
  "url": "https://github.com/BarganConstantin/ccdeck/issues"
55
55
  },
56
56
  "homepage": "https://github.com/BarganConstantin/ccdeck#readme",
57
- "dependencies": {
58
- "open": "^10.1.0"
59
- },
60
57
  "devDependencies": {
61
58
  "@types/react": "^18.3.12",
62
59
  "@types/react-dom": "^18.3.1",
@@ -31,6 +31,34 @@
31
31
  "about a defect in the type, and the suite refuses both it and no space at",
32
32
  "all."
33
33
  ],
34
+ "3.2.0": [
35
+ {
36
+ "title": "🌡️ Windows shows a temperature where it can find one",
37
+ "body": "On most Windows laptops there is nothing to show, and that is Windows rather than this deck: modern machines declare no ACPI thermal zone, and the sensors that do exist sit behind an interface only an administrator may read. Measured on real hardware, elevated and not.\n\nOne thing does work, and it costs you nothing to have. If LibreHardwareMonitor happens to be running with its web server on, the deck now reads its numbers over localhost — which needs no privileges at all.\n\nIt is only ever a read. Nothing is installed, nothing is asked of you, and a machine without it shows no section, exactly as before."
38
+ }
39
+ ],
40
+ "3.1.0": [
41
+ {
42
+ "title": "🚀 The first run stops looking like it hung",
43
+ "body": "`npx ccdeck` on a machine that has never run it used to sit on a spinner at \"checking claude-swap\" and say nothing else. Measured on a Windows box: seventeen and a half seconds before it told you where to point a browser. It is now one and a half.\n\nThe deck was waiting for an install nobody asked for during that boot — a Python toolchain being fetched so the accounts panel would work. The port had been open the whole time; there was just nothing on screen to say so.\n\nIt no longer waits. Anything still working past the boot says so on its own line and keeps going in the background, and the deck comes up without it."
44
+ },
45
+ {
46
+ "title": "⚡ Every start is quicker, and stays quicker",
47
+ "body": "The event log was read from the beginning on every boot to fill a buffer that only keeps the last two thousand events — so four fifths of that reading existed to be thrown away, and it got slower the longer you had been using the deck. It reads from the end now and stops when the buffer is full: 258ms down to 20ms here, on a 34 MB log, and it no longer grows.\n\nThe accounts tool is also asked for its version once instead of twice, which on this machine was the single largest thing in an ordinary boot."
48
+ },
49
+ {
50
+ "title": "📉 Ten fewer packages to install",
51
+ "body": "Opening your browser used to cost a dependency that brought nine more with it — ten downloads on a cold `npx ccdeck` before the deck's own files were unpacked. It is now done with the commands the operating system already has.\n\nThe package has no runtime dependencies at all."
52
+ },
53
+ {
54
+ "title": "🌡️ Temperature, on the machines that could not show it",
55
+ "body": "On Windows the deck was asking somewhere it is not allowed to look: that reading lives behind a namespace that needs administrator, so it failed for everybody and the section never appeared. It reads a performance counter now, which needs nothing.\n\nOn Apple Silicon there is no command that ships with macOS which prints a temperature at all — the sensors are behind an interface only native code can reach. The deck fetches a small signed tool for it, verified against the checksum its release publishes, in the background and only on the Macs that need it. You do not have to install anything. `AGENTS_DECK_NO_DOWNLOAD=1` turns that off.\n\nA machine with no sensor still shows no section, which includes every virtual machine. Nothing is drawn that was not measured."
56
+ },
57
+ {
58
+ "title": "😌 The terminal goes quiet when there is nothing happening",
59
+ "body": "The dot at the bottom blinked every 800ms for as long as the deck ran, which is what a terminal does while it is working — so a deck that had finished in a second looked like one that never finished.\n\nIt now moves only while something actually is: a job still running, or hooks that cannot find the deck. Otherwise it is drawn once and left alone, and it says what the job is rather than just blinking. A step that takes more than three seconds shows how long it has been going, which is the whole of \"is this stuck\".\n\nAnd a machine with no Claude Code installed stops being told about it every few seconds — that one is said once now, on its own line instead of across the status line."
60
+ }
61
+ ],
34
62
  "3.0.0": [
35
63
  {
36
64
  "title": "📈 The machine panel now shows what it DID, not just what it is",
@@ -0,0 +1,127 @@
1
+ // The boot has a deadline, and the jobs under it do not get to decide it.
2
+ //
3
+ // #742. Two people on two operating systems reported the same thing: `npx
4
+ // ccdeck`, the wordmark, four rows, then a spinner at "checking claude-swap…"
5
+ // and nothing — no "server ready", no browser, no way to tell a slow machine
6
+ // from a dead one. The port was open the whole time. What they were watching
7
+ // was `reportStartup` awaiting `ensureCswap`, which on a machine that has
8
+ // neither claude-swap nor a Python toolchain downloads a uv binary under a
9
+ // 120-second deadline and then runs `uv tool install claude-swap` under a
10
+ // 180-second one. Five minutes, worst case, before the line that says where to
11
+ // point a browser — for a panel that is optional and that nobody asked for
12
+ // during that boot.
13
+ //
14
+ // The install itself is not the bug and is not cancelled here. What was wrong
15
+ // is that the boot waited for it. So every job whose failure is not fatal is
16
+ // given a slice of the boot rather than the whole of it, and a job that is
17
+ // still working when its slice runs out is SAID SO and left running. The row it
18
+ // would have printed is printed later, when it settles, which is the same
19
+ // contract the deck already has with its background upgrade.
20
+ //
21
+ // bin/deck.js's `update` job has raced a timer since it was written — this is
22
+ // that idea, given a name and the one thing the inline race could not do: tell
23
+ // "the job answered null" apart from "the job did not answer", which for
24
+ // claude-swap is the difference between "not installed" and "still installing".
25
+
26
+ /** How long the whole report may spend waiting on jobs that are not fatal.
27
+ *
28
+ * Eight seconds is chosen from the two ends it sits between. Below it are the
29
+ * probes a normal boot really does pay — `cswap --version`, `uv --version`,
30
+ * a PyPI lookup — which finish in well under a second on a warm machine and in
31
+ * two or three on a cold one behind a slow DNS; a deadline under that would
32
+ * turn every honest boot into a background one and the rows would stop being
33
+ * where a reader looks for them. Above it is the only thing the deadline
34
+ * exists to bound, and that one is measured in minutes, so there is no value
35
+ * in the middle that anybody would notice being wrong. */
36
+ export const BOOT_DEADLINE_MS = 8_000;
37
+
38
+ /**
39
+ * The deadline, as the environment may override it.
40
+ *
41
+ * Tests need a boot they can watch inside a test budget, and a test that has to
42
+ * wait eight real seconds to prove the deadline works is a test that costs the
43
+ * suite eight seconds forever. Read from the environment rather than passed
44
+ * down through four call sites for one caller that is not the product.
45
+ *
46
+ * Anything unparseable, negative, or absent is the default. Zero is honoured —
47
+ * "give the jobs nothing" is a coherent thing for a test to ask for, and the
48
+ * timer path below still runs, so it exercises the same code the product does.
49
+ */
50
+ export function bootDeadlineMs(env = process.env) {
51
+ const raw = env.AGENTS_DECK_BOOT_DEADLINE_MS;
52
+ if (raw === undefined || raw === "") return BOOT_DEADLINE_MS;
53
+ const n = Number(raw);
54
+ return Number.isFinite(n) && n >= 0 ? n : BOOT_DEADLINE_MS;
55
+ }
56
+
57
+ /**
58
+ * Await `work`, but never past `ms`.
59
+ *
60
+ * Answers `{ done: true, value }` or `{ done: false, value: undefined }`. A
61
+ * shape rather than a sentinel value, because three of the jobs this wraps
62
+ * resolve to `null` on purpose — "not attempted" is a real answer and must not
63
+ * read as "ran out of time".
64
+ *
65
+ * A rejection is a settled job, not a timeout: `{ done: true, value: undefined
66
+ * }`. Every caller here already attached its own rejection handler at the point
67
+ * the promise was created, so the throw has been dealt with and what is left to
68
+ * decide is only whether to keep waiting. Re-throwing would turn a job that
69
+ * failed politely into a boot that died.
70
+ *
71
+ * The timer is cleared the moment the job settles, so a boot whose jobs all
72
+ * answer at once does not hold the event loop for the rest of the deadline.
73
+ *
74
+ * It is deliberately NOT unref'd. An unref'd deadline is a deadline that does
75
+ * not fire when the only thing left in the process is the deadline itself —
76
+ * which is exactly the shape of a test that awaits nothing else, and would have
77
+ * made this function pass by exiting rather than by working. The cost of
78
+ * keeping it is at most one deadline's worth of a process that was about to
79
+ * end, against a deck that runs until Ctrl+C.
80
+ */
81
+ export function within(work, ms, { setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
82
+ return new Promise(resolve => {
83
+ let answered = false;
84
+ const timer = setTimer(() => {
85
+ if (answered) return;
86
+ answered = true;
87
+ resolve({ done: false, value: undefined });
88
+ }, ms);
89
+ Promise.resolve(work).then(
90
+ value => {
91
+ if (answered) return;
92
+ answered = true;
93
+ clearTimer(timer);
94
+ resolve({ done: true, value });
95
+ },
96
+ () => {
97
+ if (answered) return;
98
+ answered = true;
99
+ clearTimer(timer);
100
+ resolve({ done: true, value: undefined });
101
+ },
102
+ );
103
+ });
104
+ }
105
+
106
+ /**
107
+ * A deadline shared by everything that draws on it, in the order they are
108
+ * awaited.
109
+ *
110
+ * One budget for the whole report rather than one each, because the rows are
111
+ * awaited in sequence and per-job deadlines would multiply: four jobs at eight
112
+ * seconds is a thirty-two second boot that every individual deadline would call
113
+ * within its budget. What a reader was promised is that the deck is ready
114
+ * within the deadline, and that promise is about the sum.
115
+ *
116
+ * `left()` never goes below zero, so a job reached after the budget is spent is
117
+ * given a zero-length slice and reported as still working — which it is.
118
+ */
119
+ export function budget(ms, now = Date.now) {
120
+ const started = now();
121
+ return {
122
+ left: () => Math.max(0, ms - (now() - started)),
123
+ spent: () => now() - started,
124
+ /** Await `work` against whatever is left. */
125
+ within: (work, deps) => within(work, Math.max(0, ms - (now() - started)), deps),
126
+ };
127
+ }
@@ -266,15 +266,50 @@ export function cswapOwner(bin, platform = process.platform, env = process.env,
266
266
  }
267
267
 
268
268
  let _bin = null;
269
+
270
+ /**
271
+ * What the probe that resolved `_bin` printed, and when.
272
+ *
273
+ * #742: resolving the binary means running `cswap --version`, and reading the
274
+ * version means running `cswap --version`. Those were two separate spawns of a
275
+ * Python CLI a moment apart, and on this Mac each one costs between one and two
276
+ * and a half seconds — which made a probe of an ALREADY INSTALLED claude-swap
277
+ * the single largest thing in an ordinary boot.
278
+ *
279
+ * The second spawn is what this retires, and only the second: anything asking
280
+ * later gets a fresh answer, because a version read once at boot is not a
281
+ * version for the life of a deck that runs for days and may upgrade the tool
282
+ * underneath itself. Five seconds is long enough to cover cswapBin handing
283
+ * straight over to cswapVersion and far too short to be a cache.
284
+ */
285
+ let _probe = null;
286
+ const PROBE_FRESH_MS = 5_000;
287
+
288
+ /** "claude-swap 0.25.0" → "0.25.0", and "installed" for a copy that answered
289
+ * without a number in it. Shared so the memo and the spawn cannot disagree. */
290
+ function versionIn(r) {
291
+ const m = (r.stdout || r.stderr).trim().match(/(\d+\.\d+\.\d+\S*)/);
292
+ return m ? m[1] : "installed";
293
+ }
294
+
269
295
  export async function cswapBin() {
270
296
  // An explicit path wins over everything and is never cached away — someone
271
297
  // debugging a bad resolution needs it to take effect immediately.
272
298
  if (process.env.AGENTS_DECK_CSWAP) return process.env.AGENTS_DECK_CSWAP;
273
299
  if (_bin) return _bin;
274
- if ((await run("cswap", ["--version"], { timeout: 8_000 })).ok) return (_bin = "cswap");
300
+
301
+ const take = (spelling, r) => {
302
+ _probe = { version: versionIn(r), at: Date.now() };
303
+ return (_bin = spelling);
304
+ };
305
+
306
+ const bare = await run("cswap", ["--version"], { timeout: 8_000 });
307
+ if (bare.ok) return take("cswap", bare);
275
308
 
276
309
  for (const c of cswapCandidates()) {
277
- if (existsSync(c) && (await run(c, ["--version"], { timeout: 8_000 })).ok) return (_bin = c);
310
+ if (!existsSync(c)) continue;
311
+ const r = await run(c, ["--version"], { timeout: 8_000 });
312
+ if (r.ok) return take(c, r);
278
313
  }
279
314
  return "cswap"; // not found; leave the bare name so errors read sensibly
280
315
  }
@@ -299,15 +334,18 @@ export async function cswapBin() {
299
334
  * ensureCswap below, whose return value says nothing about which binary the
300
335
  * following twenty account operations will be sent to. See cswap-bin-memo.test.ts.
301
336
  */
302
- export function resetCswapBin() { _bin = null; }
337
+ export function resetCswapBin() { _bin = null; _probe = null; }
303
338
 
304
339
  /** Installed version string, or null when cswap cannot be found. */
305
340
  export async function cswapVersion() {
306
- const r = await run(await cswapBin(), ["--version"]);
341
+ const bin = await cswapBin();
342
+ // The call above may have just asked this very question — see _probe. Nothing
343
+ // is remembered past PROBE_FRESH_MS, so this is the second half of one
344
+ // lookup rather than a cache of the answer.
345
+ if (_probe && Date.now() - _probe.at < PROBE_FRESH_MS) return _probe.version;
346
+ const r = await run(bin, ["--version"]);
307
347
  if (!r.ok) return null;
308
- // "claude-swap 0.25.0" → "0.25.0"
309
- const m = (r.stdout || r.stderr).trim().match(/(\d+\.\d+\.\d+\S*)/);
310
- return m ? m[1] : "installed";
348
+ return versionIn(r);
311
349
  }
312
350
 
313
351
  /**
@@ -536,8 +574,19 @@ async function findUpgrader(owner) {
536
574
  *
537
575
  * Returns a small status the CLI prints verbatim:
538
576
  * { state: "present" | "installed" | "upgrading" | "skipped" | "unavailable", ... }
577
+ *
578
+ * `onInstalling` is called at most once, at the moment this stops asking
579
+ * questions and commits to an install — which is the moment the answer stops
580
+ * being seconds away and starts being minutes away. #742: the boot used to have
581
+ * no way to tell those two apart, so it waited out its whole deadline on a
582
+ * machine whose answer was already decided. Everything before that call is
583
+ * probes; everything after it is a uv download and an environment build.
584
+ *
585
+ * Deliberately a callback and not a state on the return value: what the caller
586
+ * needs is the news, not the outcome, and the outcome is the thing that takes
587
+ * three minutes to arrive.
539
588
  */
540
- export async function ensureCswap() {
589
+ export async function ensureCswap({ onInstalling = null } = {}) {
541
590
  if (process.env.AGENTS_DECK_NO_INSTALL === "1") {
542
591
  const version = await cswapVersion();
543
592
  return version ? { state: "present", version } : { state: "skipped" };
@@ -565,6 +614,10 @@ export async function ensureCswap() {
565
614
  return { state: "present", version: existing };
566
615
  }
567
616
 
617
+ // Said before the install starts rather than after it, because after it is
618
+ // three minutes later and the whole point is not to be waited for.
619
+ try { onInstalling?.(); } catch { /* a caller's notification is not our problem */ }
620
+
568
621
  const result = await installCswap();
569
622
  if (!result.ok) return { state: "unavailable", ...result };
570
623
  // Something was just installed, so any path resolved before it is a guess made
@@ -0,0 +1,56 @@
1
+ // Windows' temperature, when something else on the machine already has it.
2
+ //
3
+ // #747. On a modern Intel laptop Windows publishes no CPU temperature that an
4
+ // ordinary process may read. Measured on a physical Windows 11 machine, both
5
+ // elevated and not: the firmware declares zero ACPI thermal zones, MSAcpi
6
+ // answers "Not supported" even to an administrator, Win32_TemperatureProbe is a
7
+ // stub whose every field reads 32768, and the one source that DOES have the
8
+ // numbers — Intel Dynamic Tuning's `EsifDeviceInformation` — is Access denied
9
+ // without admin. That is the state of Windows, not a gap in this deck: there is
10
+ // no standard user-mode API for it, which is why every tool that shows one
11
+ // installs a kernel driver.
12
+ //
13
+ // LibreHardwareMonitor is such a tool. It installs that driver, reads the
14
+ // registers directly, and — if its web server is switched on — publishes
15
+ // everything as plain HTTP on localhost. Reading THAT needs no privileges at
16
+ // all.
17
+ //
18
+ // SO THIS IS A READ, NEVER A REQUEST. The deck does not install
19
+ // LibreHardwareMonitor, does not ask anybody to, and does not mention it: a
20
+ // user must do nothing but install ccdeck. If the tool happens to be running —
21
+ // and on the machines where this matters it often is, because the person who
22
+ // wants a temperature has already gone and got one — the deck uses it. If not,
23
+ // the section is not drawn, exactly as before.
24
+ import { readTemps } from "./lhm-parse.mjs";
25
+
26
+ /** LibreHardwareMonitor's default, and Open Hardware Monitor's before it. Not
27
+ * probed across a range: a scan of somebody's loopback ports is not a thing to
28
+ * do unasked, and a user who moved the port can say so. */
29
+ export const LHM_URL = "http://127.0.0.1:8085/data.json";
30
+
31
+ /** Short. A refused connection returns at once; this bounds the case where
32
+ * something else holds the port open and never answers. */
33
+ const TIMEOUT_MS = 1_500;
34
+
35
+ export function lhmUrl(env = process.env) {
36
+ const port = Number(env.AGENTS_DECK_LHM_PORT);
37
+ return Number.isInteger(port) && port > 0 && port < 65536
38
+ ? `http://127.0.0.1:${port}/data.json`
39
+ : LHM_URL;
40
+ }
41
+
42
+ /**
43
+ * CPU and GPU degrees from a running LibreHardwareMonitor, or nothing.
44
+ *
45
+ * Never throws: nothing listening is the ordinary answer, and it arrives as a
46
+ * rejected fetch.
47
+ */
48
+ export async function readHwMonitorTemps({ env = process.env, fetchFn = fetch } = {}) {
49
+ try {
50
+ const res = await fetchFn(lhmUrl(env), { signal: AbortSignal.timeout(TIMEOUT_MS) });
51
+ if (!res.ok) return {};
52
+ return readTemps(await res.json());
53
+ } catch {
54
+ return {};
55
+ }
56
+ }
@@ -5,12 +5,11 @@
5
5
  // describes. Nothing else in this file talks to anything but 127.0.0.1 clients.
6
6
  import { createServer, request as httpRequest } from "node:http";
7
7
  import { readFile, stat, mkdir, open, truncate, readdir, unlink } from "node:fs/promises";
8
- import { createReadStream, existsSync, readFileSync, realpath as realpathCb, realpathSync } from "node:fs";
8
+ import { existsSync, readFileSync, realpath as realpathCb, realpathSync } from "node:fs";
9
9
  import { extname, join, resolve, sep, dirname as pdirname } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { dirname } from "node:path";
13
- import { createInterface } from "node:readline";
14
13
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
15
14
  import { promisify } from "node:util";
16
15
  import { claudeConfigDir } from "./claude-dir.mjs";
@@ -19,6 +18,7 @@ import { PRODUCT } from "./brand.mjs";
19
18
  import { invokedName, renameNotice } from "./invoked-as.mjs";
20
19
  import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
21
20
  import { historySnapshot, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
21
+ import { linesFromEnd, linesFromStart } from "./log-tail.mjs";
22
22
 
23
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
24
  const PKG_ROOT = resolve(__dirname, "..", "..");
@@ -3360,9 +3360,34 @@ function pushEvent(raw, source, opts = {}) {
3360
3360
  * two replays.
3361
3361
  */
3362
3362
  export function replayScope(workspace, platform = process.platform) {
3363
- if (!workspace || typeof workspace !== "string") return () => true;
3363
+ if (!workspace || typeof workspace !== "string") {
3364
+ const all = () => true;
3365
+ // Every caller gets the same answer for the same payload, forever. That is
3366
+ // what lets replayLog read the log from its end — see `orderDependent` on
3367
+ // the scoped predicate below for the half that cannot.
3368
+ all.orderDependent = false;
3369
+ return all;
3370
+ }
3364
3371
  const bySession = new Map();
3365
- return function admits(payload) {
3372
+ /**
3373
+ * THE ANSWER DEPENDS ON WHAT CAME BEFORE, and #742 is why that is now stated
3374
+ * out loud rather than left as an implementation detail.
3375
+ *
3376
+ * The synthetic enrichment events — ModelObserved, UsageObserved,
3377
+ * SessionNamed, ContextObserved — carry a session_id and no cwd, so the only
3378
+ * thing that can decide them is a cwd-bearing event for the same session,
3379
+ * and that event is EARLIER in the log. Fed the log backwards, this predicate
3380
+ * meets the enrichment first, has nothing in `bySession`, and drops it: the
3381
+ * session lands on the canvas with no model and no tokens.
3382
+ *
3383
+ * So the flag is not advice. replayLog reads it, and reads the file forwards
3384
+ * whenever it is set. Making a scoped replay cheap needs an index of where a
3385
+ * workspace's lines are, which is a different change from this one.
3386
+ */
3387
+ admits.orderDependent = true;
3388
+ return admits;
3389
+
3390
+ function admits(payload) {
3366
3391
  if (!payload || typeof payload !== "object") return false;
3367
3392
  if (payload.hook_event_name === "__clear") return true;
3368
3393
  const sid = typeof payload.session_id === "string" ? payload.session_id : null;
@@ -3408,38 +3433,92 @@ export function replayScope(workspace, platform = process.platform) {
3408
3433
  * declining a session it was told not to capture is the flag doing its job. They
3409
3434
  * are two different things and only the first is ever printed.
3410
3435
  *
3411
- * WHAT THE FILTER COSTS AT BOOT, honestly: nothing is saved on the read. Every
3412
- * line is still streamed off disk and still JSON.parsed, because the cwd being
3413
- * judged is inside the JSON a 30 MB log is 30 MB of reading and parsing on a
3414
- * scoped deck exactly as on an unscoped one. What it saves is everything after
3415
- * the parse: no redaction pass, no envelope, no ring insert, no character
3416
- * accounting and no eviction pressure for a line this deck should never have
3417
- * held. The ring is bounded by MAX_BUFFER events AND MAX_BUFFER_CHARS, so on a
3418
- * busy machine the out-of-scope traffic was not merely extra — it was evicting
3419
- * the in-scope sessions the user started the deck to watch.
3436
+ * WHAT THE FILTER COSTS AT BOOT, honestly: nothing is saved on the read of any
3437
+ * line this reaches, because the cwd being judged is inside the JSON. What it
3438
+ * saves is everything after the parse: no redaction pass, no envelope, no ring
3439
+ * insert, no character accounting and no eviction pressure for a line this deck
3440
+ * should never have held. The ring is bounded by MAX_BUFFER events AND
3441
+ * MAX_BUFFER_CHARS, so on a busy machine the out-of-scope traffic was not
3442
+ * merely extra it was evicting the in-scope sessions the user started the
3443
+ * deck to watch.
3444
+ *
3445
+ * READ BACKWARDS, AND ONLY AS FAR AS THE RING (#742). This used to stream the
3446
+ * whole file from the front, and the whole file is where the boot's time went:
3447
+ * 12,079 lines and 31 MB on the machine it was measured on, 690ms of
3448
+ * JSON.parse, growing with every session until rotation cuts it at 50 MB — to
3449
+ * fill a ring that holds two thousand events. Five sixths of that parse was
3450
+ * feeding the eviction loop, on the critical path of a boot, every time.
3451
+ *
3452
+ * So the lines arrive newest-first and the loop stops the moment the ring is
3453
+ * full, which makes the cost a property of MAX_BUFFER rather than of how long
3454
+ * the user has been running the deck. Nothing is lost by it: what a forward
3455
+ * replay left in the ring was always the NEWEST admitted events that fit, and
3456
+ * that is exactly the set this collects. A young log — too few events to fill
3457
+ * the ring — is read to its start, and costs what it always did.
3458
+ *
3459
+ * The order of the pushes is still oldest-first. `seq` is assigned by pushEvent
3460
+ * in the order it is called, and a ring numbered backwards would hand every
3461
+ * resuming client a Last-Event-ID that means the opposite of what it says.
3462
+ *
3463
+ * A SCOPED DECK STILL READS FORWARDS, and that is not an oversight. Its
3464
+ * predicate decides the cwd-less enrichment events — ModelObserved,
3465
+ * UsageObserved, SessionNamed, ContextObserved — from the cwd-bearing event
3466
+ * earlier in the log, so backwards it meets the answer after the question and
3467
+ * drops them: the session arrives on the canvas with no model and no tokens.
3468
+ * `replayScope` says which kind of predicate it handed over rather than this
3469
+ * inferring it from the workspace string, so the two cannot drift apart. Making
3470
+ * that case cheap needs an index of where a workspace's lines are, which is a
3471
+ * different change from this one.
3420
3472
  */
3421
3473
  async function replayLog(filePath, workspace = "") {
3422
3474
  if (!existsSync(filePath)) return 0;
3423
- let count = 0;
3424
3475
  let skipped = 0;
3425
3476
  let skippedBytes = 0;
3426
3477
  const admits = replayScope(workspace);
3427
- const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }) });
3428
- for await (const line of rl) {
3429
- if (!line) continue;
3478
+ const replay = (evt) =>
3479
+ pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
3480
+ const parse = (line) => {
3430
3481
  try {
3431
- const evt = JSON.parse(line);
3432
- if (evt && typeof evt === "object" && evt.payload) {
3433
- if (!admits(evt.payload)) continue;
3434
- pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
3435
- count++;
3436
- }
3482
+ return JSON.parse(line);
3437
3483
  } catch {
3438
3484
  skipped++;
3439
3485
  skippedBytes += Buffer.byteLength(line, "utf8");
3486
+ return null;
3487
+ }
3488
+ };
3489
+ const usable = (evt) => evt && typeof evt === "object" && evt.payload;
3490
+
3491
+ let count = 0;
3492
+ if (admits.orderDependent) {
3493
+ for await (const line of linesFromStart(filePath)) {
3494
+ if (!line) continue;
3495
+ const evt = parse(line);
3496
+ if (!usable(evt) || !admits(evt.payload)) continue;
3497
+ replay(evt);
3498
+ count++;
3499
+ }
3500
+ } else {
3501
+ // Newest first, so this is filled back to front and then walked in reverse
3502
+ // to push. Bounded by MAX_BUFFER, which is what makes the memory here a
3503
+ // property of the ring rather than of the file.
3504
+ const newestFirst = [];
3505
+ for await (const line of linesFromEnd(filePath)) {
3506
+ if (!line) continue;
3507
+ const evt = parse(line);
3508
+ if (!usable(evt) || !admits(evt.payload)) continue;
3509
+ newestFirst.push(evt);
3510
+ // Everything older than this would be evicted by the events already held,
3511
+ // so reading further is work whose only result is throwing it away.
3512
+ if (newestFirst.length >= MAX_BUFFER) break;
3440
3513
  }
3514
+ for (let i = newestFirst.length - 1; i >= 0; i--) replay(newestFirst[i]);
3515
+ count = newestFirst.length;
3441
3516
  }
3442
3517
  if (skipped > 0) {
3518
+ // "in the part of the log it read", because that is now a part rather than
3519
+ // the whole: a damaged line older than the ring is never reached, and
3520
+ // claiming to have counted every unreadable line in the file would be a
3521
+ // number this no longer has.
3443
3522
  const kb = (skippedBytes / 1024).toFixed(0);
3444
3523
  console.warn(`${PRODUCT}: skipped ${skipped} unreadable line(s) (${kb}KB) while replaying the event log`);
3445
3524
  }
@@ -0,0 +1,91 @@
1
+ // Reading LibreHardwareMonitor's sensor tree, out of the shape its own source
2
+ // produces rather than out of an example.
3
+ //
4
+ // `GenerateJsonForNode` in HttpServer.cs builds every node as
5
+ //
6
+ // { id, Text, Min, Value, Max }
7
+ //
8
+ // and a SENSOR node adds
9
+ //
10
+ // SensorId : "/intelcpu/0/temperature/0" the stable identifier
11
+ // Type : "Temperature" the SensorType enum, as text
12
+ // Value : "52.0 °C" formatted for a human
13
+ // RawValue : 52.0 the number, unformatted
14
+ //
15
+ // Two of those decide this file.
16
+ //
17
+ // RAWVALUE, NEVER VALUE. `Value` is formatted with the machine's culture, so on
18
+ // a German or Russian Windows it reads "52,0 °C" — a comma — and every naive
19
+ // parse of it either throws away the decimal or produces 520. `RawValue` is the
20
+ // number itself. This is the same trap the deck already hit once with `ps`
21
+ // output and fixed with LC_NUMERIC.
22
+ //
23
+ // AND RAWVALUE CAN BE THE STRING "NaN". The server serialises with
24
+ // JsonNumberHandling.AllowNamedFloatingPointLiterals, which writes a NaN as a
25
+ // quoted "NaN" rather than failing — so a sensor that has not read yet arrives
26
+ // as text where a number is expected. `Number("NaN")` is NaN and is rejected
27
+ // below, which is the right answer, but it is rejected on purpose rather than
28
+ // by luck.
29
+ //
30
+ // TYPE, NEVER THE NAME. `Type` comes from an enum and is the same word on every
31
+ // machine in every language; the `Text` beside it is a display name that
32
+ // differs between vendors and driver versions.
33
+
34
+ /** A plausible temperature. The same floor the rest of the thermal code uses:
35
+ * 0 is a sensor that has not read, and nothing above this is a temperature. */
36
+ const plausible = (c) => Number.isFinite(c) && c > 0 && c < 150;
37
+
38
+ /**
39
+ * Every temperature sensor in the tree, flattened.
40
+ *
41
+ * Exported for its own test, and because "what did that machine actually
42
+ * publish" is the question anybody debugging this will have first.
43
+ */
44
+ export function flattenSensors(root) {
45
+ const out = [];
46
+ const walk = (n, depth) => {
47
+ // The tree is four or five deep in practice — root, computer, hardware,
48
+ // type, sensor. The bound is against a cycle, which JSON cannot contain but
49
+ // a hand-written fixture can.
50
+ if (!n || typeof n !== "object" || depth > 12) return;
51
+ if (n.Type === "Temperature") {
52
+ out.push({
53
+ id: String(n.SensorId ?? ""),
54
+ name: String(n.Text ?? ""),
55
+ celsius: Number(n.RawValue),
56
+ });
57
+ }
58
+ if (Array.isArray(n.Children)) for (const c of n.Children) walk(c, depth + 1);
59
+ };
60
+ walk(root, 0);
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Which sensor is the CPU, and which is the GPU.
66
+ *
67
+ * Chosen by SensorId rather than by name. The identifier is built from the
68
+ * hardware type — `/intelcpu/0/…`, `/amdcpu/0/…`, `/gpu-nvidia/0/…` — and is
69
+ * the same on every machine, while `Text` is a display name that reads "CPU
70
+ * Package" on one driver and "Core (Tctl/Tdie)" on another.
71
+ *
72
+ * The HOTTEST of a hardware's sensors is taken, not the first. A CPU publishes
73
+ * a package reading and one per core; the package is usually the highest and is
74
+ * what a person means by "the CPU temperature", and where a vendor publishes no
75
+ * package the hottest core is the honest stand-in. Taking the first would
76
+ * report core #1 while core #6 is thermal-throttling.
77
+ */
78
+ export function readTemps(root) {
79
+ const sensors = flattenSensors(root).filter(s => plausible(s.celsius));
80
+ const hottest = (re) => {
81
+ const mine = sensors.filter(s => re.test(s.id));
82
+ if (!mine.length) return null;
83
+ return Math.round(Math.max(...mine.map(s => s.celsius)));
84
+ };
85
+ const out = {};
86
+ const cpu = hottest(/^\/(intel|amd)cpu\//i);
87
+ const gpu = hottest(/^\/gpu-/i);
88
+ if (cpu != null) out.cpu = cpu;
89
+ if (gpu != null) out.gpu = gpu;
90
+ return out;
91
+ }