@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,27 +1,108 @@
1
1
  // cli/doctor.ts — installation health checks.
2
- import { existsSync, promises as fs } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync, readdirSync, readFileSync, statSync, promises as fs } from 'node:fs';
3
4
  import path from 'node:path';
4
- import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, args, dryRun, fix, yes, execP, parseJsonObject, relTime, } from './common.js';
5
- import { detectHosts, hostExtensionsDir, ompPackageDir } from './hosts.js';
5
+ import { HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, args, dryRun, fix, yes, BUN_BIN_DIR, OMP_BIN, execP, parseJsonObject, relTime, } from './common.js';
6
+ import { detectHosts, hostExtensionsDir, ompPackageDir, piPonytailPackageDir } from './hosts.js';
6
7
  import { askInteractiveChoice, askInteractiveConfirm, runInteractivePhase } from './interactive.js';
7
8
  import { usageDbPath } from '../extensions/shared/usage-store.js';
8
9
  import { pricesCachePath } from '../extensions/shared/pricing.js';
9
- import { readTextIfExists, resolveRtkBinary, ruleBodyProblem } from '../extensions/lib/utils.js';
10
- import { TREE_FILES } from './manifest.js';
10
+ import { findHoistedPackage, fetchJson, piAgentDir, readTextIfExists, resolveHostBinary, resolveRtkBinary, ruleBodyProblem } from '../extensions/shared/utils.js';
11
+ import { sqliteScript, statsDbPath } from '../extensions/shared/agent-stats.js';
12
+ import { OMP_SUBAGENT_MARKER } from '../extensions/shared/session-state.js';
13
+ import { PI_PONYTAIL_FILES, PONYTAIL_BUNDLE_FILES, TREE_FILES, isObsoleteTreeFile, treeFileText, treeFilesFor } from './manifest.js';
14
+ import { serviceRunning, serviceState } from './doctor-fix.js';
15
+ /** Bytes at the scale a transcript gap reads in: `5.6M`, not `5571992`. */
16
+ function shortBytes(bytes) {
17
+ if (bytes >= 1e9)
18
+ return `${(bytes / 1e9).toFixed(1)}G`;
19
+ if (bytes >= 1e6)
20
+ return `${(bytes / 1e6).toFixed(1)}M`;
21
+ if (bytes >= 1e3)
22
+ return `${Math.round(bytes / 1e3)}K`;
23
+ return `${bytes}B`;
24
+ }
11
25
  async function runDoctor(recheck = false) {
12
26
  console.log('\n=== Tersio Doctor ===');
13
- // 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.
27
+ // OMP loads the plugin from its plugins dir; pi from its npm dir, per its settings.json.
14
28
  const pluginsDir = OMP_PLUGINS_DIR;
15
29
  const rtkBin = resolveRtkBinary();
16
- // Ponytail
17
- 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');
23
- // Independent probes start concurrently; sections report in fixed order as their data settles. Every probe resolves instead of rejecting.
30
+ // OpenCode's own binary, when installed: `~/.opencode/bin` first, then PATH.
31
+ const opencodeBin = resolveHostBinary('opencode', path.join(HOME, '.opencode', 'bin'));
32
+ // One source for the installed plugin path, so the doctor follows a package rename.
33
+ const tersioPluginDir = ompPackageDir();
34
+ // Node resolves npm's nested copy before the top-level one, so the top-level copy is a decoy no host loads.
35
+ const ponytailPkg = findHoistedPackage('@dietrichgebert/ponytail', tersioPluginDir, 'package.json')
36
+ ?? path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
37
+ const cavemanIndex = path.join(tersioPluginDir, 'extensions', 'caveman', 'index.ts');
38
+ const cavemanRule = (name) => path.join(tersioPluginDir, 'extensions', 'caveman', name);
39
+ // The pstack body is a Tersio fork with no remote, so the check proves it is usable, not current.
40
+ const pstackRule = path.join(tersioPluginDir, 'extensions', 'pstack', 'rule.md');
41
+ const rtkIndex = path.join(tersioPluginDir, 'extensions', 'rtk', 'session.ts');
42
+ const updaterIndex = path.join(tersioPluginDir, 'extensions', 'updater', 'index.ts');
43
+ // Probes start concurrently and report in fixed order as data settles; each resolves instead of rejecting.
24
44
  const hosts = detectHosts();
45
+ // Every host keeps a copy, and the copy omp loads is the reference itself, so only the hosts that get
46
+ // their own copy are compared below. The package root, not the hook's directory, is what the paths hang off.
47
+ const ponytailLoads = {
48
+ pi: { dir: piPonytailPackageDir(piAgentDir()), files: PI_PONYTAIL_FILES },
49
+ opencode: { dir: path.join(hostExtensionsDir('opencode'), 'ponytail-bundle'), files: PONYTAIL_BUNDLE_FILES },
50
+ };
51
+ // One hook cannot stand for the set: 5.1 and 5.2 ship the same instructions file and differ inside a skill.
52
+ const ponytailStale = [];
53
+ const ponytailSource = path.dirname(ponytailPkg);
54
+ for (const host of hosts.filter((h) => h.installed)) {
55
+ const copy = ponytailLoads[host.id];
56
+ if (!copy)
57
+ continue;
58
+ for (const rel of copy.files) {
59
+ const ref = await readTextIfExists(path.join(ponytailSource, ...rel.split('/')));
60
+ if (ref === null)
61
+ continue;
62
+ const theirs = await readTextIfExists(path.join(copy.dir, ...rel.split('/')));
63
+ if (theirs !== ref)
64
+ ponytailStale.push(`${host.id}/${rel}`);
65
+ }
66
+ }
67
+ // One comparison per read file catches three directions: dropped, not received, debris left under a dropped name.
68
+ const treeStale = [];
69
+ const treeMissing = [];
70
+ const treeOrphan = [];
71
+ // The tree a host loads: omp reads the plugin package, every other host its own directory.
72
+ const treeBaseFor = (host) => host.id === 'omp' ? path.join(tersioPluginDir, 'extensions') : hostExtensionsDir(host.id);
73
+ // A tree holding at least one file of this build is compared even when the host reads as not
74
+ // installed. pi auto-discovers every index.ts it finds, so a half-written tree needs reporting,
75
+ // and `detectHosts` reads one as absent: it wants every file or a declared package.
76
+ const treeHoldsOwnFiles = (host) => treeFilesFor(host.id === 'omp' ? 'opencode' : host.id).some((rel) => existsSync(path.join(treeBaseFor(host), ...rel.split('/'))));
77
+ for (const host of hosts.filter((h) => h.installed || treeHoldsOwnFiles(h))) {
78
+ const treeId = host.id === 'omp' ? 'opencode' : host.id;
79
+ const base = treeBaseFor(host);
80
+ const onDisk = new Set(hostTreeFiles(base).filter((rel) => path.extname(rel) !== '.js'));
81
+ const expected = new Set(treeFilesFor(treeId));
82
+ const every = new Set([...onDisk, ...expected]);
83
+ for (const rel of every) {
84
+ // Reference is this CLI's own definition, not `tersioPluginDir`, which compares the tree to itself.
85
+ const reference = treeFileText(rel);
86
+ const at = path.join(base, ...rel.split('/'));
87
+ // One debris definition for detection and repair, so a row can only name what a repair removes.
88
+ if (isObsoleteTreeFile(rel, treeId))
89
+ treeOrphan.push(`${host.id}/${rel}`);
90
+ else if (!expected.has(rel))
91
+ continue;
92
+ else if (!onDisk.has(rel))
93
+ treeMissing.push(`${host.id}/${rel}`);
94
+ // No reference means this build dropped the entry: debris, not a mismatch.
95
+ else if (reference === null)
96
+ continue;
97
+ else {
98
+ const theirs = await readTextIfExists(at);
99
+ if (theirs === null)
100
+ treeMissing.push(`${host.id}/${rel}`);
101
+ else if (theirs !== reference)
102
+ treeStale.push(`${host.id}/${rel}`);
103
+ }
104
+ }
105
+ }
25
106
  const probes = {
26
107
  ompPkgText: readTextIfExists(path.join(ompPackageDir(), 'package.json')),
27
108
  configText: readTextIfExists(path.join(OMP_AGENT_DIR, 'config.yml')),
@@ -31,6 +112,7 @@ async function runDoctor(recheck = false) {
31
112
  cavemanRuleText: readTextIfExists(cavemanRule('rule.md')),
32
113
  cavemanUltraText: readTextIfExists(cavemanRule('rule-ultra.md')),
33
114
  cavemanMegacaveText: readTextIfExists(cavemanRule('rule-megacave.md')),
115
+ pstackRuleText: readTextIfExists(pstackRule),
34
116
  rtkIndexText: readTextIfExists(rtkIndex),
35
117
  updaterIndexText: readTextIfExists(updaterIndex),
36
118
  rtkMtime: rtkBin ? fs.stat(rtkBin).catch(() => null) : Promise.resolve(null),
@@ -42,7 +124,11 @@ async function runDoctor(recheck = false) {
42
124
  const err = e;
43
125
  return err.stdout?.trim() || err.stderr?.trim() || null;
44
126
  }) : Promise.resolve(null);
45
- const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText], [cavemanRuleText, cavemanUltraText, cavemanMegacaveText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
127
+ // A manager that lost its daemonized process reads stopped, while the orphan still holds the port.
128
+ const opencodeServiceProbe = opencodeBin
129
+ ? Promise.resolve(serviceState())
130
+ : Promise.resolve(null);
131
+ const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText], [cavemanRuleText, cavemanUltraText, cavemanMegacaveText, pstackRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime], [opencodeService]] = await runInteractivePhase('Checking installation', () => Promise.all([
46
132
  Promise.all([
47
133
  probes.ompPkgText,
48
134
  probes.configText,
@@ -57,6 +143,7 @@ async function runDoctor(recheck = false) {
57
143
  probes.cavemanRuleText,
58
144
  probes.cavemanUltraText,
59
145
  probes.cavemanMegacaveText,
146
+ probes.pstackRuleText,
60
147
  probes.ruleMtime,
61
148
  probes.rtkBinText,
62
149
  probes.rtkMtime,
@@ -64,8 +151,9 @@ async function runDoctor(recheck = false) {
64
151
  probes.ponytailMtime,
65
152
  probes.pricesMtime,
66
153
  ]),
154
+ Promise.all([opencodeServiceProbe]),
67
155
  ]));
68
- // Categorized output with a tally; success rows stay quiet (no path echoes) while failures print the expected path or fix so they stay actionable.
156
+ // Success rows stay quiet (no path echoes); failures print the expected path or fix so they stay actionable.
69
157
  const tally = { ok: 0, missing: 0, warn: 0 };
70
158
  function absDate(ms) {
71
159
  const d = new Date(ms);
@@ -87,7 +175,7 @@ async function runDoctor(recheck = false) {
87
175
  tally.warn++;
88
176
  console.log(` ⚠️ ${label}: warn ${detail}`);
89
177
  }
90
- // A host you do not use is not a fault, so it stays out of the tally and only carries the one line that installs it.
178
+ // An unused host is not a fault: out of the tally, carrying only the one line that installs it.
91
179
  function unused(label, detail) {
92
180
  console.log(` — ${label}: not installed ${detail}`);
93
181
  }
@@ -102,7 +190,7 @@ async function runDoctor(recheck = false) {
102
190
  continue;
103
191
  }
104
192
  if (!host.installed) {
105
- // A pi declaration without the tree or the package is a broken install, so it stays a counted row rather than reading as "not installed".
193
+ // A pi declaration without tree or package is a broken install: a counted row, not "not installed".
106
194
  if (host.declared)
107
195
  check(host.label, false, `declared (${host.declared}) but not installed`);
108
196
  else
@@ -113,6 +201,17 @@ async function runDoctor(recheck = false) {
113
201
  const kind = host.via === 'tree' ? 'extensions' : 'package';
114
202
  check(host.label, true, `${kind} ${PACKAGE_NAME}${host.version ? ` ${host.version}` : ''}`);
115
203
  }
204
+ // A healthy service adds no row: the host row already names the install with its version, and
205
+ // this row earns its place only when the manager's report and the live process disagree.
206
+ if (opencodeService) {
207
+ const running = serviceRunning(opencodeService.status);
208
+ if (!running && opencodeService.live) {
209
+ warnLine('OpenCode service', `a serve process is alive but the service reads ${opencodeService.status} — run: tersio doctor --fix service`);
210
+ }
211
+ else if (running && !opencodeService.live) {
212
+ warnLine('OpenCode service', 'the service reports running but no serve process is alive — run: tersio doctor --fix service');
213
+ }
214
+ }
116
215
  section('Extensions & plugins');
117
216
  if (hosts.find((host) => host.id === 'omp')?.via === 'package') {
118
217
  const explicitEntries = (configText ?? '').split('\n')
@@ -131,6 +230,10 @@ async function runDoctor(recheck = false) {
131
230
  console.log(` Usage DB (tersio-owned · local hosted): ${usageDbPath()}`);
132
231
  const pricesAge = pricesMtime ? `(pulled ${relTime(Date.now() - pricesMtime.mtimeMs)} · ${absDate(pricesMtime.mtimeMs)})` : '';
133
232
  check('Prices feed', pricesMtime !== null, pricesAge || pricesCachePath());
233
+ // Pages read omp's own index, which advances only while an omp process owns it, so a stall reads as empty.
234
+ const indexLag = statsIndexLag();
235
+ if (indexLag)
236
+ warnLine('omp stats index', indexLag);
134
237
  section('Add-ons');
135
238
  const ruleAge = ruleMtime ? `(updated ${relTime(Date.now() - ruleMtime.mtimeMs)} · ${absDate(ruleMtime.mtimeMs)})` : '';
136
239
  const cavemanRules = [['rule.md', cavemanRuleText], ['rule-ultra.md', cavemanUltraText], ['rule-megacave.md', cavemanMegacaveText]];
@@ -143,6 +246,30 @@ async function runDoctor(recheck = false) {
143
246
  if (problem)
144
247
  warnLine(`Caveman ${name}`, `unusable body (${problem}) — run: tersio install`);
145
248
  }
249
+ // Every preset above off turns pstack on, so a body that is missing or unusable costs a live mode.
250
+ check('pstack rule', pstackRuleText !== null, pstackRuleText === null ? 'missing — run: tersio install' : '');
251
+ if (pstackRuleText !== null) {
252
+ const problem = ruleBodyProblem(pstackRuleText);
253
+ if (problem)
254
+ warnLine('pstack rule.md', `unusable body (${problem}) — run: tersio install`);
255
+ }
256
+ // A verbatim sentence read out of omp's own prompt, so a re-worded release stops Caveman reaching subagents.
257
+ const subagentGap = subagentMarkerStatus(ompBinary());
258
+ if (subagentGap)
259
+ warnLine('Caveman subagent marker', subagentGap);
260
+ // A tree on an older body serves until its install step runs; debris needs the sweeper instead.
261
+ if (treeStale.length > 0) {
262
+ warnLine('Per-host add-on files', `stale in ${treeStale.length} file${treeStale.length === 1 ? '' : 's'}: ${treeStale.slice(0, 6).join(', ')}` +
263
+ `${treeStale.length > 6 ? ` +${treeStale.length - 6} more` : ''} — run: tersio install --host <host>`);
264
+ }
265
+ if (treeMissing.length > 0) {
266
+ warnLine('Per-host add-on files', `never installed in ${treeMissing.length} file${treeMissing.length === 1 ? '' : 's'}: ${treeMissing.join(', ')}` +
267
+ ` — run: tersio install --host <host>`);
268
+ }
269
+ if (treeOrphan.length > 0) {
270
+ warnLine('Per-host add-on files', `orphaned in ${treeOrphan.length} file${treeOrphan.length === 1 ? '' : 's'}: ${treeOrphan.join(', ')}` +
271
+ ` — run: tersio doctor --fix extensions`);
272
+ }
146
273
  const rtkAge = rtkMtime ? `(updated ${relTime(Date.now() - rtkMtime.mtimeMs)} · ${absDate(rtkMtime.mtimeMs)})` : '';
147
274
  check('RTK binary', rtkBin !== null, rtkBinText === null ? 'not found in PATH' : [rtkVersion, rtkAge].filter(Boolean).join(' '));
148
275
  // rtk_run needs a host exec API, which doctor cannot assume.
@@ -155,6 +282,14 @@ async function runDoctor(recheck = false) {
155
282
  const ponytailAge = ponytailMtime ? `(updated ${relTime(Date.now() - ponytailMtime.mtimeMs)} · ${absDate(ponytailMtime.mtimeMs)})` : '';
156
283
  const ponytailVer = parseJsonObject(ponytailPkgText)?.version ?? '';
157
284
  check('Ponytail', ponytailPkgText !== null, [ponytailVer, ponytailAge].filter(Boolean).join(' '));
285
+ if (ponytailStale.length > 0) {
286
+ warnLine('Ponytail per-host bodies', `stale in ${ponytailStale.length} file${ponytailStale.length === 1 ? '' : 's'}: ${ponytailStale.slice(0, 6).join(', ')}` +
287
+ `${ponytailStale.length > 6 ? ` +${ponytailStale.length - 6} more` : ''} — run: tersio install --host <host>`);
288
+ }
289
+ // Offline-safe: the registry call is best-effort, and silence means no row rather than a stale one.
290
+ const ponytailVersionGap = await ponytailLatestGap(ponytailPkgText);
291
+ if (ponytailVersionGap)
292
+ warnLine('Ponytail version', `${ponytailVersionGap} — run: tersio update --apply-update --yes`);
158
293
  const total = tally.ok + tally.missing + tally.warn;
159
294
  console.log(`\n Summary: ${total} checks — ✅ ${tally.ok} ok, ⚠️ ${tally.warn} warn, ❌ ${tally.missing} missing`);
160
295
  if (!recheck && tally.missing + tally.warn > 0 && (fix || dryRun)) {
@@ -182,39 +317,113 @@ function fixScope() {
182
317
  return checkFixScope(next.trim().toLowerCase());
183
318
  }
184
319
  function checkFixScope(raw) {
185
- const valid = ['extensions', 'registrations', 'rtk', 'ponytail', 'cli'];
320
+ const valid = ['extensions', 'registrations', 'rtk', 'ponytail', 'cli', 'service'];
186
321
  if (!valid.includes(raw)) {
187
322
  console.error(`[fail] Invalid --fix scope: ${raw}. Valid: ${valid.join(', ')}`);
188
323
  process.exit(1);
189
324
  }
190
325
  return raw;
191
326
  }
327
+ /** omp's binary on PATH, or null, resolved against omp's own name the way `resolveRtkBinary` walks for rtk. */
328
+ function ompBinary() {
329
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
330
+ if (!dir)
331
+ continue;
332
+ const candidate = path.join(dir, OMP_BIN);
333
+ if (existsSync(candidate))
334
+ return candidate;
335
+ }
336
+ const managed = path.join(BUN_BIN_DIR, OMP_BIN);
337
+ return existsSync(managed) ? managed : null;
338
+ }
339
+ // The rule bodies a per-host tree carries. rule.md is the bundled fork; the others track upstream.
340
+ const CAVEMAN_BODIES = ['rule.md', 'rule-ultra.md', 'rule-megacave.md'];
341
+ /** `"installed < latest"` when npm answers, else null: silence means "no row", not "stale". */
342
+ async function ponytailLatestGap(installedPkgText) {
343
+ const local = parseJsonObject(installedPkgText)?.version;
344
+ if (!local)
345
+ return null;
346
+ try {
347
+ const meta = await fetchJson('https://registry.npmjs.org/@dietrichgebert/ponytail/latest');
348
+ const remote = meta?.version;
349
+ if (!remote || remote === local)
350
+ return null;
351
+ return `${local} installed, ${remote} is the latest`;
352
+ }
353
+ catch {
354
+ return null;
355
+ }
356
+ }
357
+ /** Add-on files under a host's tree, repo-relative, empty when absent: `node_modules` is skipped as a hoisted dep. */
358
+ function hostTreeFiles(base) {
359
+ const out = [];
360
+ const walk = (dir, rel) => {
361
+ let entries;
362
+ try {
363
+ entries = readdirSync(dir, { withFileTypes: true });
364
+ }
365
+ catch {
366
+ return;
367
+ }
368
+ for (const entry of entries) {
369
+ if (entry.name === 'node_modules' || entry.name === '.DS_Store')
370
+ continue;
371
+ const child = rel ? `${rel}/${entry.name}` : entry.name;
372
+ if (entry.isDirectory())
373
+ walk(path.join(dir, entry.name), child);
374
+ else
375
+ out.push(child);
376
+ }
377
+ };
378
+ walk(base, '');
379
+ return out;
380
+ }
381
+ /** Whether omp's strings still carry Caveman's marker sentence: null = no gap, string = warning; pi unchecked. */
382
+ function subagentMarkerStatus(ompBin) {
383
+ if (!ompBin)
384
+ return null;
385
+ let haystack;
386
+ try {
387
+ haystack = execFileSync(ompBin, ['--version'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] })
388
+ + readFileSync(ompBin, 'utf8');
389
+ }
390
+ catch {
391
+ // A binary that will not run is the version row's problem, not this one's.
392
+ return null;
393
+ }
394
+ if (haystack.includes(OMP_SUBAGENT_MARKER))
395
+ return null;
396
+ return `not found in ${path.basename(ompBin)} — Caveman will not reach subagents until OMP_SUBAGENT_MARKER in extensions/shared/session-state.ts is updated`;
397
+ }
192
398
  async function runDoctorFix(tally) {
193
399
  const problems = [];
194
400
  if (tally.missing > 0)
195
401
  problems.push(`${tally.missing} missing`);
196
402
  if (tally.warn > 0)
197
403
  problems.push(`${tally.warn} warn`);
198
- if (dryRun) {
199
- console.log(`\n [dry-run] would repair ${problems.join(' + ')} (extensions, stale config.yml entries, rtk wiring, ponytail refresh, CLI update)`);
200
- return;
201
- }
202
404
  const scoped = fixScope();
203
405
  let targets;
204
406
  if (scoped) {
205
407
  targets = [scoped];
206
- if (!yes) {
207
- const go = await askInteractiveConfirm(`Repair "${scoped}" now?`);
208
- if (go.status !== 'confirmed' || !go.value) {
209
- process.exitCode = 130;
210
- return;
211
- }
212
- }
213
408
  }
214
- else if (yes) {
409
+ else {
215
410
  targets = ['all'];
216
411
  }
217
- else {
412
+ // A dry run still reaches the repairs: each write is guarded, so the flag only skips prompts a preview cannot ask.
413
+ if (dryRun) {
414
+ console.log(`\n [dry-run] would repair ${problems.join(' + ') || 'nothing'} (extensions, stale config.yml entries, rtk wiring, ponytail refresh, CLI update, opencode service)`);
415
+ const { runDoctorRepairs } = await import('./doctor-fix.js');
416
+ await runDoctorRepairs(targets);
417
+ return;
418
+ }
419
+ if (scoped && !yes) {
420
+ const go = await askInteractiveConfirm(`Repair "${scoped}" now?`);
421
+ if (go.status !== 'confirmed' || !go.value) {
422
+ process.exitCode = 130;
423
+ return;
424
+ }
425
+ }
426
+ else if (!scoped && !yes) {
218
427
  const picked = await askInteractiveChoice('Doctor — what should I repair?', [
219
428
  { value: 'all', label: 'Fix everything', hint: `${tally.missing + tally.warn} rows` },
220
429
  { value: 'extensions', label: 'Missing extension files', hint: 'copy sources from this CLI' },
@@ -222,6 +431,7 @@ async function runDoctorFix(tally) {
222
431
  { value: 'rtk', label: 'RTK binary + wiring', hint: 'download checksum-verified binary, rtk init' },
223
432
  { value: 'ponytail', label: 'Ponytail package', hint: 'bundled reinstall via tersio dep' },
224
433
  { value: 'cli', label: 'CLI update', hint: 'latest tersio + delegated refresh' },
434
+ { value: 'service', label: 'OpenCode service', hint: 'stop an orphaned serve process, then start it' },
225
435
  ], 'all');
226
436
  if (picked.status !== 'selected') {
227
437
  process.exitCode = 130;
@@ -256,6 +466,45 @@ async function runDoctorFix(tally) {
256
466
  else
257
467
  console.log(` Doctor --fix: still ${again.missing} missing + ${again.warn} warn — rerun or repair manually.`);
258
468
  }
469
+ /** How far omp's transcript index lags the transcripts on disk, or null when behind or unreadable. */
470
+ function statsIndexLag() {
471
+ const statsDb = statsDbPath();
472
+ if (!existsSync(statsDb))
473
+ return null;
474
+ try {
475
+ // Same WAL-safe reader as the aggregate, so this probe creates no side files beside a database omp has open.
476
+ const rows = sqliteScript(statsDb, 'SELECT session_file, offset FROM file_offsets;\n')
477
+ .split('\n')
478
+ .filter((line) => line.includes('\t'))
479
+ .map((line) => line.split('\t'));
480
+ if (rows.length === 0)
481
+ return null;
482
+ // A page shows the gap behind the freshest transcript, so the newest file sets the number, not the sum.
483
+ let worstBytes = 0;
484
+ let freshest = '';
485
+ for (const [file, offsetText] of rows) {
486
+ let size;
487
+ try {
488
+ size = statSync(file).size;
489
+ }
490
+ catch {
491
+ continue; // a rotated or deleted transcript is not a lag
492
+ }
493
+ const behind = size - Number(offsetText);
494
+ if (behind > worstBytes) {
495
+ worstBytes = behind;
496
+ freshest = file;
497
+ }
498
+ }
499
+ if (worstBytes < 4096)
500
+ return null;
501
+ const when = relTime(Date.now() - statSync(statsDb).mtimeMs);
502
+ return `${shortBytes(worstBytes)} of ${path.basename(freshest)} unread, last written ${when === 'just now' ? 'moments ago' : when} — run: omp stats`;
503
+ }
504
+ catch {
505
+ return null; // no SQLite engine, or an unreadable index: nothing to report
506
+ }
507
+ }
259
508
  function section(name) {
260
509
  console.log(`\n${name}`);
261
510
  }
package/dist/cli/hosts.js CHANGED
@@ -1,9 +1,9 @@
1
- // cli/hosts.ts — the agent hosts Tersio installs into, and what is on disk. One detector, shared by the doctor and both menus, so they cannot disagree.
1
+ // cli/hosts.ts — agent hosts Tersio installs into, and what is on disk. One detector shared by doctor and menus.
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('/'));
@@ -24,6 +24,26 @@ function piPackageDir(agentDir = piAgentDir()) {
24
24
  function piSettingsFile(agentDir = piAgentDir()) {
25
25
  return path.join(agentDir, 'settings.json');
26
26
  }
27
+ // pi installs Ponytail as a local path package, so settings.json names the loaded directory; the npm spec is older.
28
+ export function piPonytailPackageDir(agentDir = piAgentDir()) {
29
+ let packages = [];
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(piSettingsFile(agentDir), 'utf8'));
32
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.packages)) {
33
+ packages = parsed.packages;
34
+ }
35
+ }
36
+ catch { /* no settings file, or unreadable */ }
37
+ for (const entry of packages) {
38
+ const source = typeof entry === 'string' ? entry : entry?.source;
39
+ if (typeof source !== 'string')
40
+ continue;
41
+ const trimmed = source.trim().replace(/[/\\]+$/, '');
42
+ if (path.isAbsolute(trimmed) && path.basename(trimmed) === 'ponytail')
43
+ return trimmed;
44
+ }
45
+ return path.join(agentDir, 'npm', 'node_modules', '@dietrichgebert', 'ponytail');
46
+ }
27
47
  function packageVersion(dir) {
28
48
  try {
29
49
  const parsed = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
@@ -33,7 +53,7 @@ function packageVersion(dir) {
33
53
  return null;
34
54
  }
35
55
  }
36
- // The tersio package pi declared in its own settings.json, or null. pi identifies a source by name, so an npm spec, git URL, or path all count.
56
+ // The tersio package pi declared in settings.json, or null; pi names sources, so npm spec, git URL or path count.
37
57
  export function piTersioSource(agentDir = piAgentDir()) {
38
58
  let settings;
39
59
  try {
@@ -57,7 +77,6 @@ export function piTersioSource(agentDir = piAgentDir()) {
57
77
  }
58
78
  return null;
59
79
  }
60
- // The hosts we install into, and the commands that add and remove each.
61
80
  const HOSTS = [
62
81
  { id: 'omp', label: 'Oh My Pi', bin: 'omp', installCmd: `omp plugin install ${PACKAGE_NAME}`, removeCmd: `omp plugin remove ${PACKAGE_NAME}` },
63
82
  { id: 'pi', label: 'Pi', bin: 'pi', installCmd: `pi install npm:${PACKAGE_NAME}`, removeCmd: `pi remove npm:${PACKAGE_NAME}` },
@@ -68,7 +87,8 @@ function detect(agentDir = piAgentDir()) {
68
87
  const pkgDir = host.id === 'omp' ? ompPackageDir() : host.id === 'pi' ? piPackageDir(agentDir) : null;
69
88
  const tree = hostExtensionsDir(host.id, agentDir);
70
89
  // A tree wins: it is what the installer writes, and it carries no package.json, so its version is ours.
71
- const via = missingTreeFiles(host.id, agentDir).length === 0
90
+ // OpenCode keeps no package to fall back to, so a present tree is the install; its drifted files get the per-host warning instead.
91
+ const via = missingTreeFiles(host.id, agentDir).length === 0 || (host.id === 'opencode' && existsSync(tree))
72
92
  ? 'tree'
73
93
  : pkgDir && existsSync(pkgDir) ? 'package' : null;
74
94
  return {
@@ -101,7 +121,7 @@ export function hostHint(host) {
101
121
  return host.via === 'tree' ? 'extension tree' : 'plugin package';
102
122
  return host.installCmd;
103
123
  }
104
- // `--host omp|pi` from raw argv; undefined when absent. Throws on an unknown value so the caller can exit(1) with the message.
124
+ // `--host omp|pi` from raw argv, undefined when absent; throws on an unknown value so the caller exits(1).
105
125
  export function parseHostArg(argv) {
106
126
  const i = argv.indexOf('--host');
107
127
  const raw = (i === -1 ? undefined : argv[i + 1]) ?? argv.find((a) => a.startsWith('--host='))?.slice('--host='.length);