tokentracker-cli 0.93.6 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +1 -1
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dashboard/dist/assets/{AchievementBadge-DsAclDw6.js → AchievementBadge-DPqwk2zI.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-D__EwC45.js → AchievementsPage-CELHoMQt.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-BAZNM3Cn.js → AchievementsSection-DdAC0VCb.js} +1 -1
- package/dashboard/dist/assets/BadgeDetailModal-Dd4I1SpM.js +1 -0
- package/dashboard/dist/assets/{BotAnimated-CwrScgrI.js → BotAnimated-DL2-4mnm.js} +1 -1
- package/dashboard/dist/assets/{Card-D7GmLoqP.js → Card-B_MpSRse.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-BEXgb5J_.js → ClawdAnimated-BLZZ4rG6.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-B9Mn3Qt2.js → CommandPalette-B-vfdIbH.js} +1 -1
- package/dashboard/dist/assets/CommunityStatsModal-aAico565.js +1 -0
- package/dashboard/dist/assets/ConfirmModal-DLnRwUXC.js +1 -0
- package/dashboard/dist/assets/{Controls-DxnaHBzv.js → Controls-FZ8wEN5w.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-BByRBsbt.js +1 -0
- package/dashboard/dist/assets/{DevicePage-4yTYf29M.js → DevicePage-D2IzPc5o.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-BbderFCY.js → DialogClose-BaqQaUUk.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-BUrh0Jod.js +1 -0
- package/dashboard/dist/assets/DialogTitle-DrbKjtuS.js +1 -0
- package/dashboard/dist/assets/{FadeIn-BFLojPGG.js → FadeIn-GddKDE8i.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-CPBNn61n.js → HeaderGithubStar-DUu-Z5mE.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-DbxnLj8e.js → HoverTooltip-BmDkY35A.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-CfPtXAAF.js → IpCheckPage-CGJyqWk2.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-CocswfFj.js → LandingPage-Bd91ALUj.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-iZLxxZX-.js → LeaderboardAvatar-BGGUwXFd.js} +1 -1
- package/dashboard/dist/assets/LeaderboardPage-BMEqHwif.js +2 -0
- package/dashboard/dist/assets/{LeaderboardProfileModal-BWu8jbBW.js → LeaderboardProfileModal-BSweJqm2.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-B77gOTzr.js → LeaderboardProfilePage-CRrI3RFz.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-XIRDv46K.js +5 -0
- package/dashboard/dist/assets/LocalOnlyNotice-CCD-lGWG.js +1 -0
- package/dashboard/dist/assets/{LoginCard-D592L-fh.js → LoginCard-lDuqSv4f.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-C7Qm7CVf.js → LoginModal-DStt5VXN.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-DkYw5ZQK.js → LoginPage-dmVPSL72.js} +1 -1
- package/dashboard/dist/assets/PetPage-ybcZRctF.js +1 -0
- package/dashboard/dist/assets/PopoverPopup-Dar_UjxF.js +1 -0
- package/dashboard/dist/assets/{ProviderIcon-d0_vhIkS.js → ProviderIcon-Ct9tILpz.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-UDNlpFVu.js → ResetPasswordPage-B4yLh5zx.js} +1 -1
- package/dashboard/dist/assets/SegmentedControl-BOE1CEzq.js +1 -0
- package/dashboard/dist/assets/{Select-Z2xTQ55f.js → Select-D-bSh7Zz.js} +1 -1
- package/dashboard/dist/assets/SelectItemText-DMYjXXq4.js +1 -0
- package/dashboard/dist/assets/{ServiceStatusPage-Cfr-QDZ7.js → ServiceStatusPage-CaleS-6_.js} +1 -1
- package/dashboard/dist/assets/SessionsPage-DOaY6_E9.js +2 -0
- package/dashboard/dist/assets/SettingsPage-iJH0Yyah.js +2 -0
- package/dashboard/dist/assets/SkillsPage-BkzJOBLk.js +1 -0
- package/dashboard/dist/assets/{TokenGalaxy-CjAY6rey.js → TokenGalaxy-CuXKoMPg.js} +1 -1
- package/dashboard/dist/assets/ToolbarRootContext-Dx5GB7F5.js +1 -0
- package/dashboard/dist/assets/{TrendMonitor-DAAlILwJ.js → TrendMonitor-BuKmsznP.js} +1 -1
- package/dashboard/dist/assets/WidgetsPage-DUaIyGtu.js +1 -0
- package/dashboard/dist/assets/{WrappedPage-ByYzOeZ-.js → WrappedPage-BzqlcAUC.js} +1 -1
- package/dashboard/dist/assets/__vite-browser-external-BHz2N7Ih.css +1 -0
- package/dashboard/dist/assets/{__vite-browser-external-HdCplPKs.js → __vite-browser-external-CVIGknXl.js} +3 -3
- package/dashboard/dist/assets/{agent-logos-BW-ajJ7p.js → agent-logos-BdWWxza6.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-CA5IJkqo.js → arrow-up-right-CANSzmm-.js} +1 -1
- package/dashboard/dist/assets/{check-BBjF3bSm.js → check-BnJpzXRZ.js} +1 -1
- package/dashboard/dist/assets/{chevron-right-DCvGp9iJ.js → chevron-right-DJm0mbG0.js} +1 -1
- package/dashboard/dist/assets/{useScrollLock-BUVUIPTX.js → composite-Ccj6KQmh.js} +1 -1
- package/dashboard/dist/assets/{copy-ahF8LGeu.js → copy-C2Yc8Evu.js} +1 -1
- package/dashboard/dist/assets/{download-DBm8dmvt.js → download-B52JTk6Y.js} +1 -1
- package/dashboard/dist/assets/{external-link-C1sJSL7J.js → external-link-DayTwZx-.js} +1 -1
- package/dashboard/dist/assets/{flame-B6xukEYd.js → flame-B8GEX2yT.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-6TqXlT3q.js → format-tokens-mhkbohkb.js} +1 -1
- package/dashboard/dist/assets/{icons-pGe_pInY.js → icons-o8bPXqRP.js} +1 -1
- package/dashboard/dist/assets/{index-I9O8xChC.js → index-Bp0ZHDtz.js} +1 -1
- package/dashboard/dist/assets/{index-Chb8mb2X.js → index-CK_CduLm.js} +1 -1
- package/dashboard/dist/assets/{index-RPV0zdZ9.js → index-MBVWUKpA.js} +1 -1
- package/dashboard/dist/assets/{info-DBfJv0yu.js → info-D94QM_vX.js} +1 -1
- package/dashboard/dist/assets/{limits-providers-DPQsdVq5.js → limits-providers-BLzMyCiy.js} +1 -1
- package/dashboard/dist/assets/{link-2-DNEARrPY.js → link-2-CqBmFaDI.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-BCFHUr8X.js → loader-circle-SaxUpDR6.js} +1 -1
- package/dashboard/dist/assets/{maximize-2-1EmlA2R-.js → maximize-2-BoXqARks.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-DGXFSuEq.js → modifiers.esm-Conh_-BD.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-DsPPhvsT.js → monitor-smartphone-D6qLR9qd.js} +1 -1
- package/dashboard/dist/assets/plus-D51mjIc2.js +1 -0
- package/dashboard/dist/assets/{provider-display-C20LtBC5.js → provider-display-DpHPjd_s.js} +1 -1
- package/dashboard/dist/assets/{react-B4FPJgs_.js → react-D_AcI466.js} +1 -1
- package/dashboard/dist/assets/{search-mkIFPKzW.js → search-CwLcNdvZ.js} +1 -1
- package/dashboard/dist/assets/{skills-api-D24vlu2H.js → skills-api-Dqi7Mjgo.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-BNDcP0sW.js → sortable.esm-BPDlUxPb.js} +1 -1
- package/dashboard/dist/assets/store-ikeRIRGd.js +1 -0
- package/dashboard/dist/assets/{terminal-BxVMmvEx.js → terminal-BfeX3L5T.js} +1 -1
- package/dashboard/dist/assets/{trash-2-crt9lDvj.js → trash-2-5_Qlp1k7.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-CTB_uHWV.js → use-community-stats-D90Fy2_j.js} +1 -1
- package/dashboard/dist/assets/use-limits-display-prefs-D4BYFaa-.js +1 -0
- package/dashboard/dist/assets/{use-ordered-list-BVNb5QWU.js → use-ordered-list-DJxLNdXY.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-yE3T3cat.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-Di8nY6Lr.js → use-session-efficiency-pref-CAX_GqXc.js} +1 -1
- package/dashboard/dist/assets/{use-transform-Dx9C2nBC.js → use-transform-CeW6Z5R0.js} +1 -1
- package/dashboard/dist/assets/use-usage-limits-5HP8k9a3.js +1 -0
- package/dashboard/dist/assets/{useCurrency-7dkDciam.js → useCurrency-BarGeFyK.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-DFSIAsAb.js → useTokenFormat-DUJ9iT9H.js} +1 -1
- package/dashboard/dist/assets/{zap-Png-VJSP.js → zap--m0IWoI3.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/ip-check.html +2 -2
- package/dashboard/dist/leaderboard.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +1 -1
- package/src/commands/status.js +3 -1
- package/src/commands/sync.js +8 -3
- package/src/lib/codex-rollout-parser.js +35 -4
- package/src/lib/cursor-config.js +182 -1
- package/src/lib/fs.js +12 -3
- package/src/lib/local-api.js +57 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +758 -195
- package/src/lib/session-analytics.js +205 -10
- package/src/lib/subscription-manager.js +293 -0
- package/src/lib/telemetry.js +2 -2
- package/src/lib/usage-limits.js +55 -14
- package/dashboard/dist/assets/BadgeDetailModal-Dgeer5oB.js +0 -1
- package/dashboard/dist/assets/CommunityStatsModal-eJ_a1FMd.js +0 -1
- package/dashboard/dist/assets/ConfirmModal-CumOr2J1.js +0 -1
- package/dashboard/dist/assets/DashboardPage-BpRRXCRr.js +0 -1
- package/dashboard/dist/assets/DialogDescription-C5Ujj254.js +0 -1
- package/dashboard/dist/assets/DialogTitle-6epeLIn0.js +0 -1
- package/dashboard/dist/assets/LeaderboardPage-yqauB4Q1.js +0 -2
- package/dashboard/dist/assets/LimitsPage-C9NeIcft.js +0 -5
- package/dashboard/dist/assets/LocalOnlyNotice-HPXWU8TT.js +0 -1
- package/dashboard/dist/assets/PetPage-BOhvAJp4.js +0 -1
- package/dashboard/dist/assets/PopoverPopup-BK9aqQn1.js +0 -1
- package/dashboard/dist/assets/SegmentedControl-DE5m1pTf.js +0 -1
- package/dashboard/dist/assets/SelectItemText-Daq-XRZg.js +0 -1
- package/dashboard/dist/assets/SessionsPage-nHPj6IAt.js +0 -2
- package/dashboard/dist/assets/SettingsPage-NvxUG_9m.js +0 -2
- package/dashboard/dist/assets/SkillsPage-zFbZa42f.js +0 -1
- package/dashboard/dist/assets/ToolbarRootContext-DnC8LT_8.js +0 -1
- package/dashboard/dist/assets/WidgetsPage-CXsfi-mq.js +0 -1
- package/dashboard/dist/assets/__vite-browser-external-CiR9-j4L.css +0 -1
- package/dashboard/dist/assets/store-B2nqaAqP.js +0 -1
- package/dashboard/dist/assets/use-limits-display-prefs-DXXZpjGY.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-aSaQtoJu.js +0 -1
- package/dashboard/dist/assets/use-usage-limits-D9vQFB1H.js +0 -1
|
@@ -54,7 +54,9 @@ const wsl = require("./wsl-probe");
|
|
|
54
54
|
// stay valid while Grok entries appear on the next full rebuild.
|
|
55
55
|
// v11 groups native/WSL copies of one logical Claude/Codex session and scans
|
|
56
56
|
// their union, deduping shared records while retaining divergent tails.
|
|
57
|
-
|
|
57
|
+
// v12 retains Codex parent/subagent metadata locally and derives exact child
|
|
58
|
+
// token totals from observed child rollouts instead of spawn-call estimates.
|
|
59
|
+
const SIDECAR_VERSION = 12;
|
|
58
60
|
const EDIT_TOOLS = new Set([
|
|
59
61
|
"apply_patch",
|
|
60
62
|
"edit",
|
|
@@ -511,6 +513,18 @@ async function scanCodexSession(filePath) {
|
|
|
511
513
|
version: SIDECAR_VERSION,
|
|
512
514
|
session_hash: sessionHash("codex", parsed.sessionId || primaryFilePath),
|
|
513
515
|
session_id: parsed.sessionId || null,
|
|
516
|
+
// Local-only lineage. Codex v2 normally writes forked_from_id; keep
|
|
517
|
+
// parent_thread_id as a compatibility fallback, but reject disagreement.
|
|
518
|
+
parent_session_id: parsed.forkedFromId || parsed.parentThreadId || null,
|
|
519
|
+
parent_link_conflict: Boolean(
|
|
520
|
+
parsed.forkedFromId
|
|
521
|
+
&& parsed.parentThreadId
|
|
522
|
+
&& parsed.forkedFromId !== parsed.parentThreadId
|
|
523
|
+
),
|
|
524
|
+
forked_from_id: parsed.forkedFromId || null,
|
|
525
|
+
thread_source: typeof parsed.threadSource === "string" ? parsed.threadSource : null,
|
|
526
|
+
agent_nickname: typeof parsed.agentNickname === "string" ? parsed.agentNickname : null,
|
|
527
|
+
agent_role: typeof parsed.agentRole === "string" ? parsed.agentRole : null,
|
|
514
528
|
// Local-only: stripped in summarizeSessions before any cloud/CSV export.
|
|
515
529
|
title,
|
|
516
530
|
source: "codex",
|
|
@@ -829,7 +843,14 @@ function providerRoots(home, providerDir, env, deps = {}) {
|
|
|
829
843
|
const discoverWslHome = deps.discoverWslHome || wsl.discoverWslHome;
|
|
830
844
|
const roots = [];
|
|
831
845
|
if (platform !== "win32" || wsl.shouldProbeNative(env)) {
|
|
832
|
-
|
|
846
|
+
const useProcessCodexHome = providerDir === ".codex"
|
|
847
|
+
&& path.resolve(home) === path.resolve(homedir())
|
|
848
|
+
&& typeof env?.CODEX_HOME === "string"
|
|
849
|
+
&& env.CODEX_HOME.trim();
|
|
850
|
+
const nativeRoot = useProcessCodexHome
|
|
851
|
+
? path.resolve(env.CODEX_HOME.trim())
|
|
852
|
+
: path.join(home, providerDir);
|
|
853
|
+
roots.push(nativeRoot);
|
|
833
854
|
}
|
|
834
855
|
// Only the machine's own home has a WSL sibling worth probing.
|
|
835
856
|
// discoverWslHome resolves \\wsl$ independently of `home`, so probing for an
|
|
@@ -1029,10 +1050,15 @@ function sessionFileStatKey(filePath) {
|
|
|
1029
1050
|
const codexTitleIndexCache = new Map();
|
|
1030
1051
|
|
|
1031
1052
|
function codexTitleIndexPathFor(filePath) {
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1053
|
+
let current = path.dirname(path.resolve(filePath));
|
|
1054
|
+
while (true) {
|
|
1055
|
+
if (["sessions", "archived_sessions"].includes(path.basename(current))) {
|
|
1056
|
+
return path.join(path.dirname(current), "session_index.jsonl");
|
|
1057
|
+
}
|
|
1058
|
+
const parent = path.dirname(current);
|
|
1059
|
+
if (parent === current) return null;
|
|
1060
|
+
current = parent;
|
|
1061
|
+
}
|
|
1036
1062
|
}
|
|
1037
1063
|
|
|
1038
1064
|
function loadCodexTitleIndex(filePath) {
|
|
@@ -1239,7 +1265,9 @@ function withinDayRange(row, from, to) {
|
|
|
1239
1265
|
}
|
|
1240
1266
|
|
|
1241
1267
|
function summarizeSessions(sessions, { from = "", to = "", includeSessions = true } = {}) {
|
|
1242
|
-
const filtered = (sessions || [])
|
|
1268
|
+
const filtered = annotateCodexThreadUsage((sessions || [])
|
|
1269
|
+
.filter((row) => withinDayRange(row, from, to))
|
|
1270
|
+
.map((row) => ({ ...row })));
|
|
1243
1271
|
const byModel = new Map();
|
|
1244
1272
|
const subagents = new Map();
|
|
1245
1273
|
for (const row of filtered) {
|
|
@@ -1268,6 +1296,26 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
|
|
|
1268
1296
|
agg.edit_cost_usd += finite(row.cost_usd);
|
|
1269
1297
|
}
|
|
1270
1298
|
byModel.set(key, agg);
|
|
1299
|
+
if (row.source === "codex" && row.parent_session_hash) {
|
|
1300
|
+
const name = row.agent_role || row.agent_nickname || "subagent";
|
|
1301
|
+
const sub = subagents.get(name) || {
|
|
1302
|
+
name,
|
|
1303
|
+
calls: 0,
|
|
1304
|
+
sessions: 0,
|
|
1305
|
+
total_tokens: 0,
|
|
1306
|
+
cost_usd: 0,
|
|
1307
|
+
attribution: "observed-child-session",
|
|
1308
|
+
};
|
|
1309
|
+
sub.calls += 1;
|
|
1310
|
+
sub.sessions += 1;
|
|
1311
|
+
sub.total_tokens += finite(row.total_tokens);
|
|
1312
|
+
sub.cost_usd += finite(row.cost_usd);
|
|
1313
|
+
subagents.set(name, sub);
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
// Codex child rollouts are first-class rows. A spawn call in the parent is
|
|
1317
|
+
// a control-plane event, not token usage, so do not mix in an estimate.
|
|
1318
|
+
if (row.source === "codex") continue;
|
|
1271
1319
|
for (const [name, calls] of Object.entries(row.subagent_types || {})) {
|
|
1272
1320
|
const sub = subagents.get(name) || { name, calls: 0, sessions: 0, total_tokens: 0, cost_usd: 0 };
|
|
1273
1321
|
sub.calls += finite(calls);
|
|
@@ -1301,7 +1349,22 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
|
|
|
1301
1349
|
// Git attribution and the local-only session browser, but never leave the
|
|
1302
1350
|
// Node process through API/CSV payloads.
|
|
1303
1351
|
sessions: includeSessions
|
|
1304
|
-
? filtered.map(({
|
|
1352
|
+
? filtered.map(({
|
|
1353
|
+
project_ref: _projectRef,
|
|
1354
|
+
session_id: _sessionId,
|
|
1355
|
+
parent_session_id: _parentSessionId,
|
|
1356
|
+
forked_from_id: _forkedFromId,
|
|
1357
|
+
parent_session_hash: _parentSessionHash,
|
|
1358
|
+
root_session_hash: _rootSessionHash,
|
|
1359
|
+
agent_nickname: _agentNickname,
|
|
1360
|
+
agent_role: _agentRole,
|
|
1361
|
+
thread_source: _threadSource,
|
|
1362
|
+
parent_link_conflict: _parentLinkConflict,
|
|
1363
|
+
orphaned_subagent: _orphanedSubagent,
|
|
1364
|
+
title: _title,
|
|
1365
|
+
_cache_key: _cacheKey,
|
|
1366
|
+
...row
|
|
1367
|
+
}) => row)
|
|
1305
1368
|
: [],
|
|
1306
1369
|
session_count: filtered.length,
|
|
1307
1370
|
summary: {
|
|
@@ -1350,6 +1413,14 @@ function toSessionBrowserRow(row) {
|
|
|
1350
1413
|
return {
|
|
1351
1414
|
session_hash: row.session_hash,
|
|
1352
1415
|
session_id: row.session_id || null,
|
|
1416
|
+
parent_session_id: row.parent_session_id || null,
|
|
1417
|
+
parent_session_hash: row.parent_session_hash || null,
|
|
1418
|
+
root_session_hash: row.root_session_hash || row.session_hash,
|
|
1419
|
+
thread_kind: row.parent_session_id ? "subagent" : "root",
|
|
1420
|
+
agent_nickname: row.agent_nickname || null,
|
|
1421
|
+
agent_role: row.agent_role || null,
|
|
1422
|
+
orphaned_subagent: Boolean(row.orphaned_subagent),
|
|
1423
|
+
parent_link_conflict: Boolean(row.parent_link_conflict),
|
|
1353
1424
|
title: row.title || null,
|
|
1354
1425
|
source: row.source,
|
|
1355
1426
|
project_key: row.project_key,
|
|
@@ -1364,7 +1435,15 @@ function toSessionBrowserRow(row) {
|
|
|
1364
1435
|
edit_turns: finite(row.edit_turns),
|
|
1365
1436
|
retry_turns: finite(row.retry_turns),
|
|
1366
1437
|
subagent_calls: finite(row.subagent_calls),
|
|
1438
|
+
direct_subagent_count: finite(row.direct_subagent_count),
|
|
1439
|
+
descendant_subagent_count: finite(row.descendant_subagent_count),
|
|
1440
|
+
own_total_tokens: finite(row.total_tokens),
|
|
1441
|
+
subagent_total_tokens: finite(row.subagent_total_tokens),
|
|
1442
|
+
combined_total_tokens: finite(row.combined_total_tokens || row.total_tokens),
|
|
1367
1443
|
total_tokens: finite(row.total_tokens),
|
|
1444
|
+
own_cost_usd: finite(row.cost_usd),
|
|
1445
|
+
subagent_cost_usd: finite(row.subagent_cost_usd),
|
|
1446
|
+
combined_cost_usd: finite(row.combined_cost_usd || row.cost_usd),
|
|
1368
1447
|
cost_usd: finite(row.cost_usd),
|
|
1369
1448
|
productive: Boolean(row.productive),
|
|
1370
1449
|
first_pass: Boolean(row.first_pass ?? row.one_shot),
|
|
@@ -1396,6 +1475,14 @@ function mergeSessionFragments(rows) {
|
|
|
1396
1475
|
cur.productive = Boolean(cur.productive) || Boolean(row.productive);
|
|
1397
1476
|
cur.active_ms = finite(cur.active_ms) + finite(row.active_ms);
|
|
1398
1477
|
if (!cur.title && row.title) cur.title = row.title;
|
|
1478
|
+
if (!cur.parent_session_id && row.parent_session_id) cur.parent_session_id = row.parent_session_id;
|
|
1479
|
+
if (cur.parent_session_id && row.parent_session_id && cur.parent_session_id !== row.parent_session_id) {
|
|
1480
|
+
cur.parent_session_id = null;
|
|
1481
|
+
cur.parent_link_conflict = true;
|
|
1482
|
+
}
|
|
1483
|
+
if (!cur.forked_from_id && row.forked_from_id) cur.forked_from_id = row.forked_from_id;
|
|
1484
|
+
if (!cur.agent_nickname && row.agent_nickname) cur.agent_nickname = row.agent_nickname;
|
|
1485
|
+
if (!cur.agent_role && row.agent_role) cur.agent_role = row.agent_role;
|
|
1399
1486
|
// Representative model/project comes from the busiest fragment (most
|
|
1400
1487
|
// tokens) so a tiny sidechain cannot overwrite the real coding model.
|
|
1401
1488
|
if (finite(row.total_tokens) > finite(cur._repr_tokens)) {
|
|
@@ -1427,19 +1514,127 @@ function mergeSessionFragments(rows) {
|
|
|
1427
1514
|
return merged;
|
|
1428
1515
|
}
|
|
1429
1516
|
|
|
1517
|
+
// Attach exact Codex parent/child usage without changing the authoritative
|
|
1518
|
+
// per-session total_tokens field. Combined fields are derived views only, so
|
|
1519
|
+
// summing total_tokens across all rows still equals the global usage ledger.
|
|
1520
|
+
function annotateCodexThreadUsage(rows) {
|
|
1521
|
+
const byId = new Map();
|
|
1522
|
+
const childrenById = new Map();
|
|
1523
|
+
const linkedParentById = new Map();
|
|
1524
|
+
|
|
1525
|
+
for (const row of rows || []) {
|
|
1526
|
+
if (row?.source !== "codex" || typeof row.session_id !== "string" || !row.session_id) continue;
|
|
1527
|
+
const existing = byId.get(row.session_id);
|
|
1528
|
+
if (existing && existing !== row) {
|
|
1529
|
+
existing.parent_link_conflict = true;
|
|
1530
|
+
row.parent_link_conflict = true;
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
byId.set(row.session_id, row);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function hasParentCycle(start) {
|
|
1537
|
+
const seen = new Set();
|
|
1538
|
+
let current = start;
|
|
1539
|
+
while (current?.parent_session_id && byId.has(current.parent_session_id)) {
|
|
1540
|
+
if (seen.has(current.session_id) || current.parent_session_id === current.session_id) return true;
|
|
1541
|
+
seen.add(current.session_id);
|
|
1542
|
+
current = byId.get(current.parent_session_id);
|
|
1543
|
+
}
|
|
1544
|
+
return false;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
for (const row of byId.values()) {
|
|
1548
|
+
const parentId = typeof row.parent_session_id === "string" ? row.parent_session_id.trim() : "";
|
|
1549
|
+
if (!parentId) continue;
|
|
1550
|
+
if (row.parent_link_conflict || hasParentCycle(row)) {
|
|
1551
|
+
row.parent_link_conflict = true;
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
if (!byId.has(parentId)) {
|
|
1555
|
+
row.orphaned_subagent = true;
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
const children = childrenById.get(parentId) || [];
|
|
1559
|
+
children.push(row);
|
|
1560
|
+
childrenById.set(parentId, children);
|
|
1561
|
+
linkedParentById.set(row.session_id, parentId);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
const memo = new Map();
|
|
1565
|
+
function aggregate(row, visiting = new Set()) {
|
|
1566
|
+
if (memo.has(row.session_id)) return memo.get(row.session_id);
|
|
1567
|
+
if (visiting.has(row.session_id)) {
|
|
1568
|
+
row.parent_link_conflict = true;
|
|
1569
|
+
return { count: 0, tokens: 0, cost: 0 };
|
|
1570
|
+
}
|
|
1571
|
+
const nextVisiting = new Set(visiting);
|
|
1572
|
+
nextVisiting.add(row.session_id);
|
|
1573
|
+
let count = 0;
|
|
1574
|
+
let tokens = 0;
|
|
1575
|
+
let cost = 0;
|
|
1576
|
+
for (const child of childrenById.get(row.session_id) || []) {
|
|
1577
|
+
const nested = aggregate(child, nextVisiting);
|
|
1578
|
+
count += 1 + nested.count;
|
|
1579
|
+
tokens += finite(child.total_tokens) + nested.tokens;
|
|
1580
|
+
cost += finite(child.cost_usd) + nested.cost;
|
|
1581
|
+
}
|
|
1582
|
+
const result = { count, tokens, cost };
|
|
1583
|
+
memo.set(row.session_id, result);
|
|
1584
|
+
return result;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function rootFor(row) {
|
|
1588
|
+
const seen = new Set([row.session_id]);
|
|
1589
|
+
let current = row;
|
|
1590
|
+
while (current && linkedParentById.has(current.session_id)) {
|
|
1591
|
+
const linkedParentId = linkedParentById.get(current.session_id);
|
|
1592
|
+
if (seen.has(linkedParentId)) {
|
|
1593
|
+
row.parent_link_conflict = true;
|
|
1594
|
+
break;
|
|
1595
|
+
}
|
|
1596
|
+
seen.add(linkedParentId);
|
|
1597
|
+
current = byId.get(linkedParentId);
|
|
1598
|
+
}
|
|
1599
|
+
return current || row;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
for (const row of rows || []) {
|
|
1603
|
+
row.own_total_tokens = finite(row.total_tokens);
|
|
1604
|
+
row.own_cost_usd = finite(row.cost_usd);
|
|
1605
|
+
row.direct_subagent_count = row.source === "codex" && row.session_id
|
|
1606
|
+
? (childrenById.get(row.session_id) || []).length
|
|
1607
|
+
: 0;
|
|
1608
|
+
const nested = row.source === "codex" && row.session_id
|
|
1609
|
+
? aggregate(row)
|
|
1610
|
+
: { count: 0, tokens: 0, cost: 0 };
|
|
1611
|
+
row.descendant_subagent_count = nested.count;
|
|
1612
|
+
row.subagent_total_tokens = nested.tokens;
|
|
1613
|
+
row.subagent_cost_usd = nested.cost;
|
|
1614
|
+
row.combined_total_tokens = finite(row.total_tokens) + nested.tokens;
|
|
1615
|
+
row.combined_cost_usd = finite(row.cost_usd) + nested.cost;
|
|
1616
|
+
if (row.source === "codex" && row.session_id) {
|
|
1617
|
+
const parent = byId.get(linkedParentById.get(row.session_id));
|
|
1618
|
+
row.parent_session_hash = parent?.session_hash || null;
|
|
1619
|
+
row.root_session_hash = rootFor(row).session_hash || row.session_hash;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return rows;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1430
1625
|
// Local-only session list for the dashboard session browser. Unlike
|
|
1431
1626
|
// summarizeSessions (cloud/CSV safe), this retains session_id + project_ref so
|
|
1432
1627
|
// the UI can offer one-click resume. Callers must only expose it over the
|
|
1433
1628
|
// local API.
|
|
1434
1629
|
function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0 } = {}) {
|
|
1435
|
-
const filtered = mergeSessionFragments(sessions)
|
|
1630
|
+
const filtered = annotateCodexThreadUsage(mergeSessionFragments(sessions)
|
|
1436
1631
|
// Only sessions that actually spent tokens are worth listing. This drops
|
|
1437
1632
|
// two kinds of noise: non-session logs under ~/.claude/projects
|
|
1438
1633
|
// (skill-injections.jsonl, journal.jsonl, …), and sessions abandoned before
|
|
1439
1634
|
// the model replied. Both render as "unknown · 0 tokens · $0.00" rows with
|
|
1440
1635
|
// no model, no cost and nothing to analyze.
|
|
1441
1636
|
.filter((row) => finite(row.total_tokens) > 0)
|
|
1442
|
-
.filter((row) => withinDayRange(row, from, to));
|
|
1637
|
+
.filter((row) => withinDayRange(row, from, to)));
|
|
1443
1638
|
filtered.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
|
|
1444
1639
|
const cap = Number(limit) > 0 ? Number(limit) : 0;
|
|
1445
1640
|
const limited = cap > 0 ? filtered.slice(0, cap) : filtered;
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
const fs = require("node:fs/promises");
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
|
|
5
|
+
const { readJsonStrict, writeFileAtomic, chmod600IfPossible } = require("./fs");
|
|
6
|
+
|
|
7
|
+
// Manual subscription manager: user-entered billing plans (service, plan,
|
|
8
|
+
// auto-renew flag, next renewal/expiry timestamp). Distinct from
|
|
9
|
+
// subscriptions.js (auto-detected local plan tiers) and usage-limits.js
|
|
10
|
+
// (rate-limit window resets) — see issue #460.
|
|
11
|
+
const STORE_FILE = "subscription-manager.json";
|
|
12
|
+
const STORE_VERSION = 1;
|
|
13
|
+
const MAX_TEXT_LENGTH = 120;
|
|
14
|
+
const CYCLE_UNITS = new Set(["weekly", "monthly", "yearly"]);
|
|
15
|
+
|
|
16
|
+
function resolveSubscriptionsPath(trackerDir) {
|
|
17
|
+
return path.join(trackerDir, STORE_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeText(value, { required, field, maxLength = MAX_TEXT_LENGTH } = {}) {
|
|
21
|
+
if (value === null || value === undefined) {
|
|
22
|
+
if (required) throw new Error(`${field} is required`);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (typeof value !== "string") throw new Error(`${field} must be a string`);
|
|
26
|
+
const trimmed = value.trim();
|
|
27
|
+
if (!trimmed) {
|
|
28
|
+
if (required) throw new Error(`${field} is required`);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (trimmed.length > maxLength) {
|
|
32
|
+
throw new Error(`${field} must be at most ${maxLength} characters`);
|
|
33
|
+
}
|
|
34
|
+
return trimmed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Accepts epoch milliseconds or any Date-parseable string. Stored values are
|
|
38
|
+
// UTC ISO strings truncated to whole minutes so comparisons stay clean and a
|
|
39
|
+
// second-level drift between input formats never changes the billed minute.
|
|
40
|
+
function normalizeBillingTime(value, { field = "nextBillingAt", required = true } = {}) {
|
|
41
|
+
let ms;
|
|
42
|
+
if (value === null || value === undefined) {
|
|
43
|
+
if (required) throw new Error(`${field} is required`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (typeof value === "number") {
|
|
47
|
+
ms = value;
|
|
48
|
+
} else if (typeof value === "string") {
|
|
49
|
+
const trimmed = value.trim();
|
|
50
|
+
if (!trimmed) {
|
|
51
|
+
if (required) throw new Error(`${field} is required`);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
ms = new Date(trimmed).getTime();
|
|
55
|
+
} else {
|
|
56
|
+
throw new Error(`${field} is required`);
|
|
57
|
+
}
|
|
58
|
+
if (!Number.isFinite(ms)) throw new Error(`${field} must be a valid date`);
|
|
59
|
+
return new Date(Math.floor(ms / 60000) * 60000).toISOString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Billing cycle length. Drives the progress bar span and how far an
|
|
63
|
+
// auto-renew subscription rolls forward after a recorded renewal passes.
|
|
64
|
+
// Defaults to monthly so pre-cycle records keep their original semantics.
|
|
65
|
+
function normalizeCycle(value) {
|
|
66
|
+
if (value === null || value === undefined) return "monthly";
|
|
67
|
+
if (typeof value !== "string" || !CYCLE_UNITS.has(value)) {
|
|
68
|
+
throw new Error("cycle must be one of: weekly, monthly, yearly");
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeSubscriptionFields(fields) {
|
|
74
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields)) {
|
|
75
|
+
throw new Error("subscription must be an object");
|
|
76
|
+
}
|
|
77
|
+
if (typeof fields.autoRenew !== "boolean") {
|
|
78
|
+
throw new Error("autoRenew must be a boolean");
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
service: normalizeText(fields.service, { required: true, field: "service" }),
|
|
82
|
+
plan: normalizeText(fields.plan ?? null, { required: false, field: "plan" }),
|
|
83
|
+
// Optional link to a usage-limits provider row (e.g. "codex"). The value
|
|
84
|
+
// is a limits provider id but is not validated against the canonical list
|
|
85
|
+
// here — the backend stays lenient, the dropdown constrains it in the UI.
|
|
86
|
+
provider: normalizeText(fields.provider ?? null, {
|
|
87
|
+
required: false,
|
|
88
|
+
field: "provider",
|
|
89
|
+
maxLength: 64,
|
|
90
|
+
}),
|
|
91
|
+
autoRenew: fields.autoRenew,
|
|
92
|
+
cycle: normalizeCycle(fields.cycle),
|
|
93
|
+
nextBillingAt: normalizeBillingTime(fields.nextBillingAt),
|
|
94
|
+
// Billing anchor: the day the subscription started. The dashboard derives
|
|
95
|
+
// every cycle boundary from this date, so month-end anchors (Jan 31) stay
|
|
96
|
+
// stable instead of drifting after the first clamped month. Optional:
|
|
97
|
+
// records created before the field existed fall back to a derived anchor.
|
|
98
|
+
startedAt:
|
|
99
|
+
fields.startedAt === undefined
|
|
100
|
+
? null
|
|
101
|
+
: normalizeBillingTime(fields.startedAt, { field: "startedAt", required: false }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isValidStoredRecord(record) {
|
|
106
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) return false;
|
|
107
|
+
if (typeof record.id !== "string" || !record.id) return false;
|
|
108
|
+
if (typeof record.service !== "string" || !record.service) return false;
|
|
109
|
+
if (typeof record.autoRenew !== "boolean") return false;
|
|
110
|
+
if (typeof record.nextBillingAt !== "string") return false;
|
|
111
|
+
return Number.isFinite(new Date(record.nextBillingAt).getTime());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Read the store. Any state other than a clean file or a plainly missing one
|
|
115
|
+
// must never collapse into an empty store on its own: the next write would
|
|
116
|
+
// silently destroy every stored subscription (user-entered data that cannot
|
|
117
|
+
// be rebuilt from logs). Reads surface the damage as an error; only the write
|
|
118
|
+
// path may recover, and only after backing the original file up.
|
|
119
|
+
async function readStore(filePath, { forWrite = false } = {}) {
|
|
120
|
+
const { status, value, error } = await readJsonStrict(filePath);
|
|
121
|
+
if (status === "missing") {
|
|
122
|
+
return { store: { version: STORE_VERSION, items: [] }, needsBackup: false };
|
|
123
|
+
}
|
|
124
|
+
if (status === "error") {
|
|
125
|
+
// Unreadable (permissions, I/O error): refuse — a blind overwrite would
|
|
126
|
+
// destroy data we were never able to see.
|
|
127
|
+
throw new Error(`Cannot read subscription store: ${error?.message || String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
if (status === "invalid" || !value || typeof value !== "object" || Array.isArray(value)) {
|
|
130
|
+
if (!forWrite) {
|
|
131
|
+
throw new Error("Subscription store is corrupted (invalid JSON)");
|
|
132
|
+
}
|
|
133
|
+
return { store: { version: STORE_VERSION, items: [] }, needsBackup: true };
|
|
134
|
+
}
|
|
135
|
+
if (!Array.isArray(value.items)) {
|
|
136
|
+
// Valid JSON object without a usable items array: nothing recoverable
|
|
137
|
+
// inside, so an empty store is honest. If the key exists with a wrong
|
|
138
|
+
// type, keep the file backed up before a write replaces it.
|
|
139
|
+
return {
|
|
140
|
+
store: { version: STORE_VERSION, items: [] },
|
|
141
|
+
needsBackup: value.items !== undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const items = [];
|
|
145
|
+
let dropped = false;
|
|
146
|
+
for (const item of value.items) {
|
|
147
|
+
if (isValidStoredRecord(item)) {
|
|
148
|
+
items.push(item);
|
|
149
|
+
} else {
|
|
150
|
+
dropped = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const version = Number.isFinite(value.version) ? value.version : STORE_VERSION;
|
|
154
|
+
return { store: { version, items }, needsBackup: dropped };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A write is about to replace a store that contained unreadable data — keep
|
|
158
|
+
// a copy around first so nothing is lost without a trace. Copy (not rename):
|
|
159
|
+
// if the replacement write then fails, the canonical path must still hold the
|
|
160
|
+
// damaged original, otherwise the next write would treat it as missing and
|
|
161
|
+
// start from an empty store.
|
|
162
|
+
async function backupDamagedStore(filePath) {
|
|
163
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
164
|
+
try {
|
|
165
|
+
await fs.copyFile(filePath, `${filePath}.corrupt-${stamp}`);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (e?.code !== "ENOENT") {
|
|
168
|
+
throw new Error(`Cannot back up the damaged subscription store: ${e?.message || String(e)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function loadStoreForWrite(filePath) {
|
|
174
|
+
const { store, needsBackup } = await readStore(filePath, { forWrite: true });
|
|
175
|
+
if (needsBackup) await backupDamagedStore(filePath);
|
|
176
|
+
return store;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function writeStore(filePath, store) {
|
|
180
|
+
// Private from creation: the 0o600 applies to the tmp file before the
|
|
181
|
+
// rename, so a crash before the fallback chmod cannot leave a
|
|
182
|
+
// world-readable store behind.
|
|
183
|
+
await writeFileAtomic(filePath, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
|
|
184
|
+
await chmod600IfPossible(filePath);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sortByNextBillingAt(items) {
|
|
188
|
+
return [...items].sort((a, b) =>
|
|
189
|
+
new Date(a.nextBillingAt).getTime() - new Date(b.nextBillingAt).getTime(),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// One subscription per tool: the dashboard derives the subscription's name
|
|
194
|
+
// and inline progress row from the linked provider, so two records sharing a
|
|
195
|
+
// provider cannot be displayed. Enforced inside the store lock's
|
|
196
|
+
// read-modify-write transaction — the frontend picker cannot cover concurrent
|
|
197
|
+
// requests, stale pages, or direct API calls. `excludeId` lets an update
|
|
198
|
+
// re-save its own provider.
|
|
199
|
+
function assertProviderAvailable(items, provider, excludeId = null) {
|
|
200
|
+
if (!provider) return;
|
|
201
|
+
const conflict = items.find(
|
|
202
|
+
(item) => item.provider === provider && item.id !== excludeId,
|
|
203
|
+
);
|
|
204
|
+
if (conflict) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Provider "${provider}" already has a subscription (${conflict.id})`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// In-process FIFO queue per store file. Every mutation is a full
|
|
212
|
+
// read-modify-write transaction; without serialization two concurrent writes
|
|
213
|
+
// read the same snapshot and the later one silently drops the earlier change
|
|
214
|
+
// (issue: 30 concurrent creates survived as a single record). The queue keeps
|
|
215
|
+
// independent store files unblocked while ordering operations on the same one.
|
|
216
|
+
const storeLocks = new Map();
|
|
217
|
+
|
|
218
|
+
function withStoreLock(filePath, operation) {
|
|
219
|
+
const previous = storeLocks.get(filePath) || Promise.resolve();
|
|
220
|
+
// Run even if the previous transaction rejected; its error already reached
|
|
221
|
+
// its own caller and must not wedge the queue.
|
|
222
|
+
const run = previous.then(operation, operation);
|
|
223
|
+
storeLocks.set(filePath, run.then(() => undefined, () => undefined));
|
|
224
|
+
return run;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function listSubscriptions({ trackerDir }) {
|
|
228
|
+
const { store } = await readStore(resolveSubscriptionsPath(trackerDir));
|
|
229
|
+
return sortByNextBillingAt(store.items);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function createSubscription({ trackerDir, fields }) {
|
|
233
|
+
// Validate before taking the lock so a bad payload never queues a write.
|
|
234
|
+
const normalized = normalizeSubscriptionFields(fields);
|
|
235
|
+
const filePath = resolveSubscriptionsPath(trackerDir);
|
|
236
|
+
return withStoreLock(filePath, async () => {
|
|
237
|
+
const now = new Date().toISOString();
|
|
238
|
+
const record = {
|
|
239
|
+
id: crypto.randomUUID(),
|
|
240
|
+
...normalized,
|
|
241
|
+
createdAt: now,
|
|
242
|
+
updatedAt: now,
|
|
243
|
+
};
|
|
244
|
+
const store = await loadStoreForWrite(filePath);
|
|
245
|
+
assertProviderAvailable(store.items, normalized.provider);
|
|
246
|
+
store.items.push(record);
|
|
247
|
+
await writeStore(filePath, store);
|
|
248
|
+
return record;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function updateSubscription({ trackerDir, id, fields }) {
|
|
253
|
+
if (typeof id !== "string" || !id) throw new Error("id is required");
|
|
254
|
+
const filePath = resolveSubscriptionsPath(trackerDir);
|
|
255
|
+
return withStoreLock(filePath, async () => {
|
|
256
|
+
const store = await loadStoreForWrite(filePath);
|
|
257
|
+
const index = store.items.findIndex((item) => item.id === id);
|
|
258
|
+
if (index === -1) throw new Error("Subscription not found");
|
|
259
|
+
const existing = store.items[index];
|
|
260
|
+
// Merge over the existing record so callers may send only changed fields.
|
|
261
|
+
const merged = normalizeSubscriptionFields({ ...existing, ...(fields || {}) });
|
|
262
|
+
assertProviderAvailable(store.items, merged.provider, id);
|
|
263
|
+
const record = {
|
|
264
|
+
...existing,
|
|
265
|
+
...merged,
|
|
266
|
+
updatedAt: new Date().toISOString(),
|
|
267
|
+
};
|
|
268
|
+
store.items[index] = record;
|
|
269
|
+
await writeStore(filePath, store);
|
|
270
|
+
return record;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function deleteSubscription({ trackerDir, id }) {
|
|
275
|
+
if (typeof id !== "string" || !id) throw new Error("id is required");
|
|
276
|
+
const filePath = resolveSubscriptionsPath(trackerDir);
|
|
277
|
+
return withStoreLock(filePath, async () => {
|
|
278
|
+
const store = await loadStoreForWrite(filePath);
|
|
279
|
+
const index = store.items.findIndex((item) => item.id === id);
|
|
280
|
+
if (index === -1) throw new Error("Subscription not found");
|
|
281
|
+
store.items.splice(index, 1);
|
|
282
|
+
await writeStore(filePath, store);
|
|
283
|
+
return { removed: true };
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
listSubscriptions,
|
|
289
|
+
createSubscription,
|
|
290
|
+
updateSubscription,
|
|
291
|
+
deleteSubscription,
|
|
292
|
+
resolveSubscriptionsPath,
|
|
293
|
+
};
|
package/src/lib/telemetry.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Privacy contract (documented in README "Privacy" section — keep in sync):
|
|
7
7
|
* the payload carries exactly four fields — a one-way sha256 hash of the
|
|
8
8
|
* machine id, the app version, `process.platform`, and the app shell
|
|
9
|
-
* (cli / macos / windows). No token counts, no model names, no usernames,
|
|
9
|
+
* (cli / macos / windows / linux). No token counts, no model names, no usernames,
|
|
10
10
|
* no paths. Opt out with TOKENTRACKER_NO_TELEMETRY=1, the DO_NOT_TRACK
|
|
11
11
|
* standard, or `"telemetry": false` in config.json.
|
|
12
12
|
*/
|
|
@@ -35,7 +35,7 @@ const MACHINE_HASH_NAMESPACE = "tokentracker-telemetry-v1:";
|
|
|
35
35
|
// cloud host; sync awaits this at most once per day so the worst-case stall
|
|
36
36
|
// is bounded and rare.
|
|
37
37
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
38
|
-
const VALID_SHELLS = new Set(["cli", "macos", "windows"]);
|
|
38
|
+
const VALID_SHELLS = new Set(["cli", "macos", "windows", "linux"]);
|
|
39
39
|
|
|
40
40
|
const THROTTLE_CONFIG = {
|
|
41
41
|
intervalMs: HEARTBEAT_INTERVAL_MS,
|