@krtclcdy/tersio 2.25.4 → 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 (52) hide show
  1. package/README.md +13 -30
  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 +41 -44
  7. package/dist/cli/doctor.js +69 -7
  8. package/dist/cli/hosts.js +4 -4
  9. package/dist/cli/install.js +71 -32
  10. package/dist/cli/manifest.js +45 -17
  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/{combo-toggle → combo}/index.js +1 -1
  16. package/dist/extensions/{tersio-commands → commands}/index.js +1 -1
  17. package/dist/extensions/{rtk-filter/index.js → rtk/filter.js} +1 -1
  18. package/dist/extensions/{rtk-session/index.js → rtk/session.js} +1 -1
  19. package/dist/extensions/{opencode/server.js → server.js} +8 -8
  20. package/dist/extensions/shared/agent-stats.js +1262 -0
  21. package/dist/extensions/shared/brand-marks.js +154 -0
  22. package/dist/extensions/shared/plugin-settings.js +1 -1
  23. package/dist/extensions/shared/pricing.js +91 -27
  24. package/dist/extensions/shared/usage-ledger.js +79 -17
  25. package/dist/extensions/shared/usage-store.js +106 -22
  26. package/dist/extensions/{lib → shared}/utils.js +1 -1
  27. package/dist/extensions/{ai-addons-updater → updater}/index.js +13 -3
  28. package/dist/probe-catalog.js +48 -0
  29. package/dist/probe-catalog188.js +44 -0
  30. package/dist/tersio.js +2 -2
  31. package/extensions/{combo-toggle → combo}/index.ts +1 -1
  32. package/extensions/{tersio-commands → commands}/index.ts +1 -1
  33. package/extensions/{rtk-filter/index.ts → rtk/filter.ts} +1 -1
  34. package/extensions/{rtk-session/index.ts → rtk/session.ts} +1 -1
  35. package/extensions/{opencode/server.ts → server.ts} +9 -9
  36. package/extensions/shared/agent-stats.ts +1657 -0
  37. package/extensions/shared/brand-marks.ts +163 -0
  38. package/extensions/shared/plugin-settings.ts +1 -1
  39. package/extensions/shared/pricing.ts +107 -28
  40. package/extensions/shared/usage-ledger.ts +94 -19
  41. package/extensions/shared/usage-store.ts +106 -22
  42. package/extensions/{lib → shared}/utils.ts +1 -1
  43. package/extensions/{ai-addons-updater → updater}/index.ts +15 -4
  44. package/package.json +13 -13
  45. /package/dist/extensions/{caveman-session → caveman}/index.js +0 -0
  46. /package/dist/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  47. /package/dist/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  48. /package/dist/extensions/{caveman-session → caveman}/rule.md +0 -0
  49. /package/extensions/{caveman-session → caveman}/index.ts +0 -0
  50. /package/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  51. /package/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  52. /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]];
@@ -256,6 +273,51 @@ async function runDoctorFix(tally) {
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,19 +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);
358
389
  }
359
390
  // pi's built-in grep and glob never reach the bash hook, so this one filters their output.
360
391
  async function stepRtkFilter(extDir, options) {
361
392
  if (!options.quiet)
362
393
  console.log(' RTK filter — install built-in tool output filtering');
363
- await copySources(extDir, filesUnder('rtk-filter'), 'rtk-filter/index.ts', options);
394
+ await copySources(extDir, filesUnder('rtk'), 'rtk/filter.ts', options);
364
395
  }
365
396
  // rule.md ships with the package; the other bodies track upstream skills.
366
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.
@@ -372,11 +403,11 @@ const CAVEMAN_RULES = [
372
403
  async function stepCaveman(extDir, options) {
373
404
  if (!options.quiet)
374
405
  console.log(' Caveman — install session mode and rule bodies');
375
- const cavemanDir = path.join(extDir, 'caveman-session');
406
+ const cavemanDir = path.join(extDir, 'caveman');
376
407
  if (!options.dryRun)
377
408
  await fs.mkdir(cavemanDir, { recursive: true });
378
409
  for (const [name, remote] of CAVEMAN_RULES) {
379
- const bundled = await readTextIfExists(sourcePath(`caveman-session/${name}`));
410
+ const bundled = await readTextIfExists(sourcePath(`caveman/${name}`));
380
411
  let rule = bundled;
381
412
  if (remote && !options.dryRun) {
382
413
  try {
@@ -405,7 +436,7 @@ async function stepCaveman(extDir, options) {
405
436
  await writeIfChanged(path.join(cavemanDir, name), rule, options);
406
437
  }
407
438
  }
408
- 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);
409
440
  // Renamed body: drop the stale copy an older install left behind.
410
441
  if (!options.dryRun)
411
442
  await fs.rm(path.join(cavemanDir, 'rule-wenyan.md'), { force: true });
@@ -413,15 +444,15 @@ async function stepCaveman(extDir, options) {
413
444
  async function stepTersioCommands(extDir, options) {
414
445
  if (!options.quiet)
415
446
  console.log(' Tersio commands — install /tersio root command');
416
- await copySources(extDir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
447
+ await copySources(extDir, filesUnder('commands'), 'commands/index.ts', options);
417
448
  }
418
449
  async function stepUpdater(extDir, options) {
419
- await copySources(extDir, filesUnder('ai-addons-updater'), 'ai-addons-updater/index.ts', options);
450
+ await copySources(extDir, filesUnder('updater'), 'updater/index.ts', options);
420
451
  }
421
452
  async function stepCombo(extDir, options) {
422
453
  if (!options.quiet)
423
454
  console.log(' Combo — install preset switch');
424
- await copySources(extDir, filesUnder('combo-toggle'), 'combo-toggle/index.ts', options);
455
+ await copySources(extDir, filesUnder('combo'), 'combo/index.ts', options);
425
456
  }
426
457
  // The tree ships no node_modules, so the full ruleset rides along as files.
427
458
  const PONYTAIL_BUNDLE_FILES = [
@@ -457,18 +488,15 @@ async function stepOpencode(options) {
457
488
  if (!options.quiet)
458
489
  console.log(` OpenCode — write the plugin tree (${dir})`);
459
490
  await stepSharedSessionState(dir, options);
460
- await copySources(dir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
491
+ await copySources(dir, filesUnder('rtk'), 'rtk/session.ts', options);
461
492
  await stepRtkFilter(dir, options);
462
- await copySources(dir, filesUnder('caveman-session'), 'caveman-session/index.ts', options);
493
+ await copySources(dir, filesUnder('caveman'), 'caveman/index.ts', options);
463
494
  await stepCombo(dir, options);
464
- await copySources(dir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
495
+ await copySources(dir, filesUnder('commands'), 'commands/index.ts', options);
465
496
  await stepUpdater(dir, options);
466
- await copySources(dir, filesUnder('opencode'), 'opencode/server.ts', options);
467
497
  await stepPonytailBundle(dir, options);
468
- // The loader resolves <pluginDir>/server.ts, so mirror the entry at the tree root.
469
- await writeIfChanged(path.join(dir, 'server.ts'), OPENCODE_SERVER_SHIM, options);
470
- if (!options.dryRun)
471
- 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);
472
500
  const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
473
501
  let config = {};
474
502
  try {
@@ -616,7 +644,7 @@ async function runCommandMenu() {
616
644
  closeRL();
617
645
  break;
618
646
  case 'dashboard':
619
- await runDashboard({ port: dashboardPort, open: true, exportFile: dashboardExport });
647
+ await runDashboard({ port: dashboardPort, open: true, exportFile: dashboardExport, currency });
620
648
  closeRL();
621
649
  break;
622
650
  case 'reset':
@@ -666,8 +694,8 @@ async function stepPiLayer(options) {
666
694
  if (!options.quiet)
667
695
  console.log(` Pi — write the extension tree (${extDir})`);
668
696
  await stepSharedSessionState(extDir, options);
669
- // OMP loads rtk-session from its plugin manifest, so only the pi tree copies it.
670
- 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);
671
699
  await stepRtkFilter(extDir, options);
672
700
  await stepCaveman(extDir, options);
673
701
  await stepCombo(extDir, options);
@@ -873,17 +901,28 @@ async function runInstall() {
873
901
  closeRL();
874
902
  return;
875
903
  }
876
- await capture('shared', () => stepSharedSessionState(userExtDir, installOptions));
877
904
  let selfPlugin = false;
878
905
  await capture('self-plugin', async () => { selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, installOptions); });
879
906
  await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, installOptions));
880
907
  await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions));
881
- await capture('rtk session', () => stepRtkSession(userExtDir, installOptions));
882
- await capture('rtk filter', () => stepRtkFilter(userExtDir, installOptions));
883
- await capture('caveman', () => stepCaveman(path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions'), installOptions));
884
- await capture('combo', () => stepCombo(userExtDir, installOptions));
885
- await capture('commands', () => stepTersioCommands(userExtDir, installOptions));
886
- 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));
887
926
  if (selfPlugin)
888
927
  await capture('settings', () => writePluginSettings(profile, installOptions));
889
928
  if (quiet) {
@@ -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,17 +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
- 'rtk-filter/index.ts',
28
- 'combo-toggle/index.ts',
29
- 'tersio-commands/index.ts',
30
- 'ai-addons-updater/index.ts',
31
- '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',
32
28
  ];
33
29
  export function sourcePath(relative) {
34
30
  return path.join(EXT_DIR, ...relative.split('/'));
@@ -42,3 +38,35 @@ export function filesUnder(dir) {
42
38
  }
43
39
  return found;
44
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),