tokentracker-cli 0.89.0 → 0.90.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/bin/tracker.js +9 -1
- package/dashboard/dist/assets/{AchievementBadge-BpDy2XSt.js → AchievementBadge-BDPa7twL.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-Z3rNbZAL.js → AchievementsPage-CQoThMTm.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-Cawmrzgp.js → AchievementsSection-D4Uij6_V.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-D8IPOyzs.js → BadgeDetailModal-DIyhyfZn.js} +1 -1
- package/dashboard/dist/assets/{Card-Bco5i8Fn.js → Card-C9Qq3dRv.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-RQvg50Wr.js → ClawdAnimated-DvNwXeAg.js} +1 -1
- package/dashboard/dist/assets/{CommandPalette-DWLyd7P_.js → CommandPalette-Bx_XQD25.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-DfjsjuRG.js → CommunityStatsModal-CJN0niLH.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-DNuSWkt3.js → ConfirmModal-CCEwK_z5.js} +1 -1
- package/dashboard/dist/assets/{Controls-DdLHdkEz.js → Controls-h-L9P_cI.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-DN54xqEE.js → DashboardPage-lskJQLFc.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-ydAtX5oZ.js → DevicePage-CxwatSX3.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-C40Az_dd.js → DialogClose-C3z3Trr2.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-NmqWPZeZ.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-CCg6RvL8.js → DialogTitle-BlKXVRCZ.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-a7dOxD0I.js → FadeIn-BWJwSozb.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-DmH6JYQF.js → HeaderGithubStar-iQIqNWX8.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-D3yTRqfx.js → HoverTooltip-Doqf4LkR.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-DH4av7RV.js → IpCheckPage-CAQOcumi.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-B1fuvkPh.js → LandingPage-C55ZuQ4S.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-C-tKTfIs.js → LeaderboardAvatar-AwdzkUva.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-CL7Cre5j.js → LeaderboardPage-De-XqDgp.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProfileModal-CqRvLdEB.js → LeaderboardProfileModal-Bs1jCBZR.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-BDjW5z87.js → LeaderboardProfilePage-B7hvba9_.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-bYc_9RQC.js +5 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-CQDKfXwB.js → LocalOnlyNotice-BWFxeB2d.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-CptsVdtq.js → LoginCard-BEhQV-kF.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-8V4Vsgpr.js → LoginModal-BnIpMaiu.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-DIwzC_95.js → LoginPage-lOoS6omw.js} +1 -1
- package/dashboard/dist/assets/{PetPage-CXSq_C6b.js → PetPage-CBxkB0FQ.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-C_6kkB4_.js → PopoverPopup-mRFMXjMo.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-BKGMzdEg.js → ProviderIcon-CYUc_5_Z.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-BThSQVj7.js → ResetPasswordPage-BydyI8Te.js} +1 -1
- package/dashboard/dist/assets/{SegmentedControl-DdIw-7OY.js → SegmentedControl-LfSKYbfp.js} +1 -1
- package/dashboard/dist/assets/{Select-BuxVjVTV.js → Select-UiEhxqur.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-B7AP9a2t.js → SelectItemText-DiVTS64c.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-Db6XpKGJ.js → ServiceStatusPage-krg3Du1w.js} +1 -1
- package/dashboard/dist/assets/{SessionsPage-CIvvinQm.js → SessionsPage-D28m0rcm.js} +1 -1
- package/dashboard/dist/assets/SettingsPage-DiVhaU-X.js +2 -0
- package/dashboard/dist/assets/{SkillsPage-2rr2nzvc.js → SkillsPage-CXg55JZ3.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-WS2JBmzs.js → TokenGalaxy-CymWb45G.js} +1 -1
- package/dashboard/dist/assets/{ToolbarRootContext-B5s-ucZR.js → ToolbarRootContext-D8SpeV_Z.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-DfgZi0b3.js → TrendMonitor-ZI8UVyUl.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-WWaK3S0S.js → WidgetsPage-3HI5iI7R.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-C3C85NLm.js → WrappedPage-BK8eyxWF.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-B-CAZ5HB.js → __vite-browser-external-B7TLlrta.js} +63 -18
- package/dashboard/dist/assets/{agent-logos-Do1kFgsi.js → agent-logos-BVrhjlzq.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-DbomORWZ.js → arrow-up-right-BH3-SjFz.js} +1 -1
- package/dashboard/dist/assets/{check-DzEvx9pJ.js → check-B9cFJZgF.js} +1 -1
- package/dashboard/dist/assets/{chevron-right-DZTnF2NM.js → chevron-right-Bo86g4wO.js} +1 -1
- package/dashboard/dist/assets/{copy-BS7rQ_jR.js → copy-j7Scp8t1.js} +1 -1
- package/dashboard/dist/assets/{download-DlWVR14_.js → download-BUt01rg_.js} +1 -1
- package/dashboard/dist/assets/{external-link-CtVBRLbf.js → external-link-DzxpdqJI.js} +1 -1
- package/dashboard/dist/assets/{flame-Bd3FUTdZ.js → flame-CkRd9KBe.js} +1 -1
- package/dashboard/dist/assets/{format-tokens--QLNTkZB.js → format-tokens-B1pCqj3T.js} +1 -1
- package/dashboard/dist/assets/{icons-Cs8SXqmU.js → icons-DbEfSumr.js} +1 -1
- package/dashboard/dist/assets/{index-Cj7qHXa4.js → index-3-T0VHz-.js} +1 -1
- package/dashboard/dist/assets/{index-DoTYGePn.js → index-5a1G4YuE.js} +1 -1
- package/dashboard/dist/assets/{index-CfS3tuU1.js → index-CSGxiVQp.js} +1 -1
- package/dashboard/dist/assets/{info-CsAt6S8y.js → info-nIQW2hmu.js} +1 -1
- package/dashboard/dist/assets/limits-providers-DUfDGI3w.js +1 -0
- package/dashboard/dist/assets/{link-2-CjC85wrH.js → link-2-BIma2gRd.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-Cf0h8jN7.js → loader-circle-DNwHxZy4.js} +1 -1
- package/dashboard/dist/assets/{maximize-2-DSLJns7Q.js → maximize-2-RnscHIM8.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-2DRBUt-i.js → modifiers.esm-Blvm8Jbj.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-DitUxFt7.js → monitor-smartphone-BH-q52Ze.js} +1 -1
- package/dashboard/dist/assets/{provider-display-Dm_uiVV1.js → provider-display-C_BMuv2U.js} +1 -1
- package/dashboard/dist/assets/{react-CExpcnBO.js → react-CsQaHrby.js} +1 -1
- package/dashboard/dist/assets/{search-S2Q8oj2Y.js → search-DlK17ul0.js} +1 -1
- package/dashboard/dist/assets/{skills-api-C3mlKc8x.js → skills-api-DaCUcoqM.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-22qRLWIc.js → sortable.esm-CJh9Zk1m.js} +1 -1
- package/dashboard/dist/assets/{store-DktMCl4T.js → store-DByB-W50.js} +1 -1
- package/dashboard/dist/assets/{terminal-DS6Tqgok.js → terminal-C5HhEMI5.js} +1 -1
- package/dashboard/dist/assets/{trash-2-BHgzn2Bd.js → trash-2-9Vxl6ZF4.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-DqQQKHPK.js → use-community-stats-DTQlvI-1.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-C2fbOm_s.js → use-limits-display-prefs-Du018pOW.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-BnIpnofG.js → use-ordered-list-D5RWUoYn.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-BsIV3-9R.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-B78vQ2pp.js → use-session-efficiency-pref-fttmU-6p.js} +1 -1
- package/dashboard/dist/assets/{use-transform-DJeSBZmb.js → use-transform-BErbwrSs.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-BkSQGzCV.js → use-usage-limits-C6Npx5q3.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-BY7ZJ-XD.js → useCurrency-DAj-AqX-.js} +1 -1
- package/dashboard/dist/assets/{useScrollLock-CHsj-WN5.js → useScrollLock-BhgbzYuS.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-CVJJAs3R.js → useTokenFormat-jEoapvgD.js} +1 -1
- package/dashboard/dist/assets/{zap-Dtudxu92.js → zap-uQ4fo5lp.js} +1 -1
- package/dashboard/dist/brand-logos/volcano-ark.svg +14 -0
- 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/status.js +3 -3
- package/src/lib/ark-coding-plan-limits.js +462 -0
- package/src/lib/command-runner.js +286 -0
- package/src/lib/cursor-config.js +84 -86
- package/src/lib/fs.js +11 -2
- package/src/lib/local-api.js +137 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/proxy-env.js +397 -44
- package/src/lib/proxy-settings.js +163 -0
- package/src/lib/rollout.js +80 -39
- package/src/lib/usage-limits.js +40 -142
- package/dashboard/dist/assets/DialogDescription-CNfc42U-.js +0 -1
- package/dashboard/dist/assets/LimitsPage-D533J_we.js +0 -4
- package/dashboard/dist/assets/SettingsPage-DZo5FGJE.js +0 -1
- package/dashboard/dist/assets/limits-providers-Czh_yu22.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-oDsUC_PJ.js +0 -1
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Ark Coding Plan (火山方舟 Coding Plan) quota monitoring.
|
|
4
|
+
//
|
|
5
|
+
// Coding Plan is a subscription-style quota product (Lite/Pro) that refreshes
|
|
6
|
+
// on three windows — 5-hour (session), weekly and monthly — and is shared by
|
|
7
|
+
// every compatible coding tool (Claude Code, Codex CLI, OpenCode, TRAE, ...).
|
|
8
|
+
// TokenTracker already counts those tools' token consumption from their local
|
|
9
|
+
// files, so this module deliberately adds NO consumption source. It only
|
|
10
|
+
// surfaces the subscription quota percentage, which is otherwise only visible
|
|
11
|
+
// in the Volcano console web page.
|
|
12
|
+
//
|
|
13
|
+
// The quota is read through the user's own `arkcli` binary
|
|
14
|
+
// (`arkcli usage plan --format json`), which is already installed and logged
|
|
15
|
+
// in for users of the Ark CLI ecosystem. Feature-detected: when `arkcli` is
|
|
16
|
+
// missing, the provider simply reports `configured: false` and stays out of
|
|
17
|
+
// the way. Mirrors qoder-limits.js; shares the command runner with
|
|
18
|
+
// usage-limits.js through ./command-runner (no circular dependency).
|
|
19
|
+
|
|
20
|
+
const fs = require("node:fs");
|
|
21
|
+
const os = require("node:os");
|
|
22
|
+
const path = require("node:path");
|
|
23
|
+
|
|
24
|
+
const { runCommand, resolveBinaryPath, statBinaryInDirs, commonGlobalBinDirectories } = require("./command-runner");
|
|
25
|
+
|
|
26
|
+
const ARK_LIMITS_CACHE_FILE = "ark-coding-plan-limits-cache.json";
|
|
27
|
+
const ARK_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
|
|
28
|
+
const ARK_USAGE_PLAN_TIMEOUT_MS = 10_000;
|
|
29
|
+
// `profile show` only runs on the cache-guard path, after `usage plan`
|
|
30
|
+
// already failed. It must not push the total past the outer provider
|
|
31
|
+
// timeout (the whole point of the fallback is to still serve the disk
|
|
32
|
+
// cache), so it gets a much shorter leash.
|
|
33
|
+
const ARK_PROFILE_SHOW_TIMEOUT_MS = 2_500;
|
|
34
|
+
const ARK_CLI_STDERR_TRIM = 400;
|
|
35
|
+
// Mirrors usage-limits.js DEFAULT_PROVIDER_TIMEOUT_MS, which is what the
|
|
36
|
+
// production caller passes in; kept local to avoid a circular require.
|
|
37
|
+
const ARK_PROVIDER_TIMEOUT_MS = 15_000;
|
|
38
|
+
// A runCommand whose timeout fires still takes up to ~1s to hard-kill
|
|
39
|
+
// (SIGTERM -> SIGKILL escalation). Reserve that plus slack so the serial
|
|
40
|
+
// chain (discovery -> usage plan -> plans get / profile show -> cache
|
|
41
|
+
// read) always settles inside the provider budget and the disk-cache
|
|
42
|
+
// fallback actually gets served instead of losing the outer race.
|
|
43
|
+
// Same shape as codexResetCreditListTimeoutMs's guard in usage-limits.js.
|
|
44
|
+
const ARK_PROVIDER_BUDGET_GUARD_MS = 1_500;
|
|
45
|
+
|
|
46
|
+
// arkcli period label -> canonical window slot. `session` is the 5-hour
|
|
47
|
+
// rolling window; `weekly` and `monthly` refresh on calendar boundaries.
|
|
48
|
+
const ARK_PERIOD_WINDOW = {
|
|
49
|
+
session: "primary_window",
|
|
50
|
+
weekly: "secondary_window",
|
|
51
|
+
monthly: "tertiary_window",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function clampPercent(value) {
|
|
55
|
+
return Math.max(0, Math.min(100, value));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeResetAt(value) {
|
|
59
|
+
if (value === null || value === undefined || value === "") return null;
|
|
60
|
+
if (typeof value === "number" || /^\d+(?:\.\d+)?$/.test(String(value).trim())) {
|
|
61
|
+
const raw = Number(value);
|
|
62
|
+
if (!Number.isFinite(raw)) return null;
|
|
63
|
+
const milliseconds = raw > 10_000_000_000 ? raw : raw * 1000;
|
|
64
|
+
const date = new Date(milliseconds);
|
|
65
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
|
66
|
+
}
|
|
67
|
+
const timestamp = Date.parse(String(value));
|
|
68
|
+
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The provider's display name is already "Ark Coding Plan", so the plan label
|
|
72
|
+
// carries only the tier — otherwise the panel title would read
|
|
73
|
+
// "Ark Coding Plan Coding Plan Lite".
|
|
74
|
+
function planLabelForTier(tier) {
|
|
75
|
+
const normalized = String(tier || "").trim().toLowerCase();
|
|
76
|
+
if (normalized === "pro") return "Pro";
|
|
77
|
+
if (normalized === "lite") return "Lite";
|
|
78
|
+
return tier && String(tier).trim() ? String(tier).trim() : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Normalize the JSON payload returned by `arkcli plans get --format json`.
|
|
83
|
+
* The tier ("lite" / "pro") lives on the plans payload, not on the usage
|
|
84
|
+
* payload, so it is resolved here and merged into the plan label.
|
|
85
|
+
* Returns null when there is no Coding Plan entry.
|
|
86
|
+
*/
|
|
87
|
+
function normalizeArkPlansResponse(body) {
|
|
88
|
+
if (!body || typeof body !== "object") return null;
|
|
89
|
+
const plans = Array.isArray(body.plans) ? body.plans : [];
|
|
90
|
+
const plan = plans.find((entry) => entry?.key === "coding-plan");
|
|
91
|
+
return plan?.tier ? String(plan.tier) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function arkProfileIdentity(body) {
|
|
95
|
+
const profile = body?.profile && typeof body.profile === "object" ? body.profile : body;
|
|
96
|
+
const name = typeof body?.profile === "string"
|
|
97
|
+
? body.profile
|
|
98
|
+
: profile?.name || profile?.profile || profile?.profile_name;
|
|
99
|
+
let userId = profile?.user_id || profile?.userId || body?.user_id || body?.userId;
|
|
100
|
+
if (!userId) {
|
|
101
|
+
// `arkcli profile show` reports the account through owner_trn /
|
|
102
|
+
// identity_key (e.g. "trn:iam::1234567890:root" / "volc-1234567890")
|
|
103
|
+
// instead of a user_id field — extract the numeric id so identities
|
|
104
|
+
// coming from `usage plan`'s viewer and from `profile show` compare
|
|
105
|
+
// equal.
|
|
106
|
+
const trnMatch = String(profile?.owner_trn || "").match(/::(\d+):/);
|
|
107
|
+
if (trnMatch) userId = trnMatch[1];
|
|
108
|
+
else {
|
|
109
|
+
const keyMatch = String(profile?.identity_key || "").match(/-(\d+)$/);
|
|
110
|
+
if (keyMatch) userId = keyMatch[1];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const identity = [name, userId].filter(Boolean).join(":");
|
|
114
|
+
return identity || null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Normalize the JSON payload returned by `arkcli usage plan --format json`.
|
|
119
|
+
* Returns `null` when the account has no active Coding Plan subscription
|
|
120
|
+
* (caller reports `configured: false`). Throws when the payload shape is
|
|
121
|
+
* unusable so the caller can fall back to the disk cache.
|
|
122
|
+
*/
|
|
123
|
+
function normalizeArkCodingPlanResponse(body) {
|
|
124
|
+
if (!body || typeof body !== "object") {
|
|
125
|
+
throw new Error("Ark Coding Plan response is not an object.");
|
|
126
|
+
}
|
|
127
|
+
const items = Array.isArray(body.items) ? body.items : [];
|
|
128
|
+
const item = items.find((entry) => entry?.product === "coding-plan");
|
|
129
|
+
if (!item || item.subscribed !== true) return null;
|
|
130
|
+
|
|
131
|
+
const windows = {};
|
|
132
|
+
const periods = Array.isArray(item.periods) ? item.periods : [];
|
|
133
|
+
for (const period of periods) {
|
|
134
|
+
const slot = ARK_PERIOD_WINDOW[period?.label];
|
|
135
|
+
if (!slot) continue;
|
|
136
|
+
const percent = Number(period.percent);
|
|
137
|
+
if (!Number.isFinite(percent)) continue;
|
|
138
|
+
windows[slot] = {
|
|
139
|
+
used_percent: clampPercent(percent),
|
|
140
|
+
reset_at: normalizeResetAt(period.reset_at),
|
|
141
|
+
unit: "calls",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (!windows.primary_window && !windows.secondary_window && !windows.tertiary_window) {
|
|
145
|
+
throw new Error("Ark Coding Plan response contains no usable quota periods.");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
configured: true,
|
|
150
|
+
error: null,
|
|
151
|
+
plan_label: planLabelForTier(item.tier),
|
|
152
|
+
primary_window: windows.primary_window || null,
|
|
153
|
+
secondary_window: windows.secondary_window || null,
|
|
154
|
+
tertiary_window: windows.tertiary_window || null,
|
|
155
|
+
source: "provider-api",
|
|
156
|
+
profile_identity: arkProfileIdentity(body.viewer),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function arkCodingPlanCachePath({ home = os.homedir() } = {}) {
|
|
161
|
+
return path.join(home, ".tokentracker", "tracker", ARK_LIMITS_CACHE_FILE);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readArkCodingPlanLimitsCache({ home = os.homedir(), nowMs = Date.now(), profileIdentity } = {}) {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(fs.readFileSync(arkCodingPlanCachePath({ home }), "utf8"));
|
|
167
|
+
// Deliberately fail-open: the guard only applies when `profile show`
|
|
168
|
+
// could establish the current identity. When it also failed, serving
|
|
169
|
+
// the stale cache beats erroring out — availability over strictness.
|
|
170
|
+
if (profileIdentity && parsed?.profile_identity !== profileIdentity) return null;
|
|
171
|
+
const cachedAtMs = Date.parse(parsed?.cached_at || "");
|
|
172
|
+
if (!Number.isFinite(cachedAtMs) || cachedAtMs > nowMs + 60_000) return null;
|
|
173
|
+
// A window whose reset_at has passed is stale — the quota has already
|
|
174
|
+
// rolled over, so serving its old used_percent would mislead. Drop it.
|
|
175
|
+
const windows = [parsed?.primary_window, parsed?.secondary_window, parsed?.tertiary_window];
|
|
176
|
+
const surviving = windows.map((window) => {
|
|
177
|
+
if (!window) return null;
|
|
178
|
+
const resetAtMs = Date.parse(window.reset_at || "");
|
|
179
|
+
if (Number.isFinite(resetAtMs) && resetAtMs <= nowMs) return null;
|
|
180
|
+
return window;
|
|
181
|
+
});
|
|
182
|
+
if (surviving.every((window) => !window)) return null;
|
|
183
|
+
// Undated windows can't be checked against a reset, so drop each one when
|
|
184
|
+
// the snapshot is too old. A future-dated sibling must not keep it alive.
|
|
185
|
+
const bounded = surviving.map((window) => {
|
|
186
|
+
if (!window) return null;
|
|
187
|
+
return Number.isFinite(Date.parse(window.reset_at || ""))
|
|
188
|
+
|| nowMs - cachedAtMs <= ARK_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS
|
|
189
|
+
? window
|
|
190
|
+
: null;
|
|
191
|
+
});
|
|
192
|
+
if (bounded.every((window) => !window)) return null;
|
|
193
|
+
return {
|
|
194
|
+
configured: true,
|
|
195
|
+
error: null,
|
|
196
|
+
plan_label: typeof parsed?.plan_label === "string" ? parsed.plan_label : null,
|
|
197
|
+
primary_window: bounded[0],
|
|
198
|
+
secondary_window: bounded[1],
|
|
199
|
+
tertiary_window: bounded[2],
|
|
200
|
+
cached: true,
|
|
201
|
+
stale: true,
|
|
202
|
+
cached_at: parsed.cached_at,
|
|
203
|
+
source: "disk-cache",
|
|
204
|
+
};
|
|
205
|
+
} catch (_error) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function writeArkCodingPlanLimitsCache(limits, { home = os.homedir(), nowMs = Date.now() } = {}) {
|
|
211
|
+
if (!limits?.configured || limits.error) return;
|
|
212
|
+
const cachePath = arkCodingPlanCachePath({ home });
|
|
213
|
+
const payload = {
|
|
214
|
+
plan_label: limits.plan_label || null,
|
|
215
|
+
profile_identity: limits.profile_identity || null,
|
|
216
|
+
primary_window: limits.primary_window || null,
|
|
217
|
+
secondary_window: limits.secondary_window || null,
|
|
218
|
+
tertiary_window: limits.tertiary_window || null,
|
|
219
|
+
cached_at: new Date(nowMs).toISOString(),
|
|
220
|
+
};
|
|
221
|
+
try {
|
|
222
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
223
|
+
const tmpPath = `${cachePath}.${process.pid}.tmp`;
|
|
224
|
+
fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
225
|
+
fs.renameSync(tmpPath, cachePath);
|
|
226
|
+
} catch (_error) {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// runCommand / whichBinary / resolveBinaryPath now live in ./command-runner,
|
|
230
|
+
// shared with usage-limits.js (single implementation, no forked copies).
|
|
231
|
+
|
|
232
|
+
// arkcli keeps its config and credentials under ~/.arkcli (plus a couple of
|
|
233
|
+
// platform-typical alternates). Checking for these directories is a
|
|
234
|
+
// spawn-free way to tell "arkcli has never been installed" apart from
|
|
235
|
+
// "installed but the quota call failed" — machines without the CLI skip the
|
|
236
|
+
// binary probe entirely on every poll, so the 5s refresh cadence never pays
|
|
237
|
+
// a `which` spawn for a provider they cannot use.
|
|
238
|
+
function hasArkCliInstallEvidence({ home = os.homedir(), platform = process.platform } = {}) {
|
|
239
|
+
const candidates = [
|
|
240
|
+
path.join(home, ".arkcli"),
|
|
241
|
+
path.join(home, ".config", "arkcli"),
|
|
242
|
+
];
|
|
243
|
+
if (platform === "win32") {
|
|
244
|
+
candidates.push(path.join(home, "AppData", "Roaming", "arkcli"));
|
|
245
|
+
}
|
|
246
|
+
for (const dir of candidates) {
|
|
247
|
+
try {
|
|
248
|
+
if (fs.statSync(dir).isDirectory()) return true;
|
|
249
|
+
} catch (_error) {}
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function trimStderr(stderr) {
|
|
255
|
+
const text = String(stderr || "").trim();
|
|
256
|
+
if (!text) return null;
|
|
257
|
+
return text.length > ARK_CLI_STDERR_TRIM
|
|
258
|
+
? `${text.slice(0, ARK_CLI_STDERR_TRIM)}…`
|
|
259
|
+
: text;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Fetch Ark Coding Plan quota windows from the local `arkcli` binary.
|
|
264
|
+
*
|
|
265
|
+
* Resolution order:
|
|
266
|
+
* 1. no config-dir evidence AND no arkcli in the global bin dirs
|
|
267
|
+
* -> { configured: false }, zero spawns
|
|
268
|
+
* 2. arkcli binary not resolvable -> { configured: false }
|
|
269
|
+
* 3. `arkcli usage plan` succeeds but no subscription -> { configured: false }
|
|
270
|
+
* 4. live success -> { configured: true, ...windows }
|
|
271
|
+
* 5. command/parse failure -> bounded disk cache -> { configured: true, ...stale }
|
|
272
|
+
* 6. nothing usable -> { configured: true, error }
|
|
273
|
+
*
|
|
274
|
+
* Only `usage plan` runs on the happy path. `plans get` (tier label) runs
|
|
275
|
+
* only when the usage response carries no tier, and `profile show` (the
|
|
276
|
+
* cross-account cache guard) only when the disk cache is consulted.
|
|
277
|
+
*
|
|
278
|
+
* `providerTimeoutMs` bounds the whole serial chain (mirrors the codex
|
|
279
|
+
* remaining-budget pattern): each CLI call's timeout is clamped to what
|
|
280
|
+
* is left of the budget, and calls whose share has run out are skipped
|
|
281
|
+
* so the disk-cache fallback still resolves inside the outer race.
|
|
282
|
+
*/
|
|
283
|
+
async function fetchArkCodingPlanLimits({
|
|
284
|
+
commandRunner,
|
|
285
|
+
home = os.homedir(),
|
|
286
|
+
nowMs = Date.now(),
|
|
287
|
+
platform = process.platform,
|
|
288
|
+
signal,
|
|
289
|
+
globalBinDirs,
|
|
290
|
+
providerTimeoutMs = ARK_PROVIDER_TIMEOUT_MS,
|
|
291
|
+
} = {}) {
|
|
292
|
+
// Mirror of codexResetCreditListTimeoutMs: every CLI call in the serial
|
|
293
|
+
// chain gets a timeout clamped to what is left of the provider budget,
|
|
294
|
+
// so the chain can never outrun the outer provider race and starve the
|
|
295
|
+
// disk-cache fallback.
|
|
296
|
+
const startedAtMs = performance.now();
|
|
297
|
+
const budgetedTimeoutMs = (fullTimeoutMs) => {
|
|
298
|
+
if (!Number.isFinite(providerTimeoutMs) || providerTimeoutMs <= 0) return fullTimeoutMs;
|
|
299
|
+
const remainingMs = providerTimeoutMs - (performance.now() - startedAtMs);
|
|
300
|
+
if (remainingMs <= 0) return 0;
|
|
301
|
+
const guardedMs = Math.floor(remainingMs - ARK_PROVIDER_BUDGET_GUARD_MS);
|
|
302
|
+
if (guardedMs <= 0) return 0;
|
|
303
|
+
return Math.min(fullTimeoutMs, guardedMs);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const searchDirs = () => Array.isArray(globalBinDirs)
|
|
307
|
+
? globalBinDirs
|
|
308
|
+
: commonGlobalBinDirectories({ home, platform });
|
|
309
|
+
|
|
310
|
+
let arkcliPath;
|
|
311
|
+
if (hasArkCliInstallEvidence({ home, platform })) {
|
|
312
|
+
try {
|
|
313
|
+
arkcliPath = await resolveBinaryPath("arkcli", { commandRunner, home, platform, signal, globalBinDirs });
|
|
314
|
+
} catch (_error) {
|
|
315
|
+
arkcliPath = null;
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
// No config-dir evidence — still resolve spawn-free first: an arkcli
|
|
319
|
+
// found in a global bin directory counts as install evidence too (the
|
|
320
|
+
// CLI may keep its config somewhere we don't know about). Only when
|
|
321
|
+
// that also misses do we bail, so machines without the CLI still pay
|
|
322
|
+
// zero spawns per poll.
|
|
323
|
+
arkcliPath = statBinaryInDirs("arkcli", searchDirs(), platform);
|
|
324
|
+
}
|
|
325
|
+
if (!arkcliPath) return { configured: false };
|
|
326
|
+
|
|
327
|
+
const commandOptions = {
|
|
328
|
+
signal,
|
|
329
|
+
killProcessGroup: true,
|
|
330
|
+
platform,
|
|
331
|
+
// npm installs CLI entrypoints as .cmd shims on Windows, which a direct
|
|
332
|
+
// spawn cannot execute. Every argument here is a constant with no shell
|
|
333
|
+
// metacharacters, so shell execution is safe for this call site only —
|
|
334
|
+
// it must stay opt-in (see command-runner.js).
|
|
335
|
+
useShell: platform === "win32",
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Budget already drained before the primary call could run: report the
|
|
339
|
+
// timeout without touching the cache — an unverified cache (profile
|
|
340
|
+
// identity unknown) must not be served on this path, mirroring the
|
|
341
|
+
// outer provider race's behavior. The verified-cache fallback below is
|
|
342
|
+
// what keeps hung-CLI polls serving last-known data.
|
|
343
|
+
const usageTimeoutMs = budgetedTimeoutMs(ARK_USAGE_PLAN_TIMEOUT_MS);
|
|
344
|
+
if (usageTimeoutMs <= 0) {
|
|
345
|
+
return { configured: true, error: "Ark Coding Plan provider timed out before arkcli could run." };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Spawn the resolved absolute path, never the bare name: on Windows
|
|
349
|
+
// cmd.exe searches the current directory before PATH, so a bare
|
|
350
|
+
// `arkcli` would let an `arkcli.bat` dropped in the server cwd hijack
|
|
351
|
+
// the spawn.
|
|
352
|
+
const result = await runCommand(
|
|
353
|
+
commandRunner,
|
|
354
|
+
arkcliPath,
|
|
355
|
+
["usage", "plan", "--format", "json"],
|
|
356
|
+
{ ...commandOptions, timeout: usageTimeoutMs },
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const failWithCache = async (message) => {
|
|
360
|
+
// Short leash, further clamped to the remaining provider budget: this
|
|
361
|
+
// runs after `usage plan` already burned most of the budget; a slow or
|
|
362
|
+
// hung arkcli here must not starve the cache read that the caller is
|
|
363
|
+
// actually waiting for. When no budget is left the spawn is skipped
|
|
364
|
+
// entirely and the cache is read fail-open (identity unknown).
|
|
365
|
+
const profileTimeoutMs = budgetedTimeoutMs(ARK_PROFILE_SHOW_TIMEOUT_MS);
|
|
366
|
+
const profileIdentity = profileTimeoutMs > 0
|
|
367
|
+
? await runCommand(
|
|
368
|
+
commandRunner,
|
|
369
|
+
arkcliPath,
|
|
370
|
+
["profile", "show", "--format", "json"],
|
|
371
|
+
{ ...commandOptions, timeout: profileTimeoutMs },
|
|
372
|
+
).then((profileResult) => {
|
|
373
|
+
if (profileResult?.error || profileResult?.status !== 0) return null;
|
|
374
|
+
try {
|
|
375
|
+
return arkProfileIdentity(JSON.parse(String(profileResult.stdout || "")));
|
|
376
|
+
} catch (_error) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}).catch(() => null)
|
|
380
|
+
: null;
|
|
381
|
+
const cached = readArkCodingPlanLimitsCache({ home, nowMs, profileIdentity });
|
|
382
|
+
if (cached) return cached;
|
|
383
|
+
return { configured: true, error: message };
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (result?.error || result?.status !== 0) {
|
|
387
|
+
const detail = result?.error?.message
|
|
388
|
+
|| (result?.status !== 0 && result?.status !== null
|
|
389
|
+
? `arkcli exited with code ${result.status}`
|
|
390
|
+
: "arkcli usage plan failed");
|
|
391
|
+
const stderr = trimStderr(result?.stderr);
|
|
392
|
+
return failWithCache(stderr ? `${detail}: ${stderr}` : detail);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
let body;
|
|
396
|
+
try {
|
|
397
|
+
body = JSON.parse(String(result?.stdout || ""));
|
|
398
|
+
} catch (_error) {
|
|
399
|
+
return failWithCache("arkcli usage plan returned invalid JSON.");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let limits;
|
|
403
|
+
try {
|
|
404
|
+
limits = normalizeArkCodingPlanResponse(body);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
return failWithCache(error?.message || "Ark Coding Plan response could not be parsed.");
|
|
407
|
+
}
|
|
408
|
+
if (!limits) {
|
|
409
|
+
// Only a response that explicitly carries the coding-plan entry with
|
|
410
|
+
// `subscribed: false` confirms the plan was retired — drop the cache
|
|
411
|
+
// then, or a later transient CLI failure would resurrect the retired
|
|
412
|
+
// plan's numbers through failWithCache. A payload with *no*
|
|
413
|
+
// coding-plan entry at all (`{}`, `{"items":[]}` — not logged in,
|
|
414
|
+
// backend degraded, product key renamed) is ambiguous and must NOT
|
|
415
|
+
// destroy the cache: transient signals never drive persistent state.
|
|
416
|
+
const entry = Array.isArray(body?.items)
|
|
417
|
+
? body.items.find((candidate) => candidate?.product === "coding-plan")
|
|
418
|
+
: null;
|
|
419
|
+
if (entry && entry.subscribed === false) {
|
|
420
|
+
try {
|
|
421
|
+
fs.unlinkSync(arkCodingPlanCachePath({ home }));
|
|
422
|
+
} catch (_error) {}
|
|
423
|
+
}
|
|
424
|
+
return { configured: false };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!limits.plan_label) {
|
|
428
|
+
// The tier ("lite" / "pro") lives on the `plans get` payload; fetch it
|
|
429
|
+
// only when the usage response did not carry one, and only while the
|
|
430
|
+
// provider budget still has room for the spawn. A skipped fetch just
|
|
431
|
+
// leaves the label null — never worth losing the live data over.
|
|
432
|
+
const plansTimeoutMs = budgetedTimeoutMs(ARK_USAGE_PLAN_TIMEOUT_MS);
|
|
433
|
+
if (plansTimeoutMs > 0) {
|
|
434
|
+
const plansResult = await runCommand(
|
|
435
|
+
commandRunner,
|
|
436
|
+
arkcliPath,
|
|
437
|
+
["plans", "get", "--format", "json"],
|
|
438
|
+
{ ...commandOptions, timeout: plansTimeoutMs },
|
|
439
|
+
);
|
|
440
|
+
if (!plansResult?.error && plansResult?.status === 0) {
|
|
441
|
+
try {
|
|
442
|
+
const tier = normalizeArkPlansResponse(JSON.parse(String(plansResult.stdout || "")));
|
|
443
|
+
if (tier) limits.plan_label = planLabelForTier(tier);
|
|
444
|
+
} catch (_error) {}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
writeArkCodingPlanLimitsCache(limits, { home, nowMs });
|
|
450
|
+
return limits;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
module.exports = {
|
|
454
|
+
ARK_PERIOD_WINDOW,
|
|
455
|
+
normalizeArkPlansResponse,
|
|
456
|
+
arkProfileIdentity,
|
|
457
|
+
normalizeArkCodingPlanResponse,
|
|
458
|
+
readArkCodingPlanLimitsCache,
|
|
459
|
+
writeArkCodingPlanLimitsCache,
|
|
460
|
+
hasArkCliInstallEvidence,
|
|
461
|
+
fetchArkCodingPlanLimits,
|
|
462
|
+
};
|