agent-dag 1.43.0 → 1.45.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 +13 -7
- package/bin/agent-dag.js +87 -9
- package/bin/deck.js +133 -22
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +120 -31
- package/package.json +2 -2
- package/src/server/args.mjs +113 -15
- package/src/server/ccusage.mjs +105 -1
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-auth.mjs +9 -4
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +163 -9
- package/src/server/cswap-admin.mjs +346 -40
- package/src/server/cswap-auto.mjs +365 -12
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +233 -26
- package/src/server/index.mjs +1994 -157
- package/src/server/installer.mjs +173 -11
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +262 -21
- package/src/server/supervisor.mjs +103 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +43 -11
- package/dist/web/assets/index-BxAZQc7O.css +0 -1
- package/dist/web/assets/index-DRgZVqF-.js +0 -78
- package/hook/notify.js +0 -60
- package/src/server/sound-hook.mjs +0 -390
|
@@ -26,7 +26,7 @@ import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFi
|
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
28
|
import { dirname, join, resolve } from "node:path";
|
|
29
|
-
import { killTree } from "./exec.mjs";
|
|
29
|
+
import { killTree, shimPath, spawnSpec } from "./exec.mjs";
|
|
30
30
|
|
|
31
31
|
// Once an hour, not once a day.
|
|
32
32
|
//
|
|
@@ -67,6 +67,68 @@ const LEGACY_MARKER = join(MARKER_DIR, ".self-update-check");
|
|
|
67
67
|
// names are two different questions and must not be answered with one answer.
|
|
68
68
|
const _inflight = new Map();
|
|
69
69
|
|
|
70
|
+
// ── what a forced check may cost ─────────────────────────────────────────────
|
|
71
|
+
//
|
|
72
|
+
// `_inflight` deduplicates callers that OVERLAP and nothing else. `checkDue`
|
|
73
|
+
// answers `true` on `force` before it asks anything else, so a caller that
|
|
74
|
+
// waited for one check to settle and then asked again got a fresh
|
|
75
|
+
// `https://registry.npmjs.org/…/dist-tags` every time. Reads on this server are
|
|
76
|
+
// deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
|
|
77
|
+
// that `isTrustedMutation` does, because a cross-site read of
|
|
78
|
+
// `http://127.0.0.1:4317` is an ordinary top-level navigation — so
|
|
79
|
+
//
|
|
80
|
+
// (async function spin() {
|
|
81
|
+
// for (;;) await fetch("http://127.0.0.1:4317/api/version?refresh=1",
|
|
82
|
+
// { mode: "no-cors" });
|
|
83
|
+
// })();
|
|
84
|
+
//
|
|
85
|
+
// from any page the user had open was one registry request per turn, as fast as
|
|
86
|
+
// the round trip allows, and every so often two of them: `runCheck` confirms a
|
|
87
|
+
// tag it has not seen before against the version document. The requests are
|
|
88
|
+
// small — the whole reason the dist-tags endpoint is used here rather than the
|
|
89
|
+
// packument — but they leave the user's address, carrying the user-agent this
|
|
90
|
+
// deck sets, and they are aimed at a third party rather than at the machine the
|
|
91
|
+
// loop is running on. That makes it #580's shape with the cost pointed
|
|
92
|
+
// outwards, which if anything is the worse direction.
|
|
93
|
+
//
|
|
94
|
+
// Nothing above this line was going to stop it. The hour is written to a marker
|
|
95
|
+
// FILE, and `force` walks past it; `first` walks past it too; and on a machine
|
|
96
|
+
// where `~/.agents-deck` cannot be written `writeMarker` swallows the failure,
|
|
97
|
+
// so `checkDue` sees "never checked" on every single call and the hour is not
|
|
98
|
+
// really there at all.
|
|
99
|
+
//
|
|
100
|
+
// So the floor sits under all of it, in this process's own memory, between the
|
|
101
|
+
// last rule that admitted a check and the request it admitted. It is quota.mjs's
|
|
102
|
+
// number and codex-quota.mjs's and codex-usage.mjs's — the five routes in this
|
|
103
|
+
// deck's router that accept `?refresh=1` and can pay for it have no business
|
|
104
|
+
// disagreeing about what it costs.
|
|
105
|
+
const FORCE_POLL_MS = 60_000;
|
|
106
|
+
|
|
107
|
+
// Stamped when a check STARTS rather than when npm answers, because what the
|
|
108
|
+
// floor rations is the request. Per package name, like `_inflight` and like the
|
|
109
|
+
// markers: two names are two different questions, and one of them being asked
|
|
110
|
+
// is not a reason to refuse the other. Deliberately in memory rather than on
|
|
111
|
+
// disk — the marker is shared by every deck running that package and this is
|
|
112
|
+
// about what THIS process is sending.
|
|
113
|
+
const _lastAskAt = new Map();
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether we may spend a registry request right now.
|
|
117
|
+
*
|
|
118
|
+
* Exported for tests, for the same reason quota.mjs exports `maySelfPoll`,
|
|
119
|
+
* codex-quota.mjs `mayFetchQuota` and codex-usage.mjs `mayScanUsage`: this is
|
|
120
|
+
* the rule, it is pure, and it is worth pinning down away from the request it
|
|
121
|
+
* guards.
|
|
122
|
+
*/
|
|
123
|
+
export function mayAskNpm({ now, lastAskAt }) {
|
|
124
|
+
// A stamp from the future is a clock that moved, not a check that just ran —
|
|
125
|
+
// the same case `checkDue` answers twice below with `> now`, and the same
|
|
126
|
+
// answer. Without it, a machine whose clock corrects backwards by an hour
|
|
127
|
+
// would go an hour without a version check.
|
|
128
|
+
if (lastAskAt > now) return true;
|
|
129
|
+
return now - lastAskAt >= FORCE_POLL_MS;
|
|
130
|
+
}
|
|
131
|
+
|
|
70
132
|
// ── version comparison ───────────────────────────────────────────────────────
|
|
71
133
|
|
|
72
134
|
/** True when `a` sorts before `b`. Numeric-segment compare — non-numeric
|
|
@@ -113,9 +175,24 @@ function readManifest(dir) {
|
|
|
113
175
|
|
|
114
176
|
/** Version currently written in the package's own package.json. Deliberately
|
|
115
177
|
* read fresh on every call: that is the whole point — it changes under a
|
|
116
|
-
* running process when npm replaces the install.
|
|
178
|
+
* running process when npm replaces the install.
|
|
179
|
+
*
|
|
180
|
+
* And sometimes the directory changes with it. A `npm i -g ccdeck` performed
|
|
181
|
+
* before #340 left the deck nested inside a launcher package; upgrading such an
|
|
182
|
+
* install now writes the flat tarball over that launcher, and npm's reify takes
|
|
183
|
+
* the nested copy — the one this process is running out of — with it. Reading
|
|
184
|
+
* our own manifest then answers null, which pickNotice reads as "nothing is
|
|
185
|
+
* installed" and turns into the same upgrade offered forever, with the restart
|
|
186
|
+
* notice this module exists for never firing at all.
|
|
187
|
+
*
|
|
188
|
+
* The version is not lost, it moved one directory up. successorRoot is where
|
|
189
|
+
* to, and it answers null in every layout where nothing moved — so the normal
|
|
190
|
+
* case still reads exactly one manifest. */
|
|
117
191
|
export function installedVersion(pkgRoot) {
|
|
118
|
-
const
|
|
192
|
+
const own = readManifest(pkgRoot);
|
|
193
|
+
if (typeof own?.version === "string") return own.version;
|
|
194
|
+
const moved = successorRoot(pkgRoot);
|
|
195
|
+
const v = moved ? readManifest(moved)?.version : null;
|
|
119
196
|
return typeof v === "string" ? v : null;
|
|
120
197
|
}
|
|
121
198
|
|
|
@@ -139,7 +216,16 @@ export function isNpxInstall(pkgRoot) {
|
|
|
139
216
|
* returning "git pull && npm run build", `upgradeName` refusing to move a
|
|
140
217
|
* checkout onto a published alias, and `startUpgrade` refusing with
|
|
141
218
|
* `git_checkout`. A test importing the predicate would restate what those three
|
|
142
|
-
* already prove, one level further from the behaviour a user can see.
|
|
219
|
+
* already prove, one level further from the behaviour a user can see.
|
|
220
|
+
*
|
|
221
|
+
* `existsSync` and not a directory test, deliberately (#587). Git writes `.git`
|
|
222
|
+
* as a directory only for an ordinary clone; a linked worktree and a submodule
|
|
223
|
+
* each get a FILE whose whole content is one `gitdir:` line, and all three are
|
|
224
|
+
* checkouts nobody may install over. Nothing here reads that line, which is
|
|
225
|
+
* also what keeps the rule identical on Windows, where the path inside it
|
|
226
|
+
* carries a drive letter and backslashes. Both shapes are covered in
|
|
227
|
+
* worktree-git-file-587.test.ts and beside every checkout fixture in the
|
|
228
|
+
* suite — narrowing this to `.isDirectory()` fails them. */
|
|
143
229
|
function isGitCheckout(pkgRoot) {
|
|
144
230
|
try { return existsSync(join(pkgRoot, ".git")); } catch { return false; }
|
|
145
231
|
}
|
|
@@ -245,8 +331,9 @@ export function npxRestartSpec(pkgRoot, name = "agents-deck") {
|
|
|
245
331
|
/** Every name this deck is published under.
|
|
246
332
|
*
|
|
247
333
|
* `agents-deck` and `agent-dag` are one tarball published twice — see
|
|
248
|
-
* .github/workflows/publish.yml, which renames it between the two — and
|
|
249
|
-
* `ccdeck` is
|
|
334
|
+
* .github/workflows/publish.yml, which renames it between the two — and since
|
|
335
|
+
* #340 `ccdeck` is a third rename of the same tarball rather than a launcher
|
|
336
|
+
* package in front of it. The same three strings as
|
|
250
337
|
* invoked-as.mjs's COMMANDS, and deliberately not that list: this is the set
|
|
251
338
|
* of npm PACKAGES a `npm i -g` may name, that is the set of bin commands a
|
|
252
339
|
* user may type. They are equal only because the rename made them so, and a
|
|
@@ -330,6 +417,43 @@ export function hostPackage(pkgRoot, name = "agents-deck") {
|
|
|
330
417
|
return host ? { root, name: host } : null;
|
|
331
418
|
}
|
|
332
419
|
|
|
420
|
+
/**
|
|
421
|
+
* The directory that holds this install's code AFTER an upgrade replaced it,
|
|
422
|
+
* or null when nothing has been replaced.
|
|
423
|
+
*
|
|
424
|
+
* One layout produces this and it is a transitional one. Before #340, `npm i -g
|
|
425
|
+
* ccdeck` installed a launcher package with the deck nested inside it:
|
|
426
|
+
*
|
|
427
|
+
* <prefix>/lib/node_modules/ccdeck/ the launcher
|
|
428
|
+
* <prefix>/lib/node_modules/ccdeck/node_modules/agents-deck/ pkgRoot
|
|
429
|
+
*
|
|
430
|
+
* upgradeName reads the host's declared dependency and correctly answers
|
|
431
|
+
* `ccdeck`, so the upgrade runs `npm i -g ccdeck@latest` — which since #340
|
|
432
|
+
* installs the deck itself over the host directory and removes everything that
|
|
433
|
+
* was under it, including pkgRoot. The process keeps running (POSIX keeps an
|
|
434
|
+
* open inode alive, and the modules are already loaded) out of a directory that
|
|
435
|
+
* no longer exists.
|
|
436
|
+
*
|
|
437
|
+
* Deliberately NOT hostPackage. That function recognises a host by the
|
|
438
|
+
* dependency it declares on us, and the whole point here is that the host has
|
|
439
|
+
* just stopped declaring one — it is no longer a launcher, it is the deck. What
|
|
440
|
+
* identifies it instead is its name, confined to the three we publish, for the
|
|
441
|
+
* same reason installedName confines its answer: this decides what a version
|
|
442
|
+
* report says about the user's machine, and a directory that merely happens to
|
|
443
|
+
* sit above us is not evidence.
|
|
444
|
+
*
|
|
445
|
+
* Guarded on our own manifest being unreadable, so nothing changes for an
|
|
446
|
+
* install that is intact — including the ordinary nested layout before it is
|
|
447
|
+
* upgraded, where pkgRoot answers for itself and this is never consulted.
|
|
448
|
+
*/
|
|
449
|
+
export function successorRoot(pkgRoot) {
|
|
450
|
+
if (readManifest(pkgRoot)) return null;
|
|
451
|
+
const root = hostRoot(pkgRoot);
|
|
452
|
+
if (!root) return null;
|
|
453
|
+
const name = readManifest(root)?.name;
|
|
454
|
+
return typeof name === "string" && ALIAS_PACKAGES.includes(name) ? root : null;
|
|
455
|
+
}
|
|
456
|
+
|
|
333
457
|
/** The package an upgrade would actually install here — the only package worth
|
|
334
458
|
* asking npm about.
|
|
335
459
|
*
|
|
@@ -372,7 +496,21 @@ export function upgradeName(pkgRoot, name = "agents-deck") {
|
|
|
372
496
|
// the deck is nested one level down inside a package it is not named after,
|
|
373
497
|
// and this build's own name for `npm i -g agents-deck` and `npm i -g
|
|
374
498
|
// agent-dag`, where the deck IS the whole package and nothing is above it.
|
|
375
|
-
|
|
499
|
+
//
|
|
500
|
+
// successorRoot is the third case, and it is the second half of the same
|
|
501
|
+
// question. Once that stub install HAS been upgraded, the host has stopped
|
|
502
|
+
// declaring a dependency on us — which is the only thing hostPackage
|
|
503
|
+
// recognises a host by — so this fell through to `self`, and `self` is the
|
|
504
|
+
// fallback `agents-deck` because our own manifest went with the directory.
|
|
505
|
+
// The user was then shown `npm i -g agents-deck@latest`: a different package,
|
|
506
|
+
// a second global tree, and their `ccdeck` binary left exactly where it was.
|
|
507
|
+
// That is #358 verbatim, arriving through the upgrade that was supposed to be
|
|
508
|
+
// the end of it. The successor's own manifest names it, and that name is what
|
|
509
|
+
// the next upgrade has to install.
|
|
510
|
+
const host = hostPackage(pkgRoot, self)?.name;
|
|
511
|
+
if (host) return host;
|
|
512
|
+
const moved = successorRoot(pkgRoot);
|
|
513
|
+
return moved ? installedName(moved, self) : self;
|
|
376
514
|
}
|
|
377
515
|
|
|
378
516
|
/** The exact line the user can paste, for the way THIS copy was installed. */
|
|
@@ -602,7 +740,8 @@ export function checkDue({ at, failedAt, pendingAt, now, first = false, force =
|
|
|
602
740
|
* cached answer immediately when the window has not elapsed.
|
|
603
741
|
*
|
|
604
742
|
* `force` skips the window: the first call in this process, and an explicit
|
|
605
|
-
* "check now" from the UI.
|
|
743
|
+
* "check now" from the UI. What it does not skip is FORCE_POLL_MS — see
|
|
744
|
+
* mayAskNpm, and the note above it for what a forced call used to cost. */
|
|
606
745
|
async function latestOnNpm(name, now, force = false) {
|
|
607
746
|
const m = readMarker(name);
|
|
608
747
|
const key = markerFileName(name);
|
|
@@ -611,8 +750,18 @@ async function latestOnNpm(name, now, force = false) {
|
|
|
611
750
|
if (!checkDue({ at: m?.at, failedAt: m?.failedAt, pendingAt: m?.pendingAt, now, first, force })) {
|
|
612
751
|
return m?.version ?? null;
|
|
613
752
|
}
|
|
753
|
+
// Offered before the floor: a check that has not answered yet is a lookup
|
|
754
|
+
// newer than the marker, which is what refresh asked for, and joining it costs
|
|
755
|
+
// nothing.
|
|
614
756
|
const inflight = _inflight.get(key);
|
|
615
757
|
if (inflight) return inflight;
|
|
758
|
+
// The floor, under every rule above it. A refused check is answered with the
|
|
759
|
+
// version we already hold — the same string an ordinary cached call returns,
|
|
760
|
+
// carrying the marker's own `checkedAt` rather than the moment of the read
|
|
761
|
+
// that was refused, so /api/version reports exactly what it reported a moment
|
|
762
|
+
// ago and no surface learns a new failure mode from being asked twice.
|
|
763
|
+
if (!mayAskNpm({ now, lastAskAt: _lastAskAt.get(key) ?? 0 })) return m?.version ?? null;
|
|
764
|
+
_lastAskAt.set(key, now);
|
|
616
765
|
const run = runCheck(name, m, now)
|
|
617
766
|
// Record the outcome, not just the moment, and record it against THIS
|
|
618
767
|
// package: only an answer stamps `at`; a failure takes the short retry
|
|
@@ -663,13 +812,50 @@ export function pickNotice({ running, installed, latest }) {
|
|
|
663
812
|
|
|
664
813
|
// ── installing ───────────────────────────────────────────────────────────────
|
|
665
814
|
|
|
666
|
-
// npm is a .cmd shim on Windows, which spawn can only launch through a shell.
|
|
667
|
-
// Everywhere else shell:false — with a shell, Node warns that arguments are
|
|
668
|
-
// concatenated rather than escaped. Same pair the ccusage installer uses.
|
|
669
|
-
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
670
|
-
const NPM_SHELL = process.platform === "win32";
|
|
671
815
|
const INSTALL_TIMEOUT_MS = 300_000; // a cold global install on a slow line
|
|
672
816
|
|
|
817
|
+
/**
|
|
818
|
+
* What `spawn` gets for `npm install -g <target>@latest`.
|
|
819
|
+
*
|
|
820
|
+
* This was the last caller still spelling it the way #362 and #456 were written
|
|
821
|
+
* to remove: `spawn("npm.cmd", args, { shell: true })`, with a comment claiming
|
|
822
|
+
* it was "the same pair the ccusage installer uses". ccusage stopped using that
|
|
823
|
+
* pair when #456 fixed it, and this one was never revisited — it does not go
|
|
824
|
+
* through `run`/`runInteractive`/`runDetached`, so #457's sweep of their callers
|
|
825
|
+
* could not see it and exec-shim-callers.test.ts never listed it.
|
|
826
|
+
*
|
|
827
|
+
* Both halves of the old spelling were wrong on Windows and only one of them
|
|
828
|
+
* bites today.
|
|
829
|
+
*
|
|
830
|
+
* The one that bites: a `.cmd` shim locates its payload relative to `%~dp0`,
|
|
831
|
+
* the drive and path of the command token cmd.exe was handed, and a BARE
|
|
832
|
+
* `npm.cmd` carries no directory — so `%~dp0` came out as the deck's working
|
|
833
|
+
* directory and npm's shim went looking for `node_modules\npm\bin\npm-cli.js`
|
|
834
|
+
* underneath it. A user with a global install, deck started from
|
|
835
|
+
* `C:\Users\vceban`, clicks Update now and npm dies with `Cannot find module
|
|
836
|
+
* 'C:\Users\vceban\node_modules\npm\bin\npm-cli.js'` — on a machine whose npm
|
|
837
|
+
* is perfectly healthy, and where typing the same command at the same prompt
|
|
838
|
+
* works. shimPath is the answer, and `?? "npm.cmd"` keeps a layout it cannot see
|
|
839
|
+
* exactly as well off as it was.
|
|
840
|
+
*
|
|
841
|
+
* The one that does not, yet: `shell: true` makes Node join file and args with
|
|
842
|
+
* single spaces and no quoting. These arguments contain no spaces, so it has
|
|
843
|
+
* never mattered here — but it is the #362 defect sitting one argument away, and
|
|
844
|
+
* spawnSpec removes it by quoting every token into the cmd.exe line.
|
|
845
|
+
*
|
|
846
|
+
* POSIX is untouched: `npm` there is a real executable, isBatch is false, and
|
|
847
|
+
* the vector goes to spawn exactly as it always has.
|
|
848
|
+
*
|
|
849
|
+
* Exported for tests: the platform is a parameter so the Windows command line
|
|
850
|
+
* can be checked from any OS, and `deps` stands in for the Windows filesystem
|
|
851
|
+
* the shim lookup asks about. The same shape ccusage.mjs's installSpec has.
|
|
852
|
+
*/
|
|
853
|
+
export function upgradeSpec(target, platform = process.platform, deps) {
|
|
854
|
+
const args = ["install", "-g", `${target}@latest`, "--no-audit", "--no-fund", "--loglevel", "error"];
|
|
855
|
+
const file = platform === "win32" ? (shimPath("npm.cmd", deps) ?? "npm.cmd") : "npm";
|
|
856
|
+
return { ...spawnSpec(file, args, platform), plain: args };
|
|
857
|
+
}
|
|
858
|
+
|
|
673
859
|
/**
|
|
674
860
|
* Why an in-app upgrade would be wrong here, or null when it is fine.
|
|
675
861
|
*
|
|
@@ -725,7 +911,15 @@ export function upgradeBlock(pkgRoot) {
|
|
|
725
911
|
// reason upgradeName is: the host is recognised by the dependency it declares
|
|
726
912
|
// on us, and "us" is whatever the manifest here says — `agents-deck` under the
|
|
727
913
|
// stub npm publishes today, but nothing in this rule should assume that.
|
|
728
|
-
|
|
914
|
+
//
|
|
915
|
+
// successorRoot covers the case after such an upgrade has run: pkgRoot is
|
|
916
|
+
// gone, so hostPackage cannot recognise a host that no longer declares us,
|
|
917
|
+
// and asking accessSync about a deleted directory answers ENOENT — which this
|
|
918
|
+
// function reported as `not_writable`, telling the user their npm prefix was
|
|
919
|
+
// read-only when it was not.
|
|
920
|
+
const target = hostPackage(pkgRoot, installedName(pkgRoot))?.root
|
|
921
|
+
?? successorRoot(pkgRoot)
|
|
922
|
+
?? pkgRoot;
|
|
729
923
|
return upgradeBlockedReason({
|
|
730
924
|
git: isGitCheckout(pkgRoot),
|
|
731
925
|
npx: isNpxInstall(pkgRoot),
|
|
@@ -934,13 +1128,17 @@ export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
|
|
|
934
1128
|
// there wrote a tree this process never reads, so the version on disk never
|
|
935
1129
|
// moved and the same update was offered forever.
|
|
936
1130
|
const target = upgradeName(pkgRoot, name);
|
|
937
|
-
const
|
|
938
|
-
|
|
1131
|
+
const spec = upgradeSpec(target);
|
|
1132
|
+
// The LOGICAL vector, not the cmd.exe line: this string is shown to the user
|
|
1133
|
+
// and is the one they can paste. `cmd /d /s /c "…"` is an implementation
|
|
1134
|
+
// detail of how this platform reaches npm, and pasting it would be advice
|
|
1135
|
+
// about the deck rather than about their install.
|
|
1136
|
+
const command = `npm ${spec.plain.join(" ")}`;
|
|
939
1137
|
_upgrade = { state: "running", command, error: null, at: Date.now() };
|
|
940
1138
|
|
|
941
1139
|
let child;
|
|
942
1140
|
try {
|
|
943
|
-
child = spawn(
|
|
1141
|
+
child = spawn(spec.file, spec.args, { ...spec.opts, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
944
1142
|
} catch (err) {
|
|
945
1143
|
_upgrade = { state: "failed", command, error: err?.message ?? String(err), at: Date.now() };
|
|
946
1144
|
return { ok: false, reason: "spawn_failed", command };
|
|
@@ -955,7 +1153,7 @@ export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
|
|
|
955
1153
|
|
|
956
1154
|
// The deadline states the outcome itself, and only then kills.
|
|
957
1155
|
//
|
|
958
|
-
// npm is a .cmd shim on Windows and is therefore spawned through
|
|
1156
|
+
// npm is a .cmd shim on Windows and is therefore spawned through cmd.exe, so
|
|
959
1157
|
// `child` is cmd.exe and npm itself is a grandchild — a plain kill would
|
|
960
1158
|
// report the install as timed out while it carried on writing to
|
|
961
1159
|
// node_modules. killTree is what reaches it (taskkill /T there, the same
|
|
@@ -1008,12 +1206,55 @@ export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
|
|
|
1008
1206
|
return { ok: true, command };
|
|
1009
1207
|
}
|
|
1010
1208
|
|
|
1011
|
-
|
|
1209
|
+
// The furniture around a Node crash, none of which is the reason anything
|
|
1210
|
+
// failed. npm's own log needs none of this — it is `npm ERR!` lines and a rule —
|
|
1211
|
+
// but the two get mixed the moment the thing npm's shim tried to load is
|
|
1212
|
+
// missing, which is what #535's bare `npm.cmd` produced on every attempt.
|
|
1213
|
+
const CRASH_NOISE = [
|
|
1214
|
+
/^\s*at\s/, // stack frames
|
|
1215
|
+
/^\s*\^+\s*$/, // the caret under the throw
|
|
1216
|
+
/^node:internal\//, // the frame node leads with
|
|
1217
|
+
/^\s*throw\s/,
|
|
1218
|
+
/^Node\.js v/, // the last line, and the one that was quoted
|
|
1219
|
+
/^\s*[{}]\s*$/, // the error object's braces
|
|
1220
|
+
/^\s*(code|errno|syscall|path|requireStack|stack):/,
|
|
1221
|
+
/^Require stack:/,
|
|
1222
|
+
/^-+$/,
|
|
1223
|
+
/^A complete log/,
|
|
1224
|
+
];
|
|
1225
|
+
|
|
1226
|
+
/** npm's real complaint, for the banner: the last line that is not furniture.
|
|
1227
|
+
*
|
|
1228
|
+
* Last rather than first, and that is the whole reason this is not npx.mjs's
|
|
1229
|
+
* summariser under another name. The two read different documents. npm's log
|
|
1230
|
+
* opens with its codes — `code EACCES`, `syscall mkdir` — and ends with the
|
|
1231
|
+
* sentence a person can act on, so the last line wins. An npx failure is a Node
|
|
1232
|
+
* crash dump, which opens with the sentence and ends with a stack, a brace and
|
|
1233
|
+
* a version banner, so the first signal line wins there. Sharing one function
|
|
1234
|
+
* would mean picking one of those and being wrong about the other half the
|
|
1235
|
+
* time; sharing the NOISE list would be the copy this comment exists instead
|
|
1236
|
+
* of.
|
|
1237
|
+
*
|
|
1238
|
+
* What was wrong was not the rule but its list. `lastMeaningfulLine` dropped
|
|
1239
|
+
* `npm ERR!` prefixes, rules and "A complete log", and nothing else — so when
|
|
1240
|
+
* #535's spawn failed with a MODULE_NOT_FOUND dump, the last surviving line was
|
|
1241
|
+
* `Node.js v22.11.0`, and that string was the entire explanation the UI gave
|
|
1242
|
+
* for a failed upgrade. */
|
|
1012
1243
|
export function lastMeaningfulLine(text) {
|
|
1013
1244
|
const lines = String(text ?? "").split(/\r?\n/)
|
|
1014
1245
|
.map(l => l.replace(/^npm (ERR!|WARN)\s*/, "").trim())
|
|
1015
|
-
.filter(l => l &&
|
|
1016
|
-
|
|
1246
|
+
.filter(l => l && !CRASH_NOISE.some(re => re.test(l)));
|
|
1247
|
+
if (!lines.length) return "";
|
|
1248
|
+
// A line that names an error outranks a later line that does not, because
|
|
1249
|
+
// what survives the filter after one is usually its context rather than its
|
|
1250
|
+
// successor: `Require stack:` is furniture, but the paths listed under it are
|
|
1251
|
+
// not shaped like furniture and would otherwise be the last thing standing.
|
|
1252
|
+
// Still the LAST such line, not the first — npm's own log builds up to its
|
|
1253
|
+
// sentence, and picking the first would answer `code EACCES` where the next
|
|
1254
|
+
// line says which directory and why.
|
|
1255
|
+
const named = lines.filter(l => /(^|\s)[A-Za-z]*Error:/.test(l));
|
|
1256
|
+
const pick = named.length ? named[named.length - 1] : lines[lines.length - 1];
|
|
1257
|
+
return pick.slice(0, 300);
|
|
1017
1258
|
}
|
|
1018
1259
|
|
|
1019
1260
|
/** Full answer for GET /api/version. Never throws, and answers the local half
|
|
@@ -97,6 +97,42 @@ export function signalExitAction(signal, platform = process.platform, numbers =
|
|
|
97
97
|
*
|
|
98
98
|
* `proc` is a parameter so the whole sequence can be driven in a test child.
|
|
99
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* What to say when the upgrade that just succeeded took this install with it,
|
|
102
|
+
* or null when that is not what happened.
|
|
103
|
+
*
|
|
104
|
+
* `npm i -g ccdeck` performed before #340 nests the deck inside a launcher
|
|
105
|
+
* package. Upgrading such an install now writes the deck itself over that
|
|
106
|
+
* launcher, and npm's reify removes everything that was underneath — including
|
|
107
|
+
* the directory this supervisor and its worker are running from. The process
|
|
108
|
+
* survives, because everything it needs is already loaded. The next spawn does
|
|
109
|
+
* not: bin/deck.js is a path, and npm has just deleted it.
|
|
110
|
+
*
|
|
111
|
+
* That reached the user as `could not start …/bin/deck.js: ENOENT` and an exit
|
|
112
|
+
* 1 — a dead deck and an errno, one click after an upgrade that reported
|
|
113
|
+
* success. Nothing was broken. The new version is on the machine, and the
|
|
114
|
+
* command they typed already points at it, because npm rewrote the bin shim in
|
|
115
|
+
* the same install. The only thing missing was a sentence saying so.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately not a hand-off. This process could spawn the successor's worker
|
|
118
|
+
* instead, and that would be a new worker under an old supervisor — a pair
|
|
119
|
+
* nothing has ever tested together, decided at the worst possible moment. One
|
|
120
|
+
* command the user runs themselves is the smaller promise and the one that
|
|
121
|
+
* cannot go wrong.
|
|
122
|
+
*
|
|
123
|
+
* Pure, and given its two filesystem answers rather than asking for them,
|
|
124
|
+
* because the state it describes only ever exists in a process whose own files
|
|
125
|
+
* were deleted after it started — which no test can produce by spawning one.
|
|
126
|
+
*/
|
|
127
|
+
export function replacedNote({ workerExists, moved, product = "ccdeck", command = "ccdeck" }) {
|
|
128
|
+
if (workerExists || !moved) return null;
|
|
129
|
+
return [
|
|
130
|
+
`${product}: the upgrade replaced this install, so it cannot restart in place.`,
|
|
131
|
+
` the new version is in ${moved}`,
|
|
132
|
+
` run \`${command}\` again to start it`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
100
136
|
export function dieOfSignal(signal, proc = process) {
|
|
101
137
|
const { reraise, code } = signalExitAction(signal, proc.platform);
|
|
102
138
|
if (reraise) {
|
|
@@ -197,3 +233,70 @@ export function upgradeRefusalText({ reason, waitMs = 0, attempt = 0 } = {}, tar
|
|
|
197
233
|
const left = waitMs >= 60_000 ? `${Math.ceil(waitMs / 60_000)}m` : `${Math.max(1, Math.ceil(waitMs / 1000))}s`;
|
|
198
234
|
return `${what} failed to fetch a moment ago — waiting ${left} before trying again`;
|
|
199
235
|
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Die when the process that started this one does — on all three operating
|
|
239
|
+
* systems (#702).
|
|
240
|
+
*
|
|
241
|
+
* The deck is two processes: bin/agent-dag.js supervises, bin/deck.js serves.
|
|
242
|
+
* The supervisor kills the worker on every path it knows about — a restart, an
|
|
243
|
+
* upgrade, a Ctrl+C, its own exit — and cannot kill it on the one path it never
|
|
244
|
+
* gets to run: being killed itself. SIGKILL cannot be handled, a crash runs no
|
|
245
|
+
* handler, and a `taskkill` without `/T` reaches only the process it names. On
|
|
246
|
+
* POSIX the worker is then re-parented to init and keeps its port, its temp
|
|
247
|
+
* directory and its 40-60 MB of RSS forever. 310 of those were alive on one
|
|
248
|
+
* development machine, the oldest a day and four hours old, every one a worker
|
|
249
|
+
* whose supervisor a test's teardown had SIGKILLed.
|
|
250
|
+
*
|
|
251
|
+
* WHY THE IPC CHANNEL, AND NOT ANY OF THE OBVIOUS ALTERNATIVES. The requirement
|
|
252
|
+
* is one signal meaning "whoever started me is gone", and the usual answers are
|
|
253
|
+
* each missing a platform:
|
|
254
|
+
*
|
|
255
|
+
* • `process.ppid === 1` — POSIX re-parents an orphan to init, so polling ppid
|
|
256
|
+
* works there and answers nothing on Windows, which does not re-parent at
|
|
257
|
+
* all: the ppid goes on naming a pid that no longer exists.
|
|
258
|
+
* • process groups and `kill(-pgid)` — a POSIX concept. Windows job objects
|
|
259
|
+
* are the nearest equivalent and Node exposes none of it.
|
|
260
|
+
* • a SIGTERM the parent sends on its way out — Windows delivers no signals to
|
|
261
|
+
* a Node process, so `process.on("SIGTERM")` there never fires; and a parent
|
|
262
|
+
* that was killed sends nothing anywhere.
|
|
263
|
+
*
|
|
264
|
+
* The channel is the one mechanism that behaves the same everywhere, because
|
|
265
|
+
* Node normalises it: a Unix socketpair on POSIX, a named pipe on Windows, both
|
|
266
|
+
* closed by the kernel when the process holding the other end stops existing,
|
|
267
|
+
* however it stopped. Node turns that close into a single `disconnect` event on
|
|
268
|
+
* the child's own `process`, and it arrives for a SIGKILL, a crash, a
|
|
269
|
+
* `taskkill /F`, a closed console window and an ordinary exit alike. That is the
|
|
270
|
+
* same reasoning the header above gives for Ctrl+C: what differs between
|
|
271
|
+
* platforms is how a process dies, not that this notices.
|
|
272
|
+
*
|
|
273
|
+
* The channel is not created for this. It already carries `{type:"listening"}`
|
|
274
|
+
* and the upgrade handshake; this only says what its closure means.
|
|
275
|
+
*
|
|
276
|
+
* ARMED ONLY WHERE THERE IS A PARENT TO DIE WITH — `process.send` is a function
|
|
277
|
+
* exactly when a channel was given. `node bin/deck.js` run by hand has none, and
|
|
278
|
+
* that is the same question `SUPERVISED` in bin/deck.js already asks before it
|
|
279
|
+
* offers /api/restart.
|
|
280
|
+
*
|
|
281
|
+
* WHAT IT DELIBERATELY DOES NOT COVER: a parent that calls `child.disconnect()`
|
|
282
|
+
* and stays alive would look identical from here. Nothing in this repo does
|
|
283
|
+
* that, and the alternative — an "are you still there" ping — is a second
|
|
284
|
+
* protocol to keep correct in exchange for a case that does not exist.
|
|
285
|
+
*
|
|
286
|
+
* Returns whether a leash was armed, so a caller can state it rather than infer
|
|
287
|
+
* it. `proc` is a parameter for the reason `workerExitAction` takes `stopping`:
|
|
288
|
+
* the behaviour has to be checkable without a second process.
|
|
289
|
+
*/
|
|
290
|
+
export function dieWithParent(stop, proc = process) {
|
|
291
|
+
if (!proc || typeof proc.once !== "function" || typeof proc.send !== "function") return false;
|
|
292
|
+
let done = false;
|
|
293
|
+
proc.once("disconnect", () => {
|
|
294
|
+
if (done) return;
|
|
295
|
+
done = true;
|
|
296
|
+
// A stop that throws must still end the process: the whole point is that
|
|
297
|
+
// nothing is left behind, and there is no parent left to notice a child
|
|
298
|
+
// that failed to leave.
|
|
299
|
+
try { stop(); } catch { proc.exit?.(0); }
|
|
300
|
+
});
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
@@ -111,12 +111,43 @@ function cpuPercent() {
|
|
|
111
111
|
return Math.max(0, Math.min(100, Math.round(pct * 10) / 10));
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The locale every child of this module is parsed in.
|
|
116
|
+
*
|
|
117
|
+
* Not a preference — a correctness requirement. Every command spawned here has
|
|
118
|
+
* its output read by a regex, and every one of those regexes reads a number
|
|
119
|
+
* with a `.` in it. `ps` and `sysctl` honour LC_NUMERIC, so on a machine set to
|
|
120
|
+
* de_DE, fr_FR, ru_RU or pt_BR — comma is the decimal separator for most of
|
|
121
|
+
* Europe and Latin America — the same commands print:
|
|
122
|
+
*
|
|
123
|
+
* ps 1 0,2 0,0 /sbin/launchd
|
|
124
|
+
* sysctl total = 8192,00M used = 7189,75M free = 1002,25M
|
|
125
|
+
*
|
|
126
|
+
* and the parsers matched nothing at all. Not partially: `parsePsProcesses`
|
|
127
|
+
* `continue`s on every row, so the process panel was permanently empty, and
|
|
128
|
+
* `swapFromSysctl` returned null, so the macOS swap meter was permanently
|
|
129
|
+
* blank. Silently, with nothing in the log, on a machine where everything else
|
|
130
|
+
* worked.
|
|
131
|
+
*
|
|
132
|
+
* Forcing the locale rather than teaching the parsers to read a comma is the
|
|
133
|
+
* fix that scales: it makes the OUTPUT invariant, which is what every parser
|
|
134
|
+
* here was written against and what every parser added later will assume. The
|
|
135
|
+
* comma tolerance below is defence in depth for the case where a sandbox strips
|
|
136
|
+
* the environment, not the primary answer.
|
|
137
|
+
*
|
|
138
|
+
* Meaningless on Windows, where the branches are PowerShell piped through
|
|
139
|
+
* ConvertTo-Json and already culture-invariant — and harmless there for the
|
|
140
|
+
* same reason.
|
|
141
|
+
*/
|
|
142
|
+
const C_LOCALE = { LC_ALL: "C", LANG: "C" };
|
|
143
|
+
|
|
114
144
|
/** Run a command and resolve its stdout, or null. Never rejects, never inherits
|
|
115
|
-
* a shell, and is killed rather than allowed to hang
|
|
145
|
+
* a shell, never inherits a locale, and is killed rather than allowed to hang
|
|
146
|
+
* the sampler. */
|
|
116
147
|
function run(file, args, timeoutMs = 2_000) {
|
|
117
148
|
return new Promise(resolve => {
|
|
118
149
|
let child;
|
|
119
|
-
try { child = spawn(file, args, { windowsHide: true }); }
|
|
150
|
+
try { child = spawn(file, args, { windowsHide: true, env: { ...process.env, ...C_LOCALE } }); }
|
|
120
151
|
catch { return resolve(null); }
|
|
121
152
|
let out = "";
|
|
122
153
|
const timer = setTimeout(() => { try { child.kill(); } catch {} resolve(null); }, timeoutMs);
|
|
@@ -207,10 +238,15 @@ async function readAvailable(platform = process.platform) {
|
|
|
207
238
|
*/
|
|
208
239
|
export function swapFromSysctl(text) {
|
|
209
240
|
const unit = s => {
|
|
210
|
-
|
|
241
|
+
// `,` as well as `.`: C_LOCALE should mean this never arrives, and a parser
|
|
242
|
+
// that fails closed on a whole continent's default is not a thing to leave
|
|
243
|
+
// resting on one environment variable. Safe to accept both here because
|
|
244
|
+
// sysctl formats with printf's %f, which never groups thousands — so a
|
|
245
|
+
// comma in this field can only ever be the decimal point.
|
|
246
|
+
const m = /^([\d.,]+)([KMG])?$/i.exec(s);
|
|
211
247
|
if (!m) return null;
|
|
212
248
|
const mult = { k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[(m[2] || "M").toLowerCase()] ?? 1;
|
|
213
|
-
return Math.round(Number(m[1]) * mult);
|
|
249
|
+
return Math.round(Number(m[1].replace(",", ".")) * mult);
|
|
214
250
|
};
|
|
215
251
|
const total = unit(/total\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
|
|
216
252
|
const used = unit(/used\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
|
|
@@ -320,9 +356,14 @@ export function parsePsProcesses(text, limit = TOP_N) {
|
|
|
320
356
|
const lines = String(text ?? "").trim().split("\n");
|
|
321
357
|
const out = [];
|
|
322
358
|
for (const line of lines.slice(1)) { // drop the header row
|
|
323
|
-
|
|
359
|
+
// Both separators, for the reason swapFromSysctl gives: C_LOCALE is the
|
|
360
|
+
// fix, and this is what keeps a stripped environment from emptying the
|
|
361
|
+
// panel. `%CPU` and `%MEM` are percentages printed with %.1f, so a comma in
|
|
362
|
+
// either can only be the decimal point.
|
|
363
|
+
const m = /^\s*(\d+)\s+([\d.,]+)\s+([\d.,]+)\s+(.+?)\s*$/.exec(line);
|
|
324
364
|
if (!m) continue;
|
|
325
|
-
|
|
365
|
+
const num = v => Number(v.replace(",", "."));
|
|
366
|
+
out.push({ pid: Number(m[1]), cpu: num(m[2]), mem: num(m[3]), name: m[4] });
|
|
326
367
|
if (out.length >= limit) break;
|
|
327
368
|
}
|
|
328
369
|
return out;
|
|
@@ -412,13 +453,66 @@ export function cpuFromDeltas(rows, prev, elapsedMs, limit = TOP_N) {
|
|
|
412
453
|
return out.slice(0, limit);
|
|
413
454
|
}
|
|
414
455
|
|
|
415
|
-
/** The process list, on demand only — never on the ambient timer. */
|
|
416
456
|
/** Previous Windows reading, so the next one can be a rate. Cleared with the
|
|
417
457
|
* rest of the sampler state. */
|
|
418
458
|
let prevProcCpu = null;
|
|
419
459
|
let prevProcAt = 0;
|
|
420
460
|
|
|
461
|
+
/** The run producing the next list, and the last one that finished. */
|
|
462
|
+
let procInFlight = null;
|
|
463
|
+
let procLast = null; // { at, procs }
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* How old a finished reading may be and still answer a caller.
|
|
467
|
+
*
|
|
468
|
+
* Well under SystemMeter's PROC_POLL_MS of 4000, so the panel that this exists
|
|
469
|
+
* for never once gets a cached list; long enough that a second tab, a second
|
|
470
|
+
* browser, or anything else arriving between two of those polls is handed the
|
|
471
|
+
* list the first tab is already looking at rather than starting its own child.
|
|
472
|
+
*/
|
|
473
|
+
const PROC_MIN_GAP_MS = 1_500;
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The process list, on demand only — never on the ambient timer, and never more
|
|
477
|
+
* than one child at a time.
|
|
478
|
+
*
|
|
479
|
+
* #544: /api/system/processes is a GET with no cache, no dedupe and no
|
|
480
|
+
* throttle, so the number of `powershell.exe Get-Process` children — about six
|
|
481
|
+
* seconds each — was whatever the caller asked for. That is the cheap half of
|
|
482
|
+
* the problem. The expensive half is that concurrent readers also overwrote
|
|
483
|
+
* each other's baseline: cpuFromDeltas needs the PREVIOUS reading's cpuSec per
|
|
484
|
+
* pid, prevProcCpu/prevProcAt are one shared pair, and two readers each stored
|
|
485
|
+
* theirs over the other's, so the CPU column came back computed against a
|
|
486
|
+
* baseline that belonged to somebody else's reading. That is a wrong number on
|
|
487
|
+
* screen, not merely wasted work, and it needed no attacker at all: one
|
|
488
|
+
* Get-Process takes longer than the panel's four-second poll, so a single tab
|
|
489
|
+
* on Windows already overlapped itself.
|
|
490
|
+
*
|
|
491
|
+
* One in-flight run fixes both, because one reader means one baseline. Callers
|
|
492
|
+
* that arrive while a run is going share its promise; callers that arrive just
|
|
493
|
+
* after one finished are served that reading.
|
|
494
|
+
*/
|
|
421
495
|
export async function readProcesses(platform = process.platform) {
|
|
496
|
+
const now = Date.now();
|
|
497
|
+
if (procLast && now - procLast.at < PROC_MIN_GAP_MS) return procLast.procs;
|
|
498
|
+
if (procInFlight) return procInFlight;
|
|
499
|
+
// Only a real reading is remembered. Every failure inside readProcessesNow —
|
|
500
|
+
// a spawn that never started, a non-zero exit, the timeout — resolves to an
|
|
501
|
+
// empty array, and no machine has nothing running on it, so an empty list is
|
|
502
|
+
// a failure by construction. Serving one for the next 1.5s would turn a
|
|
503
|
+
// single hiccup into a blank panel that outlives it; the next caller retries
|
|
504
|
+
// instead. The in-flight share still applies, so a burst arriving during a
|
|
505
|
+
// failing read is one failing child, not a burst of them.
|
|
506
|
+
procInFlight = readProcessesNow(platform)
|
|
507
|
+
.then(procs => {
|
|
508
|
+
if (procs.length) procLast = { at: Date.now(), procs };
|
|
509
|
+
return procs;
|
|
510
|
+
})
|
|
511
|
+
.finally(() => { procInFlight = null; });
|
|
512
|
+
return procInFlight;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function readProcessesNow(platform) {
|
|
422
516
|
if (platform === "win32") {
|
|
423
517
|
const out = await run("powershell.exe", [
|
|
424
518
|
"-NoProfile", "-NonInteractive", "-Command",
|
|
@@ -490,6 +584,10 @@ export function stopSystemMetrics() {
|
|
|
490
584
|
swap = null;
|
|
491
585
|
prevProcCpu = null;
|
|
492
586
|
prevProcAt = 0;
|
|
587
|
+
// The last list goes with the baseline it was computed against. A sampler
|
|
588
|
+
// that stopped and started again must not answer the first caller with a
|
|
589
|
+
// reading from before the stop.
|
|
590
|
+
procLast = null;
|
|
493
591
|
}
|
|
494
592
|
|
|
495
593
|
/**
|