@krtclcdy/tersio 2.25.4 → 2.25.6

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 (77) hide show
  1. package/README.md +27 -63
  2. package/dashboard/dist/index.html +18 -16
  3. package/dist/cli/backups.js +187 -0
  4. package/dist/cli/banner.js +4 -3
  5. package/dist/cli/common.js +29 -13
  6. package/dist/cli/currency.js +1 -1
  7. package/dist/cli/dashboard.js +168 -110
  8. package/dist/cli/doctor-fix.js +232 -50
  9. package/dist/cli/doctor.js +281 -32
  10. package/dist/cli/hosts.js +29 -9
  11. package/dist/cli/install.js +204 -69
  12. package/dist/cli/interactive.js +3 -3
  13. package/dist/cli/manifest.js +125 -17
  14. package/dist/cli/profile.js +14 -9
  15. package/dist/cli/rtk-wiring.js +1 -1
  16. package/dist/cli/settings.js +20 -5
  17. package/dist/cli/uninstall.js +24 -8
  18. package/dist/cli/update.js +9 -10
  19. package/dist/cli/usage.js +75 -7
  20. package/dist/extensions/{caveman-session → caveman}/index.js +4 -2
  21. package/{extensions/caveman-session → dist/extensions/caveman}/rule-megacave.md +5 -3
  22. package/{extensions/caveman-session → dist/extensions/caveman}/rule-ultra.md +4 -4
  23. package/dist/extensions/caveman/rule.md +89 -0
  24. package/dist/extensions/{combo-toggle → combo}/index.js +62 -21
  25. package/dist/extensions/{tersio-commands → commands}/index.js +2 -2
  26. package/dist/extensions/pstack/index.js +119 -0
  27. package/dist/extensions/pstack/rule.md +65 -0
  28. package/dist/extensions/{rtk-filter/index.js → rtk/filter.js} +1 -2
  29. package/dist/extensions/{rtk-session/index.js → rtk/session.js} +5 -5
  30. package/dist/extensions/{opencode/server.js → server.js} +98 -14
  31. package/dist/extensions/shared/agent-stats.js +1198 -0
  32. package/dist/extensions/shared/brand-marks.js +153 -0
  33. package/dist/extensions/shared/carbon.js +1 -1
  34. package/dist/extensions/shared/plugin-settings.js +12 -7
  35. package/dist/extensions/shared/pricing.js +86 -30
  36. package/dist/extensions/shared/rtk-gain.js +4 -9
  37. package/dist/extensions/shared/session-state.js +15 -10
  38. package/dist/extensions/shared/sqlite.js +168 -0
  39. package/dist/extensions/shared/status.js +8 -8
  40. package/dist/extensions/shared/types.js +1 -1
  41. package/dist/extensions/shared/usage-ledger.js +113 -39
  42. package/dist/extensions/shared/usage-store.js +191 -48
  43. package/dist/extensions/{lib → shared}/utils.js +6 -6
  44. package/dist/extensions/timing/index.js +31 -0
  45. package/dist/extensions/{ai-addons-updater → updater}/index.js +13 -6
  46. package/dist/probe-catalog.js +48 -0
  47. package/dist/probe-catalog188.js +44 -0
  48. package/dist/tersio.js +9 -4
  49. package/extensions/{caveman-session → caveman}/index.ts +4 -2
  50. package/{dist/extensions/caveman-session → extensions/caveman}/rule-megacave.md +5 -3
  51. package/{dist/extensions/caveman-session → extensions/caveman}/rule-ultra.md +4 -4
  52. package/extensions/caveman/rule.md +89 -0
  53. package/extensions/{combo-toggle → combo}/index.ts +62 -19
  54. package/extensions/{tersio-commands → commands}/index.ts +2 -2
  55. package/extensions/pstack/index.ts +128 -0
  56. package/extensions/pstack/rule.md +65 -0
  57. package/extensions/{rtk-filter/index.ts → rtk/filter.ts} +1 -2
  58. package/extensions/{rtk-session/index.ts → rtk/session.ts} +5 -5
  59. package/extensions/{opencode/server.ts → server.ts} +81 -16
  60. package/extensions/shared/agent-stats.ts +1605 -0
  61. package/extensions/shared/brand-marks.ts +163 -0
  62. package/extensions/shared/carbon.ts +1 -1
  63. package/extensions/shared/plugin-settings.ts +13 -7
  64. package/extensions/shared/pricing.ts +95 -32
  65. package/extensions/shared/rtk-gain.ts +4 -9
  66. package/extensions/shared/session-state.ts +18 -13
  67. package/extensions/shared/sqlite.ts +168 -0
  68. package/extensions/shared/status.ts +9 -8
  69. package/extensions/shared/types.ts +2 -1
  70. package/extensions/shared/usage-ledger.ts +134 -46
  71. package/extensions/shared/usage-store.ts +202 -50
  72. package/extensions/{lib → shared}/utils.ts +6 -6
  73. package/extensions/timing/index.ts +45 -0
  74. package/extensions/{ai-addons-updater → updater}/index.ts +15 -9
  75. package/package.json +27 -18
  76. package/dist/extensions/caveman-session/rule.md +0 -79
  77. package/extensions/caveman-session/rule.md +0 -79
@@ -1,4 +1,4 @@
1
- // Serves the built Dashboard with local usage APIs. Binds 127.0.0.1 only; --export writes a file instead. No Promise.withResolvers: Node 20.12 lacks it.
1
+ // Dashboard server + usage APIs. Binds 127.0.0.1; --export writes a file. No Promise.withResolvers (Node 20.12).
2
2
  import { spawn } from 'node:child_process';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
@@ -6,17 +6,17 @@ import { promises as fs } from 'node:fs';
6
6
  import http from 'node:http';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { clearUsageLedger, markReset, readUsage } from '../extensions/shared/usage-ledger.js';
10
9
  import { pricesCachePath } from '../extensions/shared/pricing.js';
11
- import { summarizeUsage } from './usage.js';
12
- import { backupUsageDb, deleteUsageBackup, listUsageBackups, restoreUsageBackup, usageDbPath } from '../extensions/shared/usage-store.js';
13
10
  import { isCurrencyCode } from './currency.js';
14
11
  import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, } from './common.js';
15
12
  import { BACKUP_SCHEDULES, formatCliStatus, storedProfile, writePluginSettings } from './profile.js';
16
13
  import { normalizeComboLevel } from '../extensions/shared/session-state.js';
17
- import { CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS } from './common.js';
14
+ import { CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, PSTACK_DEFAULTS } from './common.js';
18
15
  import { PACKAGE_NAME } from './common.js';
19
- import { findPackageRoot, resolveRtkBinary } from '../extensions/lib/utils.js';
16
+ import { findPackageRoot, resolveRtkBinary, ruleBodyProblem } from '../extensions/shared/utils.js';
17
+ import { BACKUP_KEEP, backupDir, backupDiskBytes, createDataBackup, dataSources, deleteDataBackup, listDataBackups, restoreDataBackup } from './backups.js';
18
+ import { readBackupSchedule, syncUsageDb } from '../extensions/shared/usage-store.js';
19
+ import { agentDbPath, emptyRequestEntry, isAgentView, isRangeKey, readAgentRequestEntry, readAgentStats, readSessionTrace, statsDbPath } from '../extensions/shared/agent-stats.js';
20
20
  const DASHBOARD_DIST = path.join(findPackageRoot(path.dirname(fileURLToPath(import.meta.url))), 'dashboard', 'dist');
21
21
  const DASHBOARD_INDEX = path.join(DASHBOARD_DIST, 'index.html');
22
22
  const DASHBOARD_BRAND = path.join(DASHBOARD_DIST, 'brand.webp');
@@ -49,8 +49,64 @@ async function brandDataUri() {
49
49
  const brand = await fs.readFile(DASHBOARD_BRAND);
50
50
  return `data:image/webp;base64,${brand.toString('base64')}`;
51
51
  }
52
- function dataJson() {
53
- return JSON.stringify(summarizeUsage(readUsage()));
52
+ /** The range a page opens on, matching the reference dashboard. */
53
+ const DEFAULT_AGENT_RANGE = '24h';
54
+ /** Poll memo TTL: every poll re-reads the aggregate, so a repeat poll reuses the last answer. */
55
+ const AGENT_REFRESH_MS = 5000;
56
+ /** A sync walks every transcript, so it runs at most this often even while a poll outruns it. */
57
+ const AGENT_SYNC_MS = 15_000;
58
+ let agentMemo = null;
59
+ let lastSyncAt = 0;
60
+ function fileStamp(file) {
61
+ try {
62
+ const stat = statSync(file);
63
+ return `${stat.mtimeMs}:${stat.size}`;
64
+ }
65
+ catch {
66
+ return '-';
67
+ }
68
+ }
69
+ /** Both sources: the usage store carries every host's rows, agent.db only omp's quota snapshots. */
70
+ function storeStamp() {
71
+ return `${fileStamp(statsDbPath())}|${fileStamp(agentDbPath())}`;
72
+ }
73
+ /** Syncs before reading past the throttle; without it a page misses pi and OpenCode rows. */
74
+ function agentStats(range, view = 'all', offset = 0) {
75
+ const held = agentMemo;
76
+ if (held && held.range === range && held.view === view && held.offset === offset && held.stamp === storeStamp() && Date.now() - held.at < AGENT_REFRESH_MS) {
77
+ return held.value;
78
+ }
79
+ if (Date.now() - lastSyncAt >= AGENT_SYNC_MS) {
80
+ lastSyncAt = Date.now();
81
+ try {
82
+ syncUsageDb();
83
+ }
84
+ catch { /* a store that will not write still reads the rows it already holds */ }
85
+ }
86
+ const value = readAgentStats(range, view, Date.now(), offset);
87
+ agentMemo = { range, view, offset, stamp: storeStamp(), at: Date.now(), value };
88
+ return value;
89
+ }
90
+ /** `?range=` on any of the omp routes; an unknown value falls back to the default. */
91
+ function queryParam(url, name) {
92
+ return (url.split('?')[1] ?? '').split('&').find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1) ?? null;
93
+ }
94
+ function rangeFrom(url) {
95
+ const raw = queryParam(url, 'range');
96
+ return isRangeKey(raw) ? raw : DEFAULT_AGENT_RANGE;
97
+ }
98
+ function viewFrom(url) {
99
+ const raw = queryParam(url, 'view');
100
+ return isAgentView(raw) ? raw : 'all';
101
+ }
102
+ /** `?offset=` in whole rows, floored; a later page starts further back instead of clamping at the cap. */
103
+ function offsetFrom(url) {
104
+ const raw = queryParam(url, 'offset');
105
+ const value = raw === null ? 0 : Number(raw);
106
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
107
+ }
108
+ function agentJson(range, view, offset) {
109
+ return JSON.stringify({ range, view, offset, agent: agentStats(range, view, offset) });
54
110
  }
55
111
  // Runs outside any agent session, so it reports the persisted defaults.
56
112
  async function statusJson() {
@@ -61,6 +117,7 @@ async function statusJson() {
61
117
  caveman: profile.cavemanDefault,
62
118
  rtk: profile.rtkDefault ? 'on' : 'off',
63
119
  ponytail: profile.ponytailDefault,
120
+ pstack: profile.pstackDefault,
64
121
  });
65
122
  }
66
123
  // Local-only health: find each agent on PATH, run `--version`, report both.
@@ -245,18 +302,29 @@ function computeDoctorRows() {
245
302
  }
246
303
  catch { /* missing config is reported by extension rows */ }
247
304
  const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
248
- addon('Unique config registrations', true, 'ok', 'Extensions');
249
- // A listed extension whose file is gone makes the host warn on every load, so surface it rather than leaving the user with only a startup message.
305
+ addon('Unique config registrations', duplicateExtensions.length === 0, duplicateExtensions.length ? duplicateExtensions.join(', ') : 'ok', 'Extensions');
306
+ // A listed extension whose file is gone makes the host warn on every load, so surface it here.
250
307
  const dangling = explicitEntries
251
308
  .filter((entry) => entry.includes(`extensions${path.sep}`) || entry.includes('extensions/'))
252
309
  .filter((entry) => !ok(path.isAbsolute(entry) ? entry : path.resolve(OMP_AGENT_DIR, entry)));
253
310
  addon('Extension files present', dangling.length === 0, dangling.length ? dangling.join(', ') : 'ok', 'Extensions');
254
- ext('Caveman extension', path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts'));
255
- ext('RTK extension', path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts'));
256
- const rule = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'rule.md');
311
+ ext('Caveman extension', path.join(tersioPluginDir, 'extensions', 'caveman', 'index.ts'));
312
+ ext('pstack extension', path.join(tersioPluginDir, 'extensions', 'pstack', 'index.ts'));
313
+ ext('RTK extension', path.join(tersioPluginDir, 'extensions', 'rtk', 'session.ts'));
314
+ const rule = path.join(tersioPluginDir, 'extensions', 'caveman', 'rule.md');
257
315
  const ruleAge = ageStr(rule);
258
316
  const ruleAt = absTime(rule);
259
317
  addon('Caveman rule', ruleAge !== null, ruleAge && ruleAt ? `(updated ${ruleAge} · ${ruleAt})` : rule);
318
+ // The pstack body is a Tersio fork with no remote, so this row proves it is usable, not current.
319
+ const pstackRule = path.join(tersioPluginDir, 'extensions', 'pstack', 'rule.md');
320
+ let pstackProblem = 'missing — run: tersio install';
321
+ try {
322
+ pstackProblem = ruleBodyProblem(readFileSync(pstackRule, 'utf8'));
323
+ }
324
+ catch { /* missing stays */ }
325
+ const pstackAge = ageStr(pstackRule);
326
+ const pstackAt = absTime(pstackRule);
327
+ addon('pstack rule', pstackProblem === null, pstackProblem ?? `(updated ${pstackAge} · ${pstackAt})`);
260
328
  const rtkVer = rtkBin ? rtkVersion(rtkBin) : null;
261
329
  const rtkAge = rtkBin ? ageStr(rtkBin) : null;
262
330
  const rtkAt = rtkBin ? absTime(rtkBin) : null;
@@ -277,7 +345,7 @@ function computeDoctorRows() {
277
345
  addon('Prices feed', pricesAge !== null, pricesAge && pricesAt ? `(pulled ${pricesAge} · ${pricesAt})` : prices, 'Usage');
278
346
  return rows;
279
347
  }
280
- // Recompute when forced, missing, or past the schedule; a report holding a retired label is stale by definition.
348
+ // A report holding any of these labels is stale by definition.
281
349
  const RETIRED_DIAG_LABELS = new Set([
282
350
  'OMP CLI',
283
351
  'Tersio CLI',
@@ -298,11 +366,10 @@ const RETIRED_DIAG_LABELS = new Set([
298
366
  function isRetiredReport(report) {
299
367
  return report.rows.some((r) => RETIRED_DIAG_LABELS.has(r.label));
300
368
  }
301
- // Recompute when forced, missing, retired, or past the schedule.
369
+ // Recompute when forced, missing, retired, or past schedule; manual schedules recompute on open, dated ones reuse.
302
370
  function getDoctorReport(force) {
303
371
  const saved = readDiagReport();
304
372
  const ttl = saved ? DIAG_TTL_MS[saved.schedule] : 0;
305
- // Manual recomputes on open; a dated schedule reuses the saved report.
306
373
  if (!force && saved && !isRetiredReport(saved) && ttl > 0 && Date.now() - saved.checkedAt < ttl)
307
374
  return saved;
308
375
  const report = { rows: computeDoctorRows(), checkedAt: Date.now(), schedule: saved?.schedule ?? 'manual' };
@@ -336,6 +403,12 @@ async function saveDefaults(body) {
336
403
  return { ok: false, error: 'unknown ponytail level' };
337
404
  profile.ponytailDefault = v;
338
405
  }
406
+ if (body.pstackDefault !== undefined) {
407
+ const v = String(body.pstackDefault);
408
+ if (!PSTACK_DEFAULTS.has(v))
409
+ return { ok: false, error: 'unknown pstack level' };
410
+ profile.pstackDefault = v;
411
+ }
339
412
  if (body.backupSchedule !== undefined) {
340
413
  const v = String(body.backupSchedule);
341
414
  if (!BACKUP_SCHEDULES.has(v))
@@ -355,6 +428,7 @@ async function saveDefaults(body) {
355
428
  cavemanDefault: profile.cavemanDefault,
356
429
  rtkDefault: profile.rtkDefault,
357
430
  ponytailDefault: profile.ponytailDefault,
431
+ pstackDefault: profile.pstackDefault,
358
432
  backupSchedule: profile.backupSchedule,
359
433
  },
360
434
  };
@@ -374,50 +448,6 @@ async function saveDashboardCurrency(raw) {
374
448
  await writePluginSettings(profile, {});
375
449
  return code;
376
450
  }
377
- export const EXPORT_FORMATS = ['json', 'jsonl', 'csv'];
378
- export function exportRows(report) {
379
- return report.recent.map((r) => ({
380
- timestamp: new Date(r.t).toISOString(),
381
- local: new Date(r.t).toLocaleString(),
382
- agent: r.h ?? 'pi',
383
- model: r.m,
384
- input: r.i,
385
- output: r.o,
386
- cacheRead: r.cr ?? 0,
387
- cacheWrite: r.cw ?? 0,
388
- costUsd: r.usd,
389
- elapsedMs: r.d,
390
- status: r.st,
391
- }));
392
- }
393
- export function csvCell(v) {
394
- const s = v === undefined || v === null ? '' : String(v);
395
- return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
396
- }
397
- export function exportBody(format, report) {
398
- const stamp = new Date().toISOString().slice(0, 10);
399
- if (format === 'json') {
400
- return { body: JSON.stringify({ exportedAt: new Date().toISOString(), version: report.version, source: report.source, report }, null, 2), type: 'application/json' };
401
- }
402
- const rows = exportRows(report);
403
- if (format === 'jsonl') {
404
- return { body: rows.map((r) => JSON.stringify(r)).join('\n') + '\n', type: 'application/x-ndjson' };
405
- }
406
- const cols = Object.keys(rows[0] ?? { timestamp: '', agent: '' });
407
- const head = cols.join(',');
408
- const body = [head, ...rows.map((r) => cols.map((c) => csvCell(r[c])).join(','))].join('\n') + '\n';
409
- return { body, type: 'text/csv; charset=utf-8' };
410
- }
411
- function serveExport(req, res) {
412
- const format = ((req.url || '').split('?')[1] || '').match(/format=([a-z]+)/)?.[1];
413
- const fmt = format && EXPORT_FORMATS.includes(format) ? format : 'json';
414
- const { body, type } = exportBody(fmt, summarizeUsage(readUsage()));
415
- res.writeHead(200, {
416
- 'Content-Type': type,
417
- 'Content-Disposition': `attachment; filename="tersio-usage-${new Date().toISOString().slice(0, 10)}.${fmt === 'jsonl' ? 'ndjson' : fmt}"`,
418
- });
419
- res.end(body);
420
- }
421
451
  function readBody(req) {
422
452
  return new Promise((resolve) => {
423
453
  let body = '';
@@ -430,7 +460,7 @@ function openBrowser(url) {
430
460
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
431
461
  spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
432
462
  }
433
- // Replacers throughout: session data holds `$'` (shell quoting) and literal `</script>`, which String.replace would mangle.
463
+ // Replacers throughout: session data holds `$'` (shell quoting) and `</script>`, which String.replace would mangle.
434
464
  function escapeInline(json) {
435
465
  return json.replace(/<\/(script)/gi, '<\\/$1');
436
466
  }
@@ -448,73 +478,78 @@ function parseOwnJson(json, field) {
448
478
  }
449
479
  return parsed;
450
480
  }
451
- async function exportDashboard(exportFile) {
481
+ async function exportDashboard(exportFile, currency) {
452
482
  requireDashboardBundle();
453
483
  const [bundle, icon] = await Promise.all([readSegment(DASHBOARD_INDEX), brandDataUri()]);
484
+ // Minified: fetch reads `fetch(\`brand.webp\`)` or `fetch("brand.webp")`; a mismatch loses the card's mark.
485
+ const b64 = icon.split(',')[1] ?? '';
486
+ // atob yields one byte per char, so a Uint8Array is needed or bytes >= 0x80 double and corrupt the webp.
487
+ const brandBytes = `(() => { const c = atob(${JSON.stringify(b64)}); const v = new Uint8Array(c.length); for (let i = 0; i < c.length; i++) v[i] = c.charCodeAt(i); return v; })()`;
454
488
  const inline = bundle
455
- .replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ data: parseOwnJson(dataJson(), 'usage data'), health: parseOwnJson(healthJson(), 'health'), doctor: getDoctorReport(false) }))};`)
456
- .replace(/href="brand\.webp"/g, () => `href="${icon}"`)
457
- .replace(/src="brand\.webp"/g, () => `src="${icon}"`)
458
- .replace('fetch("brand.webp")', () => `Promise.resolve({ ok: true, blob: async () => new Blob([atob("${icon.split(',')[1]}")], { type: "image/webp" }) })`);
489
+ .replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ currency, agent: agentStats(DEFAULT_AGENT_RANGE), health: parseOwnJson(healthJson(), 'health'), doctor: getDoctorReport(false) }))};`)
490
+ // A mark is an HTML attribute or JSX prop; match every quote or the file keeps an unresolvable reference.
491
+ .replace(/((?:src|href)[=:][ \t]*)([`"'])brand\.webp\2/g, (_all, head, quote) => `${head}${quote}${icon}${quote}`)
492
+ .replace(/fetch\(([`"])brand\.webp\1\)/g, () => `Promise.resolve({ ok: true, blob: async () => new Blob([${brandBytes}], { type: "image/webp" }) })`);
459
493
  await fs.writeFile(exportFile, inline, 'utf8');
460
494
  console.log(`[ok] Dashboard exported → ${exportFile}`);
461
495
  }
462
496
  async function runDashboard(options) {
463
497
  if (options.exportFile) {
464
- await exportDashboard(options.exportFile);
498
+ await exportDashboard(options.exportFile, options.currency);
465
499
  return;
466
500
  }
467
501
  requireDashboardBundle();
468
502
  const server = http.createServer(async (req, res) => {
469
- if (req.url === '/reset' && req.method === 'POST') {
470
- const rows = clearUsageLedger();
471
- markReset();
472
- res.writeHead(200, { 'Content-Type': 'application/json' });
473
- res.end(JSON.stringify({ ok: true, rows }));
474
- return;
475
- }
476
- if (req.url === '/backups' && req.method === 'GET') {
477
- const rows = listUsageBackups().map((b) => ({ file: path.basename(b.file), mtime: b.mtime, size: b.size }));
503
+ if (req.url === '/api/data' && req.method === 'GET') {
478
504
  res.writeHead(200, { 'Content-Type': 'application/json' });
479
- res.end(JSON.stringify({ backups: rows }));
505
+ res.end(JSON.stringify({
506
+ dir: backupDir(),
507
+ keep: BACKUP_KEEP,
508
+ sources: dataSources(),
509
+ backups: listDataBackups(),
510
+ schedule: readBackupSchedule(),
511
+ bytes: backupDiskBytes(),
512
+ }));
480
513
  return;
481
514
  }
482
- if (req.url === '/backups/create' && req.method === 'POST') {
483
- try {
484
- backupUsageDb(usageDbPath());
485
- res.writeHead(200, { 'Content-Type': 'application/json' });
486
- res.end(JSON.stringify({ ok: true }));
487
- }
488
- catch {
489
- res.writeHead(500, { 'Content-Type': 'application/json' });
490
- res.end(JSON.stringify({ ok: false, error: 'backup failed' }));
491
- }
492
- return;
493
- }
494
- if (req.url === '/backups/delete' && req.method === 'POST') {
495
- let file = '';
515
+ if (req.url === '/api/data' && req.method === 'POST') {
516
+ let body = {};
496
517
  try {
497
- file = String(JSON.parse(await readBody(req)).file ?? '');
518
+ body = JSON.parse(await readBody(req)) ?? {};
498
519
  }
499
520
  catch {
500
- file = '';
521
+ body = {};
501
522
  }
502
- const ok = deleteUsageBackup(file);
503
- res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
504
- res.end(JSON.stringify(ok ? { ok: true, file } : { ok: false, error: 'unknown backup' }));
505
- return;
506
- }
507
- if (req.url === '/backups/restore' && req.method === 'POST') {
508
- let file = '';
523
+ const action = String(body.action ?? '');
524
+ const name = String(body.name ?? '');
525
+ // A missing SQLite engine, or a locked file, is a 500 with a message rather than a silent no-op.
509
526
  try {
510
- file = String(JSON.parse(await readBody(req)).file ?? '');
527
+ if (action === 'create') {
528
+ const made = await createDataBackup();
529
+ res.writeHead(200, { 'Content-Type': 'application/json' });
530
+ res.end(JSON.stringify({ ok: true, name: made.name, files: made.files.length }));
531
+ return;
532
+ }
533
+ if (action === 'restore') {
534
+ const ok = await restoreDataBackup(name);
535
+ res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
536
+ res.end(JSON.stringify(ok ? { ok: true, name } : { ok: false, error: 'unknown snapshot' }));
537
+ return;
538
+ }
539
+ if (action === 'delete') {
540
+ const ok = await deleteDataBackup(name);
541
+ res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
542
+ res.end(JSON.stringify(ok ? { ok: true, name } : { ok: false, error: 'unknown snapshot' }));
543
+ return;
544
+ }
511
545
  }
512
- catch {
513
- file = '';
546
+ catch (error) {
547
+ res.writeHead(500, { 'Content-Type': 'application/json' });
548
+ res.end(JSON.stringify({ ok: false, error: error.message || 'backup failed' }));
549
+ return;
514
550
  }
515
- const ok = restoreUsageBackup(file);
516
- res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
517
- res.end(JSON.stringify(ok ? { ok: true, file } : { ok: false, error: 'unknown backup' }));
551
+ res.writeHead(400, { 'Content-Type': 'application/json' });
552
+ res.end(JSON.stringify({ ok: false, error: 'unknown action' }));
518
553
  return;
519
554
  }
520
555
  if (req.url === '/settings' && req.method === 'GET') {
@@ -525,7 +560,9 @@ async function runDashboard(options) {
525
560
  cavemanDefault: profile.cavemanDefault,
526
561
  rtkDefault: profile.rtkDefault,
527
562
  ponytailDefault: profile.ponytailDefault,
563
+ pstackDefault: profile.pstackDefault,
528
564
  backupSchedule: profile.backupSchedule,
565
+ currency: options.currency,
529
566
  }));
530
567
  });
531
568
  return;
@@ -568,13 +605,34 @@ async function runDashboard(options) {
568
605
  }
569
606
  return;
570
607
  }
571
- if (req.url === '/data.json') {
608
+ if (req.url === '/api/agent' || req.url?.startsWith('/api/agent?')) {
609
+ res.writeHead(200, { 'Content-Type': 'application/json' });
610
+ res.end(agentJson(rangeFrom(req.url), viewFrom(req.url), offsetFrom(req.url)));
611
+ return;
612
+ }
613
+ if (req.url?.startsWith('/api/agent/session')) {
614
+ const file = decodeURIComponent(queryParam(req.url, 'file') ?? '');
615
+ const body = file ? readSessionTrace(file) : {
616
+ sessionFile: '', project: '', title: '', cwd: '', startedAt: 0, endedAt: 0,
617
+ tracks: [], markers: [], summary: {
618
+ wallMs: 0, modelMs: 0, toolMs: 0, idleMs: 0, turns: 0, requests: 0, toolCalls: 0, agents: 0,
619
+ totalTokens: 0, costUsd: 0,
620
+ },
621
+ toolStats: [], truncated: false,
622
+ };
623
+ res.writeHead(200, { 'Content-Type': 'application/json' });
624
+ res.end(JSON.stringify(body));
625
+ return;
626
+ }
627
+ if (req.url?.startsWith('/api/agent/entry')) {
628
+ const file = decodeURIComponent(queryParam(req.url, 'file') ?? '');
629
+ const entry = decodeURIComponent(queryParam(req.url, 'entry') ?? '');
630
+ const body = file && entry ? readAgentRequestEntry(file, entry) : emptyRequestEntry();
572
631
  res.writeHead(200, { 'Content-Type': 'application/json' });
573
- res.end(dataJson());
632
+ res.end(JSON.stringify(body));
574
633
  return;
575
634
  }
576
635
  if (req.url?.startsWith('/export')) {
577
- serveExport(req, res);
578
636
  return;
579
637
  }
580
638
  if (req.url === '/health') {
@@ -664,4 +722,4 @@ async function runDashboard(options) {
664
722
  });
665
723
  });
666
724
  }
667
- export { runDashboard, saveDashboardCurrency, readDiagSchedule, setDiagSchedule };
725
+ export { runDashboard, saveDashboardCurrency, readDiagSchedule, setDiagSchedule, offsetFrom };