ccdeck 3.22.14 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +252 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -182
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +385 -51
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +495 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BdYsAtN8.js +0 -274
  51. package/dist/web/assets/index-CgwmcBHv.css +0 -1
@@ -460,17 +460,90 @@ export function hostPackage(pkgRoot, name = PUBLISHED_NAME) {
460
460
  * sit above us is not evidence.
461
461
  *
462
462
  * Guarded on our own manifest being unreadable, so nothing changes for an
463
- * install that is intact — including the ordinary nested layout before it is
464
- * upgraded, where pkgRoot answers for itself and this is never consulted.
463
+ * install that is intact — except for the one replacement that leaves a
464
+ * readable manifest behind, which is nestedDeckRoot below.
465
465
  */
466
466
  export function successorRoot(pkgRoot) {
467
- if (readManifest(pkgRoot)) return null;
467
+ if (readManifest(pkgRoot)) return nestedDeckRoot(pkgRoot);
468
468
  const root = hostRoot(pkgRoot);
469
469
  if (!root) return null;
470
470
  const name = readManifest(root)?.name;
471
471
  return typeof name === "string" && ALIAS_PACKAGES.includes(name) ? root : null;
472
472
  }
473
473
 
474
+ /**
475
+ * The other direction the same replacement can go: the deck moved DOWN, into a
476
+ * node_modules underneath the directory it used to be.
477
+ *
478
+ * `npm i -g agents-deck` before 3.22.3 installed the deck flat — that directory
479
+ * WAS the deck, bin/ and src/ and hook/ and all. What the registry serves for
480
+ * that name now is a 5 KB pointer package: a shim.js, a manifest, and a
481
+ * dependency on `ccdeck`. So reinstalling it replaces the deck with the pointer
482
+ * and puts the real deck one level down, in `<pkgRoot>/node_modules/ccdeck`.
483
+ *
484
+ * The guard above cannot see that, and that is the whole of #975. npm's reify
485
+ * deleted bin/ and src/ but WROTE a package.json in their place, so
486
+ * `readManifest(pkgRoot)` answers — with the shim's manifest — and every reader
487
+ * downstream reads "intact". successorRoot returned null, replacedNote was
488
+ * handed `moved: null` and stayed silent, and the supervisor went on spawning a
489
+ * bin/deck.js npm had just removed: five crash restarts in ten minutes and then
490
+ * a deck that stops for good, with nothing on screen about an upgrade.
491
+ *
492
+ * Three facts together, because none of them alone is evidence. Our name is one
493
+ * of the retired ones; our manifest declares a dependency on the published name,
494
+ * which a deck's own manifest never does; and the package that dependency names
495
+ * is really sitting under us. A deck that merely vendors something, or a
496
+ * retired-name package from before the pointer, matches none of them.
497
+ */
498
+ function nestedDeckRoot(pkgRoot) {
499
+ const self = readManifest(pkgRoot);
500
+ if (!RETIRED_NAMES.includes(self?.name)) return null;
501
+ if (typeof self?.dependencies?.[PUBLISHED_NAME] !== "string") return null;
502
+ const root = join(pkgRoot, "node_modules", PUBLISHED_NAME);
503
+ return readManifest(root)?.name === PUBLISHED_NAME ? root : null;
504
+ }
505
+
506
+ /**
507
+ * The retired package this install IS, when reinstalling that package would
508
+ * replace a working deck with the pointer npm now serves for it — or null,
509
+ * which is every other install shape on the planet.
510
+ *
511
+ * `npm i -g agents-deck` and `npm i -g agent-dag` at 3.22.1 or earlier put the
512
+ * deck straight into `<prefix>/lib/node_modules/<name>`: no host above it, no
513
+ * nested copy under it, that directory is the deck. registryName's exception
514
+ * asks npm about `ccdeck` for such an install and is right to — a retired name's
515
+ * dist-tag stopped moving — so the deck correctly learns that a newer version
516
+ * exists. What it then offers is `npm i -g agents-deck@latest`, and since 3.22.8
517
+ * that command fetches 5 KB of shim and deletes the deck to make room for it.
518
+ *
519
+ * The nested layout is the case registryName's comment describes and it is
520
+ * unaffected: there the retired package is the host, the deck is its dependency,
521
+ * and reinstalling the host re-resolves `ccdeck@^3` to the newest deck. The
522
+ * difference is entirely whether the retired name is the wrapper or the thing
523
+ * inside it, and a wrapper is a package — so the directory test below answers
524
+ * that too, before it is asked whose name is on it.
525
+ *
526
+ * Confined to the global layout — `<prefix>/lib/node_modules/<us>` on POSIX,
527
+ * `<prefix>/node_modules/<us>` on Windows — because that is the only tree
528
+ * `npm i -g` rewrites. The test for it is that the directory holding our
529
+ * node_modules is not a package: a global prefix's `lib` has no manifest, while
530
+ * a workspace, a CI job or any tool that vendors the deck as a dependency does.
531
+ * Reinstalling a retired name from inside one of THOSE writes a global tree the
532
+ * project never reads and leaves its copy exactly where it is — a different
533
+ * complaint, and not one to answer by telling somebody to uninstall.
534
+ *
535
+ * A checkout and an npx run are refused before this in every caller, and are
536
+ * refused here too, so the predicate is true on its own terms rather than only
537
+ * in the order it happens to be asked.
538
+ */
539
+ export function frozenNameInstall(pkgRoot, name = PUBLISHED_NAME) {
540
+ if (isGitCheckout(pkgRoot) || isNpxInstall(pkgRoot)) return null;
541
+ const above = hostRoot(pkgRoot);
542
+ if (!above || readManifest(above)) return null;
543
+ const self = installedName(pkgRoot, name);
544
+ return RETIRED_NAMES.includes(self) ? self : null;
545
+ }
546
+
474
547
  /** The package an upgrade would actually install here — and, but for a retired
475
548
  * name, the one worth asking npm about (see registryName).
476
549
  *
@@ -539,6 +612,14 @@ export function upgradeCommand(pkgRoot, name = PUBLISHED_NAME) {
539
612
  // so `npm run build` is part of the answer rather than an afterthought.
540
613
  if (isGitCheckout(pkgRoot)) return "git pull && npm run build";
541
614
  if (isNpxInstall(pkgRoot)) return `npx -y ${upgradeName(pkgRoot, name)}@latest`;
615
+ // A flat install of a retired name cannot be updated by reinstalling itself —
616
+ // that fetches the pointer package and deletes the deck (frozenNameInstall).
617
+ // The command has to move them onto the published name, and the removal is
618
+ // not optional: the old package owns `<prefix>/bin/ccdeck` as well as its own
619
+ // command, so installing ccdeck alongside it is npm linking a bin over a file
620
+ // another package still claims.
621
+ const frozen = frozenNameInstall(pkgRoot, name);
622
+ if (frozen) return `npm rm -g ${frozen} && npm i -g ${PUBLISHED_NAME}`;
542
623
  // Through upgradeName for the same reason the npx line above it is: the
543
624
  // printed command is the user's escape hatch when the button fails or is not
544
625
  // offered, and one that names a package this install cannot be replaced by is
@@ -897,7 +978,7 @@ export function upgradeSpec(target, platform = process.platform, deps) {
897
978
  * Pure so the policy can be read and tested on its own — it is the part that
898
979
  * decides whether we are allowed to write to the user's machine.
899
980
  */
900
- export function upgradeBlockedReason({ git, npx, writable, optedOut }) {
981
+ export function upgradeBlockedReason({ git, npx, writable, optedOut, frozen }) {
901
982
  if (optedOut) return "opted_out";
902
983
  // The maintainer's own tree. Its version leads npm's, and installing over it
903
984
  // would replace a working copy with a published tarball.
@@ -906,6 +987,15 @@ export function upgradeBlockedReason({ git, npx, writable, optedOut }) {
906
987
  // place — `npx agents-deck@latest` fetches a DIFFERENT directory, which this
907
988
  // process could not switch to even after restarting.
908
989
  if (npx) return "npx";
990
+ // A flat install of a retired name: the one shape where `npm i -g <us>@latest`
991
+ // runs cleanly, reports success, and leaves no deck behind — the registry
992
+ // serves a 5 KB pointer for those names now, and npm's reify removes bin/,
993
+ // src/ and hook/ to make room for it. Every other refusal here is "this
994
+ // install cannot be written over"; this one is "what the write would fetch is
995
+ // not a deck", so it is asked before writability rather than after: on a
996
+ // prefix nobody can write, the remedy is still a different command, and
997
+ // naming the wrong one is what #975 is about.
998
+ if (frozen) return "retired_name";
909
999
  // Almost always a root-owned global prefix. Failing inside npm with EACCES
910
1000
  // tells the user less than declining up front does.
911
1001
  if (!writable) return "not_writable";
@@ -996,6 +1086,10 @@ export function upgradeBlock(pkgRoot) {
996
1086
  // node_modules), so both have to be ours to write.
997
1087
  writable: dirWritable(target) && dirWritable(resolve(target, "..")),
998
1088
  optedOut: process.env.AGENTS_DECK_NO_INSTALL === "1",
1089
+ // Whether the package that write would fetch is still a deck. Kept out of
1090
+ // the pure rule above, like every other input here, so the policy stays one
1091
+ // readable expression and this file owns the filesystem half of it.
1092
+ frozen: frozenNameInstall(pkgRoot) !== null,
999
1093
  });
1000
1094
  }
1001
1095
 
@@ -0,0 +1,89 @@
1
+ // Claude Code's own account of where a session stands, read off its transcript.
2
+ //
3
+ // WHAT IT IS. Once a turn has been finished for three minutes and the terminal
4
+ // is not focused, Claude Code writes one line into the transcript — the
5
+ // "※ recap:" the terminal prints when you come back to it:
6
+ //
7
+ // {"type":"system","subtype":"away_summary","content":"VCRM-8867 is planned: … (disable recaps in /config)","timestamp":"…",…}
8
+ //
9
+ // Measured on this machine on 2026-09-14: 60 of them in two days of
10
+ // transcripts, 181 to 305 characters each, written a median 3.1 minutes after
11
+ // the line before them. Claude Code caps them at 400 characters, writes one only
12
+ // once a session has three turns, and never writes two in a row.
13
+ //
14
+ // WHY THE DECK WANTS IT. It is written at the moment the person has left the
15
+ // terminal, which is exactly when they are looking at the deck instead. It is
16
+ // the only line in the file written for a human who is catching up, and it is
17
+ // written in their language.
18
+ //
19
+ // WHEN IT STOPS BEING TRUE. A recap describes the session as the last turn left
20
+ // it, so the next turn makes it history: a main-chain assistant line after it
21
+ // retires it here. The client holds the other half of that rule — a prompt
22
+ // newer than the recap — because a prompt reaches the deck through its hook
23
+ // seconds before the model has written anything to this file.
24
+
25
+ /** The cheap test every line gets before anything is parsed. */
26
+ export const RECAP_MARK = '"away_summary"';
27
+ const ASSISTANT_MARK = '"type":"assistant"';
28
+
29
+ /** Above Claude Code's own 400, so it never cuts a real recap. It bounds what a
30
+ * malformed line could put on a card, and nothing else. */
31
+ const RECAP_MAX_CHARS = 600;
32
+
33
+ /**
34
+ * The recap as a card can show it.
35
+ *
36
+ * Claude Code ends every one with its own opt-out hint, "(disable recaps in
37
+ * /config)". That is advice about the terminal, it is the same on every recap,
38
+ * and on a 260px card it is a tenth of the room, so it goes. Only a trailing
39
+ * parenthetical that mentions `/config` is taken: a recap that ends on a
40
+ * bracket of its own keeps it.
41
+ */
42
+ export function cleanRecap(raw) {
43
+ if (typeof raw !== "string") return "";
44
+ let text = raw.replace(/\s+/g, " ").trim();
45
+ text = text.replace(/\s*\([^()]*\/config[^()]*\)\s*$/, "").trim();
46
+ if (text.length > RECAP_MAX_CHARS) text = text.slice(0, RECAP_MAX_CHARS - 1).trimEnd() + "…";
47
+ return text;
48
+ }
49
+
50
+ /**
51
+ * The recap one transcript line carries, or null.
52
+ *
53
+ * Main chain only: a subagent's sidechain is not the session a person comes
54
+ * back to. The line's own timestamp is the recap's time, never the clock here —
55
+ * the deck may be reading a line written while it was down, and the client
56
+ * compares this time against the session's prompts to decide whether the recap
57
+ * still describes it. A line with no usable time cannot be compared, so it is
58
+ * not a recap the deck can use.
59
+ */
60
+ export function recapOf(line) {
61
+ if (!line || !line.includes(RECAP_MARK)) return null;
62
+ let obj = null;
63
+ try { obj = JSON.parse(line); } catch { return null; }
64
+ if (!obj || typeof obj !== "object") return null;
65
+ if (obj.type !== "system" || obj.subtype !== "away_summary") return null;
66
+ if (obj.isSidechain === true) return null;
67
+ const text = cleanRecap(obj.content);
68
+ const at = typeof obj.timestamp === "string" ? Date.parse(obj.timestamp) : NaN;
69
+ if (!text || !Number.isFinite(at)) return null;
70
+ return { text, at };
71
+ }
72
+
73
+ /**
74
+ * Fold one line into `out.recap`: set by a recap, retired by the next turn.
75
+ *
76
+ * Pure, and shaped like foldSessionNamingLine beside it so the transcript
77
+ * cursor can run both over the same line. A line is parsed only when it could
78
+ * matter — it carries the recap mark, or a recap is standing and the line looks
79
+ * like the model answering — so the ordinary line costs two substring tests.
80
+ */
81
+ export function foldRecapLine(out, line) {
82
+ if (!line) return;
83
+ const recap = recapOf(line);
84
+ if (recap) { out.recap = recap; return; }
85
+ if (!out.recap || !line.includes(ASSISTANT_MARK)) return;
86
+ let obj = null;
87
+ try { obj = JSON.parse(line); } catch { return; }
88
+ if (obj && obj.type === "assistant" && obj.isSidechain !== true) out.recap = null;
89
+ }
@@ -0,0 +1,67 @@
1
+ // How the deck serves its own UI: compressed when the browser takes it, and
2
+ // cached for good when a file's name is its content (#883).
3
+ //
4
+ // Every static response went out uncompressed and marked `no-cache`, the 833 KB
5
+ // bundle included — so every reload, and every deck opened from another machine
6
+ // on the LAN, fetched the whole of it again at full size. Vite names every file
7
+ // under assets/ by a hash of its content, which means such a file can never
8
+ // change under its name: it is cached for a year and marked immutable. The files
9
+ // that name them — index.html above all — stay no-cache, because they are what
10
+ // moves when the deck upgrades itself.
11
+ import { brotliCompressSync, constants, gzipSync } from "node:zlib";
12
+
13
+ /** The types worth compressing; images and fonts are compressed already. */
14
+ const COMPRESSIBLE = new Set([".html", ".js", ".mjs", ".css", ".json", ".svg", ".map"]);
15
+
16
+ /** Vite's output under assets/: `<name>-<hash>.<ext>`, the hash eight or more
17
+ * characters of base64url. */
18
+ const HASHED = /^assets\/[^/]+-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/;
19
+
20
+ /** The Cache-Control for a path relative to the web root, as the URL spells it. */
21
+ export function cacheControlFor(rel) {
22
+ return HASHED.test(rel) ? "public, max-age=31536000, immutable" : "no-cache";
23
+ }
24
+
25
+ /** Does an Accept-Encoding header take `encoding`? A `q=0` is a refusal. */
26
+ function accepts(header, encoding) {
27
+ for (const part of String(header ?? "").split(",")) {
28
+ const [name, ...params] = part.trim().split(";");
29
+ if (name.trim().toLowerCase() !== encoding) continue;
30
+ const q = params.map(p => p.trim()).find(p => p.startsWith("q="));
31
+ return q ? Number(q.slice(2)) > 0 : true;
32
+ }
33
+ return false;
34
+ }
35
+
36
+ /** The encoding to send a file of this extension in, or null for none. Brotli
37
+ * first — about 15% smaller than gzip on this bundle — and gzip for the rest. */
38
+ export function pickEncoding(acceptEncoding, ext) {
39
+ if (!COMPRESSIBLE.has(ext)) return null;
40
+ if (accepts(acceptEncoding, "br")) return "br";
41
+ if (accepts(acceptEncoding, "gzip")) return "gzip";
42
+ return null;
43
+ }
44
+
45
+ /** Compressed bytes, made once per file, build and encoding. The key carries the
46
+ * file's mtime, so a rebuild is a new entry rather than a stale one; the set of
47
+ * files is the dist folder's, so the cache is as big as the build and no bigger.
48
+ * Quality 9 rather than 11: the first request after a build pays for it, and 11
49
+ * costs seconds there for a percent or two. */
50
+ const encodedCache = new Map();
51
+
52
+ export function encodedBody(filePath, mtimeMs, buf, encoding) {
53
+ const key = `${encoding}\0${mtimeMs}\0${filePath}`;
54
+ let out = encodedCache.get(key);
55
+ if (!out) {
56
+ out = encoding === "br"
57
+ ? brotliCompressSync(buf, {
58
+ params: {
59
+ [constants.BROTLI_PARAM_QUALITY]: 9,
60
+ [constants.BROTLI_PARAM_SIZE_HINT]: buf.length,
61
+ },
62
+ })
63
+ : gzipSync(buf, { level: 6 });
64
+ encodedCache.set(key, out);
65
+ }
66
+ return out;
67
+ }
@@ -35,11 +35,11 @@ import { isProcessAlive } from "./deck-probe.mjs";
35
35
 
36
36
  /** Long enough for a loopback POST and the deck's own teardown to begin; short
37
37
  * enough that a wedged deck does not hold the terminal. */
38
- export const STOP_ASK_MS = 2000;
38
+ const STOP_ASK_MS = 2000;
39
39
  /** How long a deck gets to actually disappear after each rung. A clean shutdown
40
40
  * drains SSE connections and closes the listener; 1500ms of that is the
41
41
  * fallback timer in bin/deck.js's shutdown(), so this has to outlast it. */
42
- export const STOP_GONE_MS = 3000;
42
+ const STOP_GONE_MS = 3000;
43
43
 
44
44
  /**
45
45
  * Ask one deck to end itself, politely.
@@ -0,0 +1,51 @@
1
+ // The one mutex every write to claude-swap's store goes through.
2
+ //
3
+ // WHY IT IS ITS OWN MODULE (#1039). It used to live in cswap-admin.mjs, under a
4
+ // header that states the invariant the whole feature rests on — "`cswap add`
5
+ // takes no lock while assigning the next slot as max+1 … so every mutation here
6
+ // goes through one mutex" — and "here" turned out to be doing the work. A lock
7
+ // reachable from one module serializes that module against itself and nothing
8
+ // else, and `grep -n withStoreLock src/server/*.mjs` returned hits in exactly
9
+ // one file while three writers of the same sequence.json lived in two others:
10
+ // the manual switch route, the auto-switch tick, and the first-run seed.
11
+ //
12
+ // cswap-admin.mjs imports claude-accounts.mjs, so the lock could not simply be
13
+ // imported back the other way round. Here it depends on nothing at all, which
14
+ // is what lets every writer reach it — and what keeps the next one from
15
+ // quietly growing a second lock of its own, since there is now an obvious
16
+ // place to look.
17
+ import { AsyncLocalStorage } from "node:async_hooks";
18
+
19
+ let _chain = Promise.resolve();
20
+
21
+ // Whether the code running right now is itself the mutation holding the lock.
22
+ // Async context rather than a plain boolean, which could not tell that apart
23
+ // from another request that merely arrived while the lock was held — and would
24
+ // wave that one through, which is the opposite of a mutex.
25
+ const _holder = new AsyncLocalStorage();
26
+
27
+ /**
28
+ * One store mutation at a time.
29
+ *
30
+ * Not defence against another process — that would need claude-swap's own file
31
+ * lock, which `add` does not take either. This is defence against ourselves:
32
+ * two browser tabs, a double-click, or a timer are enough to race a slot
33
+ * assignment.
34
+ *
35
+ * Re-entrant, because a mutation that reaches for the lock from inside one
36
+ * would otherwise wait for itself forever: the chain cannot advance past the
37
+ * outer link until it settles, and the outer link is blocked on this call. It
38
+ * already has exclusive access, so it simply runs. That is also what lets a
39
+ * whole decision be wrapped — `fillEmptySlot` opens the lock and then calls
40
+ * `importAccount`, which opens it again — instead of a caller having to know
41
+ * which of its callees already takes it.
42
+ */
43
+ export function withStoreLock(fn) {
44
+ if (_holder.getStore()) return Promise.resolve().then(fn);
45
+ const held = () => _holder.run(true, fn);
46
+ const next = _chain.then(held, held);
47
+ // Keep the chain alive even when a link rejects, or every later mutation
48
+ // inherits the failure.
49
+ _chain = next.then(() => {}, () => {});
50
+ return next;
51
+ }
@@ -76,7 +76,7 @@ export const CRASH_CEILING = 5;
76
76
  /** The window those five are counted in. */
77
77
  export const CRASH_WINDOW_MS = 10 * 60 * 1000;
78
78
  /** The first wait, doubling per crash inside the window. */
79
- export const CRASH_BACKOFF_MS = 1000;
79
+ const CRASH_BACKOFF_MS = 1000;
80
80
  /** …and where the doubling stops. Past this the delay is no longer protecting
81
81
  * anything: five attempts at 30s already span most of the window. */
82
82
  export const CRASH_BACKOFF_MAX_MS = 30_000;
@@ -754,8 +754,19 @@ export function parseGetProcessJson(json, totalMem) {
754
754
  // shape covers both. Absent rather than zero when the value did not come
755
755
  // back: `StartTime` throws for a process this session may not open, and
756
756
  // `Threads.Count` is undefined on a row that failed to project.
757
+ //
758
+ // `typeof` rather than `Number.isFinite(Number(…))` on the uptime, and the
759
+ // difference is the whole of that rule: `Number(null)` is 0, and 0 is
760
+ // finite, so the projection's own `else{$null}` — the branch it takes for
761
+ // every process whose StartTime this session may not read — arrived as an
762
+ // uptime of ZERO SECONDS. Every protected process on the machine was
763
+ // reported to the panel as having started this instant, which is the
764
+ // reading the comment above says must never be invented. A genuine zero
765
+ // is still kept: `[int]` of a sub-second TimeSpan is 0, and a process that
766
+ // started this instant is a real answer when it is the real answer. Same
767
+ // guard the CPU field two lines up already used, for the same reason.
757
768
  ...(Number.isFinite(Number(r.Threads)) && Number(r.Threads) > 0 ? { threads: Number(r.Threads) } : {}),
758
- ...(Number.isFinite(Number(r.StartedAt)) ? { uptimeSec: Number(r.StartedAt) } : {}),
769
+ ...(typeof r.StartedAt === "number" && Number.isFinite(r.StartedAt) ? { uptimeSec: r.StartedAt } : {}),
759
770
  rssBytes: Number(r.WorkingSet) || 0,
760
771
  }));
761
772
  }
@@ -781,6 +792,21 @@ export function parseGetProcessJson(json, totalMem) {
781
792
  * Core count is deliberately not a parameter any more. The aggregate meter's
782
793
  * 0-100 convention (see cpuPercent) is a different question with a different
783
794
  * answer, and the only way this drifts back is if a core count is in reach.
795
+ *
796
+ * THE ROW IS CARRIED, NOT REBUILT, and that is the half this used to get wrong
797
+ * (#954). It listed the four fields it knew about — pid, cpu, mem, name — so
798
+ * `threads`, `uptimeSec` and `rssBytes` were silently dropped on their way
799
+ * through, and this is the only step the Windows reading takes: readProcessesNow
800
+ * returns straight out of the win32 branch, on the other side of the
801
+ * `attachDetail` call that puts those three back on POSIX. Every Windows row
802
+ * therefore reached the panel with three of its seven columns missing, always,
803
+ * and the memory header — which sorts on `rssBytes` and treats `undefined` as a
804
+ * missing reading — reordered nothing when clicked, because every row was
805
+ * missing it. Invisible to anyone developing on macOS or Linux.
806
+ *
807
+ * `cpuSec` is the one field deliberately removed: it is the raw counter this
808
+ * function exists to turn into a rate, the POSIX rows never carry one, and
809
+ * shipping it would put two spellings of the same quantity on the wire.
784
810
  */
785
811
  export function cpuFromDeltas(rows, prev, elapsedMs, limit = Infinity) {
786
812
  const secs = elapsedMs / 1000;
@@ -793,7 +819,8 @@ export function cpuFromDeltas(rows, prev, elapsedMs, limit = Infinity) {
793
819
  // process; report nothing rather than a negative or a wild number.
794
820
  if (d >= 0) cpu = Math.round((d / secs) * 1000) / 10;
795
821
  }
796
- return { pid: r.pid, cpu, mem: r.mem, name: r.name };
822
+ const { cpuSec: _raw, ...rest } = r;
823
+ return { ...rest, cpu };
797
824
  });
798
825
  // Until the second reading lands there is no CPU to sort on, so the list is
799
826
  // ordered by memory — which is a real answer to "what is this machine doing",
@@ -869,20 +896,35 @@ export async function readProcesses(platform = process.platform, detail = false)
869
896
  return procInFlight;
870
897
  }
871
898
 
899
+ /**
900
+ * The one-liner the Windows process list is read with.
901
+ *
902
+ * Threads and StartTime ride along on the call that was already being made —
903
+ * `Get-Process` has both, so the two columns cost nothing here. `WorkingSet64`
904
+ * joins `PrivateMemorySize64` rather than replacing it: the percentage has
905
+ * always been computed from private bytes and moving it would change a number
906
+ * that is on screen today, while the new column wants the resident figure Task
907
+ * Manager itself shows.
908
+ *
909
+ * NOT `-IncludeUserName`: it requires an elevated session, and no part of this
910
+ * deck may ask for one. The user column is simply absent on Windows, which is
911
+ * the same rule the thermal section keeps.
912
+ *
913
+ * A named export for the reason WIN_THERMAL_PS is one, and #954 is why it
914
+ * became one: three of the seven columns this projection is built to fill were
915
+ * being dropped after it, and every assertion in the suite was made against a
916
+ * payload somebody typed. A constant can be handed to the real powershell.exe
917
+ * on the Windows leg of the matrix, which is the only place the claim "Windows
918
+ * really does answer with these seven keys" can be measured rather than
919
+ * reasoned about.
920
+ */
921
+ export const WIN_PROCESS_PS =
922
+ "Get-Process | Select-Object Id,ProcessName,CPU,@{n='Threads';e={$_.Threads.Count}},@{n='StartedAt';e={if($_.StartTime){[int]((Get-Date)-$_.StartTime).TotalSeconds}else{$null}}},@{n='WorkingSet';e={$_.WorkingSet64}},@{n='WorkingSetPrivate';e={$_.PrivateMemorySize64}} | ConvertTo-Json -Compress";
923
+
872
924
  async function readProcessesNow(platform, detail = false) {
873
925
  if (platform === "win32") {
874
926
  const out = await run("powershell.exe", [
875
- "-NoProfile", "-NonInteractive", "-Command",
876
- // Threads and StartTime ride along on the call that was already being
877
- // made — `Get-Process` has both, so the two columns cost nothing here.
878
- // `WorkingSet64` joins `PrivateMemorySize64` rather than replacing it:
879
- // the percentage has always been computed from private bytes and moving
880
- // it would change a number that is on screen today, while the new column
881
- // wants the resident figure Task Manager itself shows.
882
- // NOT `-IncludeUserName`: it requires an elevated session, and no part
883
- // of this deck may ask for one. The user column is simply absent on
884
- // Windows, which is the same rule the thermal section keeps.
885
- "Get-Process | Select-Object Id,ProcessName,CPU,@{n='Threads';e={$_.Threads.Count}},@{n='StartedAt';e={if($_.StartTime){[int]((Get-Date)-$_.StartTime).TotalSeconds}else{$null}}},@{n='WorkingSet';e={$_.WorkingSet64}},@{n='WorkingSetPrivate';e={$_.PrivateMemorySize64}} | ConvertTo-Json -Compress",
927
+ "-NoProfile", "-NonInteractive", "-Command", WIN_PROCESS_PS,
886
928
  ], 6_000);
887
929
  // The same shape the POSIX branch below returns, and not a bare array: the
888
930
  // caller reads `read.procs.length` to decide whether this was a real
@@ -1167,14 +1209,33 @@ export async function readHwmon(root = "/sys/class/hwmon", deps = {}) {
1167
1209
  if (!m) continue;
1168
1210
  const celsius = celsiusFromMilli(await read(`${base}/${entry}`));
1169
1211
  if (celsius == null) continue;
1212
+ const chipMax = celsiusFromMilli(await read(`${base}/${m[1]}_max`));
1213
+ const chipCrit = celsiusFromMilli(await read(`${base}/${m[1]}_crit`));
1170
1214
  out.push({
1171
1215
  chip,
1172
1216
  label: await read(`${base}/${m[1]}_label`),
1173
1217
  celsius,
1174
1218
  // The hardware's own bands where it has them. `max` is where the chip
1175
1219
  // says it is unhappy and `crit` is where it says it will act.
1176
- warnAt: celsiusFromMilli(await read(`${base}/${m[1]}_max`)) ?? WARN_C,
1177
- critAt: celsiusFromMilli(await read(`${base}/${m[1]}_crit`)) ?? CRIT_C,
1220
+ //
1221
+ // `max` ONLY WHEN IT IS BELOW `crit`, because on Intel coretemp the two
1222
+ // files carry the same number. Measured on a live machine:
1223
+ //
1224
+ // coretemp temp1_input 88000 temp1_max 100000 temp1_crit 100000
1225
+ // nvme temp1_input 45850 temp1_max 84850 temp1_crit 85850
1226
+ //
1227
+ // The nvme row is a real band and works. The coretemp row is not: with
1228
+ // max === crit the amber band is zero degrees wide, so thermalTone goes
1229
+ // from calm straight to hot at 100C — a number a CPU reaches only as it
1230
+ // shuts the machine down. A CPU at 89C was painted the same grey as one
1231
+ // at 45C, on the one row anybody reads, and the 75/90 fallback that
1232
+ // would have said so was discarded precisely BECAUSE the chip published
1233
+ // its own. A chip that publishes one usable threshold is telling us
1234
+ // where it will act, not where to start worrying.
1235
+ warnAt: chipMax != null && chipCrit != null && chipMax >= chipCrit
1236
+ ? Math.min(WARN_C, Math.round(chipCrit * 0.9))
1237
+ : chipMax ?? WARN_C,
1238
+ critAt: chipCrit ?? CRIT_C,
1178
1239
  });
1179
1240
  }
1180
1241
  }
@@ -1262,6 +1323,57 @@ export const WIN_THERMAL_PS = [
1262
1323
  "$r = [ordered]@{}",
1263
1324
  // Get-Counter, not Get-CimInstance: this is the half that works unelevated.
1264
1325
  "try { $r.perf = @((Get-Counter -Counter '\\Thermal Zone Information(*)\\High Precision Temperature' -EA Stop).CounterSamples | ForEach-Object { @{ i = $_.InstanceName; v = $_.CookedValue } }) } catch {}",
1326
+ // THE SAME COUNTER UNDER THE NAME THIS WINDOWS CALLS IT.
1327
+ //
1328
+ // Performance-counter set and counter names are LOCALIZED. That is why PDH
1329
+ // ships `PdhAddEnglishCounter` beside `PdhAddCounter` at all, and
1330
+ // `Get-Counter -Counter` takes a localized path: hand it the English one on a
1331
+ // German, French, Japanese or Russian Windows and it answers "The specified
1332
+ // object was not found on the computer." `-EA Stop` plus `catch {}` turns
1333
+ // that into silence, so the line above falls straight through to MSAcpi —
1334
+ // which this module's own header says needs elevation a deck never has — and
1335
+ // then to LibreHardwareMonitor, which is only there if the user installed it.
1336
+ // A machine with real ACPI thermal zones and a non-English display language
1337
+ // therefore drew no Thermal section at all, indistinguishable from the
1338
+ // modern-Intel-laptop case the section is written to tolerate.
1339
+ //
1340
+ // This is the class #552 fixed one module over: exec.mjs spends two screens
1341
+ // on "Windows ships cmd.exe in every language it ships in, and these
1342
+ // sentences are translated with it", and there is a C_LOCALE block in this
1343
+ // very file for the same reason.
1344
+ //
1345
+ // Perflib is the documented map between the two spellings. `…\Perflib\009`
1346
+ // holds a REG_MULTI_SZ of alternating index and ENGLISH name; the parallel
1347
+ // `…\Perflib\CurrentLanguage` holds index and LOCAL name. Look the two
1348
+ // English names up to get their indices, read the local names at the same
1349
+ // indices, and build the path out of those.
1350
+ //
1351
+ // SECOND, NOT FIRST, and that is what makes it safe to add: an English
1352
+ // Windows never reaches this line, because `$r.perf` is already filled. It
1353
+ // can only turn a machine that was reporting nothing into one that reports
1354
+ // something.
1355
+ //
1356
+ // NOT ONE DOUBLE QUOTE, for the reason the whole of this string has none: it
1357
+ // travels as a single `-Command` argument on a Windows command line. The
1358
+ // counter path is concatenated rather than interpolated so that no quoting
1359
+ // form beyond the single quote is needed anywhere in it.
1360
+ //
1361
+ // REASONED, NOT REPRODUCED. The localization of counter names is documented
1362
+ // Microsoft behaviour and the Perflib layout is documented with it, but no
1363
+ // localized Windows was available to run this against, and CI's runners are
1364
+ // English — so what CI proves about this line is that it parses and runs
1365
+ // clean on a real Windows, not that it resolves a German counter name.
1366
+ "if (-not $r.perf) { try { "
1367
+ + "$en = (Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Perflib\\009' -EA Stop).Counter; "
1368
+ + "$lo = (Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Perflib\\CurrentLanguage' -EA Stop).Counter; "
1369
+ + "$byIndex = @{}; for ($i = 1; $i -lt $lo.Count; $i += 2) { $byIndex[$lo[$i-1]] = $lo[$i] }; "
1370
+ + "$set = $null; $ctr = $null; "
1371
+ + "for ($i = 1; $i -lt $en.Count; $i += 2) { "
1372
+ + "if ($en[$i] -eq 'Thermal Zone Information') { $set = $byIndex[$en[$i-1]] } "
1373
+ + "elseif ($en[$i] -eq 'High Precision Temperature') { $ctr = $byIndex[$en[$i-1]] } }; "
1374
+ + "if ($set -and $ctr) { $r.perf = @((Get-Counter -Counter ('\\' + $set + '(*)\\' + $ctr) -EA Stop).CounterSamples "
1375
+ + "| ForEach-Object { @{ i = $_.InstanceName; v = $_.CookedValue } }) } "
1376
+ + "} catch {} }",
1265
1377
  "if (-not $r.perf) { try { $r.acpi = @(Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -EA Stop | ForEach-Object { @{ i = $_.InstanceName; v = $_.CurrentTemperature } }) } catch {} }",
1266
1378
  // Depth matters: the default of 2 turns the inner hashtables into the string
1267
1379
  // "System.Collections.Hashtable" and this parser would see nothing at all.
@@ -1729,8 +1841,17 @@ function sampleCpu() {
1729
1841
  // Free — os.loadavg() reads a kernel value, no syscall worth the name — so it
1730
1842
  // rides the CPU tick rather than earning a timer. Windows returns [0,0,0],
1731
1843
  // which is not a reading and is not recorded as one.
1844
+ //
1845
+ // THE PLATFORM TEST IS THE WHOLE TEST. It used to be joined by
1846
+ // `load.some(n => n > 0)`, which reads as belt and braces and is not: Node
1847
+ // documents `os.loadavg()` as always [0,0,0] on Windows, so the platform test
1848
+ // alone already excludes every non-reading — and the extra clause could only
1849
+ // ever reject a reading that was REAL. `/proc/loadavg` on a genuinely quiet
1850
+ // Linux box says `0.00 0.00 0.00`, so "Queued work" recorded no points at all
1851
+ // for every quiet minute, and after the machine woke the chart read as though
1852
+ // the deck had been switched off through them.
1732
1853
  const load = os.loadavg();
1733
- if (process.platform !== "win32" && load.some(n => n > 0)) record("load:1m", Math.round(load[0] * 100) / 100);
1854
+ if (process.platform !== "win32") record("load:1m", Math.round(load[0] * 100) / 100);
1734
1855
  }
1735
1856
 
1736
1857
  /**
@@ -1804,7 +1925,12 @@ export function stopSystemMetrics() {
1804
1925
  export function systemSnapshot() {
1805
1926
  const cpu = cpuHistory.length ? cpuHistory[cpuHistory.length - 1] : null;
1806
1927
  const load = os.loadavg();
1807
- const hasLoad = process.platform !== "win32" && load.some(n => n > 0);
1928
+ // Platform alone, for the reason spelled out beside the `load:1m` record in
1929
+ // sampleCpu: Node's own contract makes the platform test sufficient, and the
1930
+ // `load.some(n => n > 0)` that used to join it here rejected nothing except a
1931
+ // real reading of zero. MachinePanel gates the whole section on `{loadavg &&
1932
+ // …}`, so an idle Linux box had the section disappear from under it.
1933
+ const hasLoad = process.platform !== "win32";
1808
1934
  return {
1809
1935
  ok: true,
1810
1936
  cpu,