@jefuriiij/synthra 0.1.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.
@@ -0,0 +1,602 @@
1
+ // src/dashboard/server.ts
2
+ import { serve } from "@hono/node-server";
3
+ import { Hono } from "hono";
4
+
5
+ // src/shared/logger.ts
6
+ var LEVEL_PRIORITY = {
7
+ debug: 10,
8
+ info: 20,
9
+ warn: 30,
10
+ error: 40
11
+ };
12
+ var activeLevel = process.env.SYN_LOG_LEVEL ?? "info";
13
+ function shouldLog(level) {
14
+ return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[activeLevel];
15
+ }
16
+ function emit(level, msg, ...args) {
17
+ if (!shouldLog(level)) return;
18
+ const stream = level === "error" || level === "warn" ? process.stderr : process.stdout;
19
+ stream.write(`[syn] ${msg}${args.length ? " " + args.map(String).join(" ") : ""}
20
+ `);
21
+ }
22
+ var log = {
23
+ debug: (m, ...a) => emit("debug", m, ...a),
24
+ info: (m, ...a) => emit("info", m, ...a),
25
+ warn: (m, ...a) => emit("warn", m, ...a),
26
+ error: (m, ...a) => emit("error", m, ...a)
27
+ };
28
+
29
+ // src/server/port.ts
30
+ import { createServer } from "net";
31
+ var PORT_RANGE_START = 8080;
32
+ var PORT_RANGE_END = 8099;
33
+ async function findFreePort(start = PORT_RANGE_START, end = PORT_RANGE_END) {
34
+ for (let port = start; port <= end; port++) {
35
+ if (await isFree(port)) return port;
36
+ }
37
+ throw new Error(`Synthra: no free port in ${start}-${end}`);
38
+ }
39
+ function isFree(port) {
40
+ return new Promise((resolve) => {
41
+ const s = createServer();
42
+ s.once("error", () => resolve(false));
43
+ s.once("listening", () => s.close(() => resolve(true)));
44
+ s.listen(port, "127.0.0.1");
45
+ });
46
+ }
47
+
48
+ // src/dashboard/delta.ts
49
+ import { readFile as readFile2 } from "fs/promises";
50
+
51
+ // src/shared/paths.ts
52
+ import { join } from "path";
53
+ function resolvePaths(projectRoot) {
54
+ const graphDir = join(projectRoot, ".synthra-graph");
55
+ const contextDir = join(projectRoot, ".synthra");
56
+ const claudeDir = join(projectRoot, ".claude");
57
+ return {
58
+ projectRoot,
59
+ graphDir,
60
+ contextDir,
61
+ infoGraph: join(graphDir, "info_graph.json"),
62
+ symbolIndex: join(graphDir, "symbol_index.json"),
63
+ sessionState: join(graphDir, "session.json"),
64
+ activityLog: join(graphDir, "activity.jsonl"),
65
+ tokenLog: join(graphDir, "token_log.jsonl"),
66
+ gateLog: join(graphDir, "gate_log.jsonl"),
67
+ mcpPort: join(graphDir, "mcp_port"),
68
+ mcpServerLog: join(graphDir, "mcp_server.log"),
69
+ mcpServerErrLog: join(graphDir, "mcp_server.err.log"),
70
+ contextStore: join(contextDir, "context-store.json"),
71
+ contextMd: join(contextDir, "CONTEXT.md"),
72
+ branchesDir: join(contextDir, "branches"),
73
+ claudeDir,
74
+ claudeSettings: join(claudeDir, "settings.local.json"),
75
+ claudeHooksDir: join(claudeDir, "hooks"),
76
+ claudeMd: join(projectRoot, "CLAUDE.md"),
77
+ gitignore: join(projectRoot, ".gitignore")
78
+ };
79
+ }
80
+
81
+ // src/shared/pricing.ts
82
+ var PRICING = {
83
+ // Opus-class models — premium tier
84
+ "claude-opus-4-7": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },
85
+ "claude-opus-4-6": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },
86
+ "claude-opus-4-5": { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },
87
+ // Sonnet-class — workhorse
88
+ "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },
89
+ "claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },
90
+ // Haiku-class — fast and cheap
91
+ "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheCreate: 1.25 }
92
+ };
93
+ var FALLBACK = { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 };
94
+ function pricingFor(model) {
95
+ if (!model) return FALLBACK;
96
+ const direct = PRICING[model];
97
+ if (direct) return direct;
98
+ if (model.includes("opus")) return PRICING["claude-opus-4-7"] ?? FALLBACK;
99
+ if (model.includes("sonnet")) return PRICING["claude-sonnet-4-6"] ?? FALLBACK;
100
+ if (model.includes("haiku")) return PRICING["claude-haiku-4-5"] ?? FALLBACK;
101
+ return FALLBACK;
102
+ }
103
+ function estimateCostUsd(usage) {
104
+ const p = pricingFor(usage.model);
105
+ return usage.input_tokens / 1e6 * p.input + usage.output_tokens / 1e6 * p.output + (usage.cache_read_input_tokens ?? 0) / 1e6 * p.cacheRead + (usage.cache_creation_input_tokens ?? 0) / 1e6 * p.cacheCreate;
106
+ }
107
+
108
+ // src/shared/project-registry.ts
109
+ import { mkdir, readFile, writeFile } from "fs/promises";
110
+ import { homedir } from "os";
111
+ import { basename, dirname, join as join2 } from "path";
112
+ var REGISTRY_DIR = join2(homedir(), ".synthra");
113
+ var REGISTRY_PATH = join2(REGISTRY_DIR, "projects.json");
114
+ var SCHEMA_VERSION = 1;
115
+ async function readRegistry() {
116
+ try {
117
+ const raw = await readFile(REGISTRY_PATH, "utf8");
118
+ const parsed = JSON.parse(raw);
119
+ if (!Array.isArray(parsed.projects)) return { schema_version: SCHEMA_VERSION, projects: [] };
120
+ return { schema_version: parsed.schema_version ?? SCHEMA_VERSION, projects: parsed.projects };
121
+ } catch {
122
+ return { schema_version: SCHEMA_VERSION, projects: [] };
123
+ }
124
+ }
125
+ async function listProjects() {
126
+ const registry = await readRegistry();
127
+ return registry.projects.slice().sort((a, b) => a.last_seen > b.last_seen ? -1 : a.last_seen < b.last_seen ? 1 : 0);
128
+ }
129
+
130
+ // src/dashboard/delta.ts
131
+ var AVG_TOKENS_PER_BLOCKED_GREP = 500;
132
+ async function readJsonl(path) {
133
+ try {
134
+ const text = await readFile2(path, "utf8");
135
+ return text.split(/\r?\n/).filter((l) => l.length > 0).map((l) => {
136
+ try {
137
+ return JSON.parse(l);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }).filter((v) => v !== null);
142
+ } catch {
143
+ return [];
144
+ }
145
+ }
146
+ function basename2(p) {
147
+ const parts = p.split(/[\\/]/);
148
+ return parts[parts.length - 1] || p;
149
+ }
150
+ function summarize(p) {
151
+ let totalIn = 0;
152
+ let totalOut = 0;
153
+ let totalCacheRead = 0;
154
+ let totalCacheCreate = 0;
155
+ let costUsd = 0;
156
+ const models = {};
157
+ for (const t of p.tokens) {
158
+ totalIn += t.input_tokens ?? 0;
159
+ totalOut += t.output_tokens ?? 0;
160
+ totalCacheRead += t.cache_read_input_tokens ?? 0;
161
+ totalCacheCreate += t.cache_creation_input_tokens ?? 0;
162
+ costUsd += estimateCostUsd(t);
163
+ if (t.model) models[t.model] = (models[t.model] ?? 0) + 1;
164
+ }
165
+ const blocked = p.gates.filter((g) => g.decision === "block").length;
166
+ const saved = blocked * AVG_TOKENS_PER_BLOCKED_GREP;
167
+ return {
168
+ path: p.path,
169
+ name: p.name,
170
+ last_seen: p.last_seen,
171
+ total_turns: p.tokens.length,
172
+ total_input_tokens: totalIn,
173
+ total_output_tokens: totalOut,
174
+ total_cache_read: totalCacheRead,
175
+ total_cache_create: totalCacheCreate,
176
+ total_gate_calls: p.gates.length,
177
+ blocked_count: blocked,
178
+ estimated_tokens_saved: saved,
179
+ estimated_cost_usd: Math.round(costUsd * 100) / 100,
180
+ models
181
+ };
182
+ }
183
+ async function loadProjectFiles(path, name, lastSeen) {
184
+ const paths = resolvePaths(path);
185
+ const [rawTokens, gates] = await Promise.all([
186
+ readJsonl(paths.tokenLog),
187
+ readJsonl(paths.gateLog)
188
+ ]);
189
+ return { path, name, last_seen: lastSeen, tokens: dedupeTokens(rawTokens), gates };
190
+ }
191
+ function dedupeTokens(entries) {
192
+ const score = (model) => {
193
+ if (!model) return 0;
194
+ if (model === "<synthetic>") return 1;
195
+ return 2;
196
+ };
197
+ const groups = /* @__PURE__ */ new Map();
198
+ for (const e of entries) {
199
+ const ts = e.ts ?? e.written_at ?? "";
200
+ const second = ts.slice(0, 19);
201
+ const key = [
202
+ e.project ?? "",
203
+ e.input_tokens ?? 0,
204
+ e.output_tokens ?? 0,
205
+ e.cache_creation_input_tokens ?? 0,
206
+ e.cache_read_input_tokens ?? 0,
207
+ second
208
+ ].join("|");
209
+ const arr = groups.get(key) ?? [];
210
+ arr.push(e);
211
+ groups.set(key, arr);
212
+ }
213
+ const out = [];
214
+ for (const arr of groups.values()) {
215
+ if (arr.length === 1) {
216
+ out.push(arr[0]);
217
+ continue;
218
+ }
219
+ arr.sort((a, b) => score(b.model) - score(a.model));
220
+ out.push(arr[0]);
221
+ }
222
+ out.sort((a, b) => {
223
+ const at = a.ts ?? a.written_at ?? "";
224
+ const bt = b.ts ?? b.written_at ?? "";
225
+ return at.localeCompare(bt);
226
+ });
227
+ return out;
228
+ }
229
+ async function computeDashboardData(activePaths, recentN = 25) {
230
+ const registered = await listProjects();
231
+ const activePath = activePaths.projectRoot;
232
+ const activeName = basename2(activePath);
233
+ const knownPaths = new Set(registered.map((p) => p.path));
234
+ const allEntries = [
235
+ ...registered.map((p) => ({ path: p.path, name: p.name, last_seen: p.last_seen }))
236
+ ];
237
+ if (!knownPaths.has(activePath)) {
238
+ allEntries.unshift({ path: activePath, name: activeName, last_seen: null });
239
+ }
240
+ const loaded = await Promise.all(
241
+ allEntries.map((e) => loadProjectFiles(e.path, e.name, e.last_seen))
242
+ );
243
+ const projects = loaded.map(summarize).sort((a, b) => b.total_input_tokens + b.total_output_tokens - (a.total_input_tokens + a.total_output_tokens));
244
+ const activeFiles = loaded.find((p) => p.path === activePath) ?? {
245
+ path: activePath,
246
+ name: activeName,
247
+ last_seen: null,
248
+ tokens: [],
249
+ gates: []
250
+ };
251
+ const activeStats = summarize(activeFiles);
252
+ let g_in = 0, g_out = 0, g_cr = 0, g_cc = 0, g_gate = 0, g_block = 0, g_cost = 0, g_turns = 0;
253
+ for (const s of projects) {
254
+ g_turns += s.total_turns;
255
+ g_in += s.total_input_tokens;
256
+ g_out += s.total_output_tokens;
257
+ g_cr += s.total_cache_read;
258
+ g_cc += s.total_cache_create;
259
+ g_gate += s.total_gate_calls;
260
+ g_block += s.blocked_count;
261
+ g_cost += s.estimated_cost_usd;
262
+ }
263
+ const g_saved = g_block * AVG_TOKENS_PER_BLOCKED_GREP;
264
+ const g_used = g_in + g_out + g_cc;
265
+ const g_saved_pct = g_used + g_saved > 0 ? g_saved / (g_used + g_saved) * 100 : 0;
266
+ const allTurns = [];
267
+ const allGates = [];
268
+ for (const p of loaded) {
269
+ for (const t of p.tokens) {
270
+ allTurns.push({
271
+ // Fall back to written_at — the Stop hook today posts entries without
272
+ // a `ts` field, and the server tags them with written_at on receive.
273
+ ts: t.ts ?? t.written_at ?? "",
274
+ project_name: p.name,
275
+ project_path: p.path,
276
+ input: t.input_tokens ?? 0,
277
+ output: t.output_tokens ?? 0,
278
+ cache_read: t.cache_read_input_tokens ?? 0,
279
+ cache_create: t.cache_creation_input_tokens ?? 0,
280
+ model: t.model ?? "",
281
+ cost_usd: Math.round(estimateCostUsd(t) * 1e3) / 1e3
282
+ });
283
+ }
284
+ for (const gate of p.gates) {
285
+ allGates.push({
286
+ ts: gate.ts,
287
+ project_name: p.name,
288
+ project_path: p.path,
289
+ tool: gate.tool,
290
+ decision: gate.decision,
291
+ query: gate.query
292
+ });
293
+ }
294
+ }
295
+ allTurns.sort((a, b) => a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
296
+ allGates.sort((a, b) => a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
297
+ return {
298
+ active: {
299
+ project_root: activePath,
300
+ project_name: activeName,
301
+ stats: activeStats
302
+ },
303
+ global: {
304
+ project_count: projects.length,
305
+ total_turns: g_turns,
306
+ total_input_tokens: g_in,
307
+ total_output_tokens: g_out,
308
+ total_cache_read: g_cr,
309
+ total_cache_create: g_cc,
310
+ total_gate_calls: g_gate,
311
+ blocked_count: g_block,
312
+ estimated_tokens_saved: g_saved,
313
+ saved_percent: Math.round(g_saved_pct * 10) / 10,
314
+ estimated_cost_usd: Math.round(g_cost * 100) / 100
315
+ },
316
+ projects,
317
+ recent_turns: allTurns.slice(0, recentN),
318
+ recent_gates: allGates.slice(0, recentN)
319
+ };
320
+ }
321
+
322
+ // src/dashboard/public/index.html
323
+ var public_default = `<!doctype html>
324
+ <html lang="en">
325
+ <head>
326
+ <meta charset="UTF-8" />
327
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
328
+ <title>Synthra \u2014 Token Dashboard</title>
329
+ <link rel="stylesheet" href="./style.css" />
330
+ </head>
331
+ <body>
332
+ <header>
333
+ <div class="brand">
334
+ <h1>Synthra</h1>
335
+ <span class="tag">Token Dashboard</span>
336
+ </div>
337
+ <div class="meta">
338
+ <span class="active-project" id="active-project">\u2026</span>
339
+ <span class="dot" id="dot"></span>
340
+ <span id="status">connecting\u2026</span>
341
+ </div>
342
+ </header>
343
+
344
+ <main>
345
+ <section>
346
+ <h2>Global totals <span class="muted">(all projects)</span></h2>
347
+ <div class="cards" id="cards"></div>
348
+ </section>
349
+
350
+ <section>
351
+ <h2>Projects</h2>
352
+ <div class="projects" id="projects"></div>
353
+ <p class="empty hidden" id="projects-empty">No projects registered yet. Run <code>syn .</code> in any project to add it.</p>
354
+ </section>
355
+
356
+ <section>
357
+ <h2>Recent calls <span class="muted">(across all projects)</span></h2>
358
+ <table id="turns">
359
+ <thead>
360
+ <tr>
361
+ <th>Time</th>
362
+ <th>Project</th>
363
+ <th>Model</th>
364
+ <th class="num">Input</th>
365
+ <th class="num">Output</th>
366
+ <th class="num">Cache R / W</th>
367
+ <th class="num">Cost</th>
368
+ </tr>
369
+ </thead>
370
+ <tbody></tbody>
371
+ </table>
372
+ <p class="empty hidden" id="turns-empty">No turns logged yet. Use Claude via the IDE extension or <code>claude</code> CLI while <code>syn .</code> is running.</p>
373
+ </section>
374
+
375
+ <section>
376
+ <h2>Recent gate decisions</h2>
377
+ <table id="gates">
378
+ <thead>
379
+ <tr>
380
+ <th>Time</th>
381
+ <th>Project</th>
382
+ <th>Tool</th>
383
+ <th>Decision</th>
384
+ <th>Query</th>
385
+ </tr>
386
+ </thead>
387
+ <tbody></tbody>
388
+ </table>
389
+ <p class="empty hidden" id="gates-empty">No gate decisions yet.</p>
390
+ </section>
391
+ </main>
392
+
393
+ <footer>
394
+ <span>Token Counter MCP \xB7 live polling every 2s</span>
395
+ <span class="muted">Cost figures are approximate \u2014 see /docs/PROTOCOL.md</span>
396
+ </footer>
397
+
398
+ <script>
399
+ const $ = (sel) => document.querySelector(sel);
400
+ const cardsEl = $("#cards");
401
+ const projectsEl = $("#projects");
402
+ const turnsBody = $("#turns tbody");
403
+ const gatesBody = $("#gates tbody");
404
+ const turnsEmpty = $("#turns-empty");
405
+ const gatesEmpty = $("#gates-empty");
406
+ const projectsEmpty = $("#projects-empty");
407
+ const statusEl = $("#status");
408
+ const dotEl = $("#dot");
409
+ const activeProjectEl = $("#active-project");
410
+
411
+ function fmt(n) {
412
+ if (typeof n !== "number" || !Number.isFinite(n)) return "0";
413
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
414
+ if (n >= 1000) return (n / 1000).toFixed(1) + "K";
415
+ return n.toLocaleString();
416
+ }
417
+
418
+ function fmtFull(n) {
419
+ return (typeof n === "number" ? n : 0).toLocaleString();
420
+ }
421
+
422
+ function fmtCost(usd) {
423
+ if (typeof usd !== "number") return "~$0.00";
424
+ if (usd >= 1) return "~$" + usd.toFixed(2);
425
+ if (usd >= 0.01) return "~$" + usd.toFixed(3);
426
+ return "~$" + usd.toFixed(4);
427
+ }
428
+
429
+ function fmtTs(iso) {
430
+ try {
431
+ const d = new Date(iso);
432
+ const today = new Date();
433
+ const isToday = d.toDateString() === today.toDateString();
434
+ if (isToday) return d.toLocaleTimeString();
435
+ return d.toLocaleString();
436
+ } catch {
437
+ return iso;
438
+ }
439
+ }
440
+
441
+ function renderCards(g) {
442
+ cardsEl.innerHTML = "";
443
+ const cards = [
444
+ { label: "Total cost", value: fmtCost(g.estimated_cost_usd), accent: true },
445
+ { label: "Turns", value: fmt(g.total_turns) },
446
+ { label: "Input", value: fmt(g.total_input_tokens) },
447
+ { label: "Output", value: fmt(g.total_output_tokens) },
448
+ { label: "Cache read", value: fmt(g.total_cache_read) },
449
+ { label: "Cache write", value: fmt(g.total_cache_create) },
450
+ { label: "Projects", value: fmt(g.project_count) },
451
+ { label: "Blocked Grep / Glob", value: fmt(g.blocked_count), accent: true },
452
+ { label: "Tokens saved", value: fmt(g.estimated_tokens_saved), accent: true },
453
+ ];
454
+ for (const c of cards) {
455
+ const el = document.createElement("div");
456
+ el.className = "card" + (c.accent ? " accent" : "");
457
+ el.innerHTML = '<div class="card-label">' + c.label + '</div><div class="card-value">' + c.value + '</div>';
458
+ cardsEl.appendChild(el);
459
+ }
460
+ }
461
+
462
+ function renderProjects(projects, globalCost) {
463
+ projectsEl.innerHTML = "";
464
+ if (!projects.length) {
465
+ projectsEmpty.classList.remove("hidden");
466
+ return;
467
+ }
468
+ projectsEmpty.classList.add("hidden");
469
+ const maxTokens = Math.max(1, ...projects.map((p) => p.total_input_tokens + p.total_output_tokens));
470
+ for (const p of projects) {
471
+ const total = p.total_input_tokens + p.total_output_tokens;
472
+ const pct = Math.round((total / maxTokens) * 100);
473
+ const sharePct = globalCost > 0 ? ((p.estimated_cost_usd / globalCost) * 100).toFixed(1) : "0.0";
474
+ const row = document.createElement("div");
475
+ row.className = "project-row";
476
+ row.innerHTML =
477
+ '<div class="project-name">' +
478
+ '<strong>' + p.name + '</strong>' +
479
+ '<code class="project-path">' + p.path + '</code>' +
480
+ '</div>' +
481
+ '<div class="project-stats">' +
482
+ '<div class="stat"><span class="stat-value cost">' + fmtCost(p.estimated_cost_usd) + '</span><span class="stat-label">cost (' + sharePct + '%)</span></div>' +
483
+ '<div class="stat"><span class="stat-value">' + fmt(total) + '</span><span class="stat-label">tokens</span></div>' +
484
+ '<div class="stat"><span class="stat-value">' + fmt(p.total_turns) + '</span><span class="stat-label">turns</span></div>' +
485
+ '<div class="stat"><span class="stat-value">' + fmt(p.blocked_count) + '</span><span class="stat-label">blocks</span></div>' +
486
+ '</div>' +
487
+ '<div class="bar"><div class="bar-fill" style="width:' + pct + '%"></div></div>';
488
+ projectsEl.appendChild(row);
489
+ }
490
+ }
491
+
492
+ function renderTurns(turns) {
493
+ turnsBody.innerHTML = "";
494
+ if (!turns.length) {
495
+ turnsEmpty.classList.remove("hidden");
496
+ return;
497
+ }
498
+ turnsEmpty.classList.add("hidden");
499
+ for (const t of turns) {
500
+ const tr = document.createElement("tr");
501
+ const modelCell =
502
+ t.model && t.model !== "<synthetic>"
503
+ ? "<code>" + t.model + "</code>"
504
+ : '<span class="muted">' + (t.model === "<synthetic>" ? "synthetic" : "unknown") + "</span>";
505
+ tr.innerHTML =
506
+ "<td>" + fmtTs(t.ts) + "</td>" +
507
+ "<td><code>" + t.project_name + "</code></td>" +
508
+ "<td>" + modelCell + "</td>" +
509
+ '<td class="num">' + fmtFull(t.input) + "</td>" +
510
+ '<td class="num">' + fmtFull(t.output) + "</td>" +
511
+ '<td class="num">' + fmt(t.cache_read) + " / " + fmt(t.cache_create) + "</td>" +
512
+ '<td class="num cost">' + fmtCost(t.cost_usd) + "</td>";
513
+ turnsBody.appendChild(tr);
514
+ }
515
+ }
516
+
517
+ function renderGates(gates) {
518
+ gatesBody.innerHTML = "";
519
+ if (!gates.length) {
520
+ gatesEmpty.classList.remove("hidden");
521
+ return;
522
+ }
523
+ gatesEmpty.classList.add("hidden");
524
+ for (const g of gates) {
525
+ const tr = document.createElement("tr");
526
+ const cls = g.decision === "block" ? "decision-block" : "decision-allow";
527
+ tr.innerHTML =
528
+ "<td>" + fmtTs(g.ts) + "</td>" +
529
+ "<td><code>" + g.project_name + "</code></td>" +
530
+ "<td><code>" + g.tool + "</code></td>" +
531
+ '<td class="' + cls + '">' + g.decision + "</td>" +
532
+ "<td><code>" + (g.query || "") + "</code></td>";
533
+ gatesBody.appendChild(tr);
534
+ }
535
+ }
536
+
537
+ async function tick() {
538
+ try {
539
+ const res = await fetch("/data");
540
+ if (!res.ok) throw new Error("HTTP " + res.status);
541
+ const data = await res.json();
542
+ activeProjectEl.textContent = data.active.project_root;
543
+ renderCards(data.global);
544
+ renderProjects(data.projects, data.global.estimated_cost_usd);
545
+ renderTurns(data.recent_turns);
546
+ renderGates(data.recent_gates);
547
+ statusEl.textContent = "live \xB7 " + new Date().toLocaleTimeString();
548
+ dotEl.classList.add("live");
549
+ dotEl.classList.remove("dead");
550
+ } catch (e) {
551
+ statusEl.textContent = "disconnected \xB7 " + e.message;
552
+ dotEl.classList.add("dead");
553
+ dotEl.classList.remove("live");
554
+ }
555
+ }
556
+
557
+ tick();
558
+ setInterval(tick, 2000);
559
+ </script>
560
+ </body>
561
+ </html>
562
+ `;
563
+
564
+ // src/dashboard/public/style.css
565
+ var style_default = '/* Synthra token dashboard \u2014 palette per project brief. */\n\n:root {\n --color-heading: #ECEBD8;\n --color-body: #EDECD9;\n --color-bg: #000000;\n --color-surface: #140009;\n --color-surface-raised: #1E000D;\n --color-border: #4D0020;\n --color-accent: #FF0073;\n --color-accent-darker: #EB006A;\n --color-form-bg: #2E0014;\n --color-muted: rgba(237, 236, 217, 0.55);\n --color-very-muted: rgba(237, 236, 217, 0.35);\n --color-block: #FF0073;\n --color-allow: #ECEBD8;\n}\n\n* {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n}\n\nhtml, body {\n background: var(--color-bg);\n color: var(--color-body);\n font-family:\n ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",\n system-ui, sans-serif;\n font-size: 14px;\n line-height: 1.5;\n min-height: 100vh;\n}\n\ncode, .num, table, .project-path {\n font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;\n}\n\nheader {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n padding: 1.1rem 2rem;\n background: var(--color-surface);\n border-bottom: 1px solid var(--color-border);\n}\n\nheader .brand {\n display: flex;\n align-items: baseline;\n gap: 0.75rem;\n}\n\nheader h1 {\n color: var(--color-heading);\n font-size: 1.35rem;\n font-weight: 700;\n letter-spacing: 0.02em;\n}\n\nheader .tag {\n color: var(--color-muted);\n font-size: 0.78rem;\n text-transform: uppercase;\n letter-spacing: 0.1em;\n}\n\nheader .meta {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 0.75rem;\n font-size: 0.78rem;\n font-family: ui-monospace, monospace;\n color: var(--color-muted);\n}\n\nheader .active-project {\n max-width: 480px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\nmain {\n padding: 2rem;\n display: flex;\n flex-direction: column;\n gap: 2rem;\n max-width: 1400px;\n margin: 0 auto;\n}\n\nsection {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\nh2 {\n color: var(--color-heading);\n font-size: 0.85rem;\n font-weight: 600;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\nh2 .muted {\n color: var(--color-very-muted);\n font-size: 0.78rem;\n font-weight: 400;\n letter-spacing: 0.06em;\n text-transform: none;\n margin-left: 0.5rem;\n}\n\n/* Cards row */\n.cards {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));\n gap: 0.7rem;\n}\n\n.card {\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 6px;\n padding: 0.85rem 1rem;\n}\n\n.card.accent {\n background: var(--color-surface-raised);\n border-color: var(--color-accent);\n}\n\n.card-label {\n color: var(--color-muted);\n font-size: 0.66rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n margin-bottom: 0.35rem;\n}\n\n.card-value {\n color: var(--color-heading);\n font-family: ui-monospace, monospace;\n font-size: 1.5rem;\n font-weight: 600;\n}\n\n.card.accent .card-value {\n color: var(--color-accent);\n}\n\n/* Projects list */\n.projects {\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n}\n\n.project-row {\n display: grid;\n grid-template-columns: minmax(220px, 1fr) auto;\n grid-template-rows: auto auto;\n gap: 0.6rem 1.25rem;\n align-items: center;\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 6px;\n padding: 0.9rem 1.2rem;\n}\n\n.project-row:hover {\n background: var(--color-surface-raised);\n}\n\n.project-name {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n overflow: hidden;\n}\n\n.project-name strong {\n color: var(--color-heading);\n font-size: 0.95rem;\n font-weight: 600;\n}\n\n.project-name .project-path {\n color: var(--color-very-muted);\n font-size: 0.72rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.project-stats {\n display: flex;\n gap: 1.6rem;\n justify-self: end;\n text-align: right;\n}\n\n.stat {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n min-width: 70px;\n}\n\n.stat-value {\n color: var(--color-heading);\n font-family: ui-monospace, monospace;\n font-size: 0.95rem;\n font-weight: 600;\n}\n\n.stat-value.cost {\n color: var(--color-accent);\n}\n\n.stat-label {\n color: var(--color-muted);\n font-size: 0.66rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n}\n\n.bar {\n grid-column: 1 / -1;\n height: 3px;\n background: var(--color-form-bg);\n border-radius: 2px;\n overflow: hidden;\n}\n\n.bar-fill {\n height: 100%;\n background: linear-gradient(90deg, var(--color-accent-darker), var(--color-accent));\n border-radius: 2px;\n transition: width 400ms ease;\n}\n\n/* Tables */\ntable {\n width: 100%;\n border-collapse: collapse;\n font-size: 0.83rem;\n background: var(--color-surface);\n border: 1px solid var(--color-border);\n border-radius: 6px;\n overflow: hidden;\n}\n\ntable thead th {\n text-align: left;\n color: var(--color-muted);\n text-transform: uppercase;\n font-size: 0.66rem;\n letter-spacing: 0.08em;\n font-weight: 600;\n padding: 0.65rem 0.85rem;\n border-bottom: 1px solid var(--color-border);\n background: var(--color-surface);\n}\n\ntable thead th.num {\n text-align: right;\n}\n\ntable tbody td {\n padding: 0.55rem 0.85rem;\n border-bottom: 1px solid rgba(77, 0, 32, 0.4);\n color: var(--color-body);\n}\n\ntable tbody td.num {\n text-align: right;\n}\n\ntable tbody td.cost {\n color: var(--color-accent);\n font-weight: 600;\n}\n\ntable tbody tr:last-child td {\n border-bottom: none;\n}\n\ntable tbody tr:hover {\n background: var(--color-surface-raised);\n}\n\ncode {\n color: var(--color-heading);\n background: var(--color-form-bg);\n padding: 0.1rem 0.4rem;\n border-radius: 3px;\n font-size: 0.85em;\n}\n\n.decision-block {\n color: var(--color-block);\n font-weight: 700;\n text-transform: uppercase;\n font-size: 0.72rem;\n letter-spacing: 0.06em;\n}\n\n.decision-allow {\n color: var(--color-allow);\n opacity: 0.7;\n text-transform: uppercase;\n font-size: 0.72rem;\n letter-spacing: 0.06em;\n}\n\n.empty {\n color: var(--color-muted);\n font-style: italic;\n padding: 1rem;\n background: var(--color-surface);\n border: 1px dashed var(--color-border);\n border-radius: 6px;\n}\n\n.hidden {\n display: none;\n}\n\nfooter {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 0.85rem 2rem;\n background: var(--color-surface);\n border-top: 1px solid var(--color-border);\n color: var(--color-muted);\n font-size: 0.72rem;\n font-family: ui-monospace, monospace;\n}\n\nfooter .muted {\n color: var(--color-very-muted);\n}\n\n.dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--color-muted);\n transition: background 200ms ease, box-shadow 200ms ease;\n}\n\n.dot.live {\n background: var(--color-accent);\n box-shadow: 0 0 8px var(--color-accent-darker);\n}\n\n.dot.dead {\n background: var(--color-border);\n box-shadow: none;\n}\n';
566
+
567
+ // src/dashboard/server.ts
568
+ var FALLBACK_RANGE = 9;
569
+ async function startDashboard(paths, preferredPort = 8901) {
570
+ const port = await findFreePort(preferredPort, preferredPort + FALLBACK_RANGE);
571
+ if (port !== preferredPort) {
572
+ log.info(
573
+ `dashboard port ${preferredPort} was busy \u2014 bound to ${port} instead (likely another dashboard from a coexisting tool).`
574
+ );
575
+ }
576
+ const app = new Hono();
577
+ app.get("/", (c) => c.html(public_default));
578
+ app.get("/style.css", (c) => {
579
+ c.header("Content-Type", "text/css; charset=utf-8");
580
+ c.header("Cache-Control", "no-cache");
581
+ return c.body(style_default);
582
+ });
583
+ app.get("/health", (c) => c.json({ ok: true }));
584
+ app.get("/data", async (c) => {
585
+ const data = await computeDashboardData(paths);
586
+ return c.json(data);
587
+ });
588
+ const nodeServer = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" });
589
+ return {
590
+ port,
591
+ url: `http://127.0.0.1:${port}`,
592
+ async stop() {
593
+ await new Promise((resolve, reject) => {
594
+ nodeServer.close((err) => err ? reject(err) : resolve());
595
+ });
596
+ }
597
+ };
598
+ }
599
+ export {
600
+ startDashboard
601
+ };
602
+ //# sourceMappingURL=index.js.map