ccdeck 3.29.11 → 3.31.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 (178) hide show
  1. package/README.md +4 -3
  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-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -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 +40 -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 +7 -3
  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 +2 -2
  67. package/src/server/deck-prefs.mjs +58 -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 +174 -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 +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,189 @@
1
+ // Keeping claude-swap's store moving while the deck is looking at it.
2
+ //
3
+ // The accounts panel reads what claude-swap has already collected and fetches
4
+ // nothing itself (claude-accounts.mjs). Left at that, the panel is only as live
5
+ // as whatever else happens to be running, so each roster read — and the Usage
6
+ // panel's refresh button, through requestCollection — asks claude-swap to
7
+ // collect when its own plan says something is due, or when the active account's
8
+ // numbers have aged past the point where the engine path would refresh them.
9
+ // This module decides whether to ask, throttles the asking, and says when each
10
+ // account will next be read.
11
+ import { cswapBin } from "./cswap-install.mjs";
12
+ import { runDetached } from "./exec.mjs";
13
+ import { verdictQueue, verdictsNow } from "./claude-verdicts.mjs";
14
+
15
+ // Nudging the collector.
16
+ //
17
+ // Two throttles, because the cost of asking is not the cost of fetching. When
18
+ // claude-swap's plan says nothing is due, `cswap list` fetches nothing — the
19
+ // spawn rate is bounded by the plan (one per 180-600s), not by how often we
20
+ // ask. So asking often is cheap in requests and only costs a subprocess, and
21
+ // asking often is exactly how `cswap watch` stays current: it re-asks every
22
+ // three seconds and therefore collects the moment a plan comes due.
23
+ //
24
+ // The exception is an ask that changes nothing — a claim held by another
25
+ // collector, a backoff, a plan that stays overdue. Repeating that every few
26
+ // seconds is pure spawn churn, so a second, slower throttle applies until the
27
+ // store actually moves.
28
+ const NUDGE_EVERY_MS = 15_000; // when the last ask produced new data
29
+ const NUDGE_QUIET_MS = 60_000; // when it did not
30
+ let _lastNudge = 0;
31
+ let _lastSeenFetch = 0; // newest fetchedAt observed, in seconds
32
+
33
+ // claude-swap's SERVE_TTL_S (180s) plus slack: below this age it serves from
34
+ // the store and fetches nothing, so asking earlier only costs a subprocess.
35
+ const FRESH_MIN_AGE_MS = 190_000;
36
+
37
+ // claude-swap's RECENT_429_WINDOW_S. While a 429 is this recent, its own
38
+ // congestion control is deliberately holding back, and so do we.
39
+ const RECENT_429_MS = 3_600_000;
40
+
41
+ /**
42
+ * Whether anything is waiting to be collected.
43
+ *
44
+ * Judged per account that actually exists, and an account counts as waiting in
45
+ * three cases:
46
+ *
47
+ * - it has no usage row at all;
48
+ * - it has a row whose fetchedAt is not a number. claude-swap writes a row
49
+ * when an account is added and fills in the numbers when it first polls,
50
+ * so "row exists" is not the same as "has been fetched" — and that is the
51
+ * state a freshly added account sits in. Treating the row's existence as
52
+ * proof of a fetch left a new account reading "never fetched" forever;
53
+ * - its own schedule says the next poll is due.
54
+ *
55
+ * Rows are consulted only for accounts in the store. A removed account leaves
56
+ * its row behind, permanently overdue and impossible to collect because the
57
+ * account is gone — counting those meant something was always due and the
58
+ * collector was asked every minute for the rest of the session.
59
+ */
60
+ export function collectionDue(rows, slots, now) {
61
+ for (const slot of slots) {
62
+ const r = rows[slot];
63
+ if (!r) return true; // never seen
64
+ if (typeof r.fetchedAt !== "number") return true; // seen, never fetched
65
+ if (typeof r.nextPollAt === "number" && r.nextPollAt * 1000 <= now) return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Whether the engine path may be used to refresh this row now.
72
+ *
73
+ * claude-swap gates fetches two different ways (usage_store._row_eligible).
74
+ * On-demand surfaces — `cswap list`, status, switch — need the row to be BOTH
75
+ * stale and past its planned poll time. The auto engine needs it to be stale
76
+ * OR due, and stale means older than SERVE_TTL_S, which is 180 seconds. That
77
+ * OR is the whole difference: it is why a plan stretched out to 30 minutes
78
+ * still yields three-minute-old numbers to the engine, and why `cswap list`
79
+ * cannot do the same however often it is called.
80
+ *
81
+ * The plan is stretched for a reason, though, and one of those reasons must be
82
+ * respected rather than routed around: claude-swap runs AIMD congestion
83
+ * control on a budget it shares with every other machine holding the same
84
+ * account (POST_429_BACKOFF_MULT, RECENT_429_WINDOW_S). While an account is
85
+ * recovering from a 429, backing off IS the correct behaviour, and polling
86
+ * every 180 seconds through it would re-saturate exactly the window that needs
87
+ * to drain. So the engine path is used only for a healthy row: no live
88
+ * backoff, no failures, and no 429 seen within claude-swap's own recovery
89
+ * window.
90
+ *
91
+ * Exported for tests.
92
+ */
93
+ export function freshenAllowed(row, now, recent429Ms = RECENT_429_MS) {
94
+ if (!row || typeof row.fetchedAt !== "number") return false; // the list path owns this case
95
+ if (process.env.AGENTS_DECK_NO_FRESHEN === "1") return false;
96
+ if (typeof row.backoffUntil === "number" && row.backoffUntil * 1000 > now) return false;
97
+ if ((row.consecutiveFailures ?? 0) > 0) return false;
98
+ if (typeof row.last429At === "number" && now - row.last429At * 1000 < recent429Ms) return false;
99
+ return true;
100
+ }
101
+
102
+ /** freshenAllowed, plus old enough that asking would actually fetch. */
103
+ export function freshenDue(row, now, { minAgeMs = FRESH_MIN_AGE_MS, recent429Ms = RECENT_429_MS } = {}) {
104
+ if (!freshenAllowed(row, now, recent429Ms)) return false;
105
+ // Younger than the serve TTL: claude-swap would answer from the store
106
+ // without fetching, so asking achieves nothing but a subprocess.
107
+ return now - row.fetchedAt * 1000 >= minAgeMs;
108
+ }
109
+
110
+ /**
111
+ * When this account's numbers will next be refreshed, in epoch ms.
112
+ *
113
+ * claude-swap's plan, except for a healthy active account, where the deck's
114
+ * own freshen tick gets there first. Exported for tests.
115
+ */
116
+ export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
117
+ const planned = matches && typeof row?.nextPollAt === "number"
118
+ ? Math.round(row.nextPollAt * 1000)
119
+ : null;
120
+ const freshenAt = (isActive && fetchedAtMs != null && freshenAllowed(row, now))
121
+ ? fetchedAtMs + FRESH_MIN_AGE_MS
122
+ : null;
123
+ if (planned == null) return freshenAt;
124
+ if (freshenAt == null) return planned;
125
+ return Math.min(planned, freshenAt);
126
+ }
127
+
128
+ /**
129
+ * Keep the store moving while someone is looking at it.
130
+ *
131
+ * Without this the panel is only as live as whatever else is running: with no
132
+ * `cswap watch`/`auto`/TUI open, nothing ever writes the store and the panel
133
+ * shows frozen numbers while looking current.
134
+ *
135
+ * Two ways to ask, and the cheaper one is preferred:
136
+ * - something is due by claude-swap's own plan → `cswap list`, the ordinary
137
+ * on-demand pass every surface uses;
138
+ * - nothing is due but the active account's numbers have aged past the serve
139
+ * TTL → one `cswap auto --once --dry-run`, which is the engine path and so
140
+ * is judged on staleness rather than on the plan.
141
+ *
142
+ * What dry run guarantees is narrower than the name suggests, and worth stating
143
+ * exactly: it never switches accounts and never writes autoswitch state — the
144
+ * switch call is unreachable behind its dry-run return. Its collect pass, on
145
+ * the other hand, runs unconditionally, which is the point: it fetches, writes
146
+ * usage rows, and can rotate and persist an OAuth token exactly as `cswap list`
147
+ * does. So this is not a read-only call; it is the same collection every other
148
+ * surface performs, minus the switch.
149
+ *
150
+ * Either way claude-swap decides whether a network call actually happens, and
151
+ * this is throttled on top of that.
152
+ *
153
+ * Answers whether it asked — true from the moment the throttle is spent, even
154
+ * when the ask joins a collection already running — which is what
155
+ * requestCollection reports to the refresh button.
156
+ */
157
+ export function nudgeCollector(rows, slots, now, activeNum) {
158
+ // Did the last ask accomplish anything? Cheap proxy: the newest collection
159
+ // timestamp in the store.
160
+ let newest = 0;
161
+ for (const slot of slots) {
162
+ const f = rows[slot]?.fetchedAt;
163
+ if (typeof f === "number" && f > newest) newest = f;
164
+ }
165
+ const moved = newest > _lastSeenFetch;
166
+ _lastSeenFetch = Math.max(_lastSeenFetch, newest);
167
+
168
+ if (now - _lastNudge < (moved ? NUDGE_EVERY_MS : NUDGE_QUIET_MS)) return false;
169
+
170
+ const due = collectionDue(rows, slots, now);
171
+ const freshen = !due && freshenDue(rows[String(activeNum)], now);
172
+ if (!due && !freshen) return false;
173
+
174
+ _lastNudge = now;
175
+ // The due path asks for JSON and KEEPS it — see _verdicts, in
176
+ // claude-verdicts.mjs. The dry-run path stays detached: it is the engine's own
177
+ // collect pass, its JSON is a different shape, and nothing here reads it.
178
+ if (!due) {
179
+ cswapBin().then(bin => runDetached(bin, ["auto", "--once", "--dry-run", "--json"])).catch(() => {});
180
+ return true;
181
+ }
182
+ if (verdictQueue.busy()) return true;
183
+ // Fire-and-forget: this function is deliberately synchronous so callers never
184
+ // wait on it, and resolving the binary is the only async part. `run` rather
185
+ // than `runDetached` only so the output can be read; the caller is no more
186
+ // aware of it than before.
187
+ void verdictsNow();
188
+ return true;
189
+ }
@@ -29,8 +29,12 @@ import { join, resolve, posix as posixPath, win32 as winPath } from "node:path";
29
29
  * The environment and the home directory are parameters purely so callers that
30
30
  * are already working from an injected environment can stay consistent with it;
31
31
  * every caller in the deck passes nothing and gets the real machine's answer.
32
+ * `platform` is the same kind of parameter: the answer for another platform's
33
+ * paths, joined with that platform's separator, which is what legacyDeckDir
34
+ * needs to be checked for a Mac on a Windows runner (#1711).
32
35
  */
33
- export function claudeConfigDir(env = process.env, home = homedir()) {
36
+ export function claudeConfigDir(env = process.env, home = homedir(), platform = process.platform) {
37
+ const { join, resolve } = platform === "win32" ? winPath : posixPath;
34
38
  const override = env.CLAUDE_CONFIG_DIR?.trim();
35
39
  return override ? resolve(override) : join(home, ".claude");
36
40
  }
@@ -227,8 +231,8 @@ function ccPathHash(s) {
227
231
 
228
232
  /** Encode an absolute path the way CC stores it under
229
233
  * ~/.claude/projects/<slug>/, so a scan looks in the directory CC actually
230
- * wrote — index.mjs's auto-memory scan, and the Projects rollup reading a
231
- * transcript's folder back out of it (account-projects.mjs).
234
+ * wrote — memory-files.mjs's auto-memory scan, and the Projects rollup
235
+ * reading a transcript's folder back out of it (account-projects.mjs).
232
236
  *
233
237
  * CC flattens *every* non-alphanumeric character to "-", not just the path
234
238
  * separators and the Windows drive colon this used to replace. Dots are the
@@ -16,10 +16,11 @@
16
16
  // boot-module-graph.test.ts, which now asserts that src/server has no cycles at
17
17
  // all rather than trying to police the call sites of one.
18
18
  //
19
- // This file imports only leaves — exec.mjs and claude-dir.mjs have no relative
20
- // imports of their own — so it can be reached from either side without closing
21
- // anything. Both functions are re-exported from cswap-admin.mjs, which is where
22
- // their callers and their tests already look for them.
19
+ // Nothing this file imports can lead back to it — claude-dir.mjs has no
20
+ // relative imports of its own, and exec.mjs has only the leaves lifted out of
21
+ // it — so it can be reached from either side without closing anything. Both
22
+ // functions are re-exported from cswap-admin.mjs, which is where their callers
23
+ // and their tests already look for them.
23
24
  import { pathLookup, run } from "./exec.mjs";
24
25
  import { claudeCliCandidates } from "./claude-dir.mjs";
25
26
  import { existsSync } from "node:fs";
@@ -0,0 +1,130 @@
1
+ // Claude's saved limit resets — "Reset for free" in Claude's Settings → Usage —
2
+ // read out of the `cedar_ember` block of Anthropic's usage response.
3
+ //
4
+ // WHAT THEY ARE, AND WHAT THEY ARE NOT. A grant is a coupon Anthropic hands out
5
+ // (a model launch, a promotion) that clears a rate-limit window on demand. It
6
+ // has nothing to do with the `resets_at` timestamps on `five_hour` and
7
+ // `seven_day`: those say when a window rolls over by itself, and a panel that
8
+ // read one as the other would promise a reset the account does not have.
9
+ //
10
+ // THE DECK ONLY COUNTS THEM. Redeeming one is a POST to a different endpoint,
11
+ // and nothing in ccdeck makes it — spending a one-off reset is the user's
12
+ // decision, taken in Claude itself. So nothing here reads a grant's `id`, its
13
+ // `label`, `next_grant_id` or `event_props`: an id is a redemption handle, and
14
+ // a value that is never read cannot end up in a response, a log line or a test
15
+ // fixture. What survives the parse is a count and two instants per grant.
16
+ //
17
+ // FAIL CLOSED. The shape below is Claude Code's own (its zod schema for this
18
+ // block, read out of the 2.1.283 binary), and it can change without notice. A
19
+ // block that does not look like it is not evidence of zero resets, and a grant
20
+ // that does not look like one is not evidence of a reset, so either is dropped
21
+ // rather than guessed at: a missing row is a small loss, a count the account
22
+ // cannot actually spend is the confidently wrong number this panel exists not
23
+ // to show. CodexBar, which shipped this first, draws the same lines.
24
+ //
25
+ // Pure, and a leaf: quota.mjs is the only caller, and the tests reach every
26
+ // rule here without a network, a credential or a clock of their own.
27
+
28
+ /**
29
+ * More grants than this in one block is not an inventory, it is a payload
30
+ * nobody has seen. Every grant observed so far holds one reset.
31
+ */
32
+ export const MAX_GRANT_RECORDS = 200;
33
+
34
+ /**
35
+ * More resets than this available at once reads as a malformed block rather
36
+ * than a generous one, and the row would print it with a straight face.
37
+ */
38
+ export const MAX_AVAILABLE_RESETS = 50;
39
+
40
+ const isCount = (v) => Number.isInteger(v) && v >= 0;
41
+
42
+ /**
43
+ * A grant's start or end as epoch milliseconds: `null` when it has none, and
44
+ * `undefined` when it has one nobody can read. The difference matters — an
45
+ * absent bound is an open one, an unreadable bound is a malformed grant, and
46
+ * treating the second as the first would count a reset that may have lapsed.
47
+ */
48
+ function bound(v) {
49
+ if (v === undefined || v === null) return null;
50
+ if (typeof v !== "string") return undefined;
51
+ const t = Date.parse(v);
52
+ return Number.isFinite(t) ? t : undefined;
53
+ }
54
+
55
+ /** One grant, reduced to what counting needs, or null when it is malformed. */
56
+ function readGrant(g) {
57
+ if (!g || typeof g !== "object" || Array.isArray(g)) return null;
58
+ if (!isCount(g.resets_left)) return null;
59
+ // `resets_total` is optional, but when it is sent it has to be able to
60
+ // contain `resets_left`. A grant that has more left than it ever held is
61
+ // saying something false about at least one of the two.
62
+ if (g.resets_total !== undefined && g.resets_total !== null
63
+ && (!isCount(g.resets_total) || g.resets_total < g.resets_left)) return null;
64
+ // Required, although Claude Code defaults it to false: a grant whose pause
65
+ // state is unknown might be paused, and a paused reset cannot be used.
66
+ if (typeof g.paused !== "boolean") return null;
67
+ const startsAt = bound(g.starts_at);
68
+ const endsAt = bound(g.ends_at);
69
+ if (startsAt === undefined || endsAt === undefined) return null;
70
+ return { resetsLeft: g.resets_left, paused: g.paused, startsAt, endsAt };
71
+ }
72
+
73
+ /**
74
+ * The grants in a `cedar_ember` block, or null when the block says nothing
75
+ * this deck can trust.
76
+ *
77
+ * Null covers every "we do not know": no block at all (a response to a request
78
+ * that did not ask, or from a server that stopped sending it), `eligible` not
79
+ * exactly `true` — which is also how the endpoint answers a client it does not
80
+ * recognise, with `ineligible_reason: "surface"` — and a grant list that is not
81
+ * a list or is implausibly long. An eligible block with no grants is an empty
82
+ * list, which is a real answer: zero.
83
+ *
84
+ * A single malformed grant is dropped without hiding the others, the way both
85
+ * Claude Code and CodexBar treat one.
86
+ */
87
+ export function readResetGrants(block) {
88
+ if (!block || typeof block !== "object" || Array.isArray(block)) return null;
89
+ if (block.eligible !== true) return null;
90
+ const raw = block.grants ?? [];
91
+ if (!Array.isArray(raw) || raw.length > MAX_GRANT_RECORDS) return null;
92
+ return raw.map(readGrant).filter(Boolean);
93
+ }
94
+
95
+ /**
96
+ * What the Usage panel shows, from grants readResetGrants accepted: how many
97
+ * resets can be spent at `now`, and when the first of them lapses.
98
+ *
99
+ * A grant counts when it is not paused, has resets left, has started and has
100
+ * not ended. `usable_now` is deliberately not consulted: Claude sets it false
101
+ * while the account is under its limit, because a reset only redeems against a
102
+ * window that is full, and a reset saved for later is still a reset the user
103
+ * owns.
104
+ *
105
+ * `nextExpiryAt` is epoch milliseconds, like the Codex row's, and null when no
106
+ * counted grant has an end.
107
+ *
108
+ * Recomputed at every read rather than frozen at fetch time, because the
109
+ * inventory is held between fetches: a grant that ends while it is held has to
110
+ * stop being counted when it ends, not when the next fetch happens to land.
111
+ */
112
+ export function availableResetCredits(grants, now) {
113
+ if (!Array.isArray(grants)) return null;
114
+ let availableCount = 0;
115
+ let nextExpiryAt = null;
116
+ for (const g of grants) {
117
+ if (g.paused || g.resetsLeft <= 0) continue;
118
+ if (g.startsAt !== null && g.startsAt > now) continue;
119
+ if (g.endsAt !== null && g.endsAt <= now) continue;
120
+ availableCount += g.resetsLeft;
121
+ if (g.endsAt !== null && (nextExpiryAt === null || g.endsAt < nextExpiryAt)) nextExpiryAt = g.endsAt;
122
+ }
123
+ if (availableCount > MAX_AVAILABLE_RESETS) return null;
124
+ return { availableCount, nextExpiryAt };
125
+ }
126
+
127
+ /** Both of the above, for a response read once and shown straight away. */
128
+ export function resetCreditsFrom(block, now) {
129
+ return availableResetCredits(readResetGrants(block), now);
130
+ }
@@ -0,0 +1,174 @@
1
+ // claude-swap's own verdict on each account, asked for rather than read.
2
+ //
3
+ // Everything else the accounts panel shows is read straight out of the store
4
+ // claude-swap writes (claude-accounts.mjs). The verdict is the one thing that
5
+ // is not in a file: it is what `cswap list --json` answers when asked, so it
6
+ // comes with a subprocess, a queue that keeps that to one child at a time, and a
7
+ // cache with its own clock. claude-accounts.mjs puts the cached verdict on each
8
+ // roster row and asks for a new one when its collector nudge finds a
9
+ // collection due.
10
+ import { cswapBin } from "./cswap-install.mjs";
11
+ import { run } from "./exec.mjs";
12
+ // The identity of one account, spelled once. claude-swap keys on `(email,
13
+ // organizationUuid)`; the verdict cache files each verdict under the pair it was
14
+ // collected for, and lan-copies.mjs (which lan-sync.mjs re-exports) matches
15
+ // accounts across two stores on the same pair. Nothing either of them imports
16
+ // reaches this file, so the edge closes no cycle.
17
+ import { accountKey } from "./lan-sync.mjs";
18
+
19
+ /**
20
+ * What claude-swap says about each slot, in its own words.
21
+ *
22
+ * `usage.json` records numbers and a failure COUNTER; `cswap list --json`
23
+ * records a per-slot VERDICT, and the two answer different questions. Measured
24
+ * on the machine this was written for, at one instant, for the same account:
25
+ *
26
+ * usage.json -> consecutiveFailures: 0, lastError: null
27
+ * cswap list -> usageStatus: "no_credentials"
28
+ *
29
+ * Three verdicts, three different things for a person to do — `no_credentials`
30
+ * is an account to receive or re-add, `relogin_required` is one to sign into,
31
+ * `keychain_unavailable` is not about the account at all but about the process
32
+ * asking. The counter can tell none of them apart, and for two of the three it
33
+ * reads zero.
34
+ *
35
+ * WHERE IT COMES FROM COSTS NOTHING EXTRA. nudgeCollector already spawns
36
+ * `cswap list` when a collection is due, and threw the output away. It now asks
37
+ * for `--json` and keeps the verdicts. Still not awaited by anyone — the nudge
38
+ * stays synchronous for its callers — and still one child at a time.
39
+ */
40
+ let _verdicts = { at: 0, byNum: {}, identities: {} };
41
+ /** Stale after this, because a verdict that outlives its cause is worse than no
42
+ * verdict: "no credentials" under an account somebody has since signed into is
43
+ * a sentence that sends them to fix what is already fixed. */
44
+ const VERDICT_TTL_MS = 10 * 60_000;
45
+ /** Long enough for a cold collection over a slow network, short enough that a
46
+ * wedged claude-swap does not hold a child for the rest of the day. */
47
+ const VERDICT_TIMEOUT_MS = 90_000;
48
+ /** Run one `cswap list --json` at a time. A post-write question must start
49
+ * after any older collection has finished; routine readers can share the
50
+ * latest pending answer without starting another slow collection. */
51
+ export function createVerdictQueue(collect) {
52
+ let pending = null;
53
+ return {
54
+ ask({ fresh = false } = {}) {
55
+ if (pending && !fresh) return pending;
56
+ const previous = pending;
57
+ // Start the first poll immediately: requestCollection promises the UI that
58
+ // its refresh has started before it returns. Only subsequent fresh polls
59
+ // wait for the earlier snapshot to finish.
60
+ let next;
61
+ if (previous) next = previous.then(collect, collect);
62
+ else {
63
+ try { next = Promise.resolve(collect()); }
64
+ catch (error) { next = Promise.reject(error); }
65
+ }
66
+ pending = next;
67
+ void next.then(
68
+ () => { if (pending === next) pending = null; },
69
+ () => { if (pending === next) pending = null; },
70
+ );
71
+ return next;
72
+ },
73
+ busy() { return pending !== null; },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Ask claude-swap about one account RIGHT NOW, rather than reading the cache.
79
+ *
80
+ * The cached verdicts are ten minutes old at worst, which is right for a label
81
+ * and wrong for a decision that writes a credential: somebody who signed in two
82
+ * minutes ago still reads as `no_credentials` there, and acting on that would
83
+ * replace the login they just created with a peer's.
84
+ *
85
+ * Matched on IDENTITY rather than on the slot number, because a number is a
86
+ * position in one machine's store and the caller is holding an account.
87
+ *
88
+ * Returns null when the question cannot be answered — no claude-swap, a refusal,
89
+ * an account it does not know. Null is not `no_credentials`, and the one caller
90
+ * treats it as "do not act".
91
+ */
92
+ export async function verdictNow(email, org, { runner = run, bin = cswapBin } = {}) {
93
+ const want = String(email ?? "").trim().toLowerCase();
94
+ if (!want) return null;
95
+ const all = await verdictsNow({ runner, bin, fresh: true });
96
+ return all?.find(a => a.email === want && a.org === (org ?? ""))?.status ?? null;
97
+ }
98
+
99
+ /**
100
+ * Every account's verdict from ONE `cswap list --json`, as `{ email, org,
101
+ * status, active }` rows (email lower-cased, status null when claude-swap gave none),
102
+ * or null when the question could not be asked at all.
103
+ *
104
+ * For a caller holding several identities at once — a round that imported
105
+ * three logins asks once, not three times, since each ask is a full usage
106
+ * collection that can take a minute on a cold network.
107
+ */
108
+ async function collectVerdicts({ runner, bin }) {
109
+ try {
110
+ const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
111
+ if (!out?.ok) return null;
112
+ const d = JSON.parse(out.stdout);
113
+ // The same freshly-read answer feeds the cache the panel draws from, since
114
+ // it cost a subprocess either way.
115
+ const byNum = readVerdicts(out.stdout);
116
+ const identities = {};
117
+ for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
118
+ if (Number.isInteger(a?.number) && typeof a?.email === "string" && a.email.trim()) {
119
+ identities[String(a.number)] = accountKey(a.email, a.organizationUuid);
120
+ }
121
+ }
122
+ _verdicts = { at: Date.now(), byNum, identities };
123
+ return (Array.isArray(d?.accounts) ? d.accounts : []).map(a => ({
124
+ number: a?.number,
125
+ email: String(a?.email ?? "").trim().toLowerCase(),
126
+ org: a?.organizationUuid ?? "",
127
+ status: typeof a?.usageStatus === "string" ? a.usageStatus : null,
128
+ // For the account Claude Code is signed in as, claude-swap reads the
129
+ // LIVE credential, not the stored copy — so its verdict there is about
130
+ // the live login. See checkImports.
131
+ active: a?.active === true,
132
+ }));
133
+ } catch { return null; }
134
+ }
135
+
136
+ export const verdictQueue = createVerdictQueue(() => collectVerdicts({ runner: run, bin: cswapBin }));
137
+
138
+ /** Routine readers share a collection; post-write callers wait for any older
139
+ * collection and start a new one, so they never inspect the pre-write store. */
140
+ export function verdictsNow({ runner = run, bin = cswapBin, fresh = false } = {}) {
141
+ // Tests and callers supplying their own runner must receive their own answer.
142
+ if (runner !== run || bin !== cswapBin) return collectVerdicts({ runner, bin });
143
+ return verdictQueue.ask({ fresh });
144
+ }
145
+
146
+ /** claude-swap's verdict for a slot, or null when there is none fresh enough. */
147
+ export function verdictFor(num, now, email, org) {
148
+ return cachedVerdictFor(_verdicts, num, now, email, org);
149
+ }
150
+
151
+ /** Only attach a cached slot verdict to the identity it was collected for. */
152
+ export function cachedVerdictFor(cache, num, now, email, org) {
153
+ if (now - cache.at > VERDICT_TTL_MS) return null;
154
+ const identity = accountKey(email, org);
155
+ if (cache.identities[String(num)] !== identity) return null;
156
+ const v = cache.byNum[String(num)];
157
+ return typeof v === "string" && v !== "" ? v : null;
158
+ }
159
+
160
+ /** Read the verdicts out of a `cswap list --json` payload. Tolerant by
161
+ * construction: this is another tool's output, and a shape we do not recognise
162
+ * means no verdicts rather than a thrown boot. */
163
+ export function readVerdicts(stdout) {
164
+ try {
165
+ const d = JSON.parse(stdout);
166
+ const out = {};
167
+ for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
168
+ if (typeof a?.number === "number" && typeof a?.usageStatus === "string") {
169
+ out[String(a.number)] = a.usageStatus;
170
+ }
171
+ }
172
+ return out;
173
+ } catch { return {}; }
174
+ }