agent-dag 3.22.1 → 3.22.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
|
@@ -1,951 +0,0 @@
|
|
|
1
|
-
// Multi-account Claude usage, read out of claude-swap's store.
|
|
2
|
-
//
|
|
3
|
-
// Anthropic has no endpoint that reports usage for an account you are not
|
|
4
|
-
// logged into: the only way is to hold that account's OAuth token and call
|
|
5
|
-
// /api/oauth/usage once per account. claude-swap already does exactly that,
|
|
6
|
-
// and pays the whole cost of it — credential custody, one-time refresh tokens
|
|
7
|
-
// (double-spending one permanently kills an account), macOS Keychain access,
|
|
8
|
-
// and a request budget of roughly 28-30 calls per rolling hour PER ACCOUNT
|
|
9
|
-
// that is shared across every tool on the machine.
|
|
10
|
-
//
|
|
11
|
-
// So agents-deck does not fetch. It reads what claude-swap already fetched and
|
|
12
|
-
// renders it. Nothing here makes a network call or writes a credential, which
|
|
13
|
-
// means the deck cannot 429 the user's account, cannot burn a refresh token,
|
|
14
|
-
// and cannot lose a login. Switching shells out to `cswap` rather than
|
|
15
|
-
// reimplementing the lock protocol its correctness depends on.
|
|
16
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
17
|
-
import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
|
|
18
|
-
import { run, runDetached } from "./exec.mjs";
|
|
19
|
-
// The CLI identity oracle, already written and already trusted by the account
|
|
20
|
-
// admin routes. #721 needs the same answer, so it reuses the same function
|
|
21
|
-
// rather than shelling out a second way to ask one question.
|
|
22
|
-
import { currentIdentity } from "./cswap-admin.mjs";
|
|
23
|
-
import { dirname, join } from "node:path";
|
|
24
|
-
import { existsSync } from "node:fs";
|
|
25
|
-
import { homedir, platform } from "node:os";
|
|
26
|
-
|
|
27
|
-
// claude-swap keeps its store under XDG on Linux and in the home directory
|
|
28
|
-
// everywhere else (paths.py get_backup_root).
|
|
29
|
-
//
|
|
30
|
-
// A relative XDG_DATA_HOME is ignored, per the XDG base-dir spec: those paths
|
|
31
|
-
// must be absolute, and the alternative is a store root resolved against
|
|
32
|
-
// whatever directory the deck happened to be launched from — a different one
|
|
33
|
-
// per terminal. `/` is the whole test because this branch only runs on Linux.
|
|
34
|
-
//
|
|
35
|
-
// Exported because cswap-admin.mjs reads sequence.json out of the same root to
|
|
36
|
-
// work out which slot `cswap add` just created. Two copies of this rule that
|
|
37
|
-
// disagree means the reader and the writer look at two different stores, and
|
|
38
|
-
// the panel reports a successful add as having produced nothing.
|
|
39
|
-
export function backupRoot() {
|
|
40
|
-
if (process.env.CLAUDE_SWAP_BACKUP) return process.env.CLAUDE_SWAP_BACKUP;
|
|
41
|
-
if (platform() === "linux") {
|
|
42
|
-
// `~` IS EXPANDED BEFORE THE ABSOLUTENESS TEST (#796), which is what
|
|
43
|
-
// claude-swap's own paths.py does: `Path(os.path.expanduser(xdg))` and then
|
|
44
|
-
// `is_absolute()`, under a docstring saying it exists so that "values like
|
|
45
|
-
// `~/data` set via systemd unit files or Dockerfiles (which don't get shell
|
|
46
|
-
// expansion) still work". This function claims to mirror that and did not.
|
|
47
|
-
//
|
|
48
|
-
// Unexpanded, `XDG_DATA_HOME=~/data` failed `startsWith("/")` and the deck
|
|
49
|
-
// read ~/.local/share/claude-swap while cswap read and wrote
|
|
50
|
-
// ~/data/claude-swap. The Accounts panel then reported `no_accounts` while
|
|
51
|
-
// `cswap list` showed the roster — and the damaging part is
|
|
52
|
-
// `seedFirstAccount`, which reads a missing sequence file as zero accounts,
|
|
53
|
-
// passes its `before > 0` guard, and runs `cswap add` against a populated
|
|
54
|
-
// store, re-pointing activeAccountNumber with nothing here to restore it.
|
|
55
|
-
const raw = process.env.XDG_DATA_HOME;
|
|
56
|
-
const xdg = raw === "~" ? homedir()
|
|
57
|
-
: raw?.startsWith("~/") ? join(homedir(), raw.slice(2))
|
|
58
|
-
: raw;
|
|
59
|
-
if (xdg && xdg.startsWith("/")) return join(xdg, "claude-swap");
|
|
60
|
-
return join(homedir(), ".local/share/claude-swap");
|
|
61
|
-
}
|
|
62
|
-
return join(homedir(), ".claude-swap-backup");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
let _cache = null;
|
|
66
|
-
let _cacheAt = 0;
|
|
67
|
-
// Short: these are local file reads, and the point of the panel is that it
|
|
68
|
-
// tracks what claude-swap is doing. No network cost to amortise.
|
|
69
|
-
const CACHE_MS = 5_000;
|
|
70
|
-
|
|
71
|
-
// ── what a forced read may cost ──────────────────────────────────────────────
|
|
72
|
-
//
|
|
73
|
-
// Until #604 the cache above was the whole of the admission control here, and
|
|
74
|
-
// `force` walked straight past it. `handleClaudeAccounts` reads `refresh=1` off
|
|
75
|
-
// the query string and passes it through, and reads on this server are
|
|
76
|
-
// deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
|
|
77
|
-
// that `isTrustedMutation` does, because a cross-site read of
|
|
78
|
-
// `http://127.0.0.1:4317` is an ordinary top-level navigation — so any page the
|
|
79
|
-
// user had open could run a `?refresh=1` loop and get one roster read per
|
|
80
|
-
// request, concurrently.
|
|
81
|
-
//
|
|
82
|
-
// What that buys is the cheapest of the six forcible routes and it is worth
|
|
83
|
-
// saying so plainly rather than dressing it up: two small local JSON reads,
|
|
84
|
-
// sequence.json and cache/usage.json, plus a call into nudgeCollector that is
|
|
85
|
-
// throttled on its own terms and spawns nothing when nothing is due. There is no
|
|
86
|
-
// network here by design — see the note at the top of this file — and no
|
|
87
|
-
// subprocess per request. This is the SHAPE the four routes before it were fixed
|
|
88
|
-
// for, not a cost anyone would have noticed.
|
|
89
|
-
//
|
|
90
|
-
// The reason to fix it anyway is the second half. This is the only one of the
|
|
91
|
-
// six whose cache is invalidated from elsewhere — nine call sites in four
|
|
92
|
-
// modules: six mutations in cswap-admin.mjs, an auto-switch in cswap-auto.mjs, a
|
|
93
|
-
// manual one in index.mjs, and the first `cswap add` below — and #582 has
|
|
94
|
-
// already shown what a read that started before an invalidation does when it
|
|
95
|
-
// lands after one. So the in-flight slot this route was missing arrives with the
|
|
96
|
-
// generation guard that makes it safe, rather than after the next bug report.
|
|
97
|
-
const FORCE_POLL_MS = 60_000;
|
|
98
|
-
|
|
99
|
-
// A read in progress, offered to callers that arrive while it is running.
|
|
100
|
-
let _inflight = null;
|
|
101
|
-
// Stamped when a read STARTS rather than when it lands: what the floor rations
|
|
102
|
-
// is the trip to disk, and one that is still running has already been paid for.
|
|
103
|
-
let _lastReadAt = 0;
|
|
104
|
-
// Which roster the reading below is about — as a counter, because the answer is
|
|
105
|
-
// about whichever account claude-swap's store says is active, and that can move
|
|
106
|
-
// under a read that is already running. invalidateClaudeAccountsCache bumps it;
|
|
107
|
-
// every write is stamped with the value that was current when the read STARTED.
|
|
108
|
-
// quota.mjs's `_generation`, for quota.mjs's reason (#582).
|
|
109
|
-
let _generation = 0;
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Whether we may go to disk for the roster again.
|
|
113
|
-
*
|
|
114
|
-
* The same shape and the same minute as quota.mjs's `maySelfPoll`, and exported
|
|
115
|
-
* for the same reason it is: this is the rule, it is pure, and it belongs
|
|
116
|
-
* somewhere a test can point at it.
|
|
117
|
-
*
|
|
118
|
-
* Two intervals, like `maySelfPoll`'s. A forced read may beat the cache, but not
|
|
119
|
-
* turn into a poll loop when the button is held down, so it takes the minute the
|
|
120
|
-
* other four forcible routes use. An unforced read takes the cache's own
|
|
121
|
-
* interval — it is the panel's ordinary poll, the cache above has already
|
|
122
|
-
* answered it, and measuring from the START of the last read rather than from
|
|
123
|
-
* its end is the only difference between the two rules.
|
|
124
|
-
*/
|
|
125
|
-
export function mayReadAccounts({ now, force, lastReadAt }) {
|
|
126
|
-
return now - lastReadAt >= (force ? FORCE_POLL_MS : CACHE_MS);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* The answer to a read the floor refused.
|
|
131
|
-
*
|
|
132
|
-
* A reading, not an error. AccountsPanel renders `data.accounts`, and an
|
|
133
|
-
* `{ ok: false }` refusal would empty the roster for a minute — the deck
|
|
134
|
-
* teaching itself a new failure mode in order to defend against a loop nobody
|
|
135
|
-
* ran. `stale` is the flag quota.mjs, codex-quota.mjs and codex-usage.mjs all
|
|
136
|
-
* use for exactly this, and every account row already carries its own
|
|
137
|
-
* `fetchedAt` from claude-swap's store, which is the age the panel draws and
|
|
138
|
-
* which nothing here touches.
|
|
139
|
-
*/
|
|
140
|
-
function heldReading(now) {
|
|
141
|
-
if (_cache) return { ..._cache, stale: true };
|
|
142
|
-
// Unreachable in practice, and spelled the way codex-quota.mjs and
|
|
143
|
-
// codex-usage.mjs spell the same state. Every outcome below is cached, a read
|
|
144
|
-
// still running is served by `_inflight`, and the one moment `_cache` is empty
|
|
145
|
-
// with a recent stamp — just after an invalidation — is exactly the moment
|
|
146
|
-
// invalidateClaudeAccountsCache clears the stamp as well.
|
|
147
|
-
return { ok: false, reason: "waiting", fetchedAt: now };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Past this, claude-swap's own numbers are old enough that showing them
|
|
151
|
-
// without a marker would misrepresent them (its own trust ceiling is 3600s).
|
|
152
|
-
const STALE_AFTER_MS = 15 * 60_000;
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* How long a collector may produce nothing before that is a fault rather than a
|
|
156
|
-
* cadence.
|
|
157
|
-
*
|
|
158
|
-
* TWELVE HOURS, and the number is chosen from what the failure actually looks
|
|
159
|
-
* like rather than from taste. Measured on the machine that reported this: three
|
|
160
|
-
* accounts last collected 21 hours, 40 hours and 28 days ago, every one of them
|
|
161
|
-
* with `consecutiveFailures: 0` — because the thing stopping them was
|
|
162
|
-
* `keychain_unavailable`, which is claude-swap failing to OPEN the credential
|
|
163
|
-
* rather than having it rejected, and which never touches that counter.
|
|
164
|
-
*
|
|
165
|
-
* Long enough that a laptop closed overnight does not trip it. Short enough that
|
|
166
|
-
* an account nobody can read stops being advertised to the group as one they
|
|
167
|
-
* can. And the cost of being wrong is deliberately lopsided: a false "not
|
|
168
|
-
* collecting" asks a peer for a blob that `cswap import` then declines, because
|
|
169
|
-
* a plain import skips an account that is present and healthy — while the false
|
|
170
|
-
* negative this replaces is an account that is never repaired by anything, ever,
|
|
171
|
-
* and is published to every paired deck as good.
|
|
172
|
-
*/
|
|
173
|
-
const COLLECTION_STOPPED_AFTER_MS = 12 * 60 * 60_000;
|
|
174
|
-
|
|
175
|
-
// Nudging the collector.
|
|
176
|
-
//
|
|
177
|
-
// Two throttles, because the cost of asking is not the cost of fetching. When
|
|
178
|
-
// claude-swap's plan says nothing is due, `cswap list` fetches nothing — the
|
|
179
|
-
// spawn rate is bounded by the plan (one per 180-600s), not by how often we
|
|
180
|
-
// ask. So asking often is cheap in requests and only costs a subprocess, and
|
|
181
|
-
// asking often is exactly how `cswap watch` stays current: it re-asks every
|
|
182
|
-
// three seconds and therefore collects the moment a plan comes due.
|
|
183
|
-
//
|
|
184
|
-
// The exception is an ask that changes nothing — a claim held by another
|
|
185
|
-
// collector, a backoff, a plan that stays overdue. Repeating that every few
|
|
186
|
-
// seconds is pure spawn churn, so a second, slower throttle applies until the
|
|
187
|
-
// store actually moves.
|
|
188
|
-
const NUDGE_EVERY_MS = 15_000; // when the last ask produced new data
|
|
189
|
-
const NUDGE_QUIET_MS = 60_000; // when it did not
|
|
190
|
-
let _lastNudge = 0;
|
|
191
|
-
let _lastSeenFetch = 0; // newest fetchedAt observed, in seconds
|
|
192
|
-
|
|
193
|
-
// claude-swap's SERVE_TTL_S (180s) plus slack: below this age it serves from
|
|
194
|
-
// the store and fetches nothing, so asking earlier only costs a subprocess.
|
|
195
|
-
const FRESH_MIN_AGE_MS = 190_000;
|
|
196
|
-
|
|
197
|
-
// claude-swap's RECENT_429_WINDOW_S. While a 429 is this recent, its own
|
|
198
|
-
// congestion control is deliberately holding back, and so do we.
|
|
199
|
-
const RECENT_429_MS = 3_600_000;
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Whether anything is waiting to be collected.
|
|
203
|
-
*
|
|
204
|
-
* Judged per account that actually exists, and an account counts as waiting in
|
|
205
|
-
* three cases:
|
|
206
|
-
*
|
|
207
|
-
* - it has no usage row at all;
|
|
208
|
-
* - it has a row whose fetchedAt is not a number. claude-swap writes a row
|
|
209
|
-
* when an account is added and fills in the numbers when it first polls,
|
|
210
|
-
* so "row exists" is not the same as "has been fetched" — and that is the
|
|
211
|
-
* state a freshly added account sits in. Treating the row's existence as
|
|
212
|
-
* proof of a fetch left a new account reading "never fetched" forever;
|
|
213
|
-
* - its own schedule says the next poll is due.
|
|
214
|
-
*
|
|
215
|
-
* Rows are consulted only for accounts in the store. A removed account leaves
|
|
216
|
-
* its row behind, permanently overdue and impossible to collect because the
|
|
217
|
-
* account is gone — counting those meant something was always due and the
|
|
218
|
-
* collector was asked every minute for the rest of the session.
|
|
219
|
-
*/
|
|
220
|
-
export function collectionDue(rows, slots, now) {
|
|
221
|
-
for (const slot of slots) {
|
|
222
|
-
const r = rows[slot];
|
|
223
|
-
if (!r) return true; // never seen
|
|
224
|
-
if (typeof r.fetchedAt !== "number") return true; // seen, never fetched
|
|
225
|
-
if (typeof r.nextPollAt === "number" && r.nextPollAt * 1000 <= now) return true;
|
|
226
|
-
}
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Whether the engine path may be used to refresh this row now.
|
|
232
|
-
*
|
|
233
|
-
* claude-swap gates fetches two different ways (usage_store._row_eligible).
|
|
234
|
-
* On-demand surfaces — `cswap list`, status, switch — need the row to be BOTH
|
|
235
|
-
* stale and past its planned poll time. The auto engine needs it to be stale
|
|
236
|
-
* OR due, and stale means older than SERVE_TTL_S, which is 180 seconds. That
|
|
237
|
-
* OR is the whole difference: it is why a plan stretched out to 30 minutes
|
|
238
|
-
* still yields three-minute-old numbers to the engine, and why `cswap list`
|
|
239
|
-
* cannot do the same however often it is called.
|
|
240
|
-
*
|
|
241
|
-
* The plan is stretched for a reason, though, and one of those reasons must be
|
|
242
|
-
* respected rather than routed around: claude-swap runs AIMD congestion
|
|
243
|
-
* control on a budget it shares with every other machine holding the same
|
|
244
|
-
* account (POST_429_BACKOFF_MULT, RECENT_429_WINDOW_S). While an account is
|
|
245
|
-
* recovering from a 429, backing off IS the correct behaviour, and polling
|
|
246
|
-
* every 180 seconds through it would re-saturate exactly the window that needs
|
|
247
|
-
* to drain. So the engine path is used only for a healthy row: no live
|
|
248
|
-
* backoff, no failures, and no 429 seen within claude-swap's own recovery
|
|
249
|
-
* window.
|
|
250
|
-
*
|
|
251
|
-
* Exported for tests.
|
|
252
|
-
*/
|
|
253
|
-
export function freshenAllowed(row, now, recent429Ms = RECENT_429_MS) {
|
|
254
|
-
if (!row || typeof row.fetchedAt !== "number") return false; // the list path owns this case
|
|
255
|
-
if (process.env.AGENTS_DECK_NO_FRESHEN === "1") return false;
|
|
256
|
-
if (typeof row.backoffUntil === "number" && row.backoffUntil * 1000 > now) return false;
|
|
257
|
-
if ((row.consecutiveFailures ?? 0) > 0) return false;
|
|
258
|
-
if (typeof row.last429At === "number" && now - row.last429At * 1000 < recent429Ms) return false;
|
|
259
|
-
return true;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** freshenAllowed, plus old enough that asking would actually fetch. */
|
|
263
|
-
export function freshenDue(row, now, { minAgeMs = FRESH_MIN_AGE_MS, recent429Ms = RECENT_429_MS } = {}) {
|
|
264
|
-
if (!freshenAllowed(row, now, recent429Ms)) return false;
|
|
265
|
-
// Younger than the serve TTL: claude-swap would answer from the store
|
|
266
|
-
// without fetching, so asking achieves nothing but a subprocess.
|
|
267
|
-
return now - row.fetchedAt * 1000 >= minAgeMs;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* When this account's numbers will next be refreshed, in epoch ms.
|
|
272
|
-
*
|
|
273
|
-
* claude-swap's plan, except for a healthy active account, where the deck's
|
|
274
|
-
* own freshen tick gets there first. Exported for tests.
|
|
275
|
-
*/
|
|
276
|
-
export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
|
|
277
|
-
const planned = matches && typeof row?.nextPollAt === "number"
|
|
278
|
-
? Math.round(row.nextPollAt * 1000)
|
|
279
|
-
: null;
|
|
280
|
-
const freshenAt = (isActive && fetchedAtMs != null && freshenAllowed(row, now))
|
|
281
|
-
? fetchedAtMs + FRESH_MIN_AGE_MS
|
|
282
|
-
: null;
|
|
283
|
-
if (planned == null) return freshenAt;
|
|
284
|
-
if (freshenAt == null) return planned;
|
|
285
|
-
return Math.min(planned, freshenAt);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Keep the store moving while someone is looking at it.
|
|
290
|
-
*
|
|
291
|
-
* Without this the panel is only as live as whatever else is running: with no
|
|
292
|
-
* `cswap watch`/`auto`/TUI open, nothing ever writes the store and the panel
|
|
293
|
-
* shows frozen numbers while looking current.
|
|
294
|
-
*
|
|
295
|
-
* Two ways to ask, and the cheaper one is preferred:
|
|
296
|
-
* - something is due by claude-swap's own plan → `cswap list`, the ordinary
|
|
297
|
-
* on-demand pass every surface uses;
|
|
298
|
-
* - nothing is due but the active account's numbers have aged past the serve
|
|
299
|
-
* TTL → one `cswap auto --once --dry-run`, which is the engine path and so
|
|
300
|
-
* is judged on staleness rather than on the plan.
|
|
301
|
-
*
|
|
302
|
-
* What dry run guarantees is narrower than the name suggests, and worth stating
|
|
303
|
-
* exactly: it never switches accounts and never writes autoswitch state — the
|
|
304
|
-
* switch call is unreachable behind its dry-run return. Its collect pass, on
|
|
305
|
-
* the other hand, runs unconditionally, which is the point: it fetches, writes
|
|
306
|
-
* usage rows, and can rotate and persist an OAuth token exactly as `cswap list`
|
|
307
|
-
* does. So this is not a read-only call; it is the same collection every other
|
|
308
|
-
* surface performs, minus the switch.
|
|
309
|
-
*
|
|
310
|
-
* Either way claude-swap decides whether a network call actually happens, and
|
|
311
|
-
* this is throttled on top of that.
|
|
312
|
-
*/
|
|
313
|
-
/**
|
|
314
|
-
* What claude-swap says about each slot, in its own words.
|
|
315
|
-
*
|
|
316
|
-
* `usage.json` records numbers and a failure COUNTER; `cswap list --json`
|
|
317
|
-
* records a per-slot VERDICT, and the two answer different questions. Measured
|
|
318
|
-
* on the machine this was written for, at one instant, for the same account:
|
|
319
|
-
*
|
|
320
|
-
* usage.json -> consecutiveFailures: 0, lastError: null
|
|
321
|
-
* cswap list -> usageStatus: "no_credentials"
|
|
322
|
-
*
|
|
323
|
-
* Three verdicts, three different things for a person to do — `no_credentials`
|
|
324
|
-
* is an account to receive or re-add, `relogin_required` is one to sign into,
|
|
325
|
-
* `keychain_unavailable` is not about the account at all but about the process
|
|
326
|
-
* asking. The counter can tell none of them apart, and for two of the three it
|
|
327
|
-
* reads zero.
|
|
328
|
-
*
|
|
329
|
-
* WHERE IT COMES FROM COSTS NOTHING EXTRA. nudgeCollector already spawns
|
|
330
|
-
* `cswap list` when a collection is due, and threw the output away. It now asks
|
|
331
|
-
* for `--json` and keeps the verdicts. Still not awaited by anyone — the nudge
|
|
332
|
-
* stays synchronous for its callers — and still one child at a time.
|
|
333
|
-
*/
|
|
334
|
-
let _verdicts = { at: 0, byNum: {} };
|
|
335
|
-
/** Stale after this, because a verdict that outlives its cause is worse than no
|
|
336
|
-
* verdict: "no credentials" under an account somebody has since signed into is
|
|
337
|
-
* a sentence that sends them to fix what is already fixed. */
|
|
338
|
-
const VERDICT_TTL_MS = 10 * 60_000;
|
|
339
|
-
/** One `cswap list --json` at a time. A collection can take a while on a cold
|
|
340
|
-
* network, and a nudge landing inside one must not start a second. */
|
|
341
|
-
let _verdictInFlight = false;
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Ask claude-swap about one account RIGHT NOW, rather than reading the cache.
|
|
345
|
-
*
|
|
346
|
-
* The cached verdicts are ten minutes old at worst, which is right for a label
|
|
347
|
-
* and wrong for a decision that writes a credential: somebody who signed in two
|
|
348
|
-
* minutes ago still reads as `no_credentials` there, and acting on that would
|
|
349
|
-
* replace the login they just created with a peer's.
|
|
350
|
-
*
|
|
351
|
-
* Matched on IDENTITY rather than on the slot number, because a number is a
|
|
352
|
-
* position in one machine's store and the caller is holding an account.
|
|
353
|
-
*
|
|
354
|
-
* Returns null when the question cannot be answered — no claude-swap, a refusal,
|
|
355
|
-
* an account it does not know. Null is not `no_credentials`, and the one caller
|
|
356
|
-
* treats it as "do not act".
|
|
357
|
-
*/
|
|
358
|
-
export async function verdictNow(email, org, { runner = run, bin = cswapBin } = {}) {
|
|
359
|
-
const want = String(email ?? "").trim().toLowerCase();
|
|
360
|
-
if (!want) return null;
|
|
361
|
-
try {
|
|
362
|
-
const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
|
|
363
|
-
if (!out?.ok) return null;
|
|
364
|
-
const d = JSON.parse(out.stdout);
|
|
365
|
-
// The same freshly-read answer feeds the cache the panel draws from, since
|
|
366
|
-
// it cost a subprocess either way.
|
|
367
|
-
const byNum = readVerdicts(out.stdout);
|
|
368
|
-
if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
|
|
369
|
-
for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
|
|
370
|
-
if (String(a?.email ?? "").trim().toLowerCase() !== want) continue;
|
|
371
|
-
if ((a?.organizationUuid ?? "") !== (org ?? "")) continue;
|
|
372
|
-
return typeof a?.usageStatus === "string" ? a.usageStatus : null;
|
|
373
|
-
}
|
|
374
|
-
return null;
|
|
375
|
-
} catch { return null; }
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** claude-swap's verdict for a slot, or null when there is none fresh enough. */
|
|
379
|
-
function verdictFor(num, now) {
|
|
380
|
-
if (now - _verdicts.at > VERDICT_TTL_MS) return null;
|
|
381
|
-
const v = _verdicts.byNum[String(num)];
|
|
382
|
-
return typeof v === "string" && v !== "" ? v : null;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/** Read the verdicts out of a `cswap list --json` payload. Tolerant by
|
|
386
|
-
* construction: this is another tool's output, and a shape we do not recognise
|
|
387
|
-
* means no verdicts rather than a thrown boot. */
|
|
388
|
-
export function readVerdicts(stdout) {
|
|
389
|
-
try {
|
|
390
|
-
const d = JSON.parse(stdout);
|
|
391
|
-
const out = {};
|
|
392
|
-
for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
|
|
393
|
-
if (typeof a?.number === "number" && typeof a?.usageStatus === "string") {
|
|
394
|
-
out[String(a.number)] = a.usageStatus;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
return out;
|
|
398
|
-
} catch { return {}; }
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function nudgeCollector(rows, slots, now, activeNum) {
|
|
402
|
-
// Did the last ask accomplish anything? Cheap proxy: the newest collection
|
|
403
|
-
// timestamp in the store.
|
|
404
|
-
let newest = 0;
|
|
405
|
-
for (const slot of slots) {
|
|
406
|
-
const f = rows[slot]?.fetchedAt;
|
|
407
|
-
if (typeof f === "number" && f > newest) newest = f;
|
|
408
|
-
}
|
|
409
|
-
const moved = newest > _lastSeenFetch;
|
|
410
|
-
_lastSeenFetch = Math.max(_lastSeenFetch, newest);
|
|
411
|
-
|
|
412
|
-
if (now - _lastNudge < (moved ? NUDGE_EVERY_MS : NUDGE_QUIET_MS)) return;
|
|
413
|
-
|
|
414
|
-
const due = collectionDue(rows, slots, now);
|
|
415
|
-
const freshen = !due && freshenDue(rows[String(activeNum)], now);
|
|
416
|
-
if (!due && !freshen) return;
|
|
417
|
-
|
|
418
|
-
_lastNudge = now;
|
|
419
|
-
// The due path asks for JSON and KEEPS it — see _verdicts. The dry-run path
|
|
420
|
-
// stays detached: it is the engine's own collect pass, its JSON is a different
|
|
421
|
-
// shape, and nothing here reads it.
|
|
422
|
-
if (!due) {
|
|
423
|
-
cswapBin().then(bin => runDetached(bin, ["auto", "--once", "--dry-run", "--json"])).catch(() => {});
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
if (_verdictInFlight) return;
|
|
427
|
-
_verdictInFlight = true;
|
|
428
|
-
// Fire-and-forget: this function is deliberately synchronous so callers never
|
|
429
|
-
// wait on it, and resolving the binary is the only async part. `run` rather
|
|
430
|
-
// than `runDetached` only so the output can be read; the caller is no more
|
|
431
|
-
// aware of it than before.
|
|
432
|
-
cswapBin()
|
|
433
|
-
.then(bin => run(bin, ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS }))
|
|
434
|
-
.then(out => {
|
|
435
|
-
const byNum = out?.ok ? readVerdicts(out.stdout) : {};
|
|
436
|
-
// Replaced whole rather than merged. A slot that has gone away must not
|
|
437
|
-
// keep the verdict it had when it was last seen.
|
|
438
|
-
if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
|
|
439
|
-
})
|
|
440
|
-
.catch(() => {})
|
|
441
|
-
.finally(() => { _verdictInFlight = false; });
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/** Long enough for a cold collection over a slow network, short enough that a
|
|
445
|
-
* wedged claude-swap does not hold a child for the rest of the day. */
|
|
446
|
-
const VERDICT_TIMEOUT_MS = 90_000;
|
|
447
|
-
|
|
448
|
-
async function readJson(path) {
|
|
449
|
-
try {
|
|
450
|
-
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
451
|
-
return (parsed && typeof parsed === "object") ? parsed : null;
|
|
452
|
-
} catch {
|
|
453
|
-
return null;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function pctOf(win) {
|
|
458
|
-
const p = win?.pct;
|
|
459
|
-
return typeof p === "number" && Number.isFinite(p) ? p : null;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/** One lane, in the shape the panel's bars already speak. */
|
|
463
|
-
function lane(id, label, win) {
|
|
464
|
-
const pct = pctOf(win);
|
|
465
|
-
if (pct == null) return null;
|
|
466
|
-
const resetAt = win?.resets_at ? Date.parse(win.resets_at) : NaN;
|
|
467
|
-
return {
|
|
468
|
-
id,
|
|
469
|
-
label,
|
|
470
|
-
pct,
|
|
471
|
-
// claude-swap stores a countdown string too, but it was computed when the
|
|
472
|
-
// row was written and drifts — the client recomputes from the timestamp.
|
|
473
|
-
resetAt: isNaN(resetAt) ? null : Math.floor(resetAt / 1000),
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Every managed account with whatever usage claude-swap last saw for it.
|
|
479
|
-
*
|
|
480
|
-
* When there is nothing to show, says which of the two reasons it is:
|
|
481
|
-
* "no_cswap" (the tool is not installed, and here is the command for this
|
|
482
|
-
* machine) or "no_accounts" (it is installed but nothing has been added yet).
|
|
483
|
-
* They need different things from the user, and reporting both as one empty
|
|
484
|
-
* panel leaves whichever one they are in with nowhere to go.
|
|
485
|
-
*/
|
|
486
|
-
export async function fetchClaudeAccounts({ force = false } = {}) {
|
|
487
|
-
const now = Date.now();
|
|
488
|
-
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
489
|
-
// Offered before the floor: a read that has not finished yet is a reading
|
|
490
|
-
// newer than the cache, which is what refresh asked for, and joining it costs
|
|
491
|
-
// nothing.
|
|
492
|
-
if (_inflight) return _inflight;
|
|
493
|
-
if (!mayReadAccounts({ now, force, lastReadAt: _lastReadAt })) return heldReading(now);
|
|
494
|
-
_lastReadAt = now;
|
|
495
|
-
|
|
496
|
-
// `_inflight === mine` rather than a bare clear, which is quota.mjs's guard
|
|
497
|
-
// and is here for quota.mjs's reason: invalidateClaudeAccountsCache drops
|
|
498
|
-
// `_inflight` so the next caller starts a read that knows the roster moved,
|
|
499
|
-
// and that read installs its own promise here. A read from before the switch
|
|
500
|
-
// finishing afterwards would otherwise clear the NEW one on its way out.
|
|
501
|
-
const mine = readRoster(now, _generation)
|
|
502
|
-
.finally(() => { if (_inflight === mine) _inflight = null; });
|
|
503
|
-
_inflight = mine;
|
|
504
|
-
return mine;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// ── the repair nobody should have to press ───────────────────────────────────
|
|
508
|
-
//
|
|
509
|
-
// A `stale-copy` row — claude-swap's stored copy of the signed-in account's
|
|
510
|
-
// login was rejected, while the login itself works — has exactly one repair,
|
|
511
|
-
// and it asks the user nothing: re-capture the copy from the login they already
|
|
512
|
-
// have (cswap-admin's recaptureActive). It was a button, `resume`, which asked a
|
|
513
|
-
// person to confirm the only answer there is. So the read that finds the state
|
|
514
|
-
// starts the repair, and the row carries how it is going instead of a button.
|
|
515
|
-
//
|
|
516
|
-
// HANDED IN, NOT IMPORTED. The repair writes a credential into claude-swap's
|
|
517
|
-
// store, and this module is read by dozens of tests against fixture stores. The
|
|
518
|
-
// server hands it in when it starts listening (index.mjs) and nothing else does,
|
|
519
|
-
// so no test run can reach `cswap add` through a read.
|
|
520
|
-
let _repairStaleCopy = null;
|
|
521
|
-
|
|
522
|
-
/** Called with `{ num, email, now }` for a `stale-copy` row, and returns that
|
|
523
|
-
* row's `repair`. Null unregisters. */
|
|
524
|
-
export function repairStaleCopyWith(fn) {
|
|
525
|
-
_repairStaleCopy = typeof fn === "function" ? fn : null;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/** The registered repair's state for one row. A repair that throws costs the
|
|
529
|
-
* row its state, never the read. */
|
|
530
|
-
function repairFor(num, email, now) {
|
|
531
|
-
if (!_repairStaleCopy) return null;
|
|
532
|
-
try { return _repairStaleCopy({ num, email, now }) ?? null; } catch { return null; }
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/**
|
|
536
|
-
* The read itself, split out from the admission control above it so the guard is
|
|
537
|
-
* readable as the four lines it is.
|
|
538
|
-
*
|
|
539
|
-
* `gen` is the generation that was current when this read STARTED, and finish()
|
|
540
|
-
* refuses to write anything under it once that has moved.
|
|
541
|
-
*
|
|
542
|
-
* Every write here happens after at least one await, and
|
|
543
|
-
* invalidateClaudeAccountsCache clears variables — which does nothing to a
|
|
544
|
-
* function that is already running and still holds the old roster in a local. So
|
|
545
|
-
* a switch landing mid-read was followed, milliseconds later, by the pre-switch
|
|
546
|
-
* roster being written straight back over the cleared cache: the invalidation
|
|
547
|
-
* looked like it worked and was undone before the next poll could observe it,
|
|
548
|
-
* and the panel went on showing the account the user had just switched away
|
|
549
|
-
* from. That is #582's defect, in the module #582 did not touch.
|
|
550
|
-
*
|
|
551
|
-
* The read is deliberately not cancelled. Whoever asked for it is still owed an
|
|
552
|
-
* answer, and the answer is not wrong — it is about a roster that has since
|
|
553
|
-
* moved, which makes it a fine return value and a bad cached one.
|
|
554
|
-
*/
|
|
555
|
-
async function readRoster(now, gen) {
|
|
556
|
-
const finish = (r) => {
|
|
557
|
-
if (gen !== _generation) return r;
|
|
558
|
-
_cache = r;
|
|
559
|
-
_cacheAt = Date.now();
|
|
560
|
-
return r;
|
|
561
|
-
};
|
|
562
|
-
|
|
563
|
-
const root = backupRoot();
|
|
564
|
-
const seq = await readJson(join(root, "sequence.json"));
|
|
565
|
-
if (!seq?.accounts) {
|
|
566
|
-
const version = await cswapVersion();
|
|
567
|
-
return finish(version
|
|
568
|
-
? { ok: false, reason: "no_accounts", version, fetchedAt: now }
|
|
569
|
-
: { ok: false, reason: "no_cswap", hint: await installHint(), fetchedAt: now });
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
const usage = await readJson(join(root, "cache", "usage.json"));
|
|
573
|
-
// A schema bump means the rows may not mean what this code thinks they do.
|
|
574
|
-
const rows = usage?.schemaVersion === 2 ? (usage.accounts ?? {}) : {};
|
|
575
|
-
|
|
576
|
-
// Kick a collection for the NEXT poll if anything is due — either because
|
|
577
|
-
// claude-swap's schedule says so, or because an account has never been
|
|
578
|
-
// fetched at all.
|
|
579
|
-
nudgeCollector(rows, Object.keys(seq.accounts), now, seq.activeAccountNumber);
|
|
580
|
-
|
|
581
|
-
// Who the CLI says is signed in, asked ONLY when the store claims the active
|
|
582
|
-
// account is in trouble — see authTrouble. That is the one case where the
|
|
583
|
-
// stored verdict and the live truth can disagree, and it is rare: a healthy
|
|
584
|
-
// machine never spends this subprocess. Never fatal, because a CLI that
|
|
585
|
-
// cannot be reached is not evidence either way.
|
|
586
|
-
const activeNum = seq.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
|
|
587
|
-
const activeRow = activeNum ? rows[activeNum] : null;
|
|
588
|
-
const identity = (activeRow?.consecutiveFailures ?? 0) > 0
|
|
589
|
-
? await currentIdentity().catch(() => null)
|
|
590
|
-
: null;
|
|
591
|
-
|
|
592
|
-
const order = Array.isArray(seq.sequence) && seq.sequence.length
|
|
593
|
-
? seq.sequence.map(String)
|
|
594
|
-
: Object.keys(seq.accounts).sort((a, b) => Number(a) - Number(b));
|
|
595
|
-
|
|
596
|
-
const accounts = [];
|
|
597
|
-
for (const num of order) {
|
|
598
|
-
const acct = seq.accounts[num];
|
|
599
|
-
if (!acct) continue; // sequence lists a slot that no longer exists
|
|
600
|
-
|
|
601
|
-
const row = rows[num];
|
|
602
|
-
// claude-swap keys usage rows by slot but guards them on identity, because
|
|
603
|
-
// a removed account leaves its row behind and slots get reused. Without
|
|
604
|
-
// the same check the panel would show the previous occupant's numbers.
|
|
605
|
-
const matches = row
|
|
606
|
-
&& row.email === acct.email
|
|
607
|
-
&& (row.organizationUuid ?? "") === (acct.organizationUuid ?? "");
|
|
608
|
-
const good = matches ? row.lastGood : null;
|
|
609
|
-
|
|
610
|
-
const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
|
|
611
|
-
const isActive = String(seq.activeAccountNumber) === num;
|
|
612
|
-
const trouble = authTrouble(row, {
|
|
613
|
-
matches, isActive, identity, email: acct.email, fetchedAt: fetchedAtMs, now,
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
const lanes = [
|
|
617
|
-
lane("five_hour", "5h", good?.five_hour),
|
|
618
|
-
lane("seven_day", "7d", good?.seven_day),
|
|
619
|
-
...(Array.isArray(good?.scoped) ? good.scoped : [])
|
|
620
|
-
.map((s, i) => lane(`scoped-${i}`, s?.name ?? "model", s))
|
|
621
|
-
.filter(Boolean),
|
|
622
|
-
].filter(Boolean);
|
|
623
|
-
|
|
624
|
-
accounts.push({
|
|
625
|
-
num: Number(num),
|
|
626
|
-
email: acct.email ?? null,
|
|
627
|
-
alias: acct.alias ?? null,
|
|
628
|
-
org: acct.organizationName ?? null,
|
|
629
|
-
// The other half of an account's identity, and the reason a slot number
|
|
630
|
-
// is not one: claude-swap keys on `(email, organizationUuid)` — the same
|
|
631
|
-
// email under two orgs is two accounts on purpose — and assigns slots
|
|
632
|
-
// max+1 per store, so the account that is 4 here is 2 on another machine.
|
|
633
|
-
// Surfaced for LAN sync, which has to match accounts across two stores
|
|
634
|
-
// that grew in a different order.
|
|
635
|
-
orgUuid: acct.organizationUuid ?? null,
|
|
636
|
-
// Whether CLAUDE-SWAP'S STORED COPY works — not whether the user is
|
|
637
|
-
// signed in. The two differ, and #721 is the whole argument: a
|
|
638
|
-
// `stale-copy` row means the live session is fine while the copy in the
|
|
639
|
-
// store is dead, and the copy is what a share would carry and what a
|
|
640
|
-
// peer's copy would heal. So both kinds of trouble read as not alive.
|
|
641
|
-
alive: trouble == null,
|
|
642
|
-
active: String(seq.activeAccountNumber) === num,
|
|
643
|
-
disabled: acct.disabled === true,
|
|
644
|
-
lanes,
|
|
645
|
-
// Headroom against the tightest lane — the number that decides whether
|
|
646
|
-
// this account is worth switching to.
|
|
647
|
-
headroom: lanes.length ? Math.max(0, 100 - Math.max(...lanes.map(l => l.pct))) : null,
|
|
648
|
-
fetchedAt: fetchedAtMs,
|
|
649
|
-
// When this account will next be read — the earlier of claude-swap's own
|
|
650
|
-
// plan and, for a healthy active account, the deck's freshen tick. The
|
|
651
|
-
// plan alone would promise "next in 15m" while the panel actually
|
|
652
|
-
// updates in three.
|
|
653
|
-
nextAt: nextReadAt(row, matches, fetchedAtMs, isActive, now),
|
|
654
|
-
stale: fetchedAtMs == null || now - fetchedAtMs > STALE_AFTER_MS,
|
|
655
|
-
// Surfaced rather than hidden: a rate-limited or re-login-needed account
|
|
656
|
-
// is exactly the one the user is about to try switching to.
|
|
657
|
-
//
|
|
658
|
-
// Through authTrouble rather than read straight off the row: see #721.
|
|
659
|
-
// consecutiveFailures says the COLLECTOR is failing, which for the active
|
|
660
|
-
// account is not the same claim as the user being signed out — and the
|
|
661
|
-
// CLI can settle that.
|
|
662
|
-
error: trouble?.error ?? null,
|
|
663
|
-
// True when the collector cannot read this account but the user is signed
|
|
664
|
-
// in as it anyway. The panel says so quietly instead of offering to log
|
|
665
|
-
// them in again.
|
|
666
|
-
staleCopy: trouble?.kind === "stale-copy",
|
|
667
|
-
// How the deck's own repair of that is going — `{ state: "running" }` or
|
|
668
|
-
// `{ state: "failed", reason, retryAt }` — and null on every other row.
|
|
669
|
-
// Asking is what starts it; see repairStaleCopyWith.
|
|
670
|
-
repair: trouble?.kind === "stale-copy" ? repairFor(Number(num), acct.email ?? null, now) : null,
|
|
671
|
-
// Nothing has been collected for this account in half a day, and nothing
|
|
672
|
-
// says why. Its own word because the two existing ones would both be
|
|
673
|
-
// wrong: `error` claims a rejection that was never reported, and
|
|
674
|
-
// `staleCopy` promises the user is signed in as it, which is only
|
|
675
|
-
// knowable for the active account.
|
|
676
|
-
stopped: trouble?.kind === "stopped",
|
|
677
|
-
// claude-swap's own verdict for this slot, when there is a fresh one:
|
|
678
|
-
// "no_credentials", "relogin_required", "keychain_unavailable", … It is
|
|
679
|
-
// what turns "not collecting" into a sentence with a next step in it, and
|
|
680
|
-
// it is null on every machine where the collector has not been asked yet.
|
|
681
|
-
collector: verdictFor(num, now),
|
|
682
|
-
});
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
return finish({ ok: true, accounts, activeNum: seq.activeAccountNumber ?? null, fetchedAt: now });
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
/**
|
|
689
|
-
* What to say about an account whose collector is failing — which is not the
|
|
690
|
-
* same question as whether the user is signed out.
|
|
691
|
-
*
|
|
692
|
-
* TWO FACTS, AND #721 SHIPPED THEM AS ONE. claude-swap keeps its own copy of
|
|
693
|
-
* each account's credentials, taken when `cswap add` captured the slot. When
|
|
694
|
-
* that copy's refresh token dies, claude-swap can no longer collect usage for
|
|
695
|
-
* the row and says `relogin_required`. That is true, and it is about the COPY.
|
|
696
|
-
*
|
|
697
|
-
* It says nothing about whether the user is signed in. Measured on the machine
|
|
698
|
-
* that reported this, at the same instant:
|
|
699
|
-
*
|
|
700
|
-
* claude auth status --json -> loggedIn: true, claude3@sapec.md
|
|
701
|
-
* cswap list --json -> claude3@sapec.md: relogin_required
|
|
702
|
-
* GET /api/quota -> source: cli, 5h 33%, 7d 37%
|
|
703
|
-
*
|
|
704
|
-
* The user had signed in again in a terminal, which refreshes the LIVE
|
|
705
|
-
* credentials and leaves claude-swap's stored copy exactly as dead as it was.
|
|
706
|
-
* The deck held live quota numbers for that account and printed "login expired"
|
|
707
|
-
* beside them, and the button it offered ran `claude auth login` — a full
|
|
708
|
-
* re-login of the account the user was mid-session in, to fix a problem they
|
|
709
|
-
* did not have.
|
|
710
|
-
*
|
|
711
|
-
* So: for the ACTIVE account the CLI is the authority, because it is the one
|
|
712
|
-
* thing that can answer about the live credentials rather than about a copy.
|
|
713
|
-
* When it says the user is signed in as this account, there is no login
|
|
714
|
-
* failure to report — only a collector that cannot see it, which is quieter,
|
|
715
|
-
* true, and fixed by re-capturing the slot rather than by signing in again.
|
|
716
|
-
*
|
|
717
|
-
* `identity` is null when the CLI could not be asked at all. That is not
|
|
718
|
-
* evidence of anything, so the stored verdict stands: refusing to show a real
|
|
719
|
-
* expiry because a subprocess failed is the opposite mistake.
|
|
720
|
-
*/
|
|
721
|
-
export function authTrouble(row, {
|
|
722
|
-
matches, isActive, identity, email,
|
|
723
|
-
fetchedAt = null, now = Date.now(), stoppedAfterMs = COLLECTION_STOPPED_AFTER_MS,
|
|
724
|
-
} = {}) {
|
|
725
|
-
if (!matches) return null;
|
|
726
|
-
const failing = (row?.consecutiveFailures ?? 0) > 0;
|
|
727
|
-
// A COLLECTOR THAT HAS PRODUCED NOTHING IN HALF A DAY IS FAILING, whatever
|
|
728
|
-
// its counter says — see COLLECTION_STOPPED_AFTER_MS. `consecutiveFailures`
|
|
729
|
-
// counts rejections, and the failure found on the reporting machine was not a
|
|
730
|
-
// rejection: `cswap list` answered `usageStatus: keychain_unavailable` for
|
|
731
|
-
// three accounts whose counters all read zero.
|
|
732
|
-
//
|
|
733
|
-
// `fetchedAt == null` is deliberately NOT this. That is an account nobody has
|
|
734
|
-
// ever collected — usually one added a minute ago — and the panel already has
|
|
735
|
-
// a word for it. Calling a new account broken is a worse first impression than
|
|
736
|
-
// saying nothing.
|
|
737
|
-
const stopped = !failing && fetchedAt != null && now - fetchedAt > stoppedAfterMs;
|
|
738
|
-
if (!failing && !stopped) return null;
|
|
739
|
-
|
|
740
|
-
const signedInHere = isActive
|
|
741
|
-
&& identity
|
|
742
|
-
&& typeof identity.email === "string"
|
|
743
|
-
&& email
|
|
744
|
-
&& identity.email.toLowerCase() === String(email).toLowerCase();
|
|
745
|
-
|
|
746
|
-
if (signedInHere) {
|
|
747
|
-
// Deliberately not the `error` field: this is not the user's problem to
|
|
748
|
-
// fix under a red badge, and it must not offer to sign them in again.
|
|
749
|
-
return { kind: "stale-copy", error: null };
|
|
750
|
-
}
|
|
751
|
-
// A SILENCE, NOT A DIAGNOSIS. All that is known is that nothing has been
|
|
752
|
-
// collected for half a day; the reason lives in claude-swap and may be a dead
|
|
753
|
-
// login, a keychain it cannot open, or a machine that was off. Putting
|
|
754
|
-
// `invalid_grant` on it would be inventing evidence, and the panel would then
|
|
755
|
-
// offer "sign in again" for a problem that may not be a sign-in at all.
|
|
756
|
-
if (stopped) return { kind: "stopped", error: null };
|
|
757
|
-
return { kind: "auth", error: row.lastError ?? "error" };
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
* claude-swap's last good usage for whichever account is active right now.
|
|
762
|
-
*
|
|
763
|
-
* The Usage panel wants the same numbers for one account that this panel shows
|
|
764
|
-
* for all of them, and claude-swap has already paid for them. Reading its row
|
|
765
|
-
* instead of asking Anthropic again is the difference between one collector on
|
|
766
|
-
* this machine and two competing for the same per-token budget — the second
|
|
767
|
-
* one is what was 429ing the first.
|
|
768
|
-
*
|
|
769
|
-
* Returns null rather than a partial when anything about the row is unsure:
|
|
770
|
-
* the caller's fallback is to fetch for itself, and a wrong number is worse
|
|
771
|
-
* than a slow one.
|
|
772
|
-
*/
|
|
773
|
-
export async function activeAccountUsage() {
|
|
774
|
-
const root = backupRoot();
|
|
775
|
-
const seq = await readJson(join(root, "sequence.json"));
|
|
776
|
-
const num = seq?.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
|
|
777
|
-
const acct = num ? seq?.accounts?.[num] : null;
|
|
778
|
-
if (!acct) return null;
|
|
779
|
-
|
|
780
|
-
const usage = await readJson(join(root, "cache", "usage.json"));
|
|
781
|
-
if (usage?.schemaVersion !== 2) return null;
|
|
782
|
-
|
|
783
|
-
const row = usage.accounts?.[num];
|
|
784
|
-
// Same identity guard the panel uses: rows are keyed by slot, and slots are
|
|
785
|
-
// reused, so a row can outlive the account it was written for.
|
|
786
|
-
if (!row?.lastGood
|
|
787
|
-
|| row.email !== acct.email
|
|
788
|
-
|| (row.organizationUuid ?? "") !== (acct.organizationUuid ?? "")
|
|
789
|
-
|| typeof row.fetchedAt !== "number") return null;
|
|
790
|
-
|
|
791
|
-
return {
|
|
792
|
-
num: Number(num),
|
|
793
|
-
email: acct.email ?? null,
|
|
794
|
-
lastGood: row.lastGood,
|
|
795
|
-
fetchedAt: Math.round(row.fetchedAt * 1000),
|
|
796
|
-
};
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
/**
|
|
800
|
-
* Ask claude-swap to collect now, if its own schedule agrees.
|
|
801
|
-
*
|
|
802
|
-
* Exported for the Usage panel's refresh button, which has no other way to ask
|
|
803
|
-
* for fresher numbers once it stopped fetching them itself. Goes through the
|
|
804
|
-
* same throttle and the same `cswap list` as the accounts panel, so pressing
|
|
805
|
-
* refresh cannot outrun the request budget either.
|
|
806
|
-
*/
|
|
807
|
-
export async function requestCollection() {
|
|
808
|
-
const root = backupRoot();
|
|
809
|
-
const seq = await readJson(join(root, "sequence.json"));
|
|
810
|
-
if (!seq?.accounts) return false;
|
|
811
|
-
const usage = await readJson(join(root, "cache", "usage.json"));
|
|
812
|
-
const rows = usage?.schemaVersion === 2 ? (usage.accounts ?? {}) : {};
|
|
813
|
-
const before = _lastNudge;
|
|
814
|
-
nudgeCollector(rows, Object.keys(seq.accounts), Date.now(), seq.activeAccountNumber);
|
|
815
|
-
return _lastNudge !== before;
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
/**
|
|
819
|
-
* Forget the roster, because something just made it wrong.
|
|
820
|
-
*
|
|
821
|
-
* Called from nine places in four modules — every `cswap` mutation the deck
|
|
822
|
-
* performs — and every one of them is behind a POST that `isTrustedMutation`
|
|
823
|
-
* guards, so nothing a page can send in a loop reaches this.
|
|
824
|
-
*
|
|
825
|
-
* Three things go besides the reading itself:
|
|
826
|
-
*
|
|
827
|
-
* `_generation` so a read that STARTED before this call cannot write its
|
|
828
|
-
* answer into the cache afterwards. See finish() in readRoster.
|
|
829
|
-
* `_inflight` so a caller arriving after the switch is not handed the read
|
|
830
|
-
* that began before it — joining a run is only free when the
|
|
831
|
-
* run is still about the right thing.
|
|
832
|
-
* `_lastReadAt` so the very next read is real work rather than a refusal.
|
|
833
|
-
* Every one of these call sites is followed by the panel
|
|
834
|
-
* reloading with ?refresh=1, and a floor that answered THAT
|
|
835
|
-
* with the pre-switch roster would make the guard the bug.
|
|
836
|
-
*/
|
|
837
|
-
export function invalidateClaudeAccountsCache() {
|
|
838
|
-
_cache = null;
|
|
839
|
-
_cacheAt = 0;
|
|
840
|
-
_generation++;
|
|
841
|
-
_inflight = null;
|
|
842
|
-
_lastReadAt = 0;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
/**
|
|
846
|
-
* Switch the active Claude account by delegating to `cswap`.
|
|
847
|
-
*
|
|
848
|
-
* Not reimplemented here on purpose. A correct switch has to hold three of
|
|
849
|
-
* Claude Code's own lock files, in its order, with its staleness values, or it
|
|
850
|
-
* can interleave with Claude Code's token refresh and clobber it. cswap does
|
|
851
|
-
* that; a second implementation racing it would be worse than useless.
|
|
852
|
-
*/
|
|
853
|
-
export function switchClaudeAccount(accountNum) {
|
|
854
|
-
// Straight into an exec argument, so nothing but a slot number gets through.
|
|
855
|
-
const num = Number(accountNum);
|
|
856
|
-
if (!Number.isInteger(num) || num < 1 || num > 999) {
|
|
857
|
-
return Promise.resolve({ ok: false, reason: "bad_account" });
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// Argument vector, never a shell — and resolved through exec.mjs so the
|
|
861
|
-
// Windows `.exe`/`.cmd` shim is found too.
|
|
862
|
-
return cswapBin()
|
|
863
|
-
.then(bin => run(bin, ["switch", String(num)], { timeout: 30_000 }))
|
|
864
|
-
.then(r => {
|
|
865
|
-
if (r.ok) return { ok: true, output: r.stdout.trim() };
|
|
866
|
-
const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
|
|
867
|
-
return { ok: false, reason, output: (r.stderr || r.stdout).trim().slice(0, 500) };
|
|
868
|
-
});
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
/**
|
|
873
|
-
* Register the account already signed in, the first time and only the first
|
|
874
|
-
* time.
|
|
875
|
-
*
|
|
876
|
-
* A fresh install leaves claude-swap with an empty store, so the panel comes up
|
|
877
|
-
* saying "no accounts added yet" and telling the user to run `cswap add`
|
|
878
|
-
* themselves — for the account they are already using, on the machine they are
|
|
879
|
-
* already on. Running it for them is the difference between the panel working
|
|
880
|
-
* and the panel being a to-do item.
|
|
881
|
-
*
|
|
882
|
-
* `cswap add` takes no arguments, prompts for nothing, and does not sign
|
|
883
|
-
* anyone in: it records the Claude Code session that already exists. Even so it
|
|
884
|
-
* is bounded tightly, because it writes to a credential store:
|
|
885
|
-
*
|
|
886
|
-
* - only when the store holds no accounts at all, so nothing can be
|
|
887
|
-
* overwritten or reordered;
|
|
888
|
-
* - only once ever, marked on disk, so a user who deliberately removes their
|
|
889
|
-
* last account does not get it added back on the next launch;
|
|
890
|
-
* - never when AGENTS_DECK_NO_INSTALL=1.
|
|
891
|
-
*
|
|
892
|
-
* Failure is normal and quiet: on a machine where Claude Code has never signed
|
|
893
|
-
* in there is nothing to record.
|
|
894
|
-
*/
|
|
895
|
-
const SEED_MARKER = join(homedir(), ".agents-deck", ".cswap-seeded");
|
|
896
|
-
|
|
897
|
-
/**
|
|
898
|
-
* How many accounts a sequence.json holds.
|
|
899
|
-
*
|
|
900
|
-
* claude-swap writes `accounts` as an object keyed by slot number — {"2": {…},
|
|
901
|
-
* "3": {…}} — not as a list. An Array.isArray guard here read that as "no
|
|
902
|
-
* accounts" and ran `cswap add` against a populated store, which is exactly
|
|
903
|
-
* what the guard existed to prevent. Both shapes are accepted now, and
|
|
904
|
-
* anything unrecognised counts as -1: unknown is not the same as empty, and
|
|
905
|
-
* only a confident zero may lead to a write.
|
|
906
|
-
*/
|
|
907
|
-
export function accountCount(seq) {
|
|
908
|
-
const a = seq?.accounts;
|
|
909
|
-
if (Array.isArray(a)) return a.length;
|
|
910
|
-
if (a && typeof a === "object") return Object.keys(a).length;
|
|
911
|
-
if (a == null && seq && typeof seq === "object") return 0; // store exists, no accounts yet
|
|
912
|
-
return -1; // unreadable — do nothing
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
export async function seedFirstAccount() {
|
|
916
|
-
if (process.env.AGENTS_DECK_NO_INSTALL === "1") return { state: "skipped" };
|
|
917
|
-
if (existsSync(SEED_MARKER)) return { state: "already-tried" };
|
|
918
|
-
|
|
919
|
-
// Empty store only. A store with accounts in it is the user's, not ours, and
|
|
920
|
-
// a store we cannot parse is treated the same way.
|
|
921
|
-
// No file at all is the fresh install this exists for; a file that will not
|
|
922
|
-
// parse is a store in an unknown state. readJson answers null to both, so
|
|
923
|
-
// they are told apart before deciding, because they call for opposite
|
|
924
|
-
// decisions — seed, and keep well away.
|
|
925
|
-
const seqPath = join(backupRoot(), "sequence.json");
|
|
926
|
-
const before = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
|
|
927
|
-
if (before > 0) return { state: "has-accounts", count: before };
|
|
928
|
-
if (before < 0) return { state: "unreadable-store" };
|
|
929
|
-
|
|
930
|
-
// Mark before running, not after: if `cswap add` half-succeeds or the process
|
|
931
|
-
// dies mid-way, the retry-forever loop is the worse outcome.
|
|
932
|
-
try {
|
|
933
|
-
await mkdir(dirname(SEED_MARKER), { recursive: true });
|
|
934
|
-
await writeFile(SEED_MARKER, new Date().toISOString());
|
|
935
|
-
} catch { /* best-effort — worst case it is attempted again */ }
|
|
936
|
-
|
|
937
|
-
const r = await run(await cswapBin(), ["add"], { timeout: 60_000 });
|
|
938
|
-
if (!r.ok) {
|
|
939
|
-
return { state: "failed", detail: (r.stderr || r.stdout).trim().slice(0, 200) };
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
invalidateClaudeAccountsCache();
|
|
943
|
-
const count = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
|
|
944
|
-
if (count > 0) {
|
|
945
|
-
// Collect straight away. Otherwise the first thing the user sees is their
|
|
946
|
-
// account listed with "never fetched" beside it, waiting on a poll cycle
|
|
947
|
-
// for numbers that are the reason the panel exists.
|
|
948
|
-
runDetached(await cswapBin(), ["list"]);
|
|
949
|
-
}
|
|
950
|
-
return count > 0 ? { state: "added", count } : { state: "nothing-to-add" };
|
|
951
|
-
}
|