ccdeck 3.36.9 → 3.37.1
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 +15 -7
- package/bin/agent-dag.js +19 -6
- package/bin/cli/help.js +1 -0
- package/bin/cli/login-item.js +44 -20
- package/bin/cli/one-shot.js +15 -5
- package/bin/cli/screen.js +10 -5
- package/bin/cli/startup.js +6 -2
- package/bin/cli/uninstall.js +41 -5
- package/bin/deck.js +50 -8
- package/dist/web/assets/BrowserWatchModal-njN0zg93.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DYSZZZEz.js +1 -0
- package/dist/web/assets/index-B0SOa-XB.css +1 -0
- package/dist/web/assets/index-DDxnWaTa.js +306 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +80 -9
- package/package.json +1 -1
- package/release-notes.json +8 -1
- package/src/server/account-origins.mjs +8 -1
- package/src/server/account-projects.mjs +34 -9
- package/src/server/activation.mjs +39 -10
- package/src/server/app-host.mjs +7 -2
- package/src/server/args.mjs +3 -0
- package/src/server/browser-watch-routes.mjs +23 -16
- package/src/server/browser-watch.mjs +9 -3
- package/src/server/claude-accounts.mjs +27 -7
- package/src/server/claude-verdicts.mjs +12 -1
- package/src/server/cli-versions.mjs +6 -4
- package/src/server/codex-auth.mjs +29 -0
- package/src/server/codex-quota.mjs +57 -7
- package/src/server/deck-home.mjs +23 -1
- package/src/server/deck-prefs.mjs +70 -7
- package/src/server/depth-facts.mjs +178 -0
- package/src/server/detach.mjs +61 -33
- package/src/server/event-log.mjs +15 -0
- package/src/server/event-pipeline.mjs +5 -2
- package/src/server/event-routes.mjs +22 -0
- package/src/server/feedback-form.mjs +36 -13
- package/src/server/index.mjs +6 -2
- package/src/server/install-layout.mjs +35 -0
- package/src/server/install-ref.mjs +39 -0
- package/src/server/installer.mjs +20 -2
- package/src/server/lan-beacon.mjs +18 -3
- package/src/server/lan-deck.mjs +7 -1
- package/src/server/lan-engine.mjs +40 -21
- package/src/server/lan-manifest.mjs +9 -6
- package/src/server/lan-requests.mjs +18 -2
- package/src/server/lan-serve.mjs +4 -2
- package/src/server/lan-sync.mjs +97 -6
- package/src/server/lifecycle.mjs +18 -2
- package/src/server/log-replay.mjs +9 -3
- package/src/server/login-service.mjs +95 -12
- package/src/server/npm-upgrade.mjs +12 -4
- package/src/server/pinned-build.mjs +3 -0
- package/src/server/prefs-routes.mjs +5 -8
- package/src/server/prefs-state.mjs +9 -1
- package/src/server/quota-oauth.mjs +88 -13
- package/src/server/quota-shape.mjs +28 -0
- package/src/server/quota.mjs +68 -17
- package/src/server/rating.mjs +87 -0
- package/src/server/reports-routes.mjs +21 -2
- package/src/server/reports.mjs +672 -64
- package/src/server/ring-bounds.mjs +11 -0
- package/src/server/running-deck.mjs +48 -0
- package/src/server/self-update.mjs +1 -1
- package/src/server/session-enrichment.mjs +14 -6
- package/src/server/stable-node.mjs +68 -0
- package/src/server/static-serve.mjs +5 -0
- package/src/server/supervisor.mjs +21 -15
- package/src/server/tailscale.mjs +8 -4
- package/src/server/transcript-scan.mjs +7 -4
- package/src/server/usage-day.mjs +76 -11
- package/dist/web/assets/BrowserWatchModal-BCrDvuug.js +0 -1
- package/dist/web/assets/UsageHistoryModal-BtRCuo33.js +0 -1
- package/dist/web/assets/index-BkXTN4dZ.css +0 -1
- package/dist/web/assets/index-tVBMXvDv.js +0 -306
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// self-poll floor; this module knows only how, and when a 429 says to stop.
|
|
10
10
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
11
11
|
import { readFile } from "node:fs/promises";
|
|
12
|
-
import {
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { join, win32 as winPath } from "node:path";
|
|
13
14
|
import { mapOAuthUsage } from "./quota-shape.mjs";
|
|
14
15
|
|
|
15
16
|
export const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
@@ -100,6 +101,23 @@ export async function readOAuthToken() {
|
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Which token the credentials file holds, as a hash, or null when it holds
|
|
106
|
+
* none — expired or not, because this is a question of whose it is rather
|
|
107
|
+
* than whether it still works. The token itself is not kept.
|
|
108
|
+
*
|
|
109
|
+
* For quota.mjs, which stamps a reading with it so a held reading is not
|
|
110
|
+
* served after Claude Code has been signed in as somebody else.
|
|
111
|
+
*/
|
|
112
|
+
export async function credentialFingerprint() {
|
|
113
|
+
try {
|
|
114
|
+
const token = JSON.parse(await readFile(credentialsPath(), "utf8"))?.claudeAiOauth?.accessToken;
|
|
115
|
+
return typeof token === "string" && token ? createHash("sha256").update(token).digest("hex") : null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
/**
|
|
104
122
|
* A cooldown from a `retry-after`, kept inside limits the deck can live with.
|
|
105
123
|
*
|
|
@@ -136,23 +154,80 @@ export function cooldownFromHeader(raw, fallbackMs, minMs = 30_000, maxMs = 3600
|
|
|
136
154
|
* answers this properly, with `api_key_mode` as its own reason and its own
|
|
137
155
|
* sentence.
|
|
138
156
|
*
|
|
139
|
-
* Cheap
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
157
|
+
* Cheap: configuration first, because a machine set up for Bedrock or Vertex
|
|
158
|
+
* says so there, then the presence of the OAuth block in the credentials file.
|
|
159
|
+
* `readOAuthToken` above answers a different question — it also rejects an
|
|
160
|
+
* EXPIRED token, and an expired subscription is still a subscription.
|
|
161
|
+
*
|
|
162
|
+
* THE CONFIGURATION IS CLAUDE CODE'S, not only this process's environment. The
|
|
163
|
+
* documented place for it is the `env` block of Claude Code's settings — user
|
|
164
|
+
* or managed — an API key can come from an `apiKeyHelper` there, and a deck
|
|
165
|
+
* started by the desktop app or a login item never sees the shell's exports at
|
|
166
|
+
* all. Reading process.env alone brought the two zero bars back on each of
|
|
167
|
+
* those machines. Values are read the way Claude Code reads them: "true",
|
|
168
|
+
* "yes" and "on" switch a provider on as well as "1".
|
|
169
|
+
*
|
|
170
|
+
* @param files Claude Code's settings files to read; claudeSettingsFiles() by
|
|
171
|
+
* default, and a parameter so a test can name its own.
|
|
144
172
|
*/
|
|
145
|
-
export async function hasSubscriptionCredential(env = process.env) {
|
|
146
|
-
|
|
173
|
+
export async function hasSubscriptionCredential(env = process.env, files = claudeSettingsFiles()) {
|
|
174
|
+
const { cloud, apiKey } = await claudeSignIn(env, files);
|
|
175
|
+
if (cloud) return false;
|
|
147
176
|
try {
|
|
148
177
|
const raw = await readFile(credentialsPath(), "utf8");
|
|
149
178
|
if (JSON.parse(raw)?.claudeAiOauth?.accessToken) return true;
|
|
150
179
|
} catch { /* absent or unreadable, decided below */ }
|
|
151
|
-
// A key in
|
|
152
|
-
// install. Without
|
|
153
|
-
// "sign in" is the right thing to say — which is the
|
|
154
|
-
// this one.
|
|
155
|
-
return !
|
|
180
|
+
// A key — in an environment, or from a helper — and no OAuth block beside
|
|
181
|
+
// it is the API-key install. Without any, this deck simply has not been
|
|
182
|
+
// signed in yet, and "sign in" is the right thing to say — which is the
|
|
183
|
+
// `waiting` branch, not this one.
|
|
184
|
+
return !apiKey;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What Claude Code's configuration says about how it signs in, from `env` and
|
|
189
|
+
* the `env` blocks of its settings files, read as hasSubscriptionCredential
|
|
190
|
+
* explains: `cloud` when Bedrock or Vertex is switched on, `apiKey` when a key
|
|
191
|
+
* is set or an `apiKeyHelper` supplies one. The OAuth block in the credentials
|
|
192
|
+
* file is each caller's own question — it outranks a key, and only a cloud
|
|
193
|
+
* provider outranks it. Exported for the deck's own report of its plan
|
|
194
|
+
* (depth-facts.mjs), which has to answer the way the quota panel does.
|
|
195
|
+
*/
|
|
196
|
+
export async function claudeSignIn(env = process.env, files = claudeSettingsFiles()) {
|
|
197
|
+
const settings = await readSettings(files);
|
|
198
|
+
const envs = [env, ...settings.map(s => (s.env && typeof s.env === "object" ? s.env : {}))];
|
|
199
|
+
return {
|
|
200
|
+
cloud: envs.some(e => isOn(e.CLAUDE_CODE_USE_BEDROCK) || isOn(e.CLAUDE_CODE_USE_VERTEX)),
|
|
201
|
+
apiKey: settings.some(s => typeof s.apiKeyHelper === "string" && s.apiKeyHelper.trim())
|
|
202
|
+
|| envs.some(e => e.ANTHROPIC_API_KEY || e.ANTHROPIC_AUTH_TOKEN),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** On, as Claude Code reads a switch in its environment. */
|
|
207
|
+
const isOn = (v) => /^(1|true|yes|on)$/i.test(String(v ?? "").trim());
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The settings files in which Claude Code can be told how to sign in: the
|
|
211
|
+
* user's, in its config dir, and the managed one an administrator deploys.
|
|
212
|
+
* Project settings are left out — a deck watches every project at once, and
|
|
213
|
+
* no one project's file speaks for the machine.
|
|
214
|
+
*/
|
|
215
|
+
export function claudeSettingsFiles(platform = process.platform, env = process.env) {
|
|
216
|
+
const managed = platform === "darwin" ? "/Library/Application Support/ClaudeCode/managed-settings.json"
|
|
217
|
+
: platform === "win32" ? winPath.join(env.ProgramFiles || "C:\\Program Files", "ClaudeCode", "managed-settings.json")
|
|
218
|
+
: "/etc/claude-code/managed-settings.json";
|
|
219
|
+
return [join(claudeConfigDir(env), "settings.json"), managed];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Each of `files` that parses as an object; a missing or broken one says nothing. */
|
|
223
|
+
async function readSettings(files) {
|
|
224
|
+
const read = await Promise.all(files.map(async (f) => {
|
|
225
|
+
try {
|
|
226
|
+
const parsed = JSON.parse(await readFile(f, "utf8"));
|
|
227
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
228
|
+
} catch { return null; }
|
|
229
|
+
}));
|
|
230
|
+
return read.filter(Boolean);
|
|
156
231
|
}
|
|
157
232
|
|
|
158
233
|
/** The headers every request made with the Claude Code token sends. */
|
|
@@ -146,6 +146,20 @@ export function quotaFromStore(entry) {
|
|
|
146
146
|
return out;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Whether a window this reading measured has reset since it was taken: its
|
|
151
|
+
* reset instant is at or before `now`, so the percentage beside it belongs to a
|
|
152
|
+
* window that is over, and a new one has started near empty.
|
|
153
|
+
*
|
|
154
|
+
* Both windows, because both end: a reading from before a weekly reset is as
|
|
155
|
+
* wrong about the week as one from before a 5-hour reset is about the session.
|
|
156
|
+
* A window with no reset instant cannot be said to have passed it.
|
|
157
|
+
*/
|
|
158
|
+
export function readingLapsed(reading, now) {
|
|
159
|
+
const past = (at) => typeof at === "number" && at * 1000 <= now;
|
|
160
|
+
return past(reading?.session5hResetAt) || past(reading?.week7dResetAt);
|
|
161
|
+
}
|
|
162
|
+
|
|
149
163
|
// Parse "Jun 18, 4:09pm" (local time, no tz) into unix seconds.
|
|
150
164
|
// Claude shows times in the user's local timezone, so parsing as local is correct.
|
|
151
165
|
// `now` is injectable so the year-boundary case is testable.
|
|
@@ -158,6 +172,20 @@ export function parseResetToSec(resetStr, now = Date.now()) {
|
|
|
158
172
|
.replace(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i,
|
|
159
173
|
(_all, h, mm, ampm) => `${h}:${mm ?? "00"} ${ampm}`)
|
|
160
174
|
.trim();
|
|
175
|
+
// A bare clock reading is what the CLI prints for a reset that falls
|
|
176
|
+
// today, and it has no date to find a year for: with one appended anyway,
|
|
177
|
+
// "4:09 pm 2027" reads as the first of January and the bar counted down
|
|
178
|
+
// to it. It means the next time that reading comes round — today, or
|
|
179
|
+
// tomorrow once today's has gone by. The CLI prints to the minute, so the
|
|
180
|
+
// minute it names is still today's.
|
|
181
|
+
const bare = /^(\d{1,2}):(\d{2}) (am|pm)$/i.exec(norm);
|
|
182
|
+
if (bare && +bare[1] >= 1 && +bare[1] <= 12 && +bare[2] < 60) {
|
|
183
|
+
const hour = (+bare[1] % 12) + (/pm/i.test(bare[3]) ? 12 : 0);
|
|
184
|
+
const d = new Date(now);
|
|
185
|
+
const on = (day) => new Date(d.getFullYear(), d.getMonth(), day, hour, +bare[2]).getTime();
|
|
186
|
+
const today = on(d.getDate());
|
|
187
|
+
return Math.floor((today + 60_000 <= now ? on(d.getDate() + 1) : today) / 1000);
|
|
188
|
+
}
|
|
161
189
|
// The CLI prints no year, so we have to supply one. Stamping the current
|
|
162
190
|
// year blindly puts a "Jan 2" reset read on Dec 30 eleven months in the
|
|
163
191
|
// past, which hides the countdown and pins the pace marker at 100%. A
|
package/src/server/quota.mjs
CHANGED
|
@@ -53,12 +53,13 @@
|
|
|
53
53
|
// quota-oauth.mjs's; running source 3 is quota-cli.mjs's; and the store path's
|
|
54
54
|
// reset inventory is quota-store-resets.mjs's.
|
|
55
55
|
import { activeAccountUsage, requestCollection } from "./claude-accounts.mjs";
|
|
56
|
-
import { quotaFromStore, WIN_5H_SEC, WIN_7D_SEC } from "./quota-shape.mjs";
|
|
56
|
+
import { quotaFromStore, readingLapsed, WIN_5H_SEC, WIN_7D_SEC } from "./quota-shape.mjs";
|
|
57
57
|
import {
|
|
58
|
-
SELF_POLL_MS, clearCooldown, coolingDown, cooldownUntil,
|
|
58
|
+
SELF_POLL_MS, clearCooldown, coolingDown, cooldownUntil, credentialFingerprint, fetchOAuthUsage,
|
|
59
|
+
hasSubscriptionCredential,
|
|
59
60
|
} from "./quota-oauth.mjs";
|
|
60
61
|
import {
|
|
61
|
-
accountOf, clearResetCreditsFloor, forgetResetCredits, heldResetCredits, refreshStoreResetCredits,
|
|
62
|
+
accountOf, clearResetCreditsFloor, forgetResetCredits, heldResetCredits, refreshStoreResetCredits, sameAccount,
|
|
62
63
|
} from "./quota-store-resets.mjs";
|
|
63
64
|
import { runUsageOnce, quotaClaudeBin } from "./quota-cli.mjs";
|
|
64
65
|
|
|
@@ -79,6 +80,7 @@ let _cache = null;
|
|
|
79
80
|
let _cacheAt = 0;
|
|
80
81
|
let _inflight = null; // deduplicates concurrent CLI probes
|
|
81
82
|
let _lastGood = null; // last result that had real quota percentages
|
|
83
|
+
let _lastGoodFor = null; // whose it is — see readFor()
|
|
82
84
|
let _lastSelfPollAt = 0;
|
|
83
85
|
// Which account the readings below are about — as a counter, because the
|
|
84
86
|
// account's identity is not something this module holds. invalidateQuotaCache
|
|
@@ -149,7 +151,9 @@ export const QUOTA_DEADLINE_MS = 5_000;
|
|
|
149
151
|
*/
|
|
150
152
|
export async function fetchClaudeQuota({ force = false, deadlineMs = 0 } = {}) {
|
|
151
153
|
const now = Date.now();
|
|
152
|
-
|
|
154
|
+
// Not a reading whose window has reset since it was cached, though: inside
|
|
155
|
+
// the minute it is still the newest answer, and it is no longer a true one.
|
|
156
|
+
if (!force && _cache && now - _cacheAt < CACHE_MS && !readingLapsed(_cache, now)) return _cache;
|
|
153
157
|
|
|
154
158
|
// If another CLI probe is already in flight, wait for it instead of spawning a
|
|
155
159
|
// second concurrent process (which can return empty output and overwrite the
|
|
@@ -242,14 +246,41 @@ function notYet(now) {
|
|
|
242
246
|
* survives the result cache's minute and comes back under a "stale" label for as
|
|
243
247
|
* long as the store has nothing to say about the new account.
|
|
244
248
|
*/
|
|
245
|
-
function publish(gen, result, at, { good = false } = {}) {
|
|
249
|
+
function publish(gen, result, at, { good = false, who = null } = {}) {
|
|
246
250
|
if (gen !== _generation) return result;
|
|
247
251
|
_cache = result;
|
|
248
252
|
_cacheAt = at;
|
|
249
|
-
if (good) _lastGood = result;
|
|
253
|
+
if (good) { _lastGood = result; _lastGoodFor = who; }
|
|
250
254
|
return result;
|
|
251
255
|
}
|
|
252
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Whose numbers a read that starts now will be: claude-swap's active account
|
|
259
|
+
* when its store names one, and the token in Claude Code's credentials file.
|
|
260
|
+
*
|
|
261
|
+
* invalidateQuotaCache covers the switches the deck makes itself. The rest —
|
|
262
|
+
* `cswap switch` in a terminal, claude-swap's own auto-switch, `claude /login`
|
|
263
|
+
* as somebody else — reach nothing here, and the self-poll floor went on
|
|
264
|
+
* serving the previous account's `_lastGood` for up to five minutes, beside an
|
|
265
|
+
* accounts panel that already showed the new account as live. So `_lastGood`
|
|
266
|
+
* carries this, and a read that finds it changed does not serve it.
|
|
267
|
+
*/
|
|
268
|
+
async function readFor(store) {
|
|
269
|
+
return { account: _accountOfReading.get(store) ?? null, token: await credentialFingerprint() };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether the account in use is no longer the one `then` was read for. The
|
|
274
|
+
* account decides whenever both sides name one: a token is refreshed under
|
|
275
|
+
* the same account every few hours, and that is not a switch. Without one,
|
|
276
|
+
* a different token is the only sign there is.
|
|
277
|
+
*/
|
|
278
|
+
function switchedSince(then, now) {
|
|
279
|
+
if (!then) return false;
|
|
280
|
+
if (then.account && now.account) return !sameAccount(then.account, now.account);
|
|
281
|
+
return !!(then.token && now.token && then.token !== now.token);
|
|
282
|
+
}
|
|
283
|
+
|
|
253
284
|
/**
|
|
254
285
|
* claude-swap's numbers for the active account, if it has any.
|
|
255
286
|
*
|
|
@@ -363,9 +394,17 @@ async function _doFetch(now, force = false, gen = _generation) {
|
|
|
363
394
|
// Refresh asks for newer numbers, and the honest way to get them from this
|
|
364
395
|
// source is to ask the collector that owns it — which applies its own
|
|
365
396
|
// schedule and backoff, so this cannot become a poll loop.
|
|
366
|
-
if (force && (!store || now - store.fetchedAt > FORCE_POLL_MS)) {
|
|
397
|
+
if (force && (!store || now - store.fetchedAt > FORCE_POLL_MS || readingLapsed(store, now))) {
|
|
367
398
|
store = await nudgeAndReread(store);
|
|
368
399
|
}
|
|
400
|
+
// A reading held over from an earlier read is only ever served for the
|
|
401
|
+
// account it was read for; see readFor. When the account has moved, the read
|
|
402
|
+
// that answers for the new one may go out on the refresh button's floor:
|
|
403
|
+
// the question is new, and it can be asked at most once per reading held.
|
|
404
|
+
const who = await readFor(store);
|
|
405
|
+
const switched = _lastGood != null && switchedSince(_lastGoodFor, who);
|
|
406
|
+
if (switched) { _lastGood = null; _lastGoodFor = null; }
|
|
407
|
+
|
|
369
408
|
if (store && now - store.fetchedAt <= STORE_TRUSTED_MS) {
|
|
370
409
|
// Keep the store moving even when the accounts panel is closed. Without
|
|
371
410
|
// this the numbers only advance while something else asks — claude-swap's
|
|
@@ -373,15 +412,23 @@ async function _doFetch(now, force = false, gen = _generation) {
|
|
|
373
412
|
// throttle inside is shared with the accounts panel, so two open panels
|
|
374
413
|
// ask no more often than one.
|
|
375
414
|
if (!force) requestCollection().catch(() => {});
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
415
|
+
// A row collected before its window reset is recent and still wrong: the
|
|
416
|
+
// 45 minutes it is trusted for are long enough to hold a 5-hour reset,
|
|
417
|
+
// and claude-swap's rows get that old exactly when its collector is
|
|
418
|
+
// backing off. It is not the answer, so the read goes on below like one
|
|
419
|
+
// for a row that is too old, and the row stays to be held if nothing
|
|
420
|
+
// newer turns up.
|
|
421
|
+
if (!readingLapsed(store, now)) {
|
|
422
|
+
// The saved limit resets are the one thing the store cannot say. Read
|
|
423
|
+
// behind this answer, never in front of it; see refreshStoreResetCredits.
|
|
424
|
+
refreshStoreResetCredits({ now, force, account: _accountOfReading.get(store) });
|
|
425
|
+
return publish(gen, store, now, { good: true, who });
|
|
426
|
+
}
|
|
380
427
|
}
|
|
381
428
|
|
|
382
429
|
// Nothing usable in the store. Everything below spends the user's budget, so
|
|
383
430
|
// it happens on a floor, and not at all while a 429 cooldown is running.
|
|
384
|
-
if (!maySelfPoll({ now, force, lastSelfPollAt: _lastSelfPollAt, rateLimitedUntil: cooldownUntil() })) {
|
|
431
|
+
if (!maySelfPoll({ now, force: force || switched, lastSelfPollAt: _lastSelfPollAt, rateLimitedUntil: cooldownUntil() })) {
|
|
385
432
|
// A stale row still beats an empty panel, and says how stale it is — but
|
|
386
433
|
// it must be the freshest thing we hold, not just the store. Preferring
|
|
387
434
|
// the store here threw away readings we had already paid for: after a boot
|
|
@@ -398,7 +445,7 @@ async function _doFetch(now, force = false, gen = _generation) {
|
|
|
398
445
|
// Source 2: OAuth usage API — instant, exact, no cold-start gap.
|
|
399
446
|
const api = await fetchOAuthUsage();
|
|
400
447
|
if (api) {
|
|
401
|
-
return publish(gen, { ok: true, ...api, source: "api", fetchedAt: now }, now, { good: true });
|
|
448
|
+
return publish(gen, { ok: true, ...api, source: "api", fetchedAt: now }, now, { good: true, who });
|
|
402
449
|
}
|
|
403
450
|
|
|
404
451
|
// Source 3: parse `claude --print /usage` CLI output.
|
|
@@ -406,7 +453,7 @@ async function _doFetch(now, force = false, gen = _generation) {
|
|
|
406
453
|
|
|
407
454
|
// Got real quota lines — cache normally and remember as last-known-good.
|
|
408
455
|
if (parsed) {
|
|
409
|
-
return publish(gen, { ok: true, ...parsed, source: "cli", fetchedAt: now }, now, { good: true });
|
|
456
|
+
return publish(gen, { ok: true, ...parsed, source: "cli", fetchedAt: now }, now, { good: true, who });
|
|
410
457
|
}
|
|
411
458
|
|
|
412
459
|
// No quota lines after retries. If we've ever seen real values, keep showing
|
|
@@ -415,9 +462,12 @@ async function _doFetch(now, force = false, gen = _generation) {
|
|
|
415
462
|
// now" over percentages collected hours earlier for one poll in five, then let
|
|
416
463
|
// the label snap back to the true age: an age indicator that oscillates, and
|
|
417
464
|
// vouches for numbers this branch already knows are stale. Short-cache so we
|
|
418
|
-
// retry the CLI again soon.
|
|
419
|
-
|
|
420
|
-
|
|
465
|
+
// retry the CLI again soon. The store's row is a candidate too, as it is on
|
|
466
|
+
// the floor above: a row passed over because its window reset is still the
|
|
467
|
+
// last thing claude-swap read, and stale says what it is.
|
|
468
|
+
const held = freshest(store, _lastGood);
|
|
469
|
+
if (held) {
|
|
470
|
+
return publish(gen, { ...held, stale: true }, shortLived(now));
|
|
421
471
|
}
|
|
422
472
|
|
|
423
473
|
// Never had good data. A CLI that RAN and printed no quota lines is two
|
|
@@ -498,6 +548,7 @@ export function invalidateQuotaCache() {
|
|
|
498
548
|
_cache = null;
|
|
499
549
|
_cacheAt = 0;
|
|
500
550
|
_lastGood = null;
|
|
551
|
+
_lastGoodFor = null;
|
|
501
552
|
_generation++;
|
|
502
553
|
_inflight = null;
|
|
503
554
|
// Forgotten with the rest, because it is about the account the deck just
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The one question the deck asks about itself: "How useful is ccdeck to you?",
|
|
2
|
+
// 0 to 10, once, after about a week of use.
|
|
3
|
+
//
|
|
4
|
+
// WHEN. Only once the deck has heard a session on RATING_AFTER_DAYS different
|
|
5
|
+
// days (usage-day.mjs keeps that count), only while reports are on — the answer
|
|
6
|
+
// is a report, and a deck that sends none has nobody to tell — and never in the
|
|
7
|
+
// first minutes after a launch, when the board is filling and the person came
|
|
8
|
+
// to look at their sessions, not at us. The page also waits for no dialog to be
|
|
9
|
+
// open (use-rating-ask.ts).
|
|
10
|
+
//
|
|
11
|
+
// HOW OFTEN. "Not now" puts it off by RATING_AGAIN_AFTER_MS, once. A second
|
|
12
|
+
// "Not now", or an answer, and it is never asked again — even after a 3.36.x
|
|
13
|
+
// deck sharing the data dir rewrites prefs.json without it: the outcome is kept
|
|
14
|
+
// a second time beside that file (reports.mjs ratingFile).
|
|
15
|
+
//
|
|
16
|
+
// WHAT LEAVES. The number picked and how many days the deck had been used, as a
|
|
17
|
+
// range (daysUsedBucket) — a "rated" event, sent once (reports.mjs). Nothing
|
|
18
|
+
// about a question left unanswered or put off.
|
|
19
|
+
//
|
|
20
|
+
// Imports nothing, so deck-prefs.mjs can normalise with it.
|
|
21
|
+
|
|
22
|
+
/** Days the deck has to have been used on before it asks. */
|
|
23
|
+
export const RATING_AFTER_DAYS = 7;
|
|
24
|
+
|
|
25
|
+
/** How long "Not now" puts the question off: thirty days. */
|
|
26
|
+
export const RATING_AGAIN_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
/** How long after a launch the question waits: the launch is the busy minute. */
|
|
29
|
+
export const RATING_AFTER_LAUNCH_MS = 5 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/** How many times "Not now" may be said before the question stops for good. */
|
|
32
|
+
const LATER_LIMIT = 2;
|
|
33
|
+
|
|
34
|
+
/** What prefs.json keeps about the question, coerced: the answer (null until
|
|
35
|
+
* there is one), whether it went out, and how often and when it was put off. */
|
|
36
|
+
export function normaliseRating(raw) {
|
|
37
|
+
const src = raw && typeof raw === "object" ? raw : {};
|
|
38
|
+
const score = Number.isInteger(src.score) && src.score >= 0 && src.score <= 10 ? src.score : null;
|
|
39
|
+
const later = Number.isInteger(src.later) && src.later > 0 ? Math.min(src.later, LATER_LIMIT) : 0;
|
|
40
|
+
return {
|
|
41
|
+
score,
|
|
42
|
+
sent: score !== null && src.sent === true,
|
|
43
|
+
later,
|
|
44
|
+
laterAt: typeof src.laterAt === "string" && !Number.isNaN(Date.parse(src.laterAt)) ? src.laterAt : "",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How far the question has got: put off none, once or twice, then answered,
|
|
49
|
+
* then answered and sent. Of two copies of it, the further one is the truth —
|
|
50
|
+
* nothing ever moves it back. */
|
|
51
|
+
export function ratingStage(rating) {
|
|
52
|
+
const r = normaliseRating(rating);
|
|
53
|
+
if (r.score === null) return r.later;
|
|
54
|
+
return r.sent ? LATER_LIMIT + 2 : LATER_LIMIT + 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A score the page may send: a whole number from 0 to 10, else null. */
|
|
58
|
+
export function scoreOf(value) {
|
|
59
|
+
return Number.isInteger(value) && value >= 0 && value <= 10 ? value : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How long the deck had been used when it was rated, as the range the API
|
|
63
|
+
* keeps — never the count itself. */
|
|
64
|
+
export function daysUsedBucket(days) {
|
|
65
|
+
if (!Number.isInteger(days) || days < 7) return "1-6";
|
|
66
|
+
if (days < 14) return "7-13";
|
|
67
|
+
if (days < 30) return "14-29";
|
|
68
|
+
if (days < 90) return "30-89";
|
|
69
|
+
return "90+";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Is it time to ask? The saved state of the question, the days used so far, the
|
|
74
|
+
* time now, and how long this deck has been up. The reports switch is the
|
|
75
|
+
* caller's (reports.mjs), because only it knows the veto.
|
|
76
|
+
*/
|
|
77
|
+
export function ratingDue(rating, daysUsed, now, uptimeMs) {
|
|
78
|
+
const r = normaliseRating(rating);
|
|
79
|
+
if (r.score !== null || r.later >= LATER_LIMIT) return false;
|
|
80
|
+
if (!Number.isInteger(daysUsed) || daysUsed < RATING_AFTER_DAYS) return false;
|
|
81
|
+
if (!(uptimeMs >= RATING_AFTER_LAUNCH_MS)) return false;
|
|
82
|
+
if (r.later > 0) {
|
|
83
|
+
const at = Date.parse(r.laterAt);
|
|
84
|
+
if (Number.isNaN(at) || now.getTime() - at < RATING_AGAIN_AFTER_MS) return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The page's ways into reports.mjs (#1853): an error the page caught
|
|
2
|
-
// feedback dialog. (The switch in Appearance went on 2026-10-01; its route stays.) The page never
|
|
1
|
+
// The page's ways into reports.mjs (#1853): an error the page caught, the
|
|
2
|
+
// feedback dialog, and the one question the deck asks about itself. (The switch in Appearance went on 2026-10-01; its route stays.) The page never
|
|
3
3
|
// talks to api.ccdeck.dev itself — this server does, which is also why the
|
|
4
4
|
// install id never has to reach the page.
|
|
5
5
|
|
|
@@ -28,6 +28,25 @@ export async function handleReportsWrite(req, res, { report = reporter } = {}) {
|
|
|
28
28
|
return send(res, 200, { ok: true, reports: heldPrefs.current().reports, reportsVetoed: reportsVetoed() });
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** `GET /api/rating`: whether the page should ask "How useful is ccdeck to
|
|
32
|
+
* you?" now (rating.mjs says when). Nothing leaves the machine. */
|
|
33
|
+
export function handleRatingRead(_req, res, { report = reporter } = {}) {
|
|
34
|
+
return send(res, 200, { ok: true, ask: report.ratingAsk() });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `POST /api/rating` `{ score }` or `{ later: true }`: the question answered,
|
|
38
|
+
* 0 to 10, or put off. An answer is sent on as a "rated" report; "Not now" is
|
|
39
|
+
* kept here and goes nowhere. 409 when there is nothing to keep it under —
|
|
40
|
+
* reports off, or already answered. */
|
|
41
|
+
export async function handleRatingWrite(req, res, { report = reporter } = {}) {
|
|
42
|
+
const body = await readJson(req, res, 1_000);
|
|
43
|
+
if (body?.later === true) {
|
|
44
|
+
return (await report.rateLater()) ? send(res, 200, { ok: true }) : send(res, 409, { ok: false, reason: "not_asking" });
|
|
45
|
+
}
|
|
46
|
+
if (!Number.isInteger(body?.score) || body.score < 0 || body.score > 10) return send(res, 400, { ok: false, reason: "bad_request" });
|
|
47
|
+
return (await report.rate(body.score)) ? send(res, 200, { ok: true }) : send(res, 409, { ok: false, reason: "not_asking" });
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
/** `POST /api/client-error` `{ message, stack? }`: an error the page caught. Sent on only while reports are on. */
|
|
32
51
|
export async function handleClientError(req, res, { report = reporter } = {}) {
|
|
33
52
|
const body = await readJson(req, res, 16_000);
|