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.
@@ -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 v = readManifest(pkgRoot)?.version;
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 the stub that depends on it. The same three strings as
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
- return hostPackage(pkgRoot, self)?.name ?? self;
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
- const target = hostPackage(pkgRoot, installedName(pkgRoot))?.root ?? pkgRoot;
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 args = ["install", "-g", `${target}@latest`, "--no-audit", "--no-fund", "--loglevel", "error"];
938
- const command = `npm ${args.join(" ")}`;
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(NPM, args, { shell: NPM_SHELL, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
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 a shell, so
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
- /** npm's real complaint is usually the last non-empty, non-decorative line. */
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 && !/^-+$/.test(l) && !/^A complete log/.test(l));
1016
- return lines.length ? lines[lines.length - 1].slice(0, 300) : "";
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 the sampler. */
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
- const m = /^([\d.]+)([KMG])?$/i.exec(s);
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
- const m = /^\s*(\d+)\s+([\d.]+)\s+([\d.]+)\s+(.+?)\s*$/.exec(line);
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
- out.push({ pid: Number(m[1]), cpu: Number(m[2]), mem: Number(m[3]), name: m[4] });
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
  /**