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.
Files changed (108) hide show
  1. package/bin/tracker.js +9 -1
  2. package/dashboard/dist/assets/{AchievementBadge-BpDy2XSt.js → AchievementBadge-BDPa7twL.js} +1 -1
  3. package/dashboard/dist/assets/{AchievementsPage-Z3rNbZAL.js → AchievementsPage-CQoThMTm.js} +2 -2
  4. package/dashboard/dist/assets/{AchievementsSection-Cawmrzgp.js → AchievementsSection-D4Uij6_V.js} +1 -1
  5. package/dashboard/dist/assets/{BadgeDetailModal-D8IPOyzs.js → BadgeDetailModal-DIyhyfZn.js} +1 -1
  6. package/dashboard/dist/assets/{Card-Bco5i8Fn.js → Card-C9Qq3dRv.js} +1 -1
  7. package/dashboard/dist/assets/{ClawdAnimated-RQvg50Wr.js → ClawdAnimated-DvNwXeAg.js} +1 -1
  8. package/dashboard/dist/assets/{CommandPalette-DWLyd7P_.js → CommandPalette-Bx_XQD25.js} +1 -1
  9. package/dashboard/dist/assets/{CommunityStatsModal-DfjsjuRG.js → CommunityStatsModal-CJN0niLH.js} +1 -1
  10. package/dashboard/dist/assets/{ConfirmModal-DNuSWkt3.js → ConfirmModal-CCEwK_z5.js} +1 -1
  11. package/dashboard/dist/assets/{Controls-DdLHdkEz.js → Controls-h-L9P_cI.js} +1 -1
  12. package/dashboard/dist/assets/{DashboardPage-DN54xqEE.js → DashboardPage-lskJQLFc.js} +1 -1
  13. package/dashboard/dist/assets/{DevicePage-ydAtX5oZ.js → DevicePage-CxwatSX3.js} +1 -1
  14. package/dashboard/dist/assets/{DialogClose-C40Az_dd.js → DialogClose-C3z3Trr2.js} +1 -1
  15. package/dashboard/dist/assets/DialogDescription-NmqWPZeZ.js +1 -0
  16. package/dashboard/dist/assets/{DialogTitle-CCg6RvL8.js → DialogTitle-BlKXVRCZ.js} +1 -1
  17. package/dashboard/dist/assets/{FadeIn-a7dOxD0I.js → FadeIn-BWJwSozb.js} +1 -1
  18. package/dashboard/dist/assets/{HeaderGithubStar-DmH6JYQF.js → HeaderGithubStar-iQIqNWX8.js} +1 -1
  19. package/dashboard/dist/assets/{HoverTooltip-D3yTRqfx.js → HoverTooltip-Doqf4LkR.js} +1 -1
  20. package/dashboard/dist/assets/{IpCheckPage-DH4av7RV.js → IpCheckPage-CAQOcumi.js} +1 -1
  21. package/dashboard/dist/assets/{LandingPage-B1fuvkPh.js → LandingPage-C55ZuQ4S.js} +2 -2
  22. package/dashboard/dist/assets/{LeaderboardAvatar-C-tKTfIs.js → LeaderboardAvatar-AwdzkUva.js} +1 -1
  23. package/dashboard/dist/assets/{LeaderboardPage-CL7Cre5j.js → LeaderboardPage-De-XqDgp.js} +2 -2
  24. package/dashboard/dist/assets/{LeaderboardProfileModal-CqRvLdEB.js → LeaderboardProfileModal-Bs1jCBZR.js} +3 -3
  25. package/dashboard/dist/assets/{LeaderboardProfilePage-BDjW5z87.js → LeaderboardProfilePage-B7hvba9_.js} +1 -1
  26. package/dashboard/dist/assets/LimitsPage-bYc_9RQC.js +5 -0
  27. package/dashboard/dist/assets/{LocalOnlyNotice-CQDKfXwB.js → LocalOnlyNotice-BWFxeB2d.js} +1 -1
  28. package/dashboard/dist/assets/{LoginCard-CptsVdtq.js → LoginCard-BEhQV-kF.js} +1 -1
  29. package/dashboard/dist/assets/{LoginModal-8V4Vsgpr.js → LoginModal-BnIpMaiu.js} +1 -1
  30. package/dashboard/dist/assets/{LoginPage-DIwzC_95.js → LoginPage-lOoS6omw.js} +1 -1
  31. package/dashboard/dist/assets/{PetPage-CXSq_C6b.js → PetPage-CBxkB0FQ.js} +1 -1
  32. package/dashboard/dist/assets/{PopoverPopup-C_6kkB4_.js → PopoverPopup-mRFMXjMo.js} +1 -1
  33. package/dashboard/dist/assets/{ProviderIcon-BKGMzdEg.js → ProviderIcon-CYUc_5_Z.js} +1 -1
  34. package/dashboard/dist/assets/{ResetPasswordPage-BThSQVj7.js → ResetPasswordPage-BydyI8Te.js} +1 -1
  35. package/dashboard/dist/assets/{SegmentedControl-DdIw-7OY.js → SegmentedControl-LfSKYbfp.js} +1 -1
  36. package/dashboard/dist/assets/{Select-BuxVjVTV.js → Select-UiEhxqur.js} +1 -1
  37. package/dashboard/dist/assets/{SelectItemText-B7AP9a2t.js → SelectItemText-DiVTS64c.js} +1 -1
  38. package/dashboard/dist/assets/{ServiceStatusPage-Db6XpKGJ.js → ServiceStatusPage-krg3Du1w.js} +1 -1
  39. package/dashboard/dist/assets/{SessionsPage-CIvvinQm.js → SessionsPage-D28m0rcm.js} +1 -1
  40. package/dashboard/dist/assets/SettingsPage-DiVhaU-X.js +2 -0
  41. package/dashboard/dist/assets/{SkillsPage-2rr2nzvc.js → SkillsPage-CXg55JZ3.js} +1 -1
  42. package/dashboard/dist/assets/{TokenGalaxy-WS2JBmzs.js → TokenGalaxy-CymWb45G.js} +1 -1
  43. package/dashboard/dist/assets/{ToolbarRootContext-B5s-ucZR.js → ToolbarRootContext-D8SpeV_Z.js} +1 -1
  44. package/dashboard/dist/assets/{TrendMonitor-DfgZi0b3.js → TrendMonitor-ZI8UVyUl.js} +1 -1
  45. package/dashboard/dist/assets/{WidgetsPage-WWaK3S0S.js → WidgetsPage-3HI5iI7R.js} +1 -1
  46. package/dashboard/dist/assets/{WrappedPage-C3C85NLm.js → WrappedPage-BK8eyxWF.js} +1 -1
  47. package/dashboard/dist/assets/{__vite-browser-external-B-CAZ5HB.js → __vite-browser-external-B7TLlrta.js} +63 -18
  48. package/dashboard/dist/assets/{agent-logos-Do1kFgsi.js → agent-logos-BVrhjlzq.js} +1 -1
  49. package/dashboard/dist/assets/{arrow-up-right-DbomORWZ.js → arrow-up-right-BH3-SjFz.js} +1 -1
  50. package/dashboard/dist/assets/{check-DzEvx9pJ.js → check-B9cFJZgF.js} +1 -1
  51. package/dashboard/dist/assets/{chevron-right-DZTnF2NM.js → chevron-right-Bo86g4wO.js} +1 -1
  52. package/dashboard/dist/assets/{copy-BS7rQ_jR.js → copy-j7Scp8t1.js} +1 -1
  53. package/dashboard/dist/assets/{download-DlWVR14_.js → download-BUt01rg_.js} +1 -1
  54. package/dashboard/dist/assets/{external-link-CtVBRLbf.js → external-link-DzxpdqJI.js} +1 -1
  55. package/dashboard/dist/assets/{flame-Bd3FUTdZ.js → flame-CkRd9KBe.js} +1 -1
  56. package/dashboard/dist/assets/{format-tokens--QLNTkZB.js → format-tokens-B1pCqj3T.js} +1 -1
  57. package/dashboard/dist/assets/{icons-Cs8SXqmU.js → icons-DbEfSumr.js} +1 -1
  58. package/dashboard/dist/assets/{index-Cj7qHXa4.js → index-3-T0VHz-.js} +1 -1
  59. package/dashboard/dist/assets/{index-DoTYGePn.js → index-5a1G4YuE.js} +1 -1
  60. package/dashboard/dist/assets/{index-CfS3tuU1.js → index-CSGxiVQp.js} +1 -1
  61. package/dashboard/dist/assets/{info-CsAt6S8y.js → info-nIQW2hmu.js} +1 -1
  62. package/dashboard/dist/assets/limits-providers-DUfDGI3w.js +1 -0
  63. package/dashboard/dist/assets/{link-2-CjC85wrH.js → link-2-BIma2gRd.js} +1 -1
  64. package/dashboard/dist/assets/{loader-circle-Cf0h8jN7.js → loader-circle-DNwHxZy4.js} +1 -1
  65. package/dashboard/dist/assets/{maximize-2-DSLJns7Q.js → maximize-2-RnscHIM8.js} +1 -1
  66. package/dashboard/dist/assets/{modifiers.esm-2DRBUt-i.js → modifiers.esm-Blvm8Jbj.js} +1 -1
  67. package/dashboard/dist/assets/{monitor-smartphone-DitUxFt7.js → monitor-smartphone-BH-q52Ze.js} +1 -1
  68. package/dashboard/dist/assets/{provider-display-Dm_uiVV1.js → provider-display-C_BMuv2U.js} +1 -1
  69. package/dashboard/dist/assets/{react-CExpcnBO.js → react-CsQaHrby.js} +1 -1
  70. package/dashboard/dist/assets/{search-S2Q8oj2Y.js → search-DlK17ul0.js} +1 -1
  71. package/dashboard/dist/assets/{skills-api-C3mlKc8x.js → skills-api-DaCUcoqM.js} +1 -1
  72. package/dashboard/dist/assets/{sortable.esm-22qRLWIc.js → sortable.esm-CJh9Zk1m.js} +1 -1
  73. package/dashboard/dist/assets/{store-DktMCl4T.js → store-DByB-W50.js} +1 -1
  74. package/dashboard/dist/assets/{terminal-DS6Tqgok.js → terminal-C5HhEMI5.js} +1 -1
  75. package/dashboard/dist/assets/{trash-2-BHgzn2Bd.js → trash-2-9Vxl6ZF4.js} +1 -1
  76. package/dashboard/dist/assets/{use-community-stats-DqQQKHPK.js → use-community-stats-DTQlvI-1.js} +1 -1
  77. package/dashboard/dist/assets/{use-limits-display-prefs-C2fbOm_s.js → use-limits-display-prefs-Du018pOW.js} +1 -1
  78. package/dashboard/dist/assets/{use-ordered-list-BnIpnofG.js → use-ordered-list-D5RWUoYn.js} +1 -1
  79. package/dashboard/dist/assets/use-reduced-motion-BsIV3-9R.js +1 -0
  80. package/dashboard/dist/assets/{use-session-efficiency-pref-B78vQ2pp.js → use-session-efficiency-pref-fttmU-6p.js} +1 -1
  81. package/dashboard/dist/assets/{use-transform-DJeSBZmb.js → use-transform-BErbwrSs.js} +1 -1
  82. package/dashboard/dist/assets/{use-usage-limits-BkSQGzCV.js → use-usage-limits-C6Npx5q3.js} +1 -1
  83. package/dashboard/dist/assets/{useCurrency-BY7ZJ-XD.js → useCurrency-DAj-AqX-.js} +1 -1
  84. package/dashboard/dist/assets/{useScrollLock-CHsj-WN5.js → useScrollLock-BhgbzYuS.js} +1 -1
  85. package/dashboard/dist/assets/{useTokenFormat-CVJJAs3R.js → useTokenFormat-jEoapvgD.js} +1 -1
  86. package/dashboard/dist/assets/{zap-Dtudxu92.js → zap-uQ4fo5lp.js} +1 -1
  87. package/dashboard/dist/brand-logos/volcano-ark.svg +14 -0
  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/commands/status.js +3 -3
  94. package/src/lib/ark-coding-plan-limits.js +462 -0
  95. package/src/lib/command-runner.js +286 -0
  96. package/src/lib/cursor-config.js +84 -86
  97. package/src/lib/fs.js +11 -2
  98. package/src/lib/local-api.js +137 -0
  99. package/src/lib/pricing/seed-snapshot.json +1 -1
  100. package/src/lib/proxy-env.js +397 -44
  101. package/src/lib/proxy-settings.js +163 -0
  102. package/src/lib/rollout.js +80 -39
  103. package/src/lib/usage-limits.js +40 -142
  104. package/dashboard/dist/assets/DialogDescription-CNfc42U-.js +0 -1
  105. package/dashboard/dist/assets/LimitsPage-D533J_we.js +0 -4
  106. package/dashboard/dist/assets/SettingsPage-DZo5FGJE.js +0 -1
  107. package/dashboard/dist/assets/limits-providers-Czh_yu22.js +0 -1
  108. 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
+ };