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.
- package/README.md +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- 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 —
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
*
|
|
249
|
-
*
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
/**
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
*
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
*
|
|
233
|
+
* The rows of a parsed usage.json, or none at all.
|
|
307
234
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
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
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
554
|
-
* "
|
|
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
|
|
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 (
|
|
592
|
-
// so no test run can reach
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
676
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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
|
|
850
|
-
const num =
|
|
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
|
|
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
|
|
626
|
+
const seq = await readSequence(root);
|
|
884
627
|
if (!seq?.accounts) return false;
|
|
885
|
-
const
|
|
886
|
-
|
|
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
|
|
896
|
-
*
|
|
897
|
-
* guards
|
|
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
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
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 =
|
|
944
|
-
if (
|
|
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
|
|
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
|
|
797
|
+
return { state: "failed", detail: failureDetail(r, 200) };
|
|
1042
798
|
}
|
|
1043
799
|
|
|
1044
800
|
invalidateClaudeAccountsCache();
|