agent-dag 1.47.0 → 3.0.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.
@@ -16,6 +16,10 @@
16
16
  import { readFile, writeFile, mkdir } from "node:fs/promises";
17
17
  import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
18
18
  import { run, runDetached } from "./exec.mjs";
19
+ // The CLI identity oracle, already written and already trusted by the account
20
+ // admin routes. #721 needs the same answer, so it reuses the same function
21
+ // rather than shelling out a second way to ask one question.
22
+ import { currentIdentity } from "./cswap-admin.mjs";
19
23
  import { dirname, join } from "node:path";
20
24
  import { existsSync } from "node:fs";
21
25
  import { homedir, platform } from "node:os";
@@ -398,6 +402,17 @@ async function readRoster(now, gen) {
398
402
  // fetched at all.
399
403
  nudgeCollector(rows, Object.keys(seq.accounts), now, seq.activeAccountNumber);
400
404
 
405
+ // Who the CLI says is signed in, asked ONLY when the store claims the active
406
+ // account is in trouble — see authTrouble. That is the one case where the
407
+ // stored verdict and the live truth can disagree, and it is rare: a healthy
408
+ // machine never spends this subprocess. Never fatal, because a CLI that
409
+ // cannot be reached is not evidence either way.
410
+ const activeNum = seq.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
411
+ const activeRow = activeNum ? rows[activeNum] : null;
412
+ const identity = (activeRow?.consecutiveFailures ?? 0) > 0
413
+ ? await currentIdentity().catch(() => null)
414
+ : null;
415
+
401
416
  const order = Array.isArray(seq.sequence) && seq.sequence.length
402
417
  ? seq.sequence.map(String)
403
418
  : Object.keys(seq.accounts).sort((a, b) => Number(a) - Number(b));
@@ -417,6 +432,8 @@ async function readRoster(now, gen) {
417
432
  const good = matches ? row.lastGood : null;
418
433
 
419
434
  const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
435
+ const isActive = String(seq.activeAccountNumber) === num;
436
+ const trouble = authTrouble(row, { matches, isActive, identity, email: acct.email });
420
437
 
421
438
  const lanes = [
422
439
  lane("five_hour", "5h", good?.five_hour),
@@ -442,23 +459,76 @@ async function readRoster(now, gen) {
442
459
  // plan and, for a healthy active account, the deck's freshen tick. The
443
460
  // plan alone would promise "next in 15m" while the panel actually
444
461
  // updates in three.
445
- nextAt: nextReadAt(row, matches, fetchedAtMs, String(seq.activeAccountNumber) === num, now),
462
+ nextAt: nextReadAt(row, matches, fetchedAtMs, isActive, now),
446
463
  stale: fetchedAtMs == null || now - fetchedAtMs > STALE_AFTER_MS,
447
464
  // Surfaced rather than hidden: a rate-limited or re-login-needed account
448
465
  // is exactly the one the user is about to try switching to.
449
466
  //
450
- // Keyed off consecutiveFailures, not lastError: claude-swap keeps
451
- // lastError as history and only advances fetchedAt on success, so an
452
- // account that hit a 429 an hour ago and has been fine since still
453
- // carries the string. Reading it directly pins a red badge on a healthy
454
- // account forever.
455
- error: (matches && row.consecutiveFailures > 0) ? (row.lastError ?? "error") : null,
467
+ // Through authTrouble rather than read straight off the row: see #721.
468
+ // consecutiveFailures says the COLLECTOR is failing, which for the active
469
+ // account is not the same claim as the user being signed out and the
470
+ // CLI can settle that.
471
+ error: trouble?.error ?? null,
472
+ // True when the collector cannot read this account but the user is signed
473
+ // in as it anyway. The panel says so quietly instead of offering to log
474
+ // them in again.
475
+ staleCopy: trouble?.kind === "stale-copy",
456
476
  });
457
477
  }
458
478
 
459
479
  return finish({ ok: true, accounts, activeNum: seq.activeAccountNumber ?? null, fetchedAt: now });
460
480
  }
461
481
 
482
+ /**
483
+ * What to say about an account whose collector is failing — which is not the
484
+ * same question as whether the user is signed out.
485
+ *
486
+ * TWO FACTS, AND #721 SHIPPED THEM AS ONE. claude-swap keeps its own copy of
487
+ * each account's credentials, taken when `cswap add` captured the slot. When
488
+ * that copy's refresh token dies, claude-swap can no longer collect usage for
489
+ * the row and says `relogin_required`. That is true, and it is about the COPY.
490
+ *
491
+ * It says nothing about whether the user is signed in. Measured on the machine
492
+ * that reported this, at the same instant:
493
+ *
494
+ * claude auth status --json -> loggedIn: true, claude3@sapec.md
495
+ * cswap list --json -> claude3@sapec.md: relogin_required
496
+ * GET /api/quota -> source: cli, 5h 33%, 7d 37%
497
+ *
498
+ * The user had signed in again in a terminal, which refreshes the LIVE
499
+ * credentials and leaves claude-swap's stored copy exactly as dead as it was.
500
+ * The deck held live quota numbers for that account and printed "login expired"
501
+ * beside them, and the button it offered ran `claude auth login` — a full
502
+ * re-login of the account the user was mid-session in, to fix a problem they
503
+ * did not have.
504
+ *
505
+ * So: for the ACTIVE account the CLI is the authority, because it is the one
506
+ * thing that can answer about the live credentials rather than about a copy.
507
+ * When it says the user is signed in as this account, there is no login
508
+ * failure to report — only a collector that cannot see it, which is quieter,
509
+ * true, and fixed by re-capturing the slot rather than by signing in again.
510
+ *
511
+ * `identity` is null when the CLI could not be asked at all. That is not
512
+ * evidence of anything, so the stored verdict stands: refusing to show a real
513
+ * expiry because a subprocess failed is the opposite mistake.
514
+ */
515
+ export function authTrouble(row, { matches, isActive, identity, email } = {}) {
516
+ if (!matches || !((row?.consecutiveFailures ?? 0) > 0)) return null;
517
+
518
+ const signedInHere = isActive
519
+ && identity
520
+ && typeof identity.email === "string"
521
+ && email
522
+ && identity.email.toLowerCase() === String(email).toLowerCase();
523
+
524
+ if (signedInHere) {
525
+ // Deliberately not the `error` field: this is not the user's problem to
526
+ // fix under a red badge, and it must not offer to sign them in again.
527
+ return { kind: "stale-copy", error: null };
528
+ }
529
+ return { kind: "auth", error: row.lastError ?? "error" };
530
+ }
531
+
462
532
  /**
463
533
  * claude-swap's last good usage for whichever account is active right now.
464
534
  *
@@ -4,6 +4,13 @@
4
4
  import { open, stat } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { StringDecoder } from "node:string_decoder";
7
+ import { createReadStream } from "node:fs";
8
+ // The NAMESPACE, never a named import. `import { createZstdDecompress }` is
9
+ // resolved at link time, and node:zlib has no such export before Node 22.15 —
10
+ // so the named form does not degrade on an older runtime, it throws before a
11
+ // single line of this module runs and takes the whole deck down with it. Caught
12
+ // by the timezone probe, which spawns a child that imports this file.
13
+ import zlib from "node:zlib";
7
14
  import { STOP, walkRolloutDays } from "./codex-dir.mjs";
8
15
  import { PRODUCT } from "./brand.mjs";
9
16
 
@@ -154,7 +161,75 @@ function foldTokenLine(series, raw) {
154
161
  // Returns an ascending-by-time array of { ts, inp, out, cacheR, total } where
155
162
  // `inp` includes the cached portion (Codex reports input_tokens incl. cache),
156
163
  // or null if the file has no usable token_count events.
164
+ /**
165
+ * Codex compresses cold rollouts, and this is how the deck keeps reading them.
166
+ *
167
+ * openai/codex 0.153.0 added a background worker that rewrites any rollout older
168
+ * than seven days as `rollout-….jsonl.zst`. Its own source says the quiet part
169
+ * out loud — "Requires every reader of the Codex home to support compressed
170
+ * shared histories" — and this deck is one of those readers. The flag
171
+ * (`local_thread_store_compression`) is still `default_enabled: false`, so
172
+ * nothing on disk has changed yet; the failure it would cause is why this is
173
+ * here before it does. A reader that matched only `.jsonl` would have skipped
174
+ * every day past the seventh IN SILENCE, and the 30-day Codex window would have
175
+ * quietly collapsed to the last seven with figures that still looked right.
176
+ *
177
+ * Streamed, not decompressed whole. The plain path reads a chunk at a time
178
+ * precisely so a megabyte of prompt text is never buffered, and handing that
179
+ * property back for a one-line `zstdDecompressSync` would trade a silent
180
+ * undercount for a memory spike.
181
+ *
182
+ * `createZstdDecompress` arrived in Node 22.15 and this package declares
183
+ * `>=18`, so a deck on an older runtime cannot read them at all. It says so
184
+ * once, on the terminal it was started from, rather than counting zero and
185
+ * looking healthy.
186
+ */
187
+ const COMPRESSED = ".jsonl.zst";
188
+ const createZstdDecompress = typeof zlib.createZstdDecompress === "function"
189
+ ? zlib.createZstdDecompress
190
+ : null;
191
+ let warnedNoZstd = false;
192
+
193
+ async function readCompressedTokenSeries(filePath) {
194
+ if (!createZstdDecompress) {
195
+ if (!warnedNoZstd) {
196
+ warnedNoZstd = true;
197
+ console.error(
198
+ `${PRODUCT} codex-usage: this Node (${process.version}) cannot read Codex's compressed `
199
+ + "rollouts; sessions older than about a week are being left out. Node 22.15 or newer reads them.",
200
+ );
201
+ }
202
+ return null;
203
+ }
204
+ try {
205
+ const series = [];
206
+ const decoder = new StringDecoder("utf8");
207
+ let pending = "";
208
+ const stream = createReadStream(filePath).pipe(createZstdDecompress());
209
+ for await (const chunk of stream) {
210
+ pending += decoder.write(chunk);
211
+ let from = 0;
212
+ let nl;
213
+ while ((nl = pending.indexOf("\n", from)) >= 0) {
214
+ foldTokenLine(series, pending.slice(from, nl));
215
+ from = nl + 1;
216
+ }
217
+ if (from > 0) pending = pending.slice(from);
218
+ }
219
+ pending += decoder.end();
220
+ if (pending) foldTokenLine(series, pending);
221
+ return series.length ? series : null;
222
+ } catch { return null; }
223
+ }
224
+
225
+ /** The reader, exported under a test-only name. The compressed path cannot be
226
+ * reached through `fetchCodexUsage` without a Codex home full of week-old
227
+ * sessions, and the thing worth checking is that both spellings produce the
228
+ * same series. */
229
+ export const readTokenSeriesForTest = filePath => readTokenSeries(filePath);
230
+
157
231
  async function readTokenSeries(filePath) {
232
+ if (filePath.endsWith(COMPRESSED)) return readCompressedTokenSeries(filePath);
158
233
  let fd;
159
234
  try {
160
235
  fd = await open(filePath, "r");
@@ -330,7 +405,10 @@ async function listRolloutFiles(sinceMs) {
330
405
  await walkRolloutDays(
331
406
  (dir, files) => {
332
407
  for (const f of files) {
333
- if (!f.endsWith(".jsonl")) continue;
408
+ // Both spellings. A cold rollout is `rollout-….jsonl.zst` and its name
409
+ // still carries the timestamp parseRolloutTime reads, so nothing else
410
+ // in this function has to know.
411
+ if (!f.endsWith(".jsonl") && !f.endsWith(COMPRESSED)) continue;
334
412
  const t = parseRolloutTime(f);
335
413
  if (t != null && nowMs - t <= sinceMs) {
336
414
  out.push({ path: join(dir, f), startMs: t });