@krtclcdy/tersio 2.25.4 → 2.25.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +27 -63
  2. package/dashboard/dist/index.html +18 -16
  3. package/dist/cli/backups.js +187 -0
  4. package/dist/cli/banner.js +4 -3
  5. package/dist/cli/common.js +29 -13
  6. package/dist/cli/currency.js +1 -1
  7. package/dist/cli/dashboard.js +168 -110
  8. package/dist/cli/doctor-fix.js +232 -50
  9. package/dist/cli/doctor.js +281 -32
  10. package/dist/cli/hosts.js +29 -9
  11. package/dist/cli/install.js +204 -69
  12. package/dist/cli/interactive.js +3 -3
  13. package/dist/cli/manifest.js +125 -17
  14. package/dist/cli/profile.js +14 -9
  15. package/dist/cli/rtk-wiring.js +1 -1
  16. package/dist/cli/settings.js +20 -5
  17. package/dist/cli/uninstall.js +24 -8
  18. package/dist/cli/update.js +9 -10
  19. package/dist/cli/usage.js +75 -7
  20. package/dist/extensions/{caveman-session → caveman}/index.js +4 -2
  21. package/{extensions/caveman-session → dist/extensions/caveman}/rule-megacave.md +5 -3
  22. package/{extensions/caveman-session → dist/extensions/caveman}/rule-ultra.md +4 -4
  23. package/dist/extensions/caveman/rule.md +89 -0
  24. package/dist/extensions/{combo-toggle → combo}/index.js +62 -21
  25. package/dist/extensions/{tersio-commands → commands}/index.js +2 -2
  26. package/dist/extensions/pstack/index.js +119 -0
  27. package/dist/extensions/pstack/rule.md +65 -0
  28. package/dist/extensions/{rtk-filter/index.js → rtk/filter.js} +1 -2
  29. package/dist/extensions/{rtk-session/index.js → rtk/session.js} +5 -5
  30. package/dist/extensions/{opencode/server.js → server.js} +98 -14
  31. package/dist/extensions/shared/agent-stats.js +1198 -0
  32. package/dist/extensions/shared/brand-marks.js +153 -0
  33. package/dist/extensions/shared/carbon.js +1 -1
  34. package/dist/extensions/shared/plugin-settings.js +12 -7
  35. package/dist/extensions/shared/pricing.js +86 -30
  36. package/dist/extensions/shared/rtk-gain.js +4 -9
  37. package/dist/extensions/shared/session-state.js +15 -10
  38. package/dist/extensions/shared/sqlite.js +168 -0
  39. package/dist/extensions/shared/status.js +8 -8
  40. package/dist/extensions/shared/types.js +1 -1
  41. package/dist/extensions/shared/usage-ledger.js +113 -39
  42. package/dist/extensions/shared/usage-store.js +191 -48
  43. package/dist/extensions/{lib → shared}/utils.js +6 -6
  44. package/dist/extensions/timing/index.js +31 -0
  45. package/dist/extensions/{ai-addons-updater → updater}/index.js +13 -6
  46. package/dist/probe-catalog.js +48 -0
  47. package/dist/probe-catalog188.js +44 -0
  48. package/dist/tersio.js +9 -4
  49. package/extensions/{caveman-session → caveman}/index.ts +4 -2
  50. package/{dist/extensions/caveman-session → extensions/caveman}/rule-megacave.md +5 -3
  51. package/{dist/extensions/caveman-session → extensions/caveman}/rule-ultra.md +4 -4
  52. package/extensions/caveman/rule.md +89 -0
  53. package/extensions/{combo-toggle → combo}/index.ts +62 -19
  54. package/extensions/{tersio-commands → commands}/index.ts +2 -2
  55. package/extensions/pstack/index.ts +128 -0
  56. package/extensions/pstack/rule.md +65 -0
  57. package/extensions/{rtk-filter/index.ts → rtk/filter.ts} +1 -2
  58. package/extensions/{rtk-session/index.ts → rtk/session.ts} +5 -5
  59. package/extensions/{opencode/server.ts → server.ts} +81 -16
  60. package/extensions/shared/agent-stats.ts +1605 -0
  61. package/extensions/shared/brand-marks.ts +163 -0
  62. package/extensions/shared/carbon.ts +1 -1
  63. package/extensions/shared/plugin-settings.ts +13 -7
  64. package/extensions/shared/pricing.ts +95 -32
  65. package/extensions/shared/rtk-gain.ts +4 -9
  66. package/extensions/shared/session-state.ts +18 -13
  67. package/extensions/shared/sqlite.ts +168 -0
  68. package/extensions/shared/status.ts +9 -8
  69. package/extensions/shared/types.ts +2 -1
  70. package/extensions/shared/usage-ledger.ts +134 -46
  71. package/extensions/shared/usage-store.ts +202 -50
  72. package/extensions/{lib → shared}/utils.ts +6 -6
  73. package/extensions/timing/index.ts +45 -0
  74. package/extensions/{ai-addons-updater → updater}/index.ts +15 -9
  75. package/package.json +27 -18
  76. package/dist/extensions/caveman-session/rule.md +0 -79
  77. package/extensions/caveman-session/rule.md +0 -79
@@ -1,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, bundledPonytailSpec, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, ponytailDefaultFlag, pstackDefaultFlag, 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 { PI_RTK_MANIFEST, PI_PONYTAIL_FILES, PONYTAIL_BUNDLE_FILES, filesUnder, rootFiles, sourcePath, syncPluginManifest, syncTreeFiles } 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)
@@ -25,22 +25,44 @@ async function stepPonytail(pluginsDir, options) {
25
25
  await fs.mkdir(pluginsDir, { recursive: true });
26
26
  const pkgPath = path.join(pluginsDir, 'package.json');
27
27
  const pkg = await readPluginsPackage(pkgPath);
28
- // Drop the legacy Ponytail plugin row; it is a tersio dependency now.
28
+ // npm resolves the *published* tersio, so a plain dependency entry lets npm nest the range's version.
29
29
  let migrated = false;
30
- if ('@dietrichgebert/ponytail' in pkg.dependencies) {
30
+ const localSpec = await bundledPonytailSpec();
31
+ const declared = pkg.dependencies['@dietrichgebert/ponytail'];
32
+ // Node resolves the nested copy first, so a dependency entry alone cannot hold the local version.
33
+ const rawOverrides = typeof pkg.overrides === 'object' && pkg.overrides !== null ? { ...pkg.overrides } : {};
34
+ const overrideNeeded = localSpec !== null && rawOverrides['@dietrichgebert/ponytail'] !== localSpec;
35
+ if (localSpec !== null && declared !== localSpec) {
36
+ pkg.dependencies['@dietrichgebert/ponytail'] = localSpec;
37
+ migrated = true;
38
+ }
39
+ else if (localSpec === null && declared) {
31
40
  delete pkg.dependencies['@dietrichgebert/ponytail'];
32
41
  migrated = true;
33
42
  }
43
+ if (localSpec === null) {
44
+ // No bundled spec means no tersio-owned row, so an override would resurrect the dependency.
45
+ delete rawOverrides['@dietrichgebert/ponytail'];
46
+ }
47
+ else if (overrideNeeded) {
48
+ rawOverrides['@dietrichgebert/ponytail'] = localSpec;
49
+ }
50
+ if (Object.keys(rawOverrides).length === 0)
51
+ delete pkg.overrides;
52
+ else if (JSON.stringify(rawOverrides) !== JSON.stringify(typeof pkg.overrides === 'object' && pkg.overrides !== null ? pkg.overrides : {})) {
53
+ pkg.overrides = rawOverrides;
54
+ migrated = true;
55
+ }
34
56
  if (options.dryRun) {
35
57
  if (verbose && !options.quiet)
36
58
  sayTagged(` [dry-run] would write ${pkgPath}`);
37
59
  if (migrated && verbose && !options.quiet)
38
- sayTagged(` [dry-run] would drop the separate @dietrichgebert/ponytail dependency (bundled with tersio)`);
60
+ sayTagged(` [dry-run] would set @dietrichgebert/ponytail → ${localSpec ?? 'this tree\x27s range'} (bundled with tersio)`);
39
61
  }
40
62
  else if (migrated) {
41
63
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
42
64
  if (!options.quiet)
43
- console.log(' [migrate] dropped separate @dietrichgebert/ponytail dependency (now bundled with tersio)');
65
+ console.log(` [migrate] set @dietrichgebert/ponytail → ${pkg.dependencies['@dietrichgebert/ponytail'] ?? 'this tree\x27s range'} (bundled with tersio)`);
44
66
  }
45
67
  const ponytailExtPath = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
46
68
  const probeExt = async () => (await readTextIfExists(ponytailExtPath)) !== null;
@@ -50,7 +72,7 @@ async function stepPonytail(pluginsDir, options) {
50
72
  debug('Bundled Ponytail pi-extension already installed; skipping network refresh');
51
73
  }
52
74
  else if (options.dryRun) {
53
- // Dry runs preview the wiring below without touching the network.
75
+ // Pretend the extension is present so the wiring below still runs, without touching the network.
54
76
  if (verbose && !options.quiet)
55
77
  console.log(' [dry-run] would run: npm install --no-audit --no-fund (in plugins dir)');
56
78
  ponytailExtExists = true;
@@ -163,7 +185,7 @@ async function downloadRtkChecksums(release) {
163
185
  return null;
164
186
  }
165
187
  }
166
- // Fails closed: a missing checksum is worse than a failed install, since an unverifiable RTK binary runs with the user's privileges. `--allow-unverified` opts out.
188
+ // Fails closed: an unverifiable RTK binary runs with the user's privileges. `--allow-unverified` opts out.
167
189
  async function verifyRtkArchive(archivePath, assetName, checksumsText) {
168
190
  const unverified = (reason) => {
169
191
  if (allowUnverified) {
@@ -229,6 +251,34 @@ async function extractRtkArchive(archivePath, extractDir) {
229
251
  sayTagged(` [fail] Unknown archive format: ${archivePath}`);
230
252
  return false;
231
253
  }
254
+ // A rule body found in a retired copy still resolves and loads twice, so drop the agent tree's extras.
255
+ const RETIRED_OMP_AGENT_DIRS = [
256
+ 'caveman-session',
257
+ 'rtk-session',
258
+ 'rtk-filter',
259
+ 'combo-toggle',
260
+ 'tersio-commands',
261
+ 'ai-addons-updater',
262
+ 'shared',
263
+ 'lib',
264
+ ];
265
+ /** Drop the copies an older OMP install left in the agent tree, keeping rtk's `rtk.ts`. */
266
+ async function stepRetireAgentCopies(extDir, options) {
267
+ const stale = RETIRED_OMP_AGENT_DIRS.filter((dir) => existsSync(path.join(extDir, dir)));
268
+ if (stale.length === 0) {
269
+ if (!options.quiet)
270
+ console.log(' Retired agent copies — none');
271
+ return;
272
+ }
273
+ if (!options.quiet)
274
+ console.log(` Retired agent copies — remove ${stale.join(', ')}`);
275
+ if (options.dryRun) {
276
+ if (verbose)
277
+ sayTagged(` [dry-run] would remove ${stale.map((d) => path.join(extDir, d)).join(', ')}`);
278
+ return;
279
+ }
280
+ await Promise.all(stale.map((dir) => fs.rm(path.join(extDir, dir), { recursive: true, force: true })));
281
+ }
232
282
  // The binary is machine-wide; hosts without a hook only rebind. `tersio update` still refreshes it.
233
283
  async function stepRtk(binDir, options, target = 'omp') {
234
284
  const binDest = path.join(binDir, RTK_BINARY_NAME);
@@ -257,7 +307,7 @@ async function stepRtk(binDir, options, target = 'omp') {
257
307
  }
258
308
  if (!options.quiet)
259
309
  console.log(` RTK — download binary and wire into ${hostName}`);
260
- // A failed download must not skip wiring: a pre-existing binary serves the host hook just as well. Dry runs stay offline.
310
+ // A failed download must not skip wiring: a pre-existing binary serves the host hook. Dry runs stay offline.
261
311
  if (options.dryRun) {
262
312
  if (verbose && !options.quiet)
263
313
  sayTagged(` [dry-run] would download rtk binary and install to ${binDest}`);
@@ -268,7 +318,7 @@ async function stepRtk(binDir, options, target = 'omp') {
268
318
  const triple = resolveRtkTriple();
269
319
  if (!triple)
270
320
  return;
271
- // One task with streamed sub-steps: release lookup, download, checksum, extract, install. Non-TTY keeps the plain lines each step already prints.
321
+ // One task with streamed sub-steps; non-TTY keeps the plain lines each step already prints.
272
322
  await withInteractiveTask('Installing RTK', async (update) => {
273
323
  const release = await fetchJson(RTK_RELEASE_API);
274
324
  const asset = findRtkAsset(release, triple);
@@ -327,7 +377,7 @@ async function stepRtk(binDir, options, target = 'omp') {
327
377
  // Wire rtk's tool_call extension: the binary alone never meters a session.
328
378
  await bind(binDest);
329
379
  }
330
- // Copy sources into the target extension dir. First entry is required; the rest are optional companions.
380
+ // First entry is required; the rest are optional companions.
331
381
  async function copySources(extDir, files, skipLabel, options) {
332
382
  const src = await readTextIfExists(files[0][0]);
333
383
  if (!src) {
@@ -348,22 +398,20 @@ async function copySources(extDir, files, skipLabel, options) {
348
398
  async function stepSharedSessionState(extDir, options) {
349
399
  if (!options.quiet)
350
400
  console.log(' Shared files — sync session bridge');
351
- const shared = [...filesUnder('shared'), ...filesUnder('lib')];
352
- await copySources(extDir, shared, 'shared/session-state.js', options);
401
+ await copySources(extDir, filesUnder('shared'), 'shared/session-state.js', options);
353
402
  }
354
403
  async function stepRtkSession(extDir, options) {
355
404
  if (!options.quiet)
356
405
  console.log(' RTK session — install session mode');
357
- await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
406
+ await copySources(extDir, filesUnder('rtk'), 'rtk/session.ts', options);
358
407
  }
359
408
  // pi's built-in grep and glob never reach the bash hook, so this one filters their output.
360
409
  async function stepRtkFilter(extDir, options) {
361
410
  if (!options.quiet)
362
411
  console.log(' RTK filter — install built-in tool output filtering');
363
- await copySources(extDir, filesUnder('rtk-filter'), 'rtk-filter/index.ts', options);
412
+ await copySources(extDir, filesUnder('rtk'), 'rtk/filter.ts', options);
364
413
  }
365
- // rule.md ships with the package; the other bodies track upstream skills.
366
- // 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.
414
+ // rule.md is a deliberate fork of skills/caveman/SKILL.md: a blind sync would drop the mode switch line.
367
415
  const CAVEMAN_RULES = [
368
416
  ['rule.md', null],
369
417
  ['rule-ultra.md', CAVEMAN_REMOTE_ULTRA],
@@ -372,11 +420,11 @@ const CAVEMAN_RULES = [
372
420
  async function stepCaveman(extDir, options) {
373
421
  if (!options.quiet)
374
422
  console.log(' Caveman — install session mode and rule bodies');
375
- const cavemanDir = path.join(extDir, 'caveman-session');
423
+ const cavemanDir = path.join(extDir, 'caveman');
376
424
  if (!options.dryRun)
377
425
  await fs.mkdir(cavemanDir, { recursive: true });
378
426
  for (const [name, remote] of CAVEMAN_RULES) {
379
- const bundled = await readTextIfExists(sourcePath(`caveman-session/${name}`));
427
+ const bundled = await readTextIfExists(sourcePath(`caveman/${name}`));
380
428
  let rule = bundled;
381
429
  if (remote && !options.dryRun) {
382
430
  try {
@@ -405,7 +453,7 @@ async function stepCaveman(extDir, options) {
405
453
  await writeIfChanged(path.join(cavemanDir, name), rule, options);
406
454
  }
407
455
  }
408
- await copySources(extDir, filesUnder('caveman-session').filter(([, to]) => to.endsWith('index.ts')), 'caveman-session/index.ts', options);
456
+ await copySources(extDir, filesUnder('caveman').filter(([, to]) => to.endsWith('index.ts')), 'caveman/index.ts', options);
409
457
  // Renamed body: drop the stale copy an older install left behind.
410
458
  if (!options.dryRun)
411
459
  await fs.rm(path.join(cavemanDir, 'rule-wenyan.md'), { force: true });
@@ -413,22 +461,31 @@ async function stepCaveman(extDir, options) {
413
461
  async function stepTersioCommands(extDir, options) {
414
462
  if (!options.quiet)
415
463
  console.log(' Tersio commands — install /tersio root command');
416
- await copySources(extDir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
464
+ await copySources(extDir, filesUnder('commands'), 'commands/index.ts', options);
417
465
  }
418
466
  async function stepUpdater(extDir, options) {
419
- await copySources(extDir, filesUnder('ai-addons-updater'), 'ai-addons-updater/index.ts', options);
467
+ await copySources(extDir, filesUnder('updater'), 'updater/index.ts', options);
468
+ }
469
+ // omp loads no OpenCode entry, but its package ships one: the repair writes it, so the install does too.
470
+ async function stepPluginEntry(extDir, options) {
471
+ await copySources(extDir, rootFiles(), 'server.ts', options);
472
+ }
473
+ // pi records no first-token latency, so this stamps one into the row the dashboard reads.
474
+ async function stepTiming(extDir, options) {
475
+ await copySources(extDir, filesUnder('timing'), 'timing/index.ts', options);
420
476
  }
421
477
  async function stepCombo(extDir, options) {
422
478
  if (!options.quiet)
423
479
  console.log(' Combo — install preset switch');
424
- await copySources(extDir, filesUnder('combo-toggle'), 'combo-toggle/index.ts', options);
480
+ await copySources(extDir, filesUnder('combo'), 'combo/index.ts', options);
425
481
  }
426
- // The tree ships no node_modules, so the full ruleset rides along as files.
427
- const PONYTAIL_BUNDLE_FILES = [
428
- 'hooks/ponytail-instructions.js',
429
- 'hooks/ponytail-config.js',
430
- 'skills/ponytail/SKILL.md',
431
- ];
482
+ // The body is a Tersio fork of an upstream skill, so the install copies it and never fetches it.
483
+ async function stepPstack(extDir, options) {
484
+ if (!options.quiet)
485
+ console.log(' pstack — install the rigor mode');
486
+ await copySources(extDir, filesUnder('pstack'), 'pstack/index.ts', options);
487
+ }
488
+ // The tree ships no node_modules, so the ruleset rides along as files. Both hosts need the same six.
432
489
  async function stepPonytailBundle(treeDir, options) {
433
490
  const root = findHoistedPackage('@dietrichgebert/ponytail', findPackageRoot(path.dirname(fileURLToPath(import.meta.url))));
434
491
  if (!root) {
@@ -456,19 +513,20 @@ async function stepOpencode(options) {
456
513
  const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
457
514
  if (!options.quiet)
458
515
  console.log(` OpenCode — write the plugin tree (${dir})`);
516
+ // Retire the `-session`/`-toggle` leftovers first: a rule body found in one still resolves.
517
+ await stepRetireAgentCopies(dir, options);
459
518
  await stepSharedSessionState(dir, options);
460
- await copySources(dir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
519
+ await copySources(dir, filesUnder('rtk'), 'rtk/session.ts', options);
461
520
  await stepRtkFilter(dir, options);
462
- await copySources(dir, filesUnder('caveman-session'), 'caveman-session/index.ts', options);
521
+ await stepTiming(dir, options);
522
+ await copySources(dir, filesUnder('caveman'), 'caveman/index.ts', options);
463
523
  await stepCombo(dir, options);
464
- await copySources(dir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
524
+ await stepPstack(dir, options);
525
+ await copySources(dir, filesUnder('commands'), 'commands/index.ts', options);
465
526
  await stepUpdater(dir, options);
466
- await copySources(dir, filesUnder('opencode'), 'opencode/server.ts', options);
467
527
  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(() => { });
528
+ // The loader resolves <pluginDir>/server.ts, and the entry is that file: copy it in place.
529
+ await copySources(dir, rootFiles(), 'server.ts', options);
472
530
  const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
473
531
  let config = {};
474
532
  try {
@@ -519,6 +577,7 @@ async function resolveProfile(opts = {}) {
519
577
  profile.cavemanDefault = preset.caveman;
520
578
  profile.rtkDefault = preset.rtk;
521
579
  profile.ponytailDefault = preset.ponytail;
580
+ profile.pstackDefault = preset.pstack;
522
581
  }
523
582
  if (cavemanDefaultFlag !== undefined)
524
583
  profile.cavemanDefault = cavemanDefaultFlag;
@@ -526,8 +585,10 @@ async function resolveProfile(opts = {}) {
526
585
  profile.rtkDefault = rtkDefaultFlag === 'on';
527
586
  if (ponytailDefaultFlag !== undefined)
528
587
  profile.ponytailDefault = ponytailDefaultFlag;
588
+ if (pstackDefaultFlag !== undefined)
589
+ profile.pstackDefault = pstackDefaultFlag;
529
590
  if ((!opts.quiet && verbose) || dryRun)
530
- console.log(` Defaults: combo=${profile.comboDefault} (caveman=${profile.cavemanDefault} · rtk=${profile.rtkDefault ? 'on' : 'off'} · ponytail=${profile.ponytailDefault})`);
591
+ console.log(` Defaults: combo=${profile.comboDefault} (caveman=${profile.cavemanDefault} · rtk=${profile.rtkDefault ? 'on' : 'off'} · ponytail=${profile.ponytailDefault} · pstack=${profile.pstackDefault})`);
531
592
  return profile;
532
593
  }
533
594
  let updatePromptDone = false;
@@ -552,6 +613,7 @@ async function runCommandMenu() {
552
613
  updatePromptDone = true;
553
614
  const choice = await askInteractiveChoice(`Tersio — what next?\n${formatCliStatus(storedProfileSync())}`, [
554
615
  { value: 'install', label: 'Install add-ons', hint: 'user scope + combo defaults' },
616
+ { value: 'reinstall', label: 'Reinstall', hint: 're-install Tersio for the ai agents, then verify' },
555
617
  { value: 'update', label: 'Update', hint: 'CLI version check, then refresh add-ons (RTK, Caveman rule, Ponytail)' },
556
618
  { value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
557
619
  { value: 'settings', label: 'Settings', hint: 'default combo mode, currency, diagnosis schedule' },
@@ -568,8 +630,11 @@ async function runCommandMenu() {
568
630
  case 'install':
569
631
  await runInstall();
570
632
  break;
633
+ case 'reinstall':
634
+ await runReinstall();
635
+ break;
571
636
  case 'update': {
572
- // One bound flow: the version check already ran above, so report it and offer the refresh in the same breath — no second "update" quiz.
637
+ // Version check already ran above, so report it and offer the refresh here — no second "update" quiz.
573
638
  if (typeof newer === 'string')
574
639
  console.log(` tersio ${newer} available (installed ${PACKAGE_VERSION})`);
575
640
  else
@@ -616,7 +681,7 @@ async function runCommandMenu() {
616
681
  closeRL();
617
682
  break;
618
683
  case 'dashboard':
619
- await runDashboard({ port: dashboardPort, open: true, exportFile: dashboardExport });
684
+ await runDashboard({ port: dashboardPort, open: true, exportFile: dashboardExport, currency });
620
685
  closeRL();
621
686
  break;
622
687
  case 'reset':
@@ -629,9 +694,57 @@ async function runCommandMenu() {
629
694
  break;
630
695
  }
631
696
  }
632
- // Which host this run targets. --host pins it; a TTY user picks; scripts, pipes, --yes, and --dry-run keep the pre-multi-host default of Oh My Pi.
697
+ // Which host this run targets. --host pins it; scripts and --yes keep the default of Oh My Pi.
633
698
  let targetHost = 'omp';
634
- // Hosts the menu advertises but cannot install yet. Listed disabled so the roadmap is visible without offering a target that does nothing.
699
+ // A reinstall re-runs the npm fast path and the Ponytail probe instead of reusing what is on disk.
700
+ let forceReinstall = false;
701
+ /** Reinstall one agent: pick it from what is installed now, then run the full install with a refresh. */
702
+ async function runReinstall() {
703
+ let pinned;
704
+ try {
705
+ pinned = parseHostArg(args);
706
+ }
707
+ catch (e) {
708
+ console.error(`[fail] ${e.message}`);
709
+ process.exit(1);
710
+ }
711
+ const installed = detectHosts().filter((host) => host.installed);
712
+ let host;
713
+ if (pinned)
714
+ host = pinned;
715
+ else if (installed.length === 0) {
716
+ console.log(' Nothing to reinstall — no agent has Tersio installed.\n Run `tersio install`, or pick it from the menu.');
717
+ closeRL();
718
+ return;
719
+ }
720
+ else if (installed.length === 1 || !tty() || yes || dryRun) {
721
+ host = installed[0].id;
722
+ }
723
+ else {
724
+ const options = installed.map((entry) => ({
725
+ value: entry.id,
726
+ label: hostLabel(entry),
727
+ hint: hostHint(entry),
728
+ }));
729
+ const picked = await askInteractiveChoice('Reinstall Tersio into which agent?', options, installed[0].id);
730
+ if (picked.status !== 'selected') {
731
+ closeRL();
732
+ process.exit(130);
733
+ }
734
+ host = picked.value;
735
+ }
736
+ forceReinstall = true;
737
+ printWelcome();
738
+ console.log(`Reinstall — Tersio v${PACKAGE_VERSION} into ${hostLabel(hostEntry(host))}`);
739
+ console.log(` ${resolvedHostPath(host)}`);
740
+ await runInstall(host);
741
+ if (!dryRun) {
742
+ const summary = await runDoctor();
743
+ if (summary.missing + summary.warn === 0)
744
+ console.log(`\n Reinstall verified — ${hostLabel(hostEntry(host))} is healthy.`);
745
+ }
746
+ }
747
+ // Hosts the menu advertises but cannot install yet; listed disabled so the roadmap stays visible.
635
748
  const SOON_HOSTS = [
636
749
  { value: 'claude', label: 'Claude Code', hint: 'Coming soon' },
637
750
  { value: 'codex', label: 'Codex', hint: 'Coming soon' },
@@ -656,7 +769,19 @@ async function chooseHost(title, only) {
656
769
  function hostEntry(id) {
657
770
  return detectHosts().find((host) => host.id === id);
658
771
  }
659
- // pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes the same tree as OMP's and registers nothing.
772
+ /** pi discovers a subdirectory only through an index file or a package.json with `pi.extensions`, so rtk/ needs one or both files stay dead. */
773
+ async function stepPiRtkManifest(extDir, options) {
774
+ await syncTreeFiles(extDir, [PI_RTK_MANIFEST], options);
775
+ }
776
+ /** The directory a host's install writes, for the plan line a reinstall prints. */
777
+ function resolvedHostPath(host) {
778
+ if (host === 'pi')
779
+ return path.join(piAgentDir(), 'extensions');
780
+ if (host === 'opencode')
781
+ return path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
782
+ return path.join(OMP_PLUGINS_DIR, 'node_modules', ...PACKAGE_NAME.split('/'), 'extensions');
783
+ }
784
+ // pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes the same tree and registers nothing.
660
785
  async function stepPiLayer(options) {
661
786
  const agentDir = piAgentDir();
662
787
  const extDir = path.join(agentDir, 'extensions');
@@ -666,23 +791,19 @@ async function stepPiLayer(options) {
666
791
  if (!options.quiet)
667
792
  console.log(` Pi — write the extension tree (${extDir})`);
668
793
  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);
794
+ // OMP loads rtk/session.ts from its plugin manifest, so only the pi tree copies it.
795
+ await copySources(extDir, filesUnder('rtk'), 'rtk/session.ts', options);
671
796
  await stepRtkFilter(extDir, options);
797
+ await stepPiRtkManifest(extDir, options);
798
+ await stepTiming(extDir, options);
672
799
  await stepCaveman(extDir, options);
673
800
  await stepCombo(extDir, options);
801
+ await stepPstack(extDir, options);
674
802
  await stepTersioCommands(extDir, options);
675
803
  await stepUpdater(extDir, options);
676
804
  await stepPonytailForPi(agentDir, options);
677
805
  }
678
806
  // A local dir, not an npm spec: pi only version-checks npm entries, so this avoids the update nag.
679
- const PI_PONYTAIL_FILES = [
680
- 'pi-extension/index.js',
681
- 'hooks/ponytail-instructions.js',
682
- 'hooks/ponytail-config.js',
683
- ...['ponytail', 'ponytail-audit', 'ponytail-debt', 'ponytail-gain', 'ponytail-help', 'ponytail-review']
684
- .map((skill) => `skills/${skill}/SKILL.md`),
685
- ];
686
807
  const PI_PONYTAIL_PACKAGE = `${JSON.stringify({ name: 'ponytail', version: '0.0.0', private: true, pi: { extensions: ['./pi-extension/index.js'], skills: ['./skills'] } }, null, 2)}\n`;
687
808
  async function stepPonytailForPi(agentDir, options) {
688
809
  if (!options.quiet)
@@ -708,8 +829,7 @@ async function stepPonytailForPi(agentDir, options) {
708
829
  }
709
830
  if (options.dryRun)
710
831
  return;
711
- // Only when pi still declares the npm spec: with nothing to remove, `pi remove` still
712
- // shells out to npm, which warns on stderr and exits non-zero.
832
+ // Only when pi still declares the npm spec: with nothing to remove, `pi remove` still warns and exits non-zero.
713
833
  const declared = await piDeclaredPackages(agentDir);
714
834
  if (declared.some(isNpmPonytailPackage)) {
715
835
  try {
@@ -755,8 +875,7 @@ async function registerPiLocalPonytail(agentDir, dest, options) {
755
875
  function comboHint(host, comboDefault) {
756
876
  return comboDefault === 'off' ? `\nDone — restart ${host}.` : `\nDone — restart ${host}, then /combo ${comboDefault}.`;
757
877
  }
758
- async function runInstall() {
759
- // Without this guard a picked command re-enters here with command === null and loops straight back into the picker.
878
+ async function runInstall(forcedHost) {
760
879
  if (command === null && tty() && !yes && !updatePromptDone) {
761
880
  await runCommandMenu();
762
881
  return;
@@ -770,7 +889,9 @@ async function runInstall() {
770
889
  console.error(`[fail] ${e.message}`);
771
890
  process.exit(1);
772
891
  }
773
- if (pinned)
892
+ if (forcedHost)
893
+ targetHost = forcedHost;
894
+ else if (pinned)
774
895
  targetHost = pinned;
775
896
  else if (tty() && !yes && !dryRun && !applyUpdate && command !== 'uninstall') {
776
897
  targetHost = await chooseHost('Install Tersio into which agent?');
@@ -788,7 +909,7 @@ async function runInstall() {
788
909
  if (tty() && !applyUpdate && command !== 'uninstall') {
789
910
  const newer = await checkForUpdate();
790
911
  if (typeof newer === 'string') {
791
- // Update pending: offer it here instead of burying it above the install prompts. Yes runs the full update and stops.
912
+ // Update pending: offer it here; yes runs the full update and stops.
792
913
  if (command === null && !dryRun && !yes && !updatePromptDone) {
793
914
  const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
794
915
  if (answer.status === 'confirmed' && answer.value) {
@@ -812,7 +933,7 @@ async function runInstall() {
812
933
  const userDir = OMP_AGENT_DIR;
813
934
  const userExtDir = path.join(userDir, 'extensions');
814
935
  // apply-update refreshes every add-on like a reinstall.
815
- const installOptions = { dryRun, verbose, yes, reinstall: applyUpdate, quiet };
936
+ const installOptions = { dryRun, verbose, yes, reinstall: applyUpdate || forceReinstall, quiet };
816
937
  // Point of no return: disk writes start here; pipes stay unattended.
817
938
  if (!yes && !dryRun && !applyUpdate && tty()) {
818
939
  const target = hostEntry(targetHost);
@@ -873,17 +994,31 @@ async function runInstall() {
873
994
  closeRL();
874
995
  return;
875
996
  }
876
- await capture('shared', () => stepSharedSessionState(userExtDir, installOptions));
877
997
  let selfPlugin = false;
878
998
  await capture('self-plugin', async () => { selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, installOptions); });
879
999
  await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, installOptions));
880
1000
  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));
1001
+ // The plugin manifest owns every mode extension; only a failed registration falls back to the agent tree.
1002
+ const pluginExtDir = path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions');
1003
+ const ownsTree = selfPlugin || dryRun;
1004
+ const extDir = ownsTree ? pluginExtDir : userExtDir;
1005
+ if (!quiet)
1006
+ console.log(` Add-ons load from: ${ownsTree ? 'the OMP plugin manifest' : 'the agent tree (plugin registration failed)'}`);
1007
+ await capture('shared', () => stepSharedSessionState(extDir, installOptions));
1008
+ await capture('rtk session', () => stepRtkSession(extDir, installOptions));
1009
+ await capture('rtk filter', () => stepRtkFilter(extDir, installOptions));
1010
+ await capture('caveman', () => stepCaveman(extDir, installOptions));
1011
+ await capture('combo', () => stepCombo(extDir, installOptions));
1012
+ await capture('pstack', () => stepPstack(extDir, installOptions));
1013
+ await capture('commands', () => stepTersioCommands(extDir, installOptions));
1014
+ await capture('updater', () => stepUpdater(extDir, installOptions));
1015
+ // One package, one file set: the repair writes every tree file here, so the install does too.
1016
+ if (ownsTree)
1017
+ await capture('plugin entry', () => stepPluginEntry(extDir, installOptions));
1018
+ if (ownsTree)
1019
+ await capture('plugin manifest', () => syncPluginManifest(path.dirname(pluginExtDir), installOptions));
1020
+ if (ownsTree)
1021
+ await capture('retired agent copies', () => stepRetireAgentCopies(userExtDir, installOptions));
887
1022
  if (selfPlugin)
888
1023
  await capture('settings', () => writePluginSettings(profile, installOptions));
889
1024
  if (quiet) {
@@ -895,4 +1030,4 @@ async function runInstall() {
895
1030
  }
896
1031
  closeRL();
897
1032
  }
898
- export { runInstall, verifyRtkArchive };
1033
+ export { runInstall, runReinstall, verifyRtkArchive };
@@ -30,7 +30,7 @@ let spinnerDepth = 0;
30
30
  async function withInteractiveSpinner(message, work) {
31
31
  if (!tty() || spinnerDepth > 0 || process.env.CI === 'true')
32
32
  return work(() => { });
33
- // Clack manages stdin itself. Close the legacy question interface before its first use; non-interactive callers never reach this branch.
33
+ // Clack manages stdin itself. Close the legacy question interface first; non-interactive callers skip it.
34
34
  closeRL();
35
35
  const active = clackSpinner({ indicator: 'timer' });
36
36
  active.start(message);
@@ -73,7 +73,7 @@ async function askInteractiveConfirm(message, initialValue = true) {
73
73
  finally {
74
74
  }
75
75
  }
76
- // Confirm a destructive run: a Clack dialog at a terminal, a plain prompt in a pipe or script. False means the user declined or aborted.
76
+ // Confirm a destructive run: a Clack dialog at a terminal, a plain prompt in a pipe. False means declined or aborted.
77
77
  async function confirmDestructive(message) {
78
78
  if (tty()) {
79
79
  closeRL();
@@ -98,7 +98,7 @@ async function confirmDestructive(message) {
98
98
  }
99
99
  return true;
100
100
  }
101
- // Run collecting work under one TTY-only Clack task. Callers print after the task completes, keeping normal output out of the spinner animation.
101
+ // Run collecting work under one TTY-only Clack task; callers print after it, so output stays out of the spinner.
102
102
  async function runInteractivePhase(title, collect) {
103
103
  if (!tty())
104
104
  return collect();