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.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-CJYsv0lr.css +0 -1
  10. package/dist/web/assets/index-Ifm23DDC.js +0 -270
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -398
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. 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
- }