@krtclcdy/tersio 2.25.3 → 2.25.5

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 (61) hide show
  1. package/README.md +14 -31
  2. package/dashboard/dist/index.html +18 -16
  3. package/dist/cli/backups.js +194 -0
  4. package/dist/cli/common.js +2 -2
  5. package/dist/cli/dashboard.js +125 -98
  6. package/dist/cli/doctor-fix.js +42 -45
  7. package/dist/cli/doctor.js +70 -8
  8. package/dist/cli/hosts.js +4 -4
  9. package/dist/cli/install.js +106 -38
  10. package/dist/cli/manifest.js +45 -16
  11. package/dist/cli/profile.js +1 -1
  12. package/dist/cli/uninstall.js +21 -5
  13. package/dist/cli/update.js +3 -3
  14. package/dist/cli/usage.js +75 -4
  15. package/dist/extensions/{caveman-session → caveman}/index.js +10 -3
  16. package/dist/extensions/{combo-toggle → combo}/index.js +3 -3
  17. package/dist/extensions/{tersio-commands → commands}/index.js +1 -1
  18. package/dist/extensions/rtk/filter.js +87 -0
  19. package/dist/extensions/{rtk-session/index.js → rtk/session.js} +4 -3
  20. package/dist/extensions/{opencode/server.js → server.js} +52 -8
  21. package/dist/extensions/shared/agent-stats.js +1262 -0
  22. package/dist/extensions/shared/brand-marks.js +154 -0
  23. package/dist/extensions/shared/host.js +18 -12
  24. package/dist/extensions/shared/plugin-settings.js +1 -1
  25. package/dist/extensions/shared/pricing.js +91 -27
  26. package/dist/extensions/shared/session-state.js +3 -1
  27. package/dist/extensions/shared/usage-ledger.js +79 -17
  28. package/dist/extensions/shared/usage-store.js +106 -22
  29. package/dist/extensions/{lib → shared}/utils.js +1 -1
  30. package/dist/extensions/{ai-addons-updater → updater}/index.js +15 -5
  31. package/dist/probe-catalog.js +48 -0
  32. package/dist/probe-catalog188.js +44 -0
  33. package/dist/tersio.js +2 -2
  34. package/extensions/{caveman-session → caveman}/index.ts +10 -3
  35. package/extensions/{combo-toggle → combo}/index.ts +3 -3
  36. package/extensions/{tersio-commands → commands}/index.ts +1 -1
  37. package/extensions/rtk/filter.ts +90 -0
  38. package/extensions/{rtk-session/index.ts → rtk/session.ts} +4 -3
  39. package/extensions/{opencode/server.ts → server.ts} +46 -9
  40. package/extensions/shared/agent-stats.ts +1657 -0
  41. package/extensions/shared/brand-marks.ts +163 -0
  42. package/extensions/shared/host.ts +26 -13
  43. package/extensions/shared/plugin-settings.ts +1 -1
  44. package/extensions/shared/pricing.ts +107 -28
  45. package/extensions/shared/session-state.ts +3 -1
  46. package/extensions/shared/types.ts +10 -2
  47. package/extensions/shared/usage-ledger.ts +94 -19
  48. package/extensions/shared/usage-store.ts +106 -22
  49. package/extensions/{lib → shared}/utils.ts +1 -1
  50. package/extensions/{ai-addons-updater → updater}/index.ts +17 -7
  51. package/package.json +13 -12
  52. package/dist/cli/headroom-install.js +0 -266
  53. package/dist/cli/headroom.js +0 -492
  54. package/dist/extensions/headroom-session/index.js +0 -93
  55. package/dist/extensions/shared/headroom.js +0 -131
  56. /package/dist/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  57. /package/dist/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  58. /package/dist/extensions/{caveman-session → caveman}/rule.md +0 -0
  59. /package/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  60. /package/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  61. /package/extensions/{caveman-session → caveman}/rule.md +0 -0
@@ -1,13 +1,24 @@
1
1
  // cli/doctor.ts — installation health checks.
2
- import { existsSync, promises as fs } from 'node:fs';
2
+ import { existsSync, statSync, promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, args, dryRun, fix, yes, execP, parseJsonObject, relTime, } from './common.js';
5
5
  import { detectHosts, hostExtensionsDir, ompPackageDir } from './hosts.js';
6
6
  import { askInteractiveChoice, askInteractiveConfirm, runInteractivePhase } from './interactive.js';
7
7
  import { usageDbPath } from '../extensions/shared/usage-store.js';
8
8
  import { pricesCachePath } from '../extensions/shared/pricing.js';
9
- import { readTextIfExists, resolveRtkBinary, ruleBodyProblem } from '../extensions/lib/utils.js';
9
+ import { readTextIfExists, resolveRtkBinary, ruleBodyProblem } from '../extensions/shared/utils.js';
10
+ import { sqliteScript, statsDbPath } from '../extensions/shared/agent-stats.js';
10
11
  import { TREE_FILES } from './manifest.js';
12
+ /** Bytes at the scale a transcript gap reads in: `5.6M`, not `5571992`. */
13
+ function shortBytes(bytes) {
14
+ if (bytes >= 1e9)
15
+ return `${(bytes / 1e9).toFixed(1)}G`;
16
+ if (bytes >= 1e6)
17
+ return `${(bytes / 1e6).toFixed(1)}M`;
18
+ if (bytes >= 1e3)
19
+ return `${Math.round(bytes / 1e3)}K`;
20
+ return `${bytes}B`;
21
+ }
11
22
  async function runDoctor(recheck = false) {
12
23
  console.log('\n=== Tersio Doctor ===');
13
24
  // OMP loads the plugin from its plugins dir; pi loads the same package as a pi package, which its own settings.json declares and its npm dir holds.
@@ -15,11 +26,12 @@ async function runDoctor(recheck = false) {
15
26
  const rtkBin = resolveRtkBinary();
16
27
  // Ponytail
17
28
  const ponytailPkg = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
18
- const tersioPluginDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio');
19
- const cavemanIndex = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts');
20
- const cavemanRule = (name) => path.join(tersioPluginDir, 'extensions', 'caveman-session', name);
21
- const rtkIndex = path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts');
22
- const updaterIndex = path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts');
29
+ // One source for the installed plugin path, so the doctor follows a package rename.
30
+ const tersioPluginDir = ompPackageDir();
31
+ const cavemanIndex = path.join(tersioPluginDir, 'extensions', 'caveman', 'index.ts');
32
+ const cavemanRule = (name) => path.join(tersioPluginDir, 'extensions', 'caveman', name);
33
+ const rtkIndex = path.join(tersioPluginDir, 'extensions', 'rtk', 'session.ts');
34
+ const updaterIndex = path.join(tersioPluginDir, 'extensions', 'updater', 'index.ts');
23
35
  // Independent probes start concurrently; sections report in fixed order as their data settles. Every probe resolves instead of rejecting.
24
36
  const hosts = detectHosts();
25
37
  const probes = {
@@ -131,6 +143,11 @@ async function runDoctor(recheck = false) {
131
143
  console.log(` Usage DB (tersio-owned · local hosted): ${usageDbPath()}`);
132
144
  const pricesAge = pricesMtime ? `(pulled ${relTime(Date.now() - pricesMtime.mtimeMs)} · ${absDate(pricesMtime.mtimeMs)})` : '';
133
145
  check('Prices feed', pricesMtime !== null, pricesAge || pricesCachePath());
146
+ // The pages read omp's own index, which only advances while an omp process owns it. A stalled
147
+ // index reads as an empty dashboard, so name the gap instead of leaving a user to guess.
148
+ const indexLag = statsIndexLag();
149
+ if (indexLag)
150
+ warnLine('omp stats index', indexLag);
134
151
  section('Add-ons');
135
152
  const ruleAge = ruleMtime ? `(updated ${relTime(Date.now() - ruleMtime.mtimeMs)} · ${absDate(ruleMtime.mtimeMs)})` : '';
136
153
  const cavemanRules = [['rule.md', cavemanRuleText], ['rule-ultra.md', cavemanUltraText], ['rule-megacave.md', cavemanMegacaveText]];
@@ -252,10 +269,55 @@ async function runDoctorFix(tally) {
252
269
  console.log('\n Doctor --fix: repairs done — rechecking.');
253
270
  const again = await runDoctor(true);
254
271
  if (again.missing + again.warn === 0)
255
- console.log(' Doctor --fix: all checks pass. Restart OMP.');
272
+ console.log(' Doctor --fix: all checks pass. Restart your agent.');
256
273
  else
257
274
  console.log(` Doctor --fix: still ${again.missing} missing + ${again.warn} warn — rerun or repair manually.`);
258
275
  }
276
+ /**
277
+ * How far omp's own transcript index falls behind the transcripts on disk, or null when nothing is
278
+ * behind or the index cannot be read. omp advances the offsets only while one of its processes owns
279
+ * the index, so a closed dashboard leaves the newest turns unread and the pages read as empty.
280
+ */
281
+ function statsIndexLag() {
282
+ const statsDb = statsDbPath();
283
+ if (!existsSync(statsDb))
284
+ return null;
285
+ try {
286
+ // The same WAL-safe reader the aggregate uses, so this probe never creates side files beside a
287
+ // database omp has open, and never trips over them either.
288
+ const rows = sqliteScript(statsDb, 'SELECT session_file, offset FROM file_offsets;\n')
289
+ .split('\n')
290
+ .filter((line) => line.includes('\t'))
291
+ .map((line) => line.split('\t'));
292
+ if (rows.length === 0)
293
+ return null;
294
+ // The gap behind the freshest transcript is what a page shows as "no requests", so the newest
295
+ // file sets the number rather than the sum of every file's gap.
296
+ let worstBytes = 0;
297
+ let freshest = '';
298
+ for (const [file, offsetText] of rows) {
299
+ let size;
300
+ try {
301
+ size = statSync(file).size;
302
+ }
303
+ catch {
304
+ continue; // a rotated or deleted transcript is not a lag
305
+ }
306
+ const behind = size - Number(offsetText);
307
+ if (behind > worstBytes) {
308
+ worstBytes = behind;
309
+ freshest = file;
310
+ }
311
+ }
312
+ if (worstBytes < 4096)
313
+ return null;
314
+ const when = relTime(Date.now() - statSync(statsDb).mtimeMs);
315
+ return `${shortBytes(worstBytes)} of ${path.basename(freshest)} unread, last written ${when === 'just now' ? 'moments ago' : when} — run: omp stats`;
316
+ }
317
+ catch {
318
+ return null; // no sqlite3, or an unreadable index: nothing to report
319
+ }
320
+ }
259
321
  function section(name) {
260
322
  console.log(`\n${name}`);
261
323
  }
package/dist/cli/hosts.js CHANGED
@@ -2,8 +2,8 @@
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { HOME, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION } from './common.js';
5
- import { TREE_FILES } from './manifest.js';
6
- import { piAgentDir } from '../extensions/lib/utils.js';
5
+ import { treeFilesFor } from './manifest.js';
6
+ import { piAgentDir } from '../extensions/shared/utils.js';
7
7
  export function hostExtensionsDir(id, agentDir = piAgentDir()) {
8
8
  if (id === 'omp')
9
9
  return path.join(HOME, '.omp', 'agent', 'extensions');
@@ -11,9 +11,9 @@ export function hostExtensionsDir(id, agentDir = piAgentDir()) {
11
11
  return path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
12
12
  return path.join(agentDir, 'extensions');
13
13
  }
14
- export function missingTreeFiles(id, agentDir = piAgentDir()) {
14
+ function missingTreeFiles(id, agentDir = piAgentDir()) {
15
15
  const dir = hostExtensionsDir(id, agentDir);
16
- return TREE_FILES.filter((file) => !existsSync(path.join(dir, file)));
16
+ return treeFilesFor(id).filter((file) => !existsSync(path.join(dir, file)));
17
17
  }
18
18
  export function ompPackageDir() {
19
19
  return path.join(OMP_PLUGINS_DIR, 'node_modules', ...PACKAGE_NAME.split('/'));
@@ -1,9 +1,9 @@
1
1
  // cli/install.ts — install flow and all setup steps.
2
- import { promises as fs } from 'node:fs';
2
+ import { existsSync, promises as fs } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { BUN_BIN_DIR, COMBO_PRESET_MODES, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, args, allowUnverified, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
6
+ import { BUN_BIN_DIR, COMBO_PRESET_MODES, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, args, allowUnverified, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, verbose, yes, dashboardExport, dashboardPort, currency, debug, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
7
7
  import { askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, withInteractiveTask, sayTagged } from './interactive.js';
8
8
  import { printWelcome } from './banner.js';
9
9
  import { checkForUpdate, runLatestUpdate } from './update.js';
@@ -13,11 +13,11 @@ import { runReset } from './reset.js';
13
13
  import { runUsage } from './usage.js';
14
14
  import { runDashboard } from './dashboard.js';
15
15
  import { wireRtkOmp, wireRtkOpencode, wireRtkPi } from './rtk-wiring.js';
16
- import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_MEGACAVE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, findPackageRoot, httpsDownload, parseChecksum, piAgentDir, readTextIfExists, resolveRtkBinary, ruleBodyProblem, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
16
+ import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_MEGACAVE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, findPackageRoot, httpsDownload, parseChecksum, piAgentDir, readTextIfExists, resolveRtkBinary, ruleBodyProblem, rtkPlatformSpec, sha256File, } from '../extensions/shared/utils.js';
17
17
  import { formatCliStatus, storedProfile, storedProfileSync, writePluginSettings } from './profile.js';
18
18
  import { runSettings } from './settings.js';
19
19
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
20
- import { OPENCODE_SERVER_SHIM, filesUnder, sourcePath } from './manifest.js';
20
+ import { filesUnder, rootFiles, sourcePath } from './manifest.js';
21
21
  import { detectHosts, hostHint, hostLabel, parseHostArg, piTersioSource } from './hosts.js';
22
22
  async function stepPonytail(pluginsDir, options) {
23
23
  if (!options.quiet)
@@ -229,6 +229,38 @@ async function extractRtkArchive(archivePath, extractDir) {
229
229
  sayTagged(` [fail] Unknown archive format: ${archivePath}`);
230
230
  return false;
231
231
  }
232
+ // The agent tree keeps only rtk's own wiring file. Every Tersio extension loads from the
233
+ // OMP plugin manifest, which omp merges with the extension dir it discovers by default, so a
234
+ // copy left in the agent tree loads a second time. `caveman-session` was never written here
235
+ // after the plugin registration shipped, so an old install holds a set that cannot resolve
236
+ // its own rule bodies.
237
+ const RETIRED_OMP_AGENT_DIRS = [
238
+ 'caveman-session',
239
+ 'rtk-session',
240
+ 'rtk-filter',
241
+ 'combo-toggle',
242
+ 'tersio-commands',
243
+ 'ai-addons-updater',
244
+ 'shared',
245
+ 'lib',
246
+ ];
247
+ /** Drop the copies an older OMP install left in the agent tree, keeping rtk's `rtk.ts`. */
248
+ async function stepRetireAgentCopies(extDir, options) {
249
+ const stale = RETIRED_OMP_AGENT_DIRS.filter((dir) => existsSync(path.join(extDir, dir)));
250
+ if (stale.length === 0) {
251
+ if (!options.quiet)
252
+ console.log(' Retired agent copies — none');
253
+ return;
254
+ }
255
+ if (!options.quiet)
256
+ console.log(` Retired agent copies — remove ${stale.join(', ')}`);
257
+ if (options.dryRun) {
258
+ if (verbose)
259
+ sayTagged(` [dry-run] would remove ${stale.map((d) => path.join(extDir, d)).join(', ')}`);
260
+ return;
261
+ }
262
+ await Promise.all(stale.map((dir) => fs.rm(path.join(extDir, dir), { recursive: true, force: true })));
263
+ }
232
264
  // The binary is machine-wide; hosts without a hook only rebind. `tersio update` still refreshes it.
233
265
  async function stepRtk(binDir, options, target = 'omp') {
234
266
  const binDest = path.join(binDir, RTK_BINARY_NAME);
@@ -348,13 +380,18 @@ async function copySources(extDir, files, skipLabel, options) {
348
380
  async function stepSharedSessionState(extDir, options) {
349
381
  if (!options.quiet)
350
382
  console.log(' Shared files — sync session bridge');
351
- const shared = [...filesUnder('shared'), ...filesUnder('lib')];
352
- await copySources(extDir, shared, 'shared/session-state.js', options);
383
+ await copySources(extDir, filesUnder('shared'), 'shared/session-state.js', options);
353
384
  }
354
385
  async function stepRtkSession(extDir, options) {
355
386
  if (!options.quiet)
356
387
  console.log(' RTK session — install session mode');
357
- await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
388
+ await copySources(extDir, filesUnder('rtk'), 'rtk/session.ts', options);
389
+ }
390
+ // pi's built-in grep and glob never reach the bash hook, so this one filters their output.
391
+ async function stepRtkFilter(extDir, options) {
392
+ if (!options.quiet)
393
+ console.log(' RTK filter — install built-in tool output filtering');
394
+ await copySources(extDir, filesUnder('rtk'), 'rtk/filter.ts', options);
358
395
  }
359
396
  // rule.md ships with the package; the other bodies track upstream skills.
360
397
  // rule.md is a deliberate fork of upstream skills/caveman/SKILL.md; a blind sync would drop the mode switch line and the STE100 rules.
@@ -366,11 +403,11 @@ const CAVEMAN_RULES = [
366
403
  async function stepCaveman(extDir, options) {
367
404
  if (!options.quiet)
368
405
  console.log(' Caveman — install session mode and rule bodies');
369
- const cavemanDir = path.join(extDir, 'caveman-session');
406
+ const cavemanDir = path.join(extDir, 'caveman');
370
407
  if (!options.dryRun)
371
408
  await fs.mkdir(cavemanDir, { recursive: true });
372
409
  for (const [name, remote] of CAVEMAN_RULES) {
373
- const bundled = await readTextIfExists(sourcePath(`caveman-session/${name}`));
410
+ const bundled = await readTextIfExists(sourcePath(`caveman/${name}`));
374
411
  let rule = bundled;
375
412
  if (remote && !options.dryRun) {
376
413
  try {
@@ -399,7 +436,7 @@ async function stepCaveman(extDir, options) {
399
436
  await writeIfChanged(path.join(cavemanDir, name), rule, options);
400
437
  }
401
438
  }
402
- await copySources(extDir, filesUnder('caveman-session').filter(([, to]) => to.endsWith('index.ts')), 'caveman-session/index.ts', options);
439
+ await copySources(extDir, filesUnder('caveman').filter(([, to]) => to.endsWith('index.ts')), 'caveman/index.ts', options);
403
440
  // Renamed body: drop the stale copy an older install left behind.
404
441
  if (!options.dryRun)
405
442
  await fs.rm(path.join(cavemanDir, 'rule-wenyan.md'), { force: true });
@@ -407,15 +444,15 @@ async function stepCaveman(extDir, options) {
407
444
  async function stepTersioCommands(extDir, options) {
408
445
  if (!options.quiet)
409
446
  console.log(' Tersio commands — install /tersio root command');
410
- await copySources(extDir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
447
+ await copySources(extDir, filesUnder('commands'), 'commands/index.ts', options);
411
448
  }
412
449
  async function stepUpdater(extDir, options) {
413
- await copySources(extDir, filesUnder('ai-addons-updater'), 'ai-addons-updater/index.ts', options);
450
+ await copySources(extDir, filesUnder('updater'), 'updater/index.ts', options);
414
451
  }
415
452
  async function stepCombo(extDir, options) {
416
453
  if (!options.quiet)
417
454
  console.log(' Combo — install preset switch');
418
- await copySources(extDir, filesUnder('combo-toggle'), 'combo-toggle/index.ts', options);
455
+ await copySources(extDir, filesUnder('combo'), 'combo/index.ts', options);
419
456
  }
420
457
  // The tree ships no node_modules, so the full ruleset rides along as files.
421
458
  const PONYTAIL_BUNDLE_FILES = [
@@ -451,17 +488,15 @@ async function stepOpencode(options) {
451
488
  if (!options.quiet)
452
489
  console.log(` OpenCode — write the plugin tree (${dir})`);
453
490
  await stepSharedSessionState(dir, options);
454
- await copySources(dir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
455
- await copySources(dir, filesUnder('caveman-session'), 'caveman-session/index.ts', options);
491
+ await copySources(dir, filesUnder('rtk'), 'rtk/session.ts', options);
492
+ await stepRtkFilter(dir, options);
493
+ await copySources(dir, filesUnder('caveman'), 'caveman/index.ts', options);
456
494
  await stepCombo(dir, options);
457
- await copySources(dir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
495
+ await copySources(dir, filesUnder('commands'), 'commands/index.ts', options);
458
496
  await stepUpdater(dir, options);
459
- await copySources(dir, filesUnder('opencode'), 'opencode/server.ts', options);
460
497
  await stepPonytailBundle(dir, options);
461
- // The loader resolves <pluginDir>/server.ts, so mirror the entry at the tree root.
462
- await writeIfChanged(path.join(dir, 'server.ts'), OPENCODE_SERVER_SHIM, options);
463
- if (!options.dryRun)
464
- await fs.rm(path.join(dir, 'opencode', 'index.ts'), { force: true }).catch(() => { });
498
+ // The loader resolves <pluginDir>/server.ts, and the entry is that file: copy it in place.
499
+ await copySources(dir, rootFiles(), 'server.ts', options);
465
500
  const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
466
501
  let config = {};
467
502
  try {
@@ -609,7 +644,7 @@ async function runCommandMenu() {
609
644
  closeRL();
610
645
  break;
611
646
  case 'dashboard':
612
- await runDashboard({ port: dashboardPort, open: true, exportFile: dashboardExport });
647
+ await runDashboard({ port: dashboardPort, open: true, exportFile: dashboardExport, currency });
613
648
  closeRL();
614
649
  break;
615
650
  case 'reset':
@@ -659,8 +694,9 @@ async function stepPiLayer(options) {
659
694
  if (!options.quiet)
660
695
  console.log(` Pi — write the extension tree (${extDir})`);
661
696
  await stepSharedSessionState(extDir, options);
662
- // OMP loads rtk-session from its plugin manifest, so only the pi tree copies it.
663
- await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
697
+ // OMP loads rtk/session.ts from its plugin manifest, so only the pi tree copies it.
698
+ await copySources(extDir, filesUnder('rtk'), 'rtk/session.ts', options);
699
+ await stepRtkFilter(extDir, options);
664
700
  await stepCaveman(extDir, options);
665
701
  await stepCombo(extDir, options);
666
702
  await stepTersioCommands(extDir, options);
@@ -700,14 +736,30 @@ async function stepPonytailForPi(agentDir, options) {
700
736
  }
701
737
  if (options.dryRun)
702
738
  return;
739
+ // Only when pi still declares the npm spec: with nothing to remove, `pi remove` still
740
+ // shells out to npm, which warns on stderr and exits non-zero.
741
+ const declared = await piDeclaredPackages(agentDir);
742
+ if (declared.some(isNpmPonytailPackage)) {
743
+ try {
744
+ await execNetwork('Removing the pi npm copy of Ponytail', 'pi', ['remove', 'npm:@dietrichgebert/ponytail'], { timeout: 120000 });
745
+ }
746
+ catch (e) {
747
+ sayTagged(` [warn] pi remove ponytail: ${shortError(e)}`);
748
+ }
749
+ }
750
+ await registerPiLocalPonytail(agentDir, dest, options);
751
+ }
752
+ // pi declares its package sources in settings.json; [] when the file is absent or unreadable.
753
+ async function piDeclaredPackages(agentDir) {
703
754
  try {
704
- await execNetwork('Removing the pi npm copy of Ponytail', 'pi', ['remove', 'npm:@dietrichgebert/ponytail'], { timeout: 120000 });
755
+ const settings = JSON.parse(await fs.readFile(path.join(agentDir, 'settings.json'), 'utf8'));
756
+ return Array.isArray(settings.packages) ? settings.packages : [];
705
757
  }
706
- catch (e) {
707
- sayTagged(` [warn] pi remove ponytail: ${shortError(e)}`);
758
+ catch {
759
+ return [];
708
760
  }
709
- await registerPiLocalPonytail(agentDir, dest, options);
710
761
  }
762
+ const isNpmPonytailPackage = (value) => typeof value === 'string' && value.startsWith('npm:@dietrichgebert/ponytail');
711
763
  // pi lists package sources in settings; swap the npm spec for the local dir.
712
764
  async function registerPiLocalPonytail(agentDir, dest, options) {
713
765
  const settingsPath = path.join(agentDir, 'settings.json');
@@ -719,7 +771,7 @@ async function registerPiLocalPonytail(agentDir, dest, options) {
719
771
  return;
720
772
  }
721
773
  const packages = (Array.isArray(settings.packages) ? settings.packages : []);
722
- const kept = packages.filter((p) => typeof p !== 'string' || !p.startsWith('npm:@dietrichgebert/ponytail'));
774
+ const kept = packages.filter((p) => !isNpmPonytailPackage(p));
723
775
  if (!kept.includes(dest))
724
776
  kept.push(dest);
725
777
  settings.packages = kept;
@@ -727,6 +779,10 @@ async function registerPiLocalPonytail(agentDir, dest, options) {
727
779
  if (!options.quiet)
728
780
  sayTagged(` [write] pi package source → ${dest}`);
729
781
  }
782
+ // Name the preset this run actually chose, instead of a hardcoded level.
783
+ function comboHint(host, comboDefault) {
784
+ return comboDefault === 'off' ? `\nDone — restart ${host}.` : `\nDone — restart ${host}, then /combo ${comboDefault}.`;
785
+ }
730
786
  async function runInstall() {
731
787
  // Without this guard a picked command re-enters here with command === null and loops straight back into the picker.
732
788
  if (command === null && tty() && !yes && !updatePromptDone) {
@@ -830,7 +886,7 @@ async function runInstall() {
830
886
  if (failures.length > 0)
831
887
  console.log(`\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
832
888
  else
833
- console.log('\nDone — restart OpenCode, then /combo balanced.');
889
+ console.log(comboHint('OpenCode', profile.comboDefault));
834
890
  closeRL();
835
891
  return;
836
892
  }
@@ -841,20 +897,32 @@ async function runInstall() {
841
897
  if (failures.length > 0)
842
898
  console.log(`\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
843
899
  else
844
- console.log('\nDone — restart pi, then /combo medium.');
900
+ console.log(comboHint('pi', profile.comboDefault));
845
901
  closeRL();
846
902
  return;
847
903
  }
848
- await capture('shared', () => stepSharedSessionState(userExtDir, installOptions));
849
904
  let selfPlugin = false;
850
905
  await capture('self-plugin', async () => { selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, installOptions); });
851
906
  await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, installOptions));
852
907
  await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions));
853
- await capture('rtk session', () => stepRtkSession(userExtDir, installOptions));
854
- await capture('caveman', () => stepCaveman(path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions'), installOptions));
855
- await capture('combo', () => stepCombo(userExtDir, installOptions));
856
- await capture('commands', () => stepTersioCommands(userExtDir, installOptions));
857
- await capture('updater', () => stepUpdater(userExtDir, installOptions));
908
+ // A real install registers the plugin before it writes, so the OMP plugin manifest owns
909
+ // every mode extension. A dry run previews that same destination. A registration that
910
+ // failed is the one fallback: the agent tree then carries the tree so an offline install
911
+ // still loads its add-ons, and the retired copies stay untouched.
912
+ const pluginExtDir = path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions');
913
+ const ownsTree = selfPlugin || dryRun;
914
+ const extDir = ownsTree ? pluginExtDir : userExtDir;
915
+ if (!quiet)
916
+ console.log(` Add-ons load from: ${ownsTree ? 'the OMP plugin manifest' : 'the agent tree (plugin registration failed)'}`);
917
+ await capture('shared', () => stepSharedSessionState(extDir, installOptions));
918
+ await capture('rtk session', () => stepRtkSession(extDir, installOptions));
919
+ await capture('rtk filter', () => stepRtkFilter(extDir, installOptions));
920
+ await capture('caveman', () => stepCaveman(extDir, installOptions));
921
+ await capture('combo', () => stepCombo(extDir, installOptions));
922
+ await capture('commands', () => stepTersioCommands(extDir, installOptions));
923
+ await capture('updater', () => stepUpdater(extDir, installOptions));
924
+ if (ownsTree)
925
+ await capture('retired agent copies', () => stepRetireAgentCopies(userExtDir, installOptions));
858
926
  if (selfPlugin)
859
927
  await capture('settings', () => writePluginSettings(profile, installOptions));
860
928
  if (quiet) {
@@ -862,7 +930,7 @@ async function runInstall() {
862
930
  console.log(` add-ons: ${failures.length} failed (${failures.join(', ')}) — see [fail] lines above`);
863
931
  }
864
932
  else {
865
- console.log(failures.length === 0 ? '\nDone — restart OMP, then /combo medium.' : `\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
933
+ console.log(failures.length === 0 ? comboHint('OMP', profile.comboDefault) : `\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
866
934
  }
867
935
  closeRL();
868
936
  }
@@ -1,13 +1,9 @@
1
1
  // Single list of install writes, shared by installer, doctor, and tests.
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { findPackageRoot } from '../extensions/lib/utils.js';
4
+ import { findPackageRoot, readTextIfExists } from '../extensions/shared/utils.js';
5
+ import { writeIfChanged } from './common.js';
5
6
  export const EXT_DIR = path.join(findPackageRoot(path.dirname(fileURLToPath(import.meta.url))), 'extensions');
6
- /** Root entrypoint OpenCode resolves for the installed tree; re-exports the versioned entry beside it. Built by join so the path never reads as a relative import (repo-integrity scans literals). */
7
- const SERVER_SPEC = `./${['opencode', 'server.ts'].join('/')}`;
8
- export const OPENCODE_SERVER_SHIM = `// Generated by \`tersio install --host opencode\`: OpenCode resolves <pluginDir>/server.ts.
9
- export { default } from "${SERVER_SPEC}";
10
- `;
11
7
  /** Every file in the tree, as paths relative to the extensions directory. */
12
8
  export const TREE_FILES = [
13
9
  'shared/host.ts',
@@ -18,16 +14,17 @@ export const TREE_FILES = [
18
14
  'shared/usage-ledger.ts',
19
15
  'shared/pricing.ts',
20
16
  'shared/carbon.ts',
21
- 'lib/utils.ts',
22
- 'caveman-session/index.ts',
23
- 'caveman-session/rule.md',
24
- 'caveman-session/rule-ultra.md',
25
- 'caveman-session/rule-megacave.md',
26
- 'rtk-session/index.ts',
27
- 'combo-toggle/index.ts',
28
- 'tersio-commands/index.ts',
29
- 'ai-addons-updater/index.ts',
30
- 'opencode/server.ts',
17
+ 'shared/utils.ts',
18
+ 'caveman/index.ts',
19
+ 'caveman/rule.md',
20
+ 'caveman/rule-ultra.md',
21
+ 'caveman/rule-megacave.md',
22
+ 'rtk/session.ts',
23
+ 'rtk/filter.ts',
24
+ 'combo/index.ts',
25
+ 'commands/index.ts',
26
+ 'updater/index.ts',
27
+ 'server.ts',
31
28
  ];
32
29
  export function sourcePath(relative) {
33
30
  return path.join(EXT_DIR, ...relative.split('/'));
@@ -41,3 +38,35 @@ export function filesUnder(dir) {
41
38
  }
42
39
  return found;
43
40
  }
41
+ /** The entries at the extensions root, which `filesUnder` cannot name because it takes a directory. */
42
+ export function rootFiles() {
43
+ return TREE_FILES.filter((file) => !file.includes('/')).map((file) => [sourcePath(file), file]);
44
+ }
45
+ /** The OpenCode plugin entry. It imports `@opencode/plugin`, which the omp and pi loaders lack, so it ships in the OpenCode tree alone. */
46
+ export const OPENCODE_ENTRY = 'server.ts';
47
+ /** The files one host's extension tree holds. The package tree holds all of them. */
48
+ export function treeFilesFor(id) {
49
+ return id === 'opencode' ? TREE_FILES : TREE_FILES.filter((file) => file !== OPENCODE_ENTRY);
50
+ }
51
+ /**
52
+ * Copy every bundled file into `dest`, creating the directories it needs. A file the bundle does not
53
+ * carry is reported and skipped rather than aborting, so one missing source cannot stop the rest.
54
+ *
55
+ * `skip` answers "leave this file alone", which is how the rule bodies survive a repair: an upstream
56
+ * fetch can be newer than the bundled copy, and a re-fetch never overwrites it.
57
+ */
58
+ export async function syncTreeFiles(dest, files, options, skip) {
59
+ const missing = [];
60
+ for (const file of files) {
61
+ if (skip?.(file))
62
+ continue;
63
+ const from = sourcePath(file);
64
+ const text = await readTextIfExists(from);
65
+ if (text === null) {
66
+ missing.push(from);
67
+ continue;
68
+ }
69
+ await writeIfChanged(path.join(dest, ...file.split('/')), text, options);
70
+ }
71
+ return missing;
72
+ }
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { CAVEMAN_DEFAULTS, COMBO_PRESET_MODES, PACKAGE_NAME, PONYTAIL_DEFAULTS, verbose, } from './common.js';
5
5
  import os from 'node:os';
6
6
  import { DEFAULT_CURRENCY, isCurrencyCode } from './currency.js';
7
- import { readTextIfExists } from '../extensions/lib/utils.js';
7
+ import { readTextIfExists } from '../extensions/shared/utils.js';
8
8
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
9
9
  function readTextIfExistsSync(file) {
10
10
  try {
@@ -4,19 +4,29 @@ import path from 'node:path';
4
4
  import { BUN_BIN_DIR, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, RTK_BINARY_NAME, args, dryRun, keepPonytail, removePonytail, removeRtk, yes, debug, writeConfigLines, } from './common.js';
5
5
  import { askInteractiveChoice, closeRL, confirmDestructive, tty, sayTagged } from './interactive.js';
6
6
  import { detectHosts, hostHint, hostLabel, parseHostArg } from './hosts.js';
7
- import { piAgentDir, readTextIfExists } from '../extensions/lib/utils.js';
7
+ import { piAgentDir, readTextIfExists } from '../extensions/shared/utils.js';
8
8
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
9
9
  // The pi tree mirrors the OMP one. Ponytail and rtk stay: a package and a shared binary are not ours to delete.
10
10
  const PONYTAIL_PKG = '@dietrichgebert/ponytail';
11
11
  const PI_TREE_DIRS = [
12
+ // What an install writes now.
13
+ 'caveman',
14
+ 'rtk',
15
+ 'updater',
16
+ 'combo',
17
+ 'commands',
18
+ 'shared',
19
+ 'server.ts',
20
+ // Legacy: a tree an older build wrote, or a foreign name that was never shipped here.
12
21
  'caveman-session',
13
22
  'rtk-session',
23
+ 'rtk-filter',
14
24
  'ai-addons-updater',
15
25
  'combo-toggle',
16
26
  'tersio-commands',
17
- 'shared',
18
27
  'lib',
19
28
  'opencode',
29
+ 'aaa-combo-boot',
20
30
  ];
21
31
  // One `pi remove`, for both packages the installer adds there.
22
32
  async function piRemove(spec, shouldDryRun) {
@@ -237,15 +247,21 @@ async function runUninstall(options = {}) {
237
247
  const pluginsDir = OMP_PLUGINS_DIR;
238
248
  const ponytailPkgDir = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail');
239
249
  const targets = [
250
+ // What an install writes now.
251
+ 'caveman',
252
+ 'rtk',
253
+ 'updater',
254
+ 'combo',
255
+ 'commands',
256
+ 'shared',
257
+ // Legacy: a tree an older build wrote into the agent tree, or a foreign name never shipped here.
240
258
  'caveman-session',
241
259
  'rtk-session',
260
+ 'rtk-filter',
242
261
  'ai-addons-updater',
243
262
  'combo-toggle',
244
263
  'tersio-commands',
245
- 'shared',
246
- // Only the updater reads it, and it runs before this.
247
264
  'lib',
248
- // Legacy: imports session-state.js, so it warns once that module is gone.
249
265
  'aaa-combo-boot',
250
266
  // Foreign to the OMP loader; never shipped here on purpose.
251
267
  'opencode',
@@ -4,9 +4,9 @@ import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { IS_WINDOWS, OMP_PLUGINS_DIR, PACKAGE_BIN, PACKAGE_NAME, PACKAGE_VERSION, args, dryRun, execP, parseJsonObject, verbose, } from './common.js';
6
6
  import { execNetwork, sayTagged } from './interactive.js';
7
- import { readTextIfExists, resolveRtkBinary, tersioDataPath } from '../extensions/lib/utils.js';
7
+ import { readTextIfExists, resolveRtkBinary, tersioDataPath } from '../extensions/shared/utils.js';
8
8
  import { refreshPrices } from '../extensions/shared/pricing.js';
9
- import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_MEGACAVE, RTK_RELEASE_API, httpsGet, normalizeRtkVersion, sha256Hex, } from '../extensions/lib/utils.js';
9
+ import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_MEGACAVE, RTK_RELEASE_API, httpsGet, normalizeRtkVersion, sha256Hex, } from '../extensions/shared/utils.js';
10
10
  import { parseHostArg } from './hosts.js';
11
11
  const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
12
12
  function newerThan(a, b) {
@@ -79,7 +79,7 @@ async function probeUpdatePlan(cliLatest) {
79
79
  const rtkBin = resolveRtkBinary();
80
80
  const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
81
81
  // rule.md ships with the release; only the tracked bodies can go stale.
82
- const tersioRule = (name) => path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions', 'caveman-session', name);
82
+ const tersioRule = (name) => path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions', 'caveman', name);
83
83
  const controller = new AbortController();
84
84
  const [rtkLocal, rtkRelease, ruleLocal, ruleRemote, ponytailLocalText] = await Promise.all([
85
85
  settle(rtkBin ? execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => normalizeRtkVersion(r.stdout.trim() || r.stderr.trim()) || null) : Promise.resolve(null), 6000),