ccdeck 3.29.11 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +22 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// What each of quota.mjs's three sources answers, in the shape the panel
|
|
2
|
+
// speaks.
|
|
3
|
+
//
|
|
4
|
+
// A claude-swap row, an OAuth usage body and the text `claude --print /usage`
|
|
5
|
+
// prints go in; the same `{ session5hPct, week7dPct, … }` comes out, whichever
|
|
6
|
+
// of the three it was. Nothing here reads a file, asks the network or holds
|
|
7
|
+
// anything between calls, and that is why it is a module of its own: quota.mjs
|
|
8
|
+
// decides which source to ask and when, which is state and budget, while a
|
|
9
|
+
// wrong number on the panel comes from one of these mappings — and a test can
|
|
10
|
+
// hand one a body without driving the chain that fetched it.
|
|
11
|
+
import { resetLabelIso } from "./reset-label.mjs";
|
|
12
|
+
import { resetCreditsFrom } from "./claude-reset-credits.mjs";
|
|
13
|
+
// One ANSI stripper for the whole deck. The private copy that used to live
|
|
14
|
+
// here accepted only the BEL terminator for an OSC sequence, while term.mjs's
|
|
15
|
+
// also accepts ESC \\ — so a hyperlink written the other legal way survived
|
|
16
|
+
// into text this module then parsed for quota lines.
|
|
17
|
+
import { stripAnsi } from "./term.mjs";
|
|
18
|
+
|
|
19
|
+
// The two windows' lengths. Every reading carries them, whichever source it
|
|
20
|
+
// came from — including the CLI's, which prints no length at all, and the
|
|
21
|
+
// zero reading quota.mjs publishes for a window that has just reset.
|
|
22
|
+
export const WIN_5H_SEC = 18000;
|
|
23
|
+
export const WIN_7D_SEC = 604800;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A reported utilisation as the whole percentage the panel draws: rounded, and
|
|
27
|
+
* held inside 0–100 whatever the source said.
|
|
28
|
+
*
|
|
29
|
+
* The OAuth body and the claude-swap row each carried this as a private arrow
|
|
30
|
+
* inside their mapping, the same one character for character. One rule written
|
|
31
|
+
* twice is a rule that can drift, and a clamp is where a drift prints 140% on
|
|
32
|
+
* a bar. The CLI's parse is not a third copy: it reads at most three digits
|
|
33
|
+
* off a line, so there is nothing below zero or between integers to handle.
|
|
34
|
+
*
|
|
35
|
+
* Exported for its test.
|
|
36
|
+
*/
|
|
37
|
+
export function clampPct(v) {
|
|
38
|
+
return Math.min(100, Math.max(0, Math.round(v)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ISO-8601 → "Jun 19, 1:19pm" (local time, matching the CLI display format).
|
|
42
|
+
//
|
|
43
|
+
// The body moved to reset-label.mjs in #374: codex-quota.mjs had a copy that
|
|
44
|
+
// claimed in its own comment to match this one and did not, so the Codex lanes
|
|
45
|
+
// and the Claude lanes printed the same instant two different ways in the same
|
|
46
|
+
// panel. This rendering is the one both surfaces use now. The alias stays so
|
|
47
|
+
// the four call sites below read the way they always have.
|
|
48
|
+
const fmtResetIso = resetLabelIso;
|
|
49
|
+
|
|
50
|
+
function isoToSec(iso) {
|
|
51
|
+
if (!iso) return null;
|
|
52
|
+
const t = new Date(iso).getTime();
|
|
53
|
+
return isNaN(t) ? null : Math.floor(t / 1000);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Map the OAuth usage JSON to our quota result shape.
|
|
57
|
+
// utilization is already a 0–100 percentage. 5h falls back to 7d if absent.
|
|
58
|
+
//
|
|
59
|
+
// A 7d window that is absent is UNKNOWN, and published as null (#1627). The
|
|
60
|
+
// endpoint can send `seven_day: null`, and this used to fill in a zero there —
|
|
61
|
+
// which the bar prints as "< 1%", a week nobody measured and a reader takes as
|
|
62
|
+
// almost all of it left. The same kind of zero #765 removed; the CLI's parse
|
|
63
|
+
// below already leaves the field out for a week line it did not see.
|
|
64
|
+
export function mapOAuthUsage(data) {
|
|
65
|
+
const fh = data?.five_hour;
|
|
66
|
+
const sd = data?.seven_day;
|
|
67
|
+
const son = data?.seven_day_sonnet;
|
|
68
|
+
const opus = data?.seven_day_opus;
|
|
69
|
+
|
|
70
|
+
const primary = (fh?.utilization != null) ? fh : sd;
|
|
71
|
+
if (!primary || primary.utilization == null) return null;
|
|
72
|
+
|
|
73
|
+
const result = {
|
|
74
|
+
session5hPct: clampPct(primary.utilization),
|
|
75
|
+
session5hWindowSec: WIN_5H_SEC,
|
|
76
|
+
session5hReset: fmtResetIso(primary.resets_at),
|
|
77
|
+
session5hResetAt: isoToSec(primary.resets_at),
|
|
78
|
+
week7dWindowSec: WIN_7D_SEC,
|
|
79
|
+
};
|
|
80
|
+
if (sd?.utilization != null) {
|
|
81
|
+
result.week7dPct = clampPct(sd.utilization);
|
|
82
|
+
result.week7dReset = fmtResetIso(sd.resets_at);
|
|
83
|
+
result.week7dResetAt = isoToSec(sd.resets_at);
|
|
84
|
+
} else {
|
|
85
|
+
result.week7dPct = null;
|
|
86
|
+
}
|
|
87
|
+
if (son?.utilization != null) result.weekSonnetPct = clampPct(son.utilization);
|
|
88
|
+
if (opus?.utilization != null) result.weekOpusPct = clampPct(opus.utilization);
|
|
89
|
+
|
|
90
|
+
// extra usage credits (pay-as-you-go top-up), if enabled
|
|
91
|
+
const extra = data?.extra_usage;
|
|
92
|
+
if (extra?.is_enabled) {
|
|
93
|
+
result.extraEnabled = true;
|
|
94
|
+
if (extra.used_credits != null) result.extraUsedCredits = extra.used_credits;
|
|
95
|
+
if (extra.monthly_limit != null) result.extraMonthlyLimit = extra.monthly_limit;
|
|
96
|
+
if (extra.currency) result.extraCurrency = extra.currency;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Saved limit resets, from the same response and so from the same account as
|
|
100
|
+
// the windows above. Absent rather than null when the block says nothing
|
|
101
|
+
// readable, so a response without one maps exactly as it always has.
|
|
102
|
+
const credits = resetCreditsFrom(data?.cedar_ember, Date.now());
|
|
103
|
+
if (credits) result.resetCredits = credits;
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* claude-swap's row for the active account, in the shape the panel speaks.
|
|
109
|
+
*
|
|
110
|
+
* Exported for quota.mjs, which reads the store through it, and pinned by
|
|
111
|
+
* tests: the mapping is where a wrong number would come from, and it is pure.
|
|
112
|
+
*/
|
|
113
|
+
export function quotaFromStore(entry) {
|
|
114
|
+
const good = entry?.lastGood;
|
|
115
|
+
const fh = good?.five_hour;
|
|
116
|
+
const sd = good?.seven_day;
|
|
117
|
+
const primary = (typeof fh?.pct === "number") ? fh : sd;
|
|
118
|
+
if (typeof primary?.pct !== "number") return null;
|
|
119
|
+
|
|
120
|
+
const out = {
|
|
121
|
+
ok: true,
|
|
122
|
+
source: "claude-swap",
|
|
123
|
+
session5hPct: clampPct(primary.pct),
|
|
124
|
+
session5hWindowSec: WIN_5H_SEC,
|
|
125
|
+
session5hReset: fmtResetIso(primary.resets_at),
|
|
126
|
+
session5hResetAt: isoToSec(primary.resets_at),
|
|
127
|
+
week7dWindowSec: WIN_7D_SEC,
|
|
128
|
+
// Unknown rather than 0 when the row has no 7d window, as mapOAuthUsage
|
|
129
|
+
// publishes it (#1627): claude-swap writes no `seven_day` at all when the
|
|
130
|
+
// endpoint sent none.
|
|
131
|
+
week7dPct: typeof sd?.pct === "number" ? clampPct(sd.pct) : null,
|
|
132
|
+
week7dReset: fmtResetIso(sd?.resets_at),
|
|
133
|
+
week7dResetAt: isoToSec(sd?.resets_at),
|
|
134
|
+
// The age of the DATA, not of our read of it. The panel prints this, and
|
|
135
|
+
// "30s ago" over numbers claude-swap collected twenty minutes back is the
|
|
136
|
+
// kind of true-looking lie this whole change exists to remove.
|
|
137
|
+
fetchedAt: entry.fetchedAt,
|
|
138
|
+
};
|
|
139
|
+
// claude-swap keeps per-model windows in a named list rather than fixed
|
|
140
|
+
// fields, because which ones an account has depends on its plan.
|
|
141
|
+
for (const s of Array.isArray(good.scoped) ? good.scoped : []) {
|
|
142
|
+
if (typeof s?.pct !== "number") continue;
|
|
143
|
+
if (/sonnet/i.test(s.name ?? "")) out.weekSonnetPct = clampPct(s.pct);
|
|
144
|
+
else if (/opus/i.test(s.name ?? "")) out.weekOpusPct = clampPct(s.pct);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Parse "Jun 18, 4:09pm" (local time, no tz) into unix seconds.
|
|
150
|
+
// Claude shows times in the user's local timezone, so parsing as local is correct.
|
|
151
|
+
// `now` is injectable so the year-boundary case is testable.
|
|
152
|
+
export function parseResetToSec(resetStr, now = Date.now()) {
|
|
153
|
+
if (!resetStr) return null;
|
|
154
|
+
try {
|
|
155
|
+
// "4:09pm" → "4:09 PM" so Date.parse handles it. Minutes are optional in
|
|
156
|
+
// the CLI's output ("9am"); Date.parse rejects "9 AM", so supply ":00".
|
|
157
|
+
const norm = resetStr
|
|
158
|
+
.replace(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i,
|
|
159
|
+
(_all, h, mm, ampm) => `${h}:${mm ?? "00"} ${ampm}`)
|
|
160
|
+
.trim();
|
|
161
|
+
// The CLI prints no year, so we have to supply one. Stamping the current
|
|
162
|
+
// year blindly puts a "Jan 2" reset read on Dec 30 eleven months in the
|
|
163
|
+
// past, which hides the countdown and pins the pace marker at 100%. A
|
|
164
|
+
// reset is never more than a week away, so the neighbouring year that
|
|
165
|
+
// lands nearest to `now` is the one Claude meant.
|
|
166
|
+
const thisYear = new Date(now).getFullYear();
|
|
167
|
+
let best = null;
|
|
168
|
+
for (const year of [thisYear - 1, thisYear, thisYear + 1]) {
|
|
169
|
+
const t = new Date(`${norm} ${year}`).getTime();
|
|
170
|
+
if (isNaN(t)) continue;
|
|
171
|
+
if (best === null || Math.abs(t - now) < Math.abs(best - now)) best = t;
|
|
172
|
+
}
|
|
173
|
+
return best === null ? null : Math.floor(best / 1000);
|
|
174
|
+
} catch { return null; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parse `claude --print /usage` output.
|
|
179
|
+
*
|
|
180
|
+
* Observed format (Claude Code ≥ 1.x):
|
|
181
|
+
* "Current session: 84% used · resets Jun 18, 4:09pm (Europe/Chisinau)"
|
|
182
|
+
* "Current week (all models): 85% used · resets Jun 21, 8:59am (Europe/Chisinau)"
|
|
183
|
+
* "Current week (Sonnet only): 48% used · resets Jun 21, 9am (Europe/Chisinau)"
|
|
184
|
+
* "Current week (Opus only): ..." (if present)
|
|
185
|
+
*/
|
|
186
|
+
export function parseUsageText(raw) {
|
|
187
|
+
const text = stripAnsi(raw);
|
|
188
|
+
const result = {};
|
|
189
|
+
|
|
190
|
+
// Helper: find "X% used · resets <rest>" on a line matching a label.
|
|
191
|
+
const extract = (labelRe) => {
|
|
192
|
+
const line = text.split("\n").find(l => labelRe.test(l));
|
|
193
|
+
if (!line) return null;
|
|
194
|
+
const pctM = line.match(/(\d{1,3})\s*%/);
|
|
195
|
+
const resetM = line.match(/resets\s+(.+)/i);
|
|
196
|
+
const resetFull = resetM
|
|
197
|
+
? resetM[1].replace(/\(.*?\)/g, "").replace(/·/g, "").trim()
|
|
198
|
+
: null;
|
|
199
|
+
return {
|
|
200
|
+
pct: pctM ? Math.min(100, parseInt(pctM[1], 10)) : null,
|
|
201
|
+
reset: resetFull,
|
|
202
|
+
resetAt: parseResetToSec(resetFull),
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const session = extract(/current session/i);
|
|
207
|
+
if (session?.pct != null) {
|
|
208
|
+
result.session5hPct = session.pct;
|
|
209
|
+
result.session5hWindowSec = WIN_5H_SEC;
|
|
210
|
+
if (session.reset) result.session5hReset = session.reset;
|
|
211
|
+
if (session.resetAt) result.session5hResetAt = session.resetAt;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const weekAll = extract(/current week\s*\(all models\)/i) || extract(/current week\s*[:·]/i);
|
|
215
|
+
if (weekAll?.pct != null) {
|
|
216
|
+
result.week7dPct = weekAll.pct;
|
|
217
|
+
result.week7dWindowSec = WIN_7D_SEC;
|
|
218
|
+
if (weekAll.reset) result.week7dReset = weekAll.reset;
|
|
219
|
+
if (weekAll.resetAt) result.week7dResetAt = weekAll.resetAt;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const weekSon = extract(/current week\s*\(sonnet/i);
|
|
223
|
+
if (weekSon?.pct != null) result.weekSonnetPct = weekSon.pct;
|
|
224
|
+
|
|
225
|
+
const weekOpus = extract(/current week\s*\(opus/i);
|
|
226
|
+
if (weekOpus?.pct != null) result.weekOpusPct = weekOpus.pct;
|
|
227
|
+
|
|
228
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
229
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Claude's saved limit resets, on the store path (#1308).
|
|
2
|
+
//
|
|
3
|
+
// When claude-swap's store supplies the windows, nothing in its row says how
|
|
4
|
+
// many resets the account has saved, so the inventory is a read of its own
|
|
5
|
+
// with Claude Code's token: on a floor of its own, behind the quota read that
|
|
6
|
+
// starts it, and published only once the token's owner has been checked
|
|
7
|
+
// against the account claude-swap says is active. quota.mjs's header says why
|
|
8
|
+
// the other two sources need none of this; what is here is that check, the
|
|
9
|
+
// inventory it guards, and the floor it is read on.
|
|
10
|
+
import { availableResetCredits, readResetGrants } from "./claude-reset-credits.mjs";
|
|
11
|
+
import {
|
|
12
|
+
SELF_POLL_MS, USAGE_URL, cooldownUntil, oauthHeaders, readOAuthToken, startCooldown,
|
|
13
|
+
} from "./quota-oauth.mjs";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
// The store path's inventory read. `skip_spend=1` is what Claude Code adds to
|
|
17
|
+
// the same request, because the spend breakdown is not what it is asking for;
|
|
18
|
+
// neither is it here, where the windows come from claude-swap instead.
|
|
19
|
+
const RESETS_ONLY_URL = `${USAGE_URL}?cedar_ember=1&skip_spend=1`;
|
|
20
|
+
// Whose token this is. claude-swap asks the same endpoint the same question
|
|
21
|
+
// before it trusts a credential with an account; see tokenOwner.
|
|
22
|
+
const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
23
|
+
|
|
24
|
+
// Floor between two inventory reads while claude-swap supplies the windows. A
|
|
25
|
+
// grant is issued for a launch and lasts weeks, so this is two requests an hour
|
|
26
|
+
// out of a budget claude-swap plans to leave eight to ten of unspent. A forced
|
|
27
|
+
// refresh, or an account not tried yet, waits SELF_POLL_MS.
|
|
28
|
+
const CREDITS_POLL_MS = 30 * 60_000;
|
|
29
|
+
|
|
30
|
+
// How long an inventory is shown after the last read that succeeded. Expiry is
|
|
31
|
+
// worked out at every read, but a reset redeemed in Claude only leaves the
|
|
32
|
+
// inventory when a read says so, and a read that keeps failing must not keep
|
|
33
|
+
// promising it.
|
|
34
|
+
const CREDITS_TRUSTED_MS = 3 * CREDITS_POLL_MS;
|
|
35
|
+
|
|
36
|
+
// What the last successful read found, and for whom: `{ account, grants, at }`,
|
|
37
|
+
// where `grants` is readResetGrants's answer — a list, or null for a block that
|
|
38
|
+
// said nothing readable. Never an id: the parser does not keep one.
|
|
39
|
+
let _credits = null;
|
|
40
|
+
// When the store path last tried, successful or not, and for which account,
|
|
41
|
+
// so a failing read is on the same floor as a working one.
|
|
42
|
+
let _creditsTriedAt = 0;
|
|
43
|
+
let _creditsTriedFor = null;
|
|
44
|
+
let _creditsInflight = null;
|
|
45
|
+
// The last token whose owner was looked up, by hash, and the answer. A token
|
|
46
|
+
// belongs to one account for its whole life, so this is asked once per token
|
|
47
|
+
// rather than once per read. The token itself is not kept.
|
|
48
|
+
let _owner = null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A Claude account as the two halves claude-swap keys one on, or null when
|
|
52
|
+
* either is missing, because half an identity cannot be matched against
|
|
53
|
+
* anything.
|
|
54
|
+
*/
|
|
55
|
+
export function accountOf(email, org) {
|
|
56
|
+
const e = typeof email === "string" ? email.trim() : "";
|
|
57
|
+
const o = typeof org === "string" ? org.trim() : "";
|
|
58
|
+
return e && o ? { email: e, organizationUuid: o } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether two identities are the same Claude account.
|
|
63
|
+
*
|
|
64
|
+
* Both halves, because claude-swap treats one address under two organizations
|
|
65
|
+
* as two accounts on purpose, and those two have separate inventories. The
|
|
66
|
+
* address is compared without case — it is the same mailbox either way — and
|
|
67
|
+
* the organization exactly.
|
|
68
|
+
*
|
|
69
|
+
* Exported for tests: this comparison is the whole of what keeps one account's
|
|
70
|
+
* resets off another account's card.
|
|
71
|
+
*/
|
|
72
|
+
export function sameAccount(a, b) {
|
|
73
|
+
if (!a || !b) return false;
|
|
74
|
+
return a.email.toLowerCase() === b.email.toLowerCase() && a.organizationUuid === b.organizationUuid;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The held inventory, if it was read for `account` and recently enough. */
|
|
78
|
+
export function heldResetCredits(account, now) {
|
|
79
|
+
if (!_credits || !sameAccount(_credits.account, account)) return null;
|
|
80
|
+
if (now - _credits.at > CREDITS_TRUSTED_MS) return null;
|
|
81
|
+
return availableResetCredits(_credits.grants, now);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether the store path may spend a request on the inventory now.
|
|
86
|
+
*
|
|
87
|
+
* The long floor once this account has been tried, whether that worked or not
|
|
88
|
+
* — a profile lookup that keeps failing is not a reason to ask twelve times an
|
|
89
|
+
* hour. The short one for an account not tried yet, which is what an account
|
|
90
|
+
* switch produces, and for the refresh button.
|
|
91
|
+
*
|
|
92
|
+
* Exported for tests, like maySelfPoll, and for the same reason: it is a
|
|
93
|
+
* budget rule, and it is pure. The 429 cooldown is maySelfPoll's own — one
|
|
94
|
+
* token, one budget — so a 429 here holds source 2 off as well, and the other
|
|
95
|
+
* way round.
|
|
96
|
+
*/
|
|
97
|
+
export function resetCreditsDue({ now, force, triedAt, rateLimitedUntil, triedThisAccount }) {
|
|
98
|
+
if (now < rateLimitedUntil) return false;
|
|
99
|
+
return now - triedAt >= (triedThisAccount && !force ? CREDITS_POLL_MS : SELF_POLL_MS);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The account a token belongs to, as `{ email, organizationUuid }`, or null
|
|
104
|
+
* when that cannot be established.
|
|
105
|
+
*
|
|
106
|
+
* `/api/oauth/profile` answers about the token it is sent, which makes it the
|
|
107
|
+
* one source that cannot describe a different account than the token will be
|
|
108
|
+
* used for. The Claude config's `oauthAccount` block would have been free, but
|
|
109
|
+
* it is a separate file from the credential, written at a separate moment, and
|
|
110
|
+
* "usually agrees" is not the bar for publishing an account's data.
|
|
111
|
+
*/
|
|
112
|
+
async function tokenOwner(token) {
|
|
113
|
+
const key = createHash("sha256").update(token).digest("hex");
|
|
114
|
+
if (_owner?.key === key) return _owner.identity;
|
|
115
|
+
let identity = null;
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch(PROFILE_URL, {
|
|
118
|
+
headers: oauthHeaders(token),
|
|
119
|
+
cache: "no-store",
|
|
120
|
+
signal: AbortSignal.timeout(5_000),
|
|
121
|
+
});
|
|
122
|
+
if (res.ok) {
|
|
123
|
+
const body = await res.json();
|
|
124
|
+
identity = accountOf(body?.account?.email, body?.organization?.uuid);
|
|
125
|
+
}
|
|
126
|
+
} catch { /* unreachable or unreadable: no identity, so nothing is published */ }
|
|
127
|
+
if (identity) _owner = { key, identity };
|
|
128
|
+
return identity;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The inventory for `token`: readResetGrants's answer, or undefined when the
|
|
133
|
+
* request did not produce one — in which case whatever is held stays held.
|
|
134
|
+
*/
|
|
135
|
+
async function fetchResetGrants(token) {
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(RESETS_ONLY_URL, {
|
|
138
|
+
headers: oauthHeaders(token),
|
|
139
|
+
cache: "no-store",
|
|
140
|
+
signal: AbortSignal.timeout(5_000),
|
|
141
|
+
});
|
|
142
|
+
if (res.status === 429) {
|
|
143
|
+
startCooldown(res);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (!res.ok) return undefined;
|
|
147
|
+
return readResetGrants((await res.json())?.cedar_ember);
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read the reset inventory for the account claude-swap says is active, when
|
|
155
|
+
* the store is what the panel's windows came from.
|
|
156
|
+
*
|
|
157
|
+
* Best-effort in every direction, and never awaited by a quota read: it runs
|
|
158
|
+
* behind the read that started it, and the next read picks the answer up. It
|
|
159
|
+
* cannot delay, fail or change the windows, and nothing it catches is logged —
|
|
160
|
+
* there is nothing in a failure here the user can act on, and the one thing
|
|
161
|
+
* the error path must never do is print a request that carried a token.
|
|
162
|
+
*
|
|
163
|
+
* THE ACCOUNT CHECK IS THE POINT. The token is Claude Code's, from the Claude
|
|
164
|
+
* config dir; the windows are claude-swap's, for whichever slot it has marked
|
|
165
|
+
* active. Those are the same account after a `cswap switch`, and need not be
|
|
166
|
+
* after a `claude auth login` done in a terminal. So the token's owner is looked
|
|
167
|
+
* up first, and when it is not the store's active account the inventory
|
|
168
|
+
* request is never made. What is kept is stamped with the account it was
|
|
169
|
+
* checked against, and heldResetCredits compares that stamp with the store's
|
|
170
|
+
* active account again at every read — so a switch made anywhere, by the deck,
|
|
171
|
+
* by `cswap` in a terminal or by claude-swap's auto-switch, cannot carry one
|
|
172
|
+
* account's resets onto the next account's card.
|
|
173
|
+
*/
|
|
174
|
+
export function refreshStoreResetCredits({ now, force = false, account }) {
|
|
175
|
+
if (_creditsInflight || !account) return;
|
|
176
|
+
const triedThisAccount = sameAccount(_creditsTriedFor, account);
|
|
177
|
+
if (!resetCreditsDue({ now, force, triedAt: _creditsTriedAt, rateLimitedUntil: cooldownUntil(), triedThisAccount })) return;
|
|
178
|
+
_creditsTriedAt = now;
|
|
179
|
+
_creditsTriedFor = account;
|
|
180
|
+
const run = (async () => {
|
|
181
|
+
const token = await readOAuthToken();
|
|
182
|
+
if (!token) return;
|
|
183
|
+
const owner = await tokenOwner(token);
|
|
184
|
+
if (!sameAccount(owner, account)) return;
|
|
185
|
+
const grants = await fetchResetGrants(token);
|
|
186
|
+
if (grants !== undefined) _credits = { account, grants, at: now };
|
|
187
|
+
})();
|
|
188
|
+
// Settles rather than rejects: nothing in here is news to anyone.
|
|
189
|
+
_creditsInflight = run.catch(() => {}).finally(() => { _creditsInflight = null; });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Exported for tests: the inventory read runs behind the quota read that
|
|
193
|
+
* started it, and a test has to be able to wait for it to land. */
|
|
194
|
+
export function resetCreditsSettled() {
|
|
195
|
+
return _creditsInflight ?? Promise.resolve();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The held inventory, forgotten — for invalidateQuotaCache. */
|
|
199
|
+
export function forgetResetCredits() { _credits = null; }
|
|
200
|
+
|
|
201
|
+
/** The inventory floor, cleared — for resetQuotaPollFloor, beside the
|
|
202
|
+
* self-poll floor it clears. */
|
|
203
|
+
export function clearResetCreditsFloor() {
|
|
204
|
+
_creditsTriedAt = 0;
|
|
205
|
+
_creditsTriedFor = null;
|
|
206
|
+
}
|