agent-dag 1.43.0 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/bin/agent-dag.js +87 -9
- package/bin/deck.js +133 -22
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +120 -31
- package/package.json +2 -2
- package/src/server/args.mjs +113 -15
- package/src/server/ccusage.mjs +105 -1
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-auth.mjs +9 -4
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +163 -9
- package/src/server/cswap-admin.mjs +346 -40
- package/src/server/cswap-auto.mjs +365 -12
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +233 -26
- package/src/server/index.mjs +1994 -157
- package/src/server/installer.mjs +173 -11
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +262 -21
- package/src/server/supervisor.mjs +103 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +43 -11
- package/dist/web/assets/index-BxAZQc7O.css +0 -1
- package/dist/web/assets/index-DRgZVqF-.js +0 -78
- package/hook/notify.js +0 -60
- package/src/server/sound-hook.mjs +0 -390
package/src/server/installer.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// sessions reach the same server through the rollout watcher instead, so one
|
|
6
6
|
// running server still sees both CLIs. Re-runs are safe; entries are tagged
|
|
7
7
|
// with __agent-dag and de-duped.
|
|
8
|
-
import { readFile, mkdir, unlink, rename, open, stat, chmod } from "node:fs/promises";
|
|
8
|
+
import { readFile, mkdir, unlink, rename, open, stat, chmod, realpath, readlink } from "node:fs/promises";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { join, resolve, dirname } from "node:path";
|
|
11
11
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -133,10 +133,6 @@ function stripBom(text) {
|
|
|
133
133
|
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
async function readJsonSafe(p) {
|
|
137
|
-
try { return JSON.parse(stripBom(await readFile(p, "utf8"))); } catch { return null; }
|
|
138
|
-
}
|
|
139
|
-
|
|
140
136
|
function unreadableSettings(p, why) {
|
|
141
137
|
const err = new Error(
|
|
142
138
|
`${p} could not be read as JSON (${why}). Refusing to overwrite it — ` +
|
|
@@ -144,6 +140,11 @@ function unreadableSettings(p, why) {
|
|
|
144
140
|
);
|
|
145
141
|
err.code = "SETTINGS_UNREADABLE";
|
|
146
142
|
err.settingsPath = p;
|
|
143
|
+
// The bare reason, without the path and without the remedy sentence, so a
|
|
144
|
+
// caller that wants to phrase its own advice — `--uninstall` does; "run
|
|
145
|
+
// ccdeck again" is the wrong instruction there — does not have to take this
|
|
146
|
+
// message apart with a regex to get at the only part it cannot re-derive.
|
|
147
|
+
err.why = why;
|
|
147
148
|
return err;
|
|
148
149
|
}
|
|
149
150
|
|
|
@@ -264,6 +265,66 @@ async function createTemp(target, { mode = 0o666, attempts = 5 } = {}) {
|
|
|
264
265
|
}
|
|
265
266
|
}
|
|
266
267
|
|
|
268
|
+
// A chain longer than this is a loop, or something no real setup has: stow and
|
|
269
|
+
// chezmoi produce one hop, an encrypted volume two. The number is a bound on the
|
|
270
|
+
// walk below, not a promise about how deep a legitimate link goes.
|
|
271
|
+
const MAX_LINK_HOPS = 8;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The file a name is really asking for — the one the links under it end at.
|
|
275
|
+
*
|
|
276
|
+
* A rename replaces the DIRECTORY ENTRY it is handed. Handed a symlink, it
|
|
277
|
+
* deletes the link and leaves an ordinary file where it was, and
|
|
278
|
+
* `~/.claude/settings.json` is a symlink on a great many machines: into a
|
|
279
|
+
* dotfiles repo, a stow or chezmoi target, an encrypted volume. The content
|
|
280
|
+
* survives — we write back what we read — so nothing looks wrong and nothing
|
|
281
|
+
* says anything. The repo copy keeps what it said before, never goes dirty, and
|
|
282
|
+
* from then on the user's edits there reach nobody while every launch rewrites
|
|
283
|
+
* the detached file and widens the gap. This is a file the deck did not create
|
|
284
|
+
* and does not own; quietly cutting it loose from the thing that manages it is
|
|
285
|
+
* worse than failing to write it at all.
|
|
286
|
+
*
|
|
287
|
+
* persistAuth in codex-auth.mjs has resolved for exactly this reason since it
|
|
288
|
+
* was written, on the file that is LESS often linked. This is the same rule at
|
|
289
|
+
* the helper every settings writer in the deck goes through — and the same
|
|
290
|
+
* function, so there is one of it rather than two that can drift.
|
|
291
|
+
*
|
|
292
|
+
* Resolving also decides which filesystem the temp file is staged on, and it has
|
|
293
|
+
* to be the target's. A rename is atomic within one filesystem and fails with
|
|
294
|
+
* EXDEV across two, so staging beside the LINK — a link into a dotfiles repo on
|
|
295
|
+
* a separate volume — is a write that cannot land at all.
|
|
296
|
+
*
|
|
297
|
+
* A DANGLING link is the case realpath alone cannot answer: the target not
|
|
298
|
+
* created yet, the encrypted volume not mounted. Answering it with the raw path
|
|
299
|
+
* is the bug again, because that is precisely when the link gets replaced — so
|
|
300
|
+
* the walk falls back to readlink, which reads a link without needing its target
|
|
301
|
+
* to exist, and follows it the way opening the name for writing would.
|
|
302
|
+
* `printf x > link` creates the target; it does not replace the link. If the
|
|
303
|
+
* target's directory is gone the write then fails, which is the honest answer:
|
|
304
|
+
* the bytes did not reach the file the user's setup points at.
|
|
305
|
+
*
|
|
306
|
+
* The ordinary case — a plain file, no link anywhere — is one realpath and the
|
|
307
|
+
* first return.
|
|
308
|
+
*/
|
|
309
|
+
async function resolveWriteTarget(raw) {
|
|
310
|
+
let at = raw;
|
|
311
|
+
for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
|
|
312
|
+
// Resolves every link on the path at once, when all of them lead somewhere.
|
|
313
|
+
const real = await realpath(at).catch(() => null);
|
|
314
|
+
if (real !== null) return real;
|
|
315
|
+
const to = await readlink(at).catch(() => null);
|
|
316
|
+
// Not a link, so nothing exists at this name yet and this name is the
|
|
317
|
+
// answer: a first install, or the far end of a chain we have just followed.
|
|
318
|
+
if (to === null) return at;
|
|
319
|
+
at = resolve(dirname(at), to);
|
|
320
|
+
}
|
|
321
|
+
// Only a cycle gets here. Say so rather than pick a link out of it and
|
|
322
|
+
// destroy that one — the OS answers a write through such a name the same way.
|
|
323
|
+
const err = new Error(`too many symbolic links resolving ${raw}`);
|
|
324
|
+
err.code = "ELOOP";
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
|
|
267
328
|
/**
|
|
268
329
|
* Replace a file in a single step readers cannot land inside.
|
|
269
330
|
*
|
|
@@ -272,8 +333,13 @@ async function createTemp(target, { mode = 0o666, attempts = 5 } = {}) {
|
|
|
272
333
|
* different ones. It is fsync'd before the rename so that a crash or power loss
|
|
273
334
|
* just after a successful install cannot leave the new directory entry pointing
|
|
274
335
|
* at blocks that were never flushed — the classic file-of-zero-bytes.
|
|
336
|
+
*
|
|
337
|
+
* "Beside the target" means beside the file the name resolves to, not beside the
|
|
338
|
+
* name: see resolveWriteTarget, which is what keeps a symlinked settings.json a
|
|
339
|
+
* symlink.
|
|
275
340
|
*/
|
|
276
|
-
async function writeFileAtomic(
|
|
341
|
+
async function writeFileAtomic(rawTarget, text) {
|
|
342
|
+
const target = await resolveWriteTarget(rawTarget);
|
|
277
343
|
const { tmp, handle } = await createTemp(target);
|
|
278
344
|
try {
|
|
279
345
|
try {
|
|
@@ -365,6 +431,49 @@ export async function installHooks({ provider = "claude" } = {}) {
|
|
|
365
431
|
current.hooks[evt] = cleaned;
|
|
366
432
|
}
|
|
367
433
|
|
|
434
|
+
// Retiring the finish-sound hook rides in here, on this read and this write,
|
|
435
|
+
// and this is the seam it needs rather than a convenient one. #704 moved the
|
|
436
|
+
// sound into the browser and deleted the script the old `Stop` entry ran, so
|
|
437
|
+
// an install that upgrades a machine which HAS that entry leaves a hook
|
|
438
|
+
// pointing at a file that is no longer in the package — an error at the end of
|
|
439
|
+
// every turn, on a machine that was working before the upgrade. It therefore
|
|
440
|
+
// has to happen without the user asking for it, and a normal boot is the only
|
|
441
|
+
// moment that qualifies.
|
|
442
|
+
//
|
|
443
|
+
// Riding along buys the two properties it would otherwise have to invent.
|
|
444
|
+
// There is ONE write of settings.json on the boot that retires, compared
|
|
445
|
+
// against the exact bytes read a few lines up — so a second deck doing the
|
|
446
|
+
// same work at the same time writes the same payload, and every later boot
|
|
447
|
+
// finds nothing to do and changes nothing. And the mutate-then-let-the-caller-
|
|
448
|
+
// write split is what keeps the script deletion after the write: until the new
|
|
449
|
+
// file has landed, a live Claude Code session's next turn still runs the old
|
|
450
|
+
// command.
|
|
451
|
+
//
|
|
452
|
+
// Imported here rather than at the top of the file because retire-sound-hook.mjs
|
|
453
|
+
// imports this module — writeFileAtomic and readSettingsForWrite live here —
|
|
454
|
+
// and a static import would close that into a cycle. Claude only: the entry
|
|
455
|
+
// was one line in Claude Code's settings.json and there was never a Codex one.
|
|
456
|
+
//
|
|
457
|
+
// The equality test is not ceremony. Retirement DELETES two files — the parked
|
|
458
|
+
// hooks and the installed script — at absolute paths it resolved for itself,
|
|
459
|
+
// from claudeConfigDir() and os.homedir(), at its own import. This function
|
|
460
|
+
// writes `cfg.settingsPath`. In the product those are the same settings.json
|
|
461
|
+
// and the paths belong together. When they are not the same file, the two
|
|
462
|
+
// modules are looking at different homes, and acting on that difference means
|
|
463
|
+
// deleting files belonging to a machine this install is not writing to. That
|
|
464
|
+
// is not hypothetical: it happened to the author's own ~/.agents-deck while
|
|
465
|
+
// this very change was being written, from a test whose environment teardown
|
|
466
|
+
// ran a describe too early. Disagreement is a reason to do nothing.
|
|
467
|
+
let retire = { pending: false, changed: false, removed: 0, restored: 0 };
|
|
468
|
+
let completeSoundHookRetirement = null;
|
|
469
|
+
if (provider === "claude") {
|
|
470
|
+
const retirement = await import("./retire-sound-hook.mjs");
|
|
471
|
+
if (retirement.SETTINGS_PATH === cfg.settingsPath) {
|
|
472
|
+
completeSoundHookRetirement = retirement.completeSoundHookRetirement;
|
|
473
|
+
retire = await retirement.retireSoundHookIn(current);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
368
477
|
// Every launch reinstalls, and on all but the first the entries are already
|
|
369
478
|
// there and identical. Writing anyway is pure downside: it is one more chance
|
|
370
479
|
// to be interrupted mid-write, and one more window in which a change Claude
|
|
@@ -373,14 +482,63 @@ export async function installHooks({ provider = "claude" } = {}) {
|
|
|
373
482
|
const next = JSON.stringify(current, null, 2) + "\n";
|
|
374
483
|
const changed = next !== before;
|
|
375
484
|
if (changed) await writeFileAtomic(cfg.settingsPath, next);
|
|
376
|
-
|
|
485
|
+
// After the write, never before it: the notify script an older deck installed
|
|
486
|
+
// is what a live session's cached command still names until the new entry is
|
|
487
|
+
// on disk, and deleting it early turns a stale sound into a missing module.
|
|
488
|
+
if (retire.pending) await completeSoundHookRetirement(retire, current);
|
|
489
|
+
return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider, changed, retire };
|
|
377
490
|
}
|
|
378
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Take our forwarders back out of one provider's settings file.
|
|
494
|
+
*
|
|
495
|
+
* Returns `{ok: true, changed}` when the file was read — `changed` says whether
|
|
496
|
+
* anything of ours was in it — and `{ok: false, reason: "settings_unreadable"}`
|
|
497
|
+
* when it was not. Callers must look at `ok` FIRST: `changed: false` on a
|
|
498
|
+
* refusal is the literal truth about the disk and a lie about the question
|
|
499
|
+
* being asked, because the hooks are still in there.
|
|
500
|
+
*
|
|
501
|
+
* That conflation is what this used to ship. The read was readJsonSafe, which
|
|
502
|
+
* turned every parse and IO failure into `null`, so a settings.json with one
|
|
503
|
+
* stray comma — the exact file readSettingsForWrite was written to protect —
|
|
504
|
+
* came back indistinguishable from a clean machine with none of our hooks in
|
|
505
|
+
* it. `--uninstall` printed "no Claude hooks to remove" and exited 0 while all
|
|
506
|
+
* ten `__agent-dag` entries sat in the file, spawning node on every tool call
|
|
507
|
+
* of every session, for a deck the user had been told was gone. The other half
|
|
508
|
+
* of the same command already knew better: the sound-hook half read through
|
|
509
|
+
* readSettingsForWrite and said so out loud, so one command gave two opposite
|
|
510
|
+
* verdicts about one file and the load-bearing one was the one that lied.
|
|
511
|
+
*
|
|
512
|
+
* So the read is the same read the install does, and for the same reason. A
|
|
513
|
+
* file we cannot parse is a file whose contents we cannot reproduce, and this
|
|
514
|
+
* function rewrites the whole thing — every permission, env var, model pin and
|
|
515
|
+
* hand-written hook in it. Refusing leaves it byte for byte as it was found and
|
|
516
|
+
* hands the user something they can act on; guessing would either destroy it or
|
|
517
|
+
* quietly do nothing. Only ENOENT is genuinely empty, and readSettingsForWrite
|
|
518
|
+
* already answers that with `{}`, which falls through to `changed: false`.
|
|
519
|
+
*/
|
|
379
520
|
export async function uninstallHooks({ provider = "claude" } = {}) {
|
|
380
521
|
const cfg = PROVIDERS[provider];
|
|
381
522
|
if (!cfg) throw new Error(`unknown provider: ${provider}`);
|
|
382
|
-
|
|
383
|
-
|
|
523
|
+
let current;
|
|
524
|
+
try {
|
|
525
|
+
({ settings: current } = await readSettingsForWrite(cfg.settingsPath));
|
|
526
|
+
} catch (err) {
|
|
527
|
+
if (err?.code !== "SETTINGS_UNREADABLE") throw err;
|
|
528
|
+
// Same shape retireSoundHook answers with, so bin/deck.js reports both
|
|
529
|
+
// halves of `--uninstall` the same way instead of one of them inventing a
|
|
530
|
+
// second vocabulary for the identical condition on the identical file.
|
|
531
|
+
return {
|
|
532
|
+
ok: false,
|
|
533
|
+
reason: "settings_unreadable",
|
|
534
|
+
changed: false,
|
|
535
|
+
provider,
|
|
536
|
+
settingsPath: cfg.settingsPath,
|
|
537
|
+
why: err.why ?? err.message,
|
|
538
|
+
message: err.message,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
if (!current?.hooks) return { ok: true, changed: false, provider, settingsPath: cfg.settingsPath };
|
|
384
542
|
let changed = false;
|
|
385
543
|
for (const evt of Object.keys(current.hooks)) {
|
|
386
544
|
const cleaned = dedupeOurEntries(current.hooks[evt]);
|
|
@@ -389,7 +547,7 @@ export async function uninstallHooks({ provider = "claude" } = {}) {
|
|
|
389
547
|
else current.hooks[evt] = cleaned;
|
|
390
548
|
}
|
|
391
549
|
if (changed) await writeFileAtomic(cfg.settingsPath, JSON.stringify(current, null, 2) + "\n");
|
|
392
|
-
return { changed, provider, settingsPath: cfg.settingsPath };
|
|
550
|
+
return { ok: true, changed, provider, settingsPath: cfg.settingsPath };
|
|
393
551
|
}
|
|
394
552
|
|
|
395
553
|
/** True when ~/.codex/ exists — the CLI's default answer to whether the Codex
|
|
@@ -598,4 +756,8 @@ export { AGENT_DAG_DIR, CLAUDE_DIR, CODEX_DIR };
|
|
|
598
756
|
// the same reason one step lower: codex-auth.mjs needs the collision-free temp
|
|
599
757
|
// name but not writeFileAtomic's mode handling, which carries over the target's
|
|
600
758
|
// mode and so would leave a brand-new auth.json at whatever the umask allows.
|
|
601
|
-
|
|
759
|
+
// resolveWriteTarget goes with them, because auth.json is linked into a dotfiles
|
|
760
|
+
// repo for the same reasons settings.json is, and "never rename onto a link" is
|
|
761
|
+
// one rule: codex-auth.mjs called a realpath of its own before this existed, and
|
|
762
|
+
// two spellings of a rule are two things that can drift.
|
|
763
|
+
export { readSettingsForWrite, writeFileAtomic, installScript, renameWithRetry, createTemp, resolveWriteTarget };
|
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
// `
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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.
|
|
19
|
-
// `agents-deck
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// the
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
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.
|
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
|
}
|