@krtclcdy/tersio 2.25.5 → 2.25.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +17 -36
  2. package/dashboard/dist/index.html +15 -15
  3. package/dist/cli/backups.js +7 -14
  4. package/dist/cli/banner.js +4 -3
  5. package/dist/cli/common.js +28 -12
  6. package/dist/cli/currency.js +1 -1
  7. package/dist/cli/dashboard.js +66 -35
  8. package/dist/cli/doctor-fix.js +204 -19
  9. package/dist/cli/doctor.js +226 -39
  10. package/dist/cli/hosts.js +25 -5
  11. package/dist/cli/install.js +144 -48
  12. package/dist/cli/interactive.js +3 -3
  13. package/dist/cli/manifest.js +87 -7
  14. package/dist/cli/profile.js +13 -8
  15. package/dist/cli/rtk-wiring.js +1 -1
  16. package/dist/cli/settings.js +20 -5
  17. package/dist/cli/uninstall.js +3 -3
  18. package/dist/cli/update.js +6 -7
  19. package/dist/cli/usage.js +6 -9
  20. package/dist/extensions/caveman/index.js +4 -2
  21. package/dist/extensions/caveman/rule-megacave.md +5 -3
  22. package/dist/extensions/caveman/rule-ultra.md +4 -4
  23. package/dist/extensions/caveman/rule.md +55 -45
  24. package/dist/extensions/combo/index.js +62 -21
  25. package/dist/extensions/commands/index.js +1 -1
  26. package/dist/extensions/pstack/index.js +119 -0
  27. package/dist/extensions/pstack/rule.md +65 -0
  28. package/dist/extensions/rtk/filter.js +0 -1
  29. package/dist/extensions/rtk/session.js +4 -4
  30. package/dist/extensions/server.js +90 -6
  31. package/dist/extensions/shared/agent-stats.js +214 -278
  32. package/dist/extensions/shared/brand-marks.js +33 -34
  33. package/dist/extensions/shared/carbon.js +1 -1
  34. package/dist/extensions/shared/plugin-settings.js +11 -6
  35. package/dist/extensions/shared/pricing.js +8 -16
  36. package/dist/extensions/shared/rtk-gain.js +4 -9
  37. package/dist/extensions/shared/session-state.js +15 -10
  38. package/dist/extensions/shared/sqlite.js +168 -0
  39. package/dist/extensions/shared/status.js +8 -8
  40. package/dist/extensions/shared/types.js +1 -1
  41. package/dist/extensions/shared/usage-ledger.js +53 -41
  42. package/dist/extensions/shared/usage-store.js +111 -52
  43. package/dist/extensions/shared/utils.js +5 -5
  44. package/dist/extensions/timing/index.js +31 -0
  45. package/dist/extensions/updater/index.js +3 -6
  46. package/dist/tersio.js +8 -3
  47. package/extensions/caveman/index.ts +4 -2
  48. package/extensions/caveman/rule-megacave.md +5 -3
  49. package/extensions/caveman/rule-ultra.md +4 -4
  50. package/extensions/caveman/rule.md +55 -45
  51. package/extensions/combo/index.ts +62 -19
  52. package/extensions/commands/index.ts +1 -1
  53. package/extensions/pstack/index.ts +128 -0
  54. package/extensions/pstack/rule.md +65 -0
  55. package/extensions/rtk/filter.ts +0 -1
  56. package/extensions/rtk/session.ts +4 -4
  57. package/extensions/server.ts +72 -7
  58. package/extensions/shared/agent-stats.ts +226 -278
  59. package/extensions/shared/brand-marks.ts +34 -34
  60. package/extensions/shared/carbon.ts +1 -1
  61. package/extensions/shared/plugin-settings.ts +12 -6
  62. package/extensions/shared/pricing.ts +10 -26
  63. package/extensions/shared/rtk-gain.ts +4 -9
  64. package/extensions/shared/session-state.ts +18 -13
  65. package/extensions/shared/sqlite.ts +168 -0
  66. package/extensions/shared/status.ts +9 -8
  67. package/extensions/shared/types.ts +2 -1
  68. package/extensions/shared/usage-ledger.ts +60 -47
  69. package/extensions/shared/usage-store.ts +120 -52
  70. package/extensions/shared/utils.ts +5 -5
  71. package/extensions/timing/index.ts +45 -0
  72. package/extensions/updater/index.ts +3 -8
  73. package/package.json +17 -8
@@ -3,7 +3,7 @@ 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, currency, 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';
@@ -17,7 +17,7 @@ import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_MEGACAVE, RTK_RELEASE_API, fetchJs
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 { filesUnder, rootFiles, 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,11 +251,7 @@ async function extractRtkArchive(archivePath, extractDir) {
229
251
  sayTagged(` [fail] Unknown archive format: ${archivePath}`);
230
252
  return false;
231
253
  }
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.
254
+ // A rule body found in a retired copy still resolves and loads twice, so drop the agent tree's extras.
237
255
  const RETIRED_OMP_AGENT_DIRS = [
238
256
  'caveman-session',
239
257
  'rtk-session',
@@ -289,7 +307,7 @@ async function stepRtk(binDir, options, target = 'omp') {
289
307
  }
290
308
  if (!options.quiet)
291
309
  console.log(` RTK — download binary and wire into ${hostName}`);
292
- // 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.
293
311
  if (options.dryRun) {
294
312
  if (verbose && !options.quiet)
295
313
  sayTagged(` [dry-run] would download rtk binary and install to ${binDest}`);
@@ -300,7 +318,7 @@ async function stepRtk(binDir, options, target = 'omp') {
300
318
  const triple = resolveRtkTriple();
301
319
  if (!triple)
302
320
  return;
303
- // 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.
304
322
  await withInteractiveTask('Installing RTK', async (update) => {
305
323
  const release = await fetchJson(RTK_RELEASE_API);
306
324
  const asset = findRtkAsset(release, triple);
@@ -359,7 +377,7 @@ async function stepRtk(binDir, options, target = 'omp') {
359
377
  // Wire rtk's tool_call extension: the binary alone never meters a session.
360
378
  await bind(binDest);
361
379
  }
362
- // 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.
363
381
  async function copySources(extDir, files, skipLabel, options) {
364
382
  const src = await readTextIfExists(files[0][0]);
365
383
  if (!src) {
@@ -393,8 +411,7 @@ async function stepRtkFilter(extDir, options) {
393
411
  console.log(' RTK filter — install built-in tool output filtering');
394
412
  await copySources(extDir, filesUnder('rtk'), 'rtk/filter.ts', options);
395
413
  }
396
- // rule.md ships with the package; the other bodies track upstream skills.
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.
414
+ // rule.md is a deliberate fork of skills/caveman/SKILL.md: a blind sync would drop the mode switch line.
398
415
  const CAVEMAN_RULES = [
399
416
  ['rule.md', null],
400
417
  ['rule-ultra.md', CAVEMAN_REMOTE_ULTRA],
@@ -449,17 +466,26 @@ async function stepTersioCommands(extDir, options) {
449
466
  async function stepUpdater(extDir, options) {
450
467
  await copySources(extDir, filesUnder('updater'), 'updater/index.ts', options);
451
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);
476
+ }
452
477
  async function stepCombo(extDir, options) {
453
478
  if (!options.quiet)
454
479
  console.log(' Combo — install preset switch');
455
480
  await copySources(extDir, filesUnder('combo'), 'combo/index.ts', options);
456
481
  }
457
- // The tree ships no node_modules, so the full ruleset rides along as files.
458
- const PONYTAIL_BUNDLE_FILES = [
459
- 'hooks/ponytail-instructions.js',
460
- 'hooks/ponytail-config.js',
461
- 'skills/ponytail/SKILL.md',
462
- ];
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.
463
489
  async function stepPonytailBundle(treeDir, options) {
464
490
  const root = findHoistedPackage('@dietrichgebert/ponytail', findPackageRoot(path.dirname(fileURLToPath(import.meta.url))));
465
491
  if (!root) {
@@ -487,11 +513,15 @@ async function stepOpencode(options) {
487
513
  const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
488
514
  if (!options.quiet)
489
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);
490
518
  await stepSharedSessionState(dir, options);
491
519
  await copySources(dir, filesUnder('rtk'), 'rtk/session.ts', options);
492
520
  await stepRtkFilter(dir, options);
521
+ await stepTiming(dir, options);
493
522
  await copySources(dir, filesUnder('caveman'), 'caveman/index.ts', options);
494
523
  await stepCombo(dir, options);
524
+ await stepPstack(dir, options);
495
525
  await copySources(dir, filesUnder('commands'), 'commands/index.ts', options);
496
526
  await stepUpdater(dir, options);
497
527
  await stepPonytailBundle(dir, options);
@@ -547,6 +577,7 @@ async function resolveProfile(opts = {}) {
547
577
  profile.cavemanDefault = preset.caveman;
548
578
  profile.rtkDefault = preset.rtk;
549
579
  profile.ponytailDefault = preset.ponytail;
580
+ profile.pstackDefault = preset.pstack;
550
581
  }
551
582
  if (cavemanDefaultFlag !== undefined)
552
583
  profile.cavemanDefault = cavemanDefaultFlag;
@@ -554,8 +585,10 @@ async function resolveProfile(opts = {}) {
554
585
  profile.rtkDefault = rtkDefaultFlag === 'on';
555
586
  if (ponytailDefaultFlag !== undefined)
556
587
  profile.ponytailDefault = ponytailDefaultFlag;
588
+ if (pstackDefaultFlag !== undefined)
589
+ profile.pstackDefault = pstackDefaultFlag;
557
590
  if ((!opts.quiet && verbose) || dryRun)
558
- 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})`);
559
592
  return profile;
560
593
  }
561
594
  let updatePromptDone = false;
@@ -580,6 +613,7 @@ async function runCommandMenu() {
580
613
  updatePromptDone = true;
581
614
  const choice = await askInteractiveChoice(`Tersio — what next?\n${formatCliStatus(storedProfileSync())}`, [
582
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' },
583
617
  { value: 'update', label: 'Update', hint: 'CLI version check, then refresh add-ons (RTK, Caveman rule, Ponytail)' },
584
618
  { value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
585
619
  { value: 'settings', label: 'Settings', hint: 'default combo mode, currency, diagnosis schedule' },
@@ -596,8 +630,11 @@ async function runCommandMenu() {
596
630
  case 'install':
597
631
  await runInstall();
598
632
  break;
633
+ case 'reinstall':
634
+ await runReinstall();
635
+ break;
599
636
  case 'update': {
600
- // 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.
601
638
  if (typeof newer === 'string')
602
639
  console.log(` tersio ${newer} available (installed ${PACKAGE_VERSION})`);
603
640
  else
@@ -657,9 +694,57 @@ async function runCommandMenu() {
657
694
  break;
658
695
  }
659
696
  }
660
- // 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.
661
698
  let targetHost = 'omp';
662
- // 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.
663
748
  const SOON_HOSTS = [
664
749
  { value: 'claude', label: 'Claude Code', hint: 'Coming soon' },
665
750
  { value: 'codex', label: 'Codex', hint: 'Coming soon' },
@@ -684,7 +769,19 @@ async function chooseHost(title, only) {
684
769
  function hostEntry(id) {
685
770
  return detectHosts().find((host) => host.id === id);
686
771
  }
687
- // 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.
688
785
  async function stepPiLayer(options) {
689
786
  const agentDir = piAgentDir();
690
787
  const extDir = path.join(agentDir, 'extensions');
@@ -697,20 +794,16 @@ async function stepPiLayer(options) {
697
794
  // OMP loads rtk/session.ts from its plugin manifest, so only the pi tree copies it.
698
795
  await copySources(extDir, filesUnder('rtk'), 'rtk/session.ts', options);
699
796
  await stepRtkFilter(extDir, options);
797
+ await stepPiRtkManifest(extDir, options);
798
+ await stepTiming(extDir, options);
700
799
  await stepCaveman(extDir, options);
701
800
  await stepCombo(extDir, options);
801
+ await stepPstack(extDir, options);
702
802
  await stepTersioCommands(extDir, options);
703
803
  await stepUpdater(extDir, options);
704
804
  await stepPonytailForPi(agentDir, options);
705
805
  }
706
806
  // A local dir, not an npm spec: pi only version-checks npm entries, so this avoids the update nag.
707
- const PI_PONYTAIL_FILES = [
708
- 'pi-extension/index.js',
709
- 'hooks/ponytail-instructions.js',
710
- 'hooks/ponytail-config.js',
711
- ...['ponytail', 'ponytail-audit', 'ponytail-debt', 'ponytail-gain', 'ponytail-help', 'ponytail-review']
712
- .map((skill) => `skills/${skill}/SKILL.md`),
713
- ];
714
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`;
715
808
  async function stepPonytailForPi(agentDir, options) {
716
809
  if (!options.quiet)
@@ -736,8 +829,7 @@ async function stepPonytailForPi(agentDir, options) {
736
829
  }
737
830
  if (options.dryRun)
738
831
  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.
832
+ // Only when pi still declares the npm spec: with nothing to remove, `pi remove` still warns and exits non-zero.
741
833
  const declared = await piDeclaredPackages(agentDir);
742
834
  if (declared.some(isNpmPonytailPackage)) {
743
835
  try {
@@ -783,8 +875,7 @@ async function registerPiLocalPonytail(agentDir, dest, options) {
783
875
  function comboHint(host, comboDefault) {
784
876
  return comboDefault === 'off' ? `\nDone — restart ${host}.` : `\nDone — restart ${host}, then /combo ${comboDefault}.`;
785
877
  }
786
- async function runInstall() {
787
- // Without this guard a picked command re-enters here with command === null and loops straight back into the picker.
878
+ async function runInstall(forcedHost) {
788
879
  if (command === null && tty() && !yes && !updatePromptDone) {
789
880
  await runCommandMenu();
790
881
  return;
@@ -798,7 +889,9 @@ async function runInstall() {
798
889
  console.error(`[fail] ${e.message}`);
799
890
  process.exit(1);
800
891
  }
801
- if (pinned)
892
+ if (forcedHost)
893
+ targetHost = forcedHost;
894
+ else if (pinned)
802
895
  targetHost = pinned;
803
896
  else if (tty() && !yes && !dryRun && !applyUpdate && command !== 'uninstall') {
804
897
  targetHost = await chooseHost('Install Tersio into which agent?');
@@ -816,7 +909,7 @@ async function runInstall() {
816
909
  if (tty() && !applyUpdate && command !== 'uninstall') {
817
910
  const newer = await checkForUpdate();
818
911
  if (typeof newer === 'string') {
819
- // 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.
820
913
  if (command === null && !dryRun && !yes && !updatePromptDone) {
821
914
  const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
822
915
  if (answer.status === 'confirmed' && answer.value) {
@@ -840,7 +933,7 @@ async function runInstall() {
840
933
  const userDir = OMP_AGENT_DIR;
841
934
  const userExtDir = path.join(userDir, 'extensions');
842
935
  // apply-update refreshes every add-on like a reinstall.
843
- const installOptions = { dryRun, verbose, yes, reinstall: applyUpdate, quiet };
936
+ const installOptions = { dryRun, verbose, yes, reinstall: applyUpdate || forceReinstall, quiet };
844
937
  // Point of no return: disk writes start here; pipes stay unattended.
845
938
  if (!yes && !dryRun && !applyUpdate && tty()) {
846
939
  const target = hostEntry(targetHost);
@@ -905,10 +998,7 @@ async function runInstall() {
905
998
  await capture('self-plugin', async () => { selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, installOptions); });
906
999
  await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, installOptions));
907
1000
  await capture('rtk', () => stepRtk(BUN_BIN_DIR, 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.
1001
+ // The plugin manifest owns every mode extension; only a failed registration falls back to the agent tree.
912
1002
  const pluginExtDir = path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions');
913
1003
  const ownsTree = selfPlugin || dryRun;
914
1004
  const extDir = ownsTree ? pluginExtDir : userExtDir;
@@ -919,8 +1009,14 @@ async function runInstall() {
919
1009
  await capture('rtk filter', () => stepRtkFilter(extDir, installOptions));
920
1010
  await capture('caveman', () => stepCaveman(extDir, installOptions));
921
1011
  await capture('combo', () => stepCombo(extDir, installOptions));
1012
+ await capture('pstack', () => stepPstack(extDir, installOptions));
922
1013
  await capture('commands', () => stepTersioCommands(extDir, installOptions));
923
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));
924
1020
  if (ownsTree)
925
1021
  await capture('retired agent copies', () => stepRetireAgentCopies(userExtDir, installOptions));
926
1022
  if (selfPlugin)
@@ -934,4 +1030,4 @@ async function runInstall() {
934
1030
  }
935
1031
  closeRL();
936
1032
  }
937
- 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();
@@ -1,5 +1,6 @@
1
1
  // Single list of install writes, shared by installer, doctor, and tests.
2
2
  import path from 'node:path';
3
+ import { readFileSync } from 'node:fs';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { findPackageRoot, readTextIfExists } from '../extensions/shared/utils.js';
5
6
  import { writeIfChanged } from './common.js';
@@ -12,6 +13,7 @@ export const TREE_FILES = [
12
13
  'shared/types.ts',
13
14
  'shared/plugin-settings.ts',
14
15
  'shared/usage-ledger.ts',
16
+ 'shared/sqlite.ts',
15
17
  'shared/pricing.ts',
16
18
  'shared/carbon.ts',
17
19
  'shared/utils.ts',
@@ -19,8 +21,11 @@ export const TREE_FILES = [
19
21
  'caveman/rule.md',
20
22
  'caveman/rule-ultra.md',
21
23
  'caveman/rule-megacave.md',
24
+ 'pstack/index.ts',
25
+ 'pstack/rule.md',
22
26
  'rtk/session.ts',
23
27
  'rtk/filter.ts',
28
+ 'timing/index.ts',
24
29
  'combo/index.ts',
25
30
  'commands/index.ts',
26
31
  'updater/index.ts',
@@ -42,24 +47,99 @@ export function filesUnder(dir) {
42
47
  export function rootFiles() {
43
48
  return TREE_FILES.filter((file) => !file.includes('/')).map((file) => [sourcePath(file), file]);
44
49
  }
45
- /** The OpenCode plugin entry. It imports `@opencode/plugin`, which the omp and pi loaders lack, so it ships in the OpenCode tree alone. */
50
+ /** The OpenCode plugin entry: imports `@opencode/plugin`, absent from omp and pi loaders, so it ships there alone. */
46
51
  export const OPENCODE_ENTRY = 'server.ts';
52
+ /** pi's rtk entry manifest, generated rather than copied: no other host loads rtk that way. */
53
+ export const PI_RTK_MANIFEST = 'rtk/package.json';
54
+ /** pi's rtk entry manifest, generated rather than copied: no other host loads rtk that way. */
55
+ export const PI_RTK_MANIFEST_BODY = `${JSON.stringify({ pi: { extensions: ['./session.ts', './filter.ts'] } }, null, 2)}\n`;
56
+ /** Files the install generates, keyed by tree path, so one repair path handles copied and generated alike. */
57
+ export const GENERATED_TREE_FILES = {
58
+ [PI_RTK_MANIFEST]: PI_RTK_MANIFEST_BODY,
59
+ };
47
60
  /** The files one host's extension tree holds. The package tree holds all of them. */
48
61
  export function treeFilesFor(id) {
49
- return id === 'opencode' ? TREE_FILES : TREE_FILES.filter((file) => file !== OPENCODE_ENTRY);
62
+ if (id === 'opencode')
63
+ return TREE_FILES;
64
+ const files = TREE_FILES.filter((file) => file !== OPENCODE_ENTRY);
65
+ return id === 'pi' ? [...files, PI_RTK_MANIFEST] : files;
66
+ }
67
+ /** Obsolete install debris; explicit, not a sweep, because these trees are shared with tersio's other steps. */
68
+ const OBSOLETE_DIRS = ['caveman-session', 'rtk-filter', 'rtk-session', 'combo-toggle', 'tersio-commands', 'ai-addons-updater', 'lib', 'opencode'];
69
+ /** Debris test for a tree-relative path; one definition shared by detection and repair. */
70
+ export function isObsoleteTreeFile(rel, id) {
71
+ if (rel.endsWith('.bak') || rel === '.DS_Store')
72
+ return true;
73
+ if (OBSOLETE_DIRS.includes(rel.split('/')[0]))
74
+ return true;
75
+ return rel === 'server.ts' && id !== 'opencode';
76
+ }
77
+ /** A tree file's text: copied files come from the repo, generated ones from their own definition. */
78
+ export function treeFileText(file) {
79
+ const generated = GENERATED_TREE_FILES[file];
80
+ if (generated !== undefined)
81
+ return generated;
82
+ try {
83
+ return readFileSync(sourcePath(file), 'utf8');
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /** The ponytail ruleset this build ships, copied to every host that cannot install the package. */
90
+ export const PONYTAIL_SKILL_NAMES = ['ponytail', 'ponytail-audit', 'ponytail-debt', 'ponytail-gain', 'ponytail-help', 'ponytail-review'];
91
+ /** The OpenCode bundle: no node_modules there, so the ruleset rides along as files. */
92
+ export const PONYTAIL_BUNDLE_FILES = [
93
+ 'hooks/ponytail-instructions.js',
94
+ 'hooks/ponytail-config.js',
95
+ ...PONYTAIL_SKILL_NAMES.map((skill) => `skills/${skill}/SKILL.md`),
96
+ ];
97
+ /** pi's local package: the same ruleset, plus the extension that injects it. */
98
+ export const PI_PONYTAIL_FILES = ['pi-extension/index.js', ...PONYTAIL_BUNDLE_FILES];
99
+ /** The shipped package manifest, one level above the extension tree. */
100
+ const MANIFEST_PATH = path.join(path.dirname(EXT_DIR), 'package.json');
101
+ /** The `omp` block a host reads for what to load: extensions, features, and the settings schema. */
102
+ export function shippedOmpManifest() {
103
+ try {
104
+ const pkg = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
105
+ return typeof pkg.omp === 'object' && pkg.omp !== null ? pkg.omp : null;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
50
110
  }
51
111
  /**
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.
112
+ * Write the shipped `omp` block over an installed plugin's manifest. The tree files come from this
113
+ * build, so an entry added here reaches the host only when its manifest names it.
57
114
  */
115
+ export async function syncPluginManifest(pluginDir, options = {}) {
116
+ const omp = shippedOmpManifest();
117
+ if (omp === null)
118
+ return;
119
+ const pkgPath = path.join(pluginDir, 'package.json');
120
+ let pkg;
121
+ try {
122
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
123
+ }
124
+ catch {
125
+ return;
126
+ }
127
+ if (JSON.stringify(pkg.omp) === JSON.stringify(omp))
128
+ return;
129
+ pkg.omp = omp;
130
+ await writeIfChanged(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, options);
131
+ }
132
+ /** Copy bundled files into `dest`; misses are reported and skipped, `skip` keeps newer fetched rule bodies. */
58
133
  export async function syncTreeFiles(dest, files, options, skip) {
59
134
  const missing = [];
60
135
  for (const file of files) {
61
136
  if (skip?.(file))
62
137
  continue;
138
+ const generated = GENERATED_TREE_FILES[file];
139
+ if (generated !== undefined) {
140
+ await writeIfChanged(path.join(dest, ...file.split('/')), generated, options);
141
+ continue;
142
+ }
63
143
  const from = sourcePath(file);
64
144
  const text = await readTextIfExists(from);
65
145
  if (text === null) {
@@ -1,7 +1,7 @@
1
1
  // cli/profile.ts — session-start defaults and the display-currency default,
2
2
  import { existsSync, promises as fs, readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { CAVEMAN_DEFAULTS, COMBO_PRESET_MODES, PACKAGE_NAME, PONYTAIL_DEFAULTS, verbose, } from './common.js';
4
+ import { CAVEMAN_DEFAULTS, COMBO_PRESET_MODES, PACKAGE_NAME, PONYTAIL_DEFAULTS, PSTACK_DEFAULTS, verbose, } from './common.js';
5
5
  import os from 'node:os';
6
6
  import { DEFAULT_CURRENCY, isCurrencyCode } from './currency.js';
7
7
  import { readTextIfExists } from '../extensions/shared/utils.js';
@@ -21,12 +21,13 @@ function defaultProfile() {
21
21
  cavemanDefault: 'off',
22
22
  rtkDefault: false,
23
23
  ponytailDefault: 'off',
24
+ pstackDefault: 'off',
24
25
  currency: DEFAULT_CURRENCY,
25
26
  backupSchedule: 'monthly',
26
27
  subagentMarkers: [],
27
28
  };
28
29
  }
29
- // Seed once from OMP plugin settings so a pre-~/.tersio install keeps its values. Sync: awaiting let piped stdin arrive before `ask()` attached, so a scripted answer was swallowed and a destructive confirm defaulted to abort.
30
+ // Seed from OMP settings so pre-~/.tersio installs keep values. Sync: awaiting let piped stdin beat `ask()`.
30
31
  function storedProfileSync() {
31
32
  const base = defaultProfile();
32
33
  const stored = parseStored(readTextIfExistsSync(tersioSettingsFile()) ?? readTextIfExistsSync(legacyOmpLockPath()));
@@ -44,6 +45,8 @@ function applyStored(base, stored) {
44
45
  base.rtkDefault = stored.rtkDefault;
45
46
  if (typeof stored.ponytailDefault === 'string' && PONYTAIL_DEFAULTS.has(stored.ponytailDefault))
46
47
  base.ponytailDefault = stored.ponytailDefault;
48
+ if (typeof stored.pstackDefault === 'string' && PSTACK_DEFAULTS.has(stored.pstackDefault))
49
+ base.pstackDefault = stored.pstackDefault;
47
50
  if (typeof stored.currency === 'string' && isCurrencyCode(stored.currency.trim().toUpperCase())) {
48
51
  base.currency = stored.currency.trim().toUpperCase();
49
52
  }
@@ -80,7 +83,7 @@ function parseStored(raw) {
80
83
  return null;
81
84
  }
82
85
  }
83
- // Persist the profile where every host reads it. `omp plugin config get` no longer reports it; `tersio settings` does.
86
+ // Persist profile where every host reads it. `omp plugin config get` no longer reports it; `tersio settings` does.
84
87
  async function writePluginSettings(profile, options) {
85
88
  const file = tersioSettingsFile();
86
89
  const values = {
@@ -90,6 +93,7 @@ async function writePluginSettings(profile, options) {
90
93
  cavemanDefault: profile.cavemanDefault,
91
94
  rtkDefault: profile.rtkDefault,
92
95
  ponytailDefault: profile.ponytailDefault,
96
+ pstackDefault: profile.pstackDefault,
93
97
  currency: profile.currency,
94
98
  backupSchedule: profile.backupSchedule,
95
99
  ...(profile.subagentMarkers.length > 0 ? { subagentMarkers: profile.subagentMarkers } : {}),
@@ -104,21 +108,22 @@ async function writePluginSettings(profile, options) {
104
108
  console.log(` [write] Session defaults in ${file}`);
105
109
  }
106
110
  function formatProfile(profile) {
107
- return `combo=${profile.comboDefault} (caveman=${profile.cavemanDefault} · rtk=${profile.rtkDefault ? 'on' : 'off'} · ponytail=${profile.ponytailDefault}) · currency=${profile.currency}`;
111
+ return `combo=${profile.comboDefault} (caveman=${profile.cavemanDefault} · rtk=${profile.rtkDefault ? 'on' : 'off'} · ponytail=${profile.ponytailDefault} · pstack=${profile.pstackDefault}) · currency=${profile.currency}`;
108
112
  }
109
113
  export { defaultProfile, formatProfile, storedProfile, storedProfileSync, writePluginSettings };
110
114
  /** The status line the extensions print, read from the persisted profile. */
111
115
  export function formatCliStatus(profile) {
112
- const { comboDefault, cavemanDefault, rtkDefault, ponytailDefault } = profile;
113
- if (comboDefault === 'off' && cavemanDefault === 'off' && !rtkDefault && ponytailDefault === 'off') {
116
+ const { comboDefault, cavemanDefault, rtkDefault, ponytailDefault, pstackDefault } = profile;
117
+ if (comboDefault === 'off' && cavemanDefault === 'off' && !rtkDefault && ponytailDefault === 'off' && pstackDefault === 'off') {
114
118
  return '🧩 combo OFF';
115
119
  }
116
120
  const preset = COMBO_PRESET_MODES[comboDefault];
117
121
  const isPreset = preset !== undefined
118
122
  && preset.caveman === cavemanDefault
119
123
  && preset.rtk === rtkDefault
120
- && preset.ponytail === ponytailDefault;
124
+ && preset.ponytail === ponytailDefault
125
+ && preset.pstack === pstackDefault;
121
126
  const level = isPreset ? comboDefault.toUpperCase() : 'CUSTOM';
122
127
  const rtk = rtkDefault ? 'ON' : 'OFF';
123
- return `🧩 combo ${level}: 🪨caveman=${cavemanDefault.toUpperCase()} ⚡rtk=${rtk} 🦥ponytail=${ponytailDefault.toUpperCase()}`;
128
+ return `🧩 combo ${level}: 🪨caveman=${cavemanDefault.toUpperCase()} ⚡rtk=${rtk} 🦥ponytail=${ponytailDefault.toUpperCase()} 🥔pstack=${pstackDefault.toUpperCase()}`;
124
129
  }