agent-dag 1.30.2 → 1.30.4

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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>agents-deck</title>
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
8
- <script type="module" crossorigin src="/assets/index-DtE3W-dV.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BLOOP_Fy.css">
8
+ <script type="module" crossorigin src="/assets/index-U1M_-2Ot.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-D3L9GICQ.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.30.2",
3
+ "version": "1.30.4",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -219,6 +219,64 @@ export async function fetchClaudeAccounts({ force = false } = {}) {
219
219
  return finish({ ok: true, accounts, activeNum: seq.activeAccountNumber ?? null, fetchedAt: now });
220
220
  }
221
221
 
222
+ /**
223
+ * claude-swap's last good usage for whichever account is active right now.
224
+ *
225
+ * The Usage panel wants the same numbers for one account that this panel shows
226
+ * for all of them, and claude-swap has already paid for them. Reading its row
227
+ * instead of asking Anthropic again is the difference between one collector on
228
+ * this machine and two competing for the same per-token budget — the second
229
+ * one is what was 429ing the first.
230
+ *
231
+ * Returns null rather than a partial when anything about the row is unsure:
232
+ * the caller's fallback is to fetch for itself, and a wrong number is worse
233
+ * than a slow one.
234
+ */
235
+ export async function activeAccountUsage() {
236
+ const root = backupRoot();
237
+ const seq = await readJson(join(root, "sequence.json"));
238
+ const num = seq?.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
239
+ const acct = num ? seq?.accounts?.[num] : null;
240
+ if (!acct) return null;
241
+
242
+ const usage = await readJson(join(root, "cache", "usage.json"));
243
+ if (usage?.schemaVersion !== 2) return null;
244
+
245
+ const row = usage.accounts?.[num];
246
+ // Same identity guard the panel uses: rows are keyed by slot, and slots are
247
+ // reused, so a row can outlive the account it was written for.
248
+ if (!row?.lastGood
249
+ || row.email !== acct.email
250
+ || (row.organizationUuid ?? "") !== (acct.organizationUuid ?? "")
251
+ || typeof row.fetchedAt !== "number") return null;
252
+
253
+ return {
254
+ num: Number(num),
255
+ email: acct.email ?? null,
256
+ lastGood: row.lastGood,
257
+ fetchedAt: Math.round(row.fetchedAt * 1000),
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Ask claude-swap to collect now, if its own schedule agrees.
263
+ *
264
+ * Exported for the Usage panel's refresh button, which has no other way to ask
265
+ * for fresher numbers once it stopped fetching them itself. Goes through the
266
+ * same throttle and the same `cswap list` as the accounts panel, so pressing
267
+ * refresh cannot outrun the request budget either.
268
+ */
269
+ export async function requestCollection() {
270
+ const root = backupRoot();
271
+ const seq = await readJson(join(root, "sequence.json"));
272
+ if (!seq?.accounts) return false;
273
+ const usage = await readJson(join(root, "cache", "usage.json"));
274
+ const rows = usage?.schemaVersion === 2 ? (usage.accounts ?? {}) : {};
275
+ const before = _lastNudge;
276
+ nudgeCollector(rows, Object.keys(seq.accounts), Date.now());
277
+ return _lastNudge !== before;
278
+ }
279
+
222
280
  export function invalidateClaudeAccountsCache() {
223
281
  _cache = null;
224
282
  _cacheAt = 0;
@@ -1,5 +1,5 @@
1
- // Auto-switch controls: read and write claude-swap's autoswitch settings, run
2
- // a tick on a schedule, and preview what a tick would do without doing it.
1
+ // Auto-switch controls: read and write claude-swap's autoswitch settings and
2
+ // run a tick on a schedule.
3
3
  //
4
4
  // The engine is claude-swap's own — `cswap auto --once` evaluates one tick and
5
5
  // exits, honouring the cooldown, quarantine and poll-budget state it keeps in
@@ -8,8 +8,7 @@
8
8
  // that owns them: nothing here decides when to switch, only when to ask.
9
9
  //
10
10
  // A tick can move the user's live Claude account, so it is off unless turned
11
- // on, the setting survives restarts, and the UI can always ask what a tick
12
- // WOULD do (--dry-run) before committing to letting it happen.
11
+ // on and the setting survives restarts.
13
12
  import { run } from "./exec.mjs";
14
13
  import { cswapBin } from "./cswap-install.mjs";
15
14
  import { readFile, writeFile, mkdir } from "node:fs/promises";
@@ -30,7 +29,6 @@ const SETTINGS = {
30
29
  "autoswitch.intervalSeconds": { type: "number", min: 15, max: 3600 },
31
30
  "autoswitch.cooldownSeconds": { type: "number", min: 0, max: 86400 },
32
31
  "autoswitch.hysteresisPct": { type: "number", min: 0, max: 50 },
33
- "autoswitch.strategy": { type: "enum", values: ["best", "consume-first"] },
34
32
  "autoswitch.model": { type: "model" },
35
33
  };
36
34
 
@@ -100,19 +98,6 @@ function summarise(stdout) {
100
98
  };
101
99
  }
102
100
 
103
- /**
104
- * Evaluate a tick without acting. Safe to call from a UI button: --dry-run
105
- * never switches and never writes claude-swap's state.
106
- */
107
- export async function previewAutoSwitch() {
108
- const r = await run(await cswapBin(), ["auto", "--once", "--dry-run", "--json"], { timeout: TICK_TIMEOUT_MS });
109
- if (!r.ok && !r.stdout) {
110
- return { ok: false, reason: r.code === "ENOENT" ? "no_cswap" : "tick_failed",
111
- detail: (r.stderr || "").trim().slice(0, 300) };
112
- }
113
- return { ok: true, dryRun: true, ...summarise(r.stdout) };
114
- }
115
-
116
101
  /** Evaluate a tick for real. May switch the active account. */
117
102
  async function runAutoTick() {
118
103
  const r = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
@@ -1100,9 +1100,6 @@ async function handleCswapAutoAction(req, res) {
1100
1100
  case "enable":
1101
1101
  result = await mod.setAutoEnabled(parsed.enabled === true);
1102
1102
  break;
1103
- case "preview":
1104
- result = await mod.previewAutoSwitch();
1105
- break;
1106
1103
  case "setting":
1107
1104
  result = await mod.setCswapConfig(String(parsed.key ?? ""), parsed.value);
1108
1105
  break;
@@ -1,17 +1,30 @@
1
- // Fetches Claude rate-limit quota.
1
+ // Claude rate-limit quota, from whichever source costs least.
2
2
  //
3
- // Primary source: Anthropic's OAuth usage API
4
- // GET https://api.anthropic.com/api/oauth/usage
5
- // Auth: Bearer token from ~/.claude/.credentials.json (claudeAiOauth.accessToken)
6
- // This is instant and exact same data the `/usage` command shows, but with no
7
- // cold-start gap (the CLI omits the quota lines on its first invocation after
8
- // idle). Mechanism reverse-engineered from steipete/CodexBar.
3
+ // All three sources below end at the same place: GET /api/oauth/usage, which
4
+ // Anthropic budgets at roughly 28-30 calls per rolling hour PER TOKEN, shared
5
+ // by every tool on the machine. That budget is the constraint this module is
6
+ // built around, because it was being blown by this module: a 60s poll is 60
7
+ // calls an hour on its own, and the account the deck was polling started
8
+ // answering http-429 to claude-swap, whose collections the accounts panel is
9
+ // entirely made of. One panel went stale so the other could be a minute
10
+ // fresher.
9
11
  //
10
- // Fallback: parse `claude --print /usage` CLI output (used only if the API call
11
- // fails no token, expired token, network error). On Windows the binary is a
12
- // .cmd wrapper, so we use exec() (shell-based) for correct quoting + stdin.
12
+ // 1. claude-swap's store free. It polls the active account on its own
13
+ // schedule and writes what it got; reading that file costs nothing and
14
+ // spends none of the budget. Used whenever it holds a recent enough row.
15
+ // 2. The OAuth usage API directly, with the token from
16
+ // ~/.claude/.credentials.json. Exact and instant. Mechanism
17
+ // reverse-engineered from steipete/CodexBar.
18
+ // 3. `claude --print /usage`, parsed. Used when there is no readable token —
19
+ // notably on macOS, where Claude Code keeps credentials in the Keychain
20
+ // and that file does not exist, so this is the ONLY self-service path
21
+ // there. It is also the most expensive: a whole Claude Code process per
22
+ // poll. On Windows the binary is a .cmd wrapper, so exec() (shell-based)
23
+ // is used for correct quoting + stdin.
13
24
  //
14
- // Result is cached for 60s.
25
+ // 2 and 3 are rate-floored (SELF_POLL_MS) and gated behind the same 429
26
+ // cooldown; 1 is not, because it is a local file read.
27
+ import { activeAccountUsage, requestCollection } from "./claude-accounts.mjs";
15
28
  import { exec } from "node:child_process";
16
29
  import { promisify } from "node:util";
17
30
  import { existsSync } from "node:fs";
@@ -136,8 +149,75 @@ let _cache = null;
136
149
  let _cacheAt = 0;
137
150
  let _inflight = null; // deduplicates concurrent exec() calls
138
151
  let _lastGood = null; // last result that had real quota percentages
152
+ let _lastSelfPollAt = 0;
153
+
139
154
  const CACHE_MS = 60_000;
140
155
 
156
+ // Floor between two polls WE pay for. Twelve an hour against a budget of
157
+ // ~28-30 leaves claude-swap room to collect for every account, which is what
158
+ // the accounts panel is made of. Only reached when the store cannot answer.
159
+ const SELF_POLL_MS = 5 * 60_000;
160
+
161
+ // The refresh button may beat that floor, but not turn into a poll loop when
162
+ // held down. It never beats the 429 cooldown.
163
+ const FORCE_POLL_MS = 60_000;
164
+
165
+ // How old a claude-swap row may be before we stop treating it as the answer.
166
+ // Its own default poll interval is 1800s, so a row older than this means its
167
+ // collector is backing off or not running — the case self-polling exists for.
168
+ const STORE_TRUSTED_MS = 45 * 60_000;
169
+
170
+ /**
171
+ * claude-swap's row for the active account, in the shape the panel speaks.
172
+ *
173
+ * Exported for tests: the mapping is where a wrong number would come from, and
174
+ * it is pure.
175
+ */
176
+ export function quotaFromStore(entry) {
177
+ const good = entry?.lastGood;
178
+ const fh = good?.five_hour;
179
+ const sd = good?.seven_day;
180
+ const primary = (typeof fh?.pct === "number") ? fh : sd;
181
+ if (typeof primary?.pct !== "number") return null;
182
+
183
+ const round = (v) => Math.min(100, Math.max(0, Math.round(v)));
184
+ const out = {
185
+ ok: true,
186
+ source: "claude-swap",
187
+ session5hPct: round(primary.pct),
188
+ session5hWindowSec: WIN_5H_SEC,
189
+ session5hReset: fmtResetIso(primary.resets_at),
190
+ session5hResetAt: isoToSec(primary.resets_at),
191
+ week7dWindowSec: WIN_7D_SEC,
192
+ week7dPct: typeof sd?.pct === "number" ? round(sd.pct) : 0,
193
+ week7dReset: fmtResetIso(sd?.resets_at),
194
+ week7dResetAt: isoToSec(sd?.resets_at),
195
+ // The age of the DATA, not of our read of it. The panel prints this, and
196
+ // "30s ago" over numbers claude-swap collected twenty minutes back is the
197
+ // kind of true-looking lie this whole change exists to remove.
198
+ fetchedAt: entry.fetchedAt,
199
+ };
200
+ // claude-swap keeps per-model windows in a named list rather than fixed
201
+ // fields, because which ones an account has depends on its plan.
202
+ for (const s of Array.isArray(good.scoped) ? good.scoped : []) {
203
+ if (typeof s?.pct !== "number") continue;
204
+ if (/sonnet/i.test(s.name ?? "")) out.weekSonnetPct = round(s.pct);
205
+ else if (/opus/i.test(s.name ?? "")) out.weekOpusPct = round(s.pct);
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * Whether we may spend a request of the user's budget right now.
212
+ *
213
+ * Exported for tests — this is the rule that stopped the deck from starving
214
+ * claude-swap, and it is worth pinning down.
215
+ */
216
+ export function maySelfPoll({ now, force, lastSelfPollAt, rateLimitedUntil }) {
217
+ if (now < rateLimitedUntil) return false;
218
+ return now - lastSelfPollAt >= (force ? FORCE_POLL_MS : SELF_POLL_MS);
219
+ }
220
+
141
221
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
142
222
 
143
223
  function stripAnsi(s) {
@@ -252,10 +332,45 @@ export async function fetchClaudeQuota({ force = false } = {}) {
252
332
  // good result with 0%).
253
333
  if (_inflight) return _inflight;
254
334
 
255
- _inflight = _doFetch(now).finally(() => { _inflight = null; });
335
+ _inflight = _doFetch(now, force).finally(() => { _inflight = null; });
256
336
  return _inflight;
257
337
  }
258
338
 
339
+ /**
340
+ * claude-swap's numbers for the active account, if it has any.
341
+ *
342
+ * Never throws and never blocks on the network: worst case the store is
343
+ * missing, unparseable, or about a different account than the one that is
344
+ * active, and the caller falls through to fetching for itself.
345
+ */
346
+ async function storeQuota() {
347
+ try {
348
+ return quotaFromStore(await activeAccountUsage());
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+
354
+ // After asking claude-swap to collect, how long to keep looking for the row it
355
+ // writes. Its fetch is a single HTTPS call; three tries covers a slow one
356
+ // without making the refresh button feel stuck.
357
+ const REREAD_TRIES = 3;
358
+ const REREAD_GAP_MS = 800;
359
+
360
+ /** Ask for a collection, then watch the store for the result. */
361
+ async function nudgeAndReread(previous) {
362
+ let asked = false;
363
+ try { asked = await requestCollection(); } catch { /* cswap missing */ }
364
+ if (!asked) return previous;
365
+
366
+ for (let i = 0; i < REREAD_TRIES; i++) {
367
+ await sleep(REREAD_GAP_MS);
368
+ const fresh = await storeQuota();
369
+ if (fresh && (!previous || fresh.fetchedAt > previous.fetchedAt)) return fresh;
370
+ }
371
+ return previous;
372
+ }
373
+
259
374
  // Run `claude --print /usage` once. Returns { cliOk, parsed }.
260
375
  // cliOk — the CLI ran and we recognized its output (preamble present)
261
376
  // parsed — quota percentages object, or null if the "Current session/week"
@@ -286,8 +401,39 @@ async function _execOnce(shellCmd) {
286
401
  }
287
402
  }
288
403
 
289
- async function _doFetch(now) {
290
- // Primary: OAuth usage API — instant, exact, no cold-start gap.
404
+ async function _doFetch(now, force = false) {
405
+ // Source 1: claude-swap's store. Free, and already paid for.
406
+ let store = await storeQuota();
407
+
408
+ // Refresh asks for newer numbers, and the honest way to get them from this
409
+ // source is to ask the collector that owns it — which applies its own
410
+ // schedule and backoff, so this cannot become a poll loop.
411
+ if (force && (!store || now - store.fetchedAt > FORCE_POLL_MS)) {
412
+ store = await nudgeAndReread(store);
413
+ }
414
+ if (store && now - store.fetchedAt <= STORE_TRUSTED_MS) {
415
+ _cache = store; _cacheAt = now;
416
+ _lastGood = store;
417
+ return store;
418
+ }
419
+
420
+ // Nothing usable in the store. Everything below spends the user's budget, so
421
+ // it happens on a floor, and not at all while a 429 cooldown is running.
422
+ if (!maySelfPoll({ now, force, lastSelfPollAt: _lastSelfPollAt, rateLimitedUntil: _rateLimitedUntil })) {
423
+ // A stale row still beats an empty panel, and says how stale it is.
424
+ const held = store ?? _lastGood;
425
+ if (held) {
426
+ const result = { ...held, stale: true };
427
+ _cache = result; _cacheAt = now;
428
+ return result;
429
+ }
430
+ const result = { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
431
+ _cache = result; _cacheAt = now - (CACHE_MS - 5_000);
432
+ return result;
433
+ }
434
+ _lastSelfPollAt = now;
435
+
436
+ // Source 2: OAuth usage API — instant, exact, no cold-start gap.
291
437
  const api = await fetchOAuthUsage();
292
438
  if (api) {
293
439
  const result = { ok: true, ...api, source: "api", fetchedAt: now };
@@ -296,7 +442,7 @@ async function _doFetch(now) {
296
442
  return result;
297
443
  }
298
444
 
299
- // Fallback: parse `claude --print /usage` CLI output.
445
+ // Source 3: parse `claude --print /usage` CLI output.
300
446
  const shellCmd = buildQuotaShellCmd();
301
447
 
302
448
  // The CLI sometimes omits the "Current session/week" quota lines on a cold
@@ -314,7 +460,7 @@ async function _doFetch(now) {
314
460
 
315
461
  // Got real quota lines — cache normally and remember as last-known-good.
316
462
  if (parsed) {
317
- const result = { ok: true, ...parsed, fetchedAt: now };
463
+ const result = { ok: true, ...parsed, source: "cli", fetchedAt: now };
318
464
  _cache = result; _cacheAt = now;
319
465
  _lastGood = result;
320
466
  return result;