tokentracker-cli 0.95.2 → 0.96.1

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 (118) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +1 -1
  3. package/dashboard/dist/assets/{AchievementBadge-DtB1MwFe.js → AchievementBadge-DKyElm0T.js} +1 -1
  4. package/dashboard/dist/assets/{AchievementsPage--vddu0dC.js → AchievementsPage-BvCx3wND.js} +2 -2
  5. package/dashboard/dist/assets/{AchievementsSection-CDl9hsbj.js → AchievementsSection-DN69LzXX.js} +1 -1
  6. package/dashboard/dist/assets/{BadgeDetailModal-C7WsIRy9.js → BadgeDetailModal-DGiKdZfz.js} +1 -1
  7. package/dashboard/dist/assets/{BotAnimated-CmXyzIhX.js → BotAnimated-zN8rH7_K.js} +1 -1
  8. package/dashboard/dist/assets/{Card-SUbC3VNS.js → Card-COWo-ClV.js} +1 -1
  9. package/dashboard/dist/assets/{ClawdAnimated-BdEbs8Me.js → ClawdAnimated-BWsB7Eat.js} +2 -2
  10. package/dashboard/dist/assets/{CommandPalette-D1RLLeHB.js → CommandPalette-BfvIwuoJ.js} +1 -1
  11. package/dashboard/dist/assets/{CommunityStatsModal-5ESGJgZQ.js → CommunityStatsModal-BQWg7R_y.js} +1 -1
  12. package/dashboard/dist/assets/{ConfirmModal-DsCd51b2.js → ConfirmModal-C7ABSI-O.js} +1 -1
  13. package/dashboard/dist/assets/{Controls-BVrLC_QD.js → Controls-vMzEZzej.js} +1 -1
  14. package/dashboard/dist/assets/DashboardPage-QBhbCMZF.js +1 -0
  15. package/dashboard/dist/assets/{DevicePage-zYjj-m5V.js → DevicePage-3ejUBLWM.js} +1 -1
  16. package/dashboard/dist/assets/{DialogClose-D66yBqkd.js → DialogClose-D_dvtfGV.js} +1 -1
  17. package/dashboard/dist/assets/DialogDescription-ryGYdQjX.js +1 -0
  18. package/dashboard/dist/assets/{DialogTitle-C4ia00lc.js → DialogTitle-_ahf0z8b.js} +1 -1
  19. package/dashboard/dist/assets/{FadeIn-DF2c8XaR.js → FadeIn-D7Jcfz1N.js} +1 -1
  20. package/dashboard/dist/assets/{HeaderGithubStar-Cnr3aqcX.js → HeaderGithubStar-D--Rx5BS.js} +1 -1
  21. package/dashboard/dist/assets/{HoverTooltip-DBOpxlH7.js → HoverTooltip-n1W0z3Wr.js} +1 -1
  22. package/dashboard/dist/assets/{IpCheckPage-CUY6KBaV.js → IpCheckPage-D202tl9B.js} +1 -1
  23. package/dashboard/dist/assets/{LandingPage-DKL8hZEq.js → LandingPage-D6yL2EZo.js} +2 -2
  24. package/dashboard/dist/assets/{LeaderboardAvatar-DRYeMNlq.js → LeaderboardAvatar-ChFg2mtA.js} +1 -1
  25. package/dashboard/dist/assets/{LeaderboardPage-DpD9Az3k.js → LeaderboardPage-BXHwGs8r.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProfileModal-Dfg0V13F.js → LeaderboardProfileModal-B5LoOpCk.js} +3 -3
  27. package/dashboard/dist/assets/{LeaderboardProfilePage-DH-jcVDu.js → LeaderboardProfilePage-jsukhRz8.js} +1 -1
  28. package/dashboard/dist/assets/LimitsPage-PvqkOOwp.js +6 -0
  29. package/dashboard/dist/assets/{LocalOnlyNotice-Bj_m0hw7.js → LocalOnlyNotice-Du0lz657.js} +1 -1
  30. package/dashboard/dist/assets/{LoginCard-BVKvtx60.js → LoginCard-BFEX9x7-.js} +1 -1
  31. package/dashboard/dist/assets/{LoginModal-mUGiSIJN.js → LoginModal-DqC7_d35.js} +1 -1
  32. package/dashboard/dist/assets/{LoginPage-BORCuCUt.js → LoginPage-Dzx8N7HE.js} +1 -1
  33. package/dashboard/dist/assets/{PetPage-D1JfEOVA.js → PetPage-CNlWHF4z.js} +1 -1
  34. package/dashboard/dist/assets/{PopoverPopup-DK3et-HG.js → PopoverPopup-D9jadD3j.js} +1 -1
  35. package/dashboard/dist/assets/{ProviderIcon-By8FAjxR.js → ProviderIcon-Caz5PuAm.js} +1 -1
  36. package/dashboard/dist/assets/{ResetPasswordPage-DPa81jqa.js → ResetPasswordPage-x_GR79sR.js} +1 -1
  37. package/dashboard/dist/assets/{SegmentedControl-DLzS-2eZ.js → SegmentedControl-BxDiDV4R.js} +1 -1
  38. package/dashboard/dist/assets/{Select-C05W6XEM.js → Select-B5o-1sEl.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-CcfB3blP.js → SelectItemText-CiA6L7Wk.js} +1 -1
  40. package/dashboard/dist/assets/{ServiceStatusPage-ByqLbHv1.js → ServiceStatusPage-BVQuVdjg.js} +1 -1
  41. package/dashboard/dist/assets/SessionsPage-DE39wNIO.js +2 -0
  42. package/dashboard/dist/assets/SettingsPage-DJrb_fsK.js +2 -0
  43. package/dashboard/dist/assets/{SkillsPage-BuuqQuNU.js → SkillsPage-COk-YQQ5.js} +1 -1
  44. package/dashboard/dist/assets/{TokenGalaxy-DzccxkXW.js → TokenGalaxy-BPM779Wb.js} +1 -1
  45. package/dashboard/dist/assets/{ToolbarRootContext-XUDMp5uy.js → ToolbarRootContext-CozJT6Vs.js} +1 -1
  46. package/dashboard/dist/assets/{TrendMonitor-B-yYabbz.js → TrendMonitor-BkNy-o1D.js} +1 -1
  47. package/dashboard/dist/assets/{WidgetsPage-CwUDjEFo.js → WidgetsPage-Bpg6H-ZB.js} +1 -1
  48. package/dashboard/dist/assets/{WrappedPage-Dr9ow3gH.js → WrappedPage-C6-NM-RO.js} +1 -1
  49. package/dashboard/dist/assets/__vite-browser-external-BGnq8Ebm.js +45 -0
  50. package/dashboard/dist/assets/__vite-browser-external-CvMMIjhi.css +1 -0
  51. package/dashboard/dist/assets/{agent-logos-Cqc_suYk.js → agent-logos-DpbkRqXT.js} +1 -1
  52. package/dashboard/dist/assets/{arrow-up-right-DSsR3Ntr.js → arrow-up-right-BjOtWUVs.js} +1 -1
  53. package/dashboard/dist/assets/{check-Cy35lAKh.js → check-BlxnX5jZ.js} +1 -1
  54. package/dashboard/dist/assets/{composite-BDwOlhhX.js → composite-Dxmivlk4.js} +1 -1
  55. package/dashboard/dist/assets/{copy-4szu5cYh.js → copy-CIsWjLY5.js} +1 -1
  56. package/dashboard/dist/assets/{download-DdGW6Krz.js → download-QDaQbQdd.js} +1 -1
  57. package/dashboard/dist/assets/{external-link-DwSt83cq.js → external-link-DPV4VU3m.js} +1 -1
  58. package/dashboard/dist/assets/{flame-DubukWqK.js → flame-B_X-7VNp.js} +1 -1
  59. package/dashboard/dist/assets/{format-tokens-B70EdQcN.js → format-tokens-DEx2TJj8.js} +1 -1
  60. package/dashboard/dist/assets/{icons-B86TLdAj.js → icons-983RyGSl.js} +1 -1
  61. package/dashboard/dist/assets/{index-B_KMoBeK.js → index-BONYA8HZ.js} +1 -1
  62. package/dashboard/dist/assets/{index-DI-W-pun.js → index-Cjs5stC0.js} +1 -1
  63. package/dashboard/dist/assets/{index-DzEMcUwI.js → index-qtonw_x4.js} +1 -1
  64. package/dashboard/dist/assets/{info-BzFpLbl6.js → info-DpVOhXZI.js} +1 -1
  65. package/dashboard/dist/assets/limits-providers-BUoquSA-.js +1 -0
  66. package/dashboard/dist/assets/{link-2-BQp7-3T7.js → link-2-CBro-upz.js} +1 -1
  67. package/dashboard/dist/assets/{loader-circle-lrDmbIg5.js → loader-circle-wYFdSyOd.js} +1 -1
  68. package/dashboard/dist/assets/{maximize-2-Cnd_Oth8.js → maximize-2-DhTyQ4ly.js} +1 -1
  69. package/dashboard/dist/assets/{modifiers.esm-CPTpZdHU.js → modifiers.esm-CPljwVhN.js} +1 -1
  70. package/dashboard/dist/assets/{monitor-smartphone-C7UY1zRx.js → monitor-smartphone-D_wr-HOR.js} +1 -1
  71. package/dashboard/dist/assets/{plus-Bglp9N_7.js → plus-BNjfkBEF.js} +1 -1
  72. package/dashboard/dist/assets/{provider-display-CGsFySs6.js → provider-display-BOBtUyFi.js} +1 -1
  73. package/dashboard/dist/assets/{react-DKXriBSF.js → react-yHv9MTUA.js} +1 -1
  74. package/dashboard/dist/assets/{search-D_LLDRdU.js → search-Dfak92ZA.js} +1 -1
  75. package/dashboard/dist/assets/{skills-api-CJWjKIco.js → skills-api-By2PdNa1.js} +1 -1
  76. package/dashboard/dist/assets/{sortable.esm-zlY09tye.js → sortable.esm-B9xy66_v.js} +1 -1
  77. package/dashboard/dist/assets/{store-8e16Ac2h.js → store-oQZR4d9M.js} +1 -1
  78. package/dashboard/dist/assets/{terminal-CR228ekj.js → terminal-pbXWBmZM.js} +1 -1
  79. package/dashboard/dist/assets/{trash-2-BjLWewOp.js → trash-2-CboDPwbW.js} +1 -1
  80. package/dashboard/dist/assets/{use-community-stats-DhOJPaVg.js → use-community-stats-D6HTdnHq.js} +1 -1
  81. package/dashboard/dist/assets/{use-latest-request-guard-lcGhb-NC.js → use-latest-request-guard-hwSr9Cam.js} +1 -1
  82. package/dashboard/dist/assets/{use-limits-display-prefs-DJIImtLN.js → use-limits-display-prefs-B-pbwUS8.js} +1 -1
  83. package/dashboard/dist/assets/{use-ordered-list-BJZfZuoJ.js → use-ordered-list-OtfOqrNb.js} +1 -1
  84. package/dashboard/dist/assets/use-reduced-motion-DGXcZBX3.js +1 -0
  85. package/dashboard/dist/assets/{use-session-efficiency-pref-C-l4b1cD.js → use-session-efficiency-pref-BScvtS8F.js} +1 -1
  86. package/dashboard/dist/assets/{use-transform-UjIt477N.js → use-transform-Cy1ykjEs.js} +1 -1
  87. package/dashboard/dist/assets/{use-usage-limits-DqoTZrqT.js → use-usage-limits-CSMb2zYW.js} +1 -1
  88. package/dashboard/dist/assets/{useCurrency-CnM8aB4v.js → useCurrency-VqF3cy1C.js} +1 -1
  89. package/dashboard/dist/assets/{useTokenFormat-D4pAK2ld.js → useTokenFormat-Cgub4MXu.js} +1 -1
  90. package/dashboard/dist/assets/{zap-Du1IyEtj.js → zap-DStzvQJI.js} +1 -1
  91. package/dashboard/dist/index.html +2 -2
  92. package/dashboard/dist/ip-check.html +2 -2
  93. package/dashboard/dist/leaderboard.html +2 -2
  94. package/dashboard/dist/share.html +2 -2
  95. package/package.json +1 -1
  96. package/src/commands/status.js +4 -4
  97. package/src/commands/sync.js +5 -3
  98. package/src/lib/ark-agent-plan-limits.js +438 -0
  99. package/src/lib/ark-coding-plan-limits.js +103 -28
  100. package/src/lib/codex-model-attribution.js +128 -0
  101. package/src/lib/codex-rollout-parser.js +98 -4
  102. package/src/lib/command-runner.js +13 -0
  103. package/src/lib/local-api.js +3 -0
  104. package/src/lib/pricing/curated-overrides.json +6 -6
  105. package/src/lib/pricing/index.js +38 -1
  106. package/src/lib/pricing/seed-snapshot.json +1 -1
  107. package/src/lib/rollout.js +67 -14
  108. package/src/lib/session-analytics.js +374 -110
  109. package/src/lib/usage-limits.js +130 -18
  110. package/dashboard/dist/assets/DashboardPage-DtUOG6og.js +0 -1
  111. package/dashboard/dist/assets/DialogDescription-BVlaoajd.js +0 -1
  112. package/dashboard/dist/assets/LimitsPage-CVCKPScf.js +0 -5
  113. package/dashboard/dist/assets/SessionsPage-CEDv1thj.js +0 -2
  114. package/dashboard/dist/assets/SettingsPage-DAcndJZQ.js +0 -2
  115. package/dashboard/dist/assets/__vite-browser-external-CCMdjCX2.css +0 -1
  116. package/dashboard/dist/assets/__vite-browser-external-DPOlUkcY.js +0 -45
  117. package/dashboard/dist/assets/limits-providers-DxZOjiXQ.js +0 -1
  118. package/dashboard/dist/assets/use-reduced-motion-DdpxZQoh.js +0 -1
@@ -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
@@ -21,7 +25,7 @@ const fs = require("node:fs");
21
25
  const os = require("node:os");
22
26
  const path = require("node:path");
23
27
 
24
- const { runCommand, resolveBinaryPath, statBinaryInDirs, commonGlobalBinDirectories } = require("./command-runner");
28
+ const { runCommand, resolveBinaryPath, statBinaryInDirs, commonGlobalBinDirectories, resolvedCliEnvironment } = require("./command-runner");
25
29
 
26
30
  const ARK_LIMITS_CACHE_FILE = "ark-coding-plan-limits-cache.json";
27
31
  const ARK_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
@@ -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,
@@ -325,6 +382,7 @@ async function fetchArkCodingPlanLimits({
325
382
  if (!arkcliPath) return { configured: false };
326
383
 
327
384
  const commandOptions = {
385
+ env: resolvedCliEnvironment(arkcliPath, { platform }),
328
386
  signal,
329
387
  killProcessGroup: true,
330
388
  platform,
@@ -384,12 +442,19 @@ async function fetchArkCodingPlanLimits({
384
442
  };
385
443
 
386
444
  if (result?.error || result?.status !== 0) {
387
- const detail = result?.error?.message
445
+ const isExit127 = result?.status === 127;
446
+ const baseDetail = result?.error?.message
388
447
  || (result?.status !== 0 && result?.status !== null
389
448
  ? `arkcli exited with code ${result.status}`
390
449
  : "arkcli usage plan failed");
450
+ // Exit 127 can mean a missing interpreter as well as an outdated CLI.
451
+ // Preserve stderr so users can distinguish runtime and command failures.
452
+ const detail = isExit127
453
+ ? "arkcli exited with code 127 — check its runtime/PATH or update arkcli: npm i -g @volcengine/ark-cli"
454
+ : baseDetail;
391
455
  const stderr = trimStderr(result?.stderr);
392
- return failWithCache(stderr ? `${detail}: ${stderr}` : detail);
456
+ const message = stderr ? `${detail}: ${stderr}` : detail;
457
+ return failWithCache(message);
393
458
  }
394
459
 
395
460
  let body;
@@ -406,17 +471,22 @@ async function fetchArkCodingPlanLimits({
406
471
  return failWithCache(error?.message || "Ark Coding Plan response could not be parsed.");
407
472
  }
408
473
  if (!limits) {
409
- // Only a response that explicitly carries the coding-plan entry with
474
+ // Only a response that explicitly carries an Ark product entry with
410
475
  // `subscribed: false` confirms the plan was retired — drop the cache
411
476
  // 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.
477
+ // plan's numbers through failWithCache. A payload with *no* Ark entry
478
+ // at all (`{}`, `{"items":[]}` — not logged in, backend degraded,
479
+ // product key renamed) is ambiguous and must NOT destroy the cache:
480
+ // transient signals never drive persistent state.
481
+ // Only coding-plan is considered here; agent-plan is handled by the
482
+ // parallel provider `ark-agent-plan-limits.js`.
416
483
  const entry = Array.isArray(body?.items)
417
- ? body.items.find((candidate) => candidate?.product === "coding-plan")
484
+ ? body.items.find(
485
+ (candidate) =>
486
+ ARK_PERSONAL_PRODUCTS.includes(candidate?.product) && candidate?.subscribed === false,
487
+ )
418
488
  : null;
419
- if (entry && entry.subscribed === false) {
489
+ if (entry) {
420
490
  try {
421
491
  fs.unlinkSync(arkCodingPlanCachePath({ home }));
422
492
  } catch (_error) {}
@@ -452,6 +522,8 @@ async function fetchArkCodingPlanLimits({
452
522
 
453
523
  module.exports = {
454
524
  ARK_PERIOD_WINDOW,
525
+ ARK_PERSONAL_PRODUCTS,
526
+ ARK_PRODUCT_PRIORITY,
455
527
  normalizeArkPlansResponse,
456
528
  arkProfileIdentity,
457
529
  normalizeArkCodingPlanResponse,
@@ -459,4 +531,7 @@ module.exports = {
459
531
  writeArkCodingPlanLimitsCache,
460
532
  hasArkCliInstallEvidence,
461
533
  fetchArkCodingPlanLimits,
534
+ // exposed for testing
535
+ resolveArkProductItem,
536
+ percentFromPeriod,
462
537
  };
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+
3
+ // Codex token_count events do not carry a model id. Every usage event is
4
+ // therefore attributed to the model named by the most recent turn_context,
5
+ // which Codex emits at the start of each turn and which tracks mid-session
6
+ // model switches exactly. That per-turn path is what actually runs today.
7
+ //
8
+ // The model/rerouted upgrade below is forward-looking: it promotes usage to
9
+ // the effective model when the app server reports a server-side reroute.
10
+ //
11
+ // Deliberately NOT used for attribution: event_msg/thread_settings_applied.
12
+ // It carries thread_settings.model, but it fires when the user picks a model
13
+ // in the UI - seconds to minutes before the switch takes effect - so usage
14
+ // still streaming from the in-flight turn belongs to the previous model.
15
+ // turn_context is the only signal that marks where a turn actually begins.
16
+ //
17
+ // session_meta is a defensive fallback, not an observed source. Codex has
18
+ // never written a model into it - 0 of 10310 session_meta rows across 5849
19
+ // local rollouts carry the field (codex-cli 0.151.0); the payload records
20
+ // model_provider ("openai"), which is provenance rather than a model. The
21
+ // fallback only fills selectedModel when nothing else has, so a future format
22
+ // that does carry one attributes instead of falling through to "unknown". It
23
+ // never overrides a turn_context model.
24
+
25
+ function cleanString(value) {
26
+ return typeof value === "string" && value.trim() ? value.trim() : null;
27
+ }
28
+
29
+ function normalizeEventName(value) {
30
+ return cleanString(value)?.toLowerCase().replaceAll("_", "/") || "";
31
+ }
32
+
33
+ function eventEnvelope(obj) {
34
+ const candidates = [
35
+ { name: obj?.method, data: obj?.params },
36
+ { name: obj?.type, data: obj?.payload },
37
+ { name: obj?.payload?.method, data: obj?.payload?.params },
38
+ { name: obj?.payload?.type, data: obj?.payload },
39
+ { name: obj?.payload?.msg?.method, data: obj?.payload?.msg?.params },
40
+ { name: obj?.payload?.msg?.type, data: obj?.payload?.msg },
41
+ { name: obj?.msg?.method, data: obj?.msg?.params },
42
+ { name: obj?.msg?.type, data: obj?.msg },
43
+ ];
44
+ return candidates.find(({ name }) => normalizeEventName(name) === "model/rerouted") || null;
45
+ }
46
+
47
+ // Forward-looking: no model/rerouted event has been observed in any real
48
+ // rollout (verified against 5832 local files, codex-cli 0.151.0), so the
49
+ // envelope shapes and the fromModel/toModel field names below are inferred
50
+ // from the app-server protocol rather than confirmed against logged data.
51
+ // Re-verify against a real sample before relying on rerouted attribution.
52
+ function extractModelReroute(obj) {
53
+ const envelope = eventEnvelope(obj);
54
+ if (!envelope) return null;
55
+ const data = envelope.data && typeof envelope.data === "object" ? envelope.data : {};
56
+ const fromModel = cleanString(data.fromModel ?? data.from_model);
57
+ const toModel = cleanString(data.toModel ?? data.to_model);
58
+ if (!toModel) return null;
59
+ return {
60
+ fromModel,
61
+ toModel,
62
+ reason: cleanString(data.reason),
63
+ threadId: cleanString(data.threadId ?? data.thread_id),
64
+ turnId: cleanString(data.turnId ?? data.turn_id),
65
+ };
66
+ }
67
+
68
+ function createCodexModelAttributionState(value = {}) {
69
+ const selectedModel = cleanString(value.selectedModel ?? value.selected_model ?? value.model);
70
+ const effectiveModel = cleanString(value.effectiveModel ?? value.effective_model) || selectedModel;
71
+ return {
72
+ selectedModel,
73
+ effectiveModel,
74
+ turnId: cleanString(value.turnId ?? value.turn_id),
75
+ rerouted: Boolean(value.rerouted),
76
+ rerouteReason: cleanString(value.rerouteReason ?? value.reroute_reason),
77
+ };
78
+ }
79
+
80
+ function applyCodexModelEvent(state, obj) {
81
+ if (!state || typeof state !== "object") return null;
82
+ if (obj?.type === "turn_context" && obj.payload && typeof obj.payload === "object") {
83
+ const selectedModel = cleanString(obj.payload.model);
84
+ if (selectedModel) {
85
+ state.selectedModel = selectedModel;
86
+ }
87
+ state.effectiveModel = state.selectedModel || state.effectiveModel;
88
+ state.turnId = cleanString(obj.payload.turn_id ?? obj.payload.turnId) || state.turnId;
89
+ state.rerouted = false;
90
+ state.rerouteReason = null;
91
+ return { type: "turn_context", selectedModel };
92
+ }
93
+
94
+ // Strictly weaker than turn_context, and never allowed to overwrite it: a
95
+ // forked rollout replays the parent's session_meta rows after its own.
96
+ if (obj?.type === "session_meta" && obj.payload && typeof obj.payload === "object") {
97
+ const metaModel = cleanString(obj.payload.model);
98
+ if (!metaModel || state.selectedModel) return null;
99
+ state.selectedModel = metaModel;
100
+ state.effectiveModel = state.effectiveModel || metaModel;
101
+ return { type: "session_meta", selectedModel: metaModel };
102
+ }
103
+
104
+ const reroute = extractModelReroute(obj);
105
+ if (!reroute) return null;
106
+ state.selectedModel = reroute.fromModel || state.selectedModel || state.effectiveModel;
107
+ state.effectiveModel = reroute.toModel;
108
+ state.turnId = reroute.turnId || state.turnId;
109
+ state.rerouted = true;
110
+ state.rerouteReason = reroute.reason;
111
+ return { type: "model_rerouted", ...reroute };
112
+ }
113
+
114
+ function currentCodexModel(state) {
115
+ return cleanString(state?.effectiveModel) || cleanString(state?.selectedModel);
116
+ }
117
+
118
+ function snapshotCodexModelAttributionState(state) {
119
+ return createCodexModelAttributionState(state);
120
+ }
121
+
122
+ module.exports = {
123
+ applyCodexModelEvent,
124
+ createCodexModelAttributionState,
125
+ currentCodexModel,
126
+ extractModelReroute,
127
+ snapshotCodexModelAttributionState,
128
+ };
@@ -24,12 +24,21 @@ const {
24
24
  getExecutableName,
25
25
  buildExecStatsEntry,
26
26
  } = require("./categorizer-utils");
27
+ const {
28
+ applyCodexModelEvent,
29
+ createCodexModelAttributionState,
30
+ currentCodexModel,
31
+ snapshotCodexModelAttributionState,
32
+ } = require("./codex-model-attribution");
27
33
 
28
34
  const DISCOVERY_FULL_AUDIT_INTERVAL_MS = 60 * 1000;
29
35
  const MAX_DISCOVERY_INVENTORIES = 32;
30
36
  const DISCOVERY_INVENTORIES = new Map();
31
37
  const ZONED_DAY_FORMATTERS = new Map();
32
38
  const CODEX_RESUME_INVALIDATED = "TOKENTRACKER_CODEX_RESUME_INVALIDATED";
39
+ // See src/lib/pricing/index.js: no observed Codex request reaches this
40
+ // threshold yet (context window 258400), so long_context_* stays at zero.
41
+ const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272_000;
33
42
 
34
43
  // ---------------------------------------------------------------------------
35
44
  // Timezone helpers
@@ -520,6 +529,17 @@ function finalizeExecRows(map) {
520
529
  .sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
521
530
  }
522
531
 
532
+ function finalizeModelUsageRows(map) {
533
+ return Array.from(map.values())
534
+ .map((row) => ({
535
+ ...row,
536
+ selected_models: Array.from(row.selected_models).sort(),
537
+ reroute_reasons: Array.from(row.reroute_reasons).sort(),
538
+ model_attribution: row.rerouted_usage_events > 0 ? "effective" : "selected",
539
+ }))
540
+ .sort((a, b) => b.total_tokens - a.total_tokens || a.model.localeCompare(b.model));
541
+ }
542
+
523
543
  // ---------------------------------------------------------------------------
524
544
  // Main parser
525
545
  // ---------------------------------------------------------------------------
@@ -537,6 +557,9 @@ async function parseCodexRolloutFile(filePath, {
537
557
  captureContentHash = false,
538
558
  contentHashState = null,
539
559
  sourceHandle = null,
560
+ collectBreakdowns = true,
561
+ collectModelUsage = false,
562
+ onObject = null,
540
563
  } = {}) {
541
564
  const filePaths = (Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean);
542
565
  const primaryFilePath = filePaths[0] || String(filePath || "");
@@ -566,6 +589,9 @@ async function parseCodexRolloutFile(filePath, {
566
589
  let sessionId = isResuming ? resumeState.sessionId || null : null;
567
590
  let cwd = isResuming ? resumeState.cwd || null : null;
568
591
  let model = isResuming ? resumeState.model || null : null;
592
+ const modelAttributionState = createCodexModelAttributionState(
593
+ isResuming ? resumeState.modelAttributionState || { model } : {},
594
+ );
569
595
  let provider = isResuming ? resumeState.provider || null : null;
570
596
  let cliVersion = isResuming ? resumeState.cliVersion || null : null;
571
597
  let forkedFromId = isResuming ? resumeState.forkedFromId || null : null;
@@ -607,9 +633,55 @@ async function parseCodexRolloutFile(filePath, {
607
633
  const byExecCommand = new Map(); // sanitized executable + subcommand -> stats
608
634
  const byExecDuration = new Map(); // duration bucket -> stats
609
635
  const byExecOutput = new Map(); // output size bucket -> stats
636
+ const byModel = new Map(); // effective model -> observed token usage
610
637
 
611
638
  let turnCount = 0;
612
639
 
640
+ function recordModelUsage(delta, rawRequestUsage) {
641
+ if (!collectModelUsage || !delta || delta.total_tokens <= 0) return;
642
+ const effectiveModel = currentCodexModel(modelAttributionState) || "unknown";
643
+ let row = byModel.get(effectiveModel);
644
+ if (!row) {
645
+ row = {
646
+ model: effectiveModel,
647
+ ...emptyTotals(),
648
+ long_context_input_tokens: 0,
649
+ long_context_cached_input_tokens: 0,
650
+ long_context_cache_creation_input_tokens: 0,
651
+ long_context_output_tokens: 0,
652
+ long_context_reasoning_output_tokens: 0,
653
+ usage_events: 0,
654
+ rerouted_usage_events: 0,
655
+ long_context_usage_events: 0,
656
+ selected_models: new Set(),
657
+ reroute_reasons: new Set(),
658
+ };
659
+ byModel.set(effectiveModel, row);
660
+ }
661
+ addInto(row, delta);
662
+ row.usage_events += 1;
663
+ if (modelAttributionState.selectedModel) {
664
+ row.selected_models.add(modelAttributionState.selectedModel);
665
+ }
666
+ if (modelAttributionState.rerouted) {
667
+ row.rerouted_usage_events += 1;
668
+ if (modelAttributionState.rerouteReason) {
669
+ row.reroute_reasons.add(modelAttributionState.rerouteReason);
670
+ }
671
+ }
672
+ // OpenAI applies the long-context tier to the whole request when its raw
673
+ // (cache-inclusive) input exceeds 272K tokens. Preserve the exact subset
674
+ // so pricing can be recomputed later without rescanning private logs.
675
+ if (Number(rawRequestUsage?.input_tokens || 0) > OPENAI_LONG_CONTEXT_INPUT_THRESHOLD) {
676
+ row.long_context_usage_events += 1;
677
+ row.long_context_input_tokens += delta.input_tokens;
678
+ row.long_context_cached_input_tokens += delta.cached_input_tokens;
679
+ row.long_context_cache_creation_input_tokens += delta.cache_creation_input_tokens;
680
+ row.long_context_output_tokens += delta.output_tokens;
681
+ row.long_context_reasoning_output_tokens += delta.reasoning_output_tokens;
682
+ }
683
+ }
684
+
613
685
  function ensureTool(name) {
614
686
  if (!byTool.has(name)) {
615
687
  byTool.set(name, { name, ...buildToolStatsEntry() });
@@ -686,6 +758,14 @@ async function parseCodexRolloutFile(filePath, {
686
758
  }
687
759
  turnCount += 1;
688
760
 
761
+ if (!collectBreakdowns) {
762
+ addInto(totals, delta);
763
+ pendingToolNames = [];
764
+ pendingSkills = [];
765
+ pendingExecDetails = [];
766
+ return;
767
+ }
768
+
689
769
  const unique = [...new Set(pendingToolNames.filter(Boolean))];
690
770
  const tools = unique.length > 0 ? unique : ["text_response"];
691
771
  const share = 1 / tools.length;
@@ -822,6 +902,16 @@ async function parseCodexRolloutFile(filePath, {
822
902
  contentHasher,
823
903
  sourceHandle,
824
904
  })) {
905
+ const modelEvent = applyCodexModelEvent(modelAttributionState, obj);
906
+ const attributedModel = currentCodexModel(modelAttributionState);
907
+ model = attributedModel || model;
908
+ // onObject runs AFTER the attribution state machine, and is handed the
909
+ // model it just decided. A consumer sharing this pass (the delivery-signal
910
+ // collector, which attributes edit turns) therefore sees exactly the model
911
+ // recordModelUsage() bills the turn's tokens to. Letting it re-read
912
+ // turn_context.payload.model itself would make codex-model-attribution.js
913
+ // one of two authorities on the same question.
914
+ if (typeof onObject === "function") onObject(obj, attributedModel);
825
915
  const ts = typeof obj?.timestamp === "string" ? obj.timestamp : null;
826
916
  if (!ts) continue;
827
917
  const inRequestedRange = isTimestampInRequestedDayRange(ts, { from, to, timeZoneContext });
@@ -849,18 +939,19 @@ async function parseCodexRolloutFile(filePath, {
849
939
  if (obj.type === "turn_context") {
850
940
  const p = obj.payload || {};
851
941
  if (typeof p.cwd === "string") cwd = p.cwd;
852
- if (typeof p.model === "string") model = p.model;
853
942
  continue;
854
943
  }
855
944
 
856
- if (obj.type === "response_item" && obj.payload?.type === "function_call") {
945
+ if (modelEvent?.type === "model_rerouted") continue;
946
+
947
+ if (collectBreakdowns && obj.type === "response_item" && obj.payload?.type === "function_call") {
857
948
  pendingToolNames.push(normalizeToolName(obj.payload));
858
949
  const skill = extractSkillNameFromFunctionCall(obj.payload, diagnostics);
859
950
  if (skill) pendingSkills.push(skill);
860
951
  continue;
861
952
  }
862
953
 
863
- if (obj.type === "event_msg" && obj.payload?.type === "exec_command_end") {
954
+ if (collectBreakdowns && obj.type === "event_msg" && obj.payload?.type === "exec_command_end") {
864
955
  const details = getExecKeys(obj.payload);
865
956
  if (details) pendingExecDetails.push(details);
866
957
  continue;
@@ -881,6 +972,7 @@ async function parseCodexRolloutFile(filePath, {
881
972
  attributeTurn(null);
882
973
  } else {
883
974
  if (seenTokenEvents && delta?.total_tokens > 0) seenTokenEvents.add(eventKey);
975
+ recordModelUsage(delta, lastUsage || rawDelta);
884
976
  attributeTurn(delta);
885
977
  }
886
978
  } else {
@@ -895,7 +987,7 @@ async function parseCodexRolloutFile(filePath, {
895
987
  const result = {
896
988
  sessionId: sessionId || rolloutSessionIdFromPath(primaryFilePath) || primaryFilePath,
897
989
  cwd,
898
- model: model || provider,
990
+ model: currentCodexModel(modelAttributionState) || model || provider,
899
991
  provider,
900
992
  version: cliVersion,
901
993
  forkedFromId,
@@ -906,6 +998,7 @@ async function parseCodexRolloutFile(filePath, {
906
998
  filePath: primaryFilePath,
907
999
  turnCount,
908
1000
  totals,
1001
+ modelUsage: finalizeModelUsageRows(byModel),
909
1002
  toolBreakdown: {
910
1003
  tool_rows: finalizeToolRows(byTool),
911
1004
  },
@@ -926,6 +1019,7 @@ async function parseCodexRolloutFile(filePath, {
926
1019
  sessionId,
927
1020
  cwd,
928
1021
  model,
1022
+ modelAttributionState: snapshotCodexModelAttributionState(modelAttributionState),
929
1023
  provider,
930
1024
  cliVersion,
931
1025
  forkedFromId,
@@ -226,6 +226,8 @@ function commonGlobalBinDirectories({ home = os.homedir(), platform = process.pl
226
226
  return [
227
227
  "/opt/homebrew/bin",
228
228
  "/usr/local/bin",
229
+ path.join(home, ".local", "bin"),
230
+ path.join(home, ".bun", "bin"),
229
231
  path.join(home, ".npm-global", "bin"),
230
232
  // volta keeps shims for every global install here
231
233
  path.join(home, ".volta", "bin"),
@@ -276,6 +278,16 @@ async function resolveBinaryPath(binary, { commandRunner, home, platform = proce
276
278
  return statBinaryInDirs(binary, searchDirs, platform);
277
279
  }
278
280
 
281
+ // Resolving an npm CLI's absolute path does not resolve its /usr/bin/env node
282
+ // shebang. Finder-launched apps have a minimal PATH even when the CLI lives
283
+ // beside a working nvm/fnm/Homebrew Node. Scope this environment to the child;
284
+ // preserve the install's own runtime and use our running Node as a fallback.
285
+ function resolvedCliEnvironment(binaryPath, { env = process.env, platform = process.platform } = {}) {
286
+ if (platform === "win32") return { ...env };
287
+ const dirs = [path.dirname(binaryPath), ...(env.PATH || "/usr/bin:/bin").split(path.delimiter), path.dirname(process.execPath)];
288
+ return { ...env, PATH: [...new Set(dirs.filter(Boolean))].join(path.delimiter) };
289
+ }
290
+
279
291
  module.exports = {
280
292
  runCommand,
281
293
  whichBinary,
@@ -283,4 +295,5 @@ module.exports = {
283
295
  commonGlobalBinDirectories,
284
296
  statBinaryInDirs,
285
297
  resolveBinaryPath,
298
+ resolvedCliEnvironment,
286
299
  };
@@ -2019,6 +2019,9 @@ function createLocalApiHandler({ queuePath }) {
2019
2019
  const publishAccount =
2020
2020
  background && body.publishAccount === true && getCloudSyncPref();
2021
2021
  const allLocalSources = background && body.allLocalSources === true;
2022
+ if (background && body.nativeOnlyWsl === true) {
2023
+ extraEnv.TOKENTRACKER_WSL_MODE = "native-only";
2024
+ }
2022
2025
  if (typeof body.deviceToken === "string" && body.deviceToken.trim()) {
2023
2026
  extraEnv.TOKENTRACKER_DEVICE_TOKEN = body.deviceToken.trim();
2024
2027
  }
@@ -48,11 +48,11 @@
48
48
  "note": "Canonical undated alias emitted by Cursor. Pin to the same rates as claude-haiku-4-5-20251001 so local cost does not depend on which dated LiteLLM aliases are present in the current cache."
49
49
  },
50
50
  "gpt-5.6-sol": {
51
- "input": 5,
52
- "output": 30,
53
- "cache_read": 0.5,
54
- "cache_write": 6.25,
55
- "note": "GPT-5.6 family (public 2026-07-09). Flagship tier. Not yet in LiteLLM. developers.openai.com/api/docs/pricing short-context: $5/$30 per MTok, cached input 0.1x, cache write 1.25x. Codex emits gpt-5.6-sol (+ reasoning-effort variants like gpt-5.6-solhigh, caught by fuzzy). Remove once LiteLLM carries it."
51
+ "input": 4,
52
+ "output": 20,
53
+ "cache_read": 0.4,
54
+ "cache_write": 5,
55
+ "note": "Official GPT-5.6 Sol standard pricing: $4 input, $0.40 cached input, $5 cache write, and $20 output per MTok. Requests above 272K input are repriced from their preserved per-request long-context subset. Codex emits gpt-5.6-sol plus reasoning-effort variants caught by fuzzy matching."
56
56
  },
57
57
  "gpt-5.6-terra": {
58
58
  "input": 2,
@@ -428,7 +428,7 @@
428
428
  },
429
429
  {
430
430
  "match": "gpt-5.6",
431
- "ref": "gpt-5.6-terra"
431
+ "ref": "gpt-5.6-sol"
432
432
  },
433
433
  {
434
434
  "match": "kiro",