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.
- package/README.md +33 -0
- package/bin/deck.js +266 -61
- package/dist/web/assets/{index-6cAJDiBs.js → index-7UsJp5Ht.js} +40 -22
- package/dist/web/index.html +1 -1
- package/package.json +1 -4
- package/release-notes.json +28 -0
- package/src/server/boot-deadline.mjs +127 -0
- package/src/server/cswap-install.mjs +61 -8
- package/src/server/hwmonitor.mjs +56 -0
- package/src/server/index.mjs +102 -23
- package/src/server/lhm-parse.mjs +91 -0
- package/src/server/log-tail.mjs +139 -0
- package/src/server/macmon.mjs +299 -0
- package/src/server/open-url.mjs +226 -0
- package/src/server/quota.mjs +48 -2
- package/src/server/system-metrics.mjs +211 -18
- package/src/server/term.mjs +79 -3
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -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 {
|
|
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")
|
|
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
|
-
|
|
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
|
|
3412
|
-
* line
|
|
3413
|
-
*
|
|
3414
|
-
*
|
|
3415
|
-
*
|
|
3416
|
-
*
|
|
3417
|
-
*
|
|
3418
|
-
*
|
|
3419
|
-
*
|
|
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
|
|
3428
|
-
|
|
3429
|
-
|
|
3478
|
+
const replay = (evt) =>
|
|
3479
|
+
pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
|
|
3480
|
+
const parse = (line) => {
|
|
3430
3481
|
try {
|
|
3431
|
-
|
|
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
|
+
}
|