agent-dag 1.43.0 → 1.44.1

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.
@@ -1,11 +1,11 @@
1
1
  // Which of the three published commands the user typed — and nothing else.
2
2
  //
3
- // The deck is on npm three times over: `agents-deck` (what this repo publishes),
4
- // `agent-dag` (the same tarball under the name it shipped as first) and
5
- // `ccdeck` (a stub that depends on agents-deck and spawns its bin). Every
6
- // surface a human reads says ccdeck now, and 95% of the downloads are still on
7
- // the other two, so nothing closes that split on its own. This file is the
8
- // evidence behind saying so — once in the terminal, once in the browser.
3
+ // The deck is on npm three times over — `ccdeck`, `agents-deck` and `agent-dag`
4
+ // — and since #340 all three are literally the same tarball, published with
5
+ // `name` set to each in turn. Every surface a human reads says ccdeck now, and
6
+ // most of the downloads are still on the other two, so nothing closes that split
7
+ // on its own. This file is the evidence behind saying so — once in the terminal,
8
+ // once in the browser.
9
9
  //
10
10
  // Two rules make that safe, and they are the whole of the file.
11
11
  //
@@ -15,13 +15,15 @@
15
15
  // dead on the one machine where the deck was the thing that would have
16
16
  // explained why.
17
17
  //
18
- // And it is never asked of the PACKAGE. `ccdeck/package.json` depends on
19
- // `agents-deck`, and `ccdeck/bin/ccdeck.js` spawns
20
- // `../../agents-deck/bin/agent-dag.js` — so "is this build agents-deck?" is
21
- // true inside every ccdeck run, and a notice keyed on it would scold exactly
22
- // the people who did what we asked. The question is which name the USER TYPED.
23
- // That is a different question, with a different answer, and on one platform
24
- // with no answer at all:
18
+ // And it is never asked of the PACKAGE. That was unarguable while ccdeck was a
19
+ // stub — it depended on `agents-deck` and spawned its bin, so "is this build
20
+ // agents-deck?" was true inside every ccdeck run, and a notice keyed on it would
21
+ // have scolded exactly the people who did what we asked. #340 removed the stub
22
+ // and the rule outlived it, because the three names are now ONE tarball: asking
23
+ // the package what it is gets you whichever name the publish step happened to
24
+ // set last, which is not evidence about the user at all. The question is which
25
+ // name the USER TYPED. That is a different question, with a different answer,
26
+ // and on one platform with no answer at all:
25
27
  //
26
28
  // npx, everywhere — npm writes the literal spec into
27
29
  // `_npx/<hash>/package.json` as `_npx.packages`, which npxRestartSpec
@@ -37,7 +39,7 @@
37
39
  //
38
40
  // a global install on Windows — nowhere. npm writes <name>.cmd, <name>.ps1
39
41
  // and an extensionless sh shim, each of which runs
40
- // `node "…\node_modules\agents-deck\bin\agent-dag.js" %*` (npm's cmd-shim).
42
+ // `node "…\node_modules\<pkg>\bin\agent-dag.js" %*` (npm's cmd-shim).
41
43
  // The typed name is the shim's FILENAME and never becomes an argument, and
42
44
  // there is no other carrier — npm_config_user_agent and friends are not set
43
45
  // for a direct bin invocation. So Windows answers null.
@@ -29,7 +29,7 @@
29
29
  // cooldown; 1 is not, because it is a local file read.
30
30
  import { activeAccountUsage, requestCollection } from "./claude-accounts.mjs";
31
31
  import { claudeCliCandidates, claudeConfigDir } from "./claude-dir.mjs";
32
- import { run } from "./exec.mjs";
32
+ import { pathLookup, run } from "./exec.mjs";
33
33
  import { existsSync } from "node:fs";
34
34
  import { readFile } from "node:fs/promises";
35
35
  import { join } from "node:path";
@@ -174,6 +174,11 @@ let _cacheAt = 0;
174
174
  let _inflight = null; // deduplicates concurrent CLI probes
175
175
  let _lastGood = null; // last result that had real quota percentages
176
176
  let _lastSelfPollAt = 0;
177
+ // Which account the readings below are about — as a counter, because the
178
+ // account's identity is not something this module holds. invalidateQuotaCache
179
+ // bumps it; every write in _doFetch is stamped with the value that was current
180
+ // when that read STARTED. See publish().
181
+ let _generation = 0;
177
182
 
178
183
  const CACHE_MS = 60_000;
179
184
 
@@ -356,15 +361,69 @@ function parseUsageText(raw) {
356
361
  *
357
362
  * Exported, with everything it touches injectable, so the Windows branch is
358
363
  * testable from the platforms this repo is actually developed on.
364
+ *
365
+ * WHY THE BARE NAME HAS TO EARN ITS PLACE (#553). This used to be a `.find`
366
+ * over `!c.includes(sep) || exists(c)`, which reads as "a bare name always
367
+ * answers, a full path only when it is there". On Windows that is harmless —
368
+ * the bare name is LAST in the list — but on POSIX it is FIRST, so the `||`
369
+ * short-circuited on candidate one and `exists` was never called even once:
370
+ * `~/.local/bin/claude`, `/usr/local/bin/claude` and `/opt/homebrew/bin/claude`
371
+ * were in a list nothing ever read. The user this broke is the one
372
+ * claude-dir.mjs names out loud: Claude Code installed by the official
373
+ * installer, so the binary is at `~/.local/bin/claude`, and the deck launched
374
+ * from something whose PATH never sourced a shell rc — a LaunchAgent, a
375
+ * systemd user unit, pm2, a desktop shortcut. `hasClaudeInstalled()` stats the
376
+ * absolute paths and says yes, so hooks install and the Claude surface turns
377
+ * on; every `claude --print /usage` spawn is then a bare-name ENOENT logged as
378
+ * `quota: claude CLI failed`. On macOS there is no `.credentials.json` to fall
379
+ * back to (the token is in the Keychain, #360), so the quota panel simply stays
380
+ * dark on a machine that plainly has Claude Code. The identical install on
381
+ * Windows worked, because there the ordering already said what this now says.
382
+ *
383
+ * WHICH WINS. The candidate list's own order decides, unchanged on both
384
+ * platforms — PATH first on POSIX, the two known install directories first on
385
+ * Windows — because the ordering question here is the one getRunner in
386
+ * ccusage.mjs already answered: preferring a different copy would silently
387
+ * change which binary runs on every machine that has two, and a deck that
388
+ * works today must not start running a `claude` it has never run. A user with
389
+ * a current claude on PATH via nvm/mise/volta and a stale one left in
390
+ * `~/.local/bin` keeps getting the one their own shell gives them. All that
391
+ * changes is that a bare name is now only ANSWERED WITH when PATH actually
392
+ * holds it, which is the same rule claudeCliOnDisk in claude-dir.mjs has
393
+ * always applied to this very list — the two readers of one list can no longer
394
+ * disagree about whether the deck can run what it says is installed.
395
+ *
396
+ * WHAT IT COSTS. One PATH walk, stopping at the first hit, and only for the
397
+ * bare candidate; the absolute paths are stat'ed only once PATH has come up
398
+ * empty. That is the trade ccusage.mjs already priced for the same shape of
399
+ * question — "a handful of stats, once per uncached fetch, against a process
400
+ * spawn that follows it" — and here the spawn that follows is a whole Claude
401
+ * Code process measured at ~3s, behind the SELF_POLL_MS floor.
402
+ *
403
+ * `pathLookup` is used as a yes/no gate rather than for the path it found, on
404
+ * purpose: answering with the bare name keeps spawn's own resolution (and, on
405
+ * Windows, exec.mjs's PATHEXT candidate walk) in charge of the PATH case
406
+ * exactly as before, so a PATH entry that merely LOOKS like a hit — a
407
+ * directory named `claude` — cannot become the answer.
359
408
  */
360
409
  export function quotaClaudeBin(platform = process.platform, env = process.env,
361
410
  home = homedir(), exists = existsSync) {
362
411
  const sep = platform === "win32" ? "\\" : "/";
363
- // A bare name is left to spawn's own PATH lookup (and, on Windows, to the
364
- // PATHEXT walk exec.mjs does by hand); a full path is only worth naming when
365
- // it is actually there.
366
- return claudeCliCandidates(platform, env, home)
367
- .find(c => !c.includes(sep) || exists(c)) ?? "claude";
412
+ // process.env is case-insensitive on Windows; an injected plain object in a
413
+ // test is not, and %Path% is how the variable is actually spelled there.
414
+ const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
415
+ for (const c of claudeCliCandidates(platform, env, home)) {
416
+ // A full path is worth a single stat; a bare name means "ask PATH", which
417
+ // is pathLookup's walk — PATHEXT included, since `claude` on Windows is
418
+ // spelled `claude.exe` or `claude.cmd` and never the bare word.
419
+ if (c.includes(sep)) { if (exists(c)) return c; }
420
+ else if (pathLookup(c, platform, { pathEnv, exists })) return c;
421
+ }
422
+ // Nothing on PATH and nothing at any known install directory. The bare name
423
+ // is still the right last resort — POSIX `execvp` and cmd.exe's own search
424
+ // both deserve their turn at a layout no list here knows — and the ENOENT it
425
+ // produces is what `quota: claude CLI failed` reports.
426
+ return "claude";
368
427
  }
369
428
 
370
429
  export async function fetchClaudeQuota({ force = false } = {}) {
@@ -376,8 +435,44 @@ export async function fetchClaudeQuota({ force = false } = {}) {
376
435
  // good result with 0%).
377
436
  if (_inflight) return _inflight;
378
437
 
379
- _inflight = _doFetch(now, force).finally(() => { _inflight = null; });
380
- return _inflight;
438
+ // `_inflight === mine` rather than a bare clear: invalidateQuotaCache drops
439
+ // `_inflight` so the next caller starts a read that knows the account moved,
440
+ // and that read installs its own promise here. A read from before the switch
441
+ // finishing afterwards would otherwise clear the NEW one on its way out,
442
+ // letting a third caller spawn a second concurrent probe — which is the very
443
+ // thing this slot exists to prevent.
444
+ const mine = _doFetch(now, force, _generation)
445
+ .finally(() => { if (_inflight === mine) _inflight = null; });
446
+ _inflight = mine;
447
+ return mine;
448
+ }
449
+
450
+ /**
451
+ * Write a reading into the caches, unless the account moved while it was being
452
+ * taken.
453
+ *
454
+ * Every one of _doFetch's writes happens after at least one await — a store
455
+ * read, a 15-second HTTPS call, up to three `claude --print /usage` spawns with
456
+ * 1.2s between them — and invalidateQuotaCache clears variables, which does
457
+ * nothing to a function that is already running and still holds the old
458
+ * account's answer in a local. So a switch landing mid-flight was followed,
459
+ * milliseconds later, by the pre-switch reading being written straight back over
460
+ * the cleared cache: the invalidation looked like it worked and was undone
461
+ * before anyone could observe it.
462
+ *
463
+ * The fetch is deliberately NOT cancelled. Whoever asked for it is still owed an
464
+ * answer, and the reading is not wrong — it is simply about an account that is
465
+ * no longer active, which makes it a fine return value and a bad cached one.
466
+ * `_lastGood` gets the same guard, and needs it more: it is the half that
467
+ * survives the result cache's minute and comes back under a "stale" label for as
468
+ * long as the store has nothing to say about the new account.
469
+ */
470
+ function publish(gen, result, at, { good = false } = {}) {
471
+ if (gen !== _generation) return result;
472
+ _cache = result;
473
+ _cacheAt = at;
474
+ if (good) _lastGood = result;
475
+ return result;
381
476
  }
382
477
 
383
478
  /**
@@ -456,7 +551,7 @@ async function _execOnce(bin) {
456
551
  return { cliOk, parsed: parseUsageText(combined) };
457
552
  }
458
553
 
459
- async function _doFetch(now, force = false) {
554
+ async function _doFetch(now, force = false, gen = _generation) {
460
555
  // Source 1: claude-swap's store. Free, and already paid for.
461
556
  let store = await storeQuota();
462
557
 
@@ -473,9 +568,7 @@ async function _doFetch(now, force = false) {
473
568
  // throttle inside is shared with the accounts panel, so two open panels
474
569
  // ask no more often than one.
475
570
  if (!force) requestCollection().catch(() => {});
476
- _cache = store; _cacheAt = now;
477
- _lastGood = store;
478
- return store;
571
+ return publish(gen, store, now, { good: true });
479
572
  }
480
573
 
481
574
  // Nothing usable in the store. Everything below spends the user's budget, so
@@ -488,24 +581,16 @@ async function _doFetch(now, force = false) {
488
581
  // and then reverted to 23% (from a 48-minute-old store row) on the very
489
582
  // next poll, because the store had not moved.
490
583
  const held = freshest(store, _lastGood);
491
- if (held) {
492
- const result = { ...held, stale: true };
493
- _cache = result; _cacheAt = now;
494
- return result;
495
- }
584
+ if (held) return publish(gen, { ...held, stale: true }, now);
496
585
  const result = { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
497
- _cache = result; _cacheAt = now - (CACHE_MS - 5_000);
498
- return result;
586
+ return publish(gen, result, now - (CACHE_MS - 5_000));
499
587
  }
500
588
  _lastSelfPollAt = now;
501
589
 
502
590
  // Source 2: OAuth usage API — instant, exact, no cold-start gap.
503
591
  const api = await fetchOAuthUsage();
504
592
  if (api) {
505
- const result = { ok: true, ...api, source: "api", fetchedAt: now };
506
- _cache = result; _cacheAt = now;
507
- _lastGood = result;
508
- return result;
593
+ return publish(gen, { ok: true, ...api, source: "api", fetchedAt: now }, now, { good: true });
509
594
  }
510
595
 
511
596
  // Source 3: parse `claude --print /usage` CLI output.
@@ -526,10 +611,7 @@ async function _doFetch(now, force = false) {
526
611
 
527
612
  // Got real quota lines — cache normally and remember as last-known-good.
528
613
  if (parsed) {
529
- const result = { ok: true, ...parsed, source: "cli", fetchedAt: now };
530
- _cache = result; _cacheAt = now;
531
- _lastGood = result;
532
- return result;
614
+ return publish(gen, { ok: true, ...parsed, source: "cli", fetchedAt: now }, now, { good: true });
533
615
  }
534
616
 
535
617
  // No quota lines after retries. If we've ever seen real values, keep showing
@@ -540,10 +622,7 @@ async function _doFetch(now, force = false) {
540
622
  // vouches for numbers this branch already knows are stale. Short-cache so we
541
623
  // retry the CLI again soon.
542
624
  if (_lastGood) {
543
- const result = { ..._lastGood, stale: true };
544
- _cache = result;
545
- _cacheAt = now - (CACHE_MS - 5_000);
546
- return result;
625
+ return publish(gen, { ..._lastGood, stale: true }, now - (CACHE_MS - 5_000));
547
626
  }
548
627
 
549
628
  // Never had good data. CLI ran but lines absent → treat as genuine <1%.
@@ -552,9 +631,7 @@ async function _doFetch(now, force = false) {
552
631
  ? { ok: true, session5hPct: 0, session5hWindowSec: 18000,
553
632
  week7dPct: 0, week7dWindowSec: 604800, fetchedAt: now }
554
633
  : { ok: false, fetchedAt: now };
555
- _cache = result;
556
- _cacheAt = now - (CACHE_MS - 5_000);
557
- return result;
634
+ return publish(gen, result, now - (CACHE_MS - 5_000));
558
635
  }
559
636
 
560
637
  /**
@@ -579,9 +656,29 @@ async function _doFetch(now, force = false) {
579
656
  * the shared request budget, and one that reset it would make switching a way to
580
657
  * hammer it. Until the store answers for the new account, "no reading yet" is
581
658
  * the honest thing to serve.
659
+ *
660
+ * Clearing the three variables is not enough on its own, because a fetch that is
661
+ * already running is not a variable. `_doFetch` writes `_cache` and `_lastGood`
662
+ * AFTER its awaits, so one that read the store before the switch and resolves
663
+ * after it put the previous account's numbers back into both, undoing this call
664
+ * from the other side of an await — and callers arriving in that window were
665
+ * handed the same in-flight promise rather than a read that knows the account
666
+ * moved. The window is real: a forced fetch goes through nudgeAndReread, which
667
+ * sleeps REREAD_TRIES * REREAD_GAP_MS = 2.4 seconds by construction, comfortably
668
+ * longer than a `cswap switch`.
669
+ *
670
+ * So the generation counter moves too. Every write in `_doFetch` is stamped with
671
+ * the generation that was current when that read started, and publish() drops
672
+ * any write whose stamp is stale — the fetch still resolves, and whoever asked
673
+ * for it still gets its answer, but that answer no longer becomes this module's.
674
+ * `_inflight` is released for the same reason: the next caller must start a read
675
+ * of its own rather than join one that is describing the account the deck just
676
+ * left.
582
677
  */
583
678
  export function invalidateQuotaCache() {
584
679
  _cache = null;
585
680
  _cacheAt = 0;
586
681
  _lastGood = null;
682
+ _generation++;
683
+ _inflight = null;
587
684
  }