tokentracker-cli 1.5.6 → 1.5.8
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 +2 -0
- package/dashboard/dist/assets/{AchievementBadge-NKpY___e.js → AchievementBadge-Ck1DmXtu.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-9nXmSX72.js → AchievementsPage-DjvMoc2i.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-DttQciSY.js → AchievementsSection-DvTHE8Vd.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-bvWEdJbe.js → BadgeDetailModal-COa2Rd6M.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-ChuUfph8.js → BotAnimated-CeR3jSce.js} +1 -1
- package/dashboard/dist/assets/{Card-SWtLDXt1.js → Card-bQCEOe_K.js} +1 -1
- package/dashboard/dist/assets/{CloudCheckoutPage-c2fNl1Rl.js → CloudCheckoutPage-DDH-3QOd.js} +1 -1
- package/dashboard/dist/assets/{CloudPage-CfwDjZMS.js → CloudPage-RygH3R8b.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-tWgPa295.js → CommandPalette-E7YqsdAH.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-D7UaaUdM.js → CommunityStatsModal-B5heTDUE.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-BrBcKhfW.js → ConfirmModal-D3vd882N.js} +1 -1
- package/dashboard/dist/assets/{Controls-CcvBkk7W.js → Controls-CK74JkE8.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-DwTrlQZQ.js +1 -0
- package/dashboard/dist/assets/{DateRangePopover-BkN_pird.js → DateRangePopover-BxVCPuEX.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-BbUL7WwT.js → DevicePage-B6x5fBdU.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-BXO8SBFr.js → DialogClose-teXmwU1d.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-BSCcGkVT.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-Du332auj.js → DialogTitle-B3m-31sy.js} +1 -1
- package/dashboard/dist/assets/{ElectricLogo-CXLYfOt7.js → ElectricLogo-0wyvks4K.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-HSQBOc8B.js → FadeIn-C60pLKXu.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-D5tyQDGE.js → HeaderGithubStar-CCEUzMqo.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-BhidhbnD.js → HoverTooltip-D9RtbhSZ.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-WMEF1oS3.js → IpCheckPage-BCyW0SWY.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-BmO-59ZP.js → LandingPage-DCEofDST.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardPage-C2WyniEt.js → LeaderboardPage-CsTbalIm.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProBadge-DAZnMRNn.js → LeaderboardProBadge-Crun5RUJ.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfileModal-D1BxcloV.js → LeaderboardProfileModal-Bim9cms6.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-RPJDTwpG.js → LeaderboardProfilePage-D9pBE7f4.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-9ZOhKvn5.js → LimitsPage-CFhzwKzB.js} +6 -6
- package/dashboard/dist/assets/{LocalOnlyNotice-CH6w4hRw.js → LocalOnlyNotice-F_qczacX.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-D24HsRjs.js → LoginCard-CQm_T5K2.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-sLvHyi9U.js → LoginModal-BUf4mPCT.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-PIvLEw1Y.js → LoginPage-njdEUQEX.js} +1 -1
- package/dashboard/dist/assets/{PetPage-DYcw_4P1.js → PetPage-COczIU1k.js} +2 -2
- package/dashboard/dist/assets/{PopoverPopup-D7ZCWq5f.js → PopoverPopup-DxB1k4g7.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-B0kB52K-.js → ProviderIcon-BQfkoaQE.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-B_NT4Xfl.js → ResetPasswordPage-PGRVaPTN.js} +1 -1
- package/dashboard/dist/assets/{Select-BtxjjP2Q.js → Select-CQVnpoBO.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-Co1M6XRC.js → SelectItemText-DmAn7osh.js} +1 -1
- package/dashboard/dist/assets/{SelfHostPage-XadfhFnd.js → SelfHostPage-BUURA-Vt.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-ZEJyAxsQ.js → ServiceStatusPage-DuzSen0Q.js} +1 -1
- package/dashboard/dist/assets/SessionsPage-DndGq61W.js +3 -0
- package/dashboard/dist/assets/{SettingsPage-D4qHgIxn.js → SettingsPage-CHh0yqsZ.js} +3 -3
- package/dashboard/dist/assets/{SkillsPage-CkQCk2uF.js → SkillsPage-CCaYXQWN.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-Bx1vr4b0.js → TokenGalaxy-CGxUtq9x.js} +1 -1
- package/dashboard/dist/assets/{TooltipPopup-DQW8FWJO.js → TooltipPopup-B_B4qQD-.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-BMNcY3N8.js → TrendMonitor-CdRQTLZL.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-luRTge7O.js → WidgetsPage-DP_w32wh.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-B1j3WgXx.js → WrappedPage-C_DCqHUH.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-CvvpsW-U.js → __vite-browser-external-Lw3ipvYF.js} +3 -3
- package/dashboard/dist/assets/{agent-logos-DL4fdks6.js → agent-logos-DlnSKc2A.js} +1 -1
- package/dashboard/dist/assets/{arrow-left-DiOsjgdu.js → arrow-left-CjLKt_5C.js} +1 -1
- package/dashboard/dist/assets/{arrow-right-DBFXV7GU.js → arrow-right-Bh-Ezs8y.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-BIvpDQHo.js → arrow-up-right-C87CvEJ5.js} +1 -1
- package/dashboard/dist/assets/{calendar-days--psnXUPW.js → calendar-days-B5VHwV9j.js} +1 -1
- package/dashboard/dist/assets/{check-DLqbsN00.js → check-D2uclldH.js} +1 -1
- package/dashboard/dist/assets/{chevron-down-C0P0oLzP.js → chevron-down-Beacyoub.js} +1 -1
- package/dashboard/dist/assets/{cloud-checkout-NSmb7QNy.js → cloud-checkout-3Y7NDSG1.js} +1 -1
- package/dashboard/dist/assets/{composite-D7ahPHYQ.js → composite-B28yORTL.js} +1 -1
- package/dashboard/dist/assets/{copy-COD7vwd6.js → copy-C8fZJwms.js} +1 -1
- package/dashboard/dist/assets/{download-CpSUX4U-.js → download-IIXYYmyo.js} +1 -1
- package/dashboard/dist/assets/{external-link-DXUjrooT.js → external-link-BEb0dms0.js} +1 -1
- package/dashboard/dist/assets/{flame-Dl3ZT9oo.js → flame-gmfpE-PO.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-Ci49THbK.js → format-tokens-CKwy7NXi.js} +1 -1
- package/dashboard/dist/assets/{gift-DTHCN1Of.js → gift-twpInP_O.js} +1 -1
- package/dashboard/dist/assets/{icons-CB9s1e1k.js → icons-DQfkB5-5.js} +1 -1
- package/dashboard/dist/assets/{index-DFdfNKJM.js → index-ChVgw-dM.js} +1 -1
- package/dashboard/dist/assets/{index-Dozwi-Se.js → index-ClwErGKW.js} +1 -1
- package/dashboard/dist/assets/{index-BGT8ZQyn.js → index-D2-6Msd9.js} +1 -1
- package/dashboard/dist/assets/{index-DeX4t_5p.js → index-DzUxfO5e.js} +1 -1
- package/dashboard/dist/assets/{info-CqM2wmSI.js → info-BvndJDmD.js} +1 -1
- package/dashboard/dist/assets/{link-2-Byo7Rj3t.js → link-2-BhU_5JKG.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-CMxd78Na.js → loader-circle-BR_62BDE.js} +1 -1
- package/dashboard/dist/assets/{local-usage-transfer-CBjnoeck.js → local-usage-transfer-CfoyR0hM.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-Oowlk_FC.js → modifiers.esm-CnttlnRD.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-B2EgB_9q.js → monitor-smartphone-D4vzGxrq.js} +1 -1
- package/dashboard/dist/assets/{play-BSuP-UBL.js → play-BVWEKYTF.js} +1 -1
- package/dashboard/dist/assets/{plus-BWemSIuC.js → plus-B9GQ6VvV.js} +1 -1
- package/dashboard/dist/assets/{provider-display-BpzWuFDH.js → provider-display-DCFgR7FO.js} +1 -1
- package/dashboard/dist/assets/{react-cIJ4zx62.js → react-BKUD3UPr.js} +1 -1
- package/dashboard/dist/assets/{safePolygon-C_ZMtbgL.js → safePolygon-DPIi3FGf.js} +1 -1
- package/dashboard/dist/assets/{search-BK7eyNds.js → search-BBRJvYNz.js} +1 -1
- package/dashboard/dist/assets/{server-BqQHX-rd.js → server-Cg3zBK6a.js} +1 -1
- package/dashboard/dist/assets/{shield-check-CO5U6gRw.js → shield-check-BBzYplRb.js} +1 -1
- package/dashboard/dist/assets/{skills-api-CMye76ag.js → skills-api-CcAQ6fJX.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-Bvb61Lvj.js → sortable.esm-kNieGfR9.js} +1 -1
- package/dashboard/dist/assets/{store-CICV1KXb.js → store-DQpd_ZKv.js} +1 -1
- package/dashboard/dist/assets/{terminal-BKMWT1Ux.js → terminal-zj_c8_SG.js} +1 -1
- package/dashboard/dist/assets/{trash-D2Oqim4r.js → trash-Sl7eOlF7.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-CEqwmjct.js → use-community-stats-Bg-4yvQQ.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-CTuzzu3F.js → use-latest-request-guard-COF23Bfs.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-CA2BLWkY.js → use-limits-display-prefs-QOlhCOgQ.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-B54wHSj9.js → use-native-settings-CKDqyirL.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list--PU9ekxY.js → use-ordered-list-mFHPMcF_.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-B4rpC-Zt.js +1 -0
- package/dashboard/dist/assets/{use-transform-BRyR8szg.js → use-transform-CO5wZ0ML.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-sufKdfDY.js → use-usage-limits-CeKjW8jG.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-6oy7kSX8.js → useCurrency-WruYNKxG.js} +1 -1
- package/dashboard/dist/assets/{usePositioner-ZtDdVdJg.js → usePositioner-C3NGxtZa.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-tiOJ-PeE.js → useTokenFormat-ObRZ_T9W.js} +1 -1
- package/dashboard/dist/assets/{zap-Dr1dXUjN.js → zap-CyZ3Tf1D.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +1 -1
- package/src/commands/init.js +9 -0
- package/src/commands/status.js +12 -6
- package/src/commands/sync.js +22 -4
- package/src/lib/claude-backend.js +291 -0
- package/src/lib/claude-config.js +1 -1
- package/src/lib/cursor-config.js +3 -1
- package/src/lib/devin-limits.js +65 -25
- package/src/lib/diagnostics.js +3 -0
- package/src/lib/doctor.js +13 -0
- package/src/lib/local-api.js +2 -0
- package/src/lib/local-usage-transfer.js +22 -1
- package/src/lib/pricing/index.js +12 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +70 -15
- package/src/lib/session-analytics.js +20 -8
- package/src/lib/trae-cn-config.js +32 -2
- package/src/lib/usage-limits.js +601 -35
- package/dashboard/dist/assets/DashboardPage-DJSgRHGq.js +0 -1
- package/dashboard/dist/assets/DialogDescription-B1HyKP0x.js +0 -1
- package/dashboard/dist/assets/SessionsPage-Cte1taUu.js +0 -3
- package/dashboard/dist/assets/use-reduced-motion-CZ8_YWc2.js +0 -1
package/src/commands/init.js
CHANGED
|
@@ -1453,6 +1453,7 @@ function normalizePlatform(value) {
|
|
|
1453
1453
|
}
|
|
1454
1454
|
|
|
1455
1455
|
function buildNotifyHandler({ trackerDir, packageName }) {
|
|
1456
|
+
const { backendFromUrl, captureClaudeBackend, hashClaudeRange } = require("../lib/claude-backend");
|
|
1456
1457
|
// Keep this file dependency-free: Node built-ins only.
|
|
1457
1458
|
// It must never block Codex; it spawns sync in the background and exits 0.
|
|
1458
1459
|
const queueSignalPath = path.join(trackerDir, "notify.signal");
|
|
@@ -1526,6 +1527,14 @@ if (debugEnabled) {
|
|
|
1526
1527
|
} catch (_) {}
|
|
1527
1528
|
}
|
|
1528
1529
|
|
|
1530
|
+
// Capture before the throttle and before spawning sync. Never persist stdin.
|
|
1531
|
+
if (source === 'claude') {
|
|
1532
|
+
try { captureClaudeBackend(trackerDir, JSON.parse(fs.readFileSync(0, 'utf8'))); } catch (_) {}
|
|
1533
|
+
}
|
|
1534
|
+
${backendFromUrl.toString()}
|
|
1535
|
+
${hashClaudeRange.toString()}
|
|
1536
|
+
${captureClaudeBackend.toString()}
|
|
1537
|
+
|
|
1529
1538
|
// Throttle spawn: at most once per 20 seconds.
|
|
1530
1539
|
try {
|
|
1531
1540
|
const throttlePath = path.join(trackerDir, 'sync.throttle');
|
package/src/commands/status.js
CHANGED
|
@@ -856,13 +856,19 @@ async function cmdStatus(argv = []) {
|
|
|
856
856
|
// official API, so status surfaces the opt-in flag, the resolved storage
|
|
857
857
|
// path, and whether the auth blob decrypts — the three inputs a "why is my
|
|
858
858
|
// TRAE CN data missing" diagnosis needs.
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
859
|
+
// Follow junctions with stat. Only missing paths mean absent; access/IO
|
|
860
|
+
// errors must reach the auth reader and be shown as unreadable, not hidden
|
|
861
|
+
// behind existsSync's false result. Explicit overrides can also be absent.
|
|
862
862
|
const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
863
|
+
let traeCnInstalled = false;
|
|
864
|
+
if (traeCnStoragePath) {
|
|
865
|
+
try {
|
|
866
|
+
fssync.statSync(traeCnStoragePath);
|
|
867
|
+
traeCnInstalled = true;
|
|
868
|
+
} catch (error) {
|
|
869
|
+
traeCnInstalled = error?.code !== "ENOENT" && error?.code !== "ENOTDIR";
|
|
870
|
+
}
|
|
871
|
+
}
|
|
866
872
|
let traeCnAuthState = "not-signed-in";
|
|
867
873
|
if (traeCnInstalled) {
|
|
868
874
|
try {
|
package/src/commands/sync.js
CHANGED
|
@@ -185,7 +185,7 @@ const {
|
|
|
185
185
|
const { purgeProjectUsage } = require("../lib/project-usage-purge");
|
|
186
186
|
const {
|
|
187
187
|
fetchTraeCnUsageWithAuth,
|
|
188
|
-
|
|
188
|
+
readTraeCnAuthFromStorage,
|
|
189
189
|
isTraeCnUsageEnabled,
|
|
190
190
|
} = require("../lib/trae-cn-config");
|
|
191
191
|
const {
|
|
@@ -2315,9 +2315,10 @@ async function cmdSync(argv, context = {}) {
|
|
|
2315
2315
|
Math.floor((endTime - 30 * 24 * 60 * 60) / HALF_HOUR_SEC) * HALF_HOUR_SEC,
|
|
2316
2316
|
);
|
|
2317
2317
|
try {
|
|
2318
|
-
//
|
|
2319
|
-
|
|
2320
|
-
|
|
2318
|
+
// Read through junctions and let the credential-safe reader distinguish
|
|
2319
|
+
// missing auth from access/IO errors. existsSync masks both as absent.
|
|
2320
|
+
const traeCnAuth = readTraeCnAuthFromStorage({ env: process.env, home });
|
|
2321
|
+
if (traeCnAuth) {
|
|
2321
2322
|
if (progress?.enabled) {
|
|
2322
2323
|
progress.start(`Fetching TRAE Work CN usage...`);
|
|
2323
2324
|
}
|
|
@@ -4630,6 +4631,12 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) {
|
|
|
4630
4631
|
// rows whose input_tokens still include cached tokens (6-7x inflated) —
|
|
4631
4632
|
// while the local dashboard showed corrected numbers.
|
|
4632
4633
|
bucket = require("../lib/local-api").normalizeQueueRow(bucket);
|
|
4634
|
+
// Backend origins are local provenance; the current cloud accounting
|
|
4635
|
+
// schema has no backend dimension. Do not disclose gateway hosts there.
|
|
4636
|
+
if (bucket.backend_usage) {
|
|
4637
|
+
bucket = { ...bucket };
|
|
4638
|
+
delete bucket.backend_usage;
|
|
4639
|
+
}
|
|
4633
4640
|
bucketMap.set(`${source}|${model}|${hourStart}`, bucket);
|
|
4634
4641
|
linesRead += 1;
|
|
4635
4642
|
if (linesRead >= maxBuckets) break;
|
|
@@ -6372,8 +6379,10 @@ async function repairCodexRescanInflation({
|
|
|
6372
6379
|
}
|
|
6373
6380
|
}
|
|
6374
6381
|
rebuilt = {
|
|
6382
|
+
version: tmpCursors.hourly.version,
|
|
6375
6383
|
buckets: tmpCursors.hourly.buckets || {},
|
|
6376
6384
|
groupQueued: tmpCursors.hourly.groupQueued || {},
|
|
6385
|
+
modelAttributionPolicy: tmpCursors.hourly.modelAttributionPolicy,
|
|
6377
6386
|
codexHashes: Array.isArray(tmpCursors.codexHashes) ? tmpCursors.codexHashes : [],
|
|
6378
6387
|
codexCompactionResponseIds: Array.isArray(tmpCursors.codexCompactionResponseIds)
|
|
6379
6388
|
? tmpCursors.codexCompactionResponseIds
|
|
@@ -6510,6 +6519,9 @@ async function repairCodexRescanInflation({
|
|
|
6510
6519
|
// rebuilt per-file cursors (offset at EOF) so the later parse in THIS sync
|
|
6511
6520
|
// does not re-read codex (which would re-inflate project buckets).
|
|
6512
6521
|
const hourly = (cursors.hourly ||= { buckets: {}, groupQueued: {} });
|
|
6522
|
+
// Without the rebuilt schema version, the next normalization treats these
|
|
6523
|
+
// per-model keys as v1 and overwrites them all with an unknown bucket (#927).
|
|
6524
|
+
hourly.version = rebuilt.version;
|
|
6513
6525
|
hourly.buckets ||= {};
|
|
6514
6526
|
hourly.groupQueued ||= {};
|
|
6515
6527
|
for (const k of Object.keys(hourly.buckets)) {
|
|
@@ -6524,6 +6536,12 @@ async function repairCodexRescanInflation({
|
|
|
6524
6536
|
for (const [k, v] of Object.entries(rebuilt.groupQueued)) {
|
|
6525
6537
|
if (k.startsWith("codex|")) hourly.groupQueued[k] = v;
|
|
6526
6538
|
}
|
|
6539
|
+
// The rebuilt queue already uses the parser's current attribution policy.
|
|
6540
|
+
// Preserve it so the following parse does not re-emit every repaired row.
|
|
6541
|
+
hourly.modelAttributionPolicy = {
|
|
6542
|
+
...hourly.modelAttributionPolicy,
|
|
6543
|
+
codex: rebuilt.modelAttributionPolicy.codex,
|
|
6544
|
+
};
|
|
6527
6545
|
cursors.files ||= {};
|
|
6528
6546
|
for (const fp of Object.keys(cursors.files)) {
|
|
6529
6547
|
if (isCodexSessionCursorPath(fp)) delete cursors.files[fp];
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Local-only accounting provenance. Never infer historical routing from current config.
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const crypto = require("node:crypto");
|
|
5
|
+
|
|
6
|
+
function backendFromUrl(value) {
|
|
7
|
+
try {
|
|
8
|
+
if (typeof value !== "string" || !value.trim()) return { backend: "unknown" };
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
if (!["http:", "https:"].includes(url.protocol)) return { backend: "unknown" };
|
|
11
|
+
const backend_origin = url.origin;
|
|
12
|
+
return { backend: backend_origin === "https://api.anthropic.com" ? "anthropic" : "custom", backend_origin };
|
|
13
|
+
} catch { return { backend: "unknown" }; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Hash only the enclosed bytes, in bounded chunks. Persist no transcript text.
|
|
17
|
+
function hashClaudeRange(filePath, start, end) {
|
|
18
|
+
const fs = require("node:fs");
|
|
19
|
+
const hash = require("node:crypto").createHash("sha256");
|
|
20
|
+
const fd = fs.openSync(filePath, "r");
|
|
21
|
+
try {
|
|
22
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
23
|
+
for (let offset = start; offset < end;) {
|
|
24
|
+
const read = fs.readSync(fd, buffer, 0, Math.min(buffer.length, end - offset), offset);
|
|
25
|
+
if (!read) return null;
|
|
26
|
+
hash.update(buffer.subarray(0, read));
|
|
27
|
+
offset += read;
|
|
28
|
+
}
|
|
29
|
+
return hash.digest("hex");
|
|
30
|
+
} finally { fs.closeSync(fd); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Embedded verbatim in the dependency-free notify script. Read ONLY the endpoint
|
|
34
|
+
// env var; never credentials. Hash paths/session IDs and discard the hook body.
|
|
35
|
+
function captureClaudeBackend(trackerDir, payload, env = process.env) {
|
|
36
|
+
const fs = require("node:fs");
|
|
37
|
+
const path = require("node:path");
|
|
38
|
+
const crypto = require("node:crypto");
|
|
39
|
+
const event = payload?.hook_event_name;
|
|
40
|
+
if (!["SessionStart", "Stop", "SessionEnd"].includes(event)) return;
|
|
41
|
+
if (typeof payload.transcript_path !== "string" || typeof payload.session_id !== "string" || !payload.session_id) return;
|
|
42
|
+
const identity = backendFromUrl(env.ANTHROPIC_BASE_URL);
|
|
43
|
+
const dir = path.join(trackerDir, "claude-backends");
|
|
44
|
+
const file = crypto.createHash("sha256").update(path.resolve(payload.transcript_path)).digest("hex");
|
|
45
|
+
const session = crypto.createHash("sha256").update(String(payload.session_id)).digest("hex");
|
|
46
|
+
let stat;
|
|
47
|
+
try { stat = fs.statSync(payload.transcript_path); } catch {
|
|
48
|
+
if (event !== "SessionStart") return;
|
|
49
|
+
}
|
|
50
|
+
const observation = { event, session, inode: stat?.ino || 0, offset: stat?.size || 0, ...identity };
|
|
51
|
+
const anchorPath = path.join(dir, file + ".current.json");
|
|
52
|
+
let previous;
|
|
53
|
+
try { previous = JSON.parse(fs.readFileSync(anchorPath, "utf8")); } catch { /* first observation */ }
|
|
54
|
+
if (event === "Stop" && previous?.session === session && previous.event !== "SessionEnd"
|
|
55
|
+
&& previous.backend_origin && previous.backend_origin === identity.backend_origin
|
|
56
|
+
&& previous.offset < observation.offset) {
|
|
57
|
+
observation.range_hash = hashClaudeRange(payload.transcript_path, previous.offset, observation.offset);
|
|
58
|
+
}
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
60
|
+
fs.appendFileSync(path.join(dir, file + ".jsonl"), JSON.stringify(observation) + "\n", { mode: 0o600 });
|
|
61
|
+
fs.writeFileSync(anchorPath, JSON.stringify(observation), { mode: 0o600 });
|
|
62
|
+
fs.writeFileSync(path.join(trackerDir, "claude-backend-current.json"), JSON.stringify(identity), { mode: 0o600 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readLines(file) {
|
|
66
|
+
try { return fs.readFileSync(file, "utf8").split("\n").flatMap((line) => {
|
|
67
|
+
try { return [JSON.parse(line)]; } catch { return []; }
|
|
68
|
+
}); } catch { return []; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Parse shared upgrade metadata once per file revision, rather than once per
|
|
72
|
+
// transcript in a large installation. Failed reads are retried on the next call.
|
|
73
|
+
const legacyMetadataCache = new Map();
|
|
74
|
+
function readLegacyMetadata(file) {
|
|
75
|
+
const fd = fs.openSync(file, "r");
|
|
76
|
+
try {
|
|
77
|
+
// Bind the revision stamp and contents to the same open file, even if the
|
|
78
|
+
// metadata pathname is replaced while we read it.
|
|
79
|
+
const stat = fs.fstatSync(fd);
|
|
80
|
+
const stamp = `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
|
|
81
|
+
const cached = legacyMetadataCache.get(file);
|
|
82
|
+
if (cached?.stamp === stamp) return cached.value;
|
|
83
|
+
const value = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
84
|
+
legacyMetadataCache.set(file, { stamp, value });
|
|
85
|
+
if (legacyMetadataCache.size > 8) legacyMetadataCache.delete(legacyMetadataCache.keys().next().value);
|
|
86
|
+
return value;
|
|
87
|
+
} finally { fs.closeSync(fd); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Sync receives flat paths while session metadata keys ordered cross-root groups.
|
|
91
|
+
// Try the scanner's root order and its reverse, accepting ONLY an exact stored
|
|
92
|
+
// hash. Equal basenames alone never establish shared history.
|
|
93
|
+
function preserveLegacyClaudeGroups(files, trackerDir) {
|
|
94
|
+
let meta;
|
|
95
|
+
try { meta = readLegacyMetadata(path.join(trackerDir, "session.queue.jsonl.meta.json")); } catch { return; }
|
|
96
|
+
const groups = new Map();
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
if (typeof file !== "string" || !file) continue;
|
|
99
|
+
const id = path.basename(file).match(/^([0-9a-f-]{36})\.jsonl$/i)?.[1];
|
|
100
|
+
if (!id) continue;
|
|
101
|
+
const group = groups.get(id) || [];
|
|
102
|
+
if (!group.includes(file)) group.push(file);
|
|
103
|
+
groups.set(id, group);
|
|
104
|
+
}
|
|
105
|
+
for (const group of groups.values()) {
|
|
106
|
+
if (group.length < 2) continue;
|
|
107
|
+
for (const ordered of [group, [...group].reverse()]) {
|
|
108
|
+
const key = crypto.createHash("sha256").update(`claude\0${ordered.map((file) => path.resolve(file)).join("\0")}`).digest("hex").slice(0, 24);
|
|
109
|
+
const stats = meta.files?.[key]?.stat_key?.split("|");
|
|
110
|
+
if (!stats || stats.length !== ordered.length) continue;
|
|
111
|
+
ordered.forEach((file, index) => {
|
|
112
|
+
try { preserveLegacyClaudePrefix(file, trackerDir, Number(stats[index].split(":")[0])); }
|
|
113
|
+
catch (error) {
|
|
114
|
+
// Discovery can race a removed/unreadable mirror. Let the parser's
|
|
115
|
+
// normal per-file handling continue with the surviving paths.
|
|
116
|
+
if (!["ENOENT", "EACCES", "EPERM", "ESTALE"].includes(error.code)) throw error;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Preserve the already-accounted prefix when upgrading. The boundary is frozen
|
|
125
|
+
// before either scanner advances its cache; existing per-record decisions still
|
|
126
|
+
// win. Store only byte counts, file identity and a digest, never transcript text.
|
|
127
|
+
function preserveLegacyClaudePrefix(filePath, trackerDir, offset = 0) {
|
|
128
|
+
const key = crypto.createHash("sha256").update(path.resolve(filePath)).digest("hex");
|
|
129
|
+
const dir = path.join(trackerDir, "claude-backends");
|
|
130
|
+
const boundaryPath = path.join(dir, key + ".legacy.json");
|
|
131
|
+
try { return JSON.parse(fs.readFileSync(boundaryPath, "utf8")); } catch { /* first scan */ }
|
|
132
|
+
const stat = fs.statSync(filePath);
|
|
133
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > stat.size) offset = 0;
|
|
134
|
+
try {
|
|
135
|
+
const cursor = readLegacyMetadata(path.join(trackerDir, "cursors.json"))?.files?.[filePath];
|
|
136
|
+
if (cursor?.inode === stat.ino && cursor.offset <= stat.size) offset = Math.max(offset, cursor.offset || 0);
|
|
137
|
+
} catch { /* no previously ingested queue */ }
|
|
138
|
+
try {
|
|
139
|
+
const cacheKey = crypto.createHash("sha256").update(`claude\0${path.resolve(filePath)}`).digest("hex").slice(0, 24);
|
|
140
|
+
const meta = readLegacyMetadata(path.join(trackerDir, "session.queue.jsonl.meta.json"));
|
|
141
|
+
const size = Number(meta.files?.[cacheKey]?.stat_key?.split(":")[0]);
|
|
142
|
+
if (size <= stat.size) offset = Math.max(offset, size || 0);
|
|
143
|
+
} catch { /* no previously ingested session */ }
|
|
144
|
+
offset = Math.min(Math.max(0, offset), stat.size);
|
|
145
|
+
const boundary = { inode: stat.ino, offset, hash: hashClaudeRange(filePath, 0, offset) };
|
|
146
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
147
|
+
try { fs.writeFileSync(boundaryPath, JSON.stringify(boundary), { mode: 0o600, flag: "wx" }); }
|
|
148
|
+
catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
149
|
+
return JSON.parse(fs.readFileSync(boundaryPath, "utf8"));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createBackendResolver(filePath, trackerDir, { legacyOffset = 0 } = {}) {
|
|
153
|
+
const key = crypto.createHash("sha256").update(path.resolve(filePath)).digest("hex");
|
|
154
|
+
const dir = path.join(trackerDir, "claude-backends");
|
|
155
|
+
const ledger = path.join(dir, key + ".usage.jsonl");
|
|
156
|
+
const decisions = new Map();
|
|
157
|
+
let decisionOffset = 0;
|
|
158
|
+
function refreshDecisions() {
|
|
159
|
+
let fd;
|
|
160
|
+
try {
|
|
161
|
+
fd = fs.openSync(ledger, "r");
|
|
162
|
+
const size = fs.fstatSync(fd).size;
|
|
163
|
+
if (size <= decisionOffset) return;
|
|
164
|
+
const bytes = Buffer.alloc(size - decisionOffset);
|
|
165
|
+
fs.readSync(fd, bytes, 0, bytes.length, decisionOffset);
|
|
166
|
+
// Only consume complete append records, including another scanner's.
|
|
167
|
+
const boundary = bytes.lastIndexOf(10) + 1;
|
|
168
|
+
for (const line of bytes.subarray(0, boundary).toString("utf8").split("\n")) {
|
|
169
|
+
try {
|
|
170
|
+
const row = JSON.parse(line);
|
|
171
|
+
if (!decisions.has(row.id)) decisions.set(row.id, row);
|
|
172
|
+
} catch { /* incomplete/corrupt record */ }
|
|
173
|
+
}
|
|
174
|
+
decisionOffset += boundary;
|
|
175
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
176
|
+
finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
177
|
+
}
|
|
178
|
+
refreshDecisions();
|
|
179
|
+
const observations = readLines(path.join(dir, key + ".jsonl"));
|
|
180
|
+
const fileStat = fs.statSync(filePath);
|
|
181
|
+
const inode = fileStat.ino;
|
|
182
|
+
const legacy = preserveLegacyClaudePrefix(filePath, trackerDir, legacyOffset);
|
|
183
|
+
const legacyEnd = legacy.inode === inode && legacy.offset <= fileStat.size
|
|
184
|
+
&& hashClaudeRange(filePath, 0, legacy.offset) === legacy.hash ? legacy.offset : 0;
|
|
185
|
+
const intervals = [];
|
|
186
|
+
let previous = null;
|
|
187
|
+
for (const current of observations) {
|
|
188
|
+
if (current.event === "Stop" && previous && previous.event !== "SessionEnd"
|
|
189
|
+
&& current.session === previous.session && current.inode === inode
|
|
190
|
+
&& (previous.inode === inode || (previous.event === "SessionStart" && previous.offset === 0))
|
|
191
|
+
&& current.offset > previous.offset && current.offset <= fileStat.size) {
|
|
192
|
+
const a = backendFromUrl(previous.backend_origin);
|
|
193
|
+
const b = backendFromUrl(current.backend_origin);
|
|
194
|
+
if (a.backend_origin && a.backend_origin === b.backend_origin) {
|
|
195
|
+
intervals.push({ start: previous.offset, end: current.offset, session: current.session, hash: current.range_hash, ...a });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
previous = current;
|
|
199
|
+
}
|
|
200
|
+
// A missed Stop must not bridge multiple turns. Only inspect ranges actually
|
|
201
|
+
// bracketed by observations; no prompts or message bodies leave this function.
|
|
202
|
+
function validInterval(interval) {
|
|
203
|
+
if (interval.valid !== undefined) return interval.valid;
|
|
204
|
+
interval.valid = false;
|
|
205
|
+
const fd = fs.openSync(filePath, "r");
|
|
206
|
+
try {
|
|
207
|
+
const bytes = Buffer.alloc(interval.end - interval.start);
|
|
208
|
+
fs.readSync(fd, bytes, 0, bytes.length, interval.start);
|
|
209
|
+
if (!interval.hash || crypto.createHash("sha256").update(bytes).digest("hex") !== interval.hash) return false;
|
|
210
|
+
let turns = 0;
|
|
211
|
+
for (const line of bytes.toString("utf8").split("\n")) {
|
|
212
|
+
try {
|
|
213
|
+
const row = JSON.parse(line);
|
|
214
|
+
const content = row.message?.content;
|
|
215
|
+
if (row.type === "user" && (typeof content === "string" || content?.some?.((b) => b.type === "text"))) turns++;
|
|
216
|
+
} catch { /* partial boundary / non-JSON */ }
|
|
217
|
+
}
|
|
218
|
+
interval.valid = turns === 1;
|
|
219
|
+
return interval.valid;
|
|
220
|
+
} finally { fs.closeSync(fd); }
|
|
221
|
+
}
|
|
222
|
+
return (obj, start, end) => {
|
|
223
|
+
const id = crypto.createHash("sha256").update(JSON.stringify([
|
|
224
|
+
obj.sessionId, obj.uuid, obj.requestId, obj.message?.id, obj.timestamp, start, end,
|
|
225
|
+
])).digest("hex");
|
|
226
|
+
const saved = decisions.get(id);
|
|
227
|
+
if (saved) return saved.legacy ? null : backendFromUrl(saved.backend_origin);
|
|
228
|
+
const session = typeof obj.sessionId === "string"
|
|
229
|
+
? crypto.createHash("sha256").update(obj.sessionId).digest("hex") : null;
|
|
230
|
+
const matches = intervals.filter((i) => i.session === session && start >= i.start && end <= i.end && validInterval(i));
|
|
231
|
+
const identity = end <= legacyEnd ? { legacy: true } : matches.length === 1 ? backendFromUrl(matches[0].backend_origin) : { backend: "unknown" };
|
|
232
|
+
// First ingestion wins, including unknown. Late hooks cannot relabel usage.
|
|
233
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
234
|
+
fs.appendFileSync(ledger, JSON.stringify({ id, ...identity }) + "\n", { mode: 0o600 });
|
|
235
|
+
// Sync and session analytics can run concurrently. O_APPEND orders these
|
|
236
|
+
// small records; read that order so the first durable decision always wins.
|
|
237
|
+
refreshDecisions();
|
|
238
|
+
return decisions.get(id)?.legacy ? null : backendFromUrl(decisions.get(id)?.backend_origin);
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Byte offsets, including CRLF and multibyte text, must agree with hook stat sizes.
|
|
243
|
+
async function* claudeLines(filePath, start = 0, end = undefined) {
|
|
244
|
+
const input = fs.createReadStream(filePath, { start, ...(end > start ? { end: end - 1 } : {}) });
|
|
245
|
+
let pending = Buffer.alloc(0);
|
|
246
|
+
let offset = start;
|
|
247
|
+
for await (const chunk of input) {
|
|
248
|
+
pending = Buffer.concat([pending, chunk]);
|
|
249
|
+
let newline;
|
|
250
|
+
while ((newline = pending.indexOf(10)) !== -1) {
|
|
251
|
+
const next = offset + newline + 1;
|
|
252
|
+
yield { line: pending.subarray(0, newline).toString("utf8"), start: offset, end: next };
|
|
253
|
+
offset = next;
|
|
254
|
+
pending = pending.subarray(newline + 1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Preserve complete legacy files without a final newline; defer partial JSON.
|
|
258
|
+
if (pending.length) {
|
|
259
|
+
try { JSON.parse(pending.toString("utf8")); } catch { return; }
|
|
260
|
+
yield { line: pending.toString("utf8"), start: offset, end: offset + pending.length };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const TOKEN_FIELDS = ["input_tokens", "cached_input_tokens", "cache_creation_input_tokens", "output_tokens", "reasoning_output_tokens", "total_tokens"];
|
|
265
|
+
function mergeBackendUsage(target, delta) {
|
|
266
|
+
if (!Array.isArray(delta?.backend_usage)) return;
|
|
267
|
+
target.backend_usage ||= [];
|
|
268
|
+
for (const part of delta.backend_usage) {
|
|
269
|
+
const identity = backendFromUrl(part.backend_origin);
|
|
270
|
+
let row = target.backend_usage.find((r) => r.backend === identity.backend && r.backend_origin === identity.backend_origin);
|
|
271
|
+
if (!row) { row = { ...identity }; target.backend_usage.push(row); }
|
|
272
|
+
for (const field of TOKEN_FIELDS) row[field] = (row[field] || 0) + (Number(part[field]) || 0);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function hasUnknownBackendCost(row) {
|
|
276
|
+
return row?.source === "claude" && row.backend_usage?.some((p) => p.backend !== "anthropic" && p.total_tokens > 0);
|
|
277
|
+
}
|
|
278
|
+
function readCustomBackend({ home, env = process.env }) {
|
|
279
|
+
// Current configuration is ONLY for the limits card, never usage attribution.
|
|
280
|
+
let configured = env.ANTHROPIC_BASE_URL;
|
|
281
|
+
if (!configured) {
|
|
282
|
+
try { configured = JSON.parse(fs.readFileSync(path.join(env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), "settings.json"), "utf8"))?.env?.ANTHROPIC_BASE_URL; } catch { /* absent */ }
|
|
283
|
+
}
|
|
284
|
+
if (configured) return backendFromUrl(configured);
|
|
285
|
+
try {
|
|
286
|
+
const observed = JSON.parse(fs.readFileSync(path.join(home, ".tokentracker", "tracker", "claude-backend-current.json"), "utf8"));
|
|
287
|
+
return backendFromUrl(observed.backend_origin);
|
|
288
|
+
} catch { return { backend: "unknown" }; }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = { preserveLegacyClaudeGroups, preserveLegacyClaudePrefix, hashClaudeRange, claudeLines, backendFromUrl, captureClaudeBackend, createBackendResolver, mergeBackendUsage, hasUnknownBackendCost, readCustomBackend, TOKEN_FIELDS };
|
package/src/lib/claude-config.js
CHANGED
|
@@ -5,7 +5,7 @@ const { ensureDir, readJson, writeJson } = require("./fs");
|
|
|
5
5
|
const { buildNotifyCommand, notifyIdentity } = require("./notify-command");
|
|
6
6
|
|
|
7
7
|
const DEFAULT_EVENT = "SessionEnd";
|
|
8
|
-
const CLAUDE_USAGE_EVENTS = ["Stop", DEFAULT_EVENT];
|
|
8
|
+
const CLAUDE_USAGE_EVENTS = ["SessionStart", "Stop", DEFAULT_EVENT];
|
|
9
9
|
|
|
10
10
|
async function upsertClaudeHook({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
|
|
11
11
|
return upsertClaudeHooks({ settingsPath, hookCommand, events: [event] });
|
package/src/lib/cursor-config.js
CHANGED
|
@@ -97,7 +97,9 @@ function extractCursorAuth({ home, platform, env, deps } = {}) {
|
|
|
97
97
|
return null;
|
|
98
98
|
}
|
|
99
99
|
const jwt = readCursorAccessTokenFromStateDb(stateDbPath, deps);
|
|
100
|
-
|
|
100
|
+
// Encrypted Cursor Safe Storage values must not be treated as JWTs and must
|
|
101
|
+
// never trigger OS keychain decryption (see #1029 revert / #1043).
|
|
102
|
+
if (!jwt || jwt.length < 10 || jwt.startsWith('cursor-safe-storage:')) return null;
|
|
101
103
|
|
|
102
104
|
// 2. Extract userId — try cli-config.json first, fall back to JWT decode
|
|
103
105
|
let userId = extractUserIdFromCliConfig(cliConfigPath);
|
package/src/lib/devin-limits.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require("node:fs");
|
|
18
18
|
const path = require("node:path");
|
|
19
|
+
const { parse: parseToml } = require("smol-toml");
|
|
19
20
|
|
|
20
21
|
const DEVIN_PLAN_STATUS_URL =
|
|
21
22
|
"https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetPlanStatus";
|
|
@@ -28,25 +29,26 @@ function isNonEmptyString(value) {
|
|
|
28
29
|
return typeof value === "string" && value.trim().length > 0;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (trimmed.startsWith("[")) break;
|
|
39
|
-
const match = /^([A-Za-z0-9_]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))\s*(?:#.*)?$/.exec(
|
|
40
|
-
trimmed,
|
|
41
|
-
);
|
|
42
|
-
if (!match) continue;
|
|
43
|
-
const [, key, doubleQuoted, singleQuoted, bare] = match;
|
|
44
|
-
fields[key] = doubleQuoted ?? singleQuoted ?? bare ?? "";
|
|
45
|
-
}
|
|
46
|
-
return fields;
|
|
32
|
+
// Local sign-in problems are persistent configuration errors, not transient
|
|
33
|
+
// fetch failures: the aggregator's disk-cache fallback must never mask them,
|
|
34
|
+
// so they carry a distinctive code the assembly can gate on.
|
|
35
|
+
function credentialsError(message) {
|
|
36
|
+
const error = new Error(message);
|
|
37
|
+
error.code = "CREDENTIALS_INVALID";
|
|
38
|
+
return error;
|
|
47
39
|
}
|
|
48
40
|
|
|
49
|
-
//
|
|
41
|
+
// A credential-file revision, not a token fingerprint or account claim. Both
|
|
42
|
+
// in-place edits (ctime/mtime) and atomic replacements (device/inode) invalidate
|
|
43
|
+
// old windows, including across restarts. Never persist the token or its hash.
|
|
44
|
+
function devinCredentialIdentity(filePath, stat) {
|
|
45
|
+
return JSON.stringify([
|
|
46
|
+
path.resolve(filePath),
|
|
47
|
+
...[stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].map(String),
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Returns { apiKey, cacheIdentity } or null when no sign-in exists (no home, or the file is
|
|
50
52
|
// absent — ENOENT only). Any other read failure or a mangled file throws an
|
|
51
53
|
// owned, actionable error with no path or filesystem details, so a broken
|
|
52
54
|
// sign-in can never masquerade as "not configured".
|
|
@@ -64,20 +66,42 @@ function readDevinCredentials({ home, env = process.env } = {}) {
|
|
|
64
66
|
? path.join(dir, "devin", "credentials.toml")
|
|
65
67
|
: path.join(dir, ".local", "share", "devin", "credentials.toml");
|
|
66
68
|
let raw;
|
|
69
|
+
let descriptor;
|
|
70
|
+
let cacheIdentity = null;
|
|
67
71
|
try {
|
|
68
|
-
|
|
72
|
+
// Read the token and revision from the same opened file, so an atomic
|
|
73
|
+
// replacement cannot stamp the old token with the new file's revision.
|
|
74
|
+
descriptor = fs.openSync(base, "r");
|
|
75
|
+
const before = fs.fstatSync(descriptor, { bigint: true });
|
|
76
|
+
if (!before.isFile()) throw new Error("Not a credentials file");
|
|
77
|
+
raw = fs.readFileSync(descriptor, "utf8");
|
|
78
|
+
const after = fs.fstatSync(descriptor, { bigint: true });
|
|
79
|
+
const identity = devinCredentialIdentity(base, before);
|
|
80
|
+
if (identity === devinCredentialIdentity(base, after)) cacheIdentity = identity;
|
|
69
81
|
} catch (error) {
|
|
70
82
|
if (error && error.code === "ENOENT") return null;
|
|
71
|
-
throw
|
|
83
|
+
throw credentialsError(
|
|
72
84
|
"Could not read the Devin CLI credentials file — check its permissions or run `devin auth login` to sign in again.",
|
|
73
85
|
);
|
|
86
|
+
} finally {
|
|
87
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
88
|
+
}
|
|
89
|
+
// Decode the credential document, then select only the root-level auth
|
|
90
|
+
// fields, like the other providers' structured credential readers.
|
|
91
|
+
let fields;
|
|
92
|
+
try {
|
|
93
|
+
fields = parseToml(raw);
|
|
94
|
+
} catch {
|
|
95
|
+
// Parser errors may quote credentials; expose only an owned message.
|
|
96
|
+
throw credentialsError(
|
|
97
|
+
"Could not parse the Devin CLI credentials file — run `devin auth login` to sign in again.",
|
|
98
|
+
);
|
|
74
99
|
}
|
|
75
|
-
const fields = parseDevinCredentialsToml(raw);
|
|
76
100
|
const apiKey = isNonEmptyString(fields.windsurf_api_key)
|
|
77
101
|
? fields.windsurf_api_key.trim()
|
|
78
102
|
: null;
|
|
79
103
|
if (!apiKey) {
|
|
80
|
-
throw
|
|
104
|
+
throw credentialsError(
|
|
81
105
|
"Devin credentials file has no sign-in token — run `devin auth login`.",
|
|
82
106
|
);
|
|
83
107
|
}
|
|
@@ -85,11 +109,20 @@ function readDevinCredentials({ home, env = process.env } = {}) {
|
|
|
85
109
|
? fields.api_server_url.trim().replace(/\/+$/, "")
|
|
86
110
|
: null;
|
|
87
111
|
if (apiServerUrl && apiServerUrl !== DEVIN_API_ORIGIN) {
|
|
88
|
-
throw
|
|
112
|
+
throw credentialsError(
|
|
89
113
|
"Devin credentials point at a custom api_server_url, which this quota provider does not support.",
|
|
90
114
|
);
|
|
91
115
|
}
|
|
92
|
-
return { apiKey };
|
|
116
|
+
return { apiKey, cacheIdentity };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveDevinCacheIdentity(options) {
|
|
120
|
+
try {
|
|
121
|
+
return readDevinCredentials(options)?.cacheIdentity || null;
|
|
122
|
+
} catch {
|
|
123
|
+
// Unreadable, missing or invalid current credentials must fail closed.
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
93
126
|
}
|
|
94
127
|
|
|
95
128
|
function malformed(field) {
|
|
@@ -226,9 +259,14 @@ async function fetchDevinLimits({ home, env = process.env, enabled = false, fetc
|
|
|
226
259
|
throw error;
|
|
227
260
|
}
|
|
228
261
|
if (!response?.ok) {
|
|
229
|
-
|
|
262
|
+
const error = new Error(
|
|
230
263
|
`Devin quota request was rejected (HTTP ${response?.status ?? "?"}).`,
|
|
231
264
|
);
|
|
265
|
+
// The aggregate's disk-cache fallback only covers transient failures:
|
|
266
|
+
// carrying the status lets it tell a 429/5xx blip from an ambiguous or
|
|
267
|
+
// persistent rejection like HTTP 400 (docs/devin-limits.md).
|
|
268
|
+
if (typeof response?.status === "number") error.statusCode = response.status;
|
|
269
|
+
throw error;
|
|
232
270
|
}
|
|
233
271
|
let body;
|
|
234
272
|
try {
|
|
@@ -243,7 +281,9 @@ async function fetchDevinLimits({ home, env = process.env, enabled = false, fetc
|
|
|
243
281
|
plan_label: windows.plan_label,
|
|
244
282
|
primary_window: windows.primary_window,
|
|
245
283
|
secondary_window: windows.secondary_window,
|
|
284
|
+
// Internal: the aggregate strips this before returning its payload.
|
|
285
|
+
cache_identity: credentials.cacheIdentity,
|
|
246
286
|
};
|
|
247
287
|
}
|
|
248
288
|
|
|
249
|
-
module.exports = { fetchDevinLimits };
|
|
289
|
+
module.exports = { fetchDevinLimits, resolveDevinCacheIdentity };
|
package/src/lib/diagnostics.js
CHANGED
|
@@ -20,6 +20,7 @@ const { probeOpenclawSessionPluginState } = require("./openclaw-session-plugin")
|
|
|
20
20
|
const { probeGrokHookState } = require("./grok-hook");
|
|
21
21
|
const { resolveTrackerPaths } = require("./tracker-paths");
|
|
22
22
|
const { resolveDeviceTokenPresence } = require("./cloud-device-token");
|
|
23
|
+
const { inspectTraeCnAuth } = require("./trae-cn-config");
|
|
23
24
|
const wsl = require("./wsl-probe");
|
|
24
25
|
// TASK-011: Kiro paths inlined here to avoid pulling the ~4000-line
|
|
25
26
|
// rollout module on every `tokentracker status` / `diagnostics` call.
|
|
@@ -195,6 +196,7 @@ async function collectTrackerDiagnostics({
|
|
|
195
196
|
? new Date(uploadThrottle.lastSuccessMs).toISOString()
|
|
196
197
|
: null;
|
|
197
198
|
const autoRetryAt = parseEpochMsToIso(autoRetry?.retryAtMs);
|
|
199
|
+
const traeCn = inspectTraeCnAuth({ home });
|
|
198
200
|
|
|
199
201
|
return {
|
|
200
202
|
ok: true,
|
|
@@ -233,6 +235,7 @@ async function collectTrackerDiagnostics({
|
|
|
233
235
|
merge_policy:
|
|
234
236
|
"Kiro IDE and Kiro CLI both emit source='kiro' in queue.jsonl so token, cost, heatmap, and leaderboard aggregations merge transparently. Use this block to distinguish sub-path contributions.",
|
|
235
237
|
},
|
|
238
|
+
trae_cn: { ...traeCn, storage_path: redactValue(traeCn.storage_path, home) },
|
|
236
239
|
scan_roots: {
|
|
237
240
|
codex: describeScanRoots(scanRoots.codex),
|
|
238
241
|
claude: describeScanRoots(scanRoots.claude),
|
package/src/lib/doctor.js
CHANGED
|
@@ -293,6 +293,19 @@ async function checkNetwork({ baseUrl, fetch }) {
|
|
|
293
293
|
|
|
294
294
|
function buildDiagnosticsChecks(diagnostics) {
|
|
295
295
|
const checks = [];
|
|
296
|
+
const traeCn = diagnostics?.trae_cn;
|
|
297
|
+
if (traeCn) {
|
|
298
|
+
const { TRAE_CN_AUTH_HELP } = require("./trae-cn-config");
|
|
299
|
+
checks.push({
|
|
300
|
+
id: "trae_cn.auth",
|
|
301
|
+
status: !traeCn.usage_opt_in || traeCn.auth === "readable" ? "ok" : "warn",
|
|
302
|
+
detail: !traeCn.usage_opt_in
|
|
303
|
+
? "usage sync off (TOKENTRACKER_TRAE_CN_USAGE=1 opts in); auth not read; no TRAE request made"
|
|
304
|
+
: `local auth ${traeCn.auth}; API access not checked (${traeCn.storage_path || "storage.json not found"}). ${TRAE_CN_AUTH_HELP}`,
|
|
305
|
+
critical: false,
|
|
306
|
+
meta: traeCn,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
296
309
|
const notify = diagnostics?.notify || {};
|
|
297
310
|
const notifyConfigured = Boolean(
|
|
298
311
|
notify.codex_notify_configured ||
|
package/src/lib/local-api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const { hasUnknownBackendCost } = require("./claude-backend");
|
|
1
2
|
const fs = require("node:fs");
|
|
2
3
|
const os = require("node:os");
|
|
3
4
|
const path = require("node:path");
|
|
@@ -2942,6 +2943,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
|
|
|
2942
2943
|
if (!sa.models.has(mdl))
|
|
2943
2944
|
sa.models.set(mdl, { model: mdl, model_id: mdl, pricing: getModelPricingInfo(mdl, { source: src }), totals: { total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, conversation_count: 0, total_cost_usd: "0" } });
|
|
2944
2945
|
const ma = sa.models.get(mdl);
|
|
2946
|
+
if (hasUnknownBackendCost(row)) ma.pricing = getModelPricingInfo(mdl, row);
|
|
2945
2947
|
const reportedCost = ["grok", "cline"].includes(src) && Number(row.total_cost_usd) > 0;
|
|
2946
2948
|
const costSource = reportedCost ? "provider_reported" : "model_pricing";
|
|
2947
2949
|
ma.cost_source = ma.cost_source && ma.cost_source !== costSource ? "mixed" : costSource;
|