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.
- package/README.md +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
- package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
- package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
- package/dist/web/assets/index-Dsca2TwS.js +296 -0
- package/dist/web/index.html +9 -5
- package/hook/hook.js +631 -99
- package/package.json +1 -1
- package/release-notes.json +62 -1
- package/src/server/activity.mjs +42 -4
- package/src/server/args.mjs +30 -4
- package/src/server/browser-history.mjs +164 -38
- package/src/server/browser-presence.mjs +60 -0
- package/src/server/browser-react.mjs +160 -7
- package/src/server/browser-watch-store.mjs +319 -17
- package/src/server/browser-watch.mjs +304 -60
- package/src/server/ccusage.mjs +20 -4
- package/src/server/claude-accounts.mjs +79 -34
- package/src/server/claude-identity.mjs +121 -0
- package/src/server/cswap-admin.mjs +160 -133
- package/src/server/cswap-auto.mjs +86 -7
- package/src/server/cswap-install.mjs +280 -43
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +252 -36
- package/src/server/deck-probe.mjs +1 -1
- package/src/server/exec.mjs +84 -3
- package/src/server/global-install.mjs +13 -0
- package/src/server/index.mjs +1454 -182
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +385 -51
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +495 -22
- package/src/server/log-writer.mjs +575 -2
- package/src/server/login-service.mjs +31 -4
- package/src/server/open-url.mjs +22 -2
- package/src/server/output-watch.mjs +59 -8
- package/src/server/purge-key.mjs +135 -0
- package/src/server/quota.mjs +112 -7
- package/src/server/running-deck.mjs +15 -1
- package/src/server/self-update.mjs +98 -4
- package/src/server/session-recap.mjs +89 -0
- package/src/server/static-cache.mjs +67 -0
- package/src/server/stop-deck.mjs +2 -2
- package/src/server/store-lock.mjs +51 -0
- package/src/server/supervisor.mjs +1 -1
- package/src/server/system-metrics.mjs +143 -17
- package/dist/web/assets/index-BdYsAtN8.js +0 -274
- 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 —
|
|
464
|
-
*
|
|
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
|
|
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
|
+
}
|
package/src/server/stop-deck.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1177
|
-
|
|
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"
|
|
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
|
-
|
|
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,
|