ccdeck 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.
@@ -0,0 +1,653 @@
1
+ // Multi-account Claude usage, read out of claude-swap's store.
2
+ //
3
+ // Anthropic has no endpoint that reports usage for an account you are not
4
+ // logged into: the only way is to hold that account's OAuth token and call
5
+ // /api/oauth/usage once per account. claude-swap already does exactly that,
6
+ // and pays the whole cost of it — credential custody, one-time refresh tokens
7
+ // (double-spending one permanently kills an account), macOS Keychain access,
8
+ // and a request budget of roughly 28-30 calls per rolling hour PER ACCOUNT
9
+ // that is shared across every tool on the machine.
10
+ //
11
+ // So agents-deck does not fetch. It reads what claude-swap already fetched and
12
+ // renders it. Nothing here makes a network call or writes a credential, which
13
+ // means the deck cannot 429 the user's account, cannot burn a refresh token,
14
+ // and cannot lose a login. Switching shells out to `cswap` rather than
15
+ // reimplementing the lock protocol its correctness depends on.
16
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
17
+ import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
18
+ import { run, runDetached } from "./exec.mjs";
19
+ import { dirname, join } from "node:path";
20
+ import { existsSync } from "node:fs";
21
+ import { homedir, platform } from "node:os";
22
+
23
+ // claude-swap keeps its store under XDG on Linux and in the home directory
24
+ // everywhere else (paths.py get_backup_root).
25
+ //
26
+ // A relative XDG_DATA_HOME is ignored, per the XDG base-dir spec: those paths
27
+ // must be absolute, and the alternative is a store root resolved against
28
+ // whatever directory the deck happened to be launched from — a different one
29
+ // per terminal. `/` is the whole test because this branch only runs on Linux.
30
+ //
31
+ // Exported because cswap-admin.mjs reads sequence.json out of the same root to
32
+ // work out which slot `cswap add` just created. Two copies of this rule that
33
+ // disagree means the reader and the writer look at two different stores, and
34
+ // the panel reports a successful add as having produced nothing.
35
+ export function backupRoot() {
36
+ if (process.env.CLAUDE_SWAP_BACKUP) return process.env.CLAUDE_SWAP_BACKUP;
37
+ if (platform() === "linux") {
38
+ const xdg = process.env.XDG_DATA_HOME;
39
+ if (xdg && xdg.startsWith("/")) return join(xdg, "claude-swap");
40
+ return join(homedir(), ".local/share/claude-swap");
41
+ }
42
+ return join(homedir(), ".claude-swap-backup");
43
+ }
44
+
45
+ let _cache = null;
46
+ let _cacheAt = 0;
47
+ // Short: these are local file reads, and the point of the panel is that it
48
+ // tracks what claude-swap is doing. No network cost to amortise.
49
+ const CACHE_MS = 5_000;
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
+
130
+ // Past this, claude-swap's own numbers are old enough that showing them
131
+ // without a marker would misrepresent them (its own trust ceiling is 3600s).
132
+ const STALE_AFTER_MS = 15 * 60_000;
133
+
134
+ // Nudging the collector.
135
+ //
136
+ // Two throttles, because the cost of asking is not the cost of fetching. When
137
+ // claude-swap's plan says nothing is due, `cswap list` fetches nothing — the
138
+ // spawn rate is bounded by the plan (one per 180-600s), not by how often we
139
+ // ask. So asking often is cheap in requests and only costs a subprocess, and
140
+ // asking often is exactly how `cswap watch` stays current: it re-asks every
141
+ // three seconds and therefore collects the moment a plan comes due.
142
+ //
143
+ // The exception is an ask that changes nothing — a claim held by another
144
+ // collector, a backoff, a plan that stays overdue. Repeating that every few
145
+ // seconds is pure spawn churn, so a second, slower throttle applies until the
146
+ // store actually moves.
147
+ const NUDGE_EVERY_MS = 15_000; // when the last ask produced new data
148
+ const NUDGE_QUIET_MS = 60_000; // when it did not
149
+ let _lastNudge = 0;
150
+ let _lastSeenFetch = 0; // newest fetchedAt observed, in seconds
151
+
152
+ // claude-swap's SERVE_TTL_S (180s) plus slack: below this age it serves from
153
+ // the store and fetches nothing, so asking earlier only costs a subprocess.
154
+ const FRESH_MIN_AGE_MS = 190_000;
155
+
156
+ // claude-swap's RECENT_429_WINDOW_S. While a 429 is this recent, its own
157
+ // congestion control is deliberately holding back, and so do we.
158
+ const RECENT_429_MS = 3_600_000;
159
+
160
+ /**
161
+ * Whether anything is waiting to be collected.
162
+ *
163
+ * Judged per account that actually exists, and an account counts as waiting in
164
+ * three cases:
165
+ *
166
+ * - it has no usage row at all;
167
+ * - it has a row whose fetchedAt is not a number. claude-swap writes a row
168
+ * when an account is added and fills in the numbers when it first polls,
169
+ * so "row exists" is not the same as "has been fetched" — and that is the
170
+ * state a freshly added account sits in. Treating the row's existence as
171
+ * proof of a fetch left a new account reading "never fetched" forever;
172
+ * - its own schedule says the next poll is due.
173
+ *
174
+ * Rows are consulted only for accounts in the store. A removed account leaves
175
+ * its row behind, permanently overdue and impossible to collect because the
176
+ * account is gone — counting those meant something was always due and the
177
+ * collector was asked every minute for the rest of the session.
178
+ */
179
+ export function collectionDue(rows, slots, now) {
180
+ for (const slot of slots) {
181
+ const r = rows[slot];
182
+ if (!r) return true; // never seen
183
+ if (typeof r.fetchedAt !== "number") return true; // seen, never fetched
184
+ if (typeof r.nextPollAt === "number" && r.nextPollAt * 1000 <= now) return true;
185
+ }
186
+ return false;
187
+ }
188
+
189
+ /**
190
+ * Whether the engine path may be used to refresh this row now.
191
+ *
192
+ * claude-swap gates fetches two different ways (usage_store._row_eligible).
193
+ * On-demand surfaces — `cswap list`, status, switch — need the row to be BOTH
194
+ * stale and past its planned poll time. The auto engine needs it to be stale
195
+ * OR due, and stale means older than SERVE_TTL_S, which is 180 seconds. That
196
+ * OR is the whole difference: it is why a plan stretched out to 30 minutes
197
+ * still yields three-minute-old numbers to the engine, and why `cswap list`
198
+ * cannot do the same however often it is called.
199
+ *
200
+ * The plan is stretched for a reason, though, and one of those reasons must be
201
+ * respected rather than routed around: claude-swap runs AIMD congestion
202
+ * control on a budget it shares with every other machine holding the same
203
+ * account (POST_429_BACKOFF_MULT, RECENT_429_WINDOW_S). While an account is
204
+ * recovering from a 429, backing off IS the correct behaviour, and polling
205
+ * every 180 seconds through it would re-saturate exactly the window that needs
206
+ * to drain. So the engine path is used only for a healthy row: no live
207
+ * backoff, no failures, and no 429 seen within claude-swap's own recovery
208
+ * window.
209
+ *
210
+ * Exported for tests.
211
+ */
212
+ export function freshenAllowed(row, now, recent429Ms = RECENT_429_MS) {
213
+ if (!row || typeof row.fetchedAt !== "number") return false; // the list path owns this case
214
+ if (process.env.AGENTS_DECK_NO_FRESHEN === "1") return false;
215
+ if (typeof row.backoffUntil === "number" && row.backoffUntil * 1000 > now) return false;
216
+ if ((row.consecutiveFailures ?? 0) > 0) return false;
217
+ if (typeof row.last429At === "number" && now - row.last429At * 1000 < recent429Ms) return false;
218
+ return true;
219
+ }
220
+
221
+ /** freshenAllowed, plus old enough that asking would actually fetch. */
222
+ export function freshenDue(row, now, { minAgeMs = FRESH_MIN_AGE_MS, recent429Ms = RECENT_429_MS } = {}) {
223
+ if (!freshenAllowed(row, now, recent429Ms)) return false;
224
+ // Younger than the serve TTL: claude-swap would answer from the store
225
+ // without fetching, so asking achieves nothing but a subprocess.
226
+ return now - row.fetchedAt * 1000 >= minAgeMs;
227
+ }
228
+
229
+ /**
230
+ * When this account's numbers will next be refreshed, in epoch ms.
231
+ *
232
+ * claude-swap's plan, except for a healthy active account, where the deck's
233
+ * own freshen tick gets there first. Exported for tests.
234
+ */
235
+ export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
236
+ const planned = matches && typeof row?.nextPollAt === "number"
237
+ ? Math.round(row.nextPollAt * 1000)
238
+ : null;
239
+ const freshenAt = (isActive && fetchedAtMs != null && freshenAllowed(row, now))
240
+ ? fetchedAtMs + FRESH_MIN_AGE_MS
241
+ : null;
242
+ if (planned == null) return freshenAt;
243
+ if (freshenAt == null) return planned;
244
+ return Math.min(planned, freshenAt);
245
+ }
246
+
247
+ /**
248
+ * Keep the store moving while someone is looking at it.
249
+ *
250
+ * Without this the panel is only as live as whatever else is running: with no
251
+ * `cswap watch`/`auto`/TUI open, nothing ever writes the store and the panel
252
+ * shows frozen numbers while looking current.
253
+ *
254
+ * Two ways to ask, and the cheaper one is preferred:
255
+ * - something is due by claude-swap's own plan → `cswap list`, the ordinary
256
+ * on-demand pass every surface uses;
257
+ * - nothing is due but the active account's numbers have aged past the serve
258
+ * TTL → one `cswap auto --once --dry-run`, which is the engine path and so
259
+ * is judged on staleness rather than on the plan.
260
+ *
261
+ * What dry run guarantees is narrower than the name suggests, and worth stating
262
+ * exactly: it never switches accounts and never writes autoswitch state — the
263
+ * switch call is unreachable behind its dry-run return. Its collect pass, on
264
+ * the other hand, runs unconditionally, which is the point: it fetches, writes
265
+ * usage rows, and can rotate and persist an OAuth token exactly as `cswap list`
266
+ * does. So this is not a read-only call; it is the same collection every other
267
+ * surface performs, minus the switch.
268
+ *
269
+ * Either way claude-swap decides whether a network call actually happens, and
270
+ * this is throttled on top of that.
271
+ */
272
+ function nudgeCollector(rows, slots, now, activeNum) {
273
+ // Did the last ask accomplish anything? Cheap proxy: the newest collection
274
+ // timestamp in the store.
275
+ let newest = 0;
276
+ for (const slot of slots) {
277
+ const f = rows[slot]?.fetchedAt;
278
+ if (typeof f === "number" && f > newest) newest = f;
279
+ }
280
+ const moved = newest > _lastSeenFetch;
281
+ _lastSeenFetch = Math.max(_lastSeenFetch, newest);
282
+
283
+ if (now - _lastNudge < (moved ? NUDGE_EVERY_MS : NUDGE_QUIET_MS)) return;
284
+
285
+ const due = collectionDue(rows, slots, now);
286
+ const freshen = !due && freshenDue(rows[String(activeNum)], now);
287
+ if (!due && !freshen) return;
288
+
289
+ _lastNudge = now;
290
+ const args = due ? ["list"] : ["auto", "--once", "--dry-run", "--json"];
291
+ // Fire-and-forget: this function is deliberately synchronous so callers never
292
+ // wait on it, and resolving the binary is the only async part.
293
+ cswapBin().then(bin => runDetached(bin, args)).catch(() => {});
294
+ }
295
+
296
+ async function readJson(path) {
297
+ try {
298
+ const parsed = JSON.parse(await readFile(path, "utf8"));
299
+ return (parsed && typeof parsed === "object") ? parsed : null;
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
305
+ function pctOf(win) {
306
+ const p = win?.pct;
307
+ return typeof p === "number" && Number.isFinite(p) ? p : null;
308
+ }
309
+
310
+ /** One lane, in the shape the panel's bars already speak. */
311
+ function lane(id, label, win) {
312
+ const pct = pctOf(win);
313
+ if (pct == null) return null;
314
+ const resetAt = win?.resets_at ? Date.parse(win.resets_at) : NaN;
315
+ return {
316
+ id,
317
+ label,
318
+ pct,
319
+ // claude-swap stores a countdown string too, but it was computed when the
320
+ // row was written and drifts — the client recomputes from the timestamp.
321
+ resetAt: isNaN(resetAt) ? null : Math.floor(resetAt / 1000),
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Every managed account with whatever usage claude-swap last saw for it.
327
+ *
328
+ * When there is nothing to show, says which of the two reasons it is:
329
+ * "no_cswap" (the tool is not installed, and here is the command for this
330
+ * machine) or "no_accounts" (it is installed but nothing has been added yet).
331
+ * They need different things from the user, and reporting both as one empty
332
+ * panel leaves whichever one they are in with nowhere to go.
333
+ */
334
+ export async function fetchClaudeAccounts({ force = false } = {}) {
335
+ const now = Date.now();
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
+ }
354
+
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
+ };
382
+
383
+ const root = backupRoot();
384
+ const seq = await readJson(join(root, "sequence.json"));
385
+ if (!seq?.accounts) {
386
+ const version = await cswapVersion();
387
+ return finish(version
388
+ ? { ok: false, reason: "no_accounts", version, fetchedAt: now }
389
+ : { ok: false, reason: "no_cswap", hint: await installHint(), fetchedAt: now });
390
+ }
391
+
392
+ const usage = await readJson(join(root, "cache", "usage.json"));
393
+ // A schema bump means the rows may not mean what this code thinks they do.
394
+ const rows = usage?.schemaVersion === 2 ? (usage.accounts ?? {}) : {};
395
+
396
+ // Kick a collection for the NEXT poll if anything is due — either because
397
+ // claude-swap's schedule says so, or because an account has never been
398
+ // fetched at all.
399
+ nudgeCollector(rows, Object.keys(seq.accounts), now, seq.activeAccountNumber);
400
+
401
+ const order = Array.isArray(seq.sequence) && seq.sequence.length
402
+ ? seq.sequence.map(String)
403
+ : Object.keys(seq.accounts).sort((a, b) => Number(a) - Number(b));
404
+
405
+ const accounts = [];
406
+ for (const num of order) {
407
+ const acct = seq.accounts[num];
408
+ if (!acct) continue; // sequence lists a slot that no longer exists
409
+
410
+ const row = rows[num];
411
+ // claude-swap keys usage rows by slot but guards them on identity, because
412
+ // a removed account leaves its row behind and slots get reused. Without
413
+ // the same check the panel would show the previous occupant's numbers.
414
+ const matches = row
415
+ && row.email === acct.email
416
+ && (row.organizationUuid ?? "") === (acct.organizationUuid ?? "");
417
+ const good = matches ? row.lastGood : null;
418
+
419
+ const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
420
+
421
+ const lanes = [
422
+ lane("five_hour", "5h", good?.five_hour),
423
+ lane("seven_day", "7d", good?.seven_day),
424
+ ...(Array.isArray(good?.scoped) ? good.scoped : [])
425
+ .map((s, i) => lane(`scoped-${i}`, s?.name ?? "model", s))
426
+ .filter(Boolean),
427
+ ].filter(Boolean);
428
+
429
+ accounts.push({
430
+ num: Number(num),
431
+ email: acct.email ?? null,
432
+ alias: acct.alias ?? null,
433
+ org: acct.organizationName ?? null,
434
+ active: String(seq.activeAccountNumber) === num,
435
+ disabled: acct.disabled === true,
436
+ lanes,
437
+ // Headroom against the tightest lane — the number that decides whether
438
+ // this account is worth switching to.
439
+ headroom: lanes.length ? Math.max(0, 100 - Math.max(...lanes.map(l => l.pct))) : null,
440
+ fetchedAt: fetchedAtMs,
441
+ // When this account will next be read — the earlier of claude-swap's own
442
+ // plan and, for a healthy active account, the deck's freshen tick. The
443
+ // plan alone would promise "next in 15m" while the panel actually
444
+ // updates in three.
445
+ nextAt: nextReadAt(row, matches, fetchedAtMs, String(seq.activeAccountNumber) === num, now),
446
+ stale: fetchedAtMs == null || now - fetchedAtMs > STALE_AFTER_MS,
447
+ // Surfaced rather than hidden: a rate-limited or re-login-needed account
448
+ // is exactly the one the user is about to try switching to.
449
+ //
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,
456
+ });
457
+ }
458
+
459
+ return finish({ ok: true, accounts, activeNum: seq.activeAccountNumber ?? null, fetchedAt: now });
460
+ }
461
+
462
+ /**
463
+ * claude-swap's last good usage for whichever account is active right now.
464
+ *
465
+ * The Usage panel wants the same numbers for one account that this panel shows
466
+ * for all of them, and claude-swap has already paid for them. Reading its row
467
+ * instead of asking Anthropic again is the difference between one collector on
468
+ * this machine and two competing for the same per-token budget — the second
469
+ * one is what was 429ing the first.
470
+ *
471
+ * Returns null rather than a partial when anything about the row is unsure:
472
+ * the caller's fallback is to fetch for itself, and a wrong number is worse
473
+ * than a slow one.
474
+ */
475
+ export async function activeAccountUsage() {
476
+ const root = backupRoot();
477
+ const seq = await readJson(join(root, "sequence.json"));
478
+ const num = seq?.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
479
+ const acct = num ? seq?.accounts?.[num] : null;
480
+ if (!acct) return null;
481
+
482
+ const usage = await readJson(join(root, "cache", "usage.json"));
483
+ if (usage?.schemaVersion !== 2) return null;
484
+
485
+ const row = usage.accounts?.[num];
486
+ // Same identity guard the panel uses: rows are keyed by slot, and slots are
487
+ // reused, so a row can outlive the account it was written for.
488
+ if (!row?.lastGood
489
+ || row.email !== acct.email
490
+ || (row.organizationUuid ?? "") !== (acct.organizationUuid ?? "")
491
+ || typeof row.fetchedAt !== "number") return null;
492
+
493
+ return {
494
+ num: Number(num),
495
+ email: acct.email ?? null,
496
+ lastGood: row.lastGood,
497
+ fetchedAt: Math.round(row.fetchedAt * 1000),
498
+ };
499
+ }
500
+
501
+ /**
502
+ * Ask claude-swap to collect now, if its own schedule agrees.
503
+ *
504
+ * Exported for the Usage panel's refresh button, which has no other way to ask
505
+ * for fresher numbers once it stopped fetching them itself. Goes through the
506
+ * same throttle and the same `cswap list` as the accounts panel, so pressing
507
+ * refresh cannot outrun the request budget either.
508
+ */
509
+ export async function requestCollection() {
510
+ const root = backupRoot();
511
+ const seq = await readJson(join(root, "sequence.json"));
512
+ if (!seq?.accounts) return false;
513
+ const usage = await readJson(join(root, "cache", "usage.json"));
514
+ const rows = usage?.schemaVersion === 2 ? (usage.accounts ?? {}) : {};
515
+ const before = _lastNudge;
516
+ nudgeCollector(rows, Object.keys(seq.accounts), Date.now(), seq.activeAccountNumber);
517
+ return _lastNudge !== before;
518
+ }
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
+ */
539
+ export function invalidateClaudeAccountsCache() {
540
+ _cache = null;
541
+ _cacheAt = 0;
542
+ _generation++;
543
+ _inflight = null;
544
+ _lastReadAt = 0;
545
+ }
546
+
547
+ /**
548
+ * Switch the active Claude account by delegating to `cswap`.
549
+ *
550
+ * Not reimplemented here on purpose. A correct switch has to hold three of
551
+ * Claude Code's own lock files, in its order, with its staleness values, or it
552
+ * can interleave with Claude Code's token refresh and clobber it. cswap does
553
+ * that; a second implementation racing it would be worse than useless.
554
+ */
555
+ export function switchClaudeAccount(accountNum) {
556
+ // Straight into an exec argument, so nothing but a slot number gets through.
557
+ const num = Number(accountNum);
558
+ if (!Number.isInteger(num) || num < 1 || num > 999) {
559
+ return Promise.resolve({ ok: false, reason: "bad_account" });
560
+ }
561
+
562
+ // Argument vector, never a shell — and resolved through exec.mjs so the
563
+ // Windows `.exe`/`.cmd` shim is found too.
564
+ return cswapBin()
565
+ .then(bin => run(bin, ["switch", String(num)], { timeout: 30_000 }))
566
+ .then(r => {
567
+ if (r.ok) return { ok: true, output: r.stdout.trim() };
568
+ const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
569
+ return { ok: false, reason, output: (r.stderr || r.stdout).trim().slice(0, 500) };
570
+ });
571
+ }
572
+
573
+
574
+ /**
575
+ * Register the account already signed in, the first time and only the first
576
+ * time.
577
+ *
578
+ * A fresh install leaves claude-swap with an empty store, so the panel comes up
579
+ * saying "no accounts added yet" and telling the user to run `cswap add`
580
+ * themselves — for the account they are already using, on the machine they are
581
+ * already on. Running it for them is the difference between the panel working
582
+ * and the panel being a to-do item.
583
+ *
584
+ * `cswap add` takes no arguments, prompts for nothing, and does not sign
585
+ * anyone in: it records the Claude Code session that already exists. Even so it
586
+ * is bounded tightly, because it writes to a credential store:
587
+ *
588
+ * - only when the store holds no accounts at all, so nothing can be
589
+ * overwritten or reordered;
590
+ * - only once ever, marked on disk, so a user who deliberately removes their
591
+ * last account does not get it added back on the next launch;
592
+ * - never when AGENTS_DECK_NO_INSTALL=1.
593
+ *
594
+ * Failure is normal and quiet: on a machine where Claude Code has never signed
595
+ * in there is nothing to record.
596
+ */
597
+ const SEED_MARKER = join(homedir(), ".agents-deck", ".cswap-seeded");
598
+
599
+ /**
600
+ * How many accounts a sequence.json holds.
601
+ *
602
+ * claude-swap writes `accounts` as an object keyed by slot number — {"2": {…},
603
+ * "3": {…}} — not as a list. An Array.isArray guard here read that as "no
604
+ * accounts" and ran `cswap add` against a populated store, which is exactly
605
+ * what the guard existed to prevent. Both shapes are accepted now, and
606
+ * anything unrecognised counts as -1: unknown is not the same as empty, and
607
+ * only a confident zero may lead to a write.
608
+ */
609
+ export function accountCount(seq) {
610
+ const a = seq?.accounts;
611
+ if (Array.isArray(a)) return a.length;
612
+ if (a && typeof a === "object") return Object.keys(a).length;
613
+ if (a == null && seq && typeof seq === "object") return 0; // store exists, no accounts yet
614
+ return -1; // unreadable — do nothing
615
+ }
616
+
617
+ export async function seedFirstAccount() {
618
+ if (process.env.AGENTS_DECK_NO_INSTALL === "1") return { state: "skipped" };
619
+ if (existsSync(SEED_MARKER)) return { state: "already-tried" };
620
+
621
+ // Empty store only. A store with accounts in it is the user's, not ours, and
622
+ // a store we cannot parse is treated the same way.
623
+ // No file at all is the fresh install this exists for; a file that will not
624
+ // parse is a store in an unknown state. readJson answers null to both, so
625
+ // they are told apart before deciding, because they call for opposite
626
+ // decisions — seed, and keep well away.
627
+ const seqPath = join(backupRoot(), "sequence.json");
628
+ const before = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
629
+ if (before > 0) return { state: "has-accounts", count: before };
630
+ if (before < 0) return { state: "unreadable-store" };
631
+
632
+ // Mark before running, not after: if `cswap add` half-succeeds or the process
633
+ // dies mid-way, the retry-forever loop is the worse outcome.
634
+ try {
635
+ await mkdir(dirname(SEED_MARKER), { recursive: true });
636
+ await writeFile(SEED_MARKER, new Date().toISOString());
637
+ } catch { /* best-effort — worst case it is attempted again */ }
638
+
639
+ const r = await run(await cswapBin(), ["add"], { timeout: 60_000 });
640
+ if (!r.ok) {
641
+ return { state: "failed", detail: (r.stderr || r.stdout).trim().slice(0, 200) };
642
+ }
643
+
644
+ invalidateClaudeAccountsCache();
645
+ const count = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
646
+ if (count > 0) {
647
+ // Collect straight away. Otherwise the first thing the user sees is their
648
+ // account listed with "never fetched" beside it, waiting on a poll cycle
649
+ // for numbers that are the reason the panel exists.
650
+ runDetached(await cswapBin(), ["list"]);
651
+ }
652
+ return count > 0 ? { state: "added", count } : { state: "nothing-to-add" };
653
+ }