tokentracker-cli 0.95.1 → 0.96.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.
Files changed (107) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-Cyju9OnL.js → AchievementBadge-Bb2ljquW.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-Bh2dQR-I.js → AchievementsPage-ByUP2x8a.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-DSFAM0Zp.js → AchievementsSection-_L6sCLTw.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-CgVF0VI0.js → BadgeDetailModal-B9n_-45B.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-DI-rTxI9.js → BotAnimated-lWmg-aZg.js} +1 -1
  6. package/dashboard/dist/assets/{Card-DAlV-PSI.js → Card-aSp259T0.js} +1 -1
  7. package/dashboard/dist/assets/{ClawdAnimated-oOYKgcpq.js → ClawdAnimated-Cy-XaTXW.js} +2 -2
  8. package/dashboard/dist/assets/{CommandPalette-bYE5uD63.js → CommandPalette-CNwtKCUm.js} +1 -1
  9. package/dashboard/dist/assets/{CommunityStatsModal-DCx0sQjk.js → CommunityStatsModal-DJSezaPV.js} +1 -1
  10. package/dashboard/dist/assets/{ConfirmModal-DrYf9m15.js → ConfirmModal-CVjmhZd6.js} +1 -1
  11. package/dashboard/dist/assets/{Controls-DAift9Z4.js → Controls-BIj6yVxE.js} +1 -1
  12. package/dashboard/dist/assets/{DashboardPage-CLswnOil.js → DashboardPage-BjYZLmqQ.js} +1 -1
  13. package/dashboard/dist/assets/{DevicePage-uWLgMC4Z.js → DevicePage-ptsI2fZc.js} +1 -1
  14. package/dashboard/dist/assets/{DialogClose-NitVrdVC.js → DialogClose-D1pLoZ56.js} +1 -1
  15. package/dashboard/dist/assets/DialogDescription-KY7X94CG.js +1 -0
  16. package/dashboard/dist/assets/{DialogTitle-CA_ZstJL.js → DialogTitle-DfSBUjWf.js} +1 -1
  17. package/dashboard/dist/assets/{FadeIn-CIkYDamA.js → FadeIn-DBn4L_BV.js} +1 -1
  18. package/dashboard/dist/assets/{HeaderGithubStar-CYTJNLpL.js → HeaderGithubStar-EJLBwEqp.js} +1 -1
  19. package/dashboard/dist/assets/{HoverTooltip-jh3sI8NC.js → HoverTooltip-DaSrZhYd.js} +1 -1
  20. package/dashboard/dist/assets/{IpCheckPage-Brpe-N7B.js → IpCheckPage-diQTVaYt.js} +1 -1
  21. package/dashboard/dist/assets/{LandingPage-D4PjaNcN.js → LandingPage-BqCxjwar.js} +2 -2
  22. package/dashboard/dist/assets/{LeaderboardAvatar-D9dc6frq.js → LeaderboardAvatar-CQImCsPN.js} +1 -1
  23. package/dashboard/dist/assets/{LeaderboardPage-CEuqopiC.js → LeaderboardPage-D2ZDUi_O.js} +2 -2
  24. package/dashboard/dist/assets/{LeaderboardProfileModal-TeYdXeIA.js → LeaderboardProfileModal-DHvADX1d.js} +3 -3
  25. package/dashboard/dist/assets/{LeaderboardProfilePage-DmtuwJkL.js → LeaderboardProfilePage-CJsedB28.js} +1 -1
  26. package/dashboard/dist/assets/LimitsPage-CAwff24b.js +6 -0
  27. package/dashboard/dist/assets/{LocalOnlyNotice-V8_KFKOD.js → LocalOnlyNotice-Cmnvu9tI.js} +1 -1
  28. package/dashboard/dist/assets/{LoginCard-ud0lCY_u.js → LoginCard-BQmKkD7X.js} +1 -1
  29. package/dashboard/dist/assets/{LoginModal-Bnemx4UD.js → LoginModal-2oPROePZ.js} +1 -1
  30. package/dashboard/dist/assets/{LoginPage-v89BHssa.js → LoginPage-aqdUJoQK.js} +1 -1
  31. package/dashboard/dist/assets/{PetPage-Bg9kEi7o.js → PetPage-Bn3p_o5r.js} +1 -1
  32. package/dashboard/dist/assets/{PopoverPopup-CCHVwh0w.js → PopoverPopup-DXD_YdiB.js} +1 -1
  33. package/dashboard/dist/assets/{ProviderIcon-BecpRylD.js → ProviderIcon-BZZ45-gY.js} +1 -1
  34. package/dashboard/dist/assets/{ResetPasswordPage-CsUtFeUr.js → ResetPasswordPage-Bn5ITiHQ.js} +1 -1
  35. package/dashboard/dist/assets/{SegmentedControl-3rBqbzW_.js → SegmentedControl-hGXboQZy.js} +1 -1
  36. package/dashboard/dist/assets/{Select-DZ2ll7Qv.js → Select-BP8aVRZ1.js} +1 -1
  37. package/dashboard/dist/assets/{SelectItemText-Dr1W47W6.js → SelectItemText-Odx47kND.js} +1 -1
  38. package/dashboard/dist/assets/{ServiceStatusPage-Cc1LXdAh.js → ServiceStatusPage-DUueam0N.js} +1 -1
  39. package/dashboard/dist/assets/SessionsPage-BA9IFqQ1.js +2 -0
  40. package/dashboard/dist/assets/{SettingsPage-2wYQ2ly6.js → SettingsPage-DLOHHB4f.js} +2 -2
  41. package/dashboard/dist/assets/{SkillsPage-BEDAwBTN.js → SkillsPage-Ds2vH_PG.js} +1 -1
  42. package/dashboard/dist/assets/{TokenGalaxy-DxG0dBeG.js → TokenGalaxy-CWBR4jnB.js} +1 -1
  43. package/dashboard/dist/assets/{ToolbarRootContext-DG1pr5UP.js → ToolbarRootContext-Bmg56vYz.js} +1 -1
  44. package/dashboard/dist/assets/{TrendMonitor-CeuEQ0XC.js → TrendMonitor-BQzcllvg.js} +1 -1
  45. package/dashboard/dist/assets/{WidgetsPage-ALDlZ5aR.js → WidgetsPage-Bu3tHDjO.js} +1 -1
  46. package/dashboard/dist/assets/{WrappedPage-BF59ouR-.js → WrappedPage-BrAMJhgy.js} +1 -1
  47. package/dashboard/dist/assets/{__vite-browser-external-DXtgoDkV.js → __vite-browser-external-4IGm4Djn.js} +12 -12
  48. package/dashboard/dist/assets/{agent-logos-D6Ya1qw1.js → agent-logos-B8lT-HcI.js} +1 -1
  49. package/dashboard/dist/assets/{arrow-up-right-DUR69elJ.js → arrow-up-right-rpBCOTg2.js} +1 -1
  50. package/dashboard/dist/assets/{check-9_1xp5h5.js → check-DIKw7TYH.js} +1 -1
  51. package/dashboard/dist/assets/{composite-CEzovOpb.js → composite-_NeXO0lv.js} +1 -1
  52. package/dashboard/dist/assets/{copy-DEoLQqJE.js → copy-CsfAtL7P.js} +1 -1
  53. package/dashboard/dist/assets/{download-BNjfcJ1g.js → download-DM1W9pIW.js} +1 -1
  54. package/dashboard/dist/assets/{external-link-fV_WJ5f_.js → external-link-CwbuMbeQ.js} +1 -1
  55. package/dashboard/dist/assets/{flame-DqpuFJ14.js → flame-DcYbLdhE.js} +1 -1
  56. package/dashboard/dist/assets/{format-tokens-CatddjmK.js → format-tokens-CEvr3dNy.js} +1 -1
  57. package/dashboard/dist/assets/{icons-CBT1KI7V.js → icons-DaGMVsJ5.js} +1 -1
  58. package/dashboard/dist/assets/{index-_2LEUZTl.js → index-BVVLPj3N.js} +1 -1
  59. package/dashboard/dist/assets/{index-BK3SzL7v.js → index-ChZkJjpk.js} +1 -1
  60. package/dashboard/dist/assets/{index-tK7s2SeO.js → index-T7OjmEuX.js} +1 -1
  61. package/dashboard/dist/assets/{info-DBotcfT9.js → info-CAjN3dX4.js} +1 -1
  62. package/dashboard/dist/assets/limits-providers-jn7Yln_V.js +1 -0
  63. package/dashboard/dist/assets/{link-2-Wc54x2fh.js → link-2-DaWH2Tyc.js} +1 -1
  64. package/dashboard/dist/assets/{loader-circle-nRhGIddE.js → loader-circle-CnF8qPi1.js} +1 -1
  65. package/dashboard/dist/assets/{maximize-2-Dw3_GuKW.js → maximize-2-B-2Pnj6g.js} +1 -1
  66. package/dashboard/dist/assets/{modifiers.esm-rTZ5BUxp.js → modifiers.esm-BoDOvUKl.js} +1 -1
  67. package/dashboard/dist/assets/{monitor-smartphone-CNVAqV_N.js → monitor-smartphone-DDgwxG_E.js} +1 -1
  68. package/dashboard/dist/assets/{plus-B5oPOXhp.js → plus-DIc1YUWB.js} +1 -1
  69. package/dashboard/dist/assets/{provider-display-DhAUWfC6.js → provider-display-yeNsVCO_.js} +1 -1
  70. package/dashboard/dist/assets/{react-BKACWrsB.js → react-DZqS6YTX.js} +1 -1
  71. package/dashboard/dist/assets/{search-DgddmV09.js → search-D6tEppYJ.js} +1 -1
  72. package/dashboard/dist/assets/{skills-api-BT1uiqeq.js → skills-api-vsA1rc1Z.js} +1 -1
  73. package/dashboard/dist/assets/{sortable.esm-UCoNfZ6z.js → sortable.esm-DDCnS1HI.js} +1 -1
  74. package/dashboard/dist/assets/{store-ZswY8Voz.js → store-Crne4DqF.js} +1 -1
  75. package/dashboard/dist/assets/{terminal-BtQyotJS.js → terminal-Ca7dkLMF.js} +1 -1
  76. package/dashboard/dist/assets/{trash-2-Dc_NLbWs.js → trash-2-CjEAS5ic.js} +1 -1
  77. package/dashboard/dist/assets/{use-community-stats-yOKag_ou.js → use-community-stats-BHx2YxyK.js} +1 -1
  78. package/dashboard/dist/assets/{use-latest-request-guard-BEiszvlI.js → use-latest-request-guard-BLGbXQdf.js} +1 -1
  79. package/dashboard/dist/assets/{use-limits-display-prefs-C-l_Mxvy.js → use-limits-display-prefs-DBkAp6ob.js} +1 -1
  80. package/dashboard/dist/assets/{use-ordered-list-CKjRaO6K.js → use-ordered-list-BTfW5M9u.js} +1 -1
  81. package/dashboard/dist/assets/use-reduced-motion-D_kUnDs4.js +1 -0
  82. package/dashboard/dist/assets/{use-session-efficiency-pref-DPI_--W2.js → use-session-efficiency-pref-DBmd-MqH.js} +1 -1
  83. package/dashboard/dist/assets/{use-transform-BQUem5OA.js → use-transform-Bp5XrwaQ.js} +1 -1
  84. package/dashboard/dist/assets/{use-usage-limits-lvkMBnNR.js → use-usage-limits-BmqRcyhH.js} +1 -1
  85. package/dashboard/dist/assets/{useCurrency-C5xhS1QV.js → useCurrency-B0YsRDj-.js} +1 -1
  86. package/dashboard/dist/assets/{useTokenFormat-FBMikfEq.js → useTokenFormat-PP3D0Ket.js} +1 -1
  87. package/dashboard/dist/assets/{zap-BsCNYelz.js → zap-BVOm1aBH.js} +1 -1
  88. package/dashboard/dist/index.html +1 -1
  89. package/dashboard/dist/ip-check.html +1 -1
  90. package/dashboard/dist/leaderboard.html +1 -1
  91. package/dashboard/dist/share.html +1 -1
  92. package/package.json +1 -1
  93. package/src/lib/ark-agent-plan-limits.js +437 -0
  94. package/src/lib/ark-coding-plan-limits.js +104 -27
  95. package/src/lib/codex-model-attribution.js +128 -0
  96. package/src/lib/codex-rollout-parser.js +98 -4
  97. package/src/lib/pricing/curated-overrides.json +24 -6
  98. package/src/lib/pricing/index.js +38 -1
  99. package/src/lib/pricing/seed-snapshot.json +1 -1
  100. package/src/lib/rollout.js +50 -9
  101. package/src/lib/session-analytics.js +374 -110
  102. package/src/lib/usage-limits.js +19 -5
  103. package/dashboard/dist/assets/DialogDescription-CQuJCP4L.js +0 -1
  104. package/dashboard/dist/assets/LimitsPage-BJW3teYA.js +0 -5
  105. package/dashboard/dist/assets/SessionsPage-ytOFN1h9.js +0 -2
  106. package/dashboard/dist/assets/limits-providers-CjMU709b.js +0 -1
  107. package/dashboard/dist/assets/use-reduced-motion-BlOkflqU.js +0 -1
@@ -0,0 +1,437 @@
1
+ "use strict";
2
+
3
+ // Ark Agent Plan (火山方舟 Agent Plan) quota monitoring.
4
+ //
5
+ // Agent Plan (Small/Medium/Large/Max) and Coding Plan (Lite/Pro) are
6
+ // two parallel subscription products that each refresh on three windows —
7
+ // 5-hour (5h for Agent Plan, session for Coding Plan), weekly and monthly —
8
+ // and are shared by compatible coding tools (Claude Code, Codex CLI,
9
+ // OpenCode, TRAE, ...). Official docs present them as parallel, not
10
+ // successor/replacement. TokenTracker already counts those tools' token
11
+ // consumption from their local files, so this module deliberately adds
12
+ // NO consumption source. It only surfaces the subscription quota
13
+ // percentage, which is otherwise only visible in the Volcano console web
14
+ // page. Coding Plan is handled by a separate provider module
15
+ // `ark-coding-plan-limits.js` (dual Provider, issue #555).
16
+ //
17
+ // The quota is read through the user's own `arkcli` binary
18
+ // (`arkcli usage plan --format json`), which is already installed and logged
19
+ // in for users of the Ark CLI ecosystem. Feature-detected: when `arkcli` is
20
+ // missing, the provider simply reports `configured: false` and stays out of
21
+ // the way. Mirrors ark-coding-plan-limits.js; shares the command runner with
22
+ // usage-limits.js through ./command-runner (no circular dependency).
23
+
24
+ const fs = require("node:fs");
25
+ const os = require("node:os");
26
+ const path = require("node:path");
27
+
28
+ const { runCommand, resolveBinaryPath, statBinaryInDirs, commonGlobalBinDirectories } = require("./command-runner");
29
+
30
+ const ARK_LIMITS_CACHE_FILE = "ark-agent-plan-limits-cache.json";
31
+ const ARK_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
32
+ const ARK_USAGE_PLAN_TIMEOUT_MS = 10_000;
33
+ const ARK_PROFILE_SHOW_TIMEOUT_MS = 2_500;
34
+ const ARK_CLI_STDERR_TRIM = 400;
35
+ const ARK_PROVIDER_TIMEOUT_MS = 15_000;
36
+ const ARK_PROVIDER_BUDGET_GUARD_MS = 1_500;
37
+
38
+ // arkcli period label -> canonical window slot.
39
+ // Agent Plan uses `5h` for the 5-hour rolling window; `daily` is a
40
+ // visual-only quota not shown in the 3-window panel.
41
+ const ARK_PERIOD_WINDOW = {
42
+ "5h": "primary_window",
43
+ session: "primary_window",
44
+ weekly: "secondary_window",
45
+ monthly: "tertiary_window",
46
+ };
47
+
48
+ // Agent Plan personal product. Team products (agent-plan-team) are
49
+ // seat-scoped and not surfaced in this personal-quota panel.
50
+ const ARK_PERSONAL_PRODUCTS = ["agent-plan"];
51
+ const ARK_PRODUCT_PRIORITY = ["agent-plan"];
52
+
53
+ function clampPercent(value) {
54
+ return Math.max(0, Math.min(100, value));
55
+ }
56
+
57
+ function normalizeResetAt(value) {
58
+ if (value === null || value === undefined || value === "") return null;
59
+ if (typeof value === "number" || /^\d+(?:\.\d+)?$/.test(String(value).trim())) {
60
+ const raw = Number(value);
61
+ if (!Number.isFinite(raw)) return null;
62
+ const milliseconds = raw > 10_000_000_000 ? raw : raw * 1000;
63
+ const date = new Date(milliseconds);
64
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null;
65
+ }
66
+ const timestamp = Date.parse(String(value));
67
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
68
+ }
69
+
70
+ function planLabelForTier(tier) {
71
+ const normalized = String(tier || "").trim().toLowerCase();
72
+ const mapping = {
73
+ pro: "Pro",
74
+ lite: "Lite",
75
+ small: "Small",
76
+ medium: "Medium",
77
+ large: "Large",
78
+ max: "Max",
79
+ };
80
+ if (mapping[normalized]) return mapping[normalized];
81
+ return tier && String(tier).trim() ? String(tier).trim() : null;
82
+ }
83
+
84
+ /**
85
+ * Normalize the JSON payload returned by `arkcli plans get --format json`.
86
+ * Returns null when there is no Agent Plan entry.
87
+ */
88
+ function normalizeArkAgentPlansResponse(body) {
89
+ if (!body || typeof body !== "object") return null;
90
+ const plans = Array.isArray(body.plans) ? body.plans : [];
91
+ for (const key of ARK_PRODUCT_PRIORITY) {
92
+ const plan = plans.find((entry) => entry?.key === key);
93
+ if (plan?.tier) return String(plan.tier);
94
+ }
95
+ return null;
96
+ }
97
+ // Backwards-compat alias used by older callers
98
+ const normalizeArkPlansResponse = normalizeArkAgentPlansResponse;
99
+
100
+ function arkProfileIdentity(body) {
101
+ const profile = body?.profile && typeof body.profile === "object" ? body.profile : body;
102
+ const name = typeof body?.profile === "string"
103
+ ? body.profile
104
+ : profile?.name || profile?.profile || profile?.profile_name;
105
+ let userId = profile?.user_id || profile?.userId || body?.user_id || body?.userId;
106
+ if (!userId) {
107
+ const trnMatch = String(profile?.owner_trn || "").match(/::(\d+):/);
108
+ if (trnMatch) userId = trnMatch[1];
109
+ else {
110
+ const keyMatch = String(profile?.identity_key || "").match(/-(\d+)$/);
111
+ if (keyMatch) userId = keyMatch[1];
112
+ }
113
+ }
114
+ const identity = [name, userId].filter(Boolean).join(":");
115
+ return identity || null;
116
+ }
117
+
118
+ function resolveArkProductItem(items) {
119
+ for (const product of ARK_PRODUCT_PRIORITY) {
120
+ const item = items.find((entry) => entry?.product === product && entry?.subscribed === true);
121
+ if (item) return item;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ function percentFromPeriod(period) {
127
+ // null / undefined / blank percent must fall through to the used/total
128
+ // fallback: Number(null) and Number("") both coerce to 0, which would
129
+ // otherwise report a fake fresh 0% window instead of deriving real usage
130
+ // (the Agent Plan 5h period can omit percent entirely, #555).
131
+ const rawPercent = period?.percent;
132
+ if (rawPercent !== null && rawPercent !== undefined && String(rawPercent).trim() !== "") {
133
+ const direct = Number(rawPercent);
134
+ if (Number.isFinite(direct)) return direct;
135
+ }
136
+ const used = Number(period?.used);
137
+ const total = Number(period?.total);
138
+ if (Number.isFinite(used) && Number.isFinite(total) && total > 0) {
139
+ return (used / total) * 100;
140
+ }
141
+ return NaN;
142
+ }
143
+
144
+ /**
145
+ * Normalize the JSON payload returned by `arkcli usage plan --format json`.
146
+ * Returns `null` when the account has no active Agent Plan subscription
147
+ * (caller reports `configured: false`). Throws when the payload shape is
148
+ * unusable so the caller can fall back to the disk cache.
149
+ */
150
+ function normalizeArkAgentPlanResponse(body) {
151
+ if (!body || typeof body !== "object") {
152
+ throw new Error("Ark Agent Plan response is not an object.");
153
+ }
154
+ const items = Array.isArray(body.items) ? body.items : [];
155
+ const item = resolveArkProductItem(items);
156
+ if (!item) return null;
157
+
158
+ const windows = {};
159
+ const periods = Array.isArray(item.periods) ? item.periods : [];
160
+ for (const period of periods) {
161
+ if (period?.label === "daily") continue;
162
+ const slot = ARK_PERIOD_WINDOW[period?.label];
163
+ if (!slot) continue;
164
+ const percent = percentFromPeriod(period);
165
+ if (!Number.isFinite(percent)) continue;
166
+ windows[slot] = {
167
+ used_percent: clampPercent(percent),
168
+ reset_at: normalizeResetAt(period.reset_at),
169
+ unit: "calls",
170
+ };
171
+ }
172
+ if (!windows.primary_window && !windows.secondary_window && !windows.tertiary_window) {
173
+ throw new Error("Ark Agent Plan response contains no usable quota periods.");
174
+ }
175
+
176
+ return {
177
+ configured: true,
178
+ error: null,
179
+ plan_label: planLabelForTier(item.tier),
180
+ product: item.product || null,
181
+ primary_window: windows.primary_window || null,
182
+ secondary_window: windows.secondary_window || null,
183
+ tertiary_window: windows.tertiary_window || null,
184
+ source: "provider-api",
185
+ profile_identity: arkProfileIdentity(body.viewer),
186
+ };
187
+ }
188
+ // Alias for shared shape
189
+ const normalizeArkCodingPlanResponse = normalizeArkAgentPlanResponse;
190
+
191
+ function arkAgentPlanCachePath({ home = os.homedir() } = {}) {
192
+ return path.join(home, ".tokentracker", "tracker", ARK_LIMITS_CACHE_FILE);
193
+ }
194
+
195
+ function readArkAgentPlanLimitsCache({ home = os.homedir(), nowMs = Date.now(), profileIdentity } = {}) {
196
+ try {
197
+ const parsed = JSON.parse(fs.readFileSync(arkAgentPlanCachePath({ home }), "utf8"));
198
+ if (profileIdentity && parsed?.profile_identity !== profileIdentity) return null;
199
+ const cachedAtMs = Date.parse(parsed?.cached_at || "");
200
+ if (!Number.isFinite(cachedAtMs) || cachedAtMs > nowMs + 60_000) return null;
201
+ const windows = [parsed?.primary_window, parsed?.secondary_window, parsed?.tertiary_window];
202
+ const surviving = windows.map((window) => {
203
+ if (!window) return null;
204
+ const resetAtMs = Date.parse(window.reset_at || "");
205
+ if (Number.isFinite(resetAtMs) && resetAtMs <= nowMs) return null;
206
+ return window;
207
+ });
208
+ if (surviving.every((window) => !window)) return null;
209
+ const bounded = surviving.map((window) => {
210
+ if (!window) return null;
211
+ return Number.isFinite(Date.parse(window.reset_at || ""))
212
+ || nowMs - cachedAtMs <= ARK_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS
213
+ ? window
214
+ : null;
215
+ });
216
+ if (bounded.every((window) => !window)) return null;
217
+ return {
218
+ configured: true,
219
+ error: null,
220
+ plan_label: typeof parsed?.plan_label === "string" ? parsed.plan_label : null,
221
+ product: typeof parsed?.product === "string" ? parsed.product : null,
222
+ primary_window: bounded[0],
223
+ secondary_window: bounded[1],
224
+ tertiary_window: bounded[2],
225
+ cached: true,
226
+ stale: true,
227
+ cached_at: parsed.cached_at,
228
+ source: "disk-cache",
229
+ };
230
+ } catch (_error) {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ function writeArkAgentPlanLimitsCache(limits, { home = os.homedir(), nowMs = Date.now() } = {}) {
236
+ if (!limits?.configured || limits.error) return;
237
+ const cachePath = arkAgentPlanCachePath({ home });
238
+ const payload = {
239
+ plan_label: limits.plan_label || null,
240
+ product: limits.product || null,
241
+ profile_identity: limits.profile_identity || null,
242
+ primary_window: limits.primary_window || null,
243
+ secondary_window: limits.secondary_window || null,
244
+ tertiary_window: limits.tertiary_window || null,
245
+ cached_at: new Date(nowMs).toISOString(),
246
+ };
247
+ try {
248
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
249
+ const tmpPath = `${cachePath}.${process.pid}.tmp`;
250
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
251
+ fs.renameSync(tmpPath, cachePath);
252
+ } catch (_error) {}
253
+ }
254
+
255
+ function hasArkCliInstallEvidence({ home = os.homedir(), platform = process.platform } = {}) {
256
+ const candidates = [
257
+ path.join(home, ".arkcli"),
258
+ path.join(home, ".config", "arkcli"),
259
+ ];
260
+ if (platform === "win32") {
261
+ candidates.push(path.join(home, "AppData", "Roaming", "arkcli"));
262
+ }
263
+ for (const dir of candidates) {
264
+ try {
265
+ if (fs.statSync(dir).isDirectory()) return true;
266
+ } catch (_error) {}
267
+ }
268
+ return false;
269
+ }
270
+
271
+ function trimStderr(stderr) {
272
+ const text = String(stderr || "").trim();
273
+ if (!text) return null;
274
+ return text.length > ARK_CLI_STDERR_TRIM
275
+ ? `${text.slice(0, ARK_CLI_STDERR_TRIM)}…`
276
+ : text;
277
+ }
278
+
279
+ /**
280
+ * Fetch Ark Agent Plan quota windows from the local `arkcli` binary.
281
+ */
282
+ async function fetchArkAgentPlanLimits({
283
+ commandRunner,
284
+ home = os.homedir(),
285
+ nowMs = Date.now(),
286
+ platform = process.platform,
287
+ signal,
288
+ globalBinDirs,
289
+ providerTimeoutMs = ARK_PROVIDER_TIMEOUT_MS,
290
+ } = {}) {
291
+ const startedAtMs = performance.now();
292
+ const budgetedTimeoutMs = (fullTimeoutMs) => {
293
+ if (!Number.isFinite(providerTimeoutMs) || providerTimeoutMs <= 0) return fullTimeoutMs;
294
+ const remainingMs = providerTimeoutMs - (performance.now() - startedAtMs);
295
+ if (remainingMs <= 0) return 0;
296
+ const guardedMs = Math.floor(remainingMs - ARK_PROVIDER_BUDGET_GUARD_MS);
297
+ if (guardedMs <= 0) return 0;
298
+ return Math.min(fullTimeoutMs, guardedMs);
299
+ };
300
+
301
+ const searchDirs = () => Array.isArray(globalBinDirs)
302
+ ? globalBinDirs
303
+ : commonGlobalBinDirectories({ home, platform });
304
+
305
+ let arkcliPath;
306
+ if (hasArkCliInstallEvidence({ home, platform })) {
307
+ try {
308
+ arkcliPath = await resolveBinaryPath("arkcli", { commandRunner, home, platform, signal, globalBinDirs });
309
+ } catch (_error) {
310
+ arkcliPath = null;
311
+ }
312
+ } else {
313
+ arkcliPath = statBinaryInDirs("arkcli", searchDirs(), platform);
314
+ }
315
+ if (!arkcliPath) return { configured: false };
316
+
317
+ const commandOptions = {
318
+ signal,
319
+ killProcessGroup: true,
320
+ platform,
321
+ useShell: platform === "win32",
322
+ };
323
+
324
+ const usageTimeoutMs = budgetedTimeoutMs(ARK_USAGE_PLAN_TIMEOUT_MS);
325
+ if (usageTimeoutMs <= 0) {
326
+ return { configured: true, error: "Ark Agent Plan provider timed out before arkcli could run." };
327
+ }
328
+
329
+ const result = await runCommand(
330
+ commandRunner,
331
+ arkcliPath,
332
+ ["usage", "plan", "--format", "json"],
333
+ { ...commandOptions, timeout: usageTimeoutMs },
334
+ );
335
+
336
+ const failWithCache = async (message) => {
337
+ const profileTimeoutMs = budgetedTimeoutMs(ARK_PROFILE_SHOW_TIMEOUT_MS);
338
+ const profileIdentity = profileTimeoutMs > 0
339
+ ? await runCommand(
340
+ commandRunner,
341
+ arkcliPath,
342
+ ["profile", "show", "--format", "json"],
343
+ { ...commandOptions, timeout: profileTimeoutMs },
344
+ ).then((profileResult) => {
345
+ if (profileResult?.error || profileResult?.status !== 0) return null;
346
+ try {
347
+ return arkProfileIdentity(JSON.parse(String(profileResult.stdout || "")));
348
+ } catch (_error) {
349
+ return null;
350
+ }
351
+ }).catch(() => null)
352
+ : null;
353
+ const cached = readArkAgentPlanLimitsCache({ home, nowMs, profileIdentity });
354
+ if (cached) return cached;
355
+ return { configured: true, error: message };
356
+ };
357
+
358
+ if (result?.error || result?.status !== 0) {
359
+ const isExit127 = result?.status === 127;
360
+ const baseDetail = result?.error?.message
361
+ || (result?.status !== 0 && result?.status !== null
362
+ ? `arkcli exited with code ${result.status}`
363
+ : "arkcli usage plan failed");
364
+ const detail = isExit127
365
+ ? "arkcli exited with code 127 — please update arkcli: npm i -g @volcengine/ark-cli"
366
+ : baseDetail;
367
+ const stderr = trimStderr(result?.stderr);
368
+ const message = stderr && !isExit127 ? `${detail}: ${stderr}` : detail;
369
+ return failWithCache(message);
370
+ }
371
+
372
+ let body;
373
+ try {
374
+ body = JSON.parse(String(result?.stdout || ""));
375
+ } catch (_error) {
376
+ return failWithCache("arkcli usage plan returned invalid JSON.");
377
+ }
378
+
379
+ let limits;
380
+ try {
381
+ limits = normalizeArkAgentPlanResponse(body);
382
+ } catch (error) {
383
+ return failWithCache(error?.message || "Ark Agent Plan response could not be parsed.");
384
+ }
385
+ if (!limits) {
386
+ const entry = Array.isArray(body?.items)
387
+ ? body.items.find(
388
+ (candidate) =>
389
+ ARK_PERSONAL_PRODUCTS.includes(candidate?.product) && candidate?.subscribed === false,
390
+ )
391
+ : null;
392
+ if (entry) {
393
+ try {
394
+ fs.unlinkSync(arkAgentPlanCachePath({ home }));
395
+ } catch (_error) {}
396
+ }
397
+ return { configured: false };
398
+ }
399
+
400
+ if (!limits.plan_label) {
401
+ const plansTimeoutMs = budgetedTimeoutMs(ARK_USAGE_PLAN_TIMEOUT_MS);
402
+ if (plansTimeoutMs > 0) {
403
+ const plansResult = await runCommand(
404
+ commandRunner,
405
+ arkcliPath,
406
+ ["plans", "get", "--format", "json"],
407
+ { ...commandOptions, timeout: plansTimeoutMs },
408
+ );
409
+ if (!plansResult?.error && plansResult?.status === 0) {
410
+ try {
411
+ const tier = normalizeArkAgentPlansResponse(JSON.parse(String(plansResult.stdout || "")));
412
+ if (tier) limits.plan_label = planLabelForTier(tier);
413
+ } catch (_error) {}
414
+ }
415
+ }
416
+ }
417
+
418
+ writeArkAgentPlanLimitsCache(limits, { home, nowMs });
419
+ return limits;
420
+ }
421
+
422
+ module.exports = {
423
+ ARK_PERIOD_WINDOW,
424
+ ARK_PERSONAL_PRODUCTS,
425
+ ARK_PRODUCT_PRIORITY,
426
+ normalizeArkAgentPlansResponse,
427
+ normalizeArkPlansResponse,
428
+ arkProfileIdentity,
429
+ normalizeArkAgentPlanResponse,
430
+ normalizeArkCodingPlanResponse,
431
+ readArkAgentPlanLimitsCache,
432
+ writeArkAgentPlanLimitsCache,
433
+ hasArkCliInstallEvidence,
434
+ fetchArkAgentPlanLimits,
435
+ resolveArkProductItem,
436
+ percentFromPeriod,
437
+ };
@@ -2,13 +2,17 @@
2
2
 
3
3
  // Ark Coding Plan (火山方舟 Coding Plan) quota monitoring.
4
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.
5
+ // Coding Plan (Lite/Pro) and Agent Plan (Small/Medium/Large/Max) are
6
+ // two parallel subscription products that each refresh on three windows
7
+ // 5-hour (session for Coding Plan, 5h for Agent Plan), weekly and monthly —
8
+ // and are shared by compatible coding tools (Claude Code, Codex CLI,
9
+ // OpenCode, TRAE, ...). Official docs present them as parallel, not
10
+ // successor/replacement. TokenTracker already counts those tools' token
11
+ // consumption from their local files, so this module deliberately adds
12
+ // NO consumption source. It only surfaces the subscription quota
13
+ // percentage, which is otherwise only visible in the Volcano console web
14
+ // page. Agent Plan is handled by a separate provider module
15
+ // `ark-agent-plan-limits.js` (dual Provider, issue #555).
12
16
  //
13
17
  // The quota is read through the user's own `arkcli` binary
14
18
  // (`arkcli usage plan --format json`), which is already installed and logged
@@ -43,14 +47,23 @@ const ARK_PROVIDER_TIMEOUT_MS = 15_000;
43
47
  // Same shape as codexResetCreditListTimeoutMs's guard in usage-limits.js.
44
48
  const ARK_PROVIDER_BUDGET_GUARD_MS = 1_500;
45
49
 
46
- // arkcli period label -> canonical window slot. `session` is the 5-hour
47
- // rolling window; `weekly` and `monthly` refresh on calendar boundaries.
50
+ // arkcli period label -> canonical window slot.
51
+ // Coding Plan uses `session` for the 5-hour rolling window; Agent Plan
52
+ // uses `5h`. `weekly` and `monthly` are shared. `daily` (Agent Plan
53
+ // visual quota) is intentionally not surfaced — it is a separate
54
+ // per-day visual bucket that does not fit the three-window panel.
48
55
  const ARK_PERIOD_WINDOW = {
49
56
  session: "primary_window",
57
+ "5h": "primary_window",
50
58
  weekly: "secondary_window",
51
59
  monthly: "tertiary_window",
52
60
  };
53
61
 
62
+ // Coding Plan personal product. Team products (coding-plan-team) are
63
+ // seat-scoped and not surfaced in this personal-quota panel.
64
+ const ARK_PERSONAL_PRODUCTS = ["coding-plan"];
65
+ const ARK_PRODUCT_PRIORITY = ["coding-plan"];
66
+
54
67
  function clampPercent(value) {
55
68
  return Math.max(0, Math.min(100, value));
56
69
  }
@@ -73,22 +86,33 @@ function normalizeResetAt(value) {
73
86
  // "Ark Coding Plan Coding Plan Lite".
74
87
  function planLabelForTier(tier) {
75
88
  const normalized = String(tier || "").trim().toLowerCase();
76
- if (normalized === "pro") return "Pro";
77
- if (normalized === "lite") return "Lite";
89
+ const mapping = {
90
+ pro: "Pro",
91
+ lite: "Lite",
92
+ small: "Small",
93
+ medium: "Medium",
94
+ large: "Large",
95
+ max: "Max",
96
+ };
97
+ if (mapping[normalized]) return mapping[normalized];
78
98
  return tier && String(tier).trim() ? String(tier).trim() : null;
79
99
  }
80
100
 
81
101
  /**
82
102
  * 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.
103
+ * The tier ("lite" / "pro" for Coding Plan) lives on the plans payload,
104
+ * not on the usage payload, so it is resolved here and merged into the
105
+ * plan label.
85
106
  * Returns null when there is no Coding Plan entry.
86
107
  */
87
108
  function normalizeArkPlansResponse(body) {
88
109
  if (!body || typeof body !== "object") return null;
89
110
  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;
111
+ for (const key of ARK_PRODUCT_PRIORITY) {
112
+ const plan = plans.find((entry) => entry?.key === key);
113
+ if (plan?.tier) return String(plan.tier);
114
+ }
115
+ return null;
92
116
  }
93
117
 
94
118
  function arkProfileIdentity(body) {
@@ -114,26 +138,56 @@ function arkProfileIdentity(body) {
114
138
  return identity || null;
115
139
  }
116
140
 
141
+ function resolveArkProductItem(items) {
142
+ for (const product of ARK_PRODUCT_PRIORITY) {
143
+ const item = items.find((entry) => entry?.product === product && entry?.subscribed === true);
144
+ if (item) return item;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function percentFromPeriod(period) {
150
+ // null / undefined / blank percent must fall through to the used/total
151
+ // fallback: Number(null) and Number("") both coerce to 0, which would
152
+ // otherwise report a fake fresh 0% window instead of deriving real usage
153
+ // (Agent Plan's 5h period can omit percent entirely, #555).
154
+ const rawPercent = period?.percent;
155
+ if (rawPercent !== null && rawPercent !== undefined && String(rawPercent).trim() !== "") {
156
+ const direct = Number(rawPercent);
157
+ if (Number.isFinite(direct)) return direct;
158
+ }
159
+ const used = Number(period?.used);
160
+ const total = Number(period?.total);
161
+ if (Number.isFinite(used) && Number.isFinite(total) && total > 0) {
162
+ return (used / total) * 100;
163
+ }
164
+ return NaN;
165
+ }
166
+
117
167
  /**
118
168
  * Normalize the JSON payload returned by `arkcli usage plan --format json`.
119
169
  * Returns `null` when the account has no active Coding Plan subscription
120
170
  * (caller reports `configured: false`). Throws when the payload shape is
121
171
  * unusable so the caller can fall back to the disk cache.
172
+ * Only `coding-plan` (Lite/Pro, session label) is handled here;
173
+ * Agent Plan is handled by `ark-agent-plan-limits.js`.
122
174
  */
123
175
  function normalizeArkCodingPlanResponse(body) {
124
176
  if (!body || typeof body !== "object") {
125
177
  throw new Error("Ark Coding Plan response is not an object.");
126
178
  }
127
179
  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;
180
+ const item = resolveArkProductItem(items);
181
+ if (!item) return null;
130
182
 
131
183
  const windows = {};
132
184
  const periods = Array.isArray(item.periods) ? item.periods : [];
133
185
  for (const period of periods) {
186
+ // Agent Plan `daily` is a visual-only quota not shown in the 3-window panel.
187
+ if (period?.label === "daily") continue;
134
188
  const slot = ARK_PERIOD_WINDOW[period?.label];
135
189
  if (!slot) continue;
136
- const percent = Number(period.percent);
190
+ const percent = percentFromPeriod(period);
137
191
  if (!Number.isFinite(percent)) continue;
138
192
  windows[slot] = {
139
193
  used_percent: clampPercent(percent),
@@ -149,6 +203,7 @@ function normalizeArkCodingPlanResponse(body) {
149
203
  configured: true,
150
204
  error: null,
151
205
  plan_label: planLabelForTier(item.tier),
206
+ product: item.product || null,
152
207
  primary_window: windows.primary_window || null,
153
208
  secondary_window: windows.secondary_window || null,
154
209
  tertiary_window: windows.tertiary_window || null,
@@ -194,6 +249,7 @@ function readArkCodingPlanLimitsCache({ home = os.homedir(), nowMs = Date.now(),
194
249
  configured: true,
195
250
  error: null,
196
251
  plan_label: typeof parsed?.plan_label === "string" ? parsed.plan_label : null,
252
+ product: typeof parsed?.product === "string" ? parsed.product : null,
197
253
  primary_window: bounded[0],
198
254
  secondary_window: bounded[1],
199
255
  tertiary_window: bounded[2],
@@ -212,6 +268,7 @@ function writeArkCodingPlanLimitsCache(limits, { home = os.homedir(), nowMs = Da
212
268
  const cachePath = arkCodingPlanCachePath({ home });
213
269
  const payload = {
214
270
  plan_label: limits.plan_label || null,
271
+ product: limits.product || null,
215
272
  profile_identity: limits.profile_identity || null,
216
273
  primary_window: limits.primary_window || null,
217
274
  secondary_window: limits.secondary_window || null,
@@ -384,12 +441,22 @@ async function fetchArkCodingPlanLimits({
384
441
  };
385
442
 
386
443
  if (result?.error || result?.status !== 0) {
387
- const detail = result?.error?.message
444
+ const isExit127 = result?.status === 127;
445
+ const baseDetail = result?.error?.message
388
446
  || (result?.status !== 0 && result?.status !== null
389
447
  ? `arkcli exited with code ${result.status}`
390
448
  : "arkcli usage plan failed");
449
+ // 127 is the classic "command not found" — on a machine with arkcli
450
+ // installed it almost always means the installed arkcli is too old to
451
+ // support `usage plan --format json` or the binary shim is broken.
452
+ // Surface an actionable hint while still honoring the disk-cache fallback.
453
+ const detail = isExit127
454
+ ? "arkcli exited with code 127 — please update arkcli: npm i -g @volcengine/ark-cli"
455
+ : baseDetail;
391
456
  const stderr = trimStderr(result?.stderr);
392
- return failWithCache(stderr ? `${detail}: ${stderr}` : detail);
457
+ // Avoid duplicating the stale 127 hint when stderr already contains it.
458
+ const message = stderr && !isExit127 ? `${detail}: ${stderr}` : detail;
459
+ return failWithCache(message);
393
460
  }
394
461
 
395
462
  let body;
@@ -406,17 +473,22 @@ async function fetchArkCodingPlanLimits({
406
473
  return failWithCache(error?.message || "Ark Coding Plan response could not be parsed.");
407
474
  }
408
475
  if (!limits) {
409
- // Only a response that explicitly carries the coding-plan entry with
476
+ // Only a response that explicitly carries an Ark product entry with
410
477
  // `subscribed: false` confirms the plan was retired — drop the cache
411
478
  // 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.
479
+ // plan's numbers through failWithCache. A payload with *no* Ark entry
480
+ // at all (`{}`, `{"items":[]}` — not logged in, backend degraded,
481
+ // product key renamed) is ambiguous and must NOT destroy the cache:
482
+ // transient signals never drive persistent state.
483
+ // Only coding-plan is considered here; agent-plan is handled by the
484
+ // parallel provider `ark-agent-plan-limits.js`.
416
485
  const entry = Array.isArray(body?.items)
417
- ? body.items.find((candidate) => candidate?.product === "coding-plan")
486
+ ? body.items.find(
487
+ (candidate) =>
488
+ ARK_PERSONAL_PRODUCTS.includes(candidate?.product) && candidate?.subscribed === false,
489
+ )
418
490
  : null;
419
- if (entry && entry.subscribed === false) {
491
+ if (entry) {
420
492
  try {
421
493
  fs.unlinkSync(arkCodingPlanCachePath({ home }));
422
494
  } catch (_error) {}
@@ -452,6 +524,8 @@ async function fetchArkCodingPlanLimits({
452
524
 
453
525
  module.exports = {
454
526
  ARK_PERIOD_WINDOW,
527
+ ARK_PERSONAL_PRODUCTS,
528
+ ARK_PRODUCT_PRIORITY,
455
529
  normalizeArkPlansResponse,
456
530
  arkProfileIdentity,
457
531
  normalizeArkCodingPlanResponse,
@@ -459,4 +533,7 @@ module.exports = {
459
533
  writeArkCodingPlanLimitsCache,
460
534
  hasArkCliInstallEvidence,
461
535
  fetchArkCodingPlanLimits,
536
+ // exposed for testing
537
+ resolveArkProductItem,
538
+ percentFromPeriod,
462
539
  };