@krtclcdy/tersio 2.22.1 → 2.24.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 (59) hide show
  1. package/README.md +62 -46
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +55 -55
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +76 -49
  7. package/cli/doctor-fix.js +61 -44
  8. package/cli/doctor.js +66 -44
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +187 -143
  11. package/cli/interactive.js +27 -1
  12. package/cli/layer.js +24 -0
  13. package/cli/manifest.js +36 -0
  14. package/cli/omp-layer.js +51 -0
  15. package/cli/pi-layer.js +37 -0
  16. package/cli/pi-wiring.js +92 -0
  17. package/cli/profile.js +45 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/settings.js +1 -1
  21. package/cli/uninstall.js +135 -44
  22. package/cli/update.js +24 -30
  23. package/cli/usage.js +6 -1
  24. package/dashboard/dist/index.html +16 -16
  25. package/extensions/ai-addons-updater/index.js +36 -25
  26. package/extensions/ai-addons-updater/index.ts +36 -24
  27. package/extensions/caveman-session/index.js +23 -18
  28. package/extensions/caveman-session/index.ts +23 -18
  29. package/extensions/caveman-session/rule.md +90 -0
  30. package/extensions/combo-toggle/index.js +63 -41
  31. package/extensions/combo-toggle/index.ts +61 -48
  32. package/extensions/lib/utils.js +56 -13
  33. package/extensions/lib/utils.ts +55 -13
  34. package/extensions/rtk-session/index.js +20 -19
  35. package/extensions/rtk-session/index.ts +18 -24
  36. package/extensions/shared/carbon.js +5 -8
  37. package/extensions/shared/carbon.ts +5 -8
  38. package/extensions/shared/host.js +68 -0
  39. package/extensions/shared/host.ts +104 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +63 -16
  42. package/extensions/shared/plugin-settings.ts +67 -17
  43. package/extensions/shared/pricing.js +26 -15
  44. package/extensions/shared/pricing.ts +28 -16
  45. package/extensions/shared/rtk-gain.js +13 -24
  46. package/extensions/shared/rtk-gain.ts +14 -25
  47. package/extensions/shared/session-state.js +26 -28
  48. package/extensions/shared/session-state.ts +26 -29
  49. package/extensions/shared/types.ts +15 -3
  50. package/extensions/shared/usage-ledger.js +136 -38
  51. package/extensions/shared/usage-ledger.ts +149 -52
  52. package/extensions/shared/usage-store.js +14 -12
  53. package/extensions/shared/usage-store.ts +16 -14
  54. package/extensions/tersio-commands/index.js +2 -1
  55. package/extensions/tersio-commands/index.ts +2 -1
  56. package/package.json +15 -7
  57. package/tersio.js +6 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
  59. package/extensions/shared/mode-reinforcement.ts +0 -29
package/cli/common.js CHANGED
@@ -16,13 +16,12 @@ const OMP_BIN = IS_WINDOWS ? 'omp.cmd' : 'omp';
16
16
  const PACKAGE_NAME = '@krtclcdy/tersio';
17
17
  const PACKAGE_BIN = 'tersio';
18
18
  const { version: PACKAGE_VERSION } = createRequire(import.meta.url)('../package.json');
19
- // Session-start mode defaults for fresh installs.
20
- const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'wenyan']);
21
- const PONYTAIL_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'review']);
19
+ const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
20
+ const PONYTAIL_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra']);
22
21
  const RTK_DEFAULTS = new Set(['on', 'off']);
23
22
  const DIAG_SCHEDULES = new Set(['manual', 'daily', 'weekly', 'monthly']);
24
- // ponytail: Combo preset implies all three modes. Mirrors COMBO_LEVELS in
25
- // extensions/shared/session-state.ts (rtk as boolean here). Single source.
23
+ // Mirrors COMBO_LEVELS in extensions/shared/session-state.ts (rtk as a
24
+ // boolean here), so the combo preset implies all three modes.
26
25
  const COMBO_PRESET_MODES = {
27
26
  off: { caveman: 'off', rtk: false, ponytail: 'off' },
28
27
  medium: { caveman: 'lite', rtk: true, ponytail: 'lite' },
@@ -48,13 +47,12 @@ function flagValue(name) {
48
47
  return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
49
48
  }
50
49
  // --- CLI flags ---
51
- const COMMANDS = { install: true, update: true, reinstall: true, doctor: true, uninstall: true, usage: true, dashboard: true, reset: true, settings: true, version: true, help: true };
50
+ const COMMANDS = { install: true, update: true, doctor: true, uninstall: true, usage: true, dashboard: true, reset: true, settings: true, version: true, help: true };
52
51
  const args = process.argv.slice(2);
53
52
  const commandArg = args.find((arg) => !arg.startsWith('-'));
54
53
  const command = commandArg?.toLowerCase() || null;
55
- // Positional after the command: `tersio settings diagnosis` jumps straight
56
- // to one prompt instead of walking the whole chain. Index-based so flag
57
- // values (e.g. `--combo-default balanced`) never count as positionals.
54
+ // `tersio settings diagnosis` jumps to one prompt instead of walking the
55
+ // chain. Index-based, so flag values never count as positionals.
58
56
  const settingArg = (() => {
59
57
  const at = args.indexOf(commandArg ?? '');
60
58
  const next = at >= 0 ? args[at + 1] : undefined;
@@ -65,12 +63,11 @@ const settingArg = (() => {
65
63
  const unknownCommand = command !== null && !COMMANDS[command];
66
64
  const install = command === 'install';
67
65
  const update = command === 'update';
68
- const reinstall = command === 'reinstall';
69
66
  const showVersion = command === 'version' || args.includes('--version') || args.includes('-v');
70
67
  const showHelp = command === 'help' || args.includes('--help') || args.includes('-h');
71
68
  const applyUpdate = args.includes('--apply-update');
72
69
  const dryRun = args.includes('--dry-run');
73
- const yes = args.includes('--yes') || args.includes('-y') || install || update || reinstall || applyUpdate;
70
+ const yes = args.includes('--yes') || args.includes('-y') || install || update || applyUpdate;
74
71
  const verbose = args.includes('--verbose');
75
72
  const doctor = command === 'doctor' || args.includes('--doctor');
76
73
  const fix = args.includes('--fix') || args.some((a) => a.startsWith('--fix='));
@@ -81,16 +78,15 @@ const settings = command === 'settings';
81
78
  const dashboardPort = Number.parseInt(flagValue('--port') ?? '', 10) || 0;
82
79
  const dashboardOpen = args.includes('--open');
83
80
  const dashboardExport = flagValue('--export') ?? null;
84
- // Display currency for usage/dashboard: --currency flag wins, then the stored
85
- // plugin default (tersio settings), then USD.
81
+ // Display currency: --currency flag, then the stored default, then USD.
86
82
  const currency = parseCurrencyFlag(flagValue('--currency')) ?? readStoredCurrency();
87
83
  const currencyGiven = flagValue('--currency') !== undefined;
88
84
  const uninstall = command === 'uninstall' || args.includes('--uninstall');
89
85
  const removePonytail = args.includes('--remove-ponytail');
90
86
  const keepPonytail = args.includes('--keep-ponytail');
91
87
  const removeRtk = args.includes('--remove-rtk');
92
- // Project scope was removed: tersio installs user-level (all OMP sessions)
93
- // only. A bare `--scope user` still parses (old scripts); anything else fails.
88
+ // User scope only (all OMP sessions). A bare `--scope user` still parses for
89
+ // old scripts; anything else fails.
94
90
  const legacyScope = flagValue('--scope')?.toLowerCase() ?? null;
95
91
  if (legacyScope !== null && legacyScope !== 'user') {
96
92
  console.error(`[fail] Invalid --scope: ${legacyScope}. Project scope was removed; tersio installs user-level only.`);
@@ -137,8 +133,8 @@ async function writeIfChanged(dest, content, options = {}) {
137
133
  console.log(` [write] ${dest}`);
138
134
  return true;
139
135
  }
140
- // omp ships 'extensions: null'; appending list items under a null scalar breaks
141
- // YAML parsing. Normalize null/~/[]/empty to a mapping key first.
136
+ // omp ships 'extensions: null', and list items under a null scalar break YAML:
137
+ // normalize null/~/[]/empty to a mapping key first.
142
138
  const EXTENSIONS_KEY_RE = /^\s*extensions\s*:/i;
143
139
  const EXTENSIONS_NULL_RE = /^\s*extensions\s*:\s*(?:\[\s*\]|null|~)?\s*$/i;
144
140
  function normalizeExtensionsKey(lines) {
@@ -160,14 +156,32 @@ async function ensureExtensionInConfig(configPath, extensionPath, label, options
160
156
  const line = ` - ${normalizedPath}`;
161
157
  const raw = await readTextIfExists(configPath);
162
158
  let lines = (raw || '').split('\n');
163
- // Drop legacy compiled twins (same dir/name, .js): previous installs
164
- // registered them, and OMP must never load both copies. Match on the
165
- // trailing path so absolute and relative entries are both caught.
159
+ // Drop legacy compiled twins (same dir/name, .js): OMP must never load both
160
+ // copies. Matched on the trailing path so relative entries are caught too.
166
161
  if (normalizedPath.endsWith('.ts')) {
167
162
  const legacyTail = `${normalizedPath.slice(0, -3)}.js`.split('/').slice(-2).join('/');
168
163
  lines = lines.filter((l) => !l.trim().replace(/^\.\//, '').endsWith(legacyTail));
169
164
  }
170
- if (lines.some((l) => l.includes(normalizedPath))) {
165
+ const matchingEntries = lines.filter((entry) => entry.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, '') === normalizedPath);
166
+ if (matchingEntries.length > 1) {
167
+ if (options.dryRun) {
168
+ if (verbose && !options.quiet)
169
+ console.log(` [dry-run] would remove ${matchingEntries.length - 1} duplicate ${label} registration${matchingEntries.length === 2 ? '' : 's'}`);
170
+ return true;
171
+ }
172
+ let kept = false;
173
+ lines = lines.filter((entry) => {
174
+ if (entry.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, '') !== normalizedPath)
175
+ return true;
176
+ if (kept)
177
+ return false;
178
+ kept = true;
179
+ return true;
180
+ });
181
+ await writeConfigLines(configPath, lines, ` [write] Removed duplicate ${label} registration`, options);
182
+ return true;
183
+ }
184
+ if (matchingEntries.length === 1) {
171
185
  debug(`${label} already in config.yml`);
172
186
  return false;
173
187
  }
@@ -191,43 +205,29 @@ async function ensureExtensionInConfig(configPath, extensionPath, label, options
191
205
  await writeConfigLines(configPath, lines, ` [write] Added ${label} to config.yml`, options);
192
206
  return true;
193
207
  }
194
- async function ensureExtensionAfterConfigEntry(configPath, extensionPath, afterPath, label, options = {}) {
195
- const normalizedPath = extensionPath.replace(/\\/g, '/');
196
- const normalizedAfterPath = afterPath.replace(/\\/g, '/');
197
- const line = ` - ${normalizedPath}`;
208
+ async function removeExtensionFromConfig(configPath, extensionPath, label, options = {}) {
209
+ const normalizedPath = extensionPath.replace(/\\/g, '/').replace(/^\.\//, '');
210
+ const extensionSuffix = normalizedPath.includes('/extensions/')
211
+ ? normalizedPath.slice(normalizedPath.indexOf('/extensions/') + 1)
212
+ : normalizedPath;
198
213
  const raw = await readTextIfExists(configPath);
199
- let lines = (raw || '').split('\n');
200
- // Same legacy-twin drop as ensureExtensionInConfig above.
201
- if (normalizedPath.endsWith('.ts')) {
202
- const legacyTail = `${normalizedPath.slice(0, -3)}.js`.split('/').slice(-2).join('/');
203
- lines = lines.filter((l) => !l.trim().replace(/^\.\//, '').endsWith(legacyTail));
204
- }
205
- const existingIndex = lines.findIndex((entry) => entry.includes(normalizedPath));
206
- const afterIndex = lines.findIndex((entry) => entry.includes(normalizedAfterPath));
207
- if (existingIndex !== -1 && afterIndex !== -1 && existingIndex === afterIndex + 1)
214
+ if (raw === null)
215
+ return false;
216
+ const lines = raw.split('\n');
217
+ const matches = (entry) => {
218
+ const value = entry.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, '').replace(/\\/g, '/').replace(/^\.\//, '');
219
+ return value === extensionSuffix || value.endsWith(`/${extensionSuffix}`);
220
+ };
221
+ const kept = lines.filter((entry) => !matches(entry));
222
+ const removed = lines.length - kept.length;
223
+ if (removed === 0)
208
224
  return false;
209
225
  if (options.dryRun) {
210
226
  if (verbose && !options.quiet)
211
- console.log(` [dry-run] would place ${label} after Ponytail in config.yml`);
227
+ console.log(` [dry-run] would remove ${label} registration${removed === 1 ? '' : 's'}`);
212
228
  return true;
213
229
  }
214
- if (existingIndex !== -1)
215
- lines.splice(existingIndex, 1);
216
- const refreshedAfterIndex = lines.findIndex((entry) => entry.includes(normalizedAfterPath));
217
- if (refreshedAfterIndex !== -1) {
218
- lines.splice(refreshedAfterIndex + 1, 0, line);
219
- }
220
- else {
221
- const extensionsIndex = lines.findIndex((entry) => EXTENSIONS_KEY_RE.test(entry));
222
- if (extensionsIndex === -1) {
223
- lines.push('extensions:', line, '');
224
- }
225
- else {
226
- normalizeExtensionsKey(lines);
227
- lines.splice(extensionsIndex + 1, 0, line);
228
- }
229
- }
230
- await writeConfigLines(configPath, lines, ` [write] Placed ${label} after Ponytail in config.yml`, options);
230
+ await writeConfigLines(configPath, kept, ` [write] Removed duplicate ${label} registration`, options);
231
231
  return true;
232
232
  }
233
233
  function readPonytailConfig() {
@@ -277,8 +277,8 @@ async function ensurePonytailConfigValue(key, value, options = {}) {
277
277
  return true;
278
278
  }, `would set Ponytail ${key}=${value}`, `Set Ponytail ${key}=${value}`, options);
279
279
  }
280
- // Read plugins/package.json tolerantly; corrupt or missing files start fresh
281
- // with installer-managed defaults.
280
+ // Read plugins/package.json tolerantly; a corrupt or missing file starts
281
+ // fresh with installer-managed defaults.
282
282
  async function readPluginsPackage(pkgPath) {
283
283
  const pkg = parseJsonObject(await readTextIfExists(pkgPath)) ?? {};
284
284
  return { ...pkg, name: pkg.name || 'omp-plugins', private: true, dependencies: pkg.dependencies || {} };
@@ -298,4 +298,4 @@ function relTime(ageMs) {
298
298
  const weeks = Math.floor(days / 7);
299
299
  return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
300
300
  }
301
- 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, reinstall, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, dashboard, reset, settings, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, ensureExtensionAfterConfigEntry, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, };
301
+ 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, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, removeExtensionFromConfig, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, };
package/cli/currency.js CHANGED
@@ -3,8 +3,7 @@
3
3
  // converter (dashboard/app); the dashboard replaces rates with live
4
4
  // frankfurter figures when reachable, the CLI always uses the snapshot.
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
- import os from 'node:os';
7
- import path from 'node:path';
6
+ import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
8
7
  const CURRENCY_CODES = [
9
8
  'USD',
10
9
  'PHP',
@@ -36,18 +35,14 @@ function parseCurrencyFlag(raw) {
36
35
  // USD when missing, corrupt, or holding an unknown code. Sync so flag
37
36
  // parsing in cli/common.ts can fall back to it at startup.
38
37
  function readStoredCurrency() {
39
- const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
40
- const lock = path.join(home, '.omp', 'plugins', 'omp-plugins.lock.json');
41
- if (!existsSync(lock))
38
+ const file = tersioSettingsFile();
39
+ if (!existsSync(file))
42
40
  return DEFAULT_CURRENCY;
43
41
  try {
44
- const parsed = JSON.parse(readFileSync(lock, 'utf8'));
45
- if (!parsed || typeof parsed !== 'object' || !('settings' in parsed))
42
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
43
+ if (!parsed || typeof parsed !== 'object')
46
44
  return DEFAULT_CURRENCY;
47
- const entry = parsed.settings?.['@krtclcdy/tersio'];
48
- if (!entry || typeof entry !== 'object')
49
- return DEFAULT_CURRENCY;
50
- const raw = entry.currency;
45
+ const raw = parsed.currency;
51
46
  return typeof raw === 'string' && isCurrencyCode(raw.trim().toUpperCase())
52
47
  ? raw.trim().toUpperCase()
53
48
  : DEFAULT_CURRENCY;
@@ -68,7 +63,8 @@ const CURRENCY_META = {
68
63
  CAD: { symbol: 'C$', decimals: 2 },
69
64
  INR: { symbol: '₹', decimals: 2 },
70
65
  };
71
- const FX_SNAPSHOT_RATES = {
66
+ // Exported so a test can hold it against the Dashboard's copy of the same table.
67
+ export const FX_SNAPSHOT_RATES = {
72
68
  USD: 1,
73
69
  PHP: 58.7,
74
70
  EUR: 0.92,
package/cli/dashboard.js CHANGED
@@ -1,7 +1,5 @@
1
- // cli/dashboard.ts — serves the built shadcn Dashboard with local usage APIs.
2
- // Binds 127.0.0.1 only; --export writes a file://-ready file instead.
3
- // Note: no Promise.withResolvers here — engines still allow Node 20.12,
4
- // which lacks it; the listening server itself keeps the process alive.
1
+ // Serves the built Dashboard with local usage APIs. Binds 127.0.0.1 only;
2
+ // --export writes a file instead. No Promise.withResolvers: Node 20.12 lacks it.
5
3
  import { spawn } from 'node:child_process';
6
4
  import { execFileSync } from 'node:child_process';
7
5
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
@@ -13,8 +11,10 @@ import { clearUsageLedger, markReset, readUsage } from '../extensions/shared/usa
13
11
  import { pricesCachePath } from '../extensions/shared/pricing.js';
14
12
  import { summarizeUsage } from './usage.js';
15
13
  import { isCurrencyCode } from './currency.js';
16
- import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, RTK_BINARY_NAME, } from './common.js';
14
+ import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, } from './common.js';
17
15
  import { storedProfile, writePluginSettings } from './profile.js';
16
+ import { PACKAGE_NAME } from './common.js';
17
+ import { resolveRtkBinary } from '../extensions/lib/utils.js';
18
18
  const DASHBOARD_DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dashboard', 'dist');
19
19
  const DASHBOARD_INDEX = path.join(DASHBOARD_DIST, 'index.html');
20
20
  const DASHBOARD_BRAND = path.join(DASHBOARD_DIST, 'brand.webp');
@@ -50,10 +50,9 @@ async function brandDataUri() {
50
50
  function dataJson() {
51
51
  return JSON.stringify(summarizeUsage(readUsage()));
52
52
  }
53
- // Local-only health + diagnosis for the settings modal. No network: version
54
- // probes run with short timeouts, file checks are existsSync.
55
- function ompPath() {
56
- const names = process.platform === 'win32' ? ['omp.cmd', 'omp.exe', 'omp.bat', 'omp'] : ['omp'];
53
+ // Local-only health: find each agent on PATH, run `--version`, report both.
54
+ function binOnPath(bin) {
55
+ const names = process.platform === 'win32' ? [`${bin}.cmd`, `${bin}.exe`, `${bin}.bat`, bin] : [bin];
57
56
  for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
58
57
  for (const name of names) {
59
58
  const candidate = path.join(dir, name);
@@ -66,15 +65,28 @@ function ompPath() {
66
65
  }
67
66
  return null;
68
67
  }
69
- function ompVersion(bin = ompPath()) {
68
+ function ompPath() {
69
+ return binOnPath('omp');
70
+ }
71
+ function piPath() {
72
+ return binOnPath('pi');
73
+ }
74
+ // omp prints `omp/18.4.1`, pi prints a bare `0.87.1`; both read as name/version.
75
+ function versionLabel(bin, raw) {
76
+ const out = raw.trim();
77
+ return out.includes('/') ? out : `${bin}/${out}`;
78
+ }
79
+ function agentVersion(bin) {
70
80
  if (!bin)
71
81
  return null;
72
82
  try {
73
83
  if (process.platform === 'win32') {
74
84
  const exe = process.env.ComSpec || 'cmd.exe';
75
- return execFileSync(exe, ['/d', '/s', '/c', bin, '--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim() || null;
85
+ const out = execFileSync(exe, ['/d', '/s', '/c', bin, '--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true });
86
+ return out.trim() ? versionLabel(path.basename(bin), out) : null;
76
87
  }
77
- return execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim() || null;
88
+ const out = execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true });
89
+ return out.trim() ? versionLabel(path.basename(bin), out) : null;
78
90
  }
79
91
  catch {
80
92
  return null;
@@ -99,17 +111,20 @@ function ompDefaultModel() {
99
111
  }
100
112
  }
101
113
  function healthJson() {
102
- const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
103
- const rtkPresent = existsSync(rtkBin);
114
+ const rtkBin = resolveRtkBinary();
115
+ const rtkPresent = rtkBin !== null;
104
116
  const detectedOmpPath = ompPath();
117
+ const detectedPiPath = piPath();
105
118
  return JSON.stringify({
106
119
  tersio: PACKAGE_VERSION,
107
120
  node: process.version,
108
121
  platform: `${process.platform}/${process.arch}`,
109
- omp: ompVersion(detectedOmpPath),
122
+ omp: agentVersion(detectedOmpPath),
110
123
  ompPath: detectedOmpPath,
124
+ pi: agentVersion(detectedPiPath),
125
+ piPath: detectedPiPath,
111
126
  provider: ompDefaultModel(),
112
- rtk: { present: rtkPresent, version: rtkPresent ? rtkVersion(rtkBin) : null, path: rtkBin },
127
+ rtk: { present: rtkPresent, version: rtkPresent ? rtkVersion(rtkBin) : null, path: rtkBin || 'not found in PATH' },
113
128
  home: tersioHomePath(),
114
129
  });
115
130
  }
@@ -123,8 +138,7 @@ const DIAG_TTL_MS = {
123
138
  monthly: 30 * 24 * 3600 * 1000,
124
139
  };
125
140
  function diagPaths() {
126
- // Hermetic env (tests) overrides the DB path: keep the report next to it
127
- // so test runs never touch the real ~/.tersio/diag.json.
141
+ // Keep the report beside the overridden DB so tests never touch the real one.
128
142
  const dbOverride = process.env.TERSIO_USAGE_DB;
129
143
  if (dbOverride)
130
144
  return { report: path.join(path.dirname(dbOverride), 'diag.json') };
@@ -183,29 +197,39 @@ function relAge(ms) {
183
197
  }
184
198
  function computeDoctorRows() {
185
199
  const rows = [];
186
- const extDir = path.join(OMP_AGENT_DIR, 'extensions');
187
- const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
200
+ const rtkBin = resolveRtkBinary();
188
201
  const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
202
+ const tersioPluginDir = path.join(OMP_PLUGINS_DIR, 'node_modules', ...PACKAGE_NAME.split('/'));
189
203
  const ok = (p) => existsSync(p);
204
+ const configPath = path.join(OMP_AGENT_DIR, 'config.yml');
190
205
  const ext = (label, p) => {
191
206
  rows.push({ label, ok: ok(p), detail: ok(p) ? 'installed' : p, group: 'Extensions' });
192
207
  };
193
208
  const addon = (label, present, detail, group = 'Add-ons') => {
194
209
  rows.push({ label, ok: present, detail, group });
195
210
  };
196
- ext('Caveman extension', path.join(extDir, 'caveman-session', 'index.ts'));
197
- ext('RTK extension', path.join(extDir, 'rtk-session', 'index.ts'));
211
+ let explicitEntries = [];
212
+ try {
213
+ explicitEntries = readFileSync(configPath, 'utf8').split('\n')
214
+ .map((line) => line.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, ''))
215
+ .filter((line) => line.startsWith('/') || line.startsWith('.'));
216
+ }
217
+ catch { /* missing config is reported by extension rows */ }
218
+ const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
219
+ addon('Unique config registrations', duplicateExtensions.length === 0, duplicateExtensions.length ? duplicateExtensions.join(', ') : 'ok', 'Extensions');
220
+ const retiredReinforcement = explicitEntries.filter((entry) => entry.endsWith('/shared/mode-reinforcement.ts')).length;
221
+ addon('No retired reinforcement', retiredReinforcement === 0, retiredReinforcement ? `${retiredReinforcement} registration(s)` : 'ok', 'Extensions');
222
+ ext('Caveman extension', path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts'));
223
+ ext('RTK extension', path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts'));
198
224
  ext('Ponytail extension', path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js'));
199
- const rule = path.join(extDir, 'caveman-session', 'rule.md');
225
+ const rule = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'rule.md');
200
226
  const ruleAge = ageStr(rule);
201
227
  const ruleAt = absTime(rule);
202
228
  addon('Caveman rule', ruleAge !== null, ruleAge && ruleAt ? `(updated ${ruleAge} · ${ruleAt})` : rule);
203
- const rtkVer = ok(rtkBin) ? rtkVersion(rtkBin) : null;
204
- const rtkAge = ageStr(rtkBin);
205
- const rtkAt = absTime(rtkBin);
206
- addon('RTK binary', rtkVer !== null, rtkVer && rtkAge && rtkAt ? `${rtkVer} (updated ${rtkAge} · ${rtkAt})` : rtkBin);
207
- const wiring = path.join(extDir, 'rtk.ts');
208
- addon('RTK OMP wiring (rtk.ts)', ok(wiring), ok(wiring) ? 'loaded' : wiring);
229
+ const rtkVer = rtkBin ? rtkVersion(rtkBin) : null;
230
+ const rtkAge = rtkBin ? ageStr(rtkBin) : null;
231
+ const rtkAt = rtkBin ? absTime(rtkBin) : null;
232
+ addon('RTK binary', rtkVer !== null, rtkVer && rtkAge && rtkAt ? `${rtkVer} (updated ${rtkAge} · ${rtkAt})` : rtkBin || 'not found in PATH');
209
233
  let ponytailVer = null;
210
234
  try {
211
235
  ponytailVer = JSON.parse(readFileSync(ponytailPkg, 'utf8')).version ?? null;
@@ -222,10 +246,8 @@ function computeDoctorRows() {
222
246
  addon('Prices feed', pricesAge !== null, pricesAge && pricesAt ? `(pulled ${pricesAge} · ${pricesAt})` : prices, 'Usage');
223
247
  return rows;
224
248
  }
225
- // Retained diagnosis: recompute when forced, missing, or older than the
226
- // schedule. Otherwise return the saved report so users never re-run.
227
- // Labels retired from the report. A saved report containing any of them
228
- // is stale by definition and always recomputes.
249
+ // Recompute when forced, missing, or past the schedule; a report holding a
250
+ // retired label is stale by definition.
229
251
  const RETIRED_DIAG_LABELS = new Set([
230
252
  'OMP CLI',
231
253
  'Tersio CLI',
@@ -240,13 +262,11 @@ const RETIRED_DIAG_LABELS = new Set([
240
262
  function isRetiredReport(report) {
241
263
  return report.rows.some((r) => RETIRED_DIAG_LABELS.has(r.label));
242
264
  }
243
- // Retained diagnosis: recompute when forced, missing, retired, or older
244
- // than the schedule. Otherwise return the saved report.
265
+ // Recompute when forced, missing, retired, or past the schedule.
245
266
  function getDoctorReport(force) {
246
267
  const saved = readDiagReport();
247
268
  const ttl = saved ? DIAG_TTL_MS[saved.schedule] : 0;
248
- // Manual always recomputes on open; dated schedules reuse the saved
249
- // report until stale. Otherwise a retained snapshot hides new rows.
269
+ // Manual recomputes on open; a dated schedule reuses the saved report.
250
270
  if (!force && saved && !isRetiredReport(saved) && ttl > 0 && Date.now() - saved.checkedAt < ttl)
251
271
  return saved;
252
272
  const report = { rows: computeDoctorRows(), checkedAt: Date.now(), schedule: saved?.schedule ?? 'manual' };
@@ -262,10 +282,8 @@ function setDiagSchedule(schedule) {
262
282
  function readDiagSchedule() {
263
283
  return readDiagReport()?.schedule ?? 'manual';
264
284
  }
265
- // Persist the dashboard's picker choice so close → reopen keeps it: each
266
- // `tersio dashboard` run serves a fresh ephemeral port (a new origin), so the
267
- // browser's localStorage alone cannot survive a restart. The stored default
268
- // feeds data.json and `tersio usage` on the next run.
285
+ // Persist the picker choice: every run serves a fresh port, so localStorage
286
+ // alone cannot survive a restart.
269
287
  async function saveDashboardCurrency(raw) {
270
288
  if (typeof raw !== 'string')
271
289
  return null;
@@ -289,21 +307,30 @@ function openBrowser(url) {
289
307
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
290
308
  spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
291
309
  }
292
- // Replacer functions throughout: session data routinely contains `$'`
293
- // sequences (shell quoting in tool details), which String.replace would
294
- // expand as match-suffix patterns and corrupt the file.
295
- // RTK-metered command lines routinely contain literal `</script>` (Vue
296
- // SFC probes), which would close the inlined <script> early and dump the
297
- // rest of the JSON as page text. Escape it; JSON.parse never sees the
298
- // backslash form inside a string literal, the browser decodes it first.
310
+ // Replacers throughout: session data holds `$'` (shell quoting) and literal
311
+ // `</script>`, which String.replace would mangle.
299
312
  function escapeInline(json) {
300
313
  return json.replace(/<\/(script)/gi, '<\\/$1');
301
314
  }
315
+ // Our own JSON.stringify output, so a parse failure means a bad value: name it.
316
+ function parseOwnJson(json, field) {
317
+ let parsed;
318
+ try {
319
+ parsed = JSON.parse(json);
320
+ }
321
+ catch {
322
+ throw new Error(`dashboard snapshot ${field} is not valid JSON`);
323
+ }
324
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
325
+ throw new Error(`dashboard snapshot ${field} is not an object`);
326
+ }
327
+ return parsed;
328
+ }
302
329
  async function exportDashboard(exportFile) {
303
330
  requireDashboardBundle();
304
331
  const [bundle, icon] = await Promise.all([readSegment(DASHBOARD_INDEX), brandDataUri()]);
305
332
  const inline = bundle
306
- .replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ data: JSON.parse(dataJson()), health: JSON.parse(healthJson()), doctor: getDoctorReport(false) }))};`)
333
+ .replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ data: parseOwnJson(dataJson(), 'usage data'), health: parseOwnJson(healthJson(), 'health'), doctor: getDoctorReport(false) }))};`)
307
334
  .replace(/href="brand\.webp"/g, () => `href="${icon}"`)
308
335
  .replace(/src="brand\.webp"/g, () => `src="${icon}"`)
309
336
  .replace('fetch("brand.webp")', () => `Promise.resolve({ ok: true, blob: async () => new Blob([atob("${icon.split(',')[1]}")], { type: "image/webp" }) })`);