ccdeck 3.29.11 → 3.30.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.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +22 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +2 -2
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +1 -1
  67. package/src/server/deck-prefs.mjs +51 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +172 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  175. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  176. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -14,8 +14,9 @@
14
14
  // and cannot lose a login. Switching shells out to `cswap` rather than
15
15
  // reimplementing the lock protocol its correctness depends on.
16
16
  import { readFile, writeFile, mkdir } from "node:fs/promises";
17
- import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
17
+ import { cswapBin, cswapInstalling, cswapVersion, installHint } from "./cswap-install.mjs";
18
18
  import { run, runDetached } from "./exec.mjs";
19
+ import { failureDetail } from "./exec-output.mjs";
19
20
  import { storedCopyAlive } from "./account-health.mjs";
20
21
  // The CLI identity oracle, already written and already trusted by the account
21
22
  // admin routes. #721 needs the same answer, so it reuses the same function
@@ -37,6 +38,15 @@ import { currentIdentity } from "./claude-identity.mjs";
37
38
  // file: the dependency has to go the other way, and a lock imported over a
38
39
  // cycle is a lock that may not be there yet when a mutation wants it.
39
40
  import { withStoreLock } from "./store-lock.mjs";
41
+ // claude-swap's own per-slot verdicts, which have to be ASKED for rather than
42
+ // read off the store. The roster puts them on each row; verdictNow and
43
+ // verdictsNow are re-exported because cswap-admin.mjs and lan-deck.mjs reach
44
+ // them through this module.
45
+ import { verdictFor } from "./claude-verdicts.mjs";
46
+ export { verdictNow, verdictsNow } from "./claude-verdicts.mjs";
47
+ // Asking claude-swap to collect, which every roster read does when something is
48
+ // due, and when each account will next be read.
49
+ import { nextReadAt, nudgeCollector } from "./claude-collector.mjs";
40
50
  import { dirname, join } from "node:path";
41
51
  import { existsSync } from "node:fs";
42
52
  import { homedir, platform } from "node:os";
@@ -105,12 +115,14 @@ const CACHE_MS = 5_000;
105
115
  // for, not a cost anyone would have noticed.
106
116
  //
107
117
  // The reason to fix it anyway is the second half. This is the only one of the
108
- // six whose cache is invalidated from elsewhere — nine call sites in four
109
- // modules: six mutations in cswap-admin.mjs, an auto-switch in cswap-auto.mjs, a
110
- // manual one in index.mjs, and the first `cswap add` below — and #582 has
111
- // already shown what a read that started before an invalidation does when it
112
- // lands after one. So the in-flight slot this route was missing arrives with the
113
- // generation guard that makes it safe, rather than after the next bug report.
118
+ // six whose cache is invalidated from elsewhere — by every mutation in
119
+ // cswap-admin.mjs, the auto-switch tick in cswap-auto-loop.mjs, the rotation
120
+ // flag in cswap-auto.mjs, the manual switch in account-routes.mjs and the first
121
+ // `cswap add` below, and by lan-deck.mjs once it has asked claude-swap for
122
+ // fresh verdicts — and #582 has already shown what a read that started before
123
+ // an invalidation does when it lands after one. So the in-flight slot this
124
+ // route was missing arrives with the generation guard that makes it safe,
125
+ // rather than after the next bug report.
114
126
  const FORCE_POLL_MS = 60_000;
115
127
 
116
128
  // A read in progress, offered to callers that arrive while it is running.
@@ -189,342 +201,61 @@ const STALE_AFTER_MS = 15 * 60_000;
189
201
  */
190
202
  const COLLECTION_STOPPED_AFTER_MS = 12 * 60 * 60_000;
191
203
 
192
- // Nudging the collector.
193
- //
194
- // Two throttles, because the cost of asking is not the cost of fetching. When
195
- // claude-swap's plan says nothing is due, `cswap list` fetches nothing — the
196
- // spawn rate is bounded by the plan (one per 180-600s), not by how often we
197
- // ask. So asking often is cheap in requests and only costs a subprocess, and
198
- // asking often is exactly how `cswap watch` stays current: it re-asks every
199
- // three seconds and therefore collects the moment a plan comes due.
200
- //
201
- // The exception is an ask that changes nothing — a claim held by another
202
- // collector, a backoff, a plan that stays overdue. Repeating that every few
203
- // seconds is pure spawn churn, so a second, slower throttle applies until the
204
- // store actually moves.
205
- const NUDGE_EVERY_MS = 15_000; // when the last ask produced new data
206
- const NUDGE_QUIET_MS = 60_000; // when it did not
207
- let _lastNudge = 0;
208
- let _lastSeenFetch = 0; // newest fetchedAt observed, in seconds
209
-
210
- // claude-swap's SERVE_TTL_S (180s) plus slack: below this age it serves from
211
- // the store and fetches nothing, so asking earlier only costs a subprocess.
212
- const FRESH_MIN_AGE_MS = 190_000;
213
-
214
- // claude-swap's RECENT_429_WINDOW_S. While a 429 is this recent, its own
215
- // congestion control is deliberately holding back, and so do we.
216
- const RECENT_429_MS = 3_600_000;
217
-
218
- /**
219
- * Whether anything is waiting to be collected.
220
- *
221
- * Judged per account that actually exists, and an account counts as waiting in
222
- * three cases:
223
- *
224
- * - it has no usage row at all;
225
- * - it has a row whose fetchedAt is not a number. claude-swap writes a row
226
- * when an account is added and fills in the numbers when it first polls,
227
- * so "row exists" is not the same as "has been fetched" — and that is the
228
- * state a freshly added account sits in. Treating the row's existence as
229
- * proof of a fetch left a new account reading "never fetched" forever;
230
- * - its own schedule says the next poll is due.
231
- *
232
- * Rows are consulted only for accounts in the store. A removed account leaves
233
- * its row behind, permanently overdue and impossible to collect because the
234
- * account is gone — counting those meant something was always due and the
235
- * collector was asked every minute for the rest of the session.
236
- */
237
- export function collectionDue(rows, slots, now) {
238
- for (const slot of slots) {
239
- const r = rows[slot];
240
- if (!r) return true; // never seen
241
- if (typeof r.fetchedAt !== "number") return true; // seen, never fetched
242
- if (typeof r.nextPollAt === "number" && r.nextPollAt * 1000 <= now) return true;
204
+ async function readJson(path) {
205
+ try {
206
+ const parsed = JSON.parse(await readFile(path, "utf8"));
207
+ return (parsed && typeof parsed === "object") ? parsed : null;
208
+ } catch {
209
+ return null;
243
210
  }
244
- return false;
245
211
  }
246
212
 
247
- /**
248
- * Whether the engine path may be used to refresh this row now.
249
- *
250
- * claude-swap gates fetches two different ways (usage_store._row_eligible).
251
- * On-demand surfaces — `cswap list`, status, switch — need the row to be BOTH
252
- * stale and past its planned poll time. The auto engine needs it to be stale
253
- * OR due, and stale means older than SERVE_TTL_S, which is 180 seconds. That
254
- * OR is the whole difference: it is why a plan stretched out to 30 minutes
255
- * still yields three-minute-old numbers to the engine, and why `cswap list`
256
- * cannot do the same however often it is called.
257
- *
258
- * The plan is stretched for a reason, though, and one of those reasons must be
259
- * respected rather than routed around: claude-swap runs AIMD congestion
260
- * control on a budget it shares with every other machine holding the same
261
- * account (POST_429_BACKOFF_MULT, RECENT_429_WINDOW_S). While an account is
262
- * recovering from a 429, backing off IS the correct behaviour, and polling
263
- * every 180 seconds through it would re-saturate exactly the window that needs
264
- * to drain. So the engine path is used only for a healthy row: no live
265
- * backoff, no failures, and no 429 seen within claude-swap's own recovery
266
- * window.
267
- *
268
- * Exported for tests.
269
- */
270
- export function freshenAllowed(row, now, recent429Ms = RECENT_429_MS) {
271
- if (!row || typeof row.fetchedAt !== "number") return false; // the list path owns this case
272
- if (process.env.AGENTS_DECK_NO_FRESHEN === "1") return false;
273
- if (typeof row.backoffUntil === "number" && row.backoffUntil * 1000 > now) return false;
274
- if ((row.consecutiveFailures ?? 0) > 0) return false;
275
- if (typeof row.last429At === "number" && now - row.last429At * 1000 < recent429Ms) return false;
276
- return true;
213
+ /** claude-swap's sequence.json — the accounts, their order and which slot is
214
+ * active — or null when it is missing or will not parse. seedFirstAccount
215
+ * tells those two apart first, so it reads the file itself. */
216
+ async function readSequence(root) {
217
+ return readJson(join(root, "sequence.json"));
277
218
  }
278
219
 
279
- /** freshenAllowed, plus old enough that asking would actually fetch. */
280
- export function freshenDue(row, now, { minAgeMs = FRESH_MIN_AGE_MS, recent429Ms = RECENT_429_MS } = {}) {
281
- if (!freshenAllowed(row, now, recent429Ms)) return false;
282
- // Younger than the serve TTL: claude-swap would answer from the store
283
- // without fetching, so asking achieves nothing but a subprocess.
284
- return now - row.fetchedAt * 1000 >= minAgeMs;
220
+ /** The slot a sequence.json names as active, as the string its `accounts` are
221
+ * keyed by, or null when it names none. */
222
+ function activeSlot(seq) {
223
+ return seq?.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
285
224
  }
286
225
 
287
- /**
288
- * When this account's numbers will next be refreshed, in epoch ms.
289
- *
290
- * claude-swap's plan, except for a healthy active account, where the deck's
291
- * own freshen tick gets there first. Exported for tests.
292
- */
293
- export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
294
- const planned = matches && typeof row?.nextPollAt === "number"
295
- ? Math.round(row.nextPollAt * 1000)
296
- : null;
297
- const freshenAt = (isActive && fetchedAtMs != null && freshenAllowed(row, now))
298
- ? fetchedAtMs + FRESH_MIN_AGE_MS
299
- : null;
300
- if (planned == null) return freshenAt;
301
- if (freshenAt == null) return planned;
302
- return Math.min(planned, freshenAt);
226
+ /** The usage rows in claude-swap's cache/usage.json, keyed by slot — see
227
+ * usageRows for which of them may be believed. */
228
+ async function readUsageRows(root) {
229
+ return usageRows(await readJson(join(root, "cache", "usage.json")));
303
230
  }
304
231
 
305
232
  /**
306
- * Keep the store moving while someone is looking at it.
233
+ * The rows of a parsed usage.json, or none at all.
307
234
  *
308
- * Without this the panel is only as live as whatever else is running: with no
309
- * `cswap watch`/`auto`/TUI open, nothing ever writes the store and the panel
310
- * shows frozen numbers while looking current.
311
- *
312
- * Two ways to ask, and the cheaper one is preferred:
313
- * - something is due by claude-swap's own plan → `cswap list`, the ordinary
314
- * on-demand pass every surface uses;
315
- * - nothing is due but the active account's numbers have aged past the serve
316
- * TTL → one `cswap auto --once --dry-run`, which is the engine path and so
317
- * is judged on staleness rather than on the plan.
318
- *
319
- * What dry run guarantees is narrower than the name suggests, and worth stating
320
- * exactly: it never switches accounts and never writes autoswitch state — the
321
- * switch call is unreachable behind its dry-run return. Its collect pass, on
322
- * the other hand, runs unconditionally, which is the point: it fetches, writes
323
- * usage rows, and can rotate and persist an OAuth token exactly as `cswap list`
324
- * does. So this is not a read-only call; it is the same collection every other
325
- * surface performs, minus the switch.
326
- *
327
- * Either way claude-swap decides whether a network call actually happens, and
328
- * this is throttled on top of that.
235
+ * A schema bump means the rows may not mean what this code thinks they do, so
236
+ * a file in any schema but 2 has no rows. Three readers go through this — the
237
+ * roster, the Usage panel's active account and the refresh button's collection
238
+ * request — and a rule spelled three times is three places for one of them to
239
+ * trust a row the other two refuse. Exported for its test.
329
240
  */
330
- /**
331
- * What claude-swap says about each slot, in its own words.
332
- *
333
- * `usage.json` records numbers and a failure COUNTER; `cswap list --json`
334
- * records a per-slot VERDICT, and the two answer different questions. Measured
335
- * on the machine this was written for, at one instant, for the same account:
336
- *
337
- * usage.json -> consecutiveFailures: 0, lastError: null
338
- * cswap list -> usageStatus: "no_credentials"
339
- *
340
- * Three verdicts, three different things for a person to do — `no_credentials`
341
- * is an account to receive or re-add, `relogin_required` is one to sign into,
342
- * `keychain_unavailable` is not about the account at all but about the process
343
- * asking. The counter can tell none of them apart, and for two of the three it
344
- * reads zero.
345
- *
346
- * WHERE IT COMES FROM COSTS NOTHING EXTRA. nudgeCollector already spawns
347
- * `cswap list` when a collection is due, and threw the output away. It now asks
348
- * for `--json` and keeps the verdicts. Still not awaited by anyone — the nudge
349
- * stays synchronous for its callers — and still one child at a time.
350
- */
351
- let _verdicts = { at: 0, byNum: {}, identities: {} };
352
- /** Stale after this, because a verdict that outlives its cause is worse than no
353
- * verdict: "no credentials" under an account somebody has since signed into is
354
- * a sentence that sends them to fix what is already fixed. */
355
- const VERDICT_TTL_MS = 10 * 60_000;
356
- /** Run one `cswap list --json` at a time. A post-write question must start
357
- * after any older collection has finished; routine readers can share the
358
- * latest pending answer without starting another slow collection. */
359
- export function createVerdictQueue(collect) {
360
- let pending = null;
361
- return {
362
- ask({ fresh = false } = {}) {
363
- if (pending && !fresh) return pending;
364
- const previous = pending;
365
- // Start the first poll immediately: requestCollection promises the UI that
366
- // its refresh has started before it returns. Only subsequent fresh polls
367
- // wait for the earlier snapshot to finish.
368
- let next;
369
- if (previous) next = previous.then(collect, collect);
370
- else {
371
- try { next = Promise.resolve(collect()); }
372
- catch (error) { next = Promise.reject(error); }
373
- }
374
- pending = next;
375
- void next.then(
376
- () => { if (pending === next) pending = null; },
377
- () => { if (pending === next) pending = null; },
378
- );
379
- return next;
380
- },
381
- busy() { return pending !== null; },
382
- };
241
+ export function usageRows(usage) {
242
+ return usage?.schemaVersion === 2 ? (usage.accounts ?? {}) : {};
383
243
  }
384
244
 
385
245
  /**
386
- * Ask claude-swap about one account RIGHT NOW, rather than reading the cache.
387
- *
388
- * The cached verdicts are ten minutes old at worst, which is right for a label
389
- * and wrong for a decision that writes a credential: somebody who signed in two
390
- * minutes ago still reads as `no_credentials` there, and acting on that would
391
- * replace the login they just created with a peer's.
392
- *
393
- * Matched on IDENTITY rather than on the slot number, because a number is a
394
- * position in one machine's store and the caller is holding an account.
395
- *
396
- * Returns null when the question cannot be answered — no claude-swap, a refusal,
397
- * an account it does not know. Null is not `no_credentials`, and the one caller
398
- * treats it as "do not act".
246
+ * Whether a usage row was written for this account.
247
+ *
248
+ * claude-swap keys usage rows by slot but guards them on identity, because a
249
+ * removed account leaves its row behind and slots get reused. Without the same
250
+ * check the panel would show the previous occupant's numbers, and the Usage
251
+ * panel — which reads the active account's row through activeAccountUsage —
252
+ * would put them under this account's name. Both readers ask here, so they
253
+ * cannot disagree about whose row it is. Exported for its test.
399
254
  */
400
- export async function verdictNow(email, org, { runner = run, bin = cswapBin } = {}) {
401
- const want = String(email ?? "").trim().toLowerCase();
402
- if (!want) return null;
403
- const all = await verdictsNow({ runner, bin, fresh: true });
404
- return all?.find(a => a.email === want && a.org === (org ?? ""))?.status ?? null;
405
- }
406
-
407
- /**
408
- * Every account's verdict from ONE `cswap list --json`, as `{ email, org,
409
- * status, active }` rows (email lower-cased, status null when claude-swap gave none),
410
- * or null when the question could not be asked at all.
411
- *
412
- * For a caller holding several identities at once — a round that imported
413
- * three logins asks once, not three times, since each ask is a full usage
414
- * collection that can take a minute on a cold network.
415
- */
416
- async function collectVerdicts({ runner, bin }) {
417
- try {
418
- const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
419
- if (!out?.ok) return null;
420
- const d = JSON.parse(out.stdout);
421
- // The same freshly-read answer feeds the cache the panel draws from, since
422
- // it cost a subprocess either way.
423
- const byNum = readVerdicts(out.stdout);
424
- const identities = {};
425
- for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
426
- if (Number.isInteger(a?.number) && typeof a?.email === "string" && a.email.trim()) {
427
- identities[String(a.number)] = `${a.email.trim().toLowerCase()}@@${a.organizationUuid ?? ""}`;
428
- }
429
- }
430
- _verdicts = { at: Date.now(), byNum, identities };
431
- return (Array.isArray(d?.accounts) ? d.accounts : []).map(a => ({
432
- number: a?.number,
433
- email: String(a?.email ?? "").trim().toLowerCase(),
434
- org: a?.organizationUuid ?? "",
435
- status: typeof a?.usageStatus === "string" ? a.usageStatus : null,
436
- // For the account Claude Code is signed in as, claude-swap reads the
437
- // LIVE credential, not the stored copy — so its verdict there is about
438
- // the live login. See checkImports.
439
- active: a?.active === true,
440
- }));
441
- } catch { return null; }
442
- }
443
-
444
- const verdictQueue = createVerdictQueue(() => collectVerdicts({ runner: run, bin: cswapBin }));
445
-
446
- /** Routine readers share a collection; post-write callers wait for any older
447
- * collection and start a new one, so they never inspect the pre-write store. */
448
- export function verdictsNow({ runner = run, bin = cswapBin, fresh = false } = {}) {
449
- // Tests and callers supplying their own runner must receive their own answer.
450
- if (runner !== run || bin !== cswapBin) return collectVerdicts({ runner, bin });
451
- return verdictQueue.ask({ fresh });
452
- }
453
-
454
- /** claude-swap's verdict for a slot, or null when there is none fresh enough. */
455
- function verdictFor(num, now, email, org) {
456
- return cachedVerdictFor(_verdicts, num, now, email, org);
457
- }
458
-
459
- /** Only attach a cached slot verdict to the identity it was collected for. */
460
- export function cachedVerdictFor(cache, num, now, email, org) {
461
- if (now - cache.at > VERDICT_TTL_MS) return null;
462
- const identity = `${String(email ?? "").trim().toLowerCase()}@@${org ?? ""}`;
463
- if (cache.identities[String(num)] !== identity) return null;
464
- const v = cache.byNum[String(num)];
465
- return typeof v === "string" && v !== "" ? v : null;
466
- }
467
-
468
- /** Read the verdicts out of a `cswap list --json` payload. Tolerant by
469
- * construction: this is another tool's output, and a shape we do not recognise
470
- * means no verdicts rather than a thrown boot. */
471
- export function readVerdicts(stdout) {
472
- try {
473
- const d = JSON.parse(stdout);
474
- const out = {};
475
- for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
476
- if (typeof a?.number === "number" && typeof a?.usageStatus === "string") {
477
- out[String(a.number)] = a.usageStatus;
478
- }
479
- }
480
- return out;
481
- } catch { return {}; }
482
- }
483
-
484
- function nudgeCollector(rows, slots, now, activeNum) {
485
- // Did the last ask accomplish anything? Cheap proxy: the newest collection
486
- // timestamp in the store.
487
- let newest = 0;
488
- for (const slot of slots) {
489
- const f = rows[slot]?.fetchedAt;
490
- if (typeof f === "number" && f > newest) newest = f;
491
- }
492
- const moved = newest > _lastSeenFetch;
493
- _lastSeenFetch = Math.max(_lastSeenFetch, newest);
494
-
495
- if (now - _lastNudge < (moved ? NUDGE_EVERY_MS : NUDGE_QUIET_MS)) return;
496
-
497
- const due = collectionDue(rows, slots, now);
498
- const freshen = !due && freshenDue(rows[String(activeNum)], now);
499
- if (!due && !freshen) return;
500
-
501
- _lastNudge = now;
502
- // The due path asks for JSON and KEEPS it — see _verdicts. The dry-run path
503
- // stays detached: it is the engine's own collect pass, its JSON is a different
504
- // shape, and nothing here reads it.
505
- if (!due) {
506
- cswapBin().then(bin => runDetached(bin, ["auto", "--once", "--dry-run", "--json"])).catch(() => {});
507
- return;
508
- }
509
- if (verdictQueue.busy()) return;
510
- // Fire-and-forget: this function is deliberately synchronous so callers never
511
- // wait on it, and resolving the binary is the only async part. `run` rather
512
- // than `runDetached` only so the output can be read; the caller is no more
513
- // aware of it than before.
514
- void verdictsNow();
515
- }
516
-
517
- /** Long enough for a cold collection over a slow network, short enough that a
518
- * wedged claude-swap does not hold a child for the rest of the day. */
519
- const VERDICT_TIMEOUT_MS = 90_000;
520
-
521
- async function readJson(path) {
522
- try {
523
- const parsed = JSON.parse(await readFile(path, "utf8"));
524
- return (parsed && typeof parsed === "object") ? parsed : null;
525
- } catch {
526
- return null;
527
- }
255
+ export function rowIsFor(row, acct) {
256
+ return Boolean(row)
257
+ && row.email === acct.email
258
+ && (row.organizationUuid ?? "") === (acct.organizationUuid ?? "");
528
259
  }
529
260
 
530
261
  function pctOf(win) {
@@ -550,10 +281,11 @@ function lane(id, label, win) {
550
281
  /**
551
282
  * Every managed account with whatever usage claude-swap last saw for it.
552
283
  *
553
- * When there is nothing to show, says which of the two reasons it is:
554
- * "no_cswap" (the tool is not installed, and here is the command for this
284
+ * When there is nothing to show, says which of the three reasons it is:
285
+ * "cswap_installing" (the deck is installing the tool and there is nothing to
286
+ * do), "no_cswap" (the tool is not installed, and here is the command for this
555
287
  * machine) or "no_accounts" (it is installed but nothing has been added yet).
556
- * They need different things from the user, and reporting both as one empty
288
+ * They need different things from the user, and reporting them as one empty
557
289
  * panel leaves whichever one they are in with nowhere to go.
558
290
  */
559
291
  export async function fetchClaudeAccounts({ force = false } = {}) {
@@ -588,8 +320,9 @@ export async function fetchClaudeAccounts({ force = false } = {}) {
588
320
  //
589
321
  // HANDED IN, NOT IMPORTED. The repair writes a credential into claude-swap's
590
322
  // store, and this module is read by dozens of tests against fixture stores. The
591
- // server hands it in when it starts listening (index.mjs) and nothing else does,
592
- // so no test run can reach `cswap add` through a read.
323
+ // server hands it in when it starts listening (wireStaleCopyRepair, in
324
+ // account-routes.mjs) and nothing else does, so no test run can reach
325
+ // `cswap add` through a read.
593
326
  let _repairStaleCopy = null;
594
327
 
595
328
  /** Called with `{ num, email, now }` for a `stale-copy` row, and returns that
@@ -634,17 +367,20 @@ async function readRoster(now, gen) {
634
367
  };
635
368
 
636
369
  const root = backupRoot();
637
- const seq = await readJson(join(root, "sequence.json"));
370
+ const seq = await readSequence(root);
638
371
  if (!seq?.accounts) {
372
+ // Asked before the tool is probed: while the deck's own install is running
373
+ // there is nothing a `--version` spawn or installHint's interpreter checks
374
+ // could add, and the one thing the panel must not say is "install it
375
+ // yourself" — see cswapInstalling.
376
+ if (cswapInstalling()) return finish({ ok: false, reason: "cswap_installing", fetchedAt: now });
639
377
  const version = await cswapVersion();
640
378
  return finish(version
641
379
  ? { ok: false, reason: "no_accounts", version, fetchedAt: now }
642
380
  : { ok: false, reason: "no_cswap", hint: await installHint(), fetchedAt: now });
643
381
  }
644
382
 
645
- const usage = await readJson(join(root, "cache", "usage.json"));
646
- // A schema bump means the rows may not mean what this code thinks they do.
647
- const rows = usage?.schemaVersion === 2 ? (usage.accounts ?? {}) : {};
383
+ const rows = await readUsageRows(root);
648
384
 
649
385
  // Kick a collection for the NEXT poll if anything is due — either because
650
386
  // claude-swap's schedule says so, or because an account has never been
@@ -656,7 +392,7 @@ async function readRoster(now, gen) {
656
392
  // stored verdict and the live truth can disagree, and it is rare: a healthy
657
393
  // machine never spends this subprocess. Never fatal, because a CLI that
658
394
  // cannot be reached is not evidence either way.
659
- const activeNum = seq.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
395
+ const activeNum = activeSlot(seq);
660
396
  const activeRow = activeNum ? rows[activeNum] : null;
661
397
  const identity = (activeRow?.consecutiveFailures ?? 0) > 0
662
398
  ? await currentIdentity().catch(() => null)
@@ -672,91 +408,100 @@ async function readRoster(now, gen) {
672
408
  if (!acct) continue; // sequence lists a slot that no longer exists
673
409
 
674
410
  const row = rows[num];
675
- // claude-swap keys usage rows by slot but guards them on identity, because
676
- // a removed account leaves its row behind and slots get reused. Without
677
- // the same check the panel would show the previous occupant's numbers.
678
- const matches = row
679
- && row.email === acct.email
680
- && (row.organizationUuid ?? "") === (acct.organizationUuid ?? "");
681
- const good = matches ? row.lastGood : null;
411
+ accounts.push(rosterRow({ seq, num, acct, row, identity, now }));
412
+ }
682
413
 
683
- const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
684
- const isActive = String(seq.activeAccountNumber) === num;
685
- const trouble = authTrouble(row, {
686
- matches, isActive, identity, email: acct.email, fetchedAt: fetchedAtMs, now,
687
- });
414
+ return finish({ ok: true, accounts, activeNum: seq.activeAccountNumber ?? null, fetchedAt: now });
415
+ }
688
416
 
689
- const lanes = [
690
- lane("five_hour", "5h", good?.five_hour),
691
- lane("seven_day", "7d", good?.seven_day),
692
- ...(Array.isArray(good?.scoped) ? good.scoped : [])
693
- .map((s, i) => lane(`scoped-${i}`, s?.name ?? "model", s))
694
- .filter(Boolean),
695
- ].filter(Boolean);
417
+ /**
418
+ * One account's row on the roster: who it is, what claude-swap last saw for it,
419
+ * and what is wrong with it, if anything.
420
+ *
421
+ * `row` is the usage row stored under this account's slot, which may belong to
422
+ * whoever held the slot before (see rowIsFor); `identity` is who the CLI says
423
+ * is signed in, or null when readRoster did not need to ask. It reads the
424
+ * verdict cache, and asks the registered repair about a `stale-copy` row —
425
+ * which is what starts that repair (see repairStaleCopyWith).
426
+ */
427
+ function rosterRow({ seq, num, acct, row, identity, now }) {
428
+ const matches = rowIsFor(row, acct);
429
+ const good = matches ? row.lastGood : null;
430
+
431
+ const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
432
+ const isActive = String(seq.activeAccountNumber) === num;
433
+ const trouble = authTrouble(row, {
434
+ matches, isActive, identity, email: acct.email, fetchedAt: fetchedAtMs, now,
435
+ });
696
436
 
697
- const collector = verdictFor(num, now, acct.email, acct.organizationUuid);
698
- accounts.push({
699
- num: Number(num),
700
- email: acct.email ?? null,
701
- alias: acct.alias ?? null,
702
- org: acct.organizationName ?? null,
703
- // The other half of an account's identity, and the reason a slot number
704
- // is not one: claude-swap keys on `(email, organizationUuid)` — the same
705
- // email under two orgs is two accounts on purpose — and assigns slots
706
- // max+1 per store, so the account that is 4 here is 2 on another machine.
707
- // Surfaced for LAN sync, which has to match accounts across two stores
708
- // that grew in a different order.
709
- orgUuid: acct.organizationUuid ?? null,
710
- // Whether CLAUDE-SWAP'S STORED COPY works — not whether the user is
711
- // signed in. The two differ, and #721 is the whole argument: a
712
- // `stale-copy` row means the live session is fine while the copy in the
713
- // store is dead, and the copy is what a share would carry and what a
714
- // peer's copy would heal. So both kinds of trouble read as not alive.
715
- alive: storedCopyAlive(trouble == null, collector),
716
- active: String(seq.activeAccountNumber) === num,
717
- disabled: acct.disabled === true,
718
- lanes,
719
- // Headroom against the tightest lane — the number that decides whether
720
- // this account is worth switching to.
721
- headroom: lanes.length ? Math.max(0, 100 - Math.max(...lanes.map(l => l.pct))) : null,
722
- fetchedAt: fetchedAtMs,
723
- // When this account will next be read — the earlier of claude-swap's own
724
- // plan and, for a healthy active account, the deck's freshen tick. The
725
- // plan alone would promise "next in 15m" while the panel actually
726
- // updates in three.
727
- nextAt: nextReadAt(row, matches, fetchedAtMs, isActive, now),
728
- stale: fetchedAtMs == null || now - fetchedAtMs > STALE_AFTER_MS,
729
- // Surfaced rather than hidden: a rate-limited or re-login-needed account
730
- // is exactly the one the user is about to try switching to.
731
- //
732
- // Through authTrouble rather than read straight off the row: see #721.
733
- // consecutiveFailures says the COLLECTOR is failing, which for the active
734
- // account is not the same claim as the user being signed out — and the
735
- // CLI can settle that.
736
- error: trouble?.error ?? null,
737
- // True when the collector cannot read this account but the user is signed
738
- // in as it anyway. The panel says so quietly instead of offering to log
739
- // them in again.
740
- staleCopy: trouble?.kind === "stale-copy",
741
- // How the deck's own repair of that is going — `{ state: "running" }` or
742
- // `{ state: "failed", reason, retryAt }` — and null on every other row.
743
- // Asking is what starts it; see repairStaleCopyWith.
744
- repair: trouble?.kind === "stale-copy" ? repairFor(Number(num), acct.email ?? null, now) : null,
745
- // Nothing has been collected for this account in half a day, and nothing
746
- // says why. Its own word because the two existing ones would both be
747
- // wrong: `error` claims a rejection that was never reported, and
748
- // `staleCopy` promises the user is signed in as it, which is only
749
- // knowable for the active account.
750
- stopped: trouble?.kind === "stopped",
751
- // claude-swap's own verdict for this slot, when there is a fresh one:
752
- // "no_credentials", "relogin_required", "keychain_unavailable", … It is
753
- // what turns "not collecting" into a sentence with a next step in it, and
754
- // it is null on every machine where the collector has not been asked yet.
755
- collector,
756
- });
757
- }
437
+ const lanes = [
438
+ lane("five_hour", "5h", good?.five_hour),
439
+ lane("seven_day", "7d", good?.seven_day),
440
+ ...(Array.isArray(good?.scoped) ? good.scoped : [])
441
+ .map((s, i) => lane(`scoped-${i}`, s?.name ?? "model", s))
442
+ .filter(Boolean),
443
+ ].filter(Boolean);
758
444
 
759
- return finish({ ok: true, accounts, activeNum: seq.activeAccountNumber ?? null, fetchedAt: now });
445
+ const collector = verdictFor(num, now, acct.email, acct.organizationUuid);
446
+ return {
447
+ num: Number(num),
448
+ email: acct.email ?? null,
449
+ alias: acct.alias ?? null,
450
+ org: acct.organizationName ?? null,
451
+ // The other half of an account's identity, and the reason a slot number
452
+ // is not one: claude-swap keys on `(email, organizationUuid)` — the same
453
+ // email under two orgs is two accounts on purpose — and assigns slots
454
+ // max+1 per store, so the account that is 4 here is 2 on another machine.
455
+ // Surfaced for LAN sync, which has to match accounts across two stores
456
+ // that grew in a different order.
457
+ orgUuid: acct.organizationUuid ?? null,
458
+ // Whether CLAUDE-SWAP'S STORED COPY works — not whether the user is
459
+ // signed in. The two differ, and #721 is the whole argument: a
460
+ // `stale-copy` row means the live session is fine while the copy in the
461
+ // store is dead, and the copy is what a share would carry and what a
462
+ // peer's copy would heal. So both kinds of trouble read as not alive.
463
+ alive: storedCopyAlive(trouble == null, collector),
464
+ active: isActive,
465
+ disabled: acct.disabled === true,
466
+ lanes,
467
+ // Headroom against the tightest lane — the number that decides whether
468
+ // this account is worth switching to.
469
+ headroom: lanes.length ? Math.max(0, 100 - Math.max(...lanes.map(l => l.pct))) : null,
470
+ fetchedAt: fetchedAtMs,
471
+ // When this account will next be read — the earlier of claude-swap's own
472
+ // plan and, for a healthy active account, the deck's freshen tick. The
473
+ // plan alone would promise "next in 15m" while the panel actually
474
+ // updates in three.
475
+ nextAt: nextReadAt(row, matches, fetchedAtMs, isActive, now),
476
+ stale: fetchedAtMs == null || now - fetchedAtMs > STALE_AFTER_MS,
477
+ // Surfaced rather than hidden: a rate-limited or re-login-needed account
478
+ // is exactly the one the user is about to try switching to.
479
+ //
480
+ // Through authTrouble rather than read straight off the row: see #721.
481
+ // consecutiveFailures says the COLLECTOR is failing, which for the active
482
+ // account is not the same claim as the user being signed out — and the
483
+ // CLI can settle that.
484
+ error: trouble?.error ?? null,
485
+ // True when the collector cannot read this account but the user is signed
486
+ // in as it anyway. The panel says so quietly instead of offering to log
487
+ // them in again.
488
+ staleCopy: trouble?.kind === "stale-copy",
489
+ // How the deck's own repair of that is going — `{ state: "running" }` or
490
+ // `{ state: "failed", reason, retryAt }` — and null on every other row.
491
+ // Asking is what starts it; see repairStaleCopyWith.
492
+ repair: trouble?.kind === "stale-copy" ? repairFor(Number(num), acct.email ?? null, now) : null,
493
+ // Nothing has been collected for this account in half a day, and nothing
494
+ // says why. Its own word because the two existing ones would both be
495
+ // wrong: `error` claims a rejection that was never reported, and
496
+ // `staleCopy` promises the user is signed in as it, which is only
497
+ // knowable for the active account.
498
+ stopped: trouble?.kind === "stopped",
499
+ // claude-swap's own verdict for this slot, when there is a fresh one:
500
+ // "no_credentials", "relogin_required", "keychain_unavailable", … It is
501
+ // what turns "not collecting" into a sentence with a next step in it, and
502
+ // it is null on every machine where the collector has not been asked yet.
503
+ collector,
504
+ };
760
505
  }
761
506
 
762
507
  /**
@@ -846,25 +591,23 @@ export function authTrouble(row, {
846
591
  */
847
592
  export async function activeAccountUsage() {
848
593
  const root = backupRoot();
849
- const seq = await readJson(join(root, "sequence.json"));
850
- const num = seq?.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
594
+ const seq = await readSequence(root);
595
+ const num = activeSlot(seq);
851
596
  const acct = num ? seq?.accounts?.[num] : null;
852
597
  if (!acct) return null;
853
598
 
854
- const usage = await readJson(join(root, "cache", "usage.json"));
855
- if (usage?.schemaVersion !== 2) return null;
856
-
857
- const row = usage.accounts?.[num];
599
+ const row = (await readUsageRows(root))[num];
858
600
  // Same identity guard the panel uses: rows are keyed by slot, and slots are
859
601
  // reused, so a row can outlive the account it was written for.
860
- if (!row?.lastGood
861
- || row.email !== acct.email
862
- || (row.organizationUuid ?? "") !== (acct.organizationUuid ?? "")
863
- || typeof row.fetchedAt !== "number") return null;
602
+ if (!row?.lastGood || !rowIsFor(row, acct) || typeof row.fetchedAt !== "number") return null;
864
603
 
865
604
  return {
866
605
  num: Number(num),
867
606
  email: acct.email ?? null,
607
+ // The other half of the account's identity. quota.mjs matches the saved
608
+ // limit resets it reads with Claude Code's token against both halves
609
+ // before it puts them beside these numbers (#1308).
610
+ organizationUuid: acct.organizationUuid ?? null,
868
611
  lastGood: row.lastGood,
869
612
  fetchedAt: Math.round(row.fetchedAt * 1000),
870
613
  };
@@ -880,21 +623,20 @@ export async function activeAccountUsage() {
880
623
  */
881
624
  export async function requestCollection() {
882
625
  const root = backupRoot();
883
- const seq = await readJson(join(root, "sequence.json"));
626
+ const seq = await readSequence(root);
884
627
  if (!seq?.accounts) return false;
885
- const usage = await readJson(join(root, "cache", "usage.json"));
886
- const rows = usage?.schemaVersion === 2 ? (usage.accounts ?? {}) : {};
887
- const before = _lastNudge;
888
- nudgeCollector(rows, Object.keys(seq.accounts), Date.now(), seq.activeAccountNumber);
889
- return _lastNudge !== before;
628
+ const rows = await readUsageRows(root);
629
+ return nudgeCollector(rows, Object.keys(seq.accounts), Date.now(), seq.activeAccountNumber);
890
630
  }
891
631
 
892
632
  /**
893
633
  * Forget the roster, because something just made it wrong.
894
634
  *
895
- * Called from nine places in four modules — every `cswap` mutation the deck
896
- * performs — and every one of them is behind a POST that `isTrustedMutation`
897
- * guards, so nothing a page can send in a loop reaches this.
635
+ * Called after every `cswap` mutation the deck performs, and by lan-deck.mjs's
636
+ * verdict refresh. The mutations a page can ask for are POSTs that
637
+ * `isTrustedMutation` guards; the rest — the auto-switch tick, the first-run
638
+ * seed, the verdict refresh — run on the deck's own schedule. So nothing a page
639
+ * can send in a loop reaches this.
898
640
  *
899
641
  * Three things go besides the reading itself:
900
642
  *
@@ -904,9 +646,9 @@ export async function requestCollection() {
904
646
  * that began before it — joining a run is only free when the
905
647
  * run is still about the right thing.
906
648
  * `_lastReadAt` so the very next read is real work rather than a refusal.
907
- * Every one of these call sites is followed by the panel
908
- * reloading with ?refresh=1, and a floor that answered THAT
909
- * with the pre-switch roster would make the guard the bug.
649
+ * A mutation the panel asked for is followed by its reloading
650
+ * with ?refresh=1, and a floor that answered THAT with the
651
+ * pre-switch roster would make the guard the bug.
910
652
  */
911
653
  export function invalidateClaudeAccountsCache() {
912
654
  _cache = null;
@@ -916,6 +658,21 @@ export function invalidateClaudeAccountsCache() {
916
658
  _lastReadAt = 0;
917
659
  }
918
660
 
661
+ /**
662
+ * The slot an account argument names, as a number, or null when it names none.
663
+ *
664
+ * A slot number goes straight into an exec argument as `String(n)`, so the
665
+ * bound is what keeps a value there from being read as anything but a slot:
666
+ * `String(-1)` is "-1", which a child's parser takes for an option, and a
667
+ * fraction or NaN names no slot at all. Whole numbers from 1 to 999 only. The
668
+ * account switch here and the rotation flag in cswap-auto.mjs both ask this
669
+ * rather than restating it. Exported for that caller and for its test.
670
+ */
671
+ export function slotNumber(accountNum) {
672
+ const num = Number(accountNum);
673
+ return Number.isInteger(num) && num >= 1 && num <= 999 ? num : null;
674
+ }
675
+
919
676
  /**
920
677
  * Switch the active Claude account by delegating to `cswap`.
921
678
  *
@@ -940,8 +697,8 @@ export function invalidateClaudeAccountsCache() {
940
697
  */
941
698
  export function switchClaudeAccount(accountNum) {
942
699
  // Straight into an exec argument, so nothing but a slot number gets through.
943
- const num = Number(accountNum);
944
- if (!Number.isInteger(num) || num < 1 || num > 999) {
700
+ const num = slotNumber(accountNum);
701
+ if (num == null) {
945
702
  return Promise.resolve({ ok: false, reason: "bad_account" });
946
703
  }
947
704
 
@@ -952,10 +709,29 @@ export function switchClaudeAccount(accountNum) {
952
709
  .then(r => {
953
710
  if (r.ok) return { ok: true, output: r.stdout.trim() };
954
711
  const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
955
- return { ok: false, reason, output: (r.stderr || r.stdout).trim().slice(0, 500) };
712
+ return { ok: false, reason, output: failureDetail(r, 500) };
956
713
  }));
957
714
  }
958
715
 
716
+ /**
717
+ * How many accounts a sequence.json holds.
718
+ *
719
+ * claude-swap writes `accounts` as an object keyed by slot number — {"2": {…},
720
+ * "3": {…}} — not as a list. An Array.isArray guard here read that as "no
721
+ * accounts" and ran `cswap add` against a populated store, which is exactly
722
+ * what the guard existed to prevent. Both shapes are accepted now, and
723
+ * anything unrecognised counts as -1: unknown is not the same as empty, and
724
+ * only a confident zero may lead to a write.
725
+ */
726
+ export function accountCount(seq) {
727
+ const a = seq?.accounts;
728
+ if (Array.isArray(a)) return a.length;
729
+ if (a && typeof a === "object") return Object.keys(a).length;
730
+ if (a == null && seq && typeof seq === "object") return 0; // store exists, no accounts yet
731
+ return -1; // unreadable — do nothing
732
+ }
733
+
734
+ const SEED_MARKER = join(homedir(), ".agents-deck", ".cswap-seeded");
959
735
 
960
736
  /**
961
737
  * Register the account already signed in, the first time and only the first
@@ -980,26 +756,6 @@ export function switchClaudeAccount(accountNum) {
980
756
  * Failure is normal and quiet: on a machine where Claude Code has never signed
981
757
  * in there is nothing to record.
982
758
  */
983
- const SEED_MARKER = join(homedir(), ".agents-deck", ".cswap-seeded");
984
-
985
- /**
986
- * How many accounts a sequence.json holds.
987
- *
988
- * claude-swap writes `accounts` as an object keyed by slot number — {"2": {…},
989
- * "3": {…}} — not as a list. An Array.isArray guard here read that as "no
990
- * accounts" and ran `cswap add` against a populated store, which is exactly
991
- * what the guard existed to prevent. Both shapes are accepted now, and
992
- * anything unrecognised counts as -1: unknown is not the same as empty, and
993
- * only a confident zero may lead to a write.
994
- */
995
- export function accountCount(seq) {
996
- const a = seq?.accounts;
997
- if (Array.isArray(a)) return a.length;
998
- if (a && typeof a === "object") return Object.keys(a).length;
999
- if (a == null && seq && typeof seq === "object") return 0; // store exists, no accounts yet
1000
- return -1; // unreadable — do nothing
1001
- }
1002
-
1003
759
  export async function seedFirstAccount() {
1004
760
  if (process.env.AGENTS_DECK_NO_INSTALL === "1") return { state: "skipped" };
1005
761
  if (existsSync(SEED_MARKER)) return { state: "already-tried" };
@@ -1038,7 +794,7 @@ export async function seedFirstAccount() {
1038
794
 
1039
795
  const r = await run(await cswapBin(), ["add"], { timeout: 60_000 });
1040
796
  if (!r.ok) {
1041
- return { state: "failed", detail: (r.stderr || r.stdout).trim().slice(0, 200) };
797
+ return { state: "failed", detail: failureDetail(r, 200) };
1042
798
  }
1043
799
 
1044
800
  invalidateClaudeAccountsCache();