@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.
- package/README.md +17 -36
- package/dashboard/dist/index.html +15 -15
- package/dist/cli/backups.js +7 -14
- package/dist/cli/banner.js +4 -3
- package/dist/cli/common.js +28 -12
- package/dist/cli/currency.js +1 -1
- package/dist/cli/dashboard.js +66 -35
- package/dist/cli/doctor-fix.js +204 -19
- package/dist/cli/doctor.js +226 -39
- package/dist/cli/hosts.js +25 -5
- package/dist/cli/install.js +144 -48
- package/dist/cli/interactive.js +3 -3
- package/dist/cli/manifest.js +87 -7
- package/dist/cli/profile.js +13 -8
- package/dist/cli/rtk-wiring.js +1 -1
- package/dist/cli/settings.js +20 -5
- package/dist/cli/uninstall.js +3 -3
- package/dist/cli/update.js +6 -7
- package/dist/cli/usage.js +6 -9
- package/dist/extensions/caveman/index.js +4 -2
- package/dist/extensions/caveman/rule-megacave.md +5 -3
- package/dist/extensions/caveman/rule-ultra.md +4 -4
- package/dist/extensions/caveman/rule.md +55 -45
- package/dist/extensions/combo/index.js +62 -21
- package/dist/extensions/commands/index.js +1 -1
- package/dist/extensions/pstack/index.js +119 -0
- package/dist/extensions/pstack/rule.md +65 -0
- package/dist/extensions/rtk/filter.js +0 -1
- package/dist/extensions/rtk/session.js +4 -4
- package/dist/extensions/server.js +90 -6
- package/dist/extensions/shared/agent-stats.js +214 -278
- package/dist/extensions/shared/brand-marks.js +33 -34
- package/dist/extensions/shared/carbon.js +1 -1
- package/dist/extensions/shared/plugin-settings.js +11 -6
- package/dist/extensions/shared/pricing.js +8 -16
- package/dist/extensions/shared/rtk-gain.js +4 -9
- package/dist/extensions/shared/session-state.js +15 -10
- package/dist/extensions/shared/sqlite.js +168 -0
- package/dist/extensions/shared/status.js +8 -8
- package/dist/extensions/shared/types.js +1 -1
- package/dist/extensions/shared/usage-ledger.js +53 -41
- package/dist/extensions/shared/usage-store.js +111 -52
- package/dist/extensions/shared/utils.js +5 -5
- package/dist/extensions/timing/index.js +31 -0
- package/dist/extensions/updater/index.js +3 -6
- package/dist/tersio.js +8 -3
- package/extensions/caveman/index.ts +4 -2
- package/extensions/caveman/rule-megacave.md +5 -3
- package/extensions/caveman/rule-ultra.md +4 -4
- package/extensions/caveman/rule.md +55 -45
- package/extensions/combo/index.ts +62 -19
- package/extensions/commands/index.ts +1 -1
- package/extensions/pstack/index.ts +128 -0
- package/extensions/pstack/rule.md +65 -0
- package/extensions/rtk/filter.ts +0 -1
- package/extensions/rtk/session.ts +4 -4
- package/extensions/server.ts +72 -7
- package/extensions/shared/agent-stats.ts +226 -278
- package/extensions/shared/brand-marks.ts +34 -34
- package/extensions/shared/carbon.ts +1 -1
- package/extensions/shared/plugin-settings.ts +12 -6
- package/extensions/shared/pricing.ts +10 -26
- package/extensions/shared/rtk-gain.ts +4 -9
- package/extensions/shared/session-state.ts +18 -13
- package/extensions/shared/sqlite.ts +168 -0
- package/extensions/shared/status.ts +9 -8
- package/extensions/shared/types.ts +2 -1
- package/extensions/shared/usage-ledger.ts +60 -47
- package/extensions/shared/usage-store.ts +120 -52
- package/extensions/shared/utils.ts +5 -5
- package/extensions/timing/index.ts +45 -0
- package/extensions/updater/index.ts +3 -8
- package/package.json +17 -8
package/dist/cli/doctor.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// cli/doctor.ts — installation health checks.
|
|
2
|
-
import {
|
|
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/shared/utils.js';
|
|
10
|
+
import { findHoistedPackage, fetchJson, piAgentDir, readTextIfExists, resolveHostBinary, resolveRtkBinary, ruleBodyProblem } from '../extensions/shared/utils.js';
|
|
10
11
|
import { sqliteScript, statsDbPath } from '../extensions/shared/agent-stats.js';
|
|
11
|
-
import {
|
|
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';
|
|
12
15
|
/** Bytes at the scale a transcript gap reads in: `5.6M`, not `5571992`. */
|
|
13
16
|
function shortBytes(bytes) {
|
|
14
17
|
if (bytes >= 1e9)
|
|
@@ -21,19 +24,85 @@ function shortBytes(bytes) {
|
|
|
21
24
|
}
|
|
22
25
|
async function runDoctor(recheck = false) {
|
|
23
26
|
console.log('\n=== Tersio Doctor ===');
|
|
24
|
-
// OMP loads the plugin from its plugins dir; pi
|
|
27
|
+
// OMP loads the plugin from its plugins dir; pi from its npm dir, per its settings.json.
|
|
25
28
|
const pluginsDir = OMP_PLUGINS_DIR;
|
|
26
29
|
const rtkBin = resolveRtkBinary();
|
|
27
|
-
//
|
|
28
|
-
const
|
|
30
|
+
// OpenCode's own binary, when installed: `~/.opencode/bin` first, then PATH.
|
|
31
|
+
const opencodeBin = resolveHostBinary('opencode', path.join(HOME, '.opencode', 'bin'));
|
|
29
32
|
// One source for the installed plugin path, so the doctor follows a package rename.
|
|
30
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');
|
|
31
37
|
const cavemanIndex = path.join(tersioPluginDir, 'extensions', 'caveman', 'index.ts');
|
|
32
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');
|
|
33
41
|
const rtkIndex = path.join(tersioPluginDir, 'extensions', 'rtk', 'session.ts');
|
|
34
42
|
const updaterIndex = path.join(tersioPluginDir, 'extensions', 'updater', 'index.ts');
|
|
35
|
-
//
|
|
43
|
+
// Probes start concurrently and report in fixed order as data settles; each resolves instead of rejecting.
|
|
36
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
|
+
}
|
|
37
106
|
const probes = {
|
|
38
107
|
ompPkgText: readTextIfExists(path.join(ompPackageDir(), 'package.json')),
|
|
39
108
|
configText: readTextIfExists(path.join(OMP_AGENT_DIR, 'config.yml')),
|
|
@@ -43,6 +112,7 @@ async function runDoctor(recheck = false) {
|
|
|
43
112
|
cavemanRuleText: readTextIfExists(cavemanRule('rule.md')),
|
|
44
113
|
cavemanUltraText: readTextIfExists(cavemanRule('rule-ultra.md')),
|
|
45
114
|
cavemanMegacaveText: readTextIfExists(cavemanRule('rule-megacave.md')),
|
|
115
|
+
pstackRuleText: readTextIfExists(pstackRule),
|
|
46
116
|
rtkIndexText: readTextIfExists(rtkIndex),
|
|
47
117
|
updaterIndexText: readTextIfExists(updaterIndex),
|
|
48
118
|
rtkMtime: rtkBin ? fs.stat(rtkBin).catch(() => null) : Promise.resolve(null),
|
|
@@ -54,7 +124,11 @@ async function runDoctor(recheck = false) {
|
|
|
54
124
|
const err = e;
|
|
55
125
|
return err.stdout?.trim() || err.stderr?.trim() || null;
|
|
56
126
|
}) : Promise.resolve(null);
|
|
57
|
-
|
|
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([
|
|
58
132
|
Promise.all([
|
|
59
133
|
probes.ompPkgText,
|
|
60
134
|
probes.configText,
|
|
@@ -69,6 +143,7 @@ async function runDoctor(recheck = false) {
|
|
|
69
143
|
probes.cavemanRuleText,
|
|
70
144
|
probes.cavemanUltraText,
|
|
71
145
|
probes.cavemanMegacaveText,
|
|
146
|
+
probes.pstackRuleText,
|
|
72
147
|
probes.ruleMtime,
|
|
73
148
|
probes.rtkBinText,
|
|
74
149
|
probes.rtkMtime,
|
|
@@ -76,8 +151,9 @@ async function runDoctor(recheck = false) {
|
|
|
76
151
|
probes.ponytailMtime,
|
|
77
152
|
probes.pricesMtime,
|
|
78
153
|
]),
|
|
154
|
+
Promise.all([opencodeServiceProbe]),
|
|
79
155
|
]));
|
|
80
|
-
//
|
|
156
|
+
// Success rows stay quiet (no path echoes); failures print the expected path or fix so they stay actionable.
|
|
81
157
|
const tally = { ok: 0, missing: 0, warn: 0 };
|
|
82
158
|
function absDate(ms) {
|
|
83
159
|
const d = new Date(ms);
|
|
@@ -99,7 +175,7 @@ async function runDoctor(recheck = false) {
|
|
|
99
175
|
tally.warn++;
|
|
100
176
|
console.log(` ⚠️ ${label}: warn ${detail}`);
|
|
101
177
|
}
|
|
102
|
-
//
|
|
178
|
+
// An unused host is not a fault: out of the tally, carrying only the one line that installs it.
|
|
103
179
|
function unused(label, detail) {
|
|
104
180
|
console.log(` — ${label}: not installed ${detail}`);
|
|
105
181
|
}
|
|
@@ -114,7 +190,7 @@ async function runDoctor(recheck = false) {
|
|
|
114
190
|
continue;
|
|
115
191
|
}
|
|
116
192
|
if (!host.installed) {
|
|
117
|
-
// A pi declaration without
|
|
193
|
+
// A pi declaration without tree or package is a broken install: a counted row, not "not installed".
|
|
118
194
|
if (host.declared)
|
|
119
195
|
check(host.label, false, `declared (${host.declared}) but not installed`);
|
|
120
196
|
else
|
|
@@ -125,6 +201,17 @@ async function runDoctor(recheck = false) {
|
|
|
125
201
|
const kind = host.via === 'tree' ? 'extensions' : 'package';
|
|
126
202
|
check(host.label, true, `${kind} ${PACKAGE_NAME}${host.version ? ` ${host.version}` : ''}`);
|
|
127
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
|
+
}
|
|
128
215
|
section('Extensions & plugins');
|
|
129
216
|
if (hosts.find((host) => host.id === 'omp')?.via === 'package') {
|
|
130
217
|
const explicitEntries = (configText ?? '').split('\n')
|
|
@@ -143,8 +230,7 @@ async function runDoctor(recheck = false) {
|
|
|
143
230
|
console.log(` Usage DB (tersio-owned · local hosted): ${usageDbPath()}`);
|
|
144
231
|
const pricesAge = pricesMtime ? `(pulled ${relTime(Date.now() - pricesMtime.mtimeMs)} · ${absDate(pricesMtime.mtimeMs)})` : '';
|
|
145
232
|
check('Prices feed', pricesMtime !== null, pricesAge || pricesCachePath());
|
|
146
|
-
//
|
|
147
|
-
// index reads as an empty dashboard, so name the gap instead of leaving a user to guess.
|
|
233
|
+
// Pages read omp's own index, which advances only while an omp process owns it, so a stall reads as empty.
|
|
148
234
|
const indexLag = statsIndexLag();
|
|
149
235
|
if (indexLag)
|
|
150
236
|
warnLine('omp stats index', indexLag);
|
|
@@ -160,6 +246,30 @@ async function runDoctor(recheck = false) {
|
|
|
160
246
|
if (problem)
|
|
161
247
|
warnLine(`Caveman ${name}`, `unusable body (${problem}) — run: tersio install`);
|
|
162
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
|
+
}
|
|
163
273
|
const rtkAge = rtkMtime ? `(updated ${relTime(Date.now() - rtkMtime.mtimeMs)} · ${absDate(rtkMtime.mtimeMs)})` : '';
|
|
164
274
|
check('RTK binary', rtkBin !== null, rtkBinText === null ? 'not found in PATH' : [rtkVersion, rtkAge].filter(Boolean).join(' '));
|
|
165
275
|
// rtk_run needs a host exec API, which doctor cannot assume.
|
|
@@ -172,6 +282,14 @@ async function runDoctor(recheck = false) {
|
|
|
172
282
|
const ponytailAge = ponytailMtime ? `(updated ${relTime(Date.now() - ponytailMtime.mtimeMs)} · ${absDate(ponytailMtime.mtimeMs)})` : '';
|
|
173
283
|
const ponytailVer = parseJsonObject(ponytailPkgText)?.version ?? '';
|
|
174
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`);
|
|
175
293
|
const total = tally.ok + tally.missing + tally.warn;
|
|
176
294
|
console.log(`\n Summary: ${total} checks — ✅ ${tally.ok} ok, ⚠️ ${tally.warn} warn, ❌ ${tally.missing} missing`);
|
|
177
295
|
if (!recheck && tally.missing + tally.warn > 0 && (fix || dryRun)) {
|
|
@@ -199,39 +317,113 @@ function fixScope() {
|
|
|
199
317
|
return checkFixScope(next.trim().toLowerCase());
|
|
200
318
|
}
|
|
201
319
|
function checkFixScope(raw) {
|
|
202
|
-
const valid = ['extensions', 'registrations', 'rtk', 'ponytail', 'cli'];
|
|
320
|
+
const valid = ['extensions', 'registrations', 'rtk', 'ponytail', 'cli', 'service'];
|
|
203
321
|
if (!valid.includes(raw)) {
|
|
204
322
|
console.error(`[fail] Invalid --fix scope: ${raw}. Valid: ${valid.join(', ')}`);
|
|
205
323
|
process.exit(1);
|
|
206
324
|
}
|
|
207
325
|
return raw;
|
|
208
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
|
+
}
|
|
209
398
|
async function runDoctorFix(tally) {
|
|
210
399
|
const problems = [];
|
|
211
400
|
if (tally.missing > 0)
|
|
212
401
|
problems.push(`${tally.missing} missing`);
|
|
213
402
|
if (tally.warn > 0)
|
|
214
403
|
problems.push(`${tally.warn} warn`);
|
|
215
|
-
if (dryRun) {
|
|
216
|
-
console.log(`\n [dry-run] would repair ${problems.join(' + ')} (extensions, stale config.yml entries, rtk wiring, ponytail refresh, CLI update)`);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
404
|
const scoped = fixScope();
|
|
220
405
|
let targets;
|
|
221
406
|
if (scoped) {
|
|
222
407
|
targets = [scoped];
|
|
223
|
-
if (!yes) {
|
|
224
|
-
const go = await askInteractiveConfirm(`Repair "${scoped}" now?`);
|
|
225
|
-
if (go.status !== 'confirmed' || !go.value) {
|
|
226
|
-
process.exitCode = 130;
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
408
|
}
|
|
231
|
-
else
|
|
409
|
+
else {
|
|
232
410
|
targets = ['all'];
|
|
233
411
|
}
|
|
234
|
-
|
|
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) {
|
|
235
427
|
const picked = await askInteractiveChoice('Doctor — what should I repair?', [
|
|
236
428
|
{ value: 'all', label: 'Fix everything', hint: `${tally.missing + tally.warn} rows` },
|
|
237
429
|
{ value: 'extensions', label: 'Missing extension files', hint: 'copy sources from this CLI' },
|
|
@@ -239,6 +431,7 @@ async function runDoctorFix(tally) {
|
|
|
239
431
|
{ value: 'rtk', label: 'RTK binary + wiring', hint: 'download checksum-verified binary, rtk init' },
|
|
240
432
|
{ value: 'ponytail', label: 'Ponytail package', hint: 'bundled reinstall via tersio dep' },
|
|
241
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' },
|
|
242
435
|
], 'all');
|
|
243
436
|
if (picked.status !== 'selected') {
|
|
244
437
|
process.exitCode = 130;
|
|
@@ -273,26 +466,20 @@ async function runDoctorFix(tally) {
|
|
|
273
466
|
else
|
|
274
467
|
console.log(` Doctor --fix: still ${again.missing} missing + ${again.warn} warn — rerun or repair manually.`);
|
|
275
468
|
}
|
|
276
|
-
/**
|
|
277
|
-
* How far omp's own transcript index falls behind the transcripts on disk, or null when nothing is
|
|
278
|
-
* behind or the index cannot be read. omp advances the offsets only while one of its processes owns
|
|
279
|
-
* the index, so a closed dashboard leaves the newest turns unread and the pages read as empty.
|
|
280
|
-
*/
|
|
469
|
+
/** How far omp's transcript index lags the transcripts on disk, or null when behind or unreadable. */
|
|
281
470
|
function statsIndexLag() {
|
|
282
471
|
const statsDb = statsDbPath();
|
|
283
472
|
if (!existsSync(statsDb))
|
|
284
473
|
return null;
|
|
285
474
|
try {
|
|
286
|
-
//
|
|
287
|
-
// database omp has open, and never trips over them either.
|
|
475
|
+
// Same WAL-safe reader as the aggregate, so this probe creates no side files beside a database omp has open.
|
|
288
476
|
const rows = sqliteScript(statsDb, 'SELECT session_file, offset FROM file_offsets;\n')
|
|
289
477
|
.split('\n')
|
|
290
478
|
.filter((line) => line.includes('\t'))
|
|
291
479
|
.map((line) => line.split('\t'));
|
|
292
480
|
if (rows.length === 0)
|
|
293
481
|
return null;
|
|
294
|
-
//
|
|
295
|
-
// file sets the number rather than the sum of every file's gap.
|
|
482
|
+
// A page shows the gap behind the freshest transcript, so the newest file sets the number, not the sum.
|
|
296
483
|
let worstBytes = 0;
|
|
297
484
|
let freshest = '';
|
|
298
485
|
for (const [file, offsetText] of rows) {
|
|
@@ -315,7 +502,7 @@ function statsIndexLag() {
|
|
|
315
502
|
return `${shortBytes(worstBytes)} of ${path.basename(freshest)} unread, last written ${when === 'just now' ? 'moments ago' : when} — run: omp stats`;
|
|
316
503
|
}
|
|
317
504
|
catch {
|
|
318
|
-
return null; // no
|
|
505
|
+
return null; // no SQLite engine, or an unreadable index: nothing to report
|
|
319
506
|
}
|
|
320
507
|
}
|
|
321
508
|
function section(name) {
|
package/dist/cli/hosts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// cli/hosts.ts —
|
|
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';
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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);
|