@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
@@ -1,20 +1,119 @@
1
1
  import { existsSync, promises as fs } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
- import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, removeExtensionFromConfig, dryRun, verbose, debug, execP, readPluginsPackage, } from './common.js';
5
+ import { BUN_BIN_DIR, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, removeExtensionFromConfig, dryRun, verbose, debug, bundledPonytailSpec, execP, ponytailOverride, readPluginsPackage, } from './common.js';
5
6
  import { execNetwork, sayTagged } from './interactive.js';
6
7
  import { wireRtkOmp, wireRtkOpencode, wireRtkPi, ensureRtkInConfig } from './rtk-wiring.js';
7
- import { RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/shared/utils.js';
8
+ import { RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, resolveHostBinary, rtkPlatformSpec, sha256File, } from '../extensions/shared/utils.js';
8
9
  import { runLatestUpdate } from './update.js';
9
- import { OPENCODE_ENTRY, TREE_FILES, syncTreeFiles, treeFilesFor } from './manifest.js';
10
+ import { OPENCODE_ENTRY, TREE_FILES, isObsoleteTreeFile, syncPluginManifest, syncTreeFiles, treeFilesFor } from './manifest.js';
10
11
  import { detectHosts, hostExtensionsDir } from './hosts.js';
11
- // A written tree is the install for both hosts now, so --fix restores it for whichever hosts have it, alongside the OMP package when that is present.
12
+ /** Pids of `opencode serve --service` processes whose service manager no longer tracks them. The orphan holds the managed port, so the next start exits 1. */
13
+ export function serviceOrphanPids() {
14
+ const bin = resolveHostBinary('opencode', path.join(HOME, '.opencode', 'bin'));
15
+ if (!bin)
16
+ return [];
17
+ let status = '';
18
+ try {
19
+ status = spawnSync(bin, ['service', 'status'], { encoding: 'utf8' }).stdout.trim().toLowerCase();
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ if (serviceRunning(status))
25
+ return [];
26
+ let out = '';
27
+ try {
28
+ out = spawnSync('pgrep', ['-f', 'opencode serve --service'], { encoding: 'utf8' }).stdout ?? '';
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ // Only digits: the value comes from another process, and `process.kill` throws on anything else.
34
+ return out.split(/[\s,]+/).filter((line) => /^\d+$/.test(line.trim())).map((line) => line.trim());
35
+ }
36
+ /** Whether OpenCode reports its service running, and whether a serve process is alive. */
37
+ export function serviceState() {
38
+ const bin = resolveHostBinary('opencode', path.join(HOME, '.opencode', 'bin'));
39
+ if (!bin)
40
+ return null;
41
+ let status = '';
42
+ try {
43
+ status = spawnSync(bin, ['service', 'status'], { encoding: 'utf8' }).stdout.trim().toLowerCase();
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ if (status === '')
49
+ return null;
50
+ let live = false;
51
+ try {
52
+ const out = spawnSync('pgrep', ['-f', 'opencode serve --service'], { encoding: 'utf8' }).stdout ?? '';
53
+ live = out.split(/[\s,]+/).some((line) => /^\d+$/.test(line.trim()));
54
+ }
55
+ catch {
56
+ live = false;
57
+ }
58
+ return { status, live };
59
+ }
60
+ /** A running service reports its URL, so the word is only one of the answers. */
61
+ export function serviceRunning(status) {
62
+ return status === 'running' || status.startsWith('http://') || status.startsWith('https://');
63
+ }
64
+ /** Every obsolete file under `dest`, or an empty list. `treeFilesFor` is what the install writes. */
65
+ async function obsoleteFiles(dest, id) {
66
+ const keep = new Set(treeFilesFor(id));
67
+ const out = [];
68
+ const walk = async (dir, rel) => {
69
+ let entries;
70
+ try {
71
+ entries = await fs.readdir(dir, { withFileTypes: true });
72
+ }
73
+ catch {
74
+ return;
75
+ }
76
+ for (const entry of entries) {
77
+ if (entry.name === 'node_modules' || entry.name === '.git')
78
+ continue;
79
+ const child = rel ? `${rel}/${entry.name}` : entry.name;
80
+ const at = path.join(dir, entry.name);
81
+ if (entry.isDirectory()) {
82
+ await walk(at, child);
83
+ continue;
84
+ }
85
+ if (keep.has(child))
86
+ continue;
87
+ if (isObsoleteTreeFile(child, id))
88
+ out.push(child);
89
+ }
90
+ };
91
+ await walk(dest, '');
92
+ return out;
93
+ }
94
+ /** Obsolete top-level directories, empty or not: a debris name is a whole directory, so a repair must drop the directory, not only the files inside it. */
95
+ async function obsoleteDirs(dest, id) {
96
+ const out = [];
97
+ let entries;
98
+ try {
99
+ entries = await fs.readdir(dest, { withFileTypes: true });
100
+ }
101
+ catch {
102
+ return out;
103
+ }
104
+ for (const entry of entries) {
105
+ if (!entry.isDirectory())
106
+ continue;
107
+ if (isObsoleteTreeFile(entry.name, id))
108
+ out.push(entry.name);
109
+ }
110
+ return out;
111
+ }
112
+ // A written tree is the install for both hosts, so --fix restores it for whichever hosts have it.
12
113
  async function fixExtensionTrees() {
13
- // The OpenCode entry is foreign to the pi and omp loaders: it imports @opencode/plugin, which
14
- // those hosts do not carry. One shared filter, so a repair cannot drop it into the wrong tree.
114
+ // The entry imports @opencode/plugin, which pi and omp lack, so one shared filter keeps repairs out of their trees.
15
115
  const forHost = (id) => treeFilesFor(id);
16
- // A tree from before the entry moved to the extensions root held a nested copy, and a repair
17
- // written against the old filter may have left the entry at the root. Remove both strays.
116
+ // Older trees nested the entry; a repair under the old filter may have left it at the root, so remove both strays.
18
117
  const scrubOpencodeEntry = async (dest) => {
19
118
  for (const target of [path.join(dest, OPENCODE_ENTRY), path.join(dest, 'opencode')]) {
20
119
  if (!existsSync(target))
@@ -29,7 +128,7 @@ async function fixExtensionTrees() {
29
128
  };
30
129
  for (const host of detectHosts()) {
31
130
  const dest = hostExtensionsDir(host.id);
32
- // Repair a host whose tree is partly there: a half-written tree is exactly the case this runs for, and it reads as "not installed" to the detector.
131
+ // Repair a partly-present tree: a half-written one is this run's case, and it reads as "not installed".
33
132
  const present = forHost(host.id).filter((file) => existsSync(path.join(dest, file))).length;
34
133
  if (present === 0) {
35
134
  if (host.id !== 'opencode')
@@ -40,21 +139,38 @@ async function fixExtensionTrees() {
40
139
  const missing = await syncTreeFiles(dest, forHost(host.id), { dryRun, verbose });
41
140
  for (const from of missing)
42
141
  sayTagged(` [warn] bundled source missing: ${from}`);
142
+ // Writing the current tree repairs stale and missing at once; dropped debris needs removal, else it survives.
143
+ const debris = (await obsoleteFiles(dest, host.id)).sort();
144
+ for (const rel of debris) {
145
+ const at = path.join(dest, ...rel.split('/'));
146
+ if (dryRun) {
147
+ sayTagged(` [dry-run] would remove debris ${at}`);
148
+ continue;
149
+ }
150
+ await fs.rm(at, { force: true });
151
+ sayTagged(` [rm] debris ${at}`);
152
+ }
153
+ for (const rel of await obsoleteDirs(dest, host.id)) {
154
+ const at = path.join(dest, ...rel.split('/'));
155
+ if (dryRun) {
156
+ sayTagged(` [dry-run] would remove debris dir ${at}`);
157
+ continue;
158
+ }
159
+ await fs.rm(at, { recursive: true, force: true });
160
+ sayTagged(` [rm] debris dir ${at}`);
161
+ }
43
162
  if (host.id !== 'opencode')
44
163
  await scrubOpencodeEntry(dest);
45
164
  if (!dryRun)
46
165
  sayTagged(` [ok] ${host.label} extension tree: ${dest}`);
47
166
  }
48
167
  }
49
- // The tree alone does nothing on OpenCode without its entrypoint registered in opencode.json.
50
- // The tersio tree ships the rtk rewrite, so the rtk plugin entry is redundant.
168
+ // On OpenCode the tree is inert without its entry in opencode.json, and the rtk plugin entry is redundant beside it.
51
169
  async function fixOpencodeEntry() {
52
170
  const { HOME } = await import('./common.js');
53
171
  const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
54
172
  const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
55
- // The loader resolves <pluginDir>/server.ts, and the entry is that file. A tree from before the
56
- // entry moved to the extensions root held a root shim that re-exported a nested copy, so the
57
- // real entry is written in place and the stale nested tree is dropped.
173
+ // The loader resolves <pluginDir>/server.ts: an entry at the extensions root leaves a shim to a nested copy.
58
174
  const nested = path.join(dir, 'opencode');
59
175
  const staleNested = (await readTextIfExists(path.join(nested, 'server.ts'))) !== null;
60
176
  await syncTreeFiles(dir, TREE_FILES.filter((file) => !file.includes('/')), { dryRun, verbose });
@@ -97,6 +213,28 @@ async function fixExtensions(pluginsDir) {
97
213
  const missing = await syncTreeFiles(pluginExtDir, TREE_FILES, { dryRun, verbose }, (file) => ruleFile(file) && existsSync(path.join(pluginExtDir, ...file.split('/'))));
98
214
  for (const from of missing)
99
215
  sayTagged(` [warn] bundled source missing: ${from}`);
216
+ // omp loads this tree, so its debris is what doctor reports: the package tree, compared against every entry.
217
+ const debris = (await obsoleteFiles(pluginExtDir, 'opencode')).sort();
218
+ for (const rel of debris) {
219
+ const at = path.join(pluginExtDir, ...rel.split('/'));
220
+ if (dryRun) {
221
+ sayTagged(` [dry-run] would remove debris ${at}`);
222
+ continue;
223
+ }
224
+ await fs.rm(at, { force: true });
225
+ sayTagged(` [rm] debris ${at}`);
226
+ }
227
+ for (const rel of await obsoleteDirs(pluginExtDir, 'opencode')) {
228
+ const at = path.join(pluginExtDir, ...rel.split('/'));
229
+ if (dryRun) {
230
+ sayTagged(` [dry-run] would remove debris dir ${at}`);
231
+ continue;
232
+ }
233
+ await fs.rm(at, { recursive: true, force: true });
234
+ sayTagged(` [rm] debris dir ${at}`);
235
+ }
236
+ // A synced tree is not enough: omp loads only the entries its manifest names.
237
+ await syncPluginManifest(path.dirname(pluginExtDir), { dryRun, verbose });
100
238
  // Writing is not atomic, so re-read rather than assume the tree is whole.
101
239
  if (dryRun)
102
240
  return;
@@ -206,13 +344,22 @@ async function fixPonytail(pluginsDir) {
206
344
  await fs.mkdir(pluginsDir, { recursive: true });
207
345
  const pkgPath = path.join(pluginsDir, 'package.json');
208
346
  const pkg = await readPluginsPackage(pkgPath);
209
- // Drop the legacy separate entry: ponytail is a tersio dependency now.
210
- if ('@dietrichgebert/ponytail' in pkg.dependencies) {
211
- delete pkg.dependencies['@dietrichgebert/ponytail'];
212
- console.log(' [migrate] dropped separate @dietrichgebert/ponytail dependency (now bundled with tersio)');
213
- }
214
347
  if (!(PACKAGE_NAME in pkg.dependencies))
215
348
  pkg.dependencies[PACKAGE_NAME] = `^${PACKAGE_VERSION}`;
349
+ // npm resolves the *published* tersio, whose range can lag this tree; the override is what reaches the loaded copy.
350
+ const localSpec = await bundledPonytailSpec();
351
+ if (localSpec !== null) {
352
+ if (pkg.dependencies['@dietrichgebert/ponytail'] !== localSpec)
353
+ pkg.dependencies['@dietrichgebert/ponytail'] = localSpec;
354
+ if (ponytailOverride(pkg) !== localSpec) {
355
+ pkg.overrides = { ...(typeof pkg.overrides === 'object' && pkg.overrides !== null ? pkg.overrides : {}), '@dietrichgebert/ponytail': localSpec };
356
+ const note = 'forced with overrides, so the nested copy is dropped';
357
+ if (dryRun)
358
+ sayTagged(` [dry-run] would pin @dietrichgebert/ponytail → ${localSpec} (${note})`);
359
+ else
360
+ console.log(` [pin] @dietrichgebert/ponytail → ${localSpec} (${note})`);
361
+ }
362
+ }
216
363
  if (dryRun) {
217
364
  console.log(' [dry-run] would run: npm install --no-audit --no-fund (in plugins dir)');
218
365
  return;
@@ -222,10 +369,46 @@ async function fixPonytail(pluginsDir) {
222
369
  throw new Error(`bundled ponytail restore failed: ${e.message}`);
223
370
  });
224
371
  }
372
+ /** The decisive line of a failed command, so one [warn] line carries the reason. */
373
+ function shortError(e) {
374
+ return ((e.stderr) || e.message || '').trim().slice(0, 200);
375
+ }
225
376
  async function fixCli() {
226
377
  console.log(' Doctor --fix: updating CLI + add-ons');
227
378
  await runLatestUpdate();
228
379
  }
380
+ /** OpenCode's manager can lose its daemonized serve process, so `stop` and `restart` cannot reach it: kill the orphan by pid, then start the service again. */
381
+ async function fixOpencodeService() {
382
+ const orphans = serviceOrphanPids();
383
+ if (orphans.length === 0) {
384
+ sayTagged(' [skip] no orphaned OpenCode serve process; the service answers');
385
+ return;
386
+ }
387
+ for (const pid of orphans) {
388
+ if (dryRun) {
389
+ sayTagged(` [dry-run] would stop the orphaned serve process (pid ${pid})`);
390
+ continue;
391
+ }
392
+ try {
393
+ process.kill(Number(pid), 'SIGTERM');
394
+ sayTagged(` [ok] stopped the orphaned serve process (pid ${pid})`);
395
+ }
396
+ catch (e) {
397
+ sayTagged(` [warn] pid ${pid} already gone: ${shortError(e)}`);
398
+ }
399
+ }
400
+ if (dryRun)
401
+ return;
402
+ await new Promise((resolve) => setTimeout(resolve, 500));
403
+ const bin = resolveHostBinary('opencode', path.join(HOME, '.opencode', 'bin'));
404
+ if (!bin) {
405
+ sayTagged(' [warn] opencode not found on PATH; start it with `opencode service start`');
406
+ return;
407
+ }
408
+ await execNetwork('Starting the OpenCode service', bin, ['service', 'start'], { timeout: 60000 }).catch((e) => {
409
+ throw new Error(`service start failed: ${shortError(e)}`);
410
+ });
411
+ }
229
412
  async function runDoctorRepairs(targets) {
230
413
  const all = targets.includes('all');
231
414
  const want = (t) => all || targets.includes(t);
@@ -253,6 +436,8 @@ async function runDoctorRepairs(targets) {
253
436
  await attempt('ponytail', () => fixPonytail(OMP_PLUGINS_DIR));
254
437
  if (want('cli'))
255
438
  await attempt('cli', fixCli);
439
+ if (want('service'))
440
+ await attempt('opencode service', fixOpencodeService);
256
441
  return failed;
257
442
  }
258
443
  export { runDoctorRepairs };