@ipv9/tokentracker-cli 0.39.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 (192) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +464 -0
  3. package/README.ko.md +464 -0
  4. package/README.md +476 -0
  5. package/README.zh-CN.md +469 -0
  6. package/bin/tracker.js +52 -0
  7. package/dashboard/dist/app-icon.png +0 -0
  8. package/dashboard/dist/apple-touch-icon.png +0 -0
  9. package/dashboard/dist/assets/ActivityHeatmap-BxBGHY1z.js +42 -0
  10. package/dashboard/dist/assets/Card-GROxUc_6.js +1 -0
  11. package/dashboard/dist/assets/DashboardPage-BZ5VFoBk.js +19 -0
  12. package/dashboard/dist/assets/DevicePage-CQts7ivB.js +1 -0
  13. package/dashboard/dist/assets/DialogTitle-DqFsk26U.js +1 -0
  14. package/dashboard/dist/assets/FadeIn-HBBXW_3q.js +1 -0
  15. package/dashboard/dist/assets/HeaderGithubStar-COD2HfaF.js +1 -0
  16. package/dashboard/dist/assets/IpCheckPage-Cn0l8-BQ.js +15 -0
  17. package/dashboard/dist/assets/LandingPage-CmV1BMU4.js +4356 -0
  18. package/dashboard/dist/assets/LeaderboardAvatar-CiIpWZwb.js +1 -0
  19. package/dashboard/dist/assets/LeaderboardPage-BU0mlR3g.js +6 -0
  20. package/dashboard/dist/assets/LeaderboardProfileModal-C49_3Xm9.js +72 -0
  21. package/dashboard/dist/assets/LeaderboardProfilePage-Bh1n0BAM.js +1 -0
  22. package/dashboard/dist/assets/LimitsPage-fZAmwLWT.js +2 -0
  23. package/dashboard/dist/assets/LocalOnlyNotice-D3rQdA-P.js +1 -0
  24. package/dashboard/dist/assets/LoginPage-enO4XP-c.js +1 -0
  25. package/dashboard/dist/assets/PopoverPopup-pitJEpN1.js +1 -0
  26. package/dashboard/dist/assets/Select-tT9x4ntD.js +1 -0
  27. package/dashboard/dist/assets/SelectItemText-C7F7eIm0.js +1 -0
  28. package/dashboard/dist/assets/SettingsPage-pDasZNU2.js +1 -0
  29. package/dashboard/dist/assets/SkillsPage-DEu7FY-2.js +1 -0
  30. package/dashboard/dist/assets/WidgetsPage-DGcO7rbp.js +1 -0
  31. package/dashboard/dist/assets/WrappedPage-D_1ID213.js +1 -0
  32. package/dashboard/dist/assets/agent-logos-DXQVg1xy.js +1 -0
  33. package/dashboard/dist/assets/arrow-up-right-DNLo92Ba.js +1 -0
  34. package/dashboard/dist/assets/download-DbBBBpJF.js +1 -0
  35. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
  36. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
  37. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
  38. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
  39. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
  40. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
  41. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-BKC3PZkM.woff +0 -0
  42. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-DdrQ8KBw.woff2 +0 -0
  43. package/dashboard/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
  44. package/dashboard/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
  45. package/dashboard/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
  46. package/dashboard/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
  47. package/dashboard/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
  48. package/dashboard/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
  49. package/dashboard/dist/assets/geist-mono-latin-900-normal-CmoKXrdK.woff +0 -0
  50. package/dashboard/dist/assets/geist-mono-latin-900-normal-Cu5MFKsu.woff2 +0 -0
  51. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
  52. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
  53. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
  54. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
  55. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
  56. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
  57. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-Br6WNUGb.woff +0 -0
  58. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-CrmBTzU2.woff2 +0 -0
  59. package/dashboard/dist/assets/index-BeoRn2gJ.js +2 -0
  60. package/dashboard/dist/assets/info-Bh5qTz6k.js +1 -0
  61. package/dashboard/dist/assets/main-BIY3Nj6I.js +969 -0
  62. package/dashboard/dist/assets/main-G_F9Iu_x.css +1 -0
  63. package/dashboard/dist/assets/use-limits-display-prefs-B4iJ-bBc.js +1 -0
  64. package/dashboard/dist/assets/use-native-settings-DHjPFDv8.js +1 -0
  65. package/dashboard/dist/assets/use-usage-limits-odqe9OY7.js +1 -0
  66. package/dashboard/dist/assets/useCurrency-BsSZiZMP.js +1 -0
  67. package/dashboard/dist/assets/useOpenInteractionType-BQhgq4rv.js +12 -0
  68. package/dashboard/dist/brand-logos/antigravity.svg +1 -0
  69. package/dashboard/dist/brand-logos/claude-code.svg +1 -0
  70. package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
  71. package/dashboard/dist/brand-logos/codex.svg +15 -0
  72. package/dashboard/dist/brand-logos/copilot.svg +1 -0
  73. package/dashboard/dist/brand-logos/cursor.svg +1 -0
  74. package/dashboard/dist/brand-logos/gemini.svg +1 -0
  75. package/dashboard/dist/brand-logos/grok.svg +1 -0
  76. package/dashboard/dist/brand-logos/hermes.svg +1 -0
  77. package/dashboard/dist/brand-logos/kilo.svg +4 -0
  78. package/dashboard/dist/brand-logos/kimi.svg +1 -0
  79. package/dashboard/dist/brand-logos/kiro.svg +1 -0
  80. package/dashboard/dist/brand-logos/openclaw.svg +22 -0
  81. package/dashboard/dist/brand-logos/opencode.svg +3 -0
  82. package/dashboard/dist/clawd/happy.svg +161 -0
  83. package/dashboard/dist/clawd/idle/collapse.svg +101 -0
  84. package/dashboard/dist/clawd/idle/doze.svg +72 -0
  85. package/dashboard/dist/clawd/idle/follow.svg +64 -0
  86. package/dashboard/dist/clawd/idle/living.svg +196 -0
  87. package/dashboard/dist/clawd/idle/look.svg +115 -0
  88. package/dashboard/dist/clawd/idle/yawn.svg +158 -0
  89. package/dashboard/dist/clawd/mini/alert.svg +129 -0
  90. package/dashboard/dist/clawd/mini/crabwalk.svg +76 -0
  91. package/dashboard/dist/clawd/mini/enter-sleep.svg +65 -0
  92. package/dashboard/dist/clawd/mini/enter.svg +115 -0
  93. package/dashboard/dist/clawd/mini/happy.svg +124 -0
  94. package/dashboard/dist/clawd/mini/idle-tight.svg +15 -0
  95. package/dashboard/dist/clawd/mini/idle.svg +83 -0
  96. package/dashboard/dist/clawd/mini/peek.svg +82 -0
  97. package/dashboard/dist/clawd/mini/sleep.svg +112 -0
  98. package/dashboard/dist/clawd/react/double.svg +108 -0
  99. package/dashboard/dist/clawd/react/drag.svg +102 -0
  100. package/dashboard/dist/clawd/react/left.svg +102 -0
  101. package/dashboard/dist/clawd/react/right.svg +102 -0
  102. package/dashboard/dist/clawd/sleep/collapse-sleep.svg +247 -0
  103. package/dashboard/dist/clawd/sleep/sleeping.svg +118 -0
  104. package/dashboard/dist/clawd/sleep/wake.svg +277 -0
  105. package/dashboard/dist/clawd/static-base.svg +21 -0
  106. package/dashboard/dist/clawd/status/disconnected.svg +136 -0
  107. package/dashboard/dist/clawd/status/error.svg +94 -0
  108. package/dashboard/dist/clawd/status/notification.svg +134 -0
  109. package/dashboard/dist/clawd/working/building.svg +231 -0
  110. package/dashboard/dist/clawd/working/carrying.svg +107 -0
  111. package/dashboard/dist/clawd/working/conducting.svg +118 -0
  112. package/dashboard/dist/clawd/working/confused.svg +113 -0
  113. package/dashboard/dist/clawd/working/debugger.svg +91 -0
  114. package/dashboard/dist/clawd/working/juggling.svg +82 -0
  115. package/dashboard/dist/clawd/working/overheated.svg +75 -0
  116. package/dashboard/dist/clawd/working/pushing.svg +125 -0
  117. package/dashboard/dist/clawd/working/sweeping.svg +79 -0
  118. package/dashboard/dist/clawd/working/thinking.svg +119 -0
  119. package/dashboard/dist/clawd/working/typing.svg +153 -0
  120. package/dashboard/dist/clawd/working/ultrathink.svg +166 -0
  121. package/dashboard/dist/clawd/working/wizard.svg +98 -0
  122. package/dashboard/dist/dashboard-dark.png +0 -0
  123. package/dashboard/dist/favicon-16.png +0 -0
  124. package/dashboard/dist/favicon-32.png +0 -0
  125. package/dashboard/dist/favicon.ico +0 -0
  126. package/dashboard/dist/feed.xml +34 -0
  127. package/dashboard/dist/icon-192.png +0 -0
  128. package/dashboard/dist/icon-512.png +0 -0
  129. package/dashboard/dist/index.html +355 -0
  130. package/dashboard/dist/llms.txt +53 -0
  131. package/dashboard/dist/neon_bg.png +0 -0
  132. package/dashboard/dist/robots.txt +7 -0
  133. package/dashboard/dist/share.html +140 -0
  134. package/dashboard/dist/sitemap.xml +27 -0
  135. package/dashboard/dist/widgets-overview.png +0 -0
  136. package/package.json +78 -0
  137. package/scripts/install-local-service.sh +140 -0
  138. package/scripts/uninstall-local-service.sh +21 -0
  139. package/src/cli.js +95 -0
  140. package/src/commands/device-login.js +161 -0
  141. package/src/commands/diagnostics.js +38 -0
  142. package/src/commands/doctor.js +96 -0
  143. package/src/commands/init.js +1195 -0
  144. package/src/commands/serve.js +351 -0
  145. package/src/commands/status.js +609 -0
  146. package/src/commands/sync.js +2285 -0
  147. package/src/commands/uninstall.js +189 -0
  148. package/src/commands/wrapped.js +150 -0
  149. package/src/lib/antigravity-paths.js +48 -0
  150. package/src/lib/browser-auth.js +281 -0
  151. package/src/lib/categorizer-utils.js +232 -0
  152. package/src/lib/claude-categorizer.js +1319 -0
  153. package/src/lib/claude-config.js +204 -0
  154. package/src/lib/cli-ui.js +179 -0
  155. package/src/lib/codex-config.js +350 -0
  156. package/src/lib/codex-context-breakdown.js +400 -0
  157. package/src/lib/codex-rollout-parser.js +623 -0
  158. package/src/lib/codex-token-refresh.js +112 -0
  159. package/src/lib/cursor-config.js +425 -0
  160. package/src/lib/debug-flags.js +7 -0
  161. package/src/lib/diagnostics.js +272 -0
  162. package/src/lib/doctor.js +338 -0
  163. package/src/lib/fs.js +91 -0
  164. package/src/lib/gemini-config.js +288 -0
  165. package/src/lib/grok-hook.js +351 -0
  166. package/src/lib/init-flow.js +42 -0
  167. package/src/lib/local-api.js +1861 -0
  168. package/src/lib/openclaw-hook.js +420 -0
  169. package/src/lib/openclaw-session-plugin.js +520 -0
  170. package/src/lib/opencode-config.js +99 -0
  171. package/src/lib/passive-mode.js +185 -0
  172. package/src/lib/pricing/curated-overrides.json +79 -0
  173. package/src/lib/pricing/index.js +143 -0
  174. package/src/lib/pricing/litellm-fetcher.js +172 -0
  175. package/src/lib/pricing/matcher.js +255 -0
  176. package/src/lib/pricing/seed-snapshot.json +1 -0
  177. package/src/lib/progress.js +76 -0
  178. package/src/lib/project-usage-purge.js +106 -0
  179. package/src/lib/prompt.js +24 -0
  180. package/src/lib/proxy-env.js +131 -0
  181. package/src/lib/rollout.js +8442 -0
  182. package/src/lib/runtime-config.js +122 -0
  183. package/src/lib/skill-usage.js +267 -0
  184. package/src/lib/skills-manager.js +1127 -0
  185. package/src/lib/source-metadata.js +48 -0
  186. package/src/lib/sqlite-reader.js +136 -0
  187. package/src/lib/static-server.js +59 -0
  188. package/src/lib/subscriptions.js +453 -0
  189. package/src/lib/tracker-paths.js +16 -0
  190. package/src/lib/upload-throttle.js +148 -0
  191. package/src/lib/usage-limits.js +1806 -0
  192. package/src/lib/wrapped-aggregator.js +225 -0
@@ -0,0 +1,1861 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const { spawn } = require("node:child_process");
5
+ const crypto = require("node:crypto");
6
+ const { DEFAULT_BASE_URL, resolveRuntimeConfig } = require("./runtime-config");
7
+ const {
8
+ filterRowsByUsageScope,
9
+ getSourceScope,
10
+ listExcludedSources,
11
+ normalizeUsageScope,
12
+ } = require("./source-metadata");
13
+
14
+ const SYNC_TIMEOUT_MS = 120_000;
15
+ const TRACKER_BIN = path.resolve(__dirname, "../../bin/tracker.js");
16
+
17
+ // Avatar proxy (see /api/avatar-proxy below). In-memory LRU; survives the
18
+ // CLI server lifetime, which is good enough — the dashboard reloads cheaply.
19
+ const AVATAR_PROXY_TTL_MS = 60 * 60 * 1000; // 1h
20
+ const AVATAR_PROXY_MAX_BYTES = 512 * 1024; // 512 KiB per image
21
+ const AVATAR_PROXY_MAX_ENTRIES = 64;
22
+ const avatarProxyCache = new Map();
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Per-model pricing — delegated to src/lib/pricing/
26
+ // - CURATED overrides (kiro-*, hy3-*, composer-*, kimi-for-coding, etc.)
27
+ // - LiteLLM live data (mainstream claude / gpt-5 / gemini), 24h disk-cached
28
+ // - Bundled seed snapshot for first-install / offline fallback
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const {
32
+ MODEL_PRICING,
33
+ getModelPricing,
34
+ computeRowCost,
35
+ ensurePricingLoaded,
36
+ } = require("./pricing");
37
+
38
+ const {
39
+ computeClaudeCategoryBreakdown,
40
+ unsupportedSourcePayload: unsupportedCategoryPayload,
41
+ } = require("./claude-categorizer");
42
+
43
+ const { computeCodexContextBreakdown } = require("./codex-context-breakdown");
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Queue data helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function resolveQueuePath() {
50
+ const home = os.homedir();
51
+ return path.join(home, ".tokentracker", "tracker", "queue.jsonl");
52
+ }
53
+
54
+ function readProjectQueueData(projectQueuePath) {
55
+ let raw;
56
+ try {
57
+ raw = fs.readFileSync(projectQueuePath, "utf8");
58
+ } catch (e) {
59
+ if (e?.code !== "ENOENT") {
60
+ console.error("[LocalAPI] readProjectQueueData: failed to read:", e?.message || e);
61
+ }
62
+ return [];
63
+ }
64
+ const lines = raw.split("\n").filter((l) => l.trim());
65
+ const seen = new Map();
66
+ for (const line of lines) {
67
+ try {
68
+ const row = JSON.parse(line);
69
+ const key = `${row.project_key || ""}|${row.source || ""}|${row.hour_start || ""}`;
70
+ seen.set(key, row);
71
+ } catch {
72
+ // skip malformed
73
+ }
74
+ }
75
+ return Array.from(seen.values());
76
+ }
77
+
78
+ function isLegacyInclusiveCodexRow(row) {
79
+ if (!row || (row.source !== "codex" && row.source !== "every-code")) return false;
80
+ const inputTokens = Number(row.input_tokens || 0);
81
+ const cachedInputTokens = Number(row.cached_input_tokens || 0);
82
+ const outputTokens = Number(row.output_tokens || 0);
83
+ const totalTokens = Number(row.total_tokens || 0);
84
+ if (!Number.isFinite(inputTokens) || !Number.isFinite(cachedInputTokens)) return false;
85
+ if (cachedInputTokens <= 0 || inputTokens < cachedInputTokens) return false;
86
+ // Legacy Codex queue rows stored input inclusive of cache reads, while
87
+ // total_tokens remained input + output. Canonical rows keep input as pure
88
+ // non-cached input, so cache-heavy legacy rows can be identified by this
89
+ // exact invariant.
90
+ return totalTokens === inputTokens + outputTokens;
91
+ }
92
+
93
+ function normalizeQueueRow(row) {
94
+ let normalized = row;
95
+ if (isLegacyInclusiveCodexRow(normalized)) {
96
+ normalized = {
97
+ ...normalized,
98
+ input_tokens:
99
+ Number(normalized.input_tokens || 0) - Number(normalized.cached_input_tokens || 0),
100
+ };
101
+ }
102
+ // Legacy Cursor rows from versions ≤ 0.26.5 wrote billable_total_tokens = 0
103
+ // for "Included in Pro" / "Enterprise" / "no charge" records (kind-based
104
+ // gating in cursor-config.js#normalizeCursorUsage). The dashboard headline
105
+ // sums billable_total_tokens across sources, so those rows silently
106
+ // disappeared from the displayed total once any other source contributed
107
+ // non-zero billable usage (GitHub issue #106). Treat billing and usage as
108
+ // orthogonal: bump billable up to total_tokens at read time so historical
109
+ // queue.jsonl entries render correctly without requiring a file rewrite.
110
+ const sourceName = String(normalized.source || "").toLowerCase();
111
+ if (sourceName === "cursor") {
112
+ const totalTokens = Number(normalized.total_tokens || 0);
113
+ const billable = Number(normalized.billable_total_tokens || 0);
114
+ if (totalTokens > 0 && billable < totalTokens) {
115
+ normalized = { ...normalized, billable_total_tokens: totalTokens };
116
+ }
117
+ }
118
+ return normalized;
119
+ }
120
+
121
+ function readQueueData(queuePath) {
122
+ let raw;
123
+ try {
124
+ raw = fs.readFileSync(queuePath, "utf8");
125
+ } catch (e) {
126
+ // ENOENT is legitimate (never synced yet); anything else is a signal we
127
+ // don't want to hide behind an empty array forever — the dashboard would
128
+ // otherwise render "0 tokens" with no clue the queue was unreadable.
129
+ if (e?.code !== "ENOENT") {
130
+ console.error("[LocalAPI] readQueueData: failed to read queue:", e?.message || e);
131
+ }
132
+ return [];
133
+ }
134
+ const lines = raw.split("\n").filter((l) => l.trim());
135
+ // Parse row-by-row so a single corrupted line (partial write, disk-full
136
+ // truncation, …) does not wipe out every other row with it.
137
+ const parsed = [];
138
+ let malformed = 0;
139
+ for (const line of lines) {
140
+ try {
141
+ parsed.push(JSON.parse(line));
142
+ } catch {
143
+ malformed += 1;
144
+ }
145
+ }
146
+ if (malformed > 0) {
147
+ console.error(
148
+ `[LocalAPI] readQueueData: skipped ${malformed}/${lines.length} malformed line(s) in ${queuePath}`,
149
+ );
150
+ }
151
+ // Deduplicate: each sync appends cumulative totals per bucket, so for
152
+ // each (source, model, hour_start) keep only the latest (last) entry.
153
+ const seen = new Map();
154
+ for (const row of parsed) {
155
+ const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
156
+ seen.set(key, normalizeQueueRow(row));
157
+ }
158
+ return Array.from(seen.values());
159
+ }
160
+
161
+ function rowDayKey(row, timeZoneContext) {
162
+ const hs = row.hour_start;
163
+ if (!hs) return "";
164
+ if (
165
+ timeZoneContext &&
166
+ (timeZoneContext.timeZone || Number.isFinite(timeZoneContext.offsetMinutes))
167
+ ) {
168
+ const parts = getZonedParts(new Date(hs), timeZoneContext);
169
+ const key = formatPartsDayKey(parts);
170
+ if (key) return key;
171
+ }
172
+ return hs.slice(0, 10);
173
+ }
174
+
175
+ function aggregateByDay(rows, timeZoneContext = null) {
176
+ const byDay = new Map();
177
+ for (const row of rows) {
178
+ if (!row.hour_start) continue;
179
+ const day = rowDayKey(row, timeZoneContext);
180
+ if (!day) continue;
181
+ if (!byDay.has(day)) {
182
+ byDay.set(day, {
183
+ day,
184
+ total_tokens: 0,
185
+ billable_total_tokens: 0,
186
+ total_cost_usd: 0,
187
+ input_tokens: 0,
188
+ output_tokens: 0,
189
+ cached_input_tokens: 0,
190
+ cache_creation_input_tokens: 0,
191
+ reasoning_output_tokens: 0,
192
+ conversation_count: 0,
193
+ });
194
+ }
195
+ const a = byDay.get(day);
196
+ a.total_tokens += row.total_tokens || 0;
197
+ a.billable_total_tokens += row.billable_total_tokens ?? row.total_tokens ?? 0;
198
+ a.total_cost_usd += computeRowCost(row);
199
+ a.input_tokens += row.input_tokens || 0;
200
+ a.output_tokens += row.output_tokens || 0;
201
+ a.cached_input_tokens += row.cached_input_tokens || 0;
202
+ a.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
203
+ a.reasoning_output_tokens += row.reasoning_output_tokens || 0;
204
+ a.conversation_count += row.conversation_count || 0;
205
+
206
+ if (!a.models) {
207
+ a.models = {};
208
+ }
209
+ const model = row.model || "unknown";
210
+ a.models[model] = (a.models[model] || 0) + (row.total_tokens || 0);
211
+ }
212
+ return Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day));
213
+ }
214
+
215
+ function buildCodexCategoryFallbackFromQueue(queueRows, { from, to, timeZoneContext }) {
216
+ const totals = {
217
+ input_tokens: 0,
218
+ cached_input_tokens: 0,
219
+ cache_creation_input_tokens: 0,
220
+ output_tokens: 0,
221
+ reasoning_output_tokens: 0,
222
+ total_tokens: 0,
223
+ };
224
+ let conversationCount = 0;
225
+
226
+ for (const row of queueRows || []) {
227
+ if ((row?.source || "") !== "codex") continue;
228
+ if (!row.hour_start) continue;
229
+ const day = rowDayKey(row, timeZoneContext);
230
+ if (from && day < from) continue;
231
+ if (to && day > to) continue;
232
+ totals.input_tokens += Number(row.input_tokens || 0);
233
+ totals.cached_input_tokens += Number(row.cached_input_tokens || 0);
234
+ totals.cache_creation_input_tokens += Number(row.cache_creation_input_tokens || 0);
235
+ totals.output_tokens += Number(row.output_tokens || 0);
236
+ totals.reasoning_output_tokens += Number(row.reasoning_output_tokens || 0);
237
+ totals.total_tokens += Number(row.total_tokens || 0);
238
+ conversationCount += Number(row.conversation_count || 0);
239
+ }
240
+
241
+ return {
242
+ source: "codex",
243
+ scope: "supported",
244
+ breakdown_status: "queue_fallback",
245
+ totals,
246
+ session_count: 0,
247
+ message_count: conversationCount,
248
+ fallback: "queue_totals",
249
+ message_breakdown: {
250
+ categories: [
251
+ {
252
+ key: "user_input",
253
+ name: "User input",
254
+ totals: {
255
+ input_tokens: totals.input_tokens,
256
+ cached_input_tokens: 0,
257
+ cache_creation_input_tokens: 0,
258
+ output_tokens: 0,
259
+ reasoning_output_tokens: 0,
260
+ total_tokens: totals.input_tokens,
261
+ },
262
+ },
263
+ {
264
+ key: "conversation_history",
265
+ name: "Conversation history",
266
+ totals: {
267
+ input_tokens: 0,
268
+ cached_input_tokens: totals.cached_input_tokens,
269
+ cache_creation_input_tokens: totals.cache_creation_input_tokens,
270
+ output_tokens: 0,
271
+ reasoning_output_tokens: 0,
272
+ total_tokens: totals.cached_input_tokens + totals.cache_creation_input_tokens,
273
+ },
274
+ },
275
+ {
276
+ key: "assistant_response",
277
+ name: "Assistant response",
278
+ totals: {
279
+ input_tokens: 0,
280
+ cached_input_tokens: 0,
281
+ cache_creation_input_tokens: 0,
282
+ output_tokens: Math.max(0, totals.output_tokens - totals.reasoning_output_tokens),
283
+ reasoning_output_tokens: 0,
284
+ total_tokens: Math.max(0, totals.output_tokens - totals.reasoning_output_tokens),
285
+ },
286
+ },
287
+ ].sort((a, b) => Number(b.totals.total_tokens || 0) - Number(a.totals.total_tokens || 0)),
288
+ privacy: {
289
+ includes_content: false,
290
+ note: "Queue fallback includes aggregated token categories only; message text is never returned.",
291
+ },
292
+ },
293
+ tool_calls_breakdown: {
294
+ total_calls: 0,
295
+ tools: [],
296
+ categories: [],
297
+ tools_total: 0,
298
+ privacy: {
299
+ includes_inputs: false,
300
+ note: "Codex rollout sessions were unavailable; totals come from TokenTracker queue rows.",
301
+ },
302
+ },
303
+ exec_command_breakdown: {
304
+ by_type: [],
305
+ by_exit: [],
306
+ },
307
+ };
308
+ }
309
+
310
+ function getRequestedUsageScope(url) {
311
+ if (url.searchParams.get("include_account_level") === "1") return "all";
312
+ return normalizeUsageScope(url.searchParams.get("scope"));
313
+ }
314
+
315
+ function scopedQueueRows(queuePath, url) {
316
+ const scope = getRequestedUsageScope(url);
317
+ const allRows = readQueueData(queuePath);
318
+ return {
319
+ scope,
320
+ allRows,
321
+ rows: filterRowsByUsageScope(allRows, scope),
322
+ excludedSources: listExcludedSources(allRows, scope),
323
+ };
324
+ }
325
+
326
+ function getTimeZoneContext(url) {
327
+ const tz = String(url.searchParams.get("tz") || "").trim();
328
+ const rawOffset = Number(url.searchParams.get("tz_offset_minutes"));
329
+ return {
330
+ timeZone: tz || null,
331
+ offsetMinutes: Number.isFinite(rawOffset) ? Math.trunc(rawOffset) : null,
332
+ };
333
+ }
334
+
335
+ function getZonedParts(date, { timeZone, offsetMinutes } = {}) {
336
+ const dt = date instanceof Date ? date : new Date(date);
337
+ if (!Number.isFinite(dt.getTime())) return null;
338
+
339
+ if (timeZone && typeof Intl !== "undefined" && Intl.DateTimeFormat) {
340
+ try {
341
+ const formatter = new Intl.DateTimeFormat("en-CA", {
342
+ timeZone,
343
+ year: "numeric",
344
+ month: "2-digit",
345
+ day: "2-digit",
346
+ hour: "2-digit",
347
+ minute: "2-digit",
348
+ second: "2-digit",
349
+ hourCycle: "h23",
350
+ });
351
+ const parts = formatter.formatToParts(dt);
352
+ const values = parts.reduce((acc, part) => {
353
+ if (part.type && part.value) acc[part.type] = part.value;
354
+ return acc;
355
+ }, {});
356
+ const year = Number(values.year);
357
+ const month = Number(values.month);
358
+ const day = Number(values.day);
359
+ const hour = Number(values.hour);
360
+ const minute = Number(values.minute);
361
+ const second = Number(values.second);
362
+ if ([year, month, day, hour, minute, second].every(Number.isFinite)) {
363
+ return { year, month, day, hour, minute, second };
364
+ }
365
+ } catch (_e) {
366
+ // fall through
367
+ }
368
+ }
369
+
370
+ if (Number.isFinite(offsetMinutes)) {
371
+ const shifted = new Date(dt.getTime() + offsetMinutes * 60 * 1000);
372
+ return {
373
+ year: shifted.getUTCFullYear(),
374
+ month: shifted.getUTCMonth() + 1,
375
+ day: shifted.getUTCDate(),
376
+ hour: shifted.getUTCHours(),
377
+ minute: shifted.getUTCMinutes(),
378
+ second: shifted.getUTCSeconds(),
379
+ };
380
+ }
381
+
382
+ return {
383
+ year: dt.getFullYear(),
384
+ month: dt.getMonth() + 1,
385
+ day: dt.getDate(),
386
+ hour: dt.getHours(),
387
+ minute: dt.getMinutes(),
388
+ second: dt.getSeconds(),
389
+ };
390
+ }
391
+
392
+ function formatPartsDayKey(parts) {
393
+ if (!parts) return "";
394
+ return `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
395
+ }
396
+
397
+ function aggregateHourlyByDay(rows, dayKey, timeZoneContext) {
398
+ const byHour = new Map();
399
+ for (const row of rows) {
400
+ if (!row.hour_start) continue;
401
+ const parts = getZonedParts(new Date(row.hour_start), timeZoneContext);
402
+ if (!parts) continue;
403
+ if (formatPartsDayKey(parts) !== dayKey) continue;
404
+ const hourKey = `${dayKey}T${String(parts.hour).padStart(2, "0")}:00:00`;
405
+ if (!byHour.has(hourKey)) {
406
+ byHour.set(hourKey, {
407
+ hour: hourKey,
408
+ total_tokens: 0,
409
+ billable_total_tokens: 0,
410
+ input_tokens: 0,
411
+ output_tokens: 0,
412
+ cached_input_tokens: 0,
413
+ cache_creation_input_tokens: 0,
414
+ reasoning_output_tokens: 0,
415
+ conversation_count: 0,
416
+ });
417
+ }
418
+ const bucket = byHour.get(hourKey);
419
+ bucket.total_tokens += row.total_tokens || 0;
420
+ bucket.billable_total_tokens += row.total_tokens || 0;
421
+ bucket.input_tokens += row.input_tokens || 0;
422
+ bucket.output_tokens += row.output_tokens || 0;
423
+ bucket.cached_input_tokens += row.cached_input_tokens || 0;
424
+ bucket.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
425
+ bucket.reasoning_output_tokens += row.reasoning_output_tokens || 0;
426
+ bucket.conversation_count += row.conversation_count || 0;
427
+
428
+ if (!bucket.models) {
429
+ bucket.models = {};
430
+ }
431
+ const model = row.model || "unknown";
432
+ bucket.models[model] = (bucket.models[model] || 0) + (row.total_tokens || 0);
433
+ }
434
+ return Array.from(byHour.values()).sort((a, b) => a.hour.localeCompare(b.hour));
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Sync helper
439
+ // ---------------------------------------------------------------------------
440
+
441
+ function trimOutput(value, max = 4000) {
442
+ const t = String(value || "");
443
+ return t.length <= max ? t : t.slice(t.length - max);
444
+ }
445
+
446
+ function normalizeRemoteHttpBaseUrl(value) {
447
+ if (typeof value !== "string") return null;
448
+ const trimmed = value.trim();
449
+ if (!trimmed) return null;
450
+ try {
451
+ const url = new URL(trimmed);
452
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
453
+ url.username = "";
454
+ url.password = "";
455
+ url.hash = "";
456
+ return url.toString().replace(/\/$/, "");
457
+ } catch (_e) {
458
+ return null;
459
+ }
460
+ }
461
+
462
+ function resolveAllowedInsforgeBaseUrl(value) {
463
+ const requested = normalizeRemoteHttpBaseUrl(value);
464
+ if (!requested) return null;
465
+
466
+ const runtime = resolveRuntimeConfig();
467
+ const allowed = new Set(
468
+ [runtime.baseUrl, DEFAULT_BASE_URL]
469
+ .map((entry) => normalizeRemoteHttpBaseUrl(entry))
470
+ .filter(Boolean),
471
+ );
472
+
473
+ return allowed.has(requested) ? requested : null;
474
+ }
475
+
476
+ function parseCookieHeader(value) {
477
+ const out = new Map();
478
+ if (typeof value !== "string" || !value.trim()) return out;
479
+ for (const part of value.split(";")) {
480
+ const idx = part.indexOf("=");
481
+ if (idx < 1) continue;
482
+ const key = part.slice(0, idx).trim();
483
+ const rawValue = part.slice(idx + 1).trim();
484
+ if (key) out.set(key, rawValue);
485
+ }
486
+ return out;
487
+ }
488
+
489
+ function isLoopbackHostname(hostname) {
490
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]";
491
+ }
492
+
493
+ function hasAllowedLoopbackOrigin(headers = {}) {
494
+ const candidates = [headers.origin, headers.referer];
495
+ for (const raw of candidates) {
496
+ if (raw == null || raw === "") continue;
497
+ try {
498
+ const url = new URL(String(raw));
499
+ if (url.protocol !== "http:" || !isLoopbackHostname(url.hostname)) return false;
500
+ } catch (_e) {
501
+ return false;
502
+ }
503
+ }
504
+ return true;
505
+ }
506
+
507
+ function readJsonBody(req) {
508
+ return new Promise((resolve, reject) => {
509
+ const chunks = [];
510
+ req.on("data", (c) => chunks.push(c));
511
+ req.on("end", () => {
512
+ try {
513
+ const raw = Buffer.concat(chunks).toString("utf8");
514
+ if (!raw.trim()) return resolve({});
515
+ resolve(JSON.parse(raw));
516
+ } catch (e) {
517
+ reject(e);
518
+ }
519
+ });
520
+ req.on("error", reject);
521
+ });
522
+ }
523
+
524
+ function runSyncCommand(extraEnv = {}) {
525
+ return new Promise((resolve, reject) => {
526
+ const child = spawn(process.execPath, [TRACKER_BIN, "sync"], {
527
+ env: { ...process.env, ...extraEnv },
528
+ stdio: ["ignore", "pipe", "pipe"],
529
+ });
530
+ let stdout = "";
531
+ let stderr = "";
532
+ let settled = false;
533
+ const finish = (fn, v) => {
534
+ if (settled) return;
535
+ settled = true;
536
+ clearTimeout(tid);
537
+ fn(v);
538
+ };
539
+ const tid = setTimeout(() => {
540
+ child.kill("SIGTERM");
541
+ finish(
542
+ reject,
543
+ Object.assign(new Error("Sync timed out"), {
544
+ code: "SYNC_TIMEOUT",
545
+ stdout: trimOutput(stdout),
546
+ stderr: trimOutput(stderr),
547
+ }),
548
+ );
549
+ }, SYNC_TIMEOUT_MS);
550
+ child.stdout?.on("data", (c) => {
551
+ stdout += c;
552
+ });
553
+ child.stderr?.on("data", (c) => {
554
+ stderr += c;
555
+ });
556
+ child.on("error", (e) => {
557
+ finish(reject, Object.assign(e, { stdout: trimOutput(stdout), stderr: trimOutput(stderr) }));
558
+ });
559
+ child.on("close", (code) => {
560
+ const r = { code: code ?? 1, stdout: trimOutput(stdout), stderr: trimOutput(stderr) };
561
+ code === 0
562
+ ? finish(resolve, r)
563
+ : finish(reject, Object.assign(new Error(r.stderr || r.stdout || `exit ${r.code}`), r));
564
+ });
565
+ });
566
+ }
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // Project detection helpers
570
+ // ---------------------------------------------------------------------------
571
+
572
+ function parseGitUrl(url) {
573
+ if (!url) return null;
574
+ const ssh = url.match(/git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
575
+ if (ssh) return { owner: ssh[1], repo: ssh[2] };
576
+ const http = url.match(/https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/);
577
+ if (http) return { owner: http[1], repo: http[2] };
578
+ return null;
579
+ }
580
+
581
+ function extractProjectFromCwd(cwd) {
582
+ const home = os.homedir();
583
+ if (!cwd || cwd === home) return null;
584
+ const rel = cwd.replace(home + "/", "");
585
+ const parts = rel.split("/").filter((p) => p && !p.startsWith(".") && p !== "ext-global");
586
+ return parts.length > 0 ? parts[0] : null;
587
+ }
588
+
589
+ function scanCodexProjects(projectMap) {
590
+ const dir = path.join(os.homedir(), ".codex", "sessions");
591
+ try {
592
+ for (const year of fs.readdirSync(dir)) {
593
+ const yp = path.join(dir, year);
594
+ if (!fs.statSync(yp).isDirectory()) continue;
595
+ for (const month of fs.readdirSync(yp)) {
596
+ const mp = path.join(yp, month);
597
+ if (!fs.statSync(mp).isDirectory()) continue;
598
+ for (const day of fs.readdirSync(mp)) {
599
+ const dp = path.join(mp, day);
600
+ if (!fs.statSync(dp).isDirectory()) continue;
601
+ const files = fs.readdirSync(dp).filter((f) => f.endsWith(".jsonl"));
602
+ for (const file of files.slice(0, 200)) {
603
+ try {
604
+ const first = fs.readFileSync(path.join(dp, file), "utf8").split("\n")[0];
605
+ const d = JSON.parse(first);
606
+ if (d.git?.repository_url) {
607
+ const p = parseGitUrl(d.git.repository_url);
608
+ if (p) {
609
+ const key = `${p.owner}/${p.repo}`;
610
+ if (!projectMap.has(key))
611
+ projectMap.set(key, {
612
+ project_key: key,
613
+ project_ref: d.git.repository_url,
614
+ count: 0,
615
+ });
616
+ projectMap.get(key).count++;
617
+ }
618
+ }
619
+ } catch (_e) {}
620
+ }
621
+ }
622
+ }
623
+ }
624
+ } catch (_e) {}
625
+ }
626
+
627
+ function findSubagentsDirs(dir, depth) {
628
+ const out = [];
629
+ if (depth > 3) return out;
630
+ try {
631
+ for (const item of fs.readdirSync(dir)) {
632
+ const fp = path.join(dir, item);
633
+ if (!fs.statSync(fp).isDirectory()) continue;
634
+ if (item === "subagents") out.push(fp);
635
+ else out.push(...findSubagentsDirs(fp, depth + 1));
636
+ }
637
+ } catch (_e) {}
638
+ return out;
639
+ }
640
+
641
+ function scanClaudeProjects(projectMap) {
642
+ const dir = path.join(os.homedir(), ".claude", "projects");
643
+ try {
644
+ for (const subDir of findSubagentsDirs(dir, 0)) {
645
+ const files = fs.readdirSync(subDir).filter((f) => f.endsWith(".jsonl"));
646
+ for (const file of files.slice(0, 100)) {
647
+ try {
648
+ const first = fs.readFileSync(path.join(subDir, file), "utf8").split("\n")[0];
649
+ if (!first) continue;
650
+ const d = JSON.parse(first);
651
+ const name = extractProjectFromCwd(d.cwd);
652
+ if (name) {
653
+ if (!projectMap.has(name))
654
+ projectMap.set(name, {
655
+ project_key: name,
656
+ project_ref: `file://${d.cwd}`,
657
+ count: 0,
658
+ });
659
+ projectMap.get(name).count++;
660
+ }
661
+ } catch (_e) {}
662
+ }
663
+ }
664
+ } catch (_e) {}
665
+ }
666
+
667
+ // ---------------------------------------------------------------------------
668
+ // JSON response helper
669
+ // ---------------------------------------------------------------------------
670
+
671
+ function json(res, data, status) {
672
+ res.writeHead(status || 200, { "Content-Type": "application/json" });
673
+ res.end(JSON.stringify(data));
674
+ }
675
+
676
+ // ---------------------------------------------------------------------------
677
+ // IP check API proxy: dashboard/src/pages/IpCheckPage.jsx is a native React
678
+ // page that calls ip.net.coffee's data endpoints (/api/iprisk, /api/geoip,
679
+ // /api/dns/result, /favicons, /claude/status.json). Browser-side fetch can't
680
+ // hit them cross-origin from the dashboard, so we reverse-proxy /proxy/ipcheck/*
681
+ // to https://ip.net.coffee/* and strip embedding-hostile headers.
682
+ // (Previously this proxy also served the upstream HTML page for an iframe;
683
+ // the iframe and its HTML-rewrite path have been removed.)
684
+ // ---------------------------------------------------------------------------
685
+
686
+ const IP_CHECK_PROXY_PREFIX = "/proxy/ipcheck";
687
+ const IP_CHECK_TARGET = "https://ip.net.coffee";
688
+
689
+ // HTTP hop-by-hop headers (RFC 7230 §6.1) plus headers undici/fetch manages
690
+ // internally. Forwarding any of these to `fetch(...)` either silently breaks
691
+ // the request (host being wrong) or, on stricter undici versions like the
692
+ // 6.24.1 shipped with Node 22.22.2, throws UND_ERR_INVALID_ARG and turns
693
+ // every proxied POST into a 502. Keep this set authoritative for every
694
+ // reverse-proxy site in this module.
695
+ const HOP_BY_HOP_HEADERS = new Set([
696
+ "host",
697
+ "connection",
698
+ "keep-alive",
699
+ "content-length",
700
+ "transfer-encoding",
701
+ "upgrade",
702
+ "proxy-authorization",
703
+ "proxy-authenticate",
704
+ "proxy-connection",
705
+ "te",
706
+ "trailer",
707
+ "trailers",
708
+ ]);
709
+
710
+ // Strip forbidden + hop-by-hop headers when forwarding an inbound request to
711
+ // fetch(). Honours the Connection header's named-headers list (RFC 7230 §6.1)
712
+ // so values like `Connection: keep-alive, x-custom` also drop x-custom.
713
+ function buildProxyHeaders(headers) {
714
+ const entries =
715
+ headers && typeof headers.entries === "function"
716
+ ? Array.from(headers.entries())
717
+ : Object.entries(headers || {});
718
+
719
+ const connectionNamed = new Set();
720
+ const normalized = [];
721
+ for (const [rawKey, rawValue] of entries) {
722
+ if (rawValue == null) continue;
723
+ const key = String(rawKey).toLowerCase();
724
+ normalized.push([key, rawValue]);
725
+ if (key === "connection") {
726
+ const values = Array.isArray(rawValue) ? rawValue : [rawValue];
727
+ for (const v of values) {
728
+ String(v)
729
+ .split(",")
730
+ .map((part) => part.trim().toLowerCase())
731
+ .filter(Boolean)
732
+ .forEach((part) => connectionNamed.add(part));
733
+ }
734
+ }
735
+ }
736
+
737
+ const out = {};
738
+ for (const [key, rawValue] of normalized) {
739
+ if (HOP_BY_HOP_HEADERS.has(key) || connectionNamed.has(key)) continue;
740
+ if (Array.isArray(rawValue)) {
741
+ const joined = rawValue.filter((e) => e != null).map(String).join(", ");
742
+ if (joined) out[key] = joined;
743
+ continue;
744
+ }
745
+ out[key] = String(rawValue);
746
+ }
747
+ return out;
748
+ }
749
+
750
+ // ---------------------------------------------------------------------------
751
+ // Main handler factory
752
+ // ---------------------------------------------------------------------------
753
+
754
+ function createLocalApiHandler({ queuePath }) {
755
+ const qp = queuePath || resolveQueuePath();
756
+
757
+ // Server-side cookie relay: captures auth cookies from InsForge cloud responses
758
+ // so that both browser and WKWebView share the same login session via the proxy.
759
+ // Persisted to disk so cookies survive server restarts.
760
+ const csrfRelayCookieName = "insforge_csrf_token";
761
+ let relayCookies = new Map();
762
+ const localAuthToken = crypto.randomBytes(24).toString("hex");
763
+ const trackerDataDir = path.join(os.homedir(), ".tokentracker", "tracker");
764
+ const cookiePath = path.join(trackerDataDir, "relay-cookies.json");
765
+
766
+ // Load persisted cookies on startup
767
+ try {
768
+ if (!fs.existsSync(trackerDataDir)) fs.mkdirSync(trackerDataDir, { recursive: true });
769
+ if (fs.existsSync(cookiePath)) {
770
+ const content = fs.readFileSync(cookiePath, "utf8");
771
+ const saved = JSON.parse(content);
772
+ if (saved && typeof saved === "object") {
773
+ let count = 0;
774
+ for (const [k, v] of Object.entries(saved)) {
775
+ relayCookies.set(k, v);
776
+ count++;
777
+ }
778
+ if (count > 0) console.log(`[LocalAPI] Loaded ${count} relay cookies from ${cookiePath}`);
779
+ }
780
+ }
781
+ } catch (e) {
782
+ console.error("[LocalAPI] Failed to load relay cookies:", e.message);
783
+ }
784
+
785
+ function persistRelayCookies() {
786
+ try {
787
+ // Sticky semantics: never replace an existing on-disk session with an empty cookie map.
788
+ if (relayCookies.size === 0) return;
789
+
790
+ const json = JSON.stringify(Object.fromEntries(relayCookies));
791
+ fs.writeFileSync(cookiePath, json, { encoding: "utf8", mode: 0o600 });
792
+ } catch (e) {
793
+ console.error("[LocalAPI] Failed to persist relay cookies:", e.message);
794
+ }
795
+ }
796
+
797
+ function clearRelayCookies(reason) {
798
+ if (relayCookies.size === 0) return;
799
+ relayCookies.clear();
800
+ try {
801
+ if (fs.existsSync(cookiePath)) fs.unlinkSync(cookiePath);
802
+ } catch (e) {
803
+ console.error("[LocalAPI] Failed to clear relay cookies:", e.message);
804
+ return;
805
+ }
806
+ if (reason) console.warn(`[LocalAPI] Cleared relay cookies: ${reason}`);
807
+ }
808
+
809
+ function captureSetCookies(headerValue) {
810
+ if (!headerValue) return;
811
+ const parts = headerValue.split(/,(?=\s*\w+=)/);
812
+ let changed = false;
813
+ for (const raw of parts) {
814
+ const eqIdx = raw.indexOf("=");
815
+ if (eqIdx < 1) continue;
816
+ const name = raw.substring(0, eqIdx).trim();
817
+ if (!name) continue;
818
+
819
+ // Basic sticky logic: if it's a deletion cookie (Max-Age=0 or past date),
820
+ // we only remove it if we have it.
821
+ const lower = raw.toLowerCase();
822
+ const isDeletion = lower.includes("max-age=0") || lower.includes("expires=thu, 01 jan 1970");
823
+
824
+ if (isDeletion) {
825
+ if (relayCookies.has(name)) {
826
+ relayCookies.delete(name);
827
+ changed = true;
828
+ console.log(`[LocalAPI] Cookie deleted: ${name}`);
829
+ }
830
+ } else {
831
+ const oldVal = relayCookies.get(name);
832
+ if (oldVal !== raw.trim()) {
833
+ relayCookies.set(name, raw.trim());
834
+ changed = true;
835
+ console.log(`[LocalAPI] Cookie captured: ${name}`);
836
+ }
837
+ }
838
+ }
839
+ if (changed) persistRelayCookies();
840
+ }
841
+
842
+ function getRelayCookieValue(name, { decode = false } = {}) {
843
+ const raw = relayCookies.get(name);
844
+ if (!raw || typeof raw !== "string") return "";
845
+ const pair = raw.split(";")[0] || "";
846
+ const eqIdx = pair.indexOf("=");
847
+ if (eqIdx < 1) return "";
848
+ const value = pair.substring(eqIdx + 1).trim();
849
+ if (!decode) return value;
850
+ try {
851
+ return decodeURIComponent(value);
852
+ } catch {
853
+ return value;
854
+ }
855
+ }
856
+
857
+ function captureAuthTokensFromBody(bodyBuffer, contentType) {
858
+ if (!bodyBuffer || !String(contentType || "").toLowerCase().includes("application/json")) return;
859
+ let parsed = null;
860
+ try {
861
+ parsed = JSON.parse(bodyBuffer.toString("utf8"));
862
+ } catch {
863
+ return;
864
+ }
865
+ let changed = false;
866
+ const token = typeof parsed?.csrfToken === "string" ? parsed.csrfToken.trim() : "";
867
+ if (token) {
868
+ const cookie = `${csrfRelayCookieName}=${encodeURIComponent(token)}; Path=/; SameSite=Lax`;
869
+ if (relayCookies.get(csrfRelayCookieName) !== cookie) {
870
+ relayCookies.set(csrfRelayCookieName, cookie);
871
+ changed = true;
872
+ }
873
+ }
874
+ const refreshToken = typeof parsed?.refreshToken === "string" ? parsed.refreshToken.trim() : "";
875
+ if (refreshToken) {
876
+ const cookie = `insforge_refresh_token=${encodeURIComponent(refreshToken)}; Path=/; HttpOnly; SameSite=Lax`;
877
+ if (relayCookies.get("insforge_refresh_token") !== cookie) {
878
+ relayCookies.set("insforge_refresh_token", cookie);
879
+ changed = true;
880
+ }
881
+ }
882
+ if (changed) persistRelayCookies();
883
+ }
884
+
885
+ function normalizeCookieHeader(value) {
886
+ if (Array.isArray(value)) return value.filter(Boolean).join("; ");
887
+ return typeof value === "string" ? value : "";
888
+ }
889
+
890
+ function buildRelayCookieHeader(clientCookieHeader, { relayPrecedenceNames = [] } = {}) {
891
+ const normalizedClientCookieHeader = normalizeCookieHeader(clientCookieHeader);
892
+ if (relayCookies.size === 0) return normalizedClientCookieHeader;
893
+ const relayPrecedence = new Set(relayPrecedenceNames);
894
+ const clientPairs = new Map();
895
+ if (normalizedClientCookieHeader) {
896
+ for (const part of normalizedClientCookieHeader.split(";")) {
897
+ const eqIdx = part.indexOf("=");
898
+ if (eqIdx < 1) continue;
899
+ const n = part.substring(0, eqIdx).trim();
900
+ if (n) clientPairs.set(n, part.trim());
901
+ }
902
+ }
903
+ // Merge relay cookies. Normal requests keep client precedence; refresh
904
+ // recovery can opt relay cookies into precedence over stale WebView cookies.
905
+ for (const [name, raw] of relayCookies) {
906
+ if (clientPairs.has(name) && !relayPrecedence.has(name)) continue;
907
+ const scIdx = raw.indexOf(";");
908
+ const pair = scIdx > 0 ? raw.substring(0, scIdx).trim() : raw;
909
+ clientPairs.set(name, pair);
910
+ }
911
+ return [...clientPairs.values()].join("; ");
912
+ }
913
+
914
+ // Ephemeral auth bridge: WebView sets a "native" flag before opening system browser
915
+ // for OAuth. The callback page (in browser) checks this flag to decide whether to
916
+ // relay the code back to the app or handle it as a normal web flow.
917
+ let _nativeAuthPending = false;
918
+ let _nativeAuthExpiry = 0;
919
+
920
+ function isAuthorizedLocalMutation(req) {
921
+ const headerToken = req?.headers?.["x-tokentracker-local-auth"];
922
+ const cookieToken = parseCookieHeader(req?.headers?.cookie).get("tokentracker_local_auth");
923
+ const token = typeof headerToken === "string" && headerToken.trim()
924
+ ? headerToken.trim()
925
+ : cookieToken || "";
926
+ if (!token || token !== localAuthToken) return false;
927
+ return hasAllowedLoopbackOrigin(req?.headers || {});
928
+ }
929
+
930
+ return async function handleLocalApi(req, res, url) {
931
+ const p = url.pathname;
932
+
933
+ if (p === "/api/local-auth") {
934
+ if (String(req.method || "GET").toUpperCase() !== "GET") {
935
+ json(res, { error: "Method Not Allowed" }, 405);
936
+ return true;
937
+ }
938
+ res.writeHead(200, {
939
+ "Content-Type": "application/json",
940
+ "Cache-Control": "no-store",
941
+ });
942
+ res.end(JSON.stringify({ token: localAuthToken }));
943
+ return true;
944
+ }
945
+
946
+ // --- Auth bridge: native OAuth flag (WebView ↔ system browser) ---
947
+ if (p === "/api/auth-bridge/verifier") {
948
+ const method = String(req.method || "GET").toUpperCase();
949
+ if (method === "PUT" || method === "POST") {
950
+ if (!isAuthorizedLocalMutation(req)) {
951
+ json(res, { error: "Unauthorized" }, 401);
952
+ return true;
953
+ }
954
+ const body = await readJsonBody(req);
955
+ _nativeAuthPending = Boolean(body?.native);
956
+ _nativeAuthExpiry = Date.now() + 5 * 60 * 1000; // 5 min TTL
957
+ json(res, { ok: true });
958
+ return true;
959
+ }
960
+ if (method === "GET") {
961
+ const isNative = _nativeAuthPending && Date.now() < _nativeAuthExpiry;
962
+ _nativeAuthPending = false; // one-time read
963
+ _nativeAuthExpiry = 0;
964
+ json(res, { native: isNative });
965
+ return true;
966
+ }
967
+ json(res, { error: "Method Not Allowed" }, 405);
968
+ return true;
969
+ }
970
+
971
+ // --- auth proxy: forward /api/auth/* to InsForge cloud ---
972
+ if (p.startsWith("/api/auth/")) {
973
+ const runtime = resolveRuntimeConfig();
974
+ const insforgeBase = runtime.baseUrl || DEFAULT_BASE_URL;
975
+ try {
976
+ const targetUrl = `${insforgeBase.replace(/\/$/, "")}${p}${url.search || ""}`;
977
+ const proxyHeaders = buildProxyHeaders(req.headers);
978
+ const hasClientCookie = normalizeCookieHeader(proxyHeaders["cookie"]).trim().length > 0;
979
+ const hasCsrfHeader = typeof proxyHeaders["x-csrf-token"] === "string" && proxyHeaders["x-csrf-token"].trim().length > 0;
980
+ const relayCsrfToken = getRelayCookieValue(csrfRelayCookieName);
981
+ if (p === "/api/auth/refresh" && relayCsrfToken) {
982
+ proxyHeaders["x-csrf-token"] = relayCsrfToken;
983
+ }
984
+ const hasEffectiveCsrfHeader =
985
+ hasCsrfHeader ||
986
+ (typeof proxyHeaders["x-csrf-token"] === "string" && proxyHeaders["x-csrf-token"].trim().length > 0);
987
+ let shouldInjectRelayCookies =
988
+ p !== "/api/auth/refresh" || hasClientCookie || hasEffectiveCsrfHeader;
989
+ const relayRefreshToken = getRelayCookieValue("insforge_refresh_token", { decode: true });
990
+ const shouldUseRelayRefreshFallback =
991
+ p === "/api/auth/refresh" && !hasClientCookie && !hasEffectiveCsrfHeader && relayRefreshToken;
992
+ if (shouldUseRelayRefreshFallback) {
993
+ shouldInjectRelayCookies = false;
994
+ }
995
+
996
+ // Inject relay cookies so WebView benefits from browser's login session.
997
+ // Refresh requests need either a browser cookie or an explicit CSRF token;
998
+ // otherwise replaying a stale persisted refresh cookie just manufactures
999
+ // Invalid CSRF errors on startup.
1000
+ const originalCookieHeader = normalizeCookieHeader(proxyHeaders["cookie"]);
1001
+ const mergedCookie = shouldInjectRelayCookies
1002
+ ? buildRelayCookieHeader(originalCookieHeader, {
1003
+ relayPrecedenceNames: p === "/api/auth/refresh"
1004
+ ? [csrfRelayCookieName, "insforge_refresh_token"]
1005
+ : [],
1006
+ })
1007
+ : originalCookieHeader;
1008
+ const injectedRelayCookies =
1009
+ shouldInjectRelayCookies && relayCookies.size > 0 && mergedCookie !== originalCookieHeader;
1010
+ if (mergedCookie) proxyHeaders["cookie"] = mergedCookie;
1011
+
1012
+ const bodyChunks = [];
1013
+ for await (const chunk of req) bodyChunks.push(chunk);
1014
+ let proxyBody = bodyChunks.length > 0 ? Buffer.concat(bodyChunks) : undefined;
1015
+ let effectiveTargetUrl = targetUrl;
1016
+ if (shouldUseRelayRefreshFallback) {
1017
+ effectiveTargetUrl = `${insforgeBase.replace(/\/$/, "")}/api/auth/refresh?client_type=mobile`;
1018
+ proxyHeaders["content-type"] = "application/json";
1019
+ delete proxyHeaders["content-length"];
1020
+ proxyBody = Buffer.from(JSON.stringify({ refresh_token: relayRefreshToken }), "utf8");
1021
+ }
1022
+ const proxyRes = await fetch(effectiveTargetUrl, {
1023
+ method: req.method || "GET",
1024
+ headers: proxyHeaders,
1025
+ body: proxyBody,
1026
+ credentials: "include",
1027
+ redirect: "manual",
1028
+ });
1029
+ const responseHeaders = [...proxyRes.headers.entries()]
1030
+ .filter(([k]) => !["transfer-encoding", "connection"].includes(k.toLowerCase()))
1031
+ .map(([k, v]) => {
1032
+ if (k.toLowerCase() === "set-cookie") {
1033
+ const rewritten = v.replace(/;\s*[Dd]omain=[^;]*/g, "; Domain=localhost");
1034
+ captureSetCookies(rewritten);
1035
+ return [k, rewritten];
1036
+ }
1037
+ return [k, v];
1038
+ });
1039
+ res.writeHead(proxyRes.status, Object.fromEntries(responseHeaders));
1040
+ const resBody = Buffer.from(await proxyRes.arrayBuffer());
1041
+ if (proxyRes.status >= 200 && proxyRes.status < 300) {
1042
+ if (p === "/api/auth/logout") {
1043
+ clearRelayCookies("sign out");
1044
+ } else {
1045
+ captureAuthTokensFromBody(resBody, proxyRes.headers.get("content-type"));
1046
+ }
1047
+ }
1048
+ if (
1049
+ p === "/api/auth/refresh"
1050
+ && proxyRes.status === 403
1051
+ && injectedRelayCookies
1052
+ && !hasClientCookie
1053
+ && /invalid csrf token/i.test(resBody.toString("utf8"))
1054
+ ) {
1055
+ clearRelayCookies("stale refresh cookie without local CSRF context");
1056
+ }
1057
+ res.end(resBody);
1058
+ } catch (e) {
1059
+ json(res, { error: `Auth proxy error: ${e?.message || e}` }, 502);
1060
+ }
1061
+ return true;
1062
+ }
1063
+
1064
+ // --- ip-check proxy: reverse-proxy ip.net.coffee (issue #81) ---
1065
+ // Lock-down: GET/HEAD only, restricted path prefixes, do not forward
1066
+ // browser credentials or fingerprintable headers. Without these limits
1067
+ // /proxy/ipcheck is an open reverse-proxy any local process can abuse
1068
+ // (exfiltrate dashboard cookies, anonymously POST through user IP).
1069
+ if (p.startsWith(`${IP_CHECK_PROXY_PREFIX}/`) || p === IP_CHECK_PROXY_PREFIX) {
1070
+ const method = String(req.method || "GET").toUpperCase();
1071
+ if (method !== "GET" && method !== "HEAD") {
1072
+ json(res, { error: "Method Not Allowed" }, 405);
1073
+ return true;
1074
+ }
1075
+ const targetPath = p === IP_CHECK_PROXY_PREFIX
1076
+ ? "/"
1077
+ : p.slice(IP_CHECK_PROXY_PREFIX.length) || "/";
1078
+ const ALLOWED_PREFIXES = [
1079
+ "/api/geoip/",
1080
+ "/api/geoip-batch",
1081
+ "/api/iprisk/",
1082
+ "/api/dns/result/",
1083
+ "/claude/status.json",
1084
+ "/favicons/",
1085
+ "/ip/",
1086
+ ];
1087
+ if (!ALLOWED_PREFIXES.some((prefix) => targetPath.startsWith(prefix))) {
1088
+ json(res, { error: "Path not allowed" }, 403);
1089
+ return true;
1090
+ }
1091
+ const targetUrl = `${IP_CHECK_TARGET}${targetPath}${url.search || ""}`;
1092
+ try {
1093
+ // Whitelist forwarded headers — no cookies, no auth, no fingerprintable
1094
+ // identity. Only what the upstream needs to negotiate content. Do not
1095
+ // set `host` explicitly: undici derives it from the URL, and some
1096
+ // versions reject a manual host header on fetch() (same forbidden-
1097
+ // header family that broke /api/auth/* in 5/13).
1098
+ const proxyHeaders = {
1099
+ accept: req.headers["accept"] || "*/*",
1100
+ "accept-language": req.headers["accept-language"] || "en",
1101
+ "accept-encoding": req.headers["accept-encoding"] || "gzip",
1102
+ "user-agent": "TokenTracker/IPCheck (https://www.tokentracker.cc)",
1103
+ referer: `${IP_CHECK_TARGET}${targetPath}`,
1104
+ };
1105
+
1106
+ const proxyRes = await fetch(targetUrl, {
1107
+ method,
1108
+ headers: proxyHeaders,
1109
+ redirect: "manual",
1110
+ });
1111
+
1112
+ const stripped = new Set([
1113
+ "transfer-encoding",
1114
+ "connection",
1115
+ "content-length",
1116
+ "content-encoding",
1117
+ "x-frame-options",
1118
+ "content-security-policy",
1119
+ "cross-origin-opener-policy",
1120
+ "cross-origin-embedder-policy",
1121
+ "cross-origin-resource-policy",
1122
+ ]);
1123
+ const responseHeaders = [...proxyRes.headers.entries()].filter(
1124
+ ([k]) => !stripped.has(k.toLowerCase()),
1125
+ );
1126
+
1127
+ const resBody = Buffer.from(await proxyRes.arrayBuffer());
1128
+ res.writeHead(proxyRes.status, Object.fromEntries(responseHeaders));
1129
+ res.end(resBody);
1130
+ } catch (e) {
1131
+ json(res, { error: `IP check proxy error: ${e?.message || e}` }, 502);
1132
+ }
1133
+ return true;
1134
+ }
1135
+
1136
+ // --- avatar proxy: fetch third-party avatars server-side ---
1137
+ // Why: WKWebView in TokenTrackerBar fails to load some users' Google /
1138
+ // GitHub avatars directly (network-stack / proxy / TLS quirks vary by
1139
+ // environment), even when the same URL renders fine in Safari. Proxying
1140
+ // through Node's fetch — which honors system proxy + cookies-of-none —
1141
+ // produces a same-origin <img> the WKWebView always accepts.
1142
+ // Lock-down: GET only; host allowlist of well-known avatar CDNs (no open
1143
+ // proxy); strip cookies/auth; small in-memory cache.
1144
+ if (p === "/api/avatar-proxy") {
1145
+ const method = String(req.method || "GET").toUpperCase();
1146
+ if (method !== "GET" && method !== "HEAD") {
1147
+ json(res, { error: "Method Not Allowed" }, 405);
1148
+ return true;
1149
+ }
1150
+ const target = url.searchParams.get("url");
1151
+ if (!target) {
1152
+ json(res, { error: "Missing url" }, 400);
1153
+ return true;
1154
+ }
1155
+ let parsed;
1156
+ try {
1157
+ parsed = new URL(target);
1158
+ } catch {
1159
+ json(res, { error: "Invalid url" }, 400);
1160
+ return true;
1161
+ }
1162
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
1163
+ json(res, { error: "Only http(s) allowed" }, 400);
1164
+ return true;
1165
+ }
1166
+ const AVATAR_HOST_ALLOWLIST = [
1167
+ "lh3.googleusercontent.com",
1168
+ "lh4.googleusercontent.com",
1169
+ "lh5.googleusercontent.com",
1170
+ "lh6.googleusercontent.com",
1171
+ "avatars.githubusercontent.com",
1172
+ "secure.gravatar.com",
1173
+ "www.gravatar.com",
1174
+ "gravatar.com",
1175
+ "cdn.discordapp.com",
1176
+ "pbs.twimg.com",
1177
+ "abs.twimg.com",
1178
+ "api.dicebear.com",
1179
+ ];
1180
+ const hostOk = AVATAR_HOST_ALLOWLIST.some(
1181
+ (h) => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`),
1182
+ );
1183
+ if (!hostOk) {
1184
+ json(res, { error: "Host not allowed" }, 403);
1185
+ return true;
1186
+ }
1187
+
1188
+ const cacheKey = parsed.toString();
1189
+ const now = Date.now();
1190
+ const cached = avatarProxyCache.get(cacheKey);
1191
+ if (cached && now - cached.fetchedAt < AVATAR_PROXY_TTL_MS) {
1192
+ res.writeHead(200, {
1193
+ "Content-Type": cached.contentType,
1194
+ "Cache-Control": "public, max-age=3600",
1195
+ "X-Avatar-Cache": "HIT",
1196
+ });
1197
+ res.end(method === "HEAD" ? undefined : cached.body);
1198
+ return true;
1199
+ }
1200
+
1201
+ try {
1202
+ const upstream = await fetch(cacheKey, {
1203
+ method,
1204
+ redirect: "follow",
1205
+ headers: {
1206
+ accept: req.headers["accept"] || "image/*",
1207
+ "accept-language": req.headers["accept-language"] || "en",
1208
+ "user-agent": "TokenTracker/AvatarProxy (https://www.tokentracker.cc)",
1209
+ },
1210
+ });
1211
+ if (!upstream.ok) {
1212
+ json(res, { error: `Upstream ${upstream.status}` }, upstream.status);
1213
+ return true;
1214
+ }
1215
+ const contentType = upstream.headers.get("content-type") || "image/png";
1216
+ if (!contentType.toLowerCase().startsWith("image/")) {
1217
+ json(res, { error: "Not an image" }, 415);
1218
+ return true;
1219
+ }
1220
+ const body = Buffer.from(await upstream.arrayBuffer());
1221
+ if (body.length <= AVATAR_PROXY_MAX_BYTES) {
1222
+ // Simple LRU: drop oldest if over capacity.
1223
+ if (avatarProxyCache.size >= AVATAR_PROXY_MAX_ENTRIES) {
1224
+ const oldestKey = avatarProxyCache.keys().next().value;
1225
+ if (oldestKey) avatarProxyCache.delete(oldestKey);
1226
+ }
1227
+ avatarProxyCache.set(cacheKey, { body, contentType, fetchedAt: now });
1228
+ }
1229
+ res.writeHead(200, {
1230
+ "Content-Type": contentType,
1231
+ "Cache-Control": "public, max-age=3600",
1232
+ "X-Avatar-Cache": "MISS",
1233
+ });
1234
+ res.end(method === "HEAD" ? undefined : body);
1235
+ } catch (e) {
1236
+ json(res, { error: `Avatar proxy error: ${e?.message || e}` }, 502);
1237
+ }
1238
+ return true;
1239
+ }
1240
+
1241
+ // --- local-sync (POST) ---
1242
+ if (p === "/functions/tokentracker-local-sync") {
1243
+ if (String(req.method || "GET").toUpperCase() !== "POST") {
1244
+ json(res, { ok: false, error: "Method Not Allowed" }, 405);
1245
+ return true;
1246
+ }
1247
+ if (!isAuthorizedLocalMutation(req)) {
1248
+ json(res, { ok: false, error: "Unauthorized" }, 401);
1249
+ return true;
1250
+ }
1251
+ try {
1252
+ let body = {};
1253
+ try {
1254
+ body = await readJsonBody(req);
1255
+ } catch {
1256
+ body = {};
1257
+ }
1258
+ const extraEnv = {};
1259
+ if (typeof body.deviceToken === "string" && body.deviceToken.trim()) {
1260
+ extraEnv.TOKENTRACKER_DEVICE_TOKEN = body.deviceToken.trim();
1261
+ }
1262
+ if (body.insforgeBaseUrl != null) {
1263
+ const allowedBaseUrl = resolveAllowedInsforgeBaseUrl(body.insforgeBaseUrl);
1264
+ if (!allowedBaseUrl) {
1265
+ json(res, { ok: false, error: "Unsupported insforgeBaseUrl override" }, 400);
1266
+ return true;
1267
+ }
1268
+ extraEnv.TOKENTRACKER_INSFORGE_BASE_URL = allowedBaseUrl;
1269
+ }
1270
+ const result = await runSyncCommand(extraEnv);
1271
+ try {
1272
+ const { resetUsageLimitsCache } = require("./usage-limits");
1273
+ resetUsageLimitsCache();
1274
+ } catch (_e) {
1275
+ // ignore if module load fails
1276
+ }
1277
+ json(res, { ok: true, ...result });
1278
+ } catch (e) {
1279
+ json(res, { ok: false, error: e?.message, code: e?.code ?? null, stdout: e?.stdout || "", stderr: e?.stderr || "" }, 500);
1280
+ }
1281
+ return true;
1282
+ }
1283
+
1284
+ // --- wrapped (year-end summary, à la Spotify Wrapped) ---
1285
+ if (p === "/functions/tokentracker-wrapped") {
1286
+ const yearParam = url.searchParams.get("year");
1287
+ const year = yearParam ? Number(yearParam) : null;
1288
+ const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
1289
+ const { aggregateWrapped } = require("./wrapped-aggregator");
1290
+ const summary = aggregateWrapped(rows, year ? { year } : {});
1291
+ json(res, { scope, excluded_sources: excludedSources, ...summary });
1292
+ return true;
1293
+ }
1294
+
1295
+ // --- usage-summary ---
1296
+ if (p === "/functions/tokentracker-usage-summary") {
1297
+ const from = url.searchParams.get("from") || "";
1298
+ const to = url.searchParams.get("to") || "";
1299
+ const timeZoneContext = getTimeZoneContext(url);
1300
+ const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
1301
+ const daily = aggregateByDay(rows, timeZoneContext).filter((d) => d.day >= from && d.day <= to);
1302
+ const totals = daily.reduce(
1303
+ (acc, r) => {
1304
+ acc.total_tokens += r.total_tokens;
1305
+ acc.billable_total_tokens += r.billable_total_tokens;
1306
+ acc.total_cost_usd += r.total_cost_usd || 0;
1307
+ acc.input_tokens += r.input_tokens;
1308
+ acc.output_tokens += r.output_tokens;
1309
+ acc.cached_input_tokens += r.cached_input_tokens;
1310
+ acc.cache_creation_input_tokens += r.cache_creation_input_tokens;
1311
+ acc.reasoning_output_tokens += r.reasoning_output_tokens;
1312
+ acc.conversation_count += r.conversation_count;
1313
+ return acc;
1314
+ },
1315
+ { total_tokens: 0, billable_total_tokens: 0, total_cost_usd: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, conversation_count: 0 },
1316
+ );
1317
+ const totalCost = totals.total_cost_usd;
1318
+
1319
+ const todayParts = getZonedParts(new Date(), timeZoneContext);
1320
+ const todayStr = formatPartsDayKey(todayParts) || new Date().toISOString().slice(0, 10);
1321
+ const allDaily = aggregateByDay(rows, timeZoneContext);
1322
+
1323
+ const shiftDay = (dayStr, delta) => {
1324
+ const d = new Date(`${dayStr}T00:00:00Z`);
1325
+ d.setUTCDate(d.getUTCDate() + delta);
1326
+ return d.toISOString().slice(0, 10);
1327
+ };
1328
+ const collectDays = (n) => {
1329
+ const out = [];
1330
+ for (let i = n - 1; i >= 0; i--) {
1331
+ const ds = shiftDay(todayStr, -i);
1332
+ const dd = allDaily.find((x) => x.day === ds);
1333
+ if (dd) out.push(dd);
1334
+ }
1335
+ return out;
1336
+ };
1337
+ const sumDays = (days) =>
1338
+ days.reduce((a, r) => {
1339
+ a.billable_total_tokens += r.billable_total_tokens;
1340
+ a.conversation_count += r.conversation_count;
1341
+ return a;
1342
+ }, { billable_total_tokens: 0, conversation_count: 0 });
1343
+
1344
+ const l7 = collectDays(7);
1345
+ const l30 = collectDays(30);
1346
+ const l7t = sumDays(l7);
1347
+ const l30t = sumDays(l30);
1348
+ const l7fromStr = shiftDay(todayStr, -6);
1349
+ const l30fromStr = shiftDay(todayStr, -29);
1350
+
1351
+ json(res, {
1352
+ from, to, days: daily.length, scope, excluded_sources: excludedSources,
1353
+ totals: { ...totals, total_cost_usd: totalCost.toFixed(6) },
1354
+ rolling: {
1355
+ last_7d: { from: l7fromStr, to: todayStr, active_days: l7.length, totals: l7t },
1356
+ last_30d: { from: l30fromStr, to: todayStr, active_days: l30.length, totals: l30t, avg_per_active_day: l30.length > 0 ? Math.round(l30t.billable_total_tokens / l30.length) : 0 },
1357
+ },
1358
+ });
1359
+ return true;
1360
+ }
1361
+
1362
+ // --- usage-daily ---
1363
+ if (p === "/functions/tokentracker-usage-daily") {
1364
+ const from = url.searchParams.get("from") || "";
1365
+ const to = url.searchParams.get("to") || "";
1366
+ const timeZoneContext = getTimeZoneContext(url);
1367
+ const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
1368
+ const daily = aggregateByDay(rows, timeZoneContext).filter((d) => d.day >= from && d.day <= to);
1369
+ json(res, { from, to, scope, excluded_sources: excludedSources, data: daily });
1370
+ return true;
1371
+ }
1372
+
1373
+ // --- usage-heatmap ---
1374
+ if (p === "/functions/tokentracker-usage-heatmap") {
1375
+ const weeks = parseInt(url.searchParams.get("weeks") || "52", 10);
1376
+ const timeZoneContext = getTimeZoneContext(url);
1377
+ const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
1378
+ const daily = aggregateByDay(rows, timeZoneContext);
1379
+ const todayParts = getZonedParts(new Date(), timeZoneContext);
1380
+ const todayStr = formatPartsDayKey(todayParts) || new Date().toISOString().slice(0, 10);
1381
+ const end = new Date(`${todayStr}T00:00:00Z`);
1382
+ const start = new Date(end);
1383
+ start.setUTCDate(start.getUTCDate() - weeks * 7 + 1);
1384
+ const from = start.toISOString().slice(0, 10);
1385
+ const to = end.toISOString().slice(0, 10);
1386
+ const byDay = new Map(daily.map((d) => [d.day, d]));
1387
+
1388
+ const allValues = daily.map((d) => d.billable_total_tokens).filter((v) => v > 0);
1389
+ const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0;
1390
+ const calcLevel = (v) => {
1391
+ if (v <= 0) return 0;
1392
+ if (maxValue === 0) return 1;
1393
+ const r = v / maxValue;
1394
+ if (r <= 0.25) return 1;
1395
+ if (r <= 0.5) return 2;
1396
+ if (r <= 0.75) return 3;
1397
+ return 4;
1398
+ };
1399
+
1400
+ // Build cells and group into weeks (array of 7-cell arrays) for the dashboard
1401
+ const cells = [];
1402
+ const cursor = new Date(start);
1403
+ while (cursor <= end) {
1404
+ const day = cursor.toISOString().slice(0, 10);
1405
+ const data = byDay.get(day);
1406
+ const billable = data?.billable_total_tokens || 0;
1407
+ cells.push({ day, total_tokens: data?.total_tokens || 0, billable_total_tokens: billable, level: calcLevel(billable), models: data?.models || null });
1408
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
1409
+ }
1410
+ const weeksArr = [];
1411
+ for (let i = 0; i < cells.length; i += 7) {
1412
+ weeksArr.push(cells.slice(i, i + 7));
1413
+ }
1414
+
1415
+ let totalCostUsd = 0;
1416
+ for (const d of daily) {
1417
+ if (d.day >= from && d.day <= to) {
1418
+ totalCostUsd += d.total_cost_usd || 0;
1419
+ }
1420
+ }
1421
+
1422
+ json(res, {
1423
+ from,
1424
+ to,
1425
+ scope,
1426
+ excluded_sources: excludedSources,
1427
+ week_starts_on: "sun",
1428
+ active_days: cells.filter((c) => c.billable_total_tokens > 0).length,
1429
+ streak_days: 0,
1430
+ weeks: weeksArr,
1431
+ total_cost_usd: totalCostUsd
1432
+ });
1433
+ return true;
1434
+ }
1435
+
1436
+ // --- usage-model-breakdown ---
1437
+ if (p === "/functions/tokentracker-usage-model-breakdown") {
1438
+ const from = url.searchParams.get("from") || "";
1439
+ const to = url.searchParams.get("to") || "";
1440
+ const timeZoneContext = getTimeZoneContext(url);
1441
+ const { rows: scopedRows, scope, excludedSources } = scopedQueueRows(qp, url);
1442
+ const rows = scopedRows.filter((r) => {
1443
+ if (!r.hour_start) return false;
1444
+ const d = rowDayKey(r, timeZoneContext);
1445
+ return d >= from && d <= to;
1446
+ });
1447
+
1448
+ const bySource = new Map();
1449
+ for (const row of rows) {
1450
+ const src = row.source || "unknown";
1451
+ const mdl = row.model || "unknown";
1452
+ if (!bySource.has(src))
1453
+ bySource.set(src, { source: src, source_scope: getSourceScope(src), totals: { total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, total_cost_usd: "0" }, models: new Map() });
1454
+ const sa = bySource.get(src);
1455
+ sa.totals.total_tokens += row.total_tokens || 0;
1456
+ sa.totals.billable_total_tokens += row.billable_total_tokens ?? row.total_tokens ?? 0;
1457
+ sa.totals.input_tokens += row.input_tokens || 0;
1458
+ sa.totals.output_tokens += row.output_tokens || 0;
1459
+ sa.totals.cached_input_tokens += row.cached_input_tokens || 0;
1460
+ sa.totals.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
1461
+ sa.totals.reasoning_output_tokens += row.reasoning_output_tokens || 0;
1462
+ if (!sa.models.has(mdl))
1463
+ sa.models.set(mdl, { model: mdl, model_id: mdl, totals: { total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, total_cost_usd: "0" } });
1464
+ const ma = sa.models.get(mdl);
1465
+ ma.totals.total_tokens += row.total_tokens || 0;
1466
+ ma.totals.billable_total_tokens += row.billable_total_tokens ?? row.total_tokens ?? 0;
1467
+ ma.totals.input_tokens += row.input_tokens || 0;
1468
+ ma.totals.output_tokens += row.output_tokens || 0;
1469
+ ma.totals.cached_input_tokens += row.cached_input_tokens || 0;
1470
+ ma.totals.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
1471
+ ma.totals.reasoning_output_tokens += row.reasoning_output_tokens || 0;
1472
+ }
1473
+
1474
+ const sources = Array.from(bySource.values()).map((s) => {
1475
+ s.models = Array.from(s.models.values())
1476
+ .map((m) => {
1477
+ const cost = computeRowCost({
1478
+ ...m.totals,
1479
+ model: m.model,
1480
+ source: s.source,
1481
+ });
1482
+ return { ...m, totals: { ...m.totals, total_cost_usd: cost.toFixed(6) } };
1483
+ })
1484
+ .sort((a, b) => b.totals.total_tokens - a.totals.total_tokens);
1485
+ const sourceCost = s.models.reduce((sum, m) => sum + Number(m.totals.total_cost_usd), 0);
1486
+ s.totals.total_cost_usd = sourceCost.toFixed(6);
1487
+ return s;
1488
+ });
1489
+
1490
+ json(res, {
1491
+ from, to, days: 0, scope, excluded_sources: excludedSources, sources,
1492
+ pricing: { model: "per-model", pricing_mode: "per_token_type", source: "litellm", effective_from: new Date().toISOString().slice(0, 10) },
1493
+ });
1494
+ return true;
1495
+ }
1496
+
1497
+ // --- usage-category-breakdown (Claude + Codex) ---
1498
+ // Claude: splits historical Claude usage into seven semantic categories
1499
+ // mirroring Claude Code's /context view (approx).
1500
+ // Codex: provides a tool-oriented breakdown, attributing per-turn token
1501
+ // deltas to observed tool calls (heuristic).
1502
+ if (p === "/functions/tokentracker-usage-category-breakdown") {
1503
+ const from = url.searchParams.get("from") || "";
1504
+ const to = url.searchParams.get("to") || "";
1505
+ const requestedSource = (url.searchParams.get("source") || "claude").trim().toLowerCase();
1506
+ if (requestedSource === "claude") {
1507
+ try {
1508
+ const result = await computeClaudeCategoryBreakdown({ from, to, projectDir: process.cwd() });
1509
+ json(res, { from, to, ...result });
1510
+ } catch (e) {
1511
+ console.error("[LocalAPI] usage-category-breakdown:", e?.message || e);
1512
+ json(res, { from, to, ...unsupportedCategoryPayload("claude"), error: "compute_failed" }, 500);
1513
+ }
1514
+ return true;
1515
+ }
1516
+
1517
+ if (requestedSource === "codex") {
1518
+ try {
1519
+ const timeZoneContext = getTimeZoneContext(url);
1520
+ const result = await computeCodexContextBreakdown({
1521
+ from,
1522
+ to,
1523
+ top: 50,
1524
+ timeZoneContext,
1525
+ });
1526
+ if (!Number(result?.totals?.total_tokens || 0)) {
1527
+ const fallback = buildCodexCategoryFallbackFromQueue(readQueueData(qp), {
1528
+ from,
1529
+ to,
1530
+ timeZoneContext,
1531
+ });
1532
+ json(res, { from, to, ...fallback });
1533
+ return true;
1534
+ }
1535
+ json(res, { from, to, ...result });
1536
+ } catch (e) {
1537
+ console.error("[LocalAPI] usage-category-breakdown(codex):", e?.message || e);
1538
+ json(res, { from, to, ...unsupportedCategoryPayload("codex"), error: "compute_failed" }, 500);
1539
+ }
1540
+ return true;
1541
+ }
1542
+
1543
+ json(res, { from, to, ...unsupportedCategoryPayload(requestedSource) });
1544
+ return true;
1545
+ }
1546
+
1547
+ // --- project-usage-summary ---
1548
+ if (p === "/functions/tokentracker-project-usage-summary") {
1549
+ // Use the per-project bucket log that rollout.js emits — it already
1550
+ // carries the actual tokens attributed to each (project_key, source,
1551
+ // hour_start). Falling back to "session-file count × total tokens"
1552
+ // (the old behavior) produced pure fiction: every short-and-hot
1553
+ // project got the same weight as every long-and-cold one.
1554
+ const projectQueuePath = path.join(
1555
+ path.dirname(qp),
1556
+ "project.queue.jsonl",
1557
+ );
1558
+ const projectRows = readProjectQueueData(projectQueuePath);
1559
+
1560
+ const byProject = new Map();
1561
+ for (const row of projectRows) {
1562
+ const key = row.project_key || "unknown";
1563
+ if (!byProject.has(key)) {
1564
+ byProject.set(key, {
1565
+ project_key: key,
1566
+ project_ref: row.project_ref || key,
1567
+ total_tokens: 0,
1568
+ billable_total_tokens: 0,
1569
+ });
1570
+ }
1571
+ const agg = byProject.get(key);
1572
+ agg.total_tokens += Number(row.total_tokens || 0);
1573
+ agg.billable_total_tokens += Number(row.total_tokens || 0);
1574
+ if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref;
1575
+ }
1576
+
1577
+ // If no project-attributed rows exist yet (user hasn't synced project
1578
+ // attribution, or never used a project-capable CLI), fall back to
1579
+ // per-source aggregation over the main queue so the panel isn't
1580
+ // totally empty. This path used to also exist for the non-empty case
1581
+ // and produce wrong numbers; keep it only as the empty fallback.
1582
+ let entries;
1583
+ if (byProject.size === 0) {
1584
+ const rows = readQueueData(qp);
1585
+ const bySrc = new Map();
1586
+ for (const row of rows) {
1587
+ const src = row.source || "unknown";
1588
+ if (!bySrc.has(src)) {
1589
+ bySrc.set(src, {
1590
+ project_key: src,
1591
+ // Synthetic source-only row: leave project_ref empty rather than
1592
+ // fabricating `https://${src}.ai`, which resolves to unrelated
1593
+ // domains (e.g. codex.ai, cursor.ai) and was sent to the
1594
+ // dashboard as a clickable href before v0.11.1 / this commit.
1595
+ project_ref: "",
1596
+ total_tokens: 0,
1597
+ billable_total_tokens: 0,
1598
+ });
1599
+ }
1600
+ bySrc.get(src).total_tokens += row.total_tokens || 0;
1601
+ bySrc.get(src).billable_total_tokens += row.total_tokens || 0;
1602
+ }
1603
+ entries = Array.from(bySrc.values())
1604
+ .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
1605
+ .map((e) => ({
1606
+ ...e,
1607
+ total_tokens: String(e.total_tokens),
1608
+ billable_total_tokens: String(e.billable_total_tokens),
1609
+ }));
1610
+ } else {
1611
+ entries = Array.from(byProject.values())
1612
+ .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
1613
+ .map((e) => ({
1614
+ ...e,
1615
+ total_tokens: String(e.total_tokens),
1616
+ billable_total_tokens: String(e.billable_total_tokens),
1617
+ }));
1618
+ }
1619
+
1620
+ json(res, { generated_at: new Date().toISOString(), entries });
1621
+ return true;
1622
+ }
1623
+
1624
+ // --- user-status (stub) ---
1625
+ if (p === "/functions/tokentracker-user-status") {
1626
+ json(res, {
1627
+ user_id: "local-user", email: "local@localhost", name: "Local User", is_public: false,
1628
+ created_at: new Date().toISOString(),
1629
+ pro: { active: true, sources: ["local"], expires_at: null, partial: false, as_of: new Date().toISOString() },
1630
+ });
1631
+ return true;
1632
+ }
1633
+
1634
+ // --- usage-hourly (stub for day-view) ---
1635
+ if (p === "/functions/tokentracker-usage-hourly") {
1636
+ const day = url.searchParams.get("day") || new Date().toISOString().slice(0, 10);
1637
+ const timeZoneContext = getTimeZoneContext(url);
1638
+ const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
1639
+ const data = aggregateHourlyByDay(rows, day, timeZoneContext);
1640
+ json(res, { day, scope, excluded_sources: excludedSources, data });
1641
+ return true;
1642
+ }
1643
+
1644
+ // --- usage-monthly (stub for trend view) ---
1645
+ if (p === "/functions/tokentracker-usage-monthly") {
1646
+ const from = url.searchParams.get("from") || "";
1647
+ const to = url.searchParams.get("to") || "";
1648
+ const timeZoneContext = getTimeZoneContext(url);
1649
+ const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
1650
+ const byMonth = new Map();
1651
+ for (const row of rows) {
1652
+ if (!row.hour_start) continue;
1653
+ const day = rowDayKey(row, timeZoneContext);
1654
+ if (!day || day < from || day > to) continue;
1655
+ const month = day.slice(0, 7);
1656
+ if (!byMonth.has(month))
1657
+ byMonth.set(month, { month, total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, conversation_count: 0 });
1658
+ const a = byMonth.get(month);
1659
+ a.total_tokens += row.total_tokens || 0;
1660
+ a.billable_total_tokens += row.billable_total_tokens ?? row.total_tokens ?? 0;
1661
+ a.input_tokens += row.input_tokens || 0;
1662
+ a.output_tokens += row.output_tokens || 0;
1663
+ a.cached_input_tokens += row.cached_input_tokens || 0;
1664
+ a.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
1665
+ a.reasoning_output_tokens += row.reasoning_output_tokens || 0;
1666
+ a.conversation_count += row.conversation_count || 0;
1667
+
1668
+ if (!a.models) {
1669
+ a.models = {};
1670
+ }
1671
+ const model = row.model || "unknown";
1672
+ a.models[model] = (a.models[model] || 0) + (row.total_tokens || 0);
1673
+ }
1674
+ json(res, { from, to, scope, excluded_sources: excludedSources, data: Array.from(byMonth.values()).sort((a, b) => a.month.localeCompare(b.month)) });
1675
+ return true;
1676
+ }
1677
+
1678
+ // --- skills manager ---
1679
+ if (p === "/functions/tokentracker-skills") {
1680
+ const method = String(req.method || "GET").toUpperCase();
1681
+ const skills = require("./skills-manager");
1682
+ try {
1683
+ if (method === "GET") {
1684
+ const mode = url.searchParams.get("mode") || "installed";
1685
+ if (mode === "installed") {
1686
+ json(res, { targets: skills.targetList(), skills: skills.listInstalledSkills() });
1687
+ return true;
1688
+ }
1689
+ if (mode === "repos") {
1690
+ json(res, { repos: skills.listRepos() });
1691
+ return true;
1692
+ }
1693
+ if (mode === "discover") {
1694
+ const force = url.searchParams.get("force") === "1";
1695
+ json(res, await skills.discoverSkills({ force }));
1696
+ return true;
1697
+ }
1698
+ if (mode === "search") {
1699
+ const data = await skills.searchSkillsSh(
1700
+ url.searchParams.get("q") || "",
1701
+ Number(url.searchParams.get("limit") || 20),
1702
+ Number(url.searchParams.get("offset") || 0),
1703
+ );
1704
+ json(res, data);
1705
+ return true;
1706
+ }
1707
+ if (mode === "popular") {
1708
+ const force = url.searchParams.get("force") === "1";
1709
+ json(res, await skills.fetchPopularSkillsSh({ force }));
1710
+ return true;
1711
+ }
1712
+ if (mode === "updates") {
1713
+ const force = url.searchParams.get("force") === "1";
1714
+ json(res, await skills.checkUpdates({ force }));
1715
+ return true;
1716
+ }
1717
+ if (mode === "activity") {
1718
+ const limit = Number(url.searchParams.get("limit") || 50);
1719
+ json(res, { activity: skills.readActivity(limit) });
1720
+ return true;
1721
+ }
1722
+ if (mode === "skill_usage") {
1723
+ const force = url.searchParams.get("force") === "1";
1724
+ const usage = await require("./skill-usage").scanSkillUsage({ force });
1725
+ await ensurePricingLoaded();
1726
+ // Join raw per-skill aggregates against installed skills so we can
1727
+ // separate user-installed skills (where "dead weight" = unused) from
1728
+ // Claude Code built-in tools (bash/agent/…), which dominate the logs
1729
+ // and are NOT uninstallable. Cost is priced per-model (source=claude).
1730
+ const installed = skills.listInstalledSkills();
1731
+ const installedByName = new Map();
1732
+ for (const s of installed) {
1733
+ for (const key of [s.directory, s.name]) {
1734
+ const norm = String(key || "").trim().toLowerCase();
1735
+ if (norm) installedByName.set(norm, s);
1736
+ }
1737
+ }
1738
+ const priced = usage.skills.map((entry) => {
1739
+ let cost = 0;
1740
+ for (const [model, tokens] of Object.entries(entry.models || {})) {
1741
+ cost += computeRowCost({ ...tokens, model, source: "claude" });
1742
+ }
1743
+ const match = installedByName.get(String(entry.skill || "").trim().toLowerCase());
1744
+ return {
1745
+ skill: entry.skill,
1746
+ invocations: entry.invocations,
1747
+ lastUsedAt: entry.lastUsedAt,
1748
+ tokens: entry.tokens,
1749
+ cost,
1750
+ installed: Boolean(match),
1751
+ skillId: match?.id || null,
1752
+ directory: match?.directory || null,
1753
+ };
1754
+ });
1755
+ // Installed skills with zero invocations = dead-weight candidates.
1756
+ const usedNames = new Set(usage.skills.map((s) => String(s.skill || "").trim().toLowerCase()));
1757
+ const unusedInstalled = installed
1758
+ .filter((s) => {
1759
+ const dir = String(s.directory || "").trim().toLowerCase();
1760
+ const name = String(s.name || "").trim().toLowerCase();
1761
+ return !usedNames.has(dir) && !usedNames.has(name);
1762
+ })
1763
+ .map((s) => ({ skillId: s.id, directory: s.directory, name: s.name }));
1764
+ json(res, {
1765
+ generatedAt: usage.generatedAt,
1766
+ scannedFiles: usage.scannedFiles,
1767
+ totalInvocations: usage.totalInvocations,
1768
+ cached: usage.cached,
1769
+ skills: priced,
1770
+ unusedInstalled,
1771
+ });
1772
+ return true;
1773
+ }
1774
+ json(res, { error: "Unknown skills mode" }, 400);
1775
+ return true;
1776
+ }
1777
+
1778
+ if (method === "POST") {
1779
+ if (!isAuthorizedLocalMutation(req)) {
1780
+ json(res, { ok: false, error: "Unauthorized" }, 401);
1781
+ return true;
1782
+ }
1783
+ const body = await readJsonBody(req);
1784
+ const action = String(body?.action || "");
1785
+ if (action === "install") {
1786
+ json(res, { ok: true, skill: await skills.installSkill(body.skill, body.targets || ["claude", "codex"]) });
1787
+ return true;
1788
+ }
1789
+ if (action === "uninstall") {
1790
+ json(res, { ok: true, ...(skills.uninstallSkill(body.id) || {}) });
1791
+ return true;
1792
+ }
1793
+ if (action === "restore") {
1794
+ json(res, { ok: true, skill: skills.restoreSkill(body.id) });
1795
+ return true;
1796
+ }
1797
+ if (action === "set_targets") {
1798
+ json(res, { ok: true, skill: skills.setSkillTargets(body.id, body.targets || []) });
1799
+ return true;
1800
+ }
1801
+ if (action === "import_local") {
1802
+ json(res, { ok: true, skill: skills.importLocalSkill(body.directory, body.targets || []) });
1803
+ return true;
1804
+ }
1805
+ if (action === "delete_local") {
1806
+ json(res, { ok: true, ...(skills.deleteLocalSkill(body.directory, body.targets || []) || {}) });
1807
+ return true;
1808
+ }
1809
+ if (action === "add_repo") {
1810
+ json(res, { ok: true, repo: skills.addRepo(body.repo) });
1811
+ return true;
1812
+ }
1813
+ if (action === "remove_repo") {
1814
+ json(res, { ok: true, ...(skills.removeRepo(body.owner, body.name) || {}) });
1815
+ return true;
1816
+ }
1817
+ json(res, { ok: false, error: "Unknown skills action" }, 400);
1818
+ return true;
1819
+ }
1820
+
1821
+ json(res, { ok: false, error: "Method Not Allowed" }, 405);
1822
+ } catch (e) {
1823
+ json(res, { ok: false, error: e?.message || "Unknown skills error" }, 500);
1824
+ }
1825
+ return true;
1826
+ }
1827
+
1828
+ // --- usage-limits ---
1829
+ if (p === "/functions/tokentracker-usage-limits") {
1830
+ const { getUsageLimits, resetUsageLimitsCache } = require("./usage-limits");
1831
+ try {
1832
+ const forceRefresh = url.searchParams.get("refresh");
1833
+ if (forceRefresh === "1" || forceRefresh === "true") {
1834
+ resetUsageLimitsCache();
1835
+ }
1836
+ const data = await getUsageLimits({
1837
+ home: os.homedir(),
1838
+ env: process.env,
1839
+ platform: process.platform,
1840
+ });
1841
+ json(res, data);
1842
+ } catch (e) {
1843
+ json(res, { error: e?.message || "Unknown error" }, 500);
1844
+ }
1845
+ return true;
1846
+ }
1847
+
1848
+ return false;
1849
+ };
1850
+ }
1851
+
1852
+ module.exports = {
1853
+ createLocalApiHandler,
1854
+ resolveAllowedInsforgeBaseUrl,
1855
+ resolveQueuePath,
1856
+ // Exported for cross-consumer tests (pricing + native contract lock).
1857
+ MODEL_PRICING,
1858
+ getModelPricing,
1859
+ computeRowCost,
1860
+ ensurePricingLoaded,
1861
+ };