token-larper 1.0.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 (42) hide show
  1. package/README.md +334 -0
  2. package/Start-TokenLarper.vbs +5 -0
  3. package/bin/cli.js +34 -0
  4. package/llms.txt +59 -0
  5. package/package.json +61 -0
  6. package/scripts/docs-demo-server.ts +141 -0
  7. package/scripts/launch-on-default-desktop.ps1 +42 -0
  8. package/scripts/launch-silent.vbs +6 -0
  9. package/scripts/run-server.ps1 +59 -0
  10. package/scripts/tray-host.ps1 +181 -0
  11. package/src/ccusage.ts +1075 -0
  12. package/src/client/App.tsx +560 -0
  13. package/src/client/charts.tsx +341 -0
  14. package/src/client/components/Calendar.tsx +207 -0
  15. package/src/client/components/DateRangePicker.tsx +131 -0
  16. package/src/client/components/KpiCards.tsx +122 -0
  17. package/src/client/components/RankChip.tsx +22 -0
  18. package/src/client/components/SelectMenu.tsx +116 -0
  19. package/src/client/components/SettingsDialog.tsx +207 -0
  20. package/src/client/components/ThemeSettings.tsx +131 -0
  21. package/src/client/context.tsx +60 -0
  22. package/src/client/index.html +54 -0
  23. package/src/client/lib/aggregate.ts +266 -0
  24. package/src/client/lib/range.ts +87 -0
  25. package/src/client/lib/rank.ts +323 -0
  26. package/src/client/lib/shareCard.ts +139 -0
  27. package/src/client/logo.svg +8 -0
  28. package/src/client/main.tsx +12 -0
  29. package/src/client/styles.css +1113 -0
  30. package/src/client/themes.ts +268 -0
  31. package/src/client/utils.ts +61 -0
  32. package/src/client/views/Models.tsx +71 -0
  33. package/src/client/views/Overview.tsx +280 -0
  34. package/src/client/views/Projects.tsx +88 -0
  35. package/src/client/views/Rank.tsx +255 -0
  36. package/src/client/views/Sessions.tsx +108 -0
  37. package/src/client/views/Tools.tsx +114 -0
  38. package/src/projects.ts +214 -0
  39. package/src/server.ts +173 -0
  40. package/src/startup.ts +110 -0
  41. package/src/tray.ts +293 -0
  42. package/src/types.ts +155 -0
@@ -0,0 +1,108 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import type { SessionEntry } from "../../types.ts";
3
+ import { ToolTag, useDashboard } from "../context.tsx";
4
+ import { formatDay } from "../lib/range.ts";
5
+ import { cleanProjectPath, formatCompactNumber, formatCurrency } from "../utils.ts";
6
+
7
+ const PAGE = 100;
8
+
9
+ export function sessionWhen(s: SessionEntry): string {
10
+ if (s.lastActivity) {
11
+ return new Date(s.lastActivity).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
12
+ }
13
+ return s.date === "Unknown" ? "Unknown" : formatDay(s.date, true);
14
+ }
15
+
16
+ export function matchesSession(s: SessionEntry, q: string): boolean {
17
+ return (
18
+ (s.projectRoot ?? s.projectPath).toLowerCase().includes(q) ||
19
+ s.modelsUsed.some((m) => m.toLowerCase().includes(q)) ||
20
+ s.harness.includes(q)
21
+ );
22
+ }
23
+
24
+ export function SessionTable({ sessions, showProject = true }: { sessions: SessionEntry[]; showProject?: boolean }) {
25
+ const { costOf, estimated } = useDashboard();
26
+ const [limit, setLimit] = useState(PAGE);
27
+ const shown = sessions.slice(0, limit);
28
+
29
+ return (
30
+ <>
31
+ <div className="table-scroll">
32
+ <table className="table">
33
+ <thead>
34
+ <tr>
35
+ <th>Last active</th>
36
+ <th>Tool</th>
37
+ {showProject && <th>Project</th>}
38
+ <th>Models</th>
39
+ <th className="num">Input</th>
40
+ <th className="num">Output</th>
41
+ <th className="num">Cache read</th>
42
+ <th className="num">Total</th>
43
+ <th className="num">{estimated ? "Est. value" : "Cost"}</th>
44
+ </tr>
45
+ </thead>
46
+ <tbody>
47
+ {shown.map((s) => (
48
+ <tr key={s.id}>
49
+ <td className="nowrap">{sessionWhen(s)}</td>
50
+ <td><ToolTag id={s.harness} /></td>
51
+ {showProject && (
52
+ <td className="project-cell" title={s.cwd ?? s.projectPath}>
53
+ {s.projectName ? (
54
+ <>
55
+ <strong>{s.projectName}</strong>
56
+ <span className="cell-note">{s.projectRoot}</span>
57
+ </>
58
+ ) : (
59
+ <span className="muted">{cleanProjectPath(s.projectPath)}</span>
60
+ )}
61
+ </td>
62
+ )}
63
+ <td>
64
+ <div className="tag-row">
65
+ {s.modelsUsed.slice(0, 3).map((m) => <code key={m} className="model-name">{m}</code>)}
66
+ {s.modelsUsed.length > 3 && <span className="muted">+{s.modelsUsed.length - 3}</span>}
67
+ </div>
68
+ </td>
69
+ <td className="num">{formatCompactNumber(s.inputTokens)}</td>
70
+ <td className="num">{formatCompactNumber(s.outputTokens)}</td>
71
+ <td className="num">{formatCompactNumber(s.cacheReadTokens)}</td>
72
+ <td className="num strong">{formatCompactNumber(s.totalTokens)}</td>
73
+ <td className="num">{formatCurrency(costOf(s))}</td>
74
+ </tr>
75
+ ))}
76
+ </tbody>
77
+ </table>
78
+ </div>
79
+ {sessions.length > limit && (
80
+ <div className="table-more">
81
+ <span className="muted">Showing {limit} of {sessions.length}</span>
82
+ <button className="btn" onClick={() => setLimit((n) => n + PAGE)}>Show {Math.min(PAGE, sessions.length - limit)} more</button>
83
+ </div>
84
+ )}
85
+ </>
86
+ );
87
+ }
88
+
89
+ export function Sessions() {
90
+ const { sessions, search } = useDashboard();
91
+ const q = search.trim().toLowerCase();
92
+ const list = useMemo(() => (q ? sessions.filter((s) => matchesSession(s, q)) : sessions), [sessions, q]);
93
+
94
+ return (
95
+ <section className="panel">
96
+ <header className="panel-head">
97
+ <div>
98
+ <h2>Sessions</h2>
99
+ <p>
100
+ {list.length} {list.length === 1 ? "session" : "sessions"}
101
+ {q ? ` matching “${search.trim()}”` : ""}, newest first. A session counts toward the day it was last active.
102
+ </p>
103
+ </div>
104
+ </header>
105
+ {list.length === 0 ? <p className="muted">No sessions in this range.</p> : <SessionTable key={q} sessions={list} />}
106
+ </section>
107
+ );
108
+ }
@@ -0,0 +1,114 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { ShareBar, ToolTag, pct, useDashboard } from "../context.tsx";
3
+ import { harnessTotals, summarize } from "../lib/aggregate.ts";
4
+ import { formatDay } from "../lib/range.ts";
5
+ import { formatCompactNumber, formatCurrency } from "../utils.ts";
6
+
7
+ export function Tools() {
8
+ const { data, days, harness, setHarness, costOf, estimated, seriesOf } = useDashboard();
9
+ const [showAll, setShowAll] = useState(false);
10
+ const tools = useMemo(() => harnessTotals(days), [days]);
11
+ const total = useMemo(() => summarize(days).totalTokens, [days]);
12
+ const withUsage = new Set(tools.map((t) => t.harness));
13
+ const idle = data.harnesses.filter((h) => !withUsage.has(h.meta.id) && (showAll || h.meta.installed || h.meta.hasUsage));
14
+ const maxTokens = tools[0]?.totalTokens ?? 0;
15
+
16
+ return (
17
+ <>
18
+ <section className="panel">
19
+ <header className="panel-head">
20
+ <div>
21
+ <h2>Tools with usage</h2>
22
+ <p>Select a tool to filter the whole dashboard to it.</p>
23
+ </div>
24
+ </header>
25
+ {tools.length === 0 ? (
26
+ <p className="muted">No tool recorded usage in this range.</p>
27
+ ) : (
28
+ <div className="table-scroll">
29
+ <table className="table">
30
+ <thead>
31
+ <tr>
32
+ <th>Tool</th>
33
+ <th className="bar-col"><span className="sr-only">Share</span></th>
34
+ <th className="num">Tokens</th>
35
+ <th className="num">Share</th>
36
+ <th className="num">{estimated ? "Est. value" : "Cost"}</th>
37
+ <th className="num">Active days</th>
38
+ <th className="num">Cache read</th>
39
+ <th>Top model</th>
40
+ <th>Last active</th>
41
+ </tr>
42
+ </thead>
43
+ <tbody>
44
+ {tools.map((t) => {
45
+ const meta = data.harnesses.find((h) => h.meta.id === t.harness)?.meta;
46
+ return (
47
+ <tr
48
+ key={t.harness}
49
+ className={`row-button ${harness === t.harness ? "is-selected" : ""}`}
50
+ onClick={() => setHarness(harness === t.harness ? "all" : t.harness)}
51
+ >
52
+ <td>
53
+ <button type="button" className="cell-button" aria-pressed={harness === t.harness}>
54
+ <ToolTag id={t.harness} />
55
+ </button>
56
+ <span className="cell-note">{meta?.vendor}</span>
57
+ </td>
58
+ <td className="bar-col"><ShareBar value={t.totalTokens} max={maxTokens} color={seriesOf(t.harness).color} /></td>
59
+ <td className="num strong">{formatCompactNumber(t.totalTokens)}</td>
60
+ <td className="num muted">{pct(t.totalTokens, total)}</td>
61
+ <td className="num">{formatCurrency(costOf(t))}</td>
62
+ <td className="num">{t.activeDays}</td>
63
+ <td className="num">{t.cacheHitRate.toFixed(1)}%</td>
64
+ <td><code className="model-name">{t.models[0]?.modelName ?? "—"}</code></td>
65
+ <td className="nowrap muted">{t.lastActive ? formatDay(t.lastActive, true) : "—"}</td>
66
+ </tr>
67
+ );
68
+ })}
69
+ </tbody>
70
+ </table>
71
+ </div>
72
+ )}
73
+ </section>
74
+
75
+ <section className="panel">
76
+ <header className="panel-head">
77
+ <div>
78
+ <h2>No usage in this range</h2>
79
+ <p>
80
+ {showAll
81
+ ? `All ${data.totals.totalHarnesses} tools Token Larper can read.`
82
+ : "Tools found on this computer that were idle in the selected range."}
83
+ </p>
84
+ </div>
85
+ <button className="btn" onClick={() => setShowAll((v) => !v)}>
86
+ {showAll ? "Only installed tools" : `Show all ${data.totals.totalHarnesses} supported tools`}
87
+ </button>
88
+ </header>
89
+ {idle.length === 0 ? (
90
+ <p className="muted">Every installed tool has usage in this range.</p>
91
+ ) : (
92
+ <ul className="idle-list">
93
+ {idle.map((h) => (
94
+ <li key={h.meta.id}>
95
+ <div>
96
+ <strong>{h.meta.name}</strong>
97
+ <span className="muted">{h.meta.vendor}</span>
98
+ </div>
99
+ <code>{h.meta.configPathHint}</code>
100
+ <span className={`status ${h.meta.installed ? "status-installed" : "status-missing"}`}>
101
+ {h.meta.installed
102
+ ? h.lastActiveDate
103
+ ? `Installed · last used ${formatDay(h.lastActiveDate, true)}`
104
+ : "Installed · never used"
105
+ : "Not detected"}
106
+ </span>
107
+ </li>
108
+ ))}
109
+ </ul>
110
+ )}
111
+ </section>
112
+ </>
113
+ );
114
+ }
@@ -0,0 +1,214 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import type { HarnessId } from "./types.ts";
6
+
7
+ // ccusage reports a usable project for only some harnesses, so the working directory is
8
+ // read from each harness's own session files. A session's cwd never changes, so found
9
+ // directories are also saved to disk; misses are retried once per process.
10
+
11
+ const HOME = homedir();
12
+ const cwdCache = new Map<string, string | null>();
13
+ const repoCache = new Map<string, string | null>();
14
+ const CACHE_FILE = join(resolve(import.meta.dir, ".."), ".cache", "session-projects.json");
15
+ let diskLoaded = false;
16
+ let dirty = false;
17
+ let opencodeDirs: Map<string, { directory: string; updated: number }> | null = null;
18
+
19
+ export interface SessionProject {
20
+ cwd: string;
21
+ root: string;
22
+ name: string;
23
+ }
24
+
25
+ function readHead(path: string, bytes = 64 * 1024): string {
26
+ const fd = openSync(path, "r");
27
+ try {
28
+ const buf = Buffer.alloc(bytes);
29
+ const n = readSync(fd, buf, 0, bytes, 0);
30
+ return buf.toString("utf8", 0, n);
31
+ } finally {
32
+ closeSync(fd);
33
+ }
34
+ }
35
+
36
+ function firstCwd(path: string): string | null {
37
+ if (!existsSync(path)) return null;
38
+ const match = readHead(path).match(/"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/);
39
+ return match ? (JSON.parse(`"${match[1]}"`) as string) : null;
40
+ }
41
+
42
+ function loadOpencode(): Map<string, { directory: string; updated: number }> {
43
+ if (opencodeDirs) return opencodeDirs;
44
+ opencodeDirs = new Map();
45
+ const dbPath = join(HOME, ".local", "share", "opencode", "opencode.db");
46
+ if (!existsSync(dbPath)) return opencodeDirs;
47
+ try {
48
+ const db = new Database(dbPath, { readonly: true });
49
+ const rows = db.query("select id, directory, time_updated from session").all() as {
50
+ id: string;
51
+ directory: string | null;
52
+ time_updated: number | null;
53
+ }[];
54
+ for (const r of rows) {
55
+ if (r.directory) opencodeDirs.set(r.id, { directory: r.directory, updated: r.time_updated ?? 0 });
56
+ }
57
+ db.close();
58
+ } catch {
59
+ // An older OpenCode without the SQLite store, or a locked file: leave unattributed.
60
+ }
61
+ return opencodeDirs;
62
+ }
63
+
64
+ /** OpenCode sessions carry no timestamps in ccusage output; its own store has them. */
65
+ export function opencodeSessionUpdated(sessionId: string): number | null {
66
+ return loadOpencode().get(sessionId)?.updated || null;
67
+ }
68
+
69
+ // The IDE, the CLI and older installs each keep their own store.
70
+ const ANTIGRAVITY_DIRS = ["antigravity", "antigravity-ide", "antigravity-cli"].map((d) => join(HOME, ".gemini", d));
71
+ let antigravityWorkspaces: Map<string, string> | null = null;
72
+
73
+ function fileUriToPath(uri: string): string | null {
74
+ try {
75
+ const path = decodeURIComponent(uri.replace(/^file:\/\/\//, ""));
76
+ return /^[a-zA-Z]:\//.test(path) ? path : `/${path}`;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** Antigravity lists workspaces for some conversations in its summary table. */
83
+ function loadAntigravityWorkspaces(): Map<string, string> {
84
+ if (antigravityWorkspaces) return antigravityWorkspaces;
85
+ antigravityWorkspaces = new Map();
86
+ for (const dir of ANTIGRAVITY_DIRS) {
87
+ const dbPath = join(dir, "conversation_summaries.db");
88
+ if (!existsSync(dbPath)) continue;
89
+ try {
90
+ const db = new Database(dbPath, { readonly: true });
91
+ const rows = db.query("select conversation_id, workspace_uris from conversation_summaries").all() as {
92
+ conversation_id: string;
93
+ workspace_uris: string;
94
+ }[];
95
+ for (const r of rows) {
96
+ const first = (JSON.parse(r.workspace_uris || "[]") as string[])[0];
97
+ const path = first ? fileUriToPath(first) : null;
98
+ if (path) antigravityWorkspaces.set(r.conversation_id, path);
99
+ }
100
+ db.close();
101
+ } catch {
102
+ // Unknown schema: fall back to scanning the conversation itself.
103
+ }
104
+ }
105
+ return antigravityWorkspaces;
106
+ }
107
+
108
+ /** Otherwise the conversation store mentions the files it touched; the most-referenced git repo wins. */
109
+ function scanAntigravityConversation(id: string): string | null {
110
+ const file = ANTIGRAVITY_DIRS.flatMap((dir) => [".db", ".pb"].map((ext) => join(dir, "conversations", `${id}${ext}`))).find(existsSync);
111
+ if (!file) return null;
112
+ const dirCounts = new Map<string, number>();
113
+ for (const [uri] of readHead(file, 4 * 1024 * 1024).matchAll(/file:\/\/\/[A-Za-z0-9%:/_.\-]+/g)) {
114
+ const path = fileUriToPath(uri);
115
+ if (!path || /\/\.gemini\//i.test(path)) continue;
116
+ const dir = dirname(path);
117
+ dirCounts.set(dir, (dirCounts.get(dir) ?? 0) + 1);
118
+ }
119
+ const counts = new Map<string, number>();
120
+ for (const [dir, n] of dirCounts) {
121
+ const repo = repoFor(dir);
122
+ if (repo) counts.set(repo, (counts.get(repo) ?? 0) + n);
123
+ }
124
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
125
+ }
126
+
127
+ function lookupCwd(harness: HarnessId, sessionId: string, projectPath: string): string | null {
128
+ switch (harness) {
129
+ case "codex":
130
+ return firstCwd(join(HOME, ".codex", "sessions", `${sessionId}.jsonl`));
131
+ case "claude":
132
+ return firstCwd(join(HOME, ".claude", "projects", projectPath, `${sessionId}.jsonl`));
133
+ case "pi": {
134
+ const dir = join(HOME, ".pi", "agent", "sessions", projectPath);
135
+ if (!existsSync(dir)) return null;
136
+ const file = readdirSync(dir).find((f) => f.endsWith(`_${sessionId}.jsonl`));
137
+ return file ? firstCwd(join(dir, file)) : null;
138
+ }
139
+ case "opencode":
140
+ return loadOpencode().get(sessionId)?.directory ?? null;
141
+ case "antigravity":
142
+ return loadAntigravityWorkspaces().get(sessionId) ?? scanAntigravityConversation(sessionId);
143
+ default:
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function normalize(path: string): string {
149
+ const p = path.replace(/\\/g, "/").replace(/\/+$/, "");
150
+ return /^[a-z]:/.test(p) ? p[0]!.toUpperCase() + p.slice(1) : p;
151
+ }
152
+
153
+ /** The nearest ancestor holding a .git folder. Every folder visited is memoized, so a
154
+ * directory tree is only probed once however many files point into it. */
155
+ function repoFor(dir: string, depth = 0): string | null {
156
+ const key = dir.toLowerCase();
157
+ const cached = repoCache.get(key);
158
+ if (cached !== undefined) return cached;
159
+ let repo: string | null = null;
160
+ if (existsSync(join(dir, ".git"))) repo = dir;
161
+ else {
162
+ const parent = dirname(dir);
163
+ if (parent !== dir && depth < 12) repo = repoFor(parent, depth + 1);
164
+ }
165
+ repoCache.set(key, repo);
166
+ return repo;
167
+ }
168
+
169
+ function loadDiskCache(): void {
170
+ if (diskLoaded) return;
171
+ diskLoaded = true;
172
+ try {
173
+ const saved = JSON.parse(readFileSync(CACHE_FILE, "utf8")) as Record<string, string>;
174
+ for (const [key, cwd] of Object.entries(saved)) cwdCache.set(key, cwd);
175
+ } catch {
176
+ // First run or a damaged file: everything is looked up again.
177
+ }
178
+ }
179
+
180
+ /** Persist newly found directories; called after each dashboard rebuild. */
181
+ export function saveSessionProjects(): void {
182
+ if (!dirty) return;
183
+ dirty = false;
184
+ const found = Object.fromEntries([...cwdCache].filter((entry): entry is [string, string] => entry[1] !== null));
185
+ try {
186
+ mkdirSync(dirname(CACHE_FILE), { recursive: true });
187
+ writeFileSync(CACHE_FILE, JSON.stringify(found), "utf8");
188
+ } catch {
189
+ // The cache is only an optimization.
190
+ }
191
+ }
192
+
193
+ export function resolveSessionProject(
194
+ harness: HarnessId,
195
+ sessionId: string,
196
+ projectPath: string
197
+ ): SessionProject | null {
198
+ loadDiskCache();
199
+ const key = `${harness}:${sessionId}`;
200
+ let cwd = cwdCache.get(key);
201
+ if (cwd === undefined) {
202
+ try {
203
+ cwd = lookupCwd(harness, sessionId, projectPath);
204
+ } catch {
205
+ cwd = null;
206
+ }
207
+ cwdCache.set(key, cwd);
208
+ if (cwd) dirty = true;
209
+ }
210
+ if (!cwd) return null;
211
+ // Group sessions by repository, falling back to the working directory itself.
212
+ const root = normalize(repoFor(cwd) ?? cwd);
213
+ return { cwd: normalize(cwd), root, name: basename(root) || root };
214
+ }
package/src/server.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { join, resolve } from "node:path";
2
+ import { getDashboardData } from "./ccusage.ts";
3
+ import { getStartupStatus, setStartupStatus } from "./startup.ts";
4
+ import { startSystemTray, stopSystemTray } from "./tray.ts";
5
+ import { formatCompactNumber, formatCurrency } from "./client/utils.ts";
6
+
7
+ const ROOT_DIR = resolve(import.meta.dir, "..");
8
+ const CLIENT_DIR = join(ROOT_DIR, "src", "client");
9
+ const PORT = Number(process.env.PORT || 4269);
10
+
11
+ async function buildClientBundle(): Promise<string> {
12
+ const result = await Bun.build({
13
+ entrypoints: [join(CLIENT_DIR, "main.tsx")],
14
+ target: "browser",
15
+ format: "esm",
16
+ minify: true,
17
+ define: {
18
+ "process.env.NODE_ENV": JSON.stringify("production"),
19
+ },
20
+ });
21
+
22
+ if (!result.success || result.outputs.length === 0) {
23
+ const logs = result.logs.map((l) => l.message).join("\n");
24
+ throw new Error(`Client bundle build failed:\n${logs}`);
25
+ }
26
+
27
+ return await result.outputs[0]!.text();
28
+ }
29
+
30
+ const clientBundle = buildClientBundle();
31
+
32
+ // Only this machine's own dashboard may talk to the server. Checking Host blocks
33
+ // DNS-rebinding reads; checking Origin + Content-Type blocks cross-site POSTs.
34
+ function isAllowedHost(host: string | null, port: number): boolean {
35
+ return host === `127.0.0.1:${port}` || host === `localhost:${port}`;
36
+ }
37
+
38
+ function isAllowedMutation(req: Request, port: number): boolean {
39
+ const origin = req.headers.get("origin");
40
+ if (origin) {
41
+ try {
42
+ if (!isAllowedHost(new URL(origin).host, port)) return false;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+ // The tray (PowerShell) sends no Origin; a JSON content type still can't be sent cross-site without a preflight.
48
+ return (req.headers.get("content-type") || "").toLowerCase().startsWith("application/json");
49
+ }
50
+
51
+ // Warm cached usage before the first dashboard request.
52
+ void getDashboardData();
53
+
54
+ const server = Bun.serve({
55
+ port: PORT,
56
+ hostname: "127.0.0.1",
57
+ idleTimeout: 120,
58
+ async fetch(req) {
59
+ const url = new URL(req.url);
60
+ const port = server.port ?? PORT;
61
+
62
+ if (!isAllowedHost(req.headers.get("host"), port)) {
63
+ return new Response("Forbidden", { status: 403 });
64
+ }
65
+ if (req.method !== "GET" && req.method !== "HEAD" && !isAllowedMutation(req, port)) {
66
+ return new Response("Forbidden", { status: 403 });
67
+ }
68
+
69
+ if (url.pathname === "/" || url.pathname === "/index.html") {
70
+ return new Response(Bun.file(join(CLIENT_DIR, "index.html")), {
71
+ headers: { "Content-Type": "text/html; charset=utf-8" },
72
+ });
73
+ }
74
+
75
+ if (url.pathname === "/styles.css") {
76
+ return new Response(Bun.file(join(CLIENT_DIR, "styles.css")), {
77
+ headers: { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-cache" },
78
+ });
79
+ }
80
+
81
+ if (url.pathname === "/logo.svg") {
82
+ return new Response(Bun.file(join(CLIENT_DIR, "logo.svg")), {
83
+ headers: { "Content-Type": "image/svg+xml; charset=utf-8" },
84
+ });
85
+ }
86
+
87
+ if (url.pathname === "/app.js") {
88
+ const code = process.env.NODE_ENV === "production" ? await clientBundle : await buildClientBundle();
89
+ return new Response(code, {
90
+ headers: { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-cache" },
91
+ });
92
+ }
93
+
94
+ if (url.pathname === "/api/usage" && req.method === "GET") {
95
+ const refresh = url.searchParams.get("refresh") === "1";
96
+ const forceDeepScan = url.searchParams.get("deep") === "1";
97
+ const data = await getDashboardData({ refresh, forceDeepScan });
98
+ return Response.json(data);
99
+ }
100
+
101
+ if (url.pathname === "/api/tray-status" && req.method === "GET") {
102
+ const [data, startup] = await Promise.all([
103
+ getDashboardData({ refresh: false }),
104
+ getStartupStatus(PORT),
105
+ ]);
106
+ const tokStr = formatCompactNumber(data.totals.totalTokens);
107
+ const verStr = formatCurrency(data.totals.verifiedCost);
108
+ const estStr = formatCurrency(data.totals.estimatedCost);
109
+ return Response.json({
110
+ summaryText: `${tokStr} tokens · ${verStr} (${estStr} LARP) · ${data.totals.activeHarnesses} harnesses`,
111
+ shortTooltip: `${tokStr} tok · ${estStr} LARP`,
112
+ bootEnabled: startup.enabled,
113
+ openBrowserOnBoot: startup.openBrowserOnBoot,
114
+ });
115
+ }
116
+
117
+ if (url.pathname === "/api/startup" && req.method === "GET") {
118
+ const status = await getStartupStatus(PORT);
119
+ return Response.json(status);
120
+ }
121
+
122
+ if (url.pathname === "/api/startup" && req.method === "POST") {
123
+ let body: unknown;
124
+ try {
125
+ body = await req.json();
126
+ } catch {
127
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
128
+ }
129
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
130
+ return Response.json({ error: "Startup settings must be an object" }, { status: 400 });
131
+ }
132
+ const input = body as { enabled?: unknown; openBrowserOnBoot?: unknown };
133
+ if (typeof input.enabled !== "boolean" ||
134
+ (input.openBrowserOnBoot !== undefined && typeof input.openBrowserOnBoot !== "boolean")) {
135
+ return Response.json({ error: "Startup settings must use boolean values" }, { status: 400 });
136
+ }
137
+ try {
138
+ const status = await setStartupStatus({
139
+ enabled: input.enabled,
140
+ openBrowserOnBoot: input.openBrowserOnBoot as boolean | undefined,
141
+ port: PORT,
142
+ });
143
+ return Response.json(status);
144
+ } catch (error) {
145
+ return Response.json({ error: error instanceof Error ? error.message : "Could not update startup" }, { status: 500 });
146
+ }
147
+ }
148
+
149
+ if (url.pathname === "/api/shutdown" && req.method === "POST") {
150
+ setTimeout(() => {
151
+ stopSystemTray();
152
+ server.stop(true);
153
+ process.exit(0);
154
+ }, 150);
155
+ return Response.json({ ok: true, message: "Shutting down Token Larper..." });
156
+ }
157
+
158
+ return new Response("Not Found", { status: 404 });
159
+ },
160
+ });
161
+
162
+ startSystemTray(server.port ?? PORT);
163
+
164
+ process.on("SIGINT", () => {
165
+ stopSystemTray();
166
+ process.exit(0);
167
+ });
168
+ process.on("SIGTERM", () => {
169
+ stopSystemTray();
170
+ process.exit(0);
171
+ });
172
+
173
+ console.log(`🔥 Token Larper running at http://localhost:${server.port} (System Tray Icon Active)`);