token-larper 1.9.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,11 +21,11 @@ Token Larper passively reads session files, SQLite databases, and telemetry logs
21
21
  It consolidates all sessions into a real-time, interactive local dashboard running on [`http://localhost:4269`](http://localhost:4269) on **Windows, macOS, and Linux**. On Windows, it also lives quietly in your notification area as a lightweight, DPI-aware system tray icon.
22
22
 
23
23
  * **Powered by `ccusage`**: Uses `ccusage` v20+ for cross-platform log parsing, token counting, and verified frontier model pricing.
24
- * **100% Private & Offline**: No API keys required, no external proxies to configure, and zero analytics or telemetry sent to the cloud. The one outbound request is an optional update check that asks npm for the latest version number; turn it off in **Settings → Updates**.
24
+ * **100% Private & Offline**: No API keys required, no external proxies to configure, and zero analytics or telemetry sent to the cloud. The only outbound requests are two optional ones that send nothing about your usage: an update check that asks npm for the latest version number (turn it off in **Settings → Updates**), and a once-a-day download of OpenRouter's public model price list for cost estimates (set `TOKEN_LARPER_OFFLINE=1` to skip it).
25
25
  * **Cross-Platform Core**: The telemetry engine, 18 harness scrapers, and React dashboard run identically across Windows, macOS (Apple Silicon & Intel), and Linux.
26
26
  * **Gamified Hall of Larp**: Track your lifetime token burn, unlock 14 achievements, climb an 11-tier ranking ladder past rival personas, and export a shareable 1200×630 flex badge.
27
27
  * **Six Styles, Your Colors**: Grove, Terminal, Paper, Brutal, Soft, and Mono each change fonts, corner radius, spacing, borders, and shadows, not just colors. Pick a base color that tints backgrounds, borders, and text, and an accent for highlights, from presets or any custom color. Or import a tweakcn/shadcn CSS theme on top.
28
- * **Subscription-Aware**: Distinguishes between strict verified API billing and estimated **"LARP Value"** (the theoretical API cost of tokens consumed through flat-rate subscriptions like Claude Pro/Max, Copilot, or Antigravity).
28
+ * **Subscription-Aware**: Distinguishes between strict verified API billing and estimated **"LARP Value"** (the theoretical API cost of tokens consumed through flat-rate subscriptions like Claude Pro/Max, Copilot, or Antigravity). Estimates use [OpenRouter's](https://openrouter.ai/models) current list prices, so new models are priced without waiting for an update.
29
29
 
30
30
  ---
31
31
 
@@ -247,6 +247,11 @@ Set `TOKEN_LARPER_DATA_DIR` to use another folder. Versions before 1.5.0 kept th
247
247
  ### Updates
248
248
  When a new version is on npm, the dashboard shows a banner. **Update now** starts `bunx token-larper@latest` in the background; the new version replaces the running one on the same port and the dashboard reloads. You can also check from **Settings → Updates**, or turn automatic checks off there. A copy cloned from git updates with `git pull`.
249
249
 
250
+ ### Estimated Prices
251
+ When ccusage has no price for a model (a brand-new one, or usage through a flat-rate subscription), Token Larper estimates the cost from list prices. It downloads OpenRouter's public model price list once a day, keeps it in the data folder as `openrouter-pricing.json`, and prices any model on that list from it. Other models use rates built into the app. Only the price list is downloaded; nothing about your usage is sent, and if the download fails the last saved list is used.
252
+
253
+ Estimates do not include OpenRouter's long-context surcharges, and a model name that OpenRouter spells differently may fall back to the built-in rates. Verified costs from ccusage are never changed. Set `TOKEN_LARPER_OFFLINE=1` to skip the download and use only the saved list (if any) and the built-in rates.
254
+
250
255
  ### Headless Server Mode
251
256
  If running on a remote headless server or Docker container, you can explicitly disable the tray icon (automatically disabled on macOS/Linux):
252
257
  ```bash
package/bin/cli.js CHANGED
@@ -32,7 +32,7 @@ function dataDir() {
32
32
 
33
33
  function bunPath() {
34
34
  if (typeof Bun !== "undefined") return process.execPath;
35
- if (spawnSync("bun", ["--version"], { stdio: "ignore" }).status === 0) return "bun";
35
+ if (spawnSync("bun", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0) return "bun";
36
36
  console.error("🔥 Token Larper requires Bun (v1.1+) to run.");
37
37
  console.error("\nInstall Bun with one command:");
38
38
  if (process.platform === "win32") {
@@ -85,26 +85,47 @@ async function foreground() {
85
85
  process.exit(child.status ?? 0);
86
86
  }
87
87
 
88
+ /**
89
+ * Start the server so it outlives this terminal. On Windows it goes through Start-Process:
90
+ * the server then gets a hidden console that everything it starts shares (a process with
91
+ * no console at all makes Windows open a window for each console program it runs), and it
92
+ * inherits no handles, so it can't end up holding an older copy's port when this CLI was
93
+ * itself started by one during an update. The server writes its own log (src/logFile.ts).
94
+ */
95
+ function startServerProcess(bun, env) {
96
+ if (process.platform === "win32") {
97
+ const quote = (value) => `'${value.replace(/'/g, "''")}'`;
98
+ const command = [
99
+ "Start-Process -WindowStyle Hidden",
100
+ `-FilePath ${quote(bun)}`,
101
+ `-ArgumentList ${quote(`"${serverScript}"`)}`,
102
+ `-WorkingDirectory ${quote(rootDir)}`,
103
+ ].join(" ");
104
+ const hop = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
105
+ stdio: "ignore",
106
+ windowsHide: true,
107
+ env,
108
+ });
109
+ return hop.status === 0;
110
+ }
111
+ spawn(bun, [serverScript], { cwd: rootDir, detached: true, stdio: "ignore", env }).unref();
112
+ return true;
113
+ }
114
+
88
115
  async function background(open) {
89
116
  const bun = bunPath();
90
117
  const dir = dataDir();
91
118
  mkdirSync(dir, { recursive: true });
92
119
  const logFile = join(dir, "server.log");
93
- const log = openSync(logFile, "w");
120
+ closeSync(openSync(logFile, "w"));
94
121
 
95
122
  // The server moves to the next free port if another program has this one, and takes
96
123
  // over from an older Token Larper on the same port (see src/server.ts).
97
- const child = spawn(bun, [serverScript], {
98
- cwd: rootDir,
99
- detached: true,
100
- windowsHide: true,
101
- stdio: ["ignore", log, log],
102
- env: { ...process.env, TOKEN_LARPER_NO_BROWSER: "1" },
124
+ const started = startServerProcess(bun, {
125
+ ...process.env,
126
+ TOKEN_LARPER_NO_BROWSER: "1",
127
+ TOKEN_LARPER_LOG_FILE: logFile,
103
128
  });
104
- let exited = false;
105
- child.on("exit", () => (exited = true));
106
- child.unref();
107
- closeSync(log);
108
129
 
109
130
  const readLog = () => {
110
131
  try {
@@ -117,10 +138,22 @@ async function background(open) {
117
138
  // Ready once the server says where it is listening. A copy of the same version that
118
139
  // is already running says so too ("already running at ..."), then the new one exits.
119
140
  let url = null;
120
- for (let i = 0; i < 120 && !url; i++) {
141
+ let waitingForPort = false;
142
+ for (let i = 0; started && i < 120 && !url; i++) {
121
143
  await sleep(250);
122
- url = readLog().match(/running at (http:\/\/localhost:\d+)/)?.[1] ?? null;
123
- if (!url && exited) break;
144
+ const text = readLog();
145
+ url = text.match(/running at (http:\/\/localhost:\d+)/)?.[1] ?? null;
146
+ // Taking over from an older copy can take a minute while its port is released. This
147
+ // CLI may be holding that port itself (when the old copy started it for an update),
148
+ // so leave now instead of waiting.
149
+ waitingForPort = !url && text.includes("Waiting for port");
150
+ if (waitingForPort) break;
151
+ if (/Token Larper stopped:/.test(text)) break;
152
+ }
153
+
154
+ if (waitingForPort) {
155
+ console.log(`🔥 Token Larper is replacing an older copy. It will be at http://localhost:${port} in a minute.`);
156
+ process.exit(0);
124
157
  }
125
158
 
126
159
  if (!url) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-larper",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
4
4
  "description": "Token Larper — Multi-Harness AI Coding Usage & Cost Telemetry Dashboard powered by ccusage",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/ccusage.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  import { isoWeek, localDateKey } from "./client/utils.ts";
15
15
  import { opencodeSessionUpdated, resolveSessionProject, saveSessionProjects } from "./projects.ts";
16
16
  import { APP_ROOT, DATA_DIR } from "./paths.ts";
17
+ import { pricing, type Rates } from "./pricing.ts";
17
18
 
18
19
  const ROOT_DIR = APP_ROOT;
19
20
  const CACHE_DIR = DATA_DIR;
@@ -260,12 +261,6 @@ function resolveCcusageBinary(): { cmd: string[]; version: string } {
260
261
  return { cmd: ["bunx", "ccusage"], version };
261
262
  }
262
263
 
263
- /**
264
- * Estimates realistic frontier API cost in USD when ccusage reports missingPricing: true or 0 cost
265
- * (e.g. for bleeding-edge models like claude-opus-5-5, gpt-6-sol, minimax-m3-free, etc.).
266
- */
267
- type Rates = [input: number, output: number, cacheWrite: number, cacheRead: number];
268
-
269
264
  /**
270
265
  * Anthropic list prices per 1M tokens, by family and version. Opus dropped from $15/$75
271
266
  * at 4.5, so a single "opus" rate overstated newer models about threefold. Names come in
@@ -295,6 +290,11 @@ export function claudeRates(model: string): Rates | null {
295
290
  return [0.25, 1.25, 0.3, 0.03];
296
291
  }
297
292
 
293
+ /**
294
+ * Estimates realistic frontier API cost in USD when ccusage reports missingPricing: true or 0 cost
295
+ * (e.g. for bleeding-edge models like claude-opus-5-5, gpt-6-sol, minimax-m3-free, etc.).
296
+ * Rates come from OpenRouter's price list when it lists the model, otherwise the built-in ones below.
297
+ */
298
298
  export function estimateFrontierCost(params: {
299
299
  modelName: string;
300
300
  inputTokens: number;
@@ -309,9 +309,9 @@ export function estimateFrontierCost(params: {
309
309
  // Pricing in USD per 1M tokens: [input, output, cacheWrite, cacheRead]
310
310
  let rates: Rates = [2.0, 8.0, 2.5, 0.2];
311
311
 
312
- const claude = claudeRates(m);
313
- if (claude) {
314
- rates = claude;
312
+ const known = pricing.lookup(m) ?? claudeRates(m);
313
+ if (known) {
314
+ rates = known;
315
315
  } else if (m.includes("gpt-6") || m.includes("gpt-5") || m.includes("o3") || m.includes("o4")) {
316
316
  rates = [2.5, 10.0, 2.5, 0.25];
317
317
  } else if (m.includes("gemini") && m.includes("pro")) {
@@ -337,6 +337,21 @@ export function estimateFrontierCost(params: {
337
337
  );
338
338
  }
339
339
 
340
+ // Runs still in progress. On Windows a child keeps the server's listening socket open, so
341
+ // shutdown stops them; otherwise a newer copy can't take over the port.
342
+ const activeRuns = new Set<ReturnType<typeof Bun.spawn>>();
343
+
344
+ export function stopCcusageRuns(): void {
345
+ for (const proc of activeRuns) {
346
+ try {
347
+ proc.kill();
348
+ } catch {
349
+ // Already exited.
350
+ }
351
+ }
352
+ activeRuns.clear();
353
+ }
354
+
340
355
  async function runCcusageJson(subcommandArgs: string[], timeoutMs = 25000): Promise<any | null> {
341
356
  const { cmd } = resolveCcusageBinary();
342
357
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -345,7 +360,10 @@ async function runCcusageJson(subcommandArgs: string[], timeoutMs = 25000): Prom
345
360
  const proc = Bun.spawn([...cmd, ...subcommandArgs, "--json", "-O"], {
346
361
  stdout: "pipe",
347
362
  stderr: "ignore",
363
+ windowsHide: true,
348
364
  });
365
+ activeRuns.add(proc);
366
+ void proc.exited.finally(() => activeRuns.delete(proc));
349
367
 
350
368
  timer = setTimeout(() => {
351
369
  try {
@@ -583,6 +601,10 @@ async function syncAntigravityInBackground(): Promise<void> {
583
601
  async function runHarnessRefresh(options?: { forceDeepScan?: boolean }): Promise<DashboardPayload> {
584
602
  mkdirSync(CACHE_DIR, { recursive: true });
585
603
 
604
+ // Starts now so the price download overlaps the ccusage runs below; estimates are
605
+ // built after they finish, and ensure() never throws or blocks for long.
606
+ const pricesReady = pricing.ensure();
607
+
586
608
  // Seed antigravity from disk cache immediately if available
587
609
  const diskAg = loadAntigravityFromDiskCache();
588
610
  if (diskAg && !inMemoryHarnessStore.has("antigravity")) {
@@ -624,6 +646,7 @@ async function runHarnessRefresh(options?: { forceDeepScan?: boolean }): Promise
624
646
  }
625
647
  }
626
648
 
649
+ await pricesReady;
627
650
  cachedDashboardPayload = buildDashboardPayloadFromStore();
628
651
  try {
629
652
  writeFileSync(DASHBOARD_CACHE_FILE, JSON.stringify(cachedDashboardPayload), "utf8");
package/src/logFile.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { appendFileSync, writeFileSync } from "node:fs";
2
+
3
+ // When the CLI starts the server in the background it has no terminal, so the server
4
+ // writes its own output to the file named in TOKEN_LARPER_LOG_FILE (the CLI reads it
5
+ // to learn the URL). Imported first by server.ts so nothing is missed.
6
+
7
+ const file = process.env.TOKEN_LARPER_LOG_FILE;
8
+
9
+ if (file) {
10
+ try {
11
+ writeFileSync(file, "");
12
+ } catch {
13
+ // Logging is best effort.
14
+ }
15
+ const write = (args: unknown[]) => {
16
+ try {
17
+ appendFileSync(file, args.map((a) => (a instanceof Error ? a.stack ?? a.message : String(a))).join(" ") + "\n");
18
+ } catch {
19
+ // Logging is best effort.
20
+ }
21
+ };
22
+ for (const level of ["log", "info", "warn", "error"] as const) {
23
+ const original = console[level].bind(console);
24
+ console[level] = (...args: unknown[]) => {
25
+ write(args);
26
+ original(...args);
27
+ };
28
+ }
29
+ process.on("uncaughtException", (error) => {
30
+ write(["Token Larper stopped:", error]);
31
+ process.exit(1);
32
+ });
33
+ process.on("unhandledRejection", (reason) => {
34
+ write(["Token Larper stopped:", reason]);
35
+ process.exit(1);
36
+ });
37
+ }
package/src/pricing.ts ADDED
@@ -0,0 +1,210 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { dataPath } from "./paths.ts";
4
+
5
+ // Model prices for the estimated cost. OpenRouter publishes a public price list that
6
+ // tracks new models faster than a table kept in this repo. The one request goes to its
7
+ // models endpoint (no key, no usage data sent) at most once a day; the result is cached
8
+ // in the data folder and the built-in rates in ccusage.ts cover whatever it does not list.
9
+ // Set TOKEN_LARPER_OFFLINE=1 to skip the request and use only the cache and built-in rates.
10
+
11
+ /** USD per 1M tokens: input, output, cache write, cache read. */
12
+ export type Rates = [input: number, output: number, cacheWrite: number, cacheRead: number];
13
+
14
+ export type PriceTable = Map<string, Rates>;
15
+
16
+ export interface OpenRouterModel {
17
+ id: string;
18
+ pricing?: Record<string, unknown>;
19
+ }
20
+
21
+ const MODELS_URL = "https://openrouter.ai/api/v1/models";
22
+ const REFRESH_MS = 24 * 60 * 60 * 1000;
23
+ const RETRY_MS = 15 * 60 * 1000;
24
+ const TIMEOUT_MS = 8000;
25
+
26
+ /**
27
+ * Reduces a model name to the form both ccusage and OpenRouter can be compared in:
28
+ * lower case, without the harness tag ("[pi] "), provider ("anthropic/") or variant
29
+ * (":free"), and with dashed versions dotted ("4-5" becomes "4.5").
30
+ */
31
+ export function normalizeModelName(name: string): string {
32
+ const slug = name
33
+ .toLowerCase()
34
+ .trim()
35
+ .replace(/^\[[^\]]*\]\s*/, "")
36
+ .replace(/^.*\//, "")
37
+ .replace(/:.*$/, "");
38
+
39
+ const parts = slug.split("-");
40
+ const out: string[] = [];
41
+ for (let i = 0; i < parts.length; i++) {
42
+ const part = parts[i]!;
43
+ const next = parts[i + 1];
44
+ // A pair of short numbers is a version ("opus-4-5"), unless it follows a year
45
+ // ("2024-11-20" is a date).
46
+ const followsYear = /^\d{4}$/.test(parts[i - 1] ?? "");
47
+ if (next !== undefined && !followsYear && /^\d{1,2}$/.test(part) && /^\d{1,2}$/.test(next)) {
48
+ out.push(`${part}.${next}`);
49
+ i++;
50
+ } else {
51
+ out.push(part);
52
+ }
53
+ }
54
+ return out.join("-");
55
+ }
56
+
57
+ // Multiplying a per-token price by 1e6 leaves float noise (0.0000002 * 1e6 is
58
+ // 0.19999999999999998).
59
+ const perMillion = (perToken: number) => Number((perToken * 1e6).toPrecision(10));
60
+
61
+ /** Absent or invalid is undefined; zero is a real price. */
62
+ function cachePrice(value: unknown): number | undefined {
63
+ if (value === undefined || value === null || value === "") return undefined;
64
+ const n = Number(value);
65
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
66
+ }
67
+
68
+ /**
69
+ * Builds the rate table from OpenRouter's model list. Variants (":free" is $0, ":batch"
70
+ * is half price) and "~...-latest" aliases would misprice a model, and a price of 0 or
71
+ * -1 means free or variable, so none of those are kept. When two ids normalize to the
72
+ * same name, the first listed wins.
73
+ */
74
+ export function buildPriceTable(models: readonly OpenRouterModel[]): PriceTable {
75
+ const table: PriceTable = new Map();
76
+ for (const model of models) {
77
+ if (typeof model?.id !== "string" || model.id.startsWith("~") || model.id.includes(":")) continue;
78
+ const input = Number(model.pricing?.prompt);
79
+ const output = Number(model.pricing?.completion);
80
+ if (!(input > 0) || !(output > 0)) continue;
81
+
82
+ const key = normalizeModelName(model.id);
83
+ if (!key || table.has(key)) continue;
84
+
85
+ // Anthropic bills cache writes at 1.25x input and reads at 0.1x. Elsewhere a missing
86
+ // cache price means no discount is known, so cached tokens cost the input price.
87
+ const anthropic = model.id.startsWith("anthropic/");
88
+ const cacheWrite = cachePrice(model.pricing?.input_cache_write) ?? (anthropic ? input * 1.25 : input);
89
+ const cacheRead = cachePrice(model.pricing?.input_cache_read) ?? (anthropic ? input * 0.1 : input);
90
+ table.set(key, [perMillion(input), perMillion(output), perMillion(cacheWrite), perMillion(cacheRead)]);
91
+ }
92
+ return table;
93
+ }
94
+
95
+ /**
96
+ * The rates for a model name, or null if the table has none. An exact match wins;
97
+ * otherwise the longest listed name that prefixes it at a dash ("gpt-5.5-codex" finds
98
+ * "gpt-5.5", and "claude-opus-4-20250514" finds "claude-opus-4"). A prefix must end at
99
+ * a dash so "claude-opus-4.9" never borrows the price of "claude-opus-4".
100
+ */
101
+ export function lookupRates(modelName: string, table: PriceTable): Rates | null {
102
+ const key = normalizeModelName(modelName);
103
+ if (!key) return null;
104
+
105
+ const exact = table.get(key);
106
+ if (exact) return exact;
107
+
108
+ let best: string | undefined;
109
+ for (const listed of table.keys()) {
110
+ if (key.startsWith(`${listed}-`) && (!best || listed.length > best.length)) best = listed;
111
+ }
112
+ return best ? table.get(best)! : null;
113
+ }
114
+
115
+ const isRates = (value: unknown): value is Rates =>
116
+ Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0);
117
+
118
+ export interface PricingOptions {
119
+ cacheFile?: string;
120
+ fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>;
121
+ now?: () => number;
122
+ }
123
+
124
+ export function createPricing(options: PricingOptions = {}) {
125
+ const cacheFile = options.cacheFile ?? dataPath("openrouter-pricing.json");
126
+ const doFetch = options.fetchImpl ?? fetch;
127
+ const now = options.now ?? Date.now;
128
+
129
+ let table: PriceTable = new Map();
130
+ // The same few model names repeat across every daily row, and an unlisted name scans
131
+ // the whole table, so answers are remembered until the table changes.
132
+ let answers = new Map<string, Rates | null>();
133
+ let fetchedAt = 0;
134
+ let nextAttempt = 0;
135
+ let cacheRead = false;
136
+ let inFlight: Promise<void> | null = null;
137
+
138
+ function setTable(next: PriceTable): void {
139
+ table = next;
140
+ answers = new Map();
141
+ }
142
+
143
+ function readCache(): void {
144
+ try {
145
+ const stored = JSON.parse(readFileSync(cacheFile, "utf8")) as { fetchedAt?: unknown; rates?: Record<string, unknown> };
146
+ const rates: PriceTable = new Map();
147
+ for (const [key, value] of Object.entries(stored.rates ?? {})) if (isRates(value)) rates.set(key, value);
148
+ if (typeof stored.fetchedAt === "number" && rates.size > 0) {
149
+ setTable(rates);
150
+ fetchedAt = stored.fetchedAt;
151
+ }
152
+ } catch {
153
+ // No cache yet, or it is unreadable; the next download replaces it.
154
+ }
155
+ }
156
+
157
+ async function download(): Promise<void> {
158
+ try {
159
+ const res = await doFetch(MODELS_URL, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { Accept: "application/json" } });
160
+ if (!res.ok) throw new Error(`OpenRouter responded ${res.status}`);
161
+ const body = (await res.json()) as { data?: unknown };
162
+ if (!Array.isArray(body.data)) throw new Error("OpenRouter response had no model list");
163
+
164
+ const fresh = buildPriceTable(body.data);
165
+ // An empty list would wipe good prices, so treat it as a failed download.
166
+ if (fresh.size === 0) throw new Error("OpenRouter response had no usable prices");
167
+
168
+ setTable(fresh);
169
+ fetchedAt = now();
170
+ try {
171
+ mkdirSync(dirname(cacheFile), { recursive: true });
172
+ writeFileSync(cacheFile, JSON.stringify({ fetchedAt, rates: Object.fromEntries(fresh) }), "utf8");
173
+ } catch {
174
+ // The in-memory prices still work when the disk is not writable.
175
+ }
176
+ } catch {
177
+ // Offline or the endpoint changed: keep what we have and try again later.
178
+ nextAttempt = now() + RETRY_MS;
179
+ }
180
+ }
181
+
182
+ return {
183
+ /** Rates from the OpenRouter table, or null when it has no price for this model. */
184
+ lookup(modelName: string): Rates | null {
185
+ let rates = answers.get(modelName);
186
+ if (rates === undefined) {
187
+ rates = lookupRates(modelName, table);
188
+ answers.set(modelName, rates);
189
+ }
190
+ return rates;
191
+ },
192
+
193
+ /** Replaces the in-memory table. */
194
+ use: setTable,
195
+
196
+ /** Loads the cached table, and downloads a new one if it is over a day old. Never throws. */
197
+ async ensure(): Promise<void> {
198
+ if (!cacheRead) {
199
+ cacheRead = true;
200
+ readCache();
201
+ }
202
+ if (process.env.TOKEN_LARPER_OFFLINE === "1") return;
203
+ if (now() - fetchedAt < REFRESH_MS || now() < nextAttempt) return;
204
+ inFlight ??= download().finally(() => (inFlight = null));
205
+ await inFlight;
206
+ },
207
+ };
208
+ }
209
+
210
+ export const pricing = createPricing();
package/src/server.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import "./logFile.ts";
1
2
  import { join, resolve } from "node:path";
2
- import { getDashboardData } from "./ccusage.ts";
3
+ import { getDashboardData, stopCcusageRuns } from "./ccusage.ts";
3
4
  import { getStartupStatus, repointStartupIfStale, setStartupStatus } from "./startup.ts";
4
5
  import { startSystemTray, stopSystemTray } from "./tray.ts";
5
6
  import { buildTrayStatus, parseTheme, saveTheme } from "./trayStatus.ts";
@@ -60,7 +61,7 @@ function openDashboard(port: number) {
60
61
  : process.platform === "darwin"
61
62
  ? `open http://localhost:${port}`
62
63
  : `xdg-open http://localhost:${port}`;
63
- import("node:child_process").then(({ exec }) => exec(cmd));
64
+ import("node:child_process").then(({ exec }) => exec(cmd, { windowsHide: true }));
64
65
  }
65
66
 
66
67
  /** The version of a Token Larper already on the port, "0.0.0" for one too old to say, or null if none. */
@@ -82,6 +83,22 @@ async function runningVersion(port: number): Promise<string | null> {
82
83
  }
83
84
  }
84
85
 
86
+ async function waitForPort(port: number, timeoutMs: number): Promise<boolean> {
87
+ const deadline = Date.now() + timeoutMs;
88
+ let announced = false;
89
+ while (true) {
90
+ try {
91
+ Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response() }).stop(true);
92
+ return true;
93
+ } catch {
94
+ if (Date.now() > deadline) return false;
95
+ if (!announced) console.log(`Waiting for port ${port} to be released...`);
96
+ announced = true;
97
+ await Bun.sleep(500);
98
+ }
99
+ }
100
+ }
101
+
85
102
  // A newer copy (after an update, or a fresh npx run) replaces an older one on the
86
103
  // same port. The same or a newer version already running just gets opened.
87
104
  const running = await runningVersion(PORT);
@@ -98,6 +115,10 @@ if (running !== null && compareVersions(running, APP_VERSION) < 0) {
98
115
  // It may exit before answering.
99
116
  }
100
117
  for (let i = 0; i < 40 && (await runningVersion(PORT)) !== null; i++) await Bun.sleep(250);
118
+ // The old copy can stop answering before its port is free: on Windows, processes it
119
+ // started hold the socket until they exit. Wait for the port rather than moving to the
120
+ // next one, which the open dashboard isn't watching.
121
+ if (!(await waitForPort(PORT, 90_000))) console.warn(`Port ${PORT} is still taken after 90 s.`);
101
122
  } else if (running !== null) {
102
123
  console.log(`🔥 Token Larper is already running at http://localhost:${PORT}`);
103
124
  console.log(`🚀 Opening dashboard in your default browser...`);
@@ -231,8 +252,12 @@ function startServer(preferredPort: number) {
231
252
  return Response.json({ error: "This copy runs from a git checkout. Update it with git pull." }, { status: 409 });
232
253
  }
233
254
  try {
234
- startUpdate(activePort);
235
- return Response.json({ ok: true });
255
+ const status = await checkForUpdates();
256
+ if (!status.updateAvailable || !status.latest) {
257
+ return Response.json({ error: status.error ?? "Already on the latest version." }, { status: 409 });
258
+ }
259
+ startUpdate(activePort, status.latest);
260
+ return Response.json({ ok: true, version: status.latest });
236
261
  } catch (error) {
237
262
  return Response.json({ error: error instanceof Error ? error.message : "Could not start the update" }, { status: 500 });
238
263
  }
@@ -240,6 +265,7 @@ function startServer(preferredPort: number) {
240
265
 
241
266
  if (url.pathname === "/api/shutdown" && req.method === "POST") {
242
267
  setTimeout(() => {
268
+ stopCcusageRuns();
243
269
  stopSystemTray();
244
270
  server.stop(true);
245
271
  process.exit(0);
@@ -266,13 +292,12 @@ const server = startServer(PORT);
266
292
  startSystemTray(server.port ?? PORT);
267
293
  void repointStartupIfStale();
268
294
 
269
- process.on("SIGINT", () => {
270
- stopSystemTray();
271
- process.exit(0);
272
- });
273
- process.on("SIGTERM", () => {
274
- stopSystemTray();
275
- process.exit(0);
276
- });
295
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
296
+ process.on(signal, () => {
297
+ stopCcusageRuns();
298
+ stopSystemTray();
299
+ process.exit(0);
300
+ });
301
+ }
277
302
 
278
303
  console.log(`🔥 Token Larper running at http://localhost:${server.port} (System Tray Icon Active)`);
package/src/startup.ts CHANGED
@@ -42,7 +42,7 @@ function saveSettings(settings: SavedSettings): void {
42
42
  }
43
43
 
44
44
  async function runReg(args: string[]): Promise<{ code: number; output: string; error: string }> {
45
- const proc = Bun.spawn(["reg.exe", ...args], { stdout: "pipe", stderr: "pipe" });
45
+ const proc = Bun.spawn(["reg.exe", ...args], { stdout: "pipe", stderr: "pipe", windowsHide: true });
46
46
  const [output, error, code] = await Promise.all([
47
47
  new Response(proc.stdout).text(),
48
48
  new Response(proc.stderr).text(),
package/src/tray.ts CHANGED
@@ -444,6 +444,7 @@ export async function startSystemTray(port: number): Promise<void> {
444
444
  {
445
445
  stdout: "pipe",
446
446
  stderr: "ignore",
447
+ windowsHide: true,
447
448
  }
448
449
  );
449
450
  const out = (await new Response(proc.stdout).text()).trim();
@@ -462,6 +463,7 @@ export function stopSystemTray(): void {
462
463
  Bun.spawnSync(["taskkill.exe", "/PID", String(trayPid), "/F"], {
463
464
  stdout: "ignore",
464
465
  stderr: "ignore",
466
+ windowsHide: true,
465
467
  });
466
468
  } catch {
467
469
  // ignore
package/src/updates.ts CHANGED
@@ -48,15 +48,39 @@ export async function checkForUpdates(force = false): Promise<UpdateStatus> {
48
48
  }
49
49
 
50
50
  /**
51
- * Start the latest version in the background. The new copy sees this server running
52
- * an older version, asks it to shut down, and takes over the same port (bin/cli.js).
51
+ * Run a program in the background, independent of this server.
52
+ *
53
+ * On Windows every child of this server inherits its listening socket, which keeps the
54
+ * port taken until the child exits. Start-Process launches the program through the shell
55
+ * instead, so it inherits nothing, and gives it a hidden console that anything it starts
56
+ * shares instead of opening windows of their own. Arguments must not contain double quotes.
53
57
  */
54
- export function startUpdate(port: number): void {
55
- const child = spawn(process.execPath, ["x", `${PACKAGE}@latest`], {
56
- detached: true,
57
- stdio: "ignore",
58
- windowsHide: true,
59
- env: { ...process.env, PORT: String(port), TOKEN_LARPER_NO_BROWSER: "1" },
58
+ export function launchIndependent(program: string, args: string[], env: Record<string, string | undefined>): void {
59
+ if (process.platform === "win32") {
60
+ const quote = (value: string) => `'${value.replace(/'/g, "''")}'`;
61
+ const command = `Start-Process -WindowStyle Hidden -FilePath ${quote(program)} -ArgumentList ${args.map((a) => quote(`"${a}"`)).join(",")}`;
62
+ // Not detached: a PowerShell without any console fails to start the program.
63
+ spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
64
+ stdio: "ignore",
65
+ windowsHide: true,
66
+ env,
67
+ }).unref();
68
+ return;
69
+ }
70
+ spawn(program, args, { detached: true, stdio: "ignore", env }).unref();
71
+ }
72
+
73
+ /**
74
+ * Start the given version in the background. The new copy sees this server running an
75
+ * older version, asks it to shut down, and takes over the same port (src/server.ts).
76
+ * The exact version is installed because `@latest` can resolve from bun's cached
77
+ * package list and bring back the version already running.
78
+ */
79
+ export function startUpdate(port: number, version: string): void {
80
+ if (!/^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(version)) throw new Error(`Unexpected version "${version}"`);
81
+ launchIndependent(process.execPath, ["x", `${PACKAGE}@${version}`], {
82
+ ...process.env,
83
+ PORT: String(port),
84
+ TOKEN_LARPER_NO_BROWSER: "1",
60
85
  });
61
- child.unref();
62
86
  }