tokentracker-cli 0.82.1 → 0.82.3

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 (86) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-CkmyCQlm.js → AchievementBadge-BGoZxmgK.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-B4WDkj4Q.js → AchievementsPage-B3s4KXhm.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-CLA812Vd.js → AchievementsSection-1uuZGEo7.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-rPrkKVBY.js → BadgeDetailModal-CN5eymZt.js} +1 -1
  5. package/dashboard/dist/assets/{Card-D4wKdGxL.js → Card-PIcNzr1N.js} +1 -1
  6. package/dashboard/dist/assets/{ClawdAnimated-BzfSSyf4.js → ClawdAnimated-DkcOPogf.js} +1 -1
  7. package/dashboard/dist/assets/CommunityStatsModal-B1YWJRZb.js +1 -0
  8. package/dashboard/dist/assets/{ConfirmModal-CjKotIxk.js → ConfirmModal-CYGWzVUi.js} +1 -1
  9. package/dashboard/dist/assets/DashboardPage-B4aJ7SUw.js +1 -0
  10. package/dashboard/dist/assets/{DevicePage-Bwrgs9bf.js → DevicePage-FCsmJnLF.js} +1 -1
  11. package/dashboard/dist/assets/DialogClose-CXyS8EhY.js +1 -0
  12. package/dashboard/dist/assets/DialogDescription-BhmEaLi-.js +1 -0
  13. package/dashboard/dist/assets/DialogTitle-MFNe5LCo.js +1 -0
  14. package/dashboard/dist/assets/{FadeIn-D_skMXdS.js → FadeIn-CJHiEin7.js} +1 -1
  15. package/dashboard/dist/assets/{HeaderGithubStar-Er3o6s3b.js → HeaderGithubStar-BpjtzNEX.js} +1 -1
  16. package/dashboard/dist/assets/{IpCheckPage-CVelrxeM.js → IpCheckPage-BLapKYgS.js} +1 -1
  17. package/dashboard/dist/assets/{LandingPage-0YKN8fUt.js → LandingPage-B9ExSLhz.js} +1 -1
  18. package/dashboard/dist/assets/{LeaderboardAvatar-BTIhp_Eo.js → LeaderboardAvatar-Dea3Nvld.js} +1 -1
  19. package/dashboard/dist/assets/LeaderboardPage-BBTMQXka.js +2 -0
  20. package/dashboard/dist/assets/{LeaderboardProfileModal-HATS5y64.js → LeaderboardProfileModal-7z9Fk0XJ.js} +4 -4
  21. package/dashboard/dist/assets/{LeaderboardProfilePage-mnQke6q-.js → LeaderboardProfilePage-BiYoH1B1.js} +3 -3
  22. package/dashboard/dist/assets/{LimitsPage-OyBAsn4S.js → LimitsPage-CHe-30h4.js} +4 -4
  23. package/dashboard/dist/assets/{LocalOnlyNotice-Dc1CtgPN.js → LocalOnlyNotice-6Q8aQZsY.js} +1 -1
  24. package/dashboard/dist/assets/{LoginPage-BXJQ9slu.js → LoginPage-4aRwkJid.js} +1 -1
  25. package/dashboard/dist/assets/PetPage-BgTfqvk7.js +1 -0
  26. package/dashboard/dist/assets/PopoverPopup-BCQazXRC.js +1 -0
  27. package/dashboard/dist/assets/{ResetPasswordPage-Bc15F6XB.js → ResetPasswordPage-C-7OgULV.js} +1 -1
  28. package/dashboard/dist/assets/{Select-DE6DMSrS.js → Select-9OFeTPZ4.js} +1 -1
  29. package/dashboard/dist/assets/SelectItemText-BSWZoe6s.js +1 -0
  30. package/dashboard/dist/assets/{SettingsPage-C_c9z42f.js → SettingsPage-CZ2SXcBH.js} +1 -1
  31. package/dashboard/dist/assets/SkillsPage-Dn2OrF2W.js +1 -0
  32. package/dashboard/dist/assets/TrendMonitor-D9Fn8e67.js +19 -0
  33. package/dashboard/dist/assets/WidgetsPage-DNB-IObf.js +1 -0
  34. package/dashboard/dist/assets/{WrappedPage-wnw_Eknp.js → WrappedPage-D9ebRPxk.js} +1 -1
  35. package/dashboard/dist/assets/{__vite-browser-external-CHbEWSwX.js → __vite-browser-external-DD2iTYl3.js} +23 -11
  36. package/dashboard/dist/assets/__vite-browser-external-DWSIu6d0.css +1 -0
  37. package/dashboard/dist/assets/{agent-logos-DF4o0Ebm.js → agent-logos-fKuTDVoe.js} +1 -1
  38. package/dashboard/dist/assets/{arrow-up-right-B1yj6nxk.js → arrow-up-right-DRS5W8pO.js} +1 -1
  39. package/dashboard/dist/assets/{check-Dvngccc0.js → check-D9ALyYq-.js} +1 -1
  40. package/dashboard/dist/assets/{copy-D592E6jX.js → copy-y15ZtBq0.js} +1 -1
  41. package/dashboard/dist/assets/{download-HHcFs0Ff.js → download-E5Ji8El6.js} +1 -1
  42. package/dashboard/dist/assets/{flame-DCV1yyCQ.js → flame-KvP-Lf2b.js} +1 -1
  43. package/dashboard/dist/assets/{format-tokens-BN3gD2gw.js → format-tokens-BDhNoEEr.js} +1 -1
  44. package/dashboard/dist/assets/{icons-DhvcZGBX.js → icons-D77ICc9Q.js} +1 -1
  45. package/dashboard/dist/assets/{info-DoeeLajS.js → info-CdmYq4bF.js} +1 -1
  46. package/dashboard/dist/assets/{limits-providers-Db3iK5TG.js → limits-providers-Bu1dFr1d.js} +1 -1
  47. package/dashboard/dist/assets/{link-2-C4RnhbaI.js → link-2-BP97sWQz.js} +1 -1
  48. package/dashboard/dist/assets/{loader-circle-Cz7QNoOj.js → loader-circle-cm74ybiI.js} +1 -1
  49. package/dashboard/dist/assets/provider-display-B4nVU6QB.js +42 -0
  50. package/dashboard/dist/assets/{terminal-Bwl-eNY6.js → terminal-ZA_LXyYX.js} +1 -1
  51. package/dashboard/dist/assets/{trash-2-Ceor8aI2.js → trash-2-Ngml650r.js} +1 -1
  52. package/dashboard/dist/assets/{use-community-stats-C0cLdfhQ.js → use-community-stats-BIMPV1Xd.js} +1 -1
  53. package/dashboard/dist/assets/{use-limits-display-prefs-B-zzSXHf.js → use-limits-display-prefs-6W5ou7f5.js} +1 -1
  54. package/dashboard/dist/assets/{use-native-settings-CIHg16YZ.js → use-native-settings-BR9Gpkbc.js} +1 -1
  55. package/dashboard/dist/assets/{use-ordered-list-XWE9dLyL.js → use-ordered-list-DDqNfSi6.js} +1 -1
  56. package/dashboard/dist/assets/{use-session-efficiency-pref-DUe7bWPZ.js → use-session-efficiency-pref-DUdLAGwg.js} +1 -1
  57. package/dashboard/dist/assets/use-transform-nNhEdrWI.js +1 -0
  58. package/dashboard/dist/assets/use-usage-limits-rb5kaxzm.js +1 -0
  59. package/dashboard/dist/assets/{useCurrency-QqSMR7Ba.js → useCurrency-Cn5VZuKK.js} +1 -1
  60. package/dashboard/dist/assets/useScrollLock-BbZ8RZxC.js +1 -0
  61. package/dashboard/dist/assets/{useTokenFormat-BLfmNxg5.js → useTokenFormat-Cqt0jy2d.js} +1 -1
  62. package/dashboard/dist/assets/{zap-DjGiVr-_.js → zap-CTr-Tch6.js} +1 -1
  63. package/dashboard/dist/index.html +2 -2
  64. package/dashboard/dist/share.html +2 -2
  65. package/package.json +1 -1
  66. package/src/lib/grok-limits.js +203 -25
  67. package/src/lib/local-api.js +34 -0
  68. package/src/lib/pet-packages.js +268 -5
  69. package/src/lib/pricing/seed-snapshot.json +1 -1
  70. package/src/lib/usage-limits.js +9 -1
  71. package/dashboard/dist/assets/CommunityStatsModal-BDgJ6Rdv.js +0 -1
  72. package/dashboard/dist/assets/DashboardPage-C61HPDUg.js +0 -1
  73. package/dashboard/dist/assets/DialogDescription-D0X0v38O.js +0 -1
  74. package/dashboard/dist/assets/DialogTitle-qsUpNlJa.js +0 -1
  75. package/dashboard/dist/assets/LeaderboardPage-bGkIx65M.js +0 -2
  76. package/dashboard/dist/assets/PetPage-DsjNXLBM.js +0 -1
  77. package/dashboard/dist/assets/PopoverPopup-BuSDh9-C.js +0 -1
  78. package/dashboard/dist/assets/SelectItemText-Ca7gf5A0.js +0 -1
  79. package/dashboard/dist/assets/SkillsPage-iR9Slxl2.js +0 -1
  80. package/dashboard/dist/assets/TrendMonitor-C-acyVP7.js +0 -19
  81. package/dashboard/dist/assets/WidgetsPage-DmiqtvrV.js +0 -1
  82. package/dashboard/dist/assets/__vite-browser-external-Cd947_ni.css +0 -1
  83. package/dashboard/dist/assets/provider-display-BvvK0p2x.js +0 -42
  84. package/dashboard/dist/assets/use-transform-DgEhM1tE.js +0 -1
  85. package/dashboard/dist/assets/use-usage-limits-B_NbJCPh.js +0 -1
  86. package/dashboard/dist/assets/useScrollLock-DdUQVKDA.js +0 -1
@@ -3,6 +3,56 @@ const os = require("node:os");
3
3
  const path = require("node:path");
4
4
 
5
5
  const DEFAULT_BILLING_BASE_URL = "https://cli-chat-proxy.grok.com";
6
+ const DEFAULT_BILLING_TIMEOUT_MS = 15_000;
7
+
8
+ function grokAuthError() {
9
+ const error = new Error("Not logged in to Grok Build. Run `grok login` in Terminal to authenticate.");
10
+ error.code = "GROK_AUTH_REQUIRED";
11
+ return error;
12
+ }
13
+
14
+ function grokBillingTimeoutError() {
15
+ const error = new Error("Grok billing request timed out.");
16
+ error.code = "GROK_BILLING_TIMEOUT";
17
+ return error;
18
+ }
19
+
20
+ async function runBeforeBillingDeadline(operation, deadlineMs) {
21
+ const remainingMs = Math.ceil(deadlineMs - Date.now());
22
+ if (remainingMs <= 0) throw grokBillingTimeoutError();
23
+
24
+ const controller = new AbortController();
25
+ let timer;
26
+ const timeout = new Promise((_, reject) => {
27
+ timer = setTimeout(() => {
28
+ const error = grokBillingTimeoutError();
29
+ controller.abort(error);
30
+ reject(error);
31
+ }, remainingMs);
32
+ });
33
+
34
+ try {
35
+ return await Promise.race([operation(controller.signal), timeout]);
36
+ } finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+
41
+ async function fetchGrokBillingAttempt(fetchImpl, url, headers, deadlineMs) {
42
+ return runBeforeBillingDeadline(async (signal) => {
43
+ const response = await fetchImpl(url, { method: "GET", headers, signal });
44
+ if (!response.ok) {
45
+ try {
46
+ await response.body?.cancel?.();
47
+ } catch (_error) {
48
+ // The status remains authoritative even if discarding the body fails.
49
+ }
50
+ if (response.status === 401 || response.status === 403) throw grokAuthError();
51
+ return { ok: false, status: response.status };
52
+ }
53
+ return { ok: true, body: await response.json() };
54
+ }, deadlineMs);
55
+ }
6
56
 
7
57
  function resolveGrokHome({ home, env = process.env } = {}) {
8
58
  if (typeof env.TOKENTRACKER_GROK_HOME === "string" && env.TOKENTRACKER_GROK_HOME.trim()) {
@@ -58,6 +108,62 @@ function buildWindow({ usedPercent, resetAt }) {
58
108
  };
59
109
  }
60
110
 
111
+ /**
112
+ * Map Grok's USAGE_PERIOD_TYPE_* enum (or a bare "weekly"/"monthly" string)
113
+ * into a short period key the UI can switch labels on.
114
+ *
115
+ * Only daily / weekly / monthly are recognized end-to-end (dashboard + macOS).
116
+ * Hourly (and other unknown) types return null so UIs fall back to the generic
117
+ * Month label rather than inventing an unsupported period key.
118
+ */
119
+ function normalizeGrokPeriodType(value) {
120
+ if (typeof value !== "string" || !value.trim()) return null;
121
+ const upper = value.trim().toUpperCase();
122
+ if (upper.includes("WEEK")) return "weekly";
123
+ if (upper.includes("MONTH")) return "monthly";
124
+ // Prefer explicit DAILY / DAY over accidental "HOUR_OF_DAY" style matches.
125
+ if (upper.includes("DAILY") || /(^|_)DAY($|_)/.test(upper) || upper === "DAY") return "daily";
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Infer daily / weekly / monthly from period length when the API omits
131
+ * `currentPeriod.type` (legacy payloads only expose start/end dates).
132
+ *
133
+ * Windows used by Grok today: ~1d, ~7d, ~calendar month. Boundaries leave
134
+ * gaps for odd lengths (e.g. 12d, 2h) so we do not mislabel them.
135
+ */
136
+ function inferGrokPeriodTypeFromDates(startIso, endIso) {
137
+ if (!startIso || !endIso) return null;
138
+ const startMs = Date.parse(startIso);
139
+ const endMs = Date.parse(endIso);
140
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return null;
141
+ const days = (endMs - startMs) / 86_400_000;
142
+ if (days > 0.5 && days <= 1.5) return "daily";
143
+ if (days > 1.5 && days <= 8) return "weekly";
144
+ if (days >= 25 && days <= 35) return "monthly";
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Sum per-product attribution percentages into the shared pool total.
150
+ * productUsage breaks down the same cap (GrokBuild + GrokChat + …), not
151
+ * independent quotas — a single product entry undercounts the pool.
152
+ */
153
+ function sumProductUsagePercent(productUsage) {
154
+ if (!Array.isArray(productUsage)) return null;
155
+ let sum = 0;
156
+ let sawAny = false;
157
+ for (const entry of productUsage) {
158
+ if (!entry || typeof entry !== "object") continue;
159
+ const pct = clampPercent(entry.usagePercent);
160
+ if (pct === null) continue;
161
+ sawAny = true;
162
+ sum += pct;
163
+ }
164
+ return sawAny ? clampPercent(sum) : null;
165
+ }
166
+
61
167
  function isGrokInstalled({ home, env } = {}) {
62
168
  const grokHome = resolveGrokHome({ home, env });
63
169
  const authPath = path.join(grokHome, "auth.json");
@@ -88,25 +194,50 @@ function readGrokAccessToken({ home, env } = {}) {
88
194
  return key || null;
89
195
  }
90
196
 
197
+ /**
198
+ * Parse either:
199
+ * - Unified billing (`?format=credits`): weekly/monthly period + creditUsagePercent
200
+ * - Legacy monthly credits: monthlyLimit / used + calendar-month billingPeriod*
201
+ *
202
+ * Prefer the unified shape; legacy remains as a fallback for older accounts.
203
+ */
91
204
  function normalizeGrokBillingResponse(body) {
92
205
  const config = body?.config;
93
206
  if (!config || typeof config !== "object") {
94
207
  throw new Error("Could not parse Grok billing: missing config");
95
208
  }
96
209
 
210
+ const currentPeriod =
211
+ config.currentPeriod && typeof config.currentPeriod === "object" ? config.currentPeriod : null;
212
+
213
+ const periodStart =
214
+ grokIsoReset(currentPeriod?.start) || grokIsoReset(config.billingPeriodStart);
215
+ const resetAt = grokIsoReset(currentPeriod?.end) || grokIsoReset(config.billingPeriodEnd);
216
+
217
+ let periodType = normalizeGrokPeriodType(currentPeriod?.type);
218
+ if (!periodType) {
219
+ periodType = inferGrokPeriodTypeFromDates(periodStart, resetAt);
220
+ }
221
+
222
+ // Unified billing: overall pool percent is what gates "You hit your weekly limit".
223
+ // productUsage is attribution across products that share the same pool — sum it.
224
+ let usedPercent = clampPercent(config.creditUsagePercent);
225
+ if (usedPercent === null) {
226
+ usedPercent = sumProductUsagePercent(config.productUsage);
227
+ }
228
+
229
+ // Legacy monthly credit counters (pre-unified / non-format=credits responses).
97
230
  const monthlyLimit = grokValNumber(config.monthlyLimit);
98
231
  const used = grokValNumber(config.used);
232
+ if (usedPercent === null && Number.isFinite(monthlyLimit) && monthlyLimit > 0 && Number.isFinite(used)) {
233
+ usedPercent = (used / monthlyLimit) * 100;
234
+ if (!periodType) periodType = "monthly";
235
+ }
236
+
99
237
  const onDemandCap = grokValNumber(config.onDemandCap);
100
238
  const onDemandUsed = grokValNumber(config.onDemandUsed);
101
- const resetAt = grokIsoReset(config.billingPeriodEnd);
102
239
 
103
- let primaryWindow = null;
104
- if (Number.isFinite(monthlyLimit) && monthlyLimit > 0 && Number.isFinite(used)) {
105
- primaryWindow = buildWindow({
106
- usedPercent: (used / monthlyLimit) * 100,
107
- resetAt,
108
- });
109
- }
240
+ const primaryWindow = buildWindow({ usedPercent, resetAt });
110
241
 
111
242
  let secondaryWindow = null;
112
243
  if (Number.isFinite(onDemandCap) && onDemandCap > 0 && Number.isFinite(onDemandUsed)) {
@@ -121,35 +252,79 @@ function normalizeGrokBillingResponse(body) {
121
252
  }
122
253
 
123
254
  return {
255
+ period_type: periodType,
124
256
  monthly_credits_limit: monthlyLimit,
125
257
  monthly_credits_used: used,
258
+ // Effective percent used for the primary bar (API creditUsagePercent, or
259
+ // productUsage / legacy monthly counters when the raw field is absent).
260
+ credit_usage_percent: usedPercent == null ? null : clampPercent(usedPercent),
126
261
  on_demand_cap: onDemandCap,
127
262
  on_demand_used: onDemandUsed,
128
- billing_period_start: grokIsoReset(config.billingPeriodStart),
263
+ billing_period_start: periodStart,
129
264
  primary_window: primaryWindow,
130
265
  secondary_window: secondaryWindow,
131
266
  };
132
267
  }
133
268
 
134
- async function fetchGrokBilling(accessToken, { fetchImpl = fetch, baseUrl, env } = {}) {
269
+ /**
270
+ * Fetch Grok billing. Prefer `?format=credits` (unified weekly/monthly pool
271
+ * used by the Grok Build TUI). Fall back to the bare `/v1/billing` payload for
272
+ * older accounts that only expose monthlyLimit/used.
273
+ */
274
+ async function fetchGrokBilling(
275
+ accessToken,
276
+ { fetchImpl = fetch, baseUrl, env, timeoutMs = DEFAULT_BILLING_TIMEOUT_MS } = {},
277
+ ) {
135
278
  const root = (baseUrl || resolveGrokBillingBaseUrl(env)).replace(/\/$/, "");
136
- const res = await fetchImpl(`${root}/v1/billing`, {
137
- method: "GET",
138
- headers: {
139
- Authorization: `Bearer ${accessToken}`,
140
- Accept: "application/json",
141
- },
142
- });
143
- if (res.status === 401 || res.status === 403) {
144
- throw new Error("Not logged in to Grok Build. Run `grok login` in Terminal to authenticate.");
279
+ const headers = {
280
+ Authorization: `Bearer ${accessToken}`,
281
+ Accept: "application/json",
282
+ };
283
+ const normalizedTimeoutMs =
284
+ Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_BILLING_TIMEOUT_MS;
285
+ const deadlineMs = Date.now() + normalizedTimeoutMs;
286
+
287
+ const creditsUrl = `${root}/v1/billing?format=credits`;
288
+ let creditsFailure = "request failed";
289
+ try {
290
+ const creditsResult = await fetchGrokBillingAttempt(
291
+ fetchImpl,
292
+ creditsUrl,
293
+ headers,
294
+ deadlineMs,
295
+ );
296
+ if (creditsResult.ok) return creditsResult.body;
297
+ creditsFailure = `HTTP ${creditsResult.status}`;
298
+ } catch (error) {
299
+ if (error?.code === "GROK_AUTH_REQUIRED") throw error;
300
+ if (error?.code === "GROK_BILLING_TIMEOUT") throw error;
301
+ }
302
+
303
+ // Non-auth HTTP, network, or response-decoding failure → try the legacy
304
+ // shape once, while sharing the original request deadline.
305
+ let legacyResult;
306
+ try {
307
+ legacyResult = await fetchGrokBillingAttempt(
308
+ fetchImpl,
309
+ `${root}/v1/billing`,
310
+ headers,
311
+ deadlineMs,
312
+ );
313
+ } catch (error) {
314
+ if (error?.code === "GROK_AUTH_REQUIRED" || error?.code === "GROK_BILLING_TIMEOUT") throw error;
315
+ throw new Error(`Grok billing request failed (format=credits: ${creditsFailure})`, {
316
+ cause: error,
317
+ });
145
318
  }
146
- if (!res.ok) {
147
- throw new Error(`Grok billing API returned ${res.status}`);
319
+ if (!legacyResult.ok) {
320
+ throw new Error(
321
+ `Grok billing API returned ${legacyResult.status} (format=credits: ${creditsFailure})`,
322
+ );
148
323
  }
149
- return res.json();
324
+ return legacyResult.body;
150
325
  }
151
326
 
152
- async function fetchGrokLimits({ home, env, fetchImpl = fetch } = {}) {
327
+ async function fetchGrokLimits({ home, env, fetchImpl = fetch, timeoutMs } = {}) {
153
328
  if (!isGrokInstalled({ home, env })) {
154
329
  return { configured: false };
155
330
  }
@@ -158,7 +333,7 @@ async function fetchGrokLimits({ home, env, fetchImpl = fetch } = {}) {
158
333
  return { configured: false };
159
334
  }
160
335
  try {
161
- const body = await fetchGrokBilling(accessToken, { fetchImpl, env });
336
+ const body = await fetchGrokBilling(accessToken, { fetchImpl, env, timeoutMs });
162
337
  return {
163
338
  configured: true,
164
339
  error: null,
@@ -178,7 +353,10 @@ module.exports = {
178
353
  isGrokInstalled,
179
354
  loadGrokAuthEntry,
180
355
  readGrokAccessToken,
356
+ normalizeGrokPeriodType,
357
+ inferGrokPeriodTypeFromDates,
358
+ sumProductUsagePercent,
181
359
  normalizeGrokBillingResponse,
182
360
  fetchGrokBilling,
183
361
  fetchGrokLimits,
184
- };
362
+ };
@@ -1400,6 +1400,31 @@ function createLocalApiHandler({ queuePath }) {
1400
1400
  return true;
1401
1401
  }
1402
1402
 
1403
+ // Preview asset for a Codex pet that hasn't been imported yet (served from
1404
+ // ~/.codex/pets or straight out of the Codex.app bundle).
1405
+ const codexPetAssetMatch = p.match(/^\/api\/pets\/codex\/([a-z0-9-]+)\/spritesheet\.webp$/);
1406
+ if (codexPetAssetMatch) {
1407
+ const method = String(req.method || "GET").toUpperCase();
1408
+ if (method !== "GET" && method !== "HEAD") {
1409
+ json(res, { error: "Method Not Allowed" }, 405);
1410
+ return true;
1411
+ }
1412
+ const asset = require("./pet-packages").readCodexImportableAsset(codexPetAssetMatch[1]);
1413
+ if (!asset) {
1414
+ json(res, { error: "Pet not found" }, 404);
1415
+ return true;
1416
+ }
1417
+ res.writeHead(200, {
1418
+ "Content-Type": "image/webp",
1419
+ "Content-Length": asset.buffer.length,
1420
+ "Cache-Control": "no-cache",
1421
+ "X-Content-Type-Options": "nosniff",
1422
+ });
1423
+ if (method === "HEAD") res.end();
1424
+ else res.end(asset.buffer);
1425
+ return true;
1426
+ }
1427
+
1403
1428
  if (p === "/api/pets/import") {
1404
1429
  if (String(req.method || "GET").toUpperCase() !== "POST") {
1405
1430
  json(res, { ok: false, error: "Method Not Allowed" }, 405);
@@ -2498,6 +2523,11 @@ function createLocalApiHandler({ queuePath }) {
2498
2523
  const pets = require("./pet-packages");
2499
2524
  try {
2500
2525
  if (method === "GET") {
2526
+ if (url.searchParams.get("scope") === "codex") {
2527
+ const importable = pets.listCodexImportablePets();
2528
+ json(res, { importable, codexDetected: importable.length > 0 });
2529
+ return true;
2530
+ }
2501
2531
  json(res, { pets: pets.listInstalledPets() });
2502
2532
  return true;
2503
2533
  }
@@ -2515,6 +2545,10 @@ function createLocalApiHandler({ queuePath }) {
2515
2545
  json(res, { ok: true, ...pets.removeInstalledPet(body.id) });
2516
2546
  return true;
2517
2547
  }
2548
+ if (body?.action === "import_codex") {
2549
+ json(res, { ok: true, ...pets.importFromCodex(body.ids) });
2550
+ return true;
2551
+ }
2518
2552
  json(res, { ok: false, error: "Unknown pets action" }, 400);
2519
2553
  return true;
2520
2554
  }
@@ -16,10 +16,22 @@ const BUILTIN_IDS = new Set(["clawd", "sprout", "byte", "ember"]);
16
16
  const KIND_RE = /^[a-z][a-z0-9-]{0,39}$/;
17
17
  const PET_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
18
18
  const petDirectoryCache = new Map();
19
+ let codexAsarCache = null;
20
+
21
+ // TokenTracker keeps its own pets under ~/.tokentracker/pets and never writes to the
22
+ // Codex app: URL/zip imports stay here, and Codex is only ever read from (reverse
23
+ // import). `~/.codex/pets` is reachable purely as an import source.
24
+ const MIGRATION_MARKER = ".migrated-v1";
19
25
 
20
26
  function resolvePetsDir() {
21
27
  return path.resolve(
22
- process.env.TOKENTRACKER_PETS_DIR || path.join(os.homedir(), ".codex", "pets"),
28
+ process.env.TOKENTRACKER_PETS_DIR || path.join(os.homedir(), ".tokentracker", "pets"),
29
+ );
30
+ }
31
+
32
+ function resolveCodexPetsDir() {
33
+ return path.resolve(
34
+ process.env.TOKENTRACKER_CODEX_PETS_DIR || path.join(os.homedir(), ".codex", "pets"),
23
35
  );
24
36
  }
25
37
 
@@ -218,6 +230,12 @@ function loadPetDirectory(directory, expectedId = null) {
218
230
  if (!manifestStat.isFile() || manifestStat.isSymbolicLink() || !spriteStat.isFile() || spriteStat.isSymbolicLink()) {
219
231
  throw new Error("Pet assets must be regular files");
220
232
  }
233
+ if (manifestStat.size <= 0 || manifestStat.size > MAX_MANIFEST_BYTES) {
234
+ throw new Error("pet.json is missing or too large");
235
+ }
236
+ if (spriteStat.size <= 0 || spriteStat.size > MAX_SPRITESHEET_BYTES) {
237
+ throw new Error("spritesheet.webp is missing or too large");
238
+ }
221
239
  const cacheKey = [
222
240
  manifestStat.mtimeMs,
223
241
  manifestStat.size,
@@ -240,6 +258,7 @@ function loadPetDirectory(directory, expectedId = null) {
240
258
  }
241
259
 
242
260
  function listInstalledPets() {
261
+ migrateLegacyCodexPets();
243
262
  const root = resolvePetsDir();
244
263
  let names = [];
245
264
  try { names = fs.readdirSync(root); } catch (error) {
@@ -259,9 +278,9 @@ function listInstalledPets() {
259
278
  return pets.sort((a, b) => a.displayName.localeCompare(b.displayName));
260
279
  }
261
280
 
262
- function installValidatedPackage(manifestBuffer, spritesheetBuffer) {
263
- const { manifest } = validatePackageFiles(manifestBuffer, spritesheetBuffer);
264
- const root = resolvePetsDir();
281
+ // Atomically writes one validated pet into `root` (staged dir → rename), optionally
282
+ // dropping `markerName` inside the pet directory. Returns the loaded asset info.
283
+ function writePetIntoDir(root, manifest, spritesheetBuffer, markerName = null) {
265
284
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
266
285
  const stage = path.join(root, `.tokentracker-pet-${process.pid}-${crypto.randomBytes(6).toString("hex")}`);
267
286
  const destination = path.join(root, manifest.id);
@@ -277,6 +296,7 @@ function installValidatedPackage(manifestBuffer, spritesheetBuffer) {
277
296
  kind: manifest.kind,
278
297
  }, null, 2)}\n`, { mode: 0o600 });
279
298
  fs.writeFileSync(path.join(stage, PET_SPRITESHEET), spritesheetBuffer, { mode: 0o600 });
299
+ if (markerName) fs.writeFileSync(path.join(stage, markerName), "", { mode: 0o600 });
280
300
  loadPetDirectory(stage, manifest.id);
281
301
  petDirectoryCache.delete(destination);
282
302
  if (fs.existsSync(destination)) fs.renameSync(destination, backup);
@@ -289,7 +309,12 @@ function installValidatedPackage(manifestBuffer, spritesheetBuffer) {
289
309
  throw error;
290
310
  }
291
311
  try { fs.rmSync(backup, { recursive: true, force: true }); } catch {}
292
- const installed = loadPetDirectory(destination, manifest.id);
312
+ return loadPetDirectory(destination, manifest.id);
313
+ }
314
+
315
+ function installValidatedPackage(manifestBuffer, spritesheetBuffer) {
316
+ const { manifest } = validatePackageFiles(manifestBuffer, spritesheetBuffer);
317
+ const installed = writePetIntoDir(resolvePetsDir(), manifest, spritesheetBuffer);
293
318
  return publicPet(manifest, { assetVersion: installed.assetVersion });
294
319
  }
295
320
 
@@ -353,20 +378,258 @@ function removeInstalledPet(id) {
353
378
  }
354
379
 
355
380
  function resolvePetAsset(id) {
381
+ // Native hosts can ask for the persisted custom pet before the dashboard has
382
+ // listed the catalog. Migrate first so an upgrade does not turn that first
383
+ // request into a 404 and reset the user's selection.
384
+ migrateLegacyCodexPets();
356
385
  const normalized = String(id || "").trim().toLowerCase();
357
386
  if (!PET_ID_RE.test(normalized) || BUILTIN_IDS.has(normalized)) return null;
358
387
  try { return loadPetDirectory(path.join(resolvePetsDir(), normalized), normalized); } catch { return null; }
359
388
  }
360
389
 
390
+ // --- One-time migration: copy legacy pets out of the shared Codex dir into our own ---
391
+
392
+ function migrateLegacyCodexPets() {
393
+ const root = resolvePetsDir();
394
+ const marker = path.join(root, MIGRATION_MARKER);
395
+ try { if (fs.existsSync(marker)) return { migrated: 0 }; } catch { return { migrated: 0 }; }
396
+ try { fs.mkdirSync(root, { recursive: true, mode: 0o700 }); } catch { return { migrated: 0 }; }
397
+ let migrated = 0;
398
+ let names = [];
399
+ let scanComplete = false;
400
+ try {
401
+ names = fs.readdirSync(resolveCodexPetsDir());
402
+ scanComplete = true;
403
+ } catch (error) {
404
+ // A missing legacy directory is a completed no-op. Permission and I/O
405
+ // failures are not: leave the marker absent so the next launch can retry.
406
+ if (error?.code === "ENOENT") scanComplete = true;
407
+ }
408
+ let copyFailed = false;
409
+ for (const name of names) {
410
+ if (!PET_ID_RE.test(name) || BUILTIN_IDS.has(name)) continue;
411
+ if (fs.existsSync(path.join(root, name))) continue; // never clobber an existing local pet
412
+ let loaded;
413
+ try {
414
+ loaded = loadPetDirectory(path.join(resolveCodexPetsDir(), name), name);
415
+ } catch {
416
+ // Invalid legacy packages are ignored just as they were by the old catalog.
417
+ continue;
418
+ }
419
+ try {
420
+ writePetIntoDir(root, loaded.manifest, fs.readFileSync(loaded.spritesheetPath));
421
+ migrated += 1;
422
+ } catch {
423
+ copyFailed = true;
424
+ }
425
+ }
426
+ if (scanComplete && !copyFailed) {
427
+ try { fs.writeFileSync(marker, `${new Date().toISOString()}\n`, { mode: 0o600 }); } catch {}
428
+ }
429
+ return { migrated };
430
+ }
431
+
432
+ // --- Reverse import: pull Codex's own pets into TokenTracker ---
433
+
434
+ // Resolved per call (not frozen at load) so an env override applies at runtime.
435
+ function resolveCodexAsarPath() {
436
+ return process.env.TOKENTRACKER_CODEX_ASAR
437
+ || "/Applications/Codex.app/Contents/Resources/app.asar";
438
+ }
439
+ // Codex ships these native companions bundled inside its app; the display names are
440
+ // stable brand labels. Anything not listed falls back to a capitalized id.
441
+ const CODEX_BUILTIN_NAMES = {
442
+ rocky: "Rocky", seedy: "Seedy", hoots: "Hoots", dewey: "Dewey", fireball: "Fireball",
443
+ stacky: "Stacky", codex: "Codex", bsod: "BSOD", "null-signal": "Null Signal",
444
+ };
445
+
446
+ // Parses the asar header (Chromium Pickle) and returns the file tree plus the byte
447
+ // offset where file contents begin. Pure Node, no dependency — asar's layout is stable.
448
+ function readAsarHeader(fd) {
449
+ const head = Buffer.alloc(16);
450
+ if (fs.readSync(fd, head, 0, 16, 0) < 16) throw new Error("asar too small");
451
+ const headerPayloadSize = head.readUInt32LE(4);
452
+ const jsonStrLen = head.readUInt32LE(12);
453
+ if (jsonStrLen <= 0 || jsonStrLen > 64 * 1024 * 1024) throw new Error("asar header size out of range");
454
+ const jsonBuf = Buffer.alloc(jsonStrLen);
455
+ fs.readSync(fd, jsonBuf, 0, jsonStrLen, 16);
456
+ const header = JSON.parse(jsonBuf.toString("utf8"));
457
+ const base = 8 + headerPayloadSize;
458
+ return { header, baseOffset: base % 4 ? base + 4 - (base % 4) : base };
459
+ }
460
+
461
+ // Reads Codex's bundled companion spritesheets out of the app's asar. Returns
462
+ // [{ id, displayName, description, spriteVersionNumber, buffer }]. Degrades to [] if
463
+ // Codex isn't installed or the bundle can't be read.
464
+ function readCodexBuiltinPets(asarPath = resolveCodexAsarPath()) {
465
+ const resolvedAsarPath = path.resolve(asarPath);
466
+ let asarStat;
467
+ try { asarStat = fs.statSync(resolvedAsarPath); } catch { return []; }
468
+ const cacheKey = `${resolvedAsarPath}:${asarStat.mtimeMs}:${asarStat.size}`;
469
+ if (codexAsarCache?.key === cacheKey) return codexAsarCache.pets;
470
+
471
+ let fd;
472
+ try { fd = fs.openSync(resolvedAsarPath, "r"); } catch { return []; }
473
+ try {
474
+ const { header, baseOffset } = readAsarHeader(fd);
475
+ const entries = [];
476
+ (function walk(node) {
477
+ for (const [name, value] of Object.entries(node?.files || {})) {
478
+ if (value?.files) walk(value);
479
+ else if (/-spritesheet-.*\.webp$/i.test(name) && value?.offset != null) entries.push([name, value]);
480
+ }
481
+ })(header);
482
+ const byId = new Map();
483
+ for (const [name, entry] of entries) {
484
+ const id = name.replace(/-spritesheet-.*$/i, "").toLowerCase();
485
+ if (!PET_ID_RE.test(id) || BUILTIN_IDS.has(id) || byId.has(id)) continue;
486
+ const size = Number(entry.size);
487
+ if (!(size > 0) || size > MAX_SPRITESHEET_BYTES) continue;
488
+ const buffer = Buffer.alloc(size);
489
+ fs.readSync(fd, buffer, 0, size, baseOffset + Number(entry.offset));
490
+ let dimensions;
491
+ try { dimensions = readWebpDimensions(buffer); } catch { continue; }
492
+ const spriteVersionNumber = dimensions.width === 1536 && dimensions.height === 2288 ? 2
493
+ : dimensions.width === 1536 && dimensions.height === 1872 ? 1 : 0;
494
+ if (!spriteVersionNumber) continue;
495
+ byId.set(id, {
496
+ id,
497
+ displayName: CODEX_BUILTIN_NAMES[id] || (id.charAt(0).toUpperCase() + id.slice(1)),
498
+ description: "Built-in Codex companion.",
499
+ spriteVersionNumber,
500
+ buffer,
501
+ });
502
+ }
503
+ const pets = [...byId.values()];
504
+ codexAsarCache = { key: cacheKey, pets };
505
+ return pets;
506
+ } catch {
507
+ codexAsarCache = { key: cacheKey, pets: [] };
508
+ return [];
509
+ }
510
+ finally { try { fs.closeSync(fd); } catch {} }
511
+ }
512
+
513
+ // Preview URL for an importable Codex pet (served before it lands in our own dir).
514
+ function codexAssetUrl(id, version) {
515
+ return `/api/pets/codex/${encodeURIComponent(id)}/${PET_SPRITESHEET}${version ? `?v=${encodeURIComponent(version)}` : ""}`;
516
+ }
517
+
518
+ // Lists Codex pets that could be imported but aren't in TokenTracker yet: valid
519
+ // packages in ~/.codex/pets plus Codex's app-bundled companions. Each carries an
520
+ // assetUrl so the picker can preview sprites.
521
+ function listCodexImportablePets() {
522
+ const alreadyHere = new Set(listInstalledPets().map((pet) => pet.id));
523
+ const result = new Map();
524
+ const codexRoot = resolveCodexPetsDir();
525
+ let names = [];
526
+ try { names = fs.readdirSync(codexRoot); } catch { names = []; }
527
+ for (const name of names) {
528
+ if (!PET_ID_RE.test(name) || BUILTIN_IDS.has(name) || alreadyHere.has(name)) continue;
529
+ try {
530
+ const loaded = loadPetDirectory(path.join(codexRoot, name), name);
531
+ result.set(name, {
532
+ ...loaded.manifest,
533
+ custom: true,
534
+ assetUrl: codexAssetUrl(name, loaded.assetVersion),
535
+ assetVersion: loaded.assetVersion,
536
+ source: "codex-dir",
537
+ });
538
+ } catch {}
539
+ }
540
+ for (const pet of readCodexBuiltinPets()) {
541
+ if (alreadyHere.has(pet.id) || result.has(pet.id)) continue;
542
+ result.set(pet.id, {
543
+ id: pet.id,
544
+ displayName: pet.displayName,
545
+ description: pet.description,
546
+ spritesheetPath: PET_SPRITESHEET,
547
+ spriteVersionNumber: pet.spriteVersionNumber,
548
+ custom: true,
549
+ assetUrl: codexAssetUrl(pet.id, `app${pet.spriteVersionNumber}`),
550
+ source: "codex-app",
551
+ });
552
+ }
553
+ return [...result.values()].sort((a, b) => a.displayName.localeCompare(b.displayName));
554
+ }
555
+
556
+ // Returns { buffer, contentLength } for an importable Codex pet's spritesheet — from
557
+ // ~/.codex/pets first, then the app bundle. Powers the picker preview route.
558
+ function readCodexImportableAsset(id) {
559
+ const normalized = String(id || "").trim().toLowerCase();
560
+ if (!PET_ID_RE.test(normalized) || BUILTIN_IDS.has(normalized)) return null;
561
+ const codexDir = path.join(resolveCodexPetsDir(), normalized);
562
+ try {
563
+ const loaded = loadPetDirectory(codexDir, normalized);
564
+ return { buffer: fs.readFileSync(loaded.spritesheetPath) };
565
+ } catch {}
566
+ for (const pet of readCodexBuiltinPets()) {
567
+ if (pet.id === normalized) return { buffer: pet.buffer };
568
+ }
569
+ return null;
570
+ }
571
+
572
+ function importFromCodex(ids) {
573
+ const requested = new Set(
574
+ (Array.isArray(ids) ? ids : [])
575
+ .map((id) => String(id || "").trim().toLowerCase())
576
+ .filter((id) => PET_ID_RE.test(id) && !BUILTIN_IDS.has(id)),
577
+ );
578
+ if (requested.size === 0) throw new Error("No valid Codex pet ids to import");
579
+ const root = resolvePetsDir();
580
+ // The listing excludes already-installed ids, but enforce the same rule at
581
+ // the write boundary so a handcrafted local API request cannot overwrite a
582
+ // TokenTracker-owned pet with Codex's copy.
583
+ for (const id of [...requested]) {
584
+ if (fs.existsSync(path.join(root, id))) requested.delete(id);
585
+ }
586
+ if (requested.size === 0) throw new Error("Requested Codex pets are already installed");
587
+ const codexRoot = resolveCodexPetsDir();
588
+ const imported = [];
589
+ for (const id of [...requested]) {
590
+ try {
591
+ const loaded = loadPetDirectory(path.join(codexRoot, id), id);
592
+ const installed = writePetIntoDir(root, loaded.manifest, fs.readFileSync(loaded.spritesheetPath));
593
+ imported.push(publicPet(loaded.manifest, { assetVersion: installed.assetVersion }));
594
+ requested.delete(id);
595
+ } catch {}
596
+ }
597
+ if (requested.size > 0) {
598
+ for (const pet of readCodexBuiltinPets()) {
599
+ if (!requested.has(pet.id)) continue;
600
+ const manifest = normalizeManifest({
601
+ id: pet.id,
602
+ displayName: pet.displayName,
603
+ description: pet.description,
604
+ spritesheetPath: PET_SPRITESHEET,
605
+ ...(pet.spriteVersionNumber === 2 ? { spriteVersionNumber: 2 } : {}),
606
+ });
607
+ try {
608
+ const installed = writePetIntoDir(root, manifest, pet.buffer);
609
+ imported.push(publicPet(manifest, { assetVersion: installed.assetVersion }));
610
+ requested.delete(pet.id);
611
+ } catch {}
612
+ }
613
+ }
614
+ if (imported.length === 0) throw new Error("None of the requested Codex pets could be imported");
615
+ return { imported };
616
+ }
617
+
361
618
  module.exports = {
362
619
  MAX_PACKAGE_BYTES,
620
+ importFromCodex,
363
621
  importPetZip,
364
622
  installFromCodexPets,
623
+ listCodexImportablePets,
365
624
  listInstalledPets,
625
+ migrateLegacyCodexPets,
366
626
  normalizeManifest,
367
627
  petIdFromCodexPetsUrl,
628
+ readCodexBuiltinPets,
629
+ readCodexImportableAsset,
368
630
  readWebpDimensions,
369
631
  removeInstalledPet,
632
+ resolveCodexPetsDir,
370
633
  resolvePetAsset,
371
634
  resolvePetsDir,
372
635
  validatePackageFiles,