@krtclcdy/tersio 2.25.5 → 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 (73) hide show
  1. package/README.md +17 -36
  2. package/dashboard/dist/index.html +15 -15
  3. package/dist/cli/backups.js +7 -14
  4. package/dist/cli/banner.js +4 -3
  5. package/dist/cli/common.js +28 -12
  6. package/dist/cli/currency.js +1 -1
  7. package/dist/cli/dashboard.js +66 -35
  8. package/dist/cli/doctor-fix.js +204 -19
  9. package/dist/cli/doctor.js +226 -39
  10. package/dist/cli/hosts.js +25 -5
  11. package/dist/cli/install.js +144 -48
  12. package/dist/cli/interactive.js +3 -3
  13. package/dist/cli/manifest.js +87 -7
  14. package/dist/cli/profile.js +13 -8
  15. package/dist/cli/rtk-wiring.js +1 -1
  16. package/dist/cli/settings.js +20 -5
  17. package/dist/cli/uninstall.js +3 -3
  18. package/dist/cli/update.js +6 -7
  19. package/dist/cli/usage.js +6 -9
  20. package/dist/extensions/caveman/index.js +4 -2
  21. package/dist/extensions/caveman/rule-megacave.md +5 -3
  22. package/dist/extensions/caveman/rule-ultra.md +4 -4
  23. package/dist/extensions/caveman/rule.md +55 -45
  24. package/dist/extensions/combo/index.js +62 -21
  25. package/dist/extensions/commands/index.js +1 -1
  26. package/dist/extensions/pstack/index.js +119 -0
  27. package/dist/extensions/pstack/rule.md +65 -0
  28. package/dist/extensions/rtk/filter.js +0 -1
  29. package/dist/extensions/rtk/session.js +4 -4
  30. package/dist/extensions/server.js +90 -6
  31. package/dist/extensions/shared/agent-stats.js +214 -278
  32. package/dist/extensions/shared/brand-marks.js +33 -34
  33. package/dist/extensions/shared/carbon.js +1 -1
  34. package/dist/extensions/shared/plugin-settings.js +11 -6
  35. package/dist/extensions/shared/pricing.js +8 -16
  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 +53 -41
  42. package/dist/extensions/shared/usage-store.js +111 -52
  43. package/dist/extensions/shared/utils.js +5 -5
  44. package/dist/extensions/timing/index.js +31 -0
  45. package/dist/extensions/updater/index.js +3 -6
  46. package/dist/tersio.js +8 -3
  47. package/extensions/caveman/index.ts +4 -2
  48. package/extensions/caveman/rule-megacave.md +5 -3
  49. package/extensions/caveman/rule-ultra.md +4 -4
  50. package/extensions/caveman/rule.md +55 -45
  51. package/extensions/combo/index.ts +62 -19
  52. package/extensions/commands/index.ts +1 -1
  53. package/extensions/pstack/index.ts +128 -0
  54. package/extensions/pstack/rule.md +65 -0
  55. package/extensions/rtk/filter.ts +0 -1
  56. package/extensions/rtk/session.ts +4 -4
  57. package/extensions/server.ts +72 -7
  58. package/extensions/shared/agent-stats.ts +226 -278
  59. package/extensions/shared/brand-marks.ts +34 -34
  60. package/extensions/shared/carbon.ts +1 -1
  61. package/extensions/shared/plugin-settings.ts +12 -6
  62. package/extensions/shared/pricing.ts +10 -26
  63. package/extensions/shared/rtk-gain.ts +4 -9
  64. package/extensions/shared/session-state.ts +18 -13
  65. package/extensions/shared/sqlite.ts +168 -0
  66. package/extensions/shared/status.ts +9 -8
  67. package/extensions/shared/types.ts +2 -1
  68. package/extensions/shared/usage-ledger.ts +60 -47
  69. package/extensions/shared/usage-store.ts +120 -52
  70. package/extensions/shared/utils.ts +5 -5
  71. package/extensions/timing/index.ts +45 -0
  72. package/extensions/updater/index.ts +3 -8
  73. package/package.json +17 -8
@@ -1,25 +1,19 @@
1
- // Data snapshots: what tersio keeps, where the copies live, and how one is put back.
2
- //
3
- // A snapshot is a directory with a manifest, not a single file, so one copy holds the whole set.
4
- // Older installs wrote a lone `usage-<stamp>.db`; those still list and restore, over the ledger alone.
1
+ // Data snapshots: a manifest directory, not one file; older lone `usage-<stamp>.db` installs still restore.
5
2
  import { promises as fs } from 'node:fs';
6
3
  import { readdirSync, readFileSync, statSync } from 'node:fs';
7
- import { execFileSync } from 'node:child_process';
8
4
  import path from 'node:path';
5
+ import { sqliteExec } from '../extensions/shared/sqlite.js';
9
6
  import { agentDbPath, statsDbPath } from '../extensions/shared/agent-stats.js';
10
7
  import { rtkDbPath } from '../extensions/shared/rtk-gain.js';
11
8
  import { pricesCachePath } from '../extensions/shared/pricing.js';
12
9
  import { usageDbPath } from '../extensions/shared/usage-store.js';
13
10
  import { resetMarkerPath } from '../extensions/shared/usage-ledger.js';
14
11
  import { tersioHome } from '../extensions/shared/utils.js';
15
- /** Snapshots kept on disk; the oldest is pruned first. Six keeps the list past one screen, so
16
- * the pane scrolls instead of showing the whole history at once. */
12
+ /** Snapshots kept on disk, oldest pruned first; six fills past one screen so the pane scrolls. */
17
13
  export const BACKUP_KEEP = 6;
18
- /** Where a snapshot's files live. */
19
14
  export function backupDir() {
20
15
  return path.join(tersioHome(), 'backups');
21
16
  }
22
- /** A snapshot directory's manifest, read back out with its files. */
23
17
  function manifestPath(dir) {
24
18
  return path.join(dir, 'manifest.json');
25
19
  }
@@ -90,7 +84,6 @@ export function listDataBackups() {
90
84
  }
91
85
  return [...dirs, ...legacy].toSorted((a, b) => b.mtime - a.mtime);
92
86
  }
93
- /** The files a snapshot directory holds, read from its manifest. */
94
87
  function readManifest(dir) {
95
88
  try {
96
89
  const raw = JSON.parse(readFileSync(manifestPath(dir), 'utf8'));
@@ -120,13 +113,13 @@ function totalOf(dir) {
120
113
  /** Put the sqlite ledger's write-ahead log back in the main file, so a copy is one whole database. */
121
114
  function checkpointLedger() {
122
115
  try {
123
- execFileSync('sqlite3', [usageDbPath(), 'PRAGMA wal_checkpoint(TRUNCATE);'], { stdio: 'ignore', timeout: 10_000 });
116
+ sqliteExec(usageDbPath(), 'PRAGMA wal_checkpoint(TRUNCATE);');
124
117
  }
125
118
  catch {
126
- /* no sqlite3, or no ledger: the plain file copy is the best available */
119
+ /* no SQLite on this host, or no ledger: the plain file copy is the best available */
127
120
  }
128
121
  }
129
- /** Take a snapshot now: one copy of every file that exists, under a stamp directory, then keep only the newest {@link BACKUP_KEEP}. */
122
+ /** Take a snapshot: one copy of every existing file under a stamp dir, keeping the newest {@link BACKUP_KEEP}. */
130
123
  export async function createDataBackup() {
131
124
  // The stamp keeps milliseconds: two snapshots in the same second must not land in one directory.
132
125
  const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '');
@@ -159,7 +152,7 @@ function resolveBackup(name) {
159
152
  const found = listDataBackups().find((entry) => entry.name === name);
160
153
  return found ?? null;
161
154
  }
162
- /** Put a snapshot back: every file it holds is copied over its live source. A file it lacks is left alone, so a restore never deletes what it does not carry. */
155
+ /** Restore: files the snapshot holds overwrite their live source; files it lacks stay put, so nothing is lost. */
163
156
  export async function restoreDataBackup(name) {
164
157
  const found = resolveBackup(name);
165
158
  if (!found)
@@ -1,4 +1,4 @@
1
- // cli/banner.ts — mini pixel-scissor welcome. TTY-only and skipped on --dry-run, so piped output stays byte-identical.
1
+ // cli/banner.ts — mini pixel-scissor welcome. TTY-only, skipped on --dry-run; piped output stays byte-identical.
2
2
  import { intro as clackIntro, note as clackNote } from '@clack/prompts';
3
3
  import { PACKAGE_VERSION, dryRun } from './common.js';
4
4
  function tty() {
@@ -81,9 +81,10 @@ function printWelcome() {
81
81
  'tersio update refresh CLI + add-ons',
82
82
  'tersio doctor verify the install',
83
83
  ];
84
- // Below 26 rows the note scrolls off the top and slices the art.
84
+ // Below 26 rows the note scrolls off the top and slices the art, and the screen must also hold the
85
+ // update prompt and every option, so the tips take one line there.
85
86
  if (process.stdout.rows < 26) {
86
- clackNote(tips.map((t) => ` ${t}`).join('\n'));
87
+ clackNote(` ${tips.join(' · ')}`);
87
88
  return;
88
89
  }
89
90
  const art = bannerLines();
@@ -19,14 +19,15 @@ const PACKAGE_BIN = 'tersio';
19
19
  const { version: PACKAGE_VERSION } = createRequire(import.meta.url)(path.join(findPackageRoot(path.dirname(fileURLToPath(import.meta.url))), 'package.json'));
20
20
  const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'megacave-lite', 'megacave-full', 'megacave-ultra']);
21
21
  const PONYTAIL_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra']);
22
+ const PSTACK_DEFAULTS = new Set(['off', 'on']);
22
23
  const RTK_DEFAULTS = new Set(['on', 'off']);
23
24
  const DIAG_SCHEDULES = new Set(['manual', 'daily', 'weekly', 'monthly']);
24
- // Mirrors COMBO_LEVELS in extensions/shared/session-state.ts (rtk as a boolean here), so the combo preset implies all three modes.
25
+ // Mirrors COMBO_LEVELS in extensions/shared/session-state.ts, so the combo preset implies all four modes.
25
26
  const COMBO_PRESET_MODES = {
26
- off: { caveman: 'off', rtk: false, ponytail: 'off' },
27
- medium: { caveman: 'lite', rtk: true, ponytail: 'lite' },
28
- balanced: { caveman: 'full', rtk: true, ponytail: 'full' },
29
- max: { caveman: 'ultra', rtk: true, ponytail: 'ultra' },
27
+ off: { caveman: 'off', rtk: false, ponytail: 'off', pstack: 'off' },
28
+ medium: { caveman: 'lite', rtk: true, ponytail: 'lite', pstack: 'on' },
29
+ balanced: { caveman: 'full', rtk: true, ponytail: 'full', pstack: 'on' },
30
+ max: { caveman: 'ultra', rtk: true, ponytail: 'ultra', pstack: 'on' },
30
31
  };
31
32
  const COMBO_DEFAULTS = new Set(Object.keys(COMBO_PRESET_MODES));
32
33
  function parseEnum(value, valid, flag) {
@@ -46,12 +47,11 @@ function flagValue(name) {
46
47
  const prefix = `${name}=`;
47
48
  return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
48
49
  }
49
- // --- CLI flags ---
50
- const COMMANDS = { install: true, update: true, doctor: true, uninstall: true, usage: true, dashboard: true, reset: true, settings: true, version: true, help: true };
50
+ const COMMANDS = { install: true, reinstall: true, update: true, doctor: true, uninstall: true, usage: true, dashboard: true, reset: true, settings: true, version: true, help: true };
51
51
  const args = process.argv.slice(2);
52
52
  const commandArg = args.find((arg) => !arg.startsWith('-'));
53
53
  const command = commandArg?.toLowerCase() || null;
54
- // `tersio settings diagnosis` jumps to one prompt instead of walking the chain. Index-based, so flag values never count as positionals.
54
+ // `tersio settings diagnosis` jumps to one prompt. Index-based, so flag values never count as positionals.
55
55
  const settingArg = (() => {
56
56
  const at = args.indexOf(commandArg ?? '');
57
57
  const next = at >= 0 ? args[at + 1] : undefined;
@@ -61,6 +61,7 @@ const settingArg = (() => {
61
61
  })();
62
62
  const unknownCommand = command !== null && !COMMANDS[command];
63
63
  const install = command === 'install';
64
+ const reinstall = command === 'reinstall';
64
65
  const update = command === 'update';
65
66
  const showVersion = command === 'version' || args.includes('--version') || args.includes('-v');
66
67
  const showHelp = command === 'help' || args.includes('--help') || args.includes('-h');
@@ -96,9 +97,10 @@ if (legacyScope !== null && legacyScope !== 'user') {
96
97
  const comboDefaultFlag = parseEnum(flagValue('--combo-default'), COMBO_DEFAULTS, '--combo-default');
97
98
  const cavemanDefaultFlag = parseEnum(flagValue('--caveman-default'), CAVEMAN_DEFAULTS, '--caveman-default');
98
99
  const ponytailDefaultFlag = parseEnum(flagValue('--ponytail-default'), PONYTAIL_DEFAULTS, '--ponytail-default');
100
+ const pstackDefaultFlag = parseEnum(flagValue('--pstack-default'), PSTACK_DEFAULTS, '--pstack-default');
99
101
  const rtkDefaultFlag = parseEnum(flagValue('--rtk-default'), RTK_DEFAULTS, '--rtk-default');
100
102
  const diagScheduleFlag = parseEnum(flagValue('--diag-schedule'), DIAG_SCHEDULES, '--diag-schedule');
101
- const profileFlagsGiven = [comboDefaultFlag, cavemanDefaultFlag, rtkDefaultFlag, ponytailDefaultFlag]
103
+ const profileFlagsGiven = [comboDefaultFlag, cavemanDefaultFlag, rtkDefaultFlag, ponytailDefaultFlag, pstackDefaultFlag]
102
104
  .some((flag) => flag !== undefined);
103
105
  // Repeatable `--subagent-marker`; overrides the built-in host prompt marker.
104
106
  const subagentMarkerFlags = args
@@ -138,7 +140,7 @@ async function writeIfChanged(dest, content, options = {}) {
138
140
  console.log(` [write] ${dest}`);
139
141
  return true;
140
142
  }
141
- // omp ships 'extensions: null', and list items under a null scalar break YAML: normalize null/~/[]/empty to a mapping key first.
143
+ // omp ships 'extensions: null'; normalize it to a mapping key first, or YAML rejects the list items.
142
144
  const EXTENSIONS_KEY_RE = /^\s*extensions\s*:/i;
143
145
  const EXTENSIONS_NULL_RE = /^\s*extensions\s*:\s*(?:\[\s*\]|null|~)?\s*$/i;
144
146
  function normalizeExtensionsKey(lines) {
@@ -160,7 +162,7 @@ async function ensureExtensionInConfig(configPath, extensionPath, label, options
160
162
  const line = ` - ${normalizedPath}`;
161
163
  const raw = await readTextIfExists(configPath);
162
164
  let lines = (raw || '').split('\n');
163
- // Drop legacy compiled twins (same dir/name, .js): OMP must never load both copies. Matched on the trailing path so relative entries are caught too.
165
+ // Drop legacy compiled twins (.js beside the name): OMP must never load both. Matched on the trailing path.
164
166
  if (normalizedPath.endsWith('.ts')) {
165
167
  const legacyTail = `${normalizedPath.slice(0, -3)}.js`.split('/').slice(-2).join('/');
166
168
  lines = lines.filter((l) => !l.trim().replace(/^\.\//, '').endsWith(legacyTail));
@@ -239,6 +241,12 @@ function readPonytailConfig() {
239
241
  : path.join(HOME, '.config', 'ponytail');
240
242
  return { dir, path: path.join(dir, 'config.json') };
241
243
  }
244
+ /** This package's ponytail range, or null; repair pins it, since npm installs the published range. */
245
+ async function bundledPonytailSpec() {
246
+ const root = findPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
247
+ const text = await readTextIfExists(path.join(root, 'package.json'));
248
+ return parseJsonObject(text)?.dependencies?.['@dietrichgebert/ponytail'] ?? null;
249
+ }
242
250
  function parseJsonObject(text) {
243
251
  if (!text)
244
252
  return null;
@@ -280,6 +288,14 @@ async function ensurePonytailConfigValue(key, value, options = {}) {
280
288
  return true;
281
289
  }, `would set Ponytail ${key}=${value}`, `Set Ponytail ${key}=${value}`, options);
282
290
  }
291
+ // npm prefers a nested copy over the pin when the plugin's range excludes it; only `overrides` reaches it.
292
+ function ponytailOverride(pkg) {
293
+ const overrides = pkg.overrides;
294
+ if (typeof overrides !== 'object' || overrides === null)
295
+ return null;
296
+ const value = overrides['@dietrichgebert/ponytail'];
297
+ return typeof value === 'string' ? value : null;
298
+ }
283
299
  // Read plugins/package.json tolerantly; a corrupt or missing file starts fresh with installer-managed defaults.
284
300
  async function readPluginsPackage(pkgPath) {
285
301
  const pkg = parseJsonObject(await readTextIfExists(pkgPath)) ?? {};
@@ -300,4 +316,4 @@ function relTime(ageMs) {
300
316
  const weeks = Math.floor(days / 7);
301
317
  return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
302
318
  }
303
- export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, DIAG_SCHEDULES, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, settingArg, unknownCommand, install, update, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, dashboard, reset, settings, allowUnverified, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, subagentMarkerFlags, subagentMarkersGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, writeConfigLines, ensureExtensionInConfig, removeExtensionFromConfig, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, };
319
+ export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, PSTACK_DEFAULTS, RTK_DEFAULTS, DIAG_SCHEDULES, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, settingArg, unknownCommand, install, reinstall, update, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, dashboard, reset, settings, allowUnverified, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, pstackDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, subagentMarkerFlags, subagentMarkersGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, writeConfigLines, ensureExtensionInConfig, removeExtensionFromConfig, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, bundledPonytailSpec, ponytailOverride, };
@@ -1,4 +1,4 @@
1
- // cli/currency.ts — USD-base currency helpers on offline snapshot rates. Same 10 currencies as the dashboard, which prefers live frankfurter figures.
1
+ // cli/currency.ts — USD-base helpers on offline snapshot rates. Same 10 currencies as the dashboard's live figures.
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
4
4
  const CURRENCY_CODES = [
@@ -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';
@@ -11,11 +11,11 @@ import { isCurrencyCode } from './currency.js';
11
11
  import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, } from './common.js';
12
12
  import { BACKUP_SCHEDULES, formatCliStatus, storedProfile, writePluginSettings } from './profile.js';
13
13
  import { normalizeComboLevel } from '../extensions/shared/session-state.js';
14
- import { CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS } from './common.js';
14
+ import { CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, PSTACK_DEFAULTS } from './common.js';
15
15
  import { PACKAGE_NAME } from './common.js';
16
- import { findPackageRoot, resolveRtkBinary } from '../extensions/shared/utils.js';
16
+ import { findPackageRoot, resolveRtkBinary, ruleBodyProblem } from '../extensions/shared/utils.js';
17
17
  import { BACKUP_KEEP, backupDir, backupDiskBytes, createDataBackup, dataSources, deleteDataBackup, listDataBackups, restoreDataBackup } from './backups.js';
18
- import { readBackupSchedule } from '../extensions/shared/usage-store.js';
18
+ import { readBackupSchedule, syncUsageDb } from '../extensions/shared/usage-store.js';
19
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');
@@ -51,9 +51,12 @@ async function brandDataUri() {
51
51
  }
52
52
  /** The range a page opens on, matching the reference dashboard. */
53
53
  const DEFAULT_AGENT_RANGE = '24h';
54
- /** The omp aggregate spawns a sqlite3 process per query group, so a repeat poll reuses it. */
54
+ /** Poll memo TTL: every poll re-reads the aggregate, so a repeat poll reuses the last answer. */
55
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;
56
58
  let agentMemo = null;
59
+ let lastSyncAt = 0;
57
60
  function fileStamp(file) {
58
61
  try {
59
62
  const stat = statSync(file);
@@ -63,15 +66,25 @@ function fileStamp(file) {
63
66
  return '-';
64
67
  }
65
68
  }
66
- /** Reads the omp databases, unless the same window and view were read recently and nothing moved. */
67
- function agentStats(range, view = 'all') {
68
- const stamp = `${fileStamp(statsDbPath())}|${fileStamp(agentDbPath())}`;
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) {
69
75
  const held = agentMemo;
70
- if (held && held.range === range && held.view === view && held.stamp === stamp && Date.now() - held.at < AGENT_REFRESH_MS) {
76
+ if (held && held.range === range && held.view === view && held.offset === offset && held.stamp === storeStamp() && Date.now() - held.at < AGENT_REFRESH_MS) {
71
77
  return held.value;
72
78
  }
73
- const value = readAgentStats(range, view);
74
- agentMemo = { range, view, stamp, at: Date.now(), value };
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 };
75
88
  return value;
76
89
  }
77
90
  /** `?range=` on any of the omp routes; an unknown value falls back to the default. */
@@ -86,8 +99,14 @@ function viewFrom(url) {
86
99
  const raw = queryParam(url, 'view');
87
100
  return isAgentView(raw) ? raw : 'all';
88
101
  }
89
- function agentJson(range, view) {
90
- return JSON.stringify({ range, view, agent: agentStats(range, view) });
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) });
91
110
  }
92
111
  // Runs outside any agent session, so it reports the persisted defaults.
93
112
  async function statusJson() {
@@ -98,6 +117,7 @@ async function statusJson() {
98
117
  caveman: profile.cavemanDefault,
99
118
  rtk: profile.rtkDefault ? 'on' : 'off',
100
119
  ponytail: profile.ponytailDefault,
120
+ pstack: profile.pstackDefault,
101
121
  });
102
122
  }
103
123
  // Local-only health: find each agent on PATH, run `--version`, report both.
@@ -282,18 +302,29 @@ function computeDoctorRows() {
282
302
  }
283
303
  catch { /* missing config is reported by extension rows */ }
284
304
  const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
285
- addon('Unique config registrations', true, 'ok', 'Extensions');
286
- // 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.
287
307
  const dangling = explicitEntries
288
308
  .filter((entry) => entry.includes(`extensions${path.sep}`) || entry.includes('extensions/'))
289
309
  .filter((entry) => !ok(path.isAbsolute(entry) ? entry : path.resolve(OMP_AGENT_DIR, entry)));
290
310
  addon('Extension files present', dangling.length === 0, dangling.length ? dangling.join(', ') : 'ok', 'Extensions');
291
- ext('Caveman extension', path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts'));
292
- ext('RTK extension', path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts'));
293
- 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');
294
315
  const ruleAge = ageStr(rule);
295
316
  const ruleAt = absTime(rule);
296
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})`);
297
328
  const rtkVer = rtkBin ? rtkVersion(rtkBin) : null;
298
329
  const rtkAge = rtkBin ? ageStr(rtkBin) : null;
299
330
  const rtkAt = rtkBin ? absTime(rtkBin) : null;
@@ -314,7 +345,7 @@ function computeDoctorRows() {
314
345
  addon('Prices feed', pricesAge !== null, pricesAge && pricesAt ? `(pulled ${pricesAge} · ${pricesAt})` : prices, 'Usage');
315
346
  return rows;
316
347
  }
317
- // 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.
318
349
  const RETIRED_DIAG_LABELS = new Set([
319
350
  'OMP CLI',
320
351
  'Tersio CLI',
@@ -335,11 +366,10 @@ const RETIRED_DIAG_LABELS = new Set([
335
366
  function isRetiredReport(report) {
336
367
  return report.rows.some((r) => RETIRED_DIAG_LABELS.has(r.label));
337
368
  }
338
- // 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.
339
370
  function getDoctorReport(force) {
340
371
  const saved = readDiagReport();
341
372
  const ttl = saved ? DIAG_TTL_MS[saved.schedule] : 0;
342
- // Manual recomputes on open; a dated schedule reuses the saved report.
343
373
  if (!force && saved && !isRetiredReport(saved) && ttl > 0 && Date.now() - saved.checkedAt < ttl)
344
374
  return saved;
345
375
  const report = { rows: computeDoctorRows(), checkedAt: Date.now(), schedule: saved?.schedule ?? 'manual' };
@@ -373,6 +403,12 @@ async function saveDefaults(body) {
373
403
  return { ok: false, error: 'unknown ponytail level' };
374
404
  profile.ponytailDefault = v;
375
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
+ }
376
412
  if (body.backupSchedule !== undefined) {
377
413
  const v = String(body.backupSchedule);
378
414
  if (!BACKUP_SCHEDULES.has(v))
@@ -392,6 +428,7 @@ async function saveDefaults(body) {
392
428
  cavemanDefault: profile.cavemanDefault,
393
429
  rtkDefault: profile.rtkDefault,
394
430
  ponytailDefault: profile.ponytailDefault,
431
+ pstackDefault: profile.pstackDefault,
395
432
  backupSchedule: profile.backupSchedule,
396
433
  },
397
434
  };
@@ -423,7 +460,7 @@ function openBrowser(url) {
423
460
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
424
461
  spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
425
462
  }
426
- // 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.
427
464
  function escapeInline(json) {
428
465
  return json.replace(/<\/(script)/gi, '<\\/$1');
429
466
  }
@@ -444,20 +481,13 @@ function parseOwnJson(json, field) {
444
481
  async function exportDashboard(exportFile, currency) {
445
482
  requireDashboardBundle();
446
483
  const [bundle, icon] = await Promise.all([readSegment(DASHBOARD_INDEX), brandDataUri()]);
447
- // The bundle is minified, so the brand fetch reaches the file as `fetch(\`brand.webp\`)` or
448
- // `fetch("brand.webp")` depending on the bundler's quote choice. Match either, or the exported
449
- // card silently loses its mark. `atob` returns one char per byte, and a Blob encodes a string
450
- // as UTF-8, so the bytes go through a Uint8Array first: a webp is binary and every byte at or
451
- // above 0x80 would otherwise double and corrupt the image.
484
+ // Minified: fetch reads `fetch(\`brand.webp\`)` or `fetch("brand.webp")`; a mismatch loses the card's mark.
452
485
  const b64 = icon.split(',')[1] ?? '';
453
- // atob yields one char per byte; a Blob encodes a string as UTF-8, so the bytes pass through a
454
- // Uint8Array or every byte at or above 0x80 doubles and the webp comes out corrupt.
486
+ // atob yields one byte per char, so a Uint8Array is needed or bytes >= 0x80 double and corrupt the webp.
455
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; })()`;
456
488
  const inline = bundle
457
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) }))};`)
458
- // A mark reaches the bundle three ways: an HTML attribute in `index.html`, and a JSX prop
459
- // esbuild compiled with a backtick. Match any of `"`, `'`, and `` ` ``, or an exported file
460
- // keeps a bare reference that file:// cannot resolve.
490
+ // A mark is an HTML attribute or JSX prop; match every quote or the file keeps an unresolvable reference.
461
491
  .replace(/((?:src|href)[=:][ \t]*)([`"'])brand\.webp\2/g, (_all, head, quote) => `${head}${quote}${icon}${quote}`)
462
492
  .replace(/fetch\(([`"])brand\.webp\1\)/g, () => `Promise.resolve({ ok: true, blob: async () => new Blob([${brandBytes}], { type: "image/webp" }) })`);
463
493
  await fs.writeFile(exportFile, inline, 'utf8');
@@ -492,7 +522,7 @@ async function runDashboard(options) {
492
522
  }
493
523
  const action = String(body.action ?? '');
494
524
  const name = String(body.name ?? '');
495
- // A missing sqlite3, or a locked file, is a 500 with a message rather than a silent no-op.
525
+ // A missing SQLite engine, or a locked file, is a 500 with a message rather than a silent no-op.
496
526
  try {
497
527
  if (action === 'create') {
498
528
  const made = await createDataBackup();
@@ -530,6 +560,7 @@ async function runDashboard(options) {
530
560
  cavemanDefault: profile.cavemanDefault,
531
561
  rtkDefault: profile.rtkDefault,
532
562
  ponytailDefault: profile.ponytailDefault,
563
+ pstackDefault: profile.pstackDefault,
533
564
  backupSchedule: profile.backupSchedule,
534
565
  currency: options.currency,
535
566
  }));
@@ -576,7 +607,7 @@ async function runDashboard(options) {
576
607
  }
577
608
  if (req.url === '/api/agent' || req.url?.startsWith('/api/agent?')) {
578
609
  res.writeHead(200, { 'Content-Type': 'application/json' });
579
- res.end(agentJson(rangeFrom(req.url), viewFrom(req.url)));
610
+ res.end(agentJson(rangeFrom(req.url), viewFrom(req.url), offsetFrom(req.url)));
580
611
  return;
581
612
  }
582
613
  if (req.url?.startsWith('/api/agent/session')) {
@@ -691,4 +722,4 @@ async function runDashboard(options) {
691
722
  });
692
723
  });
693
724
  }
694
- export { runDashboard, saveDashboardCurrency, readDiagSchedule, setDiagSchedule };
725
+ export { runDashboard, saveDashboardCurrency, readDiagSchedule, setDiagSchedule, offsetFrom };