ccdeck 3.38.2 → 3.38.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.
@@ -64,7 +64,7 @@
64
64
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
65
65
  })();
66
66
  </script>
67
- <script type="module" crossorigin src="/assets/index-5Bss6_uX.js"></script>
67
+ <script type="module" crossorigin src="/assets/index-FnWpX0X5.js"></script>
68
68
  <link rel="stylesheet" crossorigin href="/assets/index-6_mJZ_aP.css">
69
69
  </head>
70
70
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.38.2",
3
+ "version": "3.38.4",
4
4
  "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -74,6 +74,18 @@
74
74
  "3.38.1",
75
75
  "3.38.2"
76
76
  ],
77
+ "3.38.4": [
78
+ {
79
+ "title": "🔔 A notification when Auto-switch changes your account",
80
+ "body": "When Auto-switch moves Claude Code to another account, ccdeck now tells you once, with where it went and why, for example \"Switched to work \u00b7 personal@example.com reached 90%\". You can turn it off in the sound settings, where two more wait, off until you turn them on: one when a quota window of the Claude or Codex account you are on reaches 90% and again at 100%, and one when a window that had reached 90% is available again. Each is said once per window and a restart does not repeat it. With either quota notification on, the deck reads quota about every two minutes even while no page is open."
81
+ }
82
+ ],
83
+ "3.38.3": [
84
+ {
85
+ "title": "🧾 Subagent costs count their whole reply",
86
+ "body": "A subagent's request was counted from the first moment of its reply, before most of it had been written, so much of its output was left out of the deck's token and cost counts, and a subagent turn in fast mode was priced at the standard rate. Each request is now counted from its finished reply. Days with subagents will read higher than before: that is the part that was missing, not a new charge. Projects counts its history again once after the update, so past days come out right too. Codex sessions on GPT-6 Sol, GPT-6.1 Sol and GPT-6 Luna now show a cost instead of not priced."
87
+ }
88
+ ],
77
89
  "3.37.1": [
78
90
  {
79
91
  "title": "🔁 Expired logins repair from your other machines",
@@ -0,0 +1,558 @@
1
+ // Three notifications about the accounts the deck is on, and the rules for when
2
+ // each is said.
3
+ //
4
+ // SWAP the deck's own auto-switch tick moved the live Claude account.
5
+ // Said once per switch, with the reason the tick itself carries,
6
+ // and never for a switch somebody pressed: those do not come
7
+ // through a tick at all.
8
+ // THRESHOLD a quota window of the account a provider is LIVE on reached 90%,
9
+ // and once more at 100%.
10
+ // RESET a window of the live account that had reached 90% is new again.
11
+ //
12
+ // SPARSE BY CONSTRUCTION. Each window is a record keyed by provider, account
13
+ // and window, holding the reset instant it is about and the levels already said
14
+ // for it. A poll that finds the same state finds the levels said and says
15
+ // nothing; a new reset instant is a new window, and only then can the levels be
16
+ // said again. The records are written to disk (account-watch.mjs), so a restart
17
+ // reads them back rather than meeting every window as news, and an account's
18
+ // records are found only under its own key, so nothing one account said can
19
+ // silence or repeat anything about another.
20
+ //
21
+ // ONE CROSSING, ONE NOTIFICATION. The crossing that makes the auto-switch move
22
+ // the account is the swap's to tell — "Switched to … · … reached 90%" — so a
23
+ // threshold notice for the live Claude account is HELD while the loop is on and
24
+ // the swap switch is on, until the next tick says what it did: a switch away
25
+ // from that account drops it, anything else lets it go. A crossing the deck
26
+ // never saw because the tick saw it first is written down from the tick, so the
27
+ // account does not announce it later when it is live again.
28
+ //
29
+ // Pure apart from the factory at the bottom, whose clock, disk and OS call are
30
+ // injected — the shape block-notify.mjs uses, for the reason it gives: what the
31
+ // suite cannot run is what drifts.
32
+
33
+ /** The levels a window is announced at, lowest first. */
34
+ export const LEVELS = Object.freeze([90, 100]);
35
+
36
+ /** At or above this a window counts as high, which is what makes its reset
37
+ * worth saying. */
38
+ export const HIGH = LEVELS[0];
39
+
40
+ /**
41
+ * How far two readings of one window may disagree about its reset instant and
42
+ * still be the same window.
43
+ *
44
+ * The three Claude sources print it differently — the store and the API to the
45
+ * second, `claude --print /usage` to the minute — and a source can change
46
+ * between two polls. A real reset moves the instant by most of a window (five
47
+ * hours or seven days), so ten minutes cannot confuse the two.
48
+ */
49
+ export const EPOCH_SLACK_SEC = 10 * 60;
50
+
51
+ /**
52
+ * For a window with no reset instant — Claude's per-model weekly windows carry
53
+ * none — how far its reading has to fall below the highest one seen before it
54
+ * is taken to have reset. Usage inside one window only climbs, so a fall this
55
+ * large is a new window and not a wobble.
56
+ */
57
+ export const RESET_DROP = 25;
58
+
59
+ /** How long after a window ended its reset is still news. A deck that was not
60
+ * running when a window reset two days ago has nothing timely to say about
61
+ * it. */
62
+ export const RESET_NEWS_MS = 6 * 60 * 60 * 1000;
63
+
64
+ /**
65
+ * The longest a threshold notice waits for a tick to say whether it switched.
66
+ *
67
+ * claude-swap ticks once a minute and holds off for five after a switch by
68
+ * default, so this covers a cooldown and the tick after it; a slower interval
69
+ * than that lets the notice go rather than holding it forever.
70
+ */
71
+ export const HOLD_MS = 6 * 60 * 1000;
72
+
73
+ /** A record nobody has read for this long is dropped, so the file stays the
74
+ * size of the windows that are still in play. */
75
+ export const RECORD_KEEP_MS = 35 * 24 * 60 * 60 * 1000;
76
+
77
+ /** Longest account name in a notification. */
78
+ export const NAME_MAX = 48;
79
+
80
+ const WIN_5H_SEC = 5 * 3600;
81
+ const WIN_7D_SEC = 7 * 24 * 3600;
82
+
83
+ const PROVIDER_NAMES = Object.freeze({ claude: "Claude", codex: "Codex" });
84
+
85
+ /** `text` in at most `max` characters, with an ellipsis when something was
86
+ * cut. */
87
+ function cut(text, max) {
88
+ const chars = [...text];
89
+ return chars.length > max ? `${chars.slice(0, max - 1).join("").trimEnd()}…` : text;
90
+ }
91
+
92
+ /** A Claude account's key: claude-swap's two halves, the address without case.
93
+ * Null for half an identity, which cannot be told apart from another. */
94
+ export function claudeAccountKey(email, org) {
95
+ const e = typeof email === "string" ? email.trim().toLowerCase() : "";
96
+ const o = typeof org === "string" ? org.trim() : "";
97
+ return e && o ? `${e}@@${o}` : null;
98
+ }
99
+
100
+ /** A Codex account's key: the ChatGPT account id and the address, so one
101
+ * address in two workspaces is two accounts. Null when neither is known. */
102
+ export function codexAccountKey(accountId, email) {
103
+ const id = typeof accountId === "string" ? accountId.trim() : "";
104
+ const e = typeof email === "string" ? email.trim().toLowerCase() : "";
105
+ return id || e ? `${id}|${e}` : null;
106
+ }
107
+
108
+ /**
109
+ * The windows a Claude reading reports, as records can be kept for them.
110
+ *
111
+ * A stale reading is left out: it is numbers already held, re-served while a
112
+ * fresh read is not possible, and has nothing new to say. The 5-hour entry is
113
+ * left out when it is a copy of the 7-day one, which is what the mapping
114
+ * publishes for an account that reports no 5-hour window.
115
+ */
116
+ export function claudeWindows(r) {
117
+ if (!r || r.ok !== true || r.stale) return [];
118
+ const num = v => (typeof v === "number" && Number.isFinite(v) ? v : null);
119
+ const out = [];
120
+ const h5 = num(r.session5hPct), d7 = num(r.week7dPct);
121
+ const copied = h5 != null && h5 === d7 && num(r.session5hResetAt) != null && r.session5hResetAt === r.week7dResetAt;
122
+ if (h5 != null && !copied) {
123
+ out.push({ id: "five_hour", name: "5-hour", pct: h5, epoch: num(r.session5hResetAt), windowSec: num(r.session5hWindowSec) ?? WIN_5H_SEC });
124
+ }
125
+ if (d7 != null) {
126
+ out.push({ id: "seven_day", name: "7-day", pct: d7, epoch: num(r.week7dResetAt), windowSec: num(r.week7dWindowSec) ?? WIN_7D_SEC });
127
+ }
128
+ if (num(r.weekSonnetPct) != null) out.push({ id: "seven_day_sonnet", name: "Sonnet 7-day", pct: r.weekSonnetPct, epoch: null, windowSec: WIN_7D_SEC });
129
+ if (num(r.weekOpusPct) != null) out.push({ id: "seven_day_opus", name: "Opus 7-day", pct: r.weekOpusPct, epoch: null, windowSec: WIN_7D_SEC });
130
+ return out;
131
+ }
132
+
133
+ /** "5-hour window" → "5-hour"; "Codex Spark · 7-day window" → "Codex Spark · 7-day". */
134
+ function codexName(label) {
135
+ const text = typeof label === "string" && label.trim() ? label.trim() : "Rate limit";
136
+ return text.replace(/\s+window$/i, "");
137
+ }
138
+
139
+ /** The windows a Codex reading reports — the main lanes and the extra limit
140
+ * families — as records can be kept for them. */
141
+ export function codexWindows(r) {
142
+ if (!r || r.ok !== true || r.stale) return [];
143
+ const lanes = [...(Array.isArray(r.windows) ? r.windows : []), ...(Array.isArray(r.extraWindows) ? r.extraWindows : [])];
144
+ return lanes
145
+ .filter(w => w && typeof w.id === "string" && typeof w.pct === "number" && Number.isFinite(w.pct))
146
+ .map(w => ({
147
+ id: w.id,
148
+ name: codexName(w.label),
149
+ pct: w.pct,
150
+ epoch: typeof w.resetAt === "number" && Number.isFinite(w.resetAt) ? w.resetAt : null,
151
+ windowSec: typeof w.windowSec === "number" && w.windowSec > 0 ? w.windowSec : null,
152
+ }));
153
+ }
154
+
155
+ /**
156
+ * Which Claude window a label in the tick's `windowsPct` names: "5h", "7d", then
157
+ * the scoped models by name. Null for one this deck keeps no record of.
158
+ */
159
+ export function claudeWindowOfLabel(label) {
160
+ if (label === "5h") return { id: "five_hour", name: "5-hour", windowSec: WIN_5H_SEC };
161
+ if (label === "7d") return { id: "seven_day", name: "7-day", windowSec: WIN_7D_SEC };
162
+ if (/sonnet/i.test(label)) return { id: "seven_day_sonnet", name: "Sonnet 7-day", windowSec: WIN_7D_SEC };
163
+ if (/opus/i.test(label)) return { id: "seven_day_opus", name: "Opus 7-day", windowSec: WIN_7D_SEC };
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * How a reading of a window stands to the record held for it.
169
+ *
170
+ * "new" no record: the first time this window of this account is seen.
171
+ * "same" the window the record is about.
172
+ * "newer" a window that started after it — the old one reset.
173
+ * "older" a reading of a window the record has already moved past.
174
+ */
175
+ export function relate(prev, w) {
176
+ if (!prev) return "new";
177
+ if (prev.epoch != null && w.epoch != null) {
178
+ if (w.epoch > prev.epoch + EPOCH_SLACK_SEC) return "newer";
179
+ if (w.epoch < prev.epoch - EPOCH_SLACK_SEC) return "older";
180
+ return "same";
181
+ }
182
+ // A record the tick wrote knew when it was written and nothing else: the
183
+ // window open at that moment is the one it is about.
184
+ if (prev.epoch == null && prev.carriedAt != null && w.epoch != null && w.windowSec) {
185
+ return w.epoch - w.windowSec > prev.carriedAt / 1000 + EPOCH_SLACK_SEC ? "newer" : "same";
186
+ }
187
+ return w.pct <= prev.peak - RESET_DROP ? "newer" : "same";
188
+ }
189
+
190
+ /** Whether the end of the window a record describes is recent enough to be
191
+ * news: its reset instant when it has one, else the last time it was read. */
192
+ function resetIsNews(prev, now) {
193
+ const endedAt = prev.epoch != null ? prev.epoch * 1000 : prev.seenAt;
194
+ return typeof endedAt === "number" && now - endedAt <= RESET_NEWS_MS;
195
+ }
196
+
197
+ /**
198
+ * One reading of one window against its record.
199
+ *
200
+ * Returns the record after it, the levels newly reached in it (said by nobody
201
+ * yet), whether the window reset into this one while it was high, and whether
202
+ * anything worth keeping on disk changed. A reading of a window whose reset
203
+ * instant has already passed is about a window that is over, and changes
204
+ * nothing.
205
+ */
206
+ export function stepWindow(prev, w, now) {
207
+ const quiet = { next: prev, reached: [], reset: false, changed: false };
208
+ if (w.epoch != null && w.epoch * 1000 <= now) return quiet;
209
+ const rel = relate(prev, w);
210
+ if (rel === "older") return quiet;
211
+ let next;
212
+ let reset = false;
213
+ if (rel === "same") {
214
+ next = {
215
+ ...prev,
216
+ epoch: prev.epoch ?? w.epoch ?? null,
217
+ windowSec: prev.windowSec ?? w.windowSec ?? null,
218
+ peak: Math.max(prev.peak, w.pct),
219
+ seenAt: now,
220
+ };
221
+ } else {
222
+ reset = rel === "newer" && prev.peak >= HIGH && resetIsNews(prev, now);
223
+ next = { epoch: w.epoch ?? null, windowSec: w.windowSec ?? null, peak: w.pct, said: [], seenAt: now };
224
+ }
225
+ const reached = LEVELS.filter(level => w.pct >= level && !next.said.includes(level));
226
+ const changed = rel !== "same" || next.epoch !== prev.epoch || (prev.peak < HIGH && next.peak >= HIGH);
227
+ return { next, reached, reset, changed };
228
+ }
229
+
230
+ /** "2h 14m", "45m", "3d 4h", "under a minute": how far `sec` is from `now`. */
231
+ export function inDuration(sec, now) {
232
+ const mins = Math.floor((sec * 1000 - now) / 60_000);
233
+ if (mins < 1) return "under a minute";
234
+ const d = Math.floor(mins / 1440), h = Math.floor((mins % 1440) / 60), m = mins % 60;
235
+ if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;
236
+ if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
237
+ return `${m}m`;
238
+ }
239
+
240
+ /** A percentage the way the panel prints a threshold: whole when it is whole. */
241
+ function pctLabel(n) {
242
+ return Number.isInteger(n) ? String(n) : String(Math.round(n * 10) / 10);
243
+ }
244
+
245
+ /** Whose notification it is, in the provider's name and the account's. */
246
+ function whoFor(provider, accountName) {
247
+ const p = PROVIDER_NAMES[provider] ?? provider;
248
+ return accountName ? `${p} · ${cut(accountName, NAME_MAX)}` : p;
249
+ }
250
+
251
+ /** "Claude · work@x.com", "5-hour usage reached 90% · resets in 2h 14m". */
252
+ export function thresholdNotice({ provider, accountName, window, level, now }) {
253
+ const resets = window.epoch != null && window.epoch * 1000 > now ? ` · resets in ${inDuration(window.epoch, now)}` : "";
254
+ return { who: whoFor(provider, accountName), body: `${window.name} usage reached ${level}%${resets}` };
255
+ }
256
+
257
+ /** "Claude · work@x.com", "5-hour window available again". */
258
+ export function resetNotice({ provider, accountName, window }) {
259
+ return { who: whoFor(provider, accountName), body: `${window.name} window available again` };
260
+ }
261
+
262
+ /** The title the OS helper shows, which has no app name above it. */
263
+ export const SWAP_WHO = "Claude auto-switch";
264
+
265
+ /**
266
+ * What the switch away from `from` was about, from the tick's own poll: the
267
+ * share of its quota it had used, or null when the poll does not say.
268
+ *
269
+ * claude-swap reports headroom per account number; an older shape gave one
270
+ * number for the active account alone, and that is `from` only when the poll
271
+ * says so.
272
+ */
273
+ export function usedBefore(result, fromNumber) {
274
+ const h = result?.headroom;
275
+ let room = null;
276
+ if (typeof h === "number") {
277
+ if (String(result?.active?.number) === String(fromNumber)) room = h;
278
+ } else if (h && typeof h === "object") {
279
+ room = h[String(fromNumber)];
280
+ }
281
+ return typeof room === "number" && Number.isFinite(room) ? 100 - room : null;
282
+ }
283
+
284
+ /**
285
+ * "Switched to work@x.com · personal@x.com reached 90%".
286
+ *
287
+ * The reason is stated only when the tick carries it: the account it left had
288
+ * used at least the threshold the policy ran with. A switch for any other
289
+ * reason — the account's usage could not be read, or a strategy that moves
290
+ * below the threshold — says where it went and from where, and nothing about
291
+ * why.
292
+ */
293
+ export function swapNotice({ toName, fromName, used, threshold }) {
294
+ const to = toName ? cut(toName, NAME_MAX) : "another account";
295
+ if (!fromName) return { who: SWAP_WHO, body: `Switched to ${to}` };
296
+ const from = cut(fromName, NAME_MAX);
297
+ if (used != null && used >= 100) return { who: SWAP_WHO, body: `Switched to ${to} · ${from} reached 100%` };
298
+ if (used != null && typeof threshold === "number" && used >= threshold) {
299
+ return { who: SWAP_WHO, body: `Switched to ${to} · ${from} reached ${pctLabel(threshold)}%` };
300
+ }
301
+ return { who: SWAP_WHO, body: `Switched to ${to} from ${from}` };
302
+ }
303
+
304
+ /** The records, as read back from disk: anything that is not one is dropped,
305
+ * and so is one nobody has read for RECORD_KEEP_MS. */
306
+ export function normaliseRecords(raw, now) {
307
+ const src = raw && typeof raw === "object" && raw.windows && typeof raw.windows === "object" ? raw.windows : {};
308
+ const out = {};
309
+ const num = v => (typeof v === "number" && Number.isFinite(v) ? v : null);
310
+ for (const [key, r] of Object.entries(src)) {
311
+ if (!r || typeof r !== "object" || typeof key !== "string") continue;
312
+ const seenAt = num(r.seenAt);
313
+ if (seenAt == null || now - seenAt > RECORD_KEEP_MS) continue;
314
+ const peak = num(r.peak);
315
+ if (peak == null) continue;
316
+ const said = Array.isArray(r.said) ? LEVELS.filter(l => r.said.includes(l)) : [];
317
+ const rec = { epoch: num(r.epoch), windowSec: num(r.windowSec), peak, said, seenAt };
318
+ if (num(r.carriedAt) != null) rec.carriedAt = r.carriedAt;
319
+ out[key] = rec;
320
+ }
321
+ return { windows: out };
322
+ }
323
+
324
+ /** A timer that never holds the process open. */
325
+ function laterUnref(fn, ms) {
326
+ const t = setTimeout(fn, ms);
327
+ t.unref?.();
328
+ return t;
329
+ }
330
+
331
+ /**
332
+ * The stateful half: the records, the held notices, and the call out.
333
+ *
334
+ * notify(title, body, meta) the OS call — injected, never awaited by a poll
335
+ * or a tick, and its failure goes to `onError`
336
+ * settings() `{ swap, quota, reset }`, asked per decision, so
337
+ * a switch pressed mid-run takes effect at once
338
+ * load() / save(records) the disk, both best effort
339
+ * now, later the clock and the hold timer, for the suite
340
+ *
341
+ * Every entry point catches its own failures: a notification that cannot be
342
+ * said must never cost the poll that found it or the tick that switched.
343
+ */
344
+ export function createAccountNotifier({
345
+ notify, settings, product = "ccdeck", now = Date.now, load = async () => null, save = async () => {},
346
+ onError, later = laterUnref, holdMs = HOLD_MS,
347
+ }) {
348
+ let records = { windows: {} };
349
+ const ready = Promise.resolve()
350
+ .then(load)
351
+ .then(raw => { records = normaliseRecords(raw, now()); })
352
+ .catch(err => onError?.(err));
353
+ /** Whether the deck's auto-switch loop is on, and the threshold its last tick
354
+ * ran with — together, whether a tick may yet carry a crossing. */
355
+ let autoOn = false;
356
+ let policy = null;
357
+ /** Threshold notices held for a tick, by record key. */
358
+ const held = new Map();
359
+ let holdTimer = null;
360
+ let saving = Promise.resolve();
361
+
362
+ const persist = () => {
363
+ const snapshot = JSON.parse(JSON.stringify(records));
364
+ saving = saving.then(() => save(snapshot)).catch(err => onError?.(err));
365
+ return saving;
366
+ };
367
+
368
+ const say = ({ who, body }) => {
369
+ try {
370
+ Promise.resolve(notify(`${who} — ${product}`, body, { who, chime: null, silent: true })).catch(err => onError?.(err));
371
+ } catch (err) {
372
+ onError?.(err);
373
+ }
374
+ };
375
+
376
+ const keyOf = (provider, accountKey, windowId) => `${provider}|${accountKey}|${windowId}`;
377
+
378
+ /** Write `levels` down as said for the window a held notice was about, if the
379
+ * record is still about that window. */
380
+ const markSaid = (entry, levels) => {
381
+ const rec = records.windows[entry.key];
382
+ if (!rec || relate(rec, entry.window) !== "same") return;
383
+ rec.said = LEVELS.filter(l => rec.said.includes(l) || levels.includes(l));
384
+ };
385
+
386
+ const sayThreshold = entry => {
387
+ markSaid(entry, entry.levels);
388
+ if (settings().quota) {
389
+ say(thresholdNotice({
390
+ provider: entry.provider, accountName: entry.accountName, window: entry.window,
391
+ level: Math.max(...entry.levels), now: now(),
392
+ }));
393
+ }
394
+ };
395
+
396
+ const release = entries => {
397
+ if (entries.length === 0) return;
398
+ for (const entry of entries) { held.delete(entry.key); sayThreshold(entry); }
399
+ persist();
400
+ };
401
+
402
+ const armHold = () => {
403
+ if (holdTimer || held.size === 0) return;
404
+ const due = Math.min(...[...held.values()].map(e => e.at + holdMs));
405
+ holdTimer = later(() => {
406
+ holdTimer = null;
407
+ const at = now();
408
+ release([...held.values()].filter(e => e.at + holdMs <= at));
409
+ armHold();
410
+ }, Math.max(0, due - now()));
411
+ };
412
+
413
+ /** Should a threshold notice for this window wait for the next tick? */
414
+ const holdFor = (provider, level) =>
415
+ provider === "claude" && autoOn && settings().swap && (policy == null || level >= policy);
416
+
417
+ async function observe(provider, account, windows) {
418
+ try {
419
+ await ready;
420
+ if (!account?.key || !Array.isArray(windows)) return;
421
+ const at = now();
422
+ let changed = false;
423
+ for (const w of windows) {
424
+ const key = keyOf(provider, account.key, w.id);
425
+ const step = stepWindow(records.windows[key], w, at);
426
+ if (step.next === records.windows[key] && !step.changed && step.reached.length === 0 && !step.reset) continue;
427
+ records.windows[key] = step.next;
428
+ changed ||= step.changed;
429
+ if (step.reset) {
430
+ held.delete(key);
431
+ if (settings().reset) say(resetNotice({ provider, accountName: account.name, window: w }));
432
+ }
433
+ if (step.reached.length === 0) continue;
434
+ const entry = { key, provider, accountKey: account.key, accountName: account.name, window: w, levels: step.reached, at };
435
+ const top = Math.max(...step.reached);
436
+ if (settings().quota && holdFor(provider, top)) {
437
+ const before = held.get(key);
438
+ held.set(key, before ? { ...entry, levels: LEVELS.filter(l => before.levels.includes(l) || step.reached.includes(l)), at: before.at } : entry);
439
+ continue;
440
+ }
441
+ held.delete(key);
442
+ sayThreshold(entry);
443
+ changed = true;
444
+ }
445
+ armHold();
446
+ if (changed) await persist();
447
+ } catch (err) {
448
+ onError?.(err);
449
+ }
450
+ }
451
+
452
+ /**
453
+ * One finished tick of the deck's auto-switch loop.
454
+ *
455
+ * `accounts` is claude-swap's roster as sequence.json has it — number to
456
+ * `{ email, organizationUuid, alias }` — read after the tick, for the names
457
+ * and the keys of the two accounts a switch moved between.
458
+ */
459
+ async function tick(result, accounts) {
460
+ try {
461
+ await ready;
462
+ if (!result || typeof result !== "object") return;
463
+ if (typeof result.threshold === "number" && Number.isFinite(result.threshold)) policy = result.threshold;
464
+ if (!result.switched) {
465
+ // A cooldown is the one answer that says a switch is still coming.
466
+ if (result.event === "no-switch" && result.reason === "cooldown") return;
467
+ release([...held.values()]);
468
+ return;
469
+ }
470
+ const at = now();
471
+ const s = settings();
472
+ const from = accountOfRef(result.from, accounts);
473
+ const to = accountOfRef(result.to, accounts);
474
+ // What the swap carries. Held notices for the account it left, and every
475
+ // level the tick's poll saw that account at, are its crossing: said by
476
+ // the swap when that switch is on, and said as what they are when not.
477
+ // Only the live account's notices are ever held, and the live account
478
+ // is the one a switch leaves — so when the store could not name it, every
479
+ // held Claude notice is still that account's.
480
+ const carried = [...held.values()].filter(e => e.provider === "claude" && (!from?.key || e.accountKey === from.key));
481
+ for (const entry of carried) {
482
+ held.delete(entry.key);
483
+ if (s.swap) markSaid(entry, entry.levels); else sayThreshold(entry);
484
+ }
485
+ if (from?.key) carryFromPoll(result, from, at, !s.swap);
486
+ release([...held.values()]);
487
+ if (s.swap) {
488
+ say(swapNotice({
489
+ toName: to?.name ?? null, fromName: from?.name ?? null,
490
+ used: usedBefore(result, result.from?.number), threshold: result.threshold,
491
+ }));
492
+ }
493
+ await persist();
494
+ } catch (err) {
495
+ onError?.(err);
496
+ }
497
+ }
498
+
499
+ /** The levels the tick's poll saw the account it left at, written into that
500
+ * account's records — and said as threshold notices when no swap will. */
501
+ function carryFromPoll(result, from, at, sayThem) {
502
+ const pcts = result.windows && typeof result.windows === "object" ? result.windows[String(from.number)] : null;
503
+ if (!pcts || typeof pcts !== "object") return;
504
+ for (const [label, pct] of Object.entries(pcts)) {
505
+ const win = claudeWindowOfLabel(label);
506
+ if (!win || typeof pct !== "number" || !Number.isFinite(pct)) continue;
507
+ const levels = LEVELS.filter(l => pct >= l);
508
+ if (levels.length === 0) continue;
509
+ const key = keyOf("claude", from.key, win.id);
510
+ let rec = records.windows[key];
511
+ if (!rec || (rec.epoch != null && rec.epoch * 1000 <= at)) {
512
+ rec = { epoch: null, windowSec: win.windowSec, peak: pct, said: [], seenAt: at, carriedAt: at };
513
+ records.windows[key] = rec;
514
+ }
515
+ const fresh = levels.filter(l => !rec.said.includes(l));
516
+ rec.peak = Math.max(rec.peak, pct);
517
+ rec.said = LEVELS.filter(l => rec.said.includes(l) || levels.includes(l));
518
+ if (sayThem && fresh.length > 0 && settings().quota) {
519
+ say(thresholdNotice({
520
+ provider: "claude", accountName: from.name, window: { ...win, epoch: rec.epoch }, level: Math.max(...fresh), now: at,
521
+ }));
522
+ }
523
+ }
524
+ }
525
+
526
+ return {
527
+ ready,
528
+ observe,
529
+ tick,
530
+ /** The loop was switched on or off. Off, nothing will carry what is held. */
531
+ autoSwitch(on) {
532
+ autoOn = on === true;
533
+ if (!autoOn) release([...held.values()]);
534
+ },
535
+ /** For the suite: the records as they stand, and what is held. */
536
+ records: () => records,
537
+ held: () => [...held.values()],
538
+ settled: () => saving,
539
+ };
540
+ }
541
+
542
+ /**
543
+ * One side of a switch, as the deck names it: claude-swap's alias for that
544
+ * slot, else the address the tick gave, else the slot's own — and the key its
545
+ * records are under, when the store and the tick agree on who that slot is.
546
+ */
547
+ export function accountOfRef(ref, accounts) {
548
+ if (!ref || typeof ref !== "object") return null;
549
+ const number = ref.number;
550
+ const entry = accounts && typeof accounts === "object" ? accounts[String(number)] : null;
551
+ const tickEmail = typeof ref.email === "string" ? ref.email.trim() : "";
552
+ const storeEmail = typeof entry?.email === "string" ? entry.email.trim() : "";
553
+ const agrees = entry && (!tickEmail || tickEmail.toLowerCase() === storeEmail.toLowerCase());
554
+ const key = agrees ? claudeAccountKey(storeEmail, entry.organizationUuid) : null;
555
+ const alias = agrees && typeof entry.alias === "string" && entry.alias.trim() ? entry.alias.trim() : "";
556
+ const name = alias || tickEmail || storeEmail || (number != null ? `account ${number}` : "");
557
+ return { number, key, name: name || null };
558
+ }