token-larper 1.10.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/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.10.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
@@ -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 {
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/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
  }