agent-dag 1.42.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.
- package/README.md +10 -6
- package/bin/agent-dag.js +63 -9
- package/bin/deck.js +46 -11
- package/dist/web/assets/index-DBsxIfdM.js +78 -0
- package/dist/web/assets/index-XtT5NdJI.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/notify.mjs +104 -0
- package/package.json +2 -2
- package/src/server/ccusage.mjs +114 -2
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +108 -3
- package/src/server/cswap-admin.mjs +180 -6
- package/src/server/cswap-auto.mjs +209 -10
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +130 -11
- package/src/server/index.mjs +226 -19
- package/src/server/installer.mjs +145 -22
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/self-update.mjs +262 -21
- package/src/server/sound-hook.mjs +158 -28
- package/src/server/supervisor.mjs +36 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +48 -11
- package/dist/web/assets/index-CHFnwsds.css +0 -1
- package/dist/web/assets/index-DLihciEi.js +0 -78
- package/hook/notify.js +0 -60
package/src/server/quota.mjs
CHANGED
|
@@ -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
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|