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.
- package/README.md +10 -6
- package/bin/agent-dag.js +63 -9
- package/bin/deck.js +38 -10
- 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 +105 -1
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +98 -2
- 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 +81 -8
- 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 +152 -24
- package/src/server/supervisor.mjs +36 -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
|
@@ -48,6 +48,85 @@ let _cacheAt = 0;
|
|
|
48
48
|
// tracks what claude-swap is doing. No network cost to amortise.
|
|
49
49
|
const CACHE_MS = 5_000;
|
|
50
50
|
|
|
51
|
+
// ── what a forced read may cost ──────────────────────────────────────────────
|
|
52
|
+
//
|
|
53
|
+
// Until #604 the cache above was the whole of the admission control here, and
|
|
54
|
+
// `force` walked straight past it. `handleClaudeAccounts` reads `refresh=1` off
|
|
55
|
+
// the query string and passes it through, and reads on this server are
|
|
56
|
+
// deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
|
|
57
|
+
// that `isTrustedMutation` does, because a cross-site read of
|
|
58
|
+
// `http://127.0.0.1:4317` is an ordinary top-level navigation — so any page the
|
|
59
|
+
// user had open could run a `?refresh=1` loop and get one roster read per
|
|
60
|
+
// request, concurrently.
|
|
61
|
+
//
|
|
62
|
+
// What that buys is the cheapest of the six forcible routes and it is worth
|
|
63
|
+
// saying so plainly rather than dressing it up: two small local JSON reads,
|
|
64
|
+
// sequence.json and cache/usage.json, plus a call into nudgeCollector that is
|
|
65
|
+
// throttled on its own terms and spawns nothing when nothing is due. There is no
|
|
66
|
+
// network here by design — see the note at the top of this file — and no
|
|
67
|
+
// subprocess per request. This is the SHAPE the four routes before it were fixed
|
|
68
|
+
// for, not a cost anyone would have noticed.
|
|
69
|
+
//
|
|
70
|
+
// The reason to fix it anyway is the second half. This is the only one of the
|
|
71
|
+
// six whose cache is invalidated from elsewhere — nine call sites in four
|
|
72
|
+
// modules: six mutations in cswap-admin.mjs, an auto-switch in cswap-auto.mjs, a
|
|
73
|
+
// manual one in index.mjs, and the first `cswap add` below — and #582 has
|
|
74
|
+
// already shown what a read that started before an invalidation does when it
|
|
75
|
+
// lands after one. So the in-flight slot this route was missing arrives with the
|
|
76
|
+
// generation guard that makes it safe, rather than after the next bug report.
|
|
77
|
+
const FORCE_POLL_MS = 60_000;
|
|
78
|
+
|
|
79
|
+
// A read in progress, offered to callers that arrive while it is running.
|
|
80
|
+
let _inflight = null;
|
|
81
|
+
// Stamped when a read STARTS rather than when it lands: what the floor rations
|
|
82
|
+
// is the trip to disk, and one that is still running has already been paid for.
|
|
83
|
+
let _lastReadAt = 0;
|
|
84
|
+
// Which roster the reading below is about — as a counter, because the answer is
|
|
85
|
+
// about whichever account claude-swap's store says is active, and that can move
|
|
86
|
+
// under a read that is already running. invalidateClaudeAccountsCache bumps it;
|
|
87
|
+
// every write is stamped with the value that was current when the read STARTED.
|
|
88
|
+
// quota.mjs's `_generation`, for quota.mjs's reason (#582).
|
|
89
|
+
let _generation = 0;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether we may go to disk for the roster again.
|
|
93
|
+
*
|
|
94
|
+
* The same shape and the same minute as quota.mjs's `maySelfPoll`, and exported
|
|
95
|
+
* for the same reason it is: this is the rule, it is pure, and it belongs
|
|
96
|
+
* somewhere a test can point at it.
|
|
97
|
+
*
|
|
98
|
+
* Two intervals, like `maySelfPoll`'s. A forced read may beat the cache, but not
|
|
99
|
+
* turn into a poll loop when the button is held down, so it takes the minute the
|
|
100
|
+
* other four forcible routes use. An unforced read takes the cache's own
|
|
101
|
+
* interval — it is the panel's ordinary poll, the cache above has already
|
|
102
|
+
* answered it, and measuring from the START of the last read rather than from
|
|
103
|
+
* its end is the only difference between the two rules.
|
|
104
|
+
*/
|
|
105
|
+
export function mayReadAccounts({ now, force, lastReadAt }) {
|
|
106
|
+
return now - lastReadAt >= (force ? FORCE_POLL_MS : CACHE_MS);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The answer to a read the floor refused.
|
|
111
|
+
*
|
|
112
|
+
* A reading, not an error. AccountsPanel renders `data.accounts`, and an
|
|
113
|
+
* `{ ok: false }` refusal would empty the roster for a minute — the deck
|
|
114
|
+
* teaching itself a new failure mode in order to defend against a loop nobody
|
|
115
|
+
* ran. `stale` is the flag quota.mjs, codex-quota.mjs and codex-usage.mjs all
|
|
116
|
+
* use for exactly this, and every account row already carries its own
|
|
117
|
+
* `fetchedAt` from claude-swap's store, which is the age the panel draws and
|
|
118
|
+
* which nothing here touches.
|
|
119
|
+
*/
|
|
120
|
+
function heldReading(now) {
|
|
121
|
+
if (_cache) return { ..._cache, stale: true };
|
|
122
|
+
// Unreachable in practice, and spelled the way codex-quota.mjs and
|
|
123
|
+
// codex-usage.mjs spell the same state. Every outcome below is cached, a read
|
|
124
|
+
// still running is served by `_inflight`, and the one moment `_cache` is empty
|
|
125
|
+
// with a recent stamp — just after an invalidation — is exactly the moment
|
|
126
|
+
// invalidateClaudeAccountsCache clears the stamp as well.
|
|
127
|
+
return { ok: false, reason: "waiting", fetchedAt: now };
|
|
128
|
+
}
|
|
129
|
+
|
|
51
130
|
// Past this, claude-swap's own numbers are old enough that showing them
|
|
52
131
|
// without a marker would misrepresent them (its own trust ceiling is 3600s).
|
|
53
132
|
const STALE_AFTER_MS = 15 * 60_000;
|
|
@@ -255,8 +334,51 @@ function lane(id, label, win) {
|
|
|
255
334
|
export async function fetchClaudeAccounts({ force = false } = {}) {
|
|
256
335
|
const now = Date.now();
|
|
257
336
|
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
337
|
+
// Offered before the floor: a read that has not finished yet is a reading
|
|
338
|
+
// newer than the cache, which is what refresh asked for, and joining it costs
|
|
339
|
+
// nothing.
|
|
340
|
+
if (_inflight) return _inflight;
|
|
341
|
+
if (!mayReadAccounts({ now, force, lastReadAt: _lastReadAt })) return heldReading(now);
|
|
342
|
+
_lastReadAt = now;
|
|
343
|
+
|
|
344
|
+
// `_inflight === mine` rather than a bare clear, which is quota.mjs's guard
|
|
345
|
+
// and is here for quota.mjs's reason: invalidateClaudeAccountsCache drops
|
|
346
|
+
// `_inflight` so the next caller starts a read that knows the roster moved,
|
|
347
|
+
// and that read installs its own promise here. A read from before the switch
|
|
348
|
+
// finishing afterwards would otherwise clear the NEW one on its way out.
|
|
349
|
+
const mine = readRoster(now, _generation)
|
|
350
|
+
.finally(() => { if (_inflight === mine) _inflight = null; });
|
|
351
|
+
_inflight = mine;
|
|
352
|
+
return mine;
|
|
353
|
+
}
|
|
258
354
|
|
|
259
|
-
|
|
355
|
+
/**
|
|
356
|
+
* The read itself, split out from the admission control above it so the guard is
|
|
357
|
+
* readable as the four lines it is.
|
|
358
|
+
*
|
|
359
|
+
* `gen` is the generation that was current when this read STARTED, and finish()
|
|
360
|
+
* refuses to write anything under it once that has moved.
|
|
361
|
+
*
|
|
362
|
+
* Every write here happens after at least one await, and
|
|
363
|
+
* invalidateClaudeAccountsCache clears variables — which does nothing to a
|
|
364
|
+
* function that is already running and still holds the old roster in a local. So
|
|
365
|
+
* a switch landing mid-read was followed, milliseconds later, by the pre-switch
|
|
366
|
+
* roster being written straight back over the cleared cache: the invalidation
|
|
367
|
+
* looked like it worked and was undone before the next poll could observe it,
|
|
368
|
+
* and the panel went on showing the account the user had just switched away
|
|
369
|
+
* from. That is #582's defect, in the module #582 did not touch.
|
|
370
|
+
*
|
|
371
|
+
* The read is deliberately not cancelled. Whoever asked for it is still owed an
|
|
372
|
+
* answer, and the answer is not wrong — it is about a roster that has since
|
|
373
|
+
* moved, which makes it a fine return value and a bad cached one.
|
|
374
|
+
*/
|
|
375
|
+
async function readRoster(now, gen) {
|
|
376
|
+
const finish = (r) => {
|
|
377
|
+
if (gen !== _generation) return r;
|
|
378
|
+
_cache = r;
|
|
379
|
+
_cacheAt = Date.now();
|
|
380
|
+
return r;
|
|
381
|
+
};
|
|
260
382
|
|
|
261
383
|
const root = backupRoot();
|
|
262
384
|
const seq = await readJson(join(root, "sequence.json"));
|
|
@@ -395,9 +517,31 @@ export async function requestCollection() {
|
|
|
395
517
|
return _lastNudge !== before;
|
|
396
518
|
}
|
|
397
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Forget the roster, because something just made it wrong.
|
|
522
|
+
*
|
|
523
|
+
* Called from nine places in four modules — every `cswap` mutation the deck
|
|
524
|
+
* performs — and every one of them is behind a POST that `isTrustedMutation`
|
|
525
|
+
* guards, so nothing a page can send in a loop reaches this.
|
|
526
|
+
*
|
|
527
|
+
* Three things go besides the reading itself:
|
|
528
|
+
*
|
|
529
|
+
* `_generation` so a read that STARTED before this call cannot write its
|
|
530
|
+
* answer into the cache afterwards. See finish() in readRoster.
|
|
531
|
+
* `_inflight` so a caller arriving after the switch is not handed the read
|
|
532
|
+
* that began before it — joining a run is only free when the
|
|
533
|
+
* run is still about the right thing.
|
|
534
|
+
* `_lastReadAt` so the very next read is real work rather than a refusal.
|
|
535
|
+
* Every one of these call sites is followed by the panel
|
|
536
|
+
* reloading with ?refresh=1, and a floor that answered THAT
|
|
537
|
+
* with the pre-switch roster would make the guard the bug.
|
|
538
|
+
*/
|
|
398
539
|
export function invalidateClaudeAccountsCache() {
|
|
399
540
|
_cache = null;
|
|
400
541
|
_cacheAt = 0;
|
|
542
|
+
_generation++;
|
|
543
|
+
_inflight = null;
|
|
544
|
+
_lastReadAt = 0;
|
|
401
545
|
}
|
|
402
546
|
|
|
403
547
|
/**
|
|
@@ -243,9 +243,84 @@ async function requestUsage(base, auth) {
|
|
|
243
243
|
// to race over the single-use refresh token.
|
|
244
244
|
let _inflight = null;
|
|
245
245
|
|
|
246
|
+
// ── what a forced read may cost ────────────────────────────────────────────
|
|
247
|
+
// The floor between two reads WE pay for, and it is the same number and the
|
|
248
|
+
// same rule quota.mjs gives the Claude half — see FORCE_POLL_MS and maySelfPoll
|
|
249
|
+
// there. The two routes are four lines apart in the router and had no business
|
|
250
|
+
// disagreeing about what `?refresh=1` costs.
|
|
251
|
+
//
|
|
252
|
+
// `force` used to mean "skip the cache", and the cache was the ONLY thing
|
|
253
|
+
// between a caller and chatgpt.com. `_inflight` deduplicates callers that
|
|
254
|
+
// overlap and nothing else, so a caller that waits for one fetch to settle and
|
|
255
|
+
// then asks again got a fresh round trip every time — two authenticated HTTPS
|
|
256
|
+
// GETs carrying the user's live ChatGPT session, as fast as the round trip
|
|
257
|
+
// allows, from any page the user happens to have open (#580). Reads on this
|
|
258
|
+
// server are deliberately open (isTrustedRead), so "any page" is the real
|
|
259
|
+
// threat model rather than a hypothetical one.
|
|
260
|
+
//
|
|
261
|
+
// The sharper half is the credential rather than the traffic. On a 401
|
|
262
|
+
// doFetchCodexQuota spends the SINGLE-USE refresh token via forceCodexRefresh,
|
|
263
|
+
// and `staleAccessToken` only stops that happening twice for the same rejected
|
|
264
|
+
// token — every turn re-reads auth.json and sees the token the previous turn
|
|
265
|
+
// rotated to, so a backend that keeps answering 401 rotated a fresh credential
|
|
266
|
+
// once per request, racing the Codex CLI for each one. codex-auth.mjs's own
|
|
267
|
+
// EXPIRY_SKEW_MS comment says what losing that race costs the user: a
|
|
268
|
+
// `refresh_token_reused` that reads as "your login is broken", recoverable only
|
|
269
|
+
// with `codex login`.
|
|
270
|
+
const FORCE_POLL_MS = 60_000;
|
|
271
|
+
|
|
272
|
+
// Set from a 429 or a rejected refresh: a backend that is refusing us must not
|
|
273
|
+
// be asked once per request, whoever is asking. Same shape as quota.mjs's
|
|
274
|
+
// _rateLimitedUntil, which is likewise never beaten by force.
|
|
275
|
+
let _rateLimitedUntil = 0;
|
|
276
|
+
const COOLDOWN_MS = 5 * 60_000;
|
|
277
|
+
|
|
278
|
+
// Stamped when a fetch STARTS rather than when it lands: what the floor is
|
|
279
|
+
// rationing is the round trip, and one that is still in flight has already been
|
|
280
|
+
// paid for.
|
|
281
|
+
let _lastFetchAt = 0;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Whether we may spend a request of the user's ChatGPT session right now.
|
|
285
|
+
*
|
|
286
|
+
* Exported for tests, for the same reason quota.mjs exports maySelfPoll: this
|
|
287
|
+
* is the rule, it is pure, and it is worth pinning down away from the fetch it
|
|
288
|
+
* guards.
|
|
289
|
+
*/
|
|
290
|
+
export function mayFetchQuota({ now, lastFetchAt, rateLimitedUntil }) {
|
|
291
|
+
if (now < rateLimitedUntil) return false;
|
|
292
|
+
return now - lastFetchAt >= FORCE_POLL_MS;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The answer to a read the floor refused.
|
|
297
|
+
*
|
|
298
|
+
* A reading, not an error — a user who clicks ↻ twice in a second must get the
|
|
299
|
+
* numbers they already have rather than a red hint, which is exactly what
|
|
300
|
+
* quota.mjs does with `{ ...held, stale: true }`. The timestamp stays the one
|
|
301
|
+
* the data was fetched at, so the panel's age label keeps telling the truth
|
|
302
|
+
* instead of vouching for a reading it did not take.
|
|
303
|
+
*/
|
|
304
|
+
function heldReading(now) {
|
|
305
|
+
if (_cache) return { ..._cache, stale: true };
|
|
306
|
+
// Only reachable before the first fetch has ever landed — `finish` caches
|
|
307
|
+
// every outcome, failures included — and spelled the way the Claude side
|
|
308
|
+
// spells the same two states.
|
|
309
|
+
return { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
|
|
310
|
+
}
|
|
311
|
+
|
|
246
312
|
export function fetchCodexQuota({ force = false } = {}) {
|
|
247
|
-
|
|
248
|
-
|
|
313
|
+
const now = Date.now();
|
|
314
|
+
if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
|
|
315
|
+
// Joining a run already in flight costs nothing, so it is offered before the
|
|
316
|
+
// floor: what refresh asks for is a reading newer than the cache, and a fetch
|
|
317
|
+
// that has not landed yet is one.
|
|
318
|
+
if (_inflight) return _inflight;
|
|
319
|
+
if (!mayFetchQuota({ now, lastFetchAt: _lastFetchAt, rateLimitedUntil: _rateLimitedUntil })) {
|
|
320
|
+
return Promise.resolve(heldReading(now));
|
|
321
|
+
}
|
|
322
|
+
_lastFetchAt = now;
|
|
323
|
+
_inflight = doFetchCodexQuota().finally(() => { _inflight = null; });
|
|
249
324
|
return _inflight;
|
|
250
325
|
}
|
|
251
326
|
|
|
@@ -256,6 +331,13 @@ async function doFetchCodexQuota() {
|
|
|
256
331
|
// shortens the effective TTL for no reason.
|
|
257
332
|
const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
|
|
258
333
|
const fail = (reason) => finish({ ok: false, reason, fetchedAt: started });
|
|
334
|
+
// A refusal we were told about, rather than one we inferred: back off further
|
|
335
|
+
// than the ordinary floor before asking again. `retry-after` is honoured when
|
|
336
|
+
// the backend sends one, because it knows better than the constant does.
|
|
337
|
+
const cooldown = (res) => {
|
|
338
|
+
const after = parseInt(res?.headers?.get?.("retry-after") ?? "", 10);
|
|
339
|
+
_rateLimitedUntil = Date.now() + (Number.isFinite(after) ? after * 1000 : COOLDOWN_MS);
|
|
340
|
+
};
|
|
259
341
|
|
|
260
342
|
let auth, base, res;
|
|
261
343
|
try {
|
|
@@ -294,12 +376,22 @@ async function doFetchCodexQuota() {
|
|
|
294
376
|
// destroyed.
|
|
295
377
|
if (res.status === 401) {
|
|
296
378
|
const refreshed = await forceCodexRefresh(auth.accessToken);
|
|
297
|
-
if (!refreshed.ok)
|
|
379
|
+
if (!refreshed.ok) {
|
|
380
|
+
// The credential is gone and only `codex login` brings it back, so
|
|
381
|
+
// rotating another single-use token at the next request would burn the
|
|
382
|
+
// one the CLI is still holding. Wait.
|
|
383
|
+
if (refreshed.reason === "refresh_rejected") cooldown(null);
|
|
384
|
+
return fail(refreshed.reason);
|
|
385
|
+
}
|
|
298
386
|
auth = refreshed;
|
|
299
387
|
res = await requestUsage(base, auth);
|
|
300
388
|
}
|
|
301
389
|
|
|
302
390
|
if (!res.ok) {
|
|
391
|
+
// A second 401 means the token we just rotated to was rejected as well —
|
|
392
|
+
// the case that turned into one rotation per request. 429 is the backend
|
|
393
|
+
// saying the same thing in the ordinary way.
|
|
394
|
+
if (res.status === 401 || res.status === 429) cooldown(res);
|
|
303
395
|
return fail(res.status === 401 ? "refresh_rejected" : `http_${res.status}`);
|
|
304
396
|
}
|
|
305
397
|
} catch (err) {
|
|
@@ -12,6 +12,88 @@ let _cache = null;
|
|
|
12
12
|
let _cacheAt = 0;
|
|
13
13
|
const CACHE_MS = 60_000;
|
|
14
14
|
|
|
15
|
+
// ── what a forced read may cost ─────────────────────────────────────────────
|
|
16
|
+
// Until #600 the cache above was the whole of the admission control here, and
|
|
17
|
+
// `force` walked straight past it. That made one GET worth a full week of the
|
|
18
|
+
// rollout tree: listRolloutFiles(WINDOW_7D_MS) and then a read of every file it
|
|
19
|
+
// returns. Measured on this repo's machine against 280 rollouts of ~90KB — a
|
|
20
|
+
// week of ordinary use — one forced call is 685ms, 280 file opens and a peak of
|
|
21
|
+
// four descriptors. Nothing bounded how many of those calls ran at once, and
|
|
22
|
+
// the cost scaled exactly linearly: 16 concurrent forced reads were 4,480 opens
|
|
23
|
+
// and 64 descriptors, 128 were 35,840 opens, 512 descriptors and 54.7s.
|
|
24
|
+
//
|
|
25
|
+
// Reads on this server are deliberately open — `isTrustedRead` does not apply
|
|
26
|
+
// the `Sec-Fetch-Site` test that `isTrustedMutation` does, because a cross-site
|
|
27
|
+
// read of `http://127.0.0.1:4317` is an ordinary top-level navigation — so any
|
|
28
|
+
// page the user has open could run
|
|
29
|
+
//
|
|
30
|
+
// for (;;) fetch("http://127.0.0.1:4317/api/codex-usage?refresh=1",
|
|
31
|
+
// { mode: "no-cors" });
|
|
32
|
+
//
|
|
33
|
+
// and get one of those scans per request. MAX_PARALLEL_READS below bounds the
|
|
34
|
+
// fan-out WITHIN one call, and it exists because opening a week of rollouts at
|
|
35
|
+
// once risked EMFILE — which readTokenSeries swallows into `return null`, a
|
|
36
|
+
// silent undercount rather than an error. Unbounded calls let that EMFILE back
|
|
37
|
+
// in through the door the pool does not cover.
|
|
38
|
+
//
|
|
39
|
+
// The two things between a caller and a scan are the ones quota.mjs established
|
|
40
|
+
// and codex-quota.mjs adopted in #597, spelled the same way in all three:
|
|
41
|
+
//
|
|
42
|
+
// _inflight — callers that overlap wait on the one scan already running,
|
|
43
|
+
// `force` included. What ?refresh=1 asks for is a reading
|
|
44
|
+
// newer than the cache, and a scan in progress is one, so
|
|
45
|
+
// joining it costs nothing and is offered before the floor.
|
|
46
|
+
// FORCE_POLL_MS — the minimum interval between two scans WE pay for.
|
|
47
|
+
// `_inflight` deduplicates callers that overlap and nothing
|
|
48
|
+
// else, so a caller that waits for one scan to settle and then
|
|
49
|
+
// asks again was a fresh week of the disk every time.
|
|
50
|
+
//
|
|
51
|
+
// This module has neither of the extra parts its two siblings carry, and
|
|
52
|
+
// deliberately: there is no upstream backend to rate-limit us, so no cooldown,
|
|
53
|
+
// and nothing outside this file invalidates the cache, so no generation guard.
|
|
54
|
+
let _inflight = null;
|
|
55
|
+
|
|
56
|
+
// Stamped when a scan STARTS rather than when it lands: what the floor rations
|
|
57
|
+
// is the walk of the disk, and one that is still running has already been paid
|
|
58
|
+
// for.
|
|
59
|
+
let _lastScanAt = 0;
|
|
60
|
+
|
|
61
|
+
// quota.mjs's number, and codex-quota.mjs's, for the reason those two give it:
|
|
62
|
+
// "The refresh button may beat that floor, but not turn into a poll loop when
|
|
63
|
+
// held down." Three routes within a few lines of each other in the router have
|
|
64
|
+
// no business disagreeing about what `?refresh=1` costs.
|
|
65
|
+
const FORCE_POLL_MS = 60_000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether we may walk a week of the rollout tree right now.
|
|
69
|
+
*
|
|
70
|
+
* Exported for tests, for the same reason quota.mjs exports `maySelfPoll` and
|
|
71
|
+
* codex-quota.mjs exports `mayFetchQuota`: this is the rule, it is pure, and it
|
|
72
|
+
* is worth pinning down away from the scan it guards.
|
|
73
|
+
*/
|
|
74
|
+
export function mayScanUsage({ now, lastScanAt }) {
|
|
75
|
+
return now - lastScanAt >= FORCE_POLL_MS;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The answer to a read the floor refused.
|
|
80
|
+
*
|
|
81
|
+
* A reading, not an error. The panel draws this number from `codexUsage?.ok &&
|
|
82
|
+
* window7d.sessionCount > 0`, so an `{ ok: false }` refusal would make the token
|
|
83
|
+
* line vanish for a minute — the deck teaching itself a new failure mode in
|
|
84
|
+
* order to defend against a loop nobody ran. `stale` is the flag quota.mjs and
|
|
85
|
+
* codex-quota.mjs both use for exactly this, and `fetchedAt` keeps the moment
|
|
86
|
+
* the DATA was read rather than the moment of the read that was refused, so an
|
|
87
|
+
* age label drawn from it never vouches for a scan that did not happen.
|
|
88
|
+
*/
|
|
89
|
+
function heldReading(now) {
|
|
90
|
+
if (_cache) return { ..._cache, stale: true };
|
|
91
|
+
// Only reachable before the first scan has ever landed — every outcome below
|
|
92
|
+
// is cached, failures included, and a scan that is still running is served by
|
|
93
|
+
// `_inflight` — and spelled the way codex-quota.mjs spells the same state.
|
|
94
|
+
return { ok: false, reason: "waiting", fetchedAt: now };
|
|
95
|
+
}
|
|
96
|
+
|
|
15
97
|
const WINDOW_5H_MS = 5 * 60 * 60 * 1000;
|
|
16
98
|
const WINDOW_7D_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
99
|
|
|
@@ -206,10 +288,24 @@ function emptyWindow() {
|
|
|
206
288
|
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, totalTokens: 0, sessionCount: 0 };
|
|
207
289
|
}
|
|
208
290
|
|
|
209
|
-
export
|
|
291
|
+
export function fetchCodexUsage({ force = false } = {}) {
|
|
210
292
|
const now = Date.now();
|
|
211
|
-
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
293
|
+
if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
|
|
294
|
+
// Offered before the floor: a scan that has not finished yet is a reading
|
|
295
|
+
// newer than the cache, which is what refresh asked for, and joining it costs
|
|
296
|
+
// nothing.
|
|
297
|
+
if (_inflight) return _inflight;
|
|
298
|
+
if (!mayScanUsage({ now, lastScanAt: _lastScanAt })) return Promise.resolve(heldReading(now));
|
|
299
|
+
_lastScanAt = now;
|
|
300
|
+
// A bare clear rather than quota.mjs's `_inflight === mine` check: that guard
|
|
301
|
+
// is there because invalidateQuotaCache drops the slot mid-flight, and this
|
|
302
|
+
// module has no invalidator to race with. If one is ever added, it needs the
|
|
303
|
+
// same check adding with it.
|
|
304
|
+
_inflight = scanCodexUsage(now).finally(() => { _inflight = null; });
|
|
305
|
+
return _inflight;
|
|
306
|
+
}
|
|
212
307
|
|
|
308
|
+
async function scanCodexUsage(now) {
|
|
213
309
|
const w5h = emptyWindow();
|
|
214
310
|
const w7d = emptyWindow();
|
|
215
311
|
const start5h = now - WINDOW_5H_MS;
|
|
@@ -17,10 +17,13 @@
|
|
|
17
17
|
// first account's record. Nothing upstream prevents it, so every mutation here
|
|
18
18
|
// goes through one mutex.
|
|
19
19
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
20
21
|
import { readFile } from "node:fs/promises";
|
|
22
|
+
import { homedir } from "node:os";
|
|
21
23
|
import { join } from "node:path";
|
|
22
|
-
import { looksMissing, run, runDetached, runInteractive } from "./exec.mjs";
|
|
24
|
+
import { looksMissing, pathLookup, run, runDetached, runInteractive } from "./exec.mjs";
|
|
23
25
|
import { backupRoot, invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
|
|
26
|
+
import { claudeCliCandidates } from "./claude-dir.mjs";
|
|
24
27
|
import { cswapBin } from "./cswap-install.mjs";
|
|
25
28
|
import { PRODUCT } from "./brand.mjs";
|
|
26
29
|
|
|
@@ -77,8 +80,79 @@ export function withStoreLock(fn) {
|
|
|
77
80
|
// import, remove, rename, reorder — failed with cmd.exe's "is not recognized",
|
|
78
81
|
// while the read-only half of the panel worked, because it was already using
|
|
79
82
|
// the resolver. Reported from Windows on 2026-08-14.
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Which `claude` the account surface runs: the configured one, else the first
|
|
86
|
+
* candidate this machine actually has, else the bare name.
|
|
87
|
+
*
|
|
88
|
+
* WHY THIS IS NOT `AGENTS_DECK_CLAUDE ?? "claude"` ANY MORE (#570). That was
|
|
89
|
+
* the whole of this module's resolution, and it feeds every child the accounts
|
|
90
|
+
* panel starts — `claude auth status --json` for `currentIdentity`, and the
|
|
91
|
+
* `claude auth login` whose output the sign-in dialog reads a link out of. On a
|
|
92
|
+
* machine whose `claude` is at `~/.local/bin/claude` but whose deck was started
|
|
93
|
+
* from something that never sourced a shell rc — a LaunchAgent, a systemd user
|
|
94
|
+
* unit, pm2, a desktop shortcut — the bare name is an ENOENT, so the login
|
|
95
|
+
* child is dead within milliseconds, the flow reports `no_url`, and the dialog
|
|
96
|
+
* shows "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE
|
|
97
|
+
* to its full path." That sentence is a real remedy and it is why this was a
|
|
98
|
+
* smaller bug than #553; it is still a request to spell out a path the deck had
|
|
99
|
+
* already found for itself, because `hasClaudeInstalled()` stat'ed that exact
|
|
100
|
+
* file at boot to decide this was a Claude machine, and since #553 the quota
|
|
101
|
+
* panel beside this one runs the same binary without being told anything.
|
|
102
|
+
*
|
|
103
|
+
* SO IT READS THE SAME LIST, ON THE SAME TERMS #553 SETTLED ON. The list is
|
|
104
|
+
* `claudeCliCandidates` in claude-dir.mjs, whose other two readers are
|
|
105
|
+
* `hasClaudeInstalled()` — the boot question this module's whole surface hangs
|
|
106
|
+
* off — and `quotaClaudeBin` in quota.mjs. This is the same question at a third
|
|
107
|
+
* site, so nothing here is decided again:
|
|
108
|
+
*
|
|
109
|
+
* - AGENTS_DECK_CLAUDE first, and it is the one thing that skips the list
|
|
110
|
+
* entirely. It is documented in the README as "full path to the `claude`
|
|
111
|
+
* CLI", it is what the failure message above tells people to set, and
|
|
112
|
+
* someone who set it has already been through this once — second-guessing
|
|
113
|
+
* them with a stat would be answering a question they have closed. An empty
|
|
114
|
+
* value reads as unset, the way `AGENTS_DECK_CSWAP` does in cswapBin.
|
|
115
|
+
* - Then the candidate list's own order, unchanged: PATH first on POSIX, the
|
|
116
|
+
* two known install directories first on Windows. Preferring a different
|
|
117
|
+
* copy would silently change which binary signs somebody in on every
|
|
118
|
+
* machine that has two, and a `claude auth login` that suddenly runs a
|
|
119
|
+
* different binary is a credential path, not a detail.
|
|
120
|
+
* - The bare name is only answered with when PATH actually holds it, and
|
|
121
|
+
* `pathLookup` is a yes/no gate rather than the path it found, so spawn's
|
|
122
|
+
* own resolution — and, on Windows, exec.mjs's PATHEXT walk, since `claude`
|
|
123
|
+
* there is `claude.exe` or `claude.cmd` and never the bare word — stays in
|
|
124
|
+
* charge of the PATH case exactly as before.
|
|
125
|
+
* - The absolute candidates are stat'ed only once PATH has come up empty, so
|
|
126
|
+
* the common case costs one stat rather than a directory walk. Against what
|
|
127
|
+
* follows it — a whole Claude Code process, and a browser sign-in a human
|
|
128
|
+
* is walking through — that is not a cost worth naming.
|
|
129
|
+
*
|
|
130
|
+
* Pure, with the platform, environment, home directory and existence check all
|
|
131
|
+
* parameters, so the Windows branch is checkable from the platforms this repo
|
|
132
|
+
* is actually developed on. Exported for that test rather than for a caller
|
|
133
|
+
* (#383): `claudeBin` below is the only one, and it hands back the real
|
|
134
|
+
* machine's answer.
|
|
135
|
+
*/
|
|
136
|
+
export function adminClaudeBin(platform = process.platform, env = process.env,
|
|
137
|
+
home = homedir(), exists = existsSync) {
|
|
138
|
+
if (env.AGENTS_DECK_CLAUDE) return env.AGENTS_DECK_CLAUDE;
|
|
139
|
+
const sep = platform === "win32" ? "\\" : "/";
|
|
140
|
+
// process.env is case-insensitive on Windows; an injected plain object in a
|
|
141
|
+
// test is not, and %Path% is how the variable is actually spelled there.
|
|
142
|
+
const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
|
|
143
|
+
for (const c of claudeCliCandidates(platform, env, home)) {
|
|
144
|
+
if (c.includes(sep)) { if (exists(c)) return c; }
|
|
145
|
+
else if (pathLookup(c, platform, { pathEnv, exists })) return c;
|
|
146
|
+
}
|
|
147
|
+
// Nothing on PATH and nothing at any known install directory. The bare name
|
|
148
|
+
// is still the right last resort — POSIX `execvp` and cmd.exe's own search
|
|
149
|
+
// both deserve their turn at a layout no list here knows — and the ENOENT it
|
|
150
|
+
// produces is what failureText turns into the AGENTS_DECK_CLAUDE sentence.
|
|
151
|
+
return "claude";
|
|
152
|
+
}
|
|
153
|
+
|
|
80
154
|
async function claudeBin() {
|
|
81
|
-
return
|
|
155
|
+
return adminClaudeBin();
|
|
82
156
|
}
|
|
83
157
|
|
|
84
158
|
/** Slot → email for everything currently in the store, plus the active slot. */
|
|
@@ -212,7 +286,71 @@ export function loginState() {
|
|
|
212
286
|
return { state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null };
|
|
213
287
|
}
|
|
214
288
|
|
|
289
|
+
/**
|
|
290
|
+
* What an address may be made of before it becomes an argv element.
|
|
291
|
+
*
|
|
292
|
+
* NOT AN RFC 5322 PARSER, and it should not be read as one. RFC 5322 permits
|
|
293
|
+
* quoted local parts, spaces inside them, comments in parentheses and bracketed
|
|
294
|
+
* address literals; a regex that accepted all of that would accept precisely the
|
|
295
|
+
* shapes this exists to keep out. The job here is narrower and worth stating
|
|
296
|
+
* plainly: keep a FLAG-SHAPED or WHITESPACE-BEARING string out of a spawn's
|
|
297
|
+
* argument vector. Anything it wrongly refuses is an address nobody has ever
|
|
298
|
+
* typed into this dialog; anything it wrongly accepts is inert as an argument,
|
|
299
|
+
* which is the only property being defended. Whether the address exists is
|
|
300
|
+
* Anthropic's question, asked a moment later by the CLI itself.
|
|
301
|
+
*
|
|
302
|
+
* `--email` was the field the alias allowlist below missed. `email.includes("@")`
|
|
303
|
+
* was the whole of its validation and the value came straight off the request
|
|
304
|
+
* body, so the two residuals exec.mjs documents were both reachable through it
|
|
305
|
+
* on Windows, where `claude` is a `.cmd` shim and the vector goes through
|
|
306
|
+
* `cmd.exe /d /s /c`: an interior newline is a command separator inside that one
|
|
307
|
+
* quoted line, and `%USERPROFILE%` expands inside quotes with no escape
|
|
308
|
+
* available. `"a@b\ncalc.exe"` and `"%USERPROFILE%@x"` both satisfy
|
|
309
|
+
* `includes("@")`, and both are payloads alias-charset.test.ts already pins as
|
|
310
|
+
* refused for the other field.
|
|
311
|
+
*
|
|
312
|
+
* The leading-character rule is the same argv-position rule ALIAS_OK now carries.
|
|
313
|
+
* `-x@y.z` starts with a dash, so a child parser reads it as an option rather
|
|
314
|
+
* than as the value of `--email`, and what happens next depends entirely on
|
|
315
|
+
* which options that CLI happens to define.
|
|
316
|
+
*/
|
|
317
|
+
const EMAIL_OK =
|
|
318
|
+
/^[A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
|
|
319
|
+
|
|
320
|
+
// The SMTP forward-path limit. The pattern above bounds each PIECE — 64 for the
|
|
321
|
+
// local part, 63 per label — and a domain may carry any number of labels, so
|
|
322
|
+
// without this the whole is unbounded. A bound belongs here for the reason
|
|
323
|
+
// ALIAS_OK has one: on Windows the value ends up inside a single cmd.exe command
|
|
324
|
+
// line, which has a hard length limit of its own.
|
|
325
|
+
const EMAIL_MAX_LENGTH = 254;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The `--email` value `claude auth login` should be given, or a refusal.
|
|
329
|
+
*
|
|
330
|
+
* Three answers rather than two, and the third is the one that matters: `null`
|
|
331
|
+
* means NO ADDRESS WAS OFFERED, which is the only shape the deck's own dialog
|
|
332
|
+
* sends (`AddAccountDialog` posts a bare `{action:"login"}`) and which must stay
|
|
333
|
+
* an ordinary sign-in with no flag appended. A value that is present and
|
|
334
|
+
* unusable is refused outright instead of being quietly dropped: dropping it
|
|
335
|
+
* would run a DIFFERENT sign-in from the one that was asked for and call it a
|
|
336
|
+
* success, and this route is reachable by anything holding the deck token.
|
|
337
|
+
*/
|
|
338
|
+
function loginEmailArg(email) {
|
|
339
|
+
if (email == null) return { ok: true, email: null };
|
|
340
|
+
if (typeof email !== "string") return { ok: false };
|
|
341
|
+
const clean = email.trim();
|
|
342
|
+
if (!clean) return { ok: true, email: null };
|
|
343
|
+
if (clean.length > EMAIL_MAX_LENGTH || !EMAIL_OK.test(clean)) return { ok: false };
|
|
344
|
+
return { ok: true, email: clean };
|
|
345
|
+
}
|
|
346
|
+
|
|
215
347
|
export async function startLogin({ email } = {}) {
|
|
348
|
+
// Argv position is settled first, before any state moves. A refusal here must
|
|
349
|
+
// not cancel a sign-in that is already running — the caller asked for
|
|
350
|
+
// something the deck will not do, and the flow already in flight is not part
|
|
351
|
+
// of that bargain.
|
|
352
|
+
const wanted = loginEmailArg(email);
|
|
353
|
+
if (!wanted.ok) return { ok: false, reason: "bad_email", ...loginState() };
|
|
216
354
|
// Registering is the half that writes to the store; interrupting it would
|
|
217
355
|
// leave an account half-recorded, so that one is refused. A flow merely
|
|
218
356
|
// waiting for a code is not precious — it is most often the one abandoned by
|
|
@@ -225,7 +363,7 @@ export async function startLogin({ email } = {}) {
|
|
|
225
363
|
await cancelLogin();
|
|
226
364
|
}
|
|
227
365
|
if (!_starting) {
|
|
228
|
-
_starting = spawnLogin(email).finally(() => { _starting = null; });
|
|
366
|
+
_starting = spawnLogin(wanted.email).finally(() => { _starting = null; });
|
|
229
367
|
}
|
|
230
368
|
const flow = await _starting;
|
|
231
369
|
|
|
@@ -277,7 +415,10 @@ async function spawnLogin(email) {
|
|
|
277
415
|
const identity = await currentIdentity();
|
|
278
416
|
|
|
279
417
|
const args = ["auth", "login"];
|
|
280
|
-
|
|
418
|
+
// Already through loginEmailArg, which is the only caller's boundary: this is
|
|
419
|
+
// either an address that cannot be read as a flag or null, and null is the
|
|
420
|
+
// ordinary case.
|
|
421
|
+
if (email) args.push("--email", email);
|
|
281
422
|
|
|
282
423
|
const child = runInteractive(await claudeBin(), args, { timeout: LOGIN_TIMEOUT_MS });
|
|
283
424
|
// A sign-in outlives the request that started it, so it can also outlive the
|
|
@@ -600,8 +741,34 @@ export async function removeAccount(num) {
|
|
|
600
741
|
* list, and it closes the unbounded-length half too: an alias is a short name
|
|
601
742
|
* shown instead of an email, so 64 characters is not a constraint anyone meets
|
|
602
743
|
* by accident.
|
|
744
|
+
*
|
|
745
|
+
* The leading `(?!-)` is the half that allowlist missed, and it is not about
|
|
746
|
+
* quoting at all — it is about ARGV POSITION, which no amount of quoting fixes
|
|
747
|
+
* because the value arrives intact and is then read as syntax by the CHILD.
|
|
748
|
+
* `-` is in the character class, so `--unset` matched, and
|
|
749
|
+
* `setAlias(3, "--unset")` built ["alias", "3", "--unset"] — character for
|
|
750
|
+
* character claude-swap's own command for CLEARING an alias. Its `_alias_command`
|
|
751
|
+
* hands that vector to argparse, which sets `unset=True` and leaves `alias_name`
|
|
752
|
+
* as None; the store dropped the name, cswap printed "Removed alias for
|
|
753
|
+
* Account 3", exited 0, and the deck reported the rename as a success. Any other
|
|
754
|
+
* `-x` spelling is consumed the same way — `-h` prints help and exits 0, which
|
|
755
|
+
* also arrives here as a rename that worked.
|
|
756
|
+
*
|
|
757
|
+
* argparse does honour `--` as an end-of-options separator, so
|
|
758
|
+
* ["alias", "3", "--", "--unset"] would reach `set_alias` as data. It is
|
|
759
|
+
* deliberately not used: the separator only helps for the one child whose parser
|
|
760
|
+
* we can read, `claude auth login` is the other spawn on this route and its
|
|
761
|
+
* parser is not ours to verify, and a value the deck refuses outright cannot be
|
|
762
|
+
* mangled by a CLI that changes its mind later. The validator is the guard.
|
|
763
|
+
*
|
|
764
|
+
* Refusing a leading dash rather than requiring a leading alphanumeric is the
|
|
765
|
+
* narrower rule, and it is the one the hazard actually describes: `.env` and
|
|
766
|
+
* `_work` are ordinary positional arguments to every parser involved, while
|
|
767
|
+
* `acme-corp` — the one dash-bearing name in alias-charset.test.ts's list of
|
|
768
|
+
* names people use — keeps working because only the FIRST character is
|
|
769
|
+
* constrained.
|
|
603
770
|
*/
|
|
604
|
-
const ALIAS_OK = /^[A-Za-z0-9 ._-]{1,64}$/;
|
|
771
|
+
const ALIAS_OK = /^(?!-)[A-Za-z0-9 ._-]{1,64}$/;
|
|
605
772
|
|
|
606
773
|
export async function setAlias(num, alias) {
|
|
607
774
|
const n = Number(num);
|
|
@@ -675,11 +842,18 @@ export async function moveAccount(num, slot) {
|
|
|
675
842
|
* Windows it is cmd.exe's two-line "is not recognized …/operable program or
|
|
676
843
|
* batch file.", and `firstUseful` — which takes the LAST line, correctly for
|
|
677
844
|
* every other CLI — leaves the second half on screen by itself.
|
|
845
|
+
*
|
|
846
|
+
* The exit status goes to looksMissing beside the text (#552). This is the one
|
|
847
|
+
* caller with no candidate spelling to compare against, so the shape rules alone
|
|
848
|
+
* are all the TEXT can offer it — and on a non-English Windows the text says
|
|
849
|
+
* nothing this recognises. The status does: 9009 is cmd.exe's "no such command"
|
|
850
|
+
* in every language. Without it, a German user pressing "share…" got the last
|
|
851
|
+
* line of a translated sentence instead of the sentence about PATH.
|
|
678
852
|
*/
|
|
679
853
|
export function failureText(r, what = "cswap") {
|
|
680
854
|
const out = `${r?.stderr ?? ""}\n${r?.stdout ?? ""}`;
|
|
681
855
|
const tool = String(what).split(" ")[0];
|
|
682
|
-
if (r?.code === "ENOENT" || looksMissing(out)) {
|
|
856
|
+
if (r?.code === "ENOENT" || looksMissing(out, "", r?.code)) {
|
|
683
857
|
return tool === "claude"
|
|
684
858
|
? "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE to its full path."
|
|
685
859
|
: "cswap could not be run: not on PATH, and not in the places uv and pipx install to. Set AGENTS_DECK_CSWAP to its full path.";
|