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.
- package/README.md +12 -4
- package/dist/web/assets/{BrowserWatchModal-CwkyKvpa.js → BrowserWatchModal-Pdx1qMyq.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-D8YK0c4C.js → UsageHistoryModal-DdeJQWA7.js} +1 -1
- package/dist/web/assets/index-FnWpX0X5.js +309 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/server/account-notify.mjs +558 -0
- package/src/server/account-projects.mjs +89 -20
- package/src/server/account-watch.mjs +169 -0
- package/src/server/codex-quota.mjs +13 -0
- package/src/server/cswap-auto-loop.mjs +10 -2
- package/src/server/deck-prefs.mjs +26 -1
- package/src/server/event-pipeline.mjs +27 -5
- package/src/server/index.mjs +7 -0
- package/src/server/lan-engine.mjs +16 -4
- package/src/server/quota.mjs +18 -0
- package/src/server/sse-clients.mjs +5 -2
- package/src/server/transcript-scan.mjs +206 -40
- package/dist/web/assets/index-5Bss6_uX.js +0 -309
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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": {
|
package/release-notes.json
CHANGED
|
@@ -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
|
+
}
|