@ipv9/tokentracker-cli 0.39.44 → 0.39.45
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/dashboard/dist/assets/{Card-C_H8B1rI.js → Card-2-TQg7P7.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-yb9ss9uE.js +60 -0
- package/dashboard/dist/assets/{FadeIn-BE8I9B4-.js → FadeIn-BOc6XtOK.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-DQ9gR343.js → IpCheckPage-C2tIb68P.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-Balfhw3-.js → LimitsPage-BsuLQ9co.js} +1 -1
- package/dashboard/dist/assets/{LocalOnlyNotice-hxi161Wl.js → LocalOnlyNotice-C8R9KLef.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-B4zbh-jA.js → PopoverPopup-BpfseoI7.js} +1 -1
- package/dashboard/dist/assets/{Select-DLYT4NCO.js → Select-Ctk8zze5.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-DUyZrDnS.js → SelectItemText-BKZlFyFs.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-BySJoulk.js → SettingsPage-CQXM8qGU.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-DpiMLTPo.js → SkillsPage-4EMm0eBX.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-C6je8upG.js → WidgetsPage-C2sdX5g6.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-ByarX-vY.js → WrappedPage-CLiuEcQZ.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-C-WAJGZ8.js → arrow-up-right-BDkp93DX.js} +1 -1
- package/dashboard/dist/assets/{download-BhOg_qb4.js → download-DZ6SoCSn.js} +1 -1
- package/dashboard/dist/assets/{format-Co2okzG-.js → format-CaW9kvsA.js} +1 -1
- package/dashboard/dist/assets/limitDisplay-DNU_w4O7.js +1 -0
- package/dashboard/dist/assets/{main-q9UoIq7C.js → main-DgyymGht.js} +7 -3
- package/dashboard/dist/assets/{mock-data-0XiGBeUV.js → mock-data-D6C7Fba3.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-DmEIJPtg.js → use-limits-display-prefs-B7cHBa7Y.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-QLM3Sd2C.js → use-native-settings-BKAzGuxw.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-Dtize6Tx.js → useCurrency-BVr6Ajuu.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/commands/doctor.js +6 -0
- package/src/lib/doctor.js +164 -10
- package/src/lib/local-api.js +79 -20
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/process-list.js +91 -0
- package/src/lib/rollout.js +5 -2
- package/src/lib/single-flight.js +59 -0
- package/src/lib/transcript-suppression.js +133 -0
- package/src/lib/usage-limits.js +79 -19
- package/dashboard/dist/assets/DashboardPage-DOa3N76u.js +0 -60
- package/dashboard/dist/assets/limitDisplay-CPMQA7lm.js +0 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const cp = require("node:child_process");
|
|
2
|
+
|
|
3
|
+
// Shared process-listing primitives. Extracted from usage-limits.js so that a
|
|
4
|
+
// second caller (transcript-suppression.js) can reuse the parser instead of
|
|
5
|
+
// keeping a second copy of the same regex in the tree.
|
|
6
|
+
//
|
|
7
|
+
// PRIVACY: callers get raw command lines here, and a command line can contain a
|
|
8
|
+
// user's file paths. Nothing in this module writes, logs, caches, or returns a
|
|
9
|
+
// command line beyond the synchronous call — that constraint belongs to every
|
|
10
|
+
// caller, and CONTRIBUTING.md's rule ("never log, store, transmit, or print ...
|
|
11
|
+
// file paths from user code") is what it exists to satisfy.
|
|
12
|
+
|
|
13
|
+
const PS_BINARY = "/bin/ps";
|
|
14
|
+
// `-x` (own user, including processes with no controlling terminal) rather than
|
|
15
|
+
// `-ax` (every user on the box). The suppression check reports its findings over
|
|
16
|
+
// an unauthenticated loopback endpoint, and on a multi-user host `-a` would make
|
|
17
|
+
// that endpoint answer questions about other people's sessions. Scoping the scan
|
|
18
|
+
// itself is the narrow fix: a session TokenTracker could not have recorded
|
|
19
|
+
// anyway is one this user is not running.
|
|
20
|
+
//
|
|
21
|
+
// Verified on both supported platforms rather than assumed from documented
|
|
22
|
+
// semantics, because Linux `ps` is procps and parses dash-prefixed options as
|
|
23
|
+
// UNIX-style, where `-x` is not an option at all. It does accept this as the BSD
|
|
24
|
+
// `x`: on Debian 12 / procps-ng 4.0.2, `ps -x -o pid=,command=` exits 0 and
|
|
25
|
+
// lists one user, while `-ax` on the same box lists seven. macOS/BSD `ps` is the
|
|
26
|
+
// native case. Had procps rejected it, every Linux host would have fallen into
|
|
27
|
+
// `process_list_failed` — a permanent non-advisory warn, which would pin
|
|
28
|
+
// `degraded` for a whole platform.
|
|
29
|
+
//
|
|
30
|
+
// Frozen because two modules now share this array. Importing one constant stops
|
|
31
|
+
// the two scans from drifting apart editorially; freezing is what stops a caller
|
|
32
|
+
// pushing `-a` onto it at runtime.
|
|
33
|
+
const PS_ARGS = Object.freeze(["-x", "-o", "pid=,command="]);
|
|
34
|
+
const PS_TIMEOUT_MS = 4000;
|
|
35
|
+
const PS_MAX_BUFFER = 10 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
function parseProcessLine(line) {
|
|
38
|
+
const match = String(line || "")
|
|
39
|
+
.trim()
|
|
40
|
+
.match(/^(\d+)\s+(.*)$/);
|
|
41
|
+
if (!match) return null;
|
|
42
|
+
return {
|
|
43
|
+
pid: Number(match[1]),
|
|
44
|
+
command: match[2],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `/bin/ps` with these flags is a POSIX assumption. Windows has no equivalent
|
|
49
|
+
// at this path, and guessing at `tasklist` output would be an untested code
|
|
50
|
+
// path, so the honest answer there is "not supported" rather than "no problems
|
|
51
|
+
// found" — the caller must not turn this into a passing check.
|
|
52
|
+
function isProcessListSupported(platform = process.platform) {
|
|
53
|
+
return platform !== "win32";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Returns { supported, ok, lines, reason }. `ok: false` never throws: a machine
|
|
57
|
+
// that refuses `ps` (sandbox, hardened runtime) is a normal condition here, and
|
|
58
|
+
// the caller reports it as "could not check" rather than as "nothing found".
|
|
59
|
+
function listProcessLines({ commandRunner, platform = process.platform } = {}) {
|
|
60
|
+
if (!isProcessListSupported(platform)) {
|
|
61
|
+
return { supported: false, ok: false, lines: [], reason: "unsupported_platform" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const runner = typeof commandRunner === "function" ? commandRunner : cp.spawnSync;
|
|
65
|
+
let result;
|
|
66
|
+
try {
|
|
67
|
+
result = runner(PS_BINARY, PS_ARGS, {
|
|
68
|
+
encoding: "utf8",
|
|
69
|
+
maxBuffer: PS_MAX_BUFFER,
|
|
70
|
+
timeout: PS_TIMEOUT_MS,
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
return { supported: true, ok: false, lines: [], reason: "process_list_failed" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (result?.error || result?.status !== 0) {
|
|
77
|
+
return { supported: true, ok: false, lines: [], reason: "process_list_failed" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const stdout = typeof result?.stdout === "string" ? result.stdout : "";
|
|
81
|
+
return { supported: true, ok: true, lines: stdout.split("\n"), reason: null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
PS_ARGS,
|
|
86
|
+
PS_BINARY,
|
|
87
|
+
PS_TIMEOUT_MS,
|
|
88
|
+
isProcessListSupported,
|
|
89
|
+
listProcessLines,
|
|
90
|
+
parseProcessLine,
|
|
91
|
+
};
|
package/src/lib/rollout.js
CHANGED
|
@@ -3398,8 +3398,11 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
|
|
|
3398
3398
|
// Skip if delta is zero (session unchanged since last sync)
|
|
3399
3399
|
if (dInput === 0 && dOutput === 0 && dCacheRead === 0 && dCacheWrite === 0 && dReasoning === 0) continue;
|
|
3400
3400
|
|
|
3401
|
-
//
|
|
3402
|
-
|
|
3401
|
+
// A first observation has only the session start as a usable timestamp.
|
|
3402
|
+
// Attribute later active-session deltas to this sync so cross-day usage
|
|
3403
|
+
// does not keep growing the day on which the session originally started.
|
|
3404
|
+
// Once Hermes records completion, ended_at is authoritative for the final delta.
|
|
3405
|
+
const epochSec = endedAt ?? (prev ? Date.parse(updatedAt) / 1000 : startedAt);
|
|
3403
3406
|
if (!epochSec || !Number.isFinite(epochSec)) continue;
|
|
3404
3407
|
const tsIso = new Date(epochSec * 1000).toISOString();
|
|
3405
3408
|
const bucketStart = toUtcHalfHourStart(tsIso);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Coalesce concurrent calls to one expensive async producer.
|
|
5
|
+
*
|
|
6
|
+
* The problem it solves is not "the same work runs twice" — it is that the work
|
|
7
|
+
* being duplicated fans out to every configured provider's private endpoint. Two
|
|
8
|
+
* dashboard tabs, a route mount, and a scheduled revalidation can all arrive
|
|
9
|
+
* inside the same second on a cold cache; without this, each one launches its own
|
|
10
|
+
* full sweep.
|
|
11
|
+
*
|
|
12
|
+
* `run(fn)` returns the in-flight promise when one exists, so every caller in a
|
|
13
|
+
* window shares a single execution and a single result object. The slot is
|
|
14
|
+
* released as soon as the work settles — success or failure — so the next call
|
|
15
|
+
* starts fresh work rather than replaying a stale outcome.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately NOT keyed by argument: the single consumer here has one shared
|
|
18
|
+
* result for the whole process. A joining caller therefore receives the FIRST
|
|
19
|
+
* caller's work, arguments included. That is the intended trade for the fan-out,
|
|
20
|
+
* and it is safe only because every production call site passes the same inputs.
|
|
21
|
+
*
|
|
22
|
+
* @returns {(fn: () => Promise<any>) => Promise<any>}
|
|
23
|
+
*/
|
|
24
|
+
function createSingleFlight() {
|
|
25
|
+
let inFlight = null;
|
|
26
|
+
|
|
27
|
+
return function run(fn) {
|
|
28
|
+
if (inFlight) return inFlight;
|
|
29
|
+
|
|
30
|
+
// `Promise.resolve().then(fn)` rather than `fn()` so a synchronous throw
|
|
31
|
+
// inside fn becomes a rejection on this path too, instead of escaping past
|
|
32
|
+
// the slot cleanup and wedging `inFlight` forever.
|
|
33
|
+
//
|
|
34
|
+
// Release carries no `inFlight === pending` identity guard, deliberately. A
|
|
35
|
+
// later run can only claim the slot after this one released it — while
|
|
36
|
+
// `pending` is unsettled every arrival joins instead of replacing it — so a
|
|
37
|
+
// stale callback clearing a successor's slot is unreachable, and a mutation
|
|
38
|
+
// test found the guard dead. Adding a way to clear the slot from outside
|
|
39
|
+
// would make it reachable again; there is none, and #141 requires that a
|
|
40
|
+
// cache reset specifically must not do it.
|
|
41
|
+
const pending = Promise.resolve()
|
|
42
|
+
.then(fn)
|
|
43
|
+
.finally(() => {
|
|
44
|
+
inFlight = null;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Module state now holds a promise that real callers may all walk away from.
|
|
48
|
+
// Without a handler of its own, a rejection reaching only this reference is
|
|
49
|
+
// an unhandledRejection raised from state nobody is watching — a failure mode
|
|
50
|
+
// that did not exist while every caller owned its own promise. Callers still
|
|
51
|
+
// receive the rejection; this only marks the stored reference as handled.
|
|
52
|
+
pending.catch(() => {});
|
|
53
|
+
|
|
54
|
+
inFlight = pending;
|
|
55
|
+
return pending;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { createSingleFlight };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const { listProcessLines, parseProcessLine } = require("./process-list");
|
|
2
|
+
|
|
3
|
+
// Detects Claude Code CLI processes started with `--no-session-persistence`.
|
|
4
|
+
//
|
|
5
|
+
// Why this exists: every Claude figure TokenTracker reports is parsed out of the
|
|
6
|
+
// session transcripts under `~/.claude/projects`. That flag tells the CLI to
|
|
7
|
+
// write no transcript at all, so those calls are unobservable here — they cost
|
|
8
|
+
// real tokens and contribute zero. Before this check, the only visible symptom
|
|
9
|
+
// was a source quietly reporting less than it should, which is indistinguishable
|
|
10
|
+
// from "the user worked less today".
|
|
11
|
+
//
|
|
12
|
+
// This is a *live* signal, not a historical one: it answers "is something
|
|
13
|
+
// running right now that I cannot see", which is why it needs no baseline, no
|
|
14
|
+
// threshold, and no per-source history.
|
|
15
|
+
//
|
|
16
|
+
// PRIVACY (CONTRIBUTING.md): the command line is read and discarded inside this
|
|
17
|
+
// call. What leaves this module is a count, a coarse reason, and the model ids —
|
|
18
|
+
// never a pid, an argv string, an environment value, or a file path. Model ids
|
|
19
|
+
// are already first-class tracked data (every queue row carries one), and they
|
|
20
|
+
// are the one field that tells a user *which* stream is unobservable, so they
|
|
21
|
+
// are included deliberately; they are also length- and charset-clamped below so
|
|
22
|
+
// that no arbitrary command-line text can ride out through this field.
|
|
23
|
+
|
|
24
|
+
const SUPPRESSION_FLAG = "--no-session-persistence";
|
|
25
|
+
const CLAUDE_BINARY_NAME = "claude";
|
|
26
|
+
const MODEL_FLAG = /(?:^|\s)--model[=\s]+([A-Za-z0-9._:-]{1,64})(?=\s|$)/;
|
|
27
|
+
const SUPPRESSION_FLAG_PATTERN = /(?:^|\s)--no-session-persistence(?:=|\s|$)/;
|
|
28
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
let cache = null;
|
|
31
|
+
|
|
32
|
+
function resetTranscriptSuppressionCache() {
|
|
33
|
+
cache = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// argv[0] must be the `claude` binary. Matching on the flag alone would also
|
|
37
|
+
// match any shell, editor, or grep whose own command line happens to contain
|
|
38
|
+
// the string — including the process asking this question.
|
|
39
|
+
//
|
|
40
|
+
// The executable region is everything before the first `--flag`, not the first
|
|
41
|
+
// whitespace-delimited token: `ps` gives one flat string, so a binary installed
|
|
42
|
+
// under a path containing a space ("/Users/me/My Tools/claude") would otherwise
|
|
43
|
+
// be read as argv[0] = "/Users/me/My" and missed. A miss is the failure mode
|
|
44
|
+
// this whole check exists to prevent, so it is worth the wider window.
|
|
45
|
+
function isClaudeInvocation(command) {
|
|
46
|
+
const executableRegion = String(command || "").split(/\s+--/)[0] || "";
|
|
47
|
+
const base = executableRegion.trim().split(/[/\\]/).pop() || "";
|
|
48
|
+
return base.replace(/\.exe$/i, "").toLowerCase() === CLAUDE_BINARY_NAME;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasSuppressionFlag(command) {
|
|
52
|
+
return SUPPRESSION_FLAG_PATTERN.test(String(command || ""));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractModel(command) {
|
|
56
|
+
const match = String(command || "").match(MODEL_FLAG);
|
|
57
|
+
return match?.[1] || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Pure: takes `ps` output lines, returns the deduplicated model ids of every
|
|
61
|
+
// suppressed Claude process. Deliberately returns models rather than processes —
|
|
62
|
+
// there is no pid in the return value for anything downstream to leak.
|
|
63
|
+
function findSuppressedModels(lines = []) {
|
|
64
|
+
const models = new Set();
|
|
65
|
+
let count = 0;
|
|
66
|
+
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
const parsed = parseProcessLine(line);
|
|
69
|
+
if (!parsed) continue;
|
|
70
|
+
if (!isClaudeInvocation(parsed.command)) continue;
|
|
71
|
+
if (!hasSuppressionFlag(parsed.command)) continue;
|
|
72
|
+
count += 1;
|
|
73
|
+
const model = extractModel(parsed.command);
|
|
74
|
+
if (model) models.add(model);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { count, models: [...models].sort() };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Returns { supported, checked, count, models, reason, checked_at }.
|
|
81
|
+
//
|
|
82
|
+
// `checked: false` is never reported as a clean result by callers. "I could not
|
|
83
|
+
// look" and "I looked and found nothing" are different answers, and collapsing
|
|
84
|
+
// them is how a monitor starts lying.
|
|
85
|
+
function detectTranscriptSuppression({
|
|
86
|
+
commandRunner,
|
|
87
|
+
platform = process.platform,
|
|
88
|
+
now = () => new Date(),
|
|
89
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
90
|
+
useCache = true,
|
|
91
|
+
} = {}) {
|
|
92
|
+
const at = now();
|
|
93
|
+
const atMs = at.getTime();
|
|
94
|
+
|
|
95
|
+
if (useCache && cache && atMs - cache.atMs < ttlMs) {
|
|
96
|
+
return cache.value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const listed = listProcessLines({ commandRunner, platform });
|
|
100
|
+
|
|
101
|
+
let value;
|
|
102
|
+
if (!listed.ok) {
|
|
103
|
+
value = {
|
|
104
|
+
supported: listed.supported,
|
|
105
|
+
checked: false,
|
|
106
|
+
count: 0,
|
|
107
|
+
models: [],
|
|
108
|
+
reason: listed.reason,
|
|
109
|
+
checked_at: at.toISOString(),
|
|
110
|
+
};
|
|
111
|
+
} else {
|
|
112
|
+
const { count, models } = findSuppressedModels(listed.lines);
|
|
113
|
+
value = {
|
|
114
|
+
supported: true,
|
|
115
|
+
checked: true,
|
|
116
|
+
count,
|
|
117
|
+
models,
|
|
118
|
+
reason: null,
|
|
119
|
+
checked_at: at.toISOString(),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (useCache) cache = { atMs, value };
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
DEFAULT_TTL_MS,
|
|
129
|
+
SUPPRESSION_FLAG,
|
|
130
|
+
detectTranscriptSuppression,
|
|
131
|
+
findSuppressedModels,
|
|
132
|
+
resetTranscriptSuppressionCache,
|
|
133
|
+
};
|
package/src/lib/usage-limits.js
CHANGED
|
@@ -21,12 +21,21 @@ const {
|
|
|
21
21
|
extractCursorSessionToken,
|
|
22
22
|
fetchCursorUsageSummary,
|
|
23
23
|
} = require("./cursor-config");
|
|
24
|
+
const { PS_ARGS, PS_BINARY, parseProcessLine } = require("./process-list");
|
|
25
|
+
const { createSingleFlight } = require("./single-flight");
|
|
24
26
|
|
|
25
27
|
// 2-minute in-memory cache
|
|
26
28
|
let cache = { data: null, fetchedAt: 0 };
|
|
29
|
+
// One slot per module instance, not a shared singleton: `delete require.cache[…]`
|
|
30
|
+
// is how the tests get a clean cache, and a shared instance would survive it.
|
|
31
|
+
const runUsageLimitsFetch = createSingleFlight();
|
|
27
32
|
const CACHE_TTL_MS = 2 * 60 * 1000;
|
|
28
33
|
const DEFAULT_PROVIDER_TIMEOUT_MS = 15_000;
|
|
29
34
|
const ANTIGRAVITY_LIMITS_CACHE_FILE = "usage-limits-cache.json";
|
|
35
|
+
// Trust marker, not a user identity: only cache blocks written after the
|
|
36
|
+
// current-user process-scan fix may be served. Missing/unknown markers fail
|
|
37
|
+
// closed so a legacy multi-user cache cannot disclose another account.
|
|
38
|
+
const ANTIGRAVITY_CACHE_PROCESS_SCOPE = "current-user-v1";
|
|
30
39
|
const ANTIGRAVITY_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
31
40
|
const ANTIGRAVITY_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
|
|
32
41
|
const CLAUDE_LIMITS_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
@@ -986,11 +995,22 @@ async function fetchGeminiLimits({ home, env, fetchImpl = fetch, commandRunner }
|
|
|
986
995
|
|
|
987
996
|
function runCommand(commandRunner, command, args, options = {}) {
|
|
988
997
|
const runner = typeof commandRunner === "function" ? commandRunner : cp.spawnSync;
|
|
989
|
-
|
|
998
|
+
const result = runner(command, args, {
|
|
990
999
|
encoding: "utf8",
|
|
991
1000
|
maxBuffer: 10 * 1024 * 1024,
|
|
992
1001
|
...options,
|
|
993
1002
|
});
|
|
1003
|
+
// Every consumer reads spawnSync-style fields immediately. An async runner
|
|
1004
|
+
// is therefore a contract error, not supported concurrency. Attach a handler
|
|
1005
|
+
// immediately, then return a synchronous failure shape so the Promise cannot
|
|
1006
|
+
// escape as unhandled process state.
|
|
1007
|
+
if (result && typeof result.then === "function") {
|
|
1008
|
+
Promise.resolve(result).catch(() => {});
|
|
1009
|
+
const error = new TypeError("commandRunner must return synchronously");
|
|
1010
|
+
error.code = "COMMAND_RUNNER_ASYNC_UNSUPPORTED";
|
|
1011
|
+
return { status: 1, stdout: "", stderr: error.message, error };
|
|
1012
|
+
}
|
|
1013
|
+
return result;
|
|
994
1014
|
}
|
|
995
1015
|
|
|
996
1016
|
function whichBinary(binary, { commandRunner } = {}) {
|
|
@@ -1332,17 +1352,6 @@ function fetchKiroLimits({ commandRunner, now = new Date() } = {}) {
|
|
|
1332
1352
|
}
|
|
1333
1353
|
}
|
|
1334
1354
|
|
|
1335
|
-
function parseProcessLine(line) {
|
|
1336
|
-
const match = String(line || "")
|
|
1337
|
-
.trim()
|
|
1338
|
-
.match(/^(\d+)\s+(.*)$/);
|
|
1339
|
-
if (!match) return null;
|
|
1340
|
-
return {
|
|
1341
|
-
pid: Number(match[1]),
|
|
1342
|
-
command: match[2],
|
|
1343
|
-
};
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
1355
|
function isAntigravityCommandLine(command) {
|
|
1347
1356
|
const lower = String(command || "").toLowerCase();
|
|
1348
1357
|
return lower.includes("language_server")
|
|
@@ -1360,10 +1369,34 @@ function extractCommandFlag(command, flag) {
|
|
|
1360
1369
|
return match?.[1] || null;
|
|
1361
1370
|
}
|
|
1362
1371
|
|
|
1372
|
+
// Scans for the local Antigravity language server and reads its `--csrf_token`
|
|
1373
|
+
// out of the command line, so that the quota request below can authenticate to
|
|
1374
|
+
// it.
|
|
1375
|
+
//
|
|
1376
|
+
// The scan is scoped to the current user, and must stay that way. Under the
|
|
1377
|
+
// previous `-ax` this walked every account on the box and attached to whichever
|
|
1378
|
+
// Antigravity matched first. The token itself never reached an HTTP response —
|
|
1379
|
+
// `processInfo` is read field by field and never spread into a return value —
|
|
1380
|
+
// but what the token *fetches* does: `normalizeAntigravityResponse` returns
|
|
1381
|
+
// `account_email` and `account_plan`, `finalize` spreads them into the result,
|
|
1382
|
+
// and `getUsageLimits` serves that at `/functions/tokentracker-usage-limits`.
|
|
1383
|
+
// On a shared host this meant showing another person's email, plan and quota as
|
|
1384
|
+
// the local user's, and `writeAntigravityLimitsCache` persisted it to disk,
|
|
1385
|
+
// where the `!configured` branch would keep serving it after their process
|
|
1386
|
+
// exited.
|
|
1387
|
+
//
|
|
1388
|
+
// Sharing PS_ARGS with process-list.js is deliberate: two scans that must both
|
|
1389
|
+
// stay own-user should not be able to drift apart.
|
|
1363
1390
|
function detectAntigravityProcess({ commandRunner } = {}) {
|
|
1364
|
-
const result = runCommand(commandRunner,
|
|
1391
|
+
const result = runCommand(commandRunner, PS_BINARY, PS_ARGS, {
|
|
1365
1392
|
timeout: 4000,
|
|
1366
1393
|
});
|
|
1394
|
+
if (result?.error?.code === "COMMAND_RUNNER_ASYNC_UNSUPPORTED") {
|
|
1395
|
+
return {
|
|
1396
|
+
configured: true,
|
|
1397
|
+
error: result.error?.message || "Antigravity process detection failed.",
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1367
1400
|
const lines = String(result?.stdout || "").split("\n");
|
|
1368
1401
|
|
|
1369
1402
|
let sawProcess = false;
|
|
@@ -1435,6 +1468,7 @@ function hasAntigravityWindow(limits) {
|
|
|
1435
1468
|
}
|
|
1436
1469
|
|
|
1437
1470
|
function normalizeAntigravityCachedLimits(raw, { nowMs = Date.now() } = {}) {
|
|
1471
|
+
if (raw?.process_scope !== ANTIGRAVITY_CACHE_PROCESS_SCOPE) return null;
|
|
1438
1472
|
const cachedAtMs = parseTimeMs(raw?.cached_at);
|
|
1439
1473
|
if (!Number.isFinite(cachedAtMs)) return null;
|
|
1440
1474
|
if (cachedAtMs > nowMs + 60_000) return null;
|
|
@@ -1463,6 +1497,7 @@ function writeAntigravityLimitsCache(limits, { home, nowMs = Date.now() } = {})
|
|
|
1463
1497
|
if (!limits?.configured || limits.error || !hasAntigravityWindow(limits)) return;
|
|
1464
1498
|
const payload = {
|
|
1465
1499
|
antigravity: {
|
|
1500
|
+
process_scope: ANTIGRAVITY_CACHE_PROCESS_SCOPE,
|
|
1466
1501
|
account_email: limits.account_email || null,
|
|
1467
1502
|
account_plan: limits.account_plan || null,
|
|
1468
1503
|
primary_window: limits.primary_window || null,
|
|
@@ -1899,7 +1934,20 @@ function withPlanLabel(obj, raw, brand) {
|
|
|
1899
1934
|
return { ...obj, plan_label: normalizePlanLabel(raw, brand) };
|
|
1900
1935
|
}
|
|
1901
1936
|
|
|
1902
|
-
async function getUsageLimits({
|
|
1937
|
+
async function getUsageLimits(options = {}) {
|
|
1938
|
+
const nowMs = Date.now();
|
|
1939
|
+
if (cache.data && nowMs - cache.fetchedAt < CACHE_TTL_MS) {
|
|
1940
|
+
return cache.data;
|
|
1941
|
+
}
|
|
1942
|
+
// Past the cache there is exactly one thing to do, and it is expensive: sweep
|
|
1943
|
+
// every configured provider. Concurrent tabs, route mounts and revalidations
|
|
1944
|
+
// land here together on a cold cache, and a forced refresh clears the cache
|
|
1945
|
+
// first (local-api.js, the `refresh` param) — so "no cached data" is a common
|
|
1946
|
+
// state for several callers at once, not a rare one. They share one sweep.
|
|
1947
|
+
return runUsageLimitsFetch(() => fetchUsageLimits(options));
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
async function fetchUsageLimits({
|
|
1903
1951
|
home,
|
|
1904
1952
|
env,
|
|
1905
1953
|
platform,
|
|
@@ -1911,9 +1959,10 @@ async function getUsageLimits({
|
|
|
1911
1959
|
providerTimeoutMs = DEFAULT_PROVIDER_TIMEOUT_MS,
|
|
1912
1960
|
} = {}) {
|
|
1913
1961
|
const nowMs = Date.now();
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1962
|
+
// The stale-Codex refresh is part of the same provider sweep and must share
|
|
1963
|
+
// its abort bound. Creating this before the serial prelude prevents one raw
|
|
1964
|
+
// OAuth fetch from holding the process-wide single-flight slot forever.
|
|
1965
|
+
const providerFetch = withFetchTimeout(fetchImpl, providerTimeoutMs);
|
|
1917
1966
|
|
|
1918
1967
|
const [claudeToken, claudeSubscription, codexAuth] = await Promise.all([
|
|
1919
1968
|
Promise.resolve().then(() => readClaudeCodeAccessToken({ platform, securityRunner, home })),
|
|
@@ -1933,7 +1982,7 @@ async function getUsageLimits({
|
|
|
1933
1982
|
try {
|
|
1934
1983
|
const newTokens = await refreshCodexTokens({
|
|
1935
1984
|
refreshToken: codexAuth.refreshToken,
|
|
1936
|
-
fetchImpl,
|
|
1985
|
+
fetchImpl: providerFetch,
|
|
1937
1986
|
});
|
|
1938
1987
|
const updatedAuth = await persistRefreshedAuth(
|
|
1939
1988
|
codexAuth.authPath,
|
|
@@ -1956,7 +2005,6 @@ async function getUsageLimits({
|
|
|
1956
2005
|
const codexAccountId = codexAuthRefreshed?.accountId || null;
|
|
1957
2006
|
const codexPlanType = codexAuthRefreshed?.planType || null;
|
|
1958
2007
|
|
|
1959
|
-
const providerFetch = withFetchTimeout(fetchImpl, providerTimeoutMs);
|
|
1960
2008
|
const [claudeResult, codexResult, cursor, kimi, zai, gemini, kiro, antigravity, copilot] = await Promise.all([
|
|
1961
2009
|
claudeToken
|
|
1962
2010
|
? withProviderTimeout(fetchClaudeUsageLimits(claudeToken, { fetchImpl: providerFetch, maxAttempts: 1 }), "Claude", providerTimeoutMs).then(
|
|
@@ -2057,8 +2105,20 @@ function resetUsageLimitsCache() {
|
|
|
2057
2105
|
cache = { data: null, fetchedAt: 0 };
|
|
2058
2106
|
}
|
|
2059
2107
|
|
|
2108
|
+
// The force protocol for the quota cache, in one place because two handlers
|
|
2109
|
+
// speak it: the CLI local API and the Vite dev middleware. They used to carry
|
|
2110
|
+
// their own copy of the same comparison, which is how one of them quietly stops
|
|
2111
|
+
// honouring a spelling — and a force that is silently downgraded to a cached
|
|
2112
|
+
// read looks exactly like a working one. `1` is what the dashboard client sends
|
|
2113
|
+
// (dashboard/src/lib/api.ts); `true` is what a human types into the URL bar.
|
|
2114
|
+
// Deliberately exact: no trimming, no case folding, no truthiness.
|
|
2115
|
+
function isForcedRefresh(value) {
|
|
2116
|
+
return value === "1" || value === "true";
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2060
2119
|
module.exports = {
|
|
2061
2120
|
getUsageLimits,
|
|
2121
|
+
isForcedRefresh,
|
|
2062
2122
|
normalizePlanLabel,
|
|
2063
2123
|
resetUsageLimitsCache,
|
|
2064
2124
|
extractGeminiOauthClientCredentials,
|