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.
@@ -40,8 +40,8 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-DRgZVqF-.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-BxAZQc7O.css">
43
+ <script type="module" crossorigin src="/assets/index-jXBjwwZC.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-Bdl1LX0-.css">
45
45
  </head>
46
46
  <body>
47
47
  <div id="root"></div>
package/hook/hook.js CHANGED
@@ -34,9 +34,38 @@ function parseProvider(argv) {
34
34
  }
35
35
  const PROVIDER = parseProvider(process.argv.slice(2));
36
36
 
37
+ /**
38
+ * The one spelling of a directory, so that a path this process reports and a
39
+ * path bin/deck.js published can be compared as strings.
40
+ *
41
+ * Resolving symlinks is the half that is easy to think you can skip, because on
42
+ * POSIX a cwd comes from getcwd(3) and has none left in it. Windows has no such
43
+ * guarantee — GetCurrentDirectoryW returns the string the directory was set
44
+ * with, junction, `subst` drive and all — so a workspace reached that way only
45
+ * matches if BOTH sides go through here. The server's rollout watcher keeps its
46
+ * own copy of this rule under the name canonicalCwd, for the Codex sessions that
47
+ * never reach this file; a test walks one path through both. A path that does
48
+ * not resolve keeps its resolved form, which is also what canonicalWorkspace
49
+ * does with a directory the user has not created yet.
50
+ *
51
+ * `.native` IS THE RULE, not a detail. fs.realpathSync is a JavaScript
52
+ * lstat-and-readlink walk that resolves symlinks and junctions and nothing else;
53
+ * fs.realpathSync.native is GetFinalPathNameByHandleW, which also expands a DOS
54
+ * 8.3 short component to its long form. This used to call the plain one while
55
+ * the server's canonicalCwd went through the native one, so the moment a path
56
+ * arrived short — `%TEMP%` under a shortened profile directory, which is what
57
+ * every GitHub Windows runner has — the two canonicalisers that exist to agree
58
+ * disagreed by a whole path: C:\Users\RUNNER~1\… against C:\Users\runneradmin\….
59
+ * canonicalWorkspace in src/server/index.mjs says the rest of it, including why
60
+ * the long form is the canonical one; all three sites name `.native` out loud.
61
+ *
62
+ * Exported for that test: it is half of what `--workspace` means, and a
63
+ * predicate handed an already-canonical path cannot show that the caller
64
+ * canonicalises.
65
+ */
37
66
  function normPath(p) {
38
67
  let r = path.resolve(p);
39
- try { r = fs.realpathSync(r); } catch {}
68
+ try { r = fs.realpathSync.native(r); } catch {}
40
69
  return r;
41
70
  }
42
71
 
@@ -235,29 +264,33 @@ function sameProof(got, want) {
235
264
  return a.length === b.length && crypto.timingSafeEqual(a, b);
236
265
  }
237
266
 
238
- // Two round trips now happen per target, and main()'s hard cap is 1500ms, so
239
- // the pair has to fit inside it with room to spare. The challenge is a bodyless
240
- // GET to a loopback port — sub-millisecond when a deck is there, and instant
267
+ // Two round trips happen per target, and main()'s hard cap is 1500ms, so the
268
+ // pair has to fit inside it with room to spare. The challenge is a bodyless GET
269
+ // to a loopback port — sub-millisecond when a deck is there, and instant
241
270
  // ECONNREFUSED when nothing is.
271
+ //
272
+ // They are now separated by a barrier: every target is challenged, then the
273
+ // election is decided, then the payload goes out (#695). The worst case is
274
+ // unchanged — the challenges run in parallel, so it is still one 400ms deadline
275
+ // followed by one 1000ms deadline. What the barrier does cost is that an honest
276
+ // deck's POST waits for the slowest challenge in the set, which only matters
277
+ // when some OTHER record's port accepts a connection and then says nothing. A
278
+ // ghost port with nothing behind it refuses instantly and delays no one.
242
279
  const CHALLENGE_TIMEOUT_MS = 400;
243
280
  const POST_TIMEOUT_MS = 1000;
244
281
 
245
282
  /**
246
- * Ask the listener to prove it is the deck that wrote `d`, and POST the payload
247
- * only once it has. `done` runs exactly once, whatever the outcome a refused
248
- * connection, a silent port, a wrong answer and a delivered event all just mean
249
- * this target is finished.
283
+ * Ask the listener to prove it is the deck that wrote `d`. `cb` is called
284
+ * exactly once with true or false a refused connection, a silent port and a
285
+ * wrong answer are all just "not the deck this record describes".
250
286
  *
251
- * A deck that advertised no token is posted to directly: see requiresProof.
252
- *
253
- * `persists` is this deck's answer from electWriters: true for the one deck
254
- * that logs the event, false for every other one it is also drawn on.
287
+ * A deck that advertised no token cannot be asked and passes: see requiresProof.
255
288
  */
256
- function deliver(d, body, persists, done) {
289
+ function prove(d, cb) {
257
290
  let settled = false;
258
- const finish = () => { if (settled) return; settled = true; done(); };
291
+ const finish = ok => { if (settled) return; settled = true; cb(ok); };
259
292
 
260
- if (!requiresProof(d)) return post(d, body, persists, finish);
293
+ if (!requiresProof(d)) return finish(true);
261
294
 
262
295
  const nonce = crypto.randomBytes(16).toString("hex");
263
296
  const want = challengeProof(d.token, nonce);
@@ -269,31 +302,79 @@ function deliver(d, body, persists, done) {
269
302
  method: "GET",
270
303
  timeout: CHALLENGE_TIMEOUT_MS,
271
304
  }, res => {
272
- if (res.statusCode !== 200) { res.resume(); return res.on("end", finish); }
305
+ if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
273
306
  let answer = "";
274
307
  res.setEncoding("utf8");
275
308
  res.on("data", c => {
276
309
  answer += c;
277
310
  // A deck answers in ~100 bytes. Anything pouring data at us is not one,
278
311
  // and must not be allowed to grow this buffer without bound.
279
- if (answer.length > 4096) { req.destroy(); finish(); }
312
+ if (answer.length > 4096) { req.destroy(); finish(false); }
280
313
  });
281
314
  res.on("end", () => {
282
315
  // Already given up on this target — a flood we cut off above. Whatever
283
316
  // arrived before that is not an answer we are going to act on.
284
317
  if (settled) return;
285
318
  let proof;
286
- try { proof = JSON.parse(answer).proof; } catch { return finish(); }
287
- if (!sameProof(proof, want)) return finish();
288
- post(d, body, persists, finish);
319
+ try { proof = JSON.parse(answer).proof; } catch { return finish(false); }
320
+ finish(sameProof(proof, want));
289
321
  });
290
322
  });
291
- req.on("error", finish);
323
+ req.on("error", () => finish(false));
292
324
  req.on("timeout", () => req.destroy());
293
325
  req.end();
294
326
  }
295
327
 
296
- function post(d, body, persists, finish) {
328
+ /**
329
+ * Challenge every target, then hand back the ones that answered — in the order
330
+ * they were given, so the election below is a function of the records alone.
331
+ *
332
+ * WHY THIS RUNS BEFORE THE ELECTION AND NOT AFTER IT (#695). The two round trips
333
+ * per target have always both happened; they used to happen in the wrong order.
334
+ * electWriters ran over every record whose pid was merely alive, and only then
335
+ * did deliver() challenge each target and drop the ones that could not answer.
336
+ * So a record left behind by a deck that is gone — SIGKILL, an OOM kill, a power
337
+ * cut, a console window closed on Windows, none of which run the shutdown that
338
+ * unlinks it — kept passing the one staleness test there is the moment the OS
339
+ * handed its pid to some other long-lived process. If it also named a port below
340
+ * every real deck's, it WON the election, was never posted to because it could
341
+ * not answer, and no other deck was posted to with the flag either: every deck
342
+ * drew the event, all of them were told `?persist=0`, and events.jsonl stopped
343
+ * growing. Silently, for as long as that file sat in the directory.
344
+ *
345
+ * The election has to be decided over the decks that are actually going to be
346
+ * handed the payload, and the only thing that establishes that is the handshake.
347
+ * So: prove, then elect, then post. It costs no extra round trip, only this
348
+ * ordering, and it is the same reordering src/server/index.mjs makes in
349
+ * readLiveDecks for the Codex rollouts no hook ever sees.
350
+ *
351
+ * The record is NOT unlinked when a target fails. A dead pid is proof the deck
352
+ * is gone and is swept above; a failed challenge is not — a deck restarting
353
+ * under its supervisor refuses connections for a moment while its record still
354
+ * stands, and a merely busy one can miss the 400ms deadline. Deleting another
355
+ * deck's registration on that evidence trades a bug that loses log lines for one
356
+ * that loses a whole deck's events, and it buys nothing now that the election no
357
+ * longer believes the record: a ghost that survives on disk costs one instant
358
+ * ECONNREFUSED per hook run and decides nothing.
359
+ */
360
+ function proveTargets(targets, cb) {
361
+ const ok = new Array(targets.length).fill(false);
362
+ let pending = targets.length;
363
+ const settle = () => { if (--pending <= 0) cb(targets.filter((_, i) => ok[i])); };
364
+ targets.forEach((d, i) => prove(d, answered => { ok[i] = answered; settle(); }));
365
+ }
366
+
367
+ /**
368
+ * Hand this deck the payload. `done` runs exactly once, whatever the outcome —
369
+ * a delivered event, a refused connection and a socket that errors after the
370
+ * response are all just "this target is finished".
371
+ *
372
+ * `persists` is this deck's answer from electWriters: true for the one deck that
373
+ * logs the event, false for every other one it is also drawn on.
374
+ */
375
+ function post(d, body, persists, done) {
376
+ let settled = false;
377
+ const finish = () => { if (settled) return; settled = true; done(); };
297
378
  const req = http.request({
298
379
  hostname: "127.0.0.1",
299
380
  port: d.port,
@@ -367,9 +448,9 @@ function main() {
367
448
  let d;
368
449
  try { d = JSON.parse(fs.readFileSync(path.join(DIR, file), "utf8")); } catch { continue; }
369
450
  if (typeof d.workspace !== "string" || !d.pid || !d.port) continue;
370
- // A missing token is not a reason to drop the file here — deliver()
371
- // decides what a target has to prove, and a deck older than the handshake
372
- // can prove nothing. See requiresProof.
451
+ // A missing token is not a reason to drop the file here — prove() decides
452
+ // what a target has to prove, and a deck older than the handshake can
453
+ // prove nothing. See requiresProof.
373
454
 
374
455
  if (!isAlive(d.pid)) {
375
456
  try { fs.unlinkSync(path.join(DIR, file)); } catch {}
@@ -388,13 +469,21 @@ function main() {
388
469
 
389
470
  if (!targets.length) return process.exit(0);
390
471
 
391
- // One deck per events log records this event; the others only draw it.
392
- const writers = electWriters(targets);
472
+ // Prove, elect, post in that order, and see proveTargets for what the
473
+ // other order cost. A record whose pid is merely alive has established
474
+ // nothing: it may be a deck that died and had its pid recycled, and electing
475
+ // one of those to write the log meant nobody wrote it (#695).
476
+ proveTargets(targets, proven => {
477
+ if (!proven.length) return process.exit(0);
393
478
 
394
- let pending = targets.length;
395
- const done = () => { if (--pending <= 0) process.exit(0); };
479
+ // One deck per events log records this event; the others only draw it.
480
+ const writers = electWriters(proven);
396
481
 
397
- for (const d of targets) deliver(d, taggedInput, writers.has(d), done);
482
+ let pending = proven.length;
483
+ const done = () => { if (--pending <= 0) process.exit(0); };
484
+
485
+ for (const d of proven) post(d, taggedInput, writers.has(d), done);
486
+ });
398
487
  });
399
488
  }
400
489
 
@@ -403,5 +492,5 @@ function main() {
403
492
  // require() it exports the rules it decides by — matching, election, the
404
493
  // handshake — and starts nothing, which is what lets them be tested without a
405
494
  // 1.5s exit timer in the test runner.
406
- module.exports = { capturesSession, cwdInWorkspace, foldsCase, electWriters, challengeProof, requiresProof };
495
+ module.exports = { capturesSession, cwdInWorkspace, foldsCase, normPath, electWriters, challengeProof, requiresProof };
407
496
  if (require.main === module) main();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.43.0",
4
- "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
3
+ "version": "1.45.0",
4
+ "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "agents-deck": "bin/agent-dag.js",
@@ -10,21 +10,97 @@
10
10
  // bin/deck.js is the only caller. The parser is the whole of the module's
11
11
  // surface; the tables it matches against stay inside it.
12
12
 
13
+ /**
14
+ * Is this token a flag rather than somebody's value?
15
+ *
16
+ * A leading `-` and nothing else, because the alternatives are worse. Matching
17
+ * the known flag list would refuse `--workspace --prot` — a typo eating the next
18
+ * token is the same accident as a real flag eating it, and the one shape that
19
+ * must be caught is the one nobody spelled right. Refusing every value that
20
+ * begins with `-` would refuse `--port -1`, which is a number the user meant and
21
+ * which deserves the port error below rather than a "missing value" one.
22
+ *
23
+ * So: a leading `-`, except a plain negative number. The negative-number carve
24
+ * is the only exception, and it is small on purpose.
25
+ *
26
+ * WHAT THIS DOES ON EACH PLATFORM. Nothing, is the intent, to any real path.
27
+ * POSIX absolute (`/srv/proj`), POSIX relative (`./sub`, `sub`), `~/proj`, a
28
+ * Windows drive letter (`C:\Users\u\proj`, `c:/users/u/proj`), a UNC share
29
+ * (`\\srv\share\proj`), a Windows long path (`\\?\C:\proj`) and a bare
30
+ * `events.jsonl` all begin with something other than `-`, so all of them are
31
+ * values. A drive letter is not a flag on any platform and is never read as one:
32
+ * `C:` starts with `C`. What IS refused is a directory whose name really begins
33
+ * with a dash, which the user can still pass as `./-weird` — a two-character
34
+ * price for catching `--workspace $UNSET --no-persist`.
35
+ */
36
+ export function looksLikeFlag(token) {
37
+ return typeof token === "string" && token.startsWith("-") && !/^-\d+(?:\.\d+)?$/.test(token);
38
+ }
39
+
40
+ /**
41
+ * Is this a port the deck could bind? Digits only, inside the range Node's
42
+ * `listen` accepts.
43
+ *
44
+ * `Number()` alone is far too willing: it takes `" 4500 "`, `0x10e4`, `1e3` and
45
+ * `Infinity`, and turns everything else into the `NaN` that used to reach
46
+ * `listen` and die there. The whole point of asking here is to answer BEFORE the
47
+ * deck has installed hooks and probed for tools, and to answer about the string
48
+ * the user actually typed. See bin/deck.js, which prints the flag and the value
49
+ * back at them.
50
+ */
51
+ export function isPortValue(raw) {
52
+ if (typeof raw !== "string" && typeof raw !== "number") return false;
53
+ const s = String(raw).trim();
54
+ if (!/^\d+$/.test(s)) return false;
55
+ const n = Number(s);
56
+ return n >= 0 && n <= 65535;
57
+ }
58
+
13
59
  /**
14
60
  * Parse `process.argv.slice(2)`.
15
61
  *
16
- * Returns the flags that were set, plus `unknown` every token the loop did
17
- * not recognise, in the order it met them. That list is the point of this
18
- * module: the loop used to have no `else`, so `ccdeck --prot 4500` booted on
19
- * 4317 and said nothing, and a typo was indistinguishable from a flag that
20
- * worked.
62
+ * Returns the flags that were set, plus two lists that are always present and
63
+ * always arrays:
64
+ *
65
+ * `unknown` — every token the loop did not recognise, in the order it met
66
+ * them. That list is the point of this module: the loop used
67
+ * to have no `else`, so `ccdeck --prot 4500` booted on 4317 and
68
+ * said nothing, and a typo was indistinguishable from a flag
69
+ * that worked.
70
+ * `incomplete` — every value-taking flag that was given no value it could
71
+ * use, as `{ flag, expects }`, `flag` spelled the way the user
72
+ * spelled it. bin/deck.js prints one row per entry.
73
+ *
74
+ * THE THREE FLAGS THAT TAKE A VALUE (`--port`/`-p`, `--workspace`, `--history`)
75
+ * used to consume the next token with `args[++i]` whatever it was. That is right
76
+ * for `--port 4500` — the value must never be re-examined as a token of its own,
77
+ * or every correct command line would report its own port as an unknown option,
78
+ * and a warning that fires on correct input is a warning everybody learns to
79
+ * ignore. It was wrong for a value that is itself a flag (#697): `ccdeck
80
+ * --workspace $PROJ --no-persist` with `PROJ` unset is, after word splitting,
81
+ * `ccdeck --workspace --no-persist`. The deck scoped itself to a directory
82
+ * called `--no-persist`, wrote to the shared events log anyway, and reported
83
+ * neither — `unknown` stayed empty, because the token that would have gone in it
84
+ * had been eaten.
85
+ *
86
+ * So the consume is conditional now, and it refuses three shapes:
87
+ *
88
+ * * the next token looks like a flag — NOT consumed, so the loop meets it on
89
+ * the next pass and it is parsed as the flag it is, or reported as unknown.
90
+ * That is also what fixes the supervisor case in bin/agent-dag.js: a
91
+ * respawn appends `--port <bound>` to the user's argv, and an argv ending in
92
+ * a bare `--workspace` used to eat the `--port` and drop the deck back on
93
+ * 4317, out from under the tab the user was looking at.
94
+ * * there is no next token at all — the trailing `--workspace`, which used to
95
+ * set `undefined` and mean "the default", silently.
96
+ * * the next token is empty or blank — consumed (it was quoted, so it was
97
+ * meant as the value) but not used. `--workspace ""` is a variable that did
98
+ * not expand, not a request for machine-wide capture, and answering it with
99
+ * the widest possible scope is the one answer that cannot be recovered from.
21
100
  *
22
- * The three flags that TAKE a value (`--port`, `--workspace`, `--history`)
23
- * consume it with `args[++i]`, so the value is never re-examined as a token of
24
- * its own and cannot land in `unknown` — `--port 4500` says nothing about
25
- * `4500`, and `--workspace ~/some dir` nothing about the path. That is the way
26
- * an unknown-flag warning usually goes wrong, and it is asserted rather than
27
- * assumed; see src/web/__tests__/argv-480.test.ts.
101
+ * In all three the flag is left UNSET, so the deck falls back to its documented
102
+ * default, and the flag is named in `incomplete` so the fallback is said out
103
+ * loud rather than discovered later.
28
104
  *
29
105
  * A bare word is `unknown` too, and deliberately: the deck takes no positional
30
106
  * arguments at all, so `ccdeck ~/proj` is the same mistake as `ccdeck --workpace
@@ -35,19 +111,41 @@
35
111
  * one used to be dropped in silence (see launchNpx in bin/agent-dag.js).
36
112
  */
37
113
  export function parseArgs(args) {
38
- const out = { unknown: [] };
114
+ const out = { unknown: [], incomplete: [] };
39
115
  for (let i = 0; i < args.length; i++) {
40
116
  const a = args[i];
117
+ // The value of the flag just matched, or `undefined` when there is nothing
118
+ // usable there. Closes over `i` so it can decline to advance it: not
119
+ // consuming is what hands the token back to the loop.
120
+ const value = (expects) => {
121
+ const next = args[i + 1];
122
+ if (next === undefined || looksLikeFlag(next)) {
123
+ out.incomplete.push({ flag: a, expects });
124
+ return undefined;
125
+ }
126
+ i++;
127
+ if (String(next).trim() === "") {
128
+ out.incomplete.push({ flag: a, expects });
129
+ return undefined;
130
+ }
131
+ return next;
132
+ };
133
+ // Assigned only when there is a value, so an unusable one leaves the key
134
+ // absent and the deck on its default — see the doc comment.
135
+ const set = (key, expects) => {
136
+ const v = value(expects);
137
+ if (v !== undefined) out[key] = v;
138
+ };
41
139
  if (a === "-h" || a === "--help") out.help = true;
42
140
  else if (a === "-v" || a === "--version") out.version = true;
43
- else if (a === "-p" || a === "--port") out.port = args[++i];
141
+ else if (a === "-p" || a === "--port") set("port", "a port number");
44
142
  else if (a === "--no-open") out.noOpen = true;
45
143
  else if (a === "--uninstall") out.uninstall = true;
46
- else if (a === "--workspace") out.workspace = args[++i];
144
+ else if (a === "--workspace") set("workspace", "a path");
47
145
  else if (a === "--scope") out.scope = true;
48
146
  else if (a === "--all") out.all = true; // legacy no-op (now default)
49
147
  else if (a === "--no-persist") out.noPersist = true;
50
- else if (a === "--history") out.history = args[++i];
148
+ else if (a === "--history") set("history", "a path");
51
149
  else if (a === "--codex") out.codex = true;
52
150
  else if (a === "--no-codex") out.noCodex = true;
53
151
  else if (a === "--claude") out.claude = true;
@@ -30,6 +30,75 @@ const MARKER = path.join(CACHE_DIR, ".last-update-check");
30
30
 
31
31
  const _cache = new Map(); // key `${since}|${until}` → { result, at }
32
32
 
33
+ // How much a read is allowed to cost. /api/ccusage is a GET, deliberately: a
34
+ // cross-site read of the loopback port is an ordinary top-level navigation and
35
+ // isTrustedRead is right not to refuse it. What was missing is a ceiling on
36
+ // what one of those reads may start. Before #544, thirty distinct `since`
37
+ // values were thirty concurrent `node <PKG_DIR>/src/cli.js daily --json`
38
+ // children — each with a ninety-second deadline, each walking the whole
39
+ // ~/.claude log tree, doubled again whenever runDaily retried flagless — and
40
+ // thirty permanent Map entries. isCliDate admits all 10^8 eight-digit strings
41
+ // on purpose (a date outside the logs is ccusage's question to answer, not the
42
+ // deck's to guess at), so the map's key space was 10^8 and its eviction policy
43
+ // was none.
44
+ //
45
+ // Both numbers are set against what the feature does rather than against the
46
+ // attack. The usage-history modal asks for one range at a time and offers three
47
+ // presets, so four ranges outstanding at once is already more than a human
48
+ // clicking as fast as they can, and thirty-two remembered ranges is more than
49
+ // one sitting will ever look at. Whatever exceeds either is not a reader.
50
+ const CACHE_MAX = 32;
51
+ const MAX_OUTSTANDING = 4;
52
+
53
+ /** Ranges being fetched right now, keyed exactly as `_cache` is, so two callers
54
+ * asking the same question wait on one child instead of starting a second. The
55
+ * cache alone could never do this: it is written when a run finishes, and the
56
+ * whole window this is about is the ninety seconds before that. */
57
+ const _inflight = new Map();
58
+
59
+ /** The tail of the run queue, and how many runs are alive behind it.
60
+ *
61
+ * Serialised rather than merely counted, because two ccusage runs are two
62
+ * walks of the same directory tree: running them at once is slower than
63
+ * running them in sequence, so the queue costs a concurrent caller nothing it
64
+ * was actually going to get. What it buys is that a flood is one child at a
65
+ * time rather than a child per request. */
66
+ let _chain = Promise.resolve();
67
+ let _outstanding = 0;
68
+
69
+ /** Run `job` after every run already queued, whatever became of them — a run
70
+ * that threw must not take the queue down with it. */
71
+ function queued(job) {
72
+ _outstanding += 1;
73
+ const started = _chain.then(job, job);
74
+ _chain = started.then(() => {}, () => {});
75
+ return started.finally(() => { _outstanding -= 1; });
76
+ }
77
+
78
+ /**
79
+ * Remember one range's answer, and keep the map from being somewhere a caller
80
+ * can grow without limit.
81
+ *
82
+ * Entries past CACHE_MS can never be served again, so they are the ones to drop
83
+ * first; only when dropping all of them is still not enough does the oldest
84
+ * surviving entry go, which Map's insertion order hands over for free.
85
+ * Re-writing a key moves it to the back, so the range someone is actually
86
+ * polling is the last one evicted rather than the first.
87
+ */
88
+ function rememberRange(key, result, at) {
89
+ _cache.delete(key);
90
+ _cache.set(key, { result, at });
91
+ if (_cache.size <= CACHE_MAX) return;
92
+ for (const [k, v] of _cache) {
93
+ if (_cache.size <= CACHE_MAX) break;
94
+ if (at - v.at >= CACHE_MS) _cache.delete(k);
95
+ }
96
+ for (const k of _cache.keys()) {
97
+ if (_cache.size <= CACHE_MAX) break;
98
+ _cache.delete(k);
99
+ }
100
+ }
101
+
33
102
  /**
34
103
  * The name to hand cmd.exe for one of npm's Windows shims: its full path when
35
104
  * one can be found, and the bare name only when none can.
@@ -835,6 +904,41 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
835
904
  const cached = _cache.get(key);
836
905
  if (!force && cached && now - cached.at < CACHE_MS) return cached.result;
837
906
 
907
+ // A run for this exact range is already going: join it. `force` joins too,
908
+ // rather than starting a competing child — what ?refresh=1 asks for is a
909
+ // reading newer than the cache, and a run still in progress is one.
910
+ const already = _inflight.get(key);
911
+ if (already) return already;
912
+
913
+ // Refused here, before anything is spawned or remembered. The caller that
914
+ // reaches this is the fifth distinct range in flight at once, which the modal
915
+ // cannot produce; queueing it would mean holding a request open behind up to
916
+ // four ninety-second deadlines, which is a worse answer than saying no.
917
+ if (_outstanding >= MAX_OUTSTANDING) {
918
+ return {
919
+ ok: false,
920
+ reason: "busy",
921
+ error: `${MAX_OUTSTANDING} usage ranges are already being read \u2014 try again in a moment`,
922
+ fetchedAt: now,
923
+ };
924
+ }
925
+
926
+ const run = queued(() => readRange(sinceArg, until, key))
927
+ .finally(() => { _inflight.delete(key); });
928
+ _inflight.set(key, run);
929
+ return run;
930
+ }
931
+
932
+ /**
933
+ * One ccusage run, once the queue has let it through.
934
+ *
935
+ * `now` is read here rather than carried in from the call, so `fetchedAt` and
936
+ * the cache stamp both mean "when this reading was taken" even for a run that
937
+ * waited its turn. With an empty queue that is the same instant the caller
938
+ * asked, which is what this always did.
939
+ */
940
+ async function readRange(sinceArg, until, key) {
941
+ const now = Date.now();
838
942
  let result;
839
943
  let ran = null; // the runner that answered, for stamping a bad_output failure
840
944
  try {
@@ -892,7 +996,7 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
892
996
  };
893
997
  }
894
998
 
895
- _cache.set(key, { result, at: now });
999
+ rememberRange(key, result, now);
896
1000
  return result;
897
1001
  }
898
1002