@krtclcdy/tersio 2.25.5 → 2.25.7

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 (231) hide show
  1. package/README.md +42 -38
  2. package/dashboard/dist/index.html +15 -15
  3. package/dist/bench/code-out/pfull-0/debounce.js +13 -0
  4. package/dist/bench/code-out/pfull-0/deepClone.js +18 -0
  5. package/dist/bench/code-out/pfull-0/groupBy.js +15 -0
  6. package/dist/bench/code-out/pfull-1/debounce.js +13 -0
  7. package/dist/bench/code-out/pfull-1/deepClone.js +17 -0
  8. package/dist/bench/code-out/pfull-1/groupBy.js +14 -0
  9. package/dist/bench/code-out/pfull-2/debounce.js +13 -0
  10. package/dist/bench/code-out/pfull-2/deepClone.js +18 -0
  11. package/dist/bench/code-out/pfull-2/groupBy.js +15 -0
  12. package/dist/bench/code-out/pfull-3/debounce.js +12 -0
  13. package/dist/bench/code-out/pfull-3/deepClone.js +17 -0
  14. package/dist/bench/code-out/pfull-3/groupBy.js +7 -0
  15. package/dist/bench/code-out/pfull-4/debounce.js +10 -0
  16. package/dist/bench/code-out/pfull-4/deepClone.js +17 -0
  17. package/dist/bench/code-out/pfull-4/groupBy.js +14 -0
  18. package/dist/bench/code-out/plite-0/debounce.js +13 -0
  19. package/dist/bench/code-out/plite-0/deepClone.js +18 -0
  20. package/dist/bench/code-out/plite-0/groupBy.js +15 -0
  21. package/dist/bench/code-out/plite-1/debounce.js +13 -0
  22. package/dist/bench/code-out/plite-1/deepClone.js +17 -0
  23. package/dist/bench/code-out/plite-1/groupBy.js +11 -0
  24. package/dist/bench/code-out/plite-2/debounce.js +12 -0
  25. package/dist/bench/code-out/plite-2/deepClone.js +17 -0
  26. package/dist/bench/code-out/plite-2/groupBy.js +14 -0
  27. package/dist/bench/code-out/plite-3/debounce.js +12 -0
  28. package/dist/bench/code-out/plite-3/deepClone.js +17 -0
  29. package/dist/bench/code-out/plite-3/groupBy.js +14 -0
  30. package/dist/bench/code-out/plite-4/debounce.js +12 -0
  31. package/dist/bench/code-out/plite-4/deepClone.js +17 -0
  32. package/dist/bench/code-out/plite-4/groupBy.js +14 -0
  33. package/dist/bench/code-out/pultra-0/debounce.js +12 -0
  34. package/dist/bench/code-out/pultra-0/deepClone.js +20 -0
  35. package/dist/bench/code-out/pultra-0/groupBy.js +14 -0
  36. package/dist/bench/code-out/pultra-1/debounce.js +10 -0
  37. package/dist/bench/code-out/pultra-1/deepClone.js +17 -0
  38. package/dist/bench/code-out/pultra-1/groupBy.js +7 -0
  39. package/dist/bench/code-out/pultra-2/debounce.js +13 -0
  40. package/dist/bench/code-out/pultra-2/deepClone.js +18 -0
  41. package/dist/bench/code-out/pultra-2/groupBy.js +15 -0
  42. package/dist/bench/code-out/pultra-3/debounce.js +13 -0
  43. package/dist/bench/code-out/pultra-3/deepClone.js +18 -0
  44. package/dist/bench/code-out/pultra-3/groupBy.js +15 -0
  45. package/dist/bench/code-out/pultra-4/debounce.js +13 -0
  46. package/dist/bench/code-out/pultra-4/deepClone.js +18 -0
  47. package/dist/bench/code-out/pultra-4/groupBy.js +15 -0
  48. package/dist/bench/code-out/run-0/debounce.js +12 -0
  49. package/dist/bench/code-out/run-0/deepClone.js +17 -0
  50. package/dist/bench/code-out/run-0/groupBy.js +14 -0
  51. package/dist/bench/code-out/run-1/debounce.js +13 -0
  52. package/dist/bench/code-out/run-1/deepClone.js +15 -0
  53. package/dist/bench/code-out/run-1/groupBy.js +13 -0
  54. package/dist/bench/code-out/run-2/debounce.js +12 -0
  55. package/dist/bench/code-out/run-2/deepClone.js +18 -0
  56. package/dist/bench/code-out/run-2/groupBy.js +13 -0
  57. package/dist/bench/code-out/run-3/debounce.js +12 -0
  58. package/dist/bench/code-out/run-3/deepClone.js +18 -0
  59. package/dist/bench/code-out/run-3/groupBy.js +14 -0
  60. package/dist/bench/code-out/run-4/debounce.js +12 -0
  61. package/dist/bench/code-out/run-4/deepClone.js +16 -0
  62. package/dist/bench/code-out/run-4/groupBy.js +13 -0
  63. package/dist/cli/backups.js +7 -14
  64. package/dist/cli/banner.js +3 -3
  65. package/dist/cli/common.js +29 -13
  66. package/dist/cli/currency.js +1 -1
  67. package/dist/cli/dashboard.js +66 -35
  68. package/dist/cli/doctor-fix.js +204 -19
  69. package/dist/cli/doctor.js +263 -40
  70. package/dist/cli/hosts.js +25 -6
  71. package/dist/cli/install.js +150 -49
  72. package/dist/cli/interactive.js +3 -3
  73. package/dist/cli/manifest.js +109 -9
  74. package/dist/cli/profile.js +13 -8
  75. package/dist/cli/rtk-wiring.js +1 -1
  76. package/dist/cli/settings.js +20 -5
  77. package/dist/cli/uninstall.js +4 -4
  78. package/dist/cli/update.js +6 -7
  79. package/dist/cli/usage.js +6 -9
  80. package/dist/extensions/caveman/index.js +10 -4
  81. package/dist/extensions/caveman/rule-lite.md +77 -0
  82. package/dist/extensions/caveman/rule-megacave-lite.md +59 -0
  83. package/dist/extensions/caveman/rule-megacave-ultra.md +59 -0
  84. package/dist/extensions/caveman/rule-megacave.md +5 -3
  85. package/dist/extensions/caveman/rule-ultra.md +4 -4
  86. package/dist/extensions/caveman/rule.md +55 -45
  87. package/dist/extensions/combo/index.js +85 -25
  88. package/dist/extensions/commands/index.js +1 -1
  89. package/dist/extensions/pstack/index.js +121 -0
  90. package/dist/extensions/pstack/rule.md +141 -0
  91. package/dist/extensions/rtk/filter.js +0 -1
  92. package/dist/extensions/rtk/session.js +20 -12
  93. package/dist/extensions/server.js +90 -6
  94. package/dist/extensions/shared/agent-stats.js +214 -278
  95. package/dist/extensions/shared/brand-marks.js +33 -34
  96. package/dist/extensions/shared/carbon.js +1 -1
  97. package/dist/extensions/shared/plugin-settings.js +11 -6
  98. package/dist/extensions/shared/pricing.js +8 -16
  99. package/dist/extensions/shared/rtk-eligible.js +84 -0
  100. package/dist/extensions/shared/rtk-gain.js +5 -10
  101. package/dist/extensions/shared/session-state.js +15 -10
  102. package/dist/extensions/shared/sqlite.js +162 -0
  103. package/dist/extensions/shared/status.js +8 -9
  104. package/dist/extensions/shared/types.js +1 -1
  105. package/dist/extensions/shared/usage-ledger.js +155 -61
  106. package/dist/extensions/shared/usage-store.js +111 -52
  107. package/dist/extensions/shared/utils.js +5 -5
  108. package/dist/extensions/timing/index.js +31 -0
  109. package/dist/extensions/updater/index.js +3 -6
  110. package/dist/tersio.js +8 -3
  111. package/extensions/caveman/index.ts +10 -4
  112. package/extensions/caveman/rule-lite.md +77 -0
  113. package/extensions/caveman/rule-megacave-lite.md +59 -0
  114. package/extensions/caveman/rule-megacave-ultra.md +59 -0
  115. package/extensions/caveman/rule-megacave.md +5 -3
  116. package/extensions/caveman/rule-ultra.md +4 -4
  117. package/extensions/caveman/rule.md +55 -45
  118. package/extensions/combo/index.ts +86 -23
  119. package/extensions/commands/index.ts +1 -1
  120. package/extensions/pstack/index.ts +130 -0
  121. package/extensions/pstack/playbooks/authoring-a-skill.md +12 -0
  122. package/extensions/pstack/playbooks/autonomous-run.md +13 -0
  123. package/extensions/pstack/playbooks/autopilot-full.md +13 -0
  124. package/extensions/pstack/playbooks/autopilot-stack.md +16 -0
  125. package/extensions/pstack/playbooks/babysit.md +23 -0
  126. package/extensions/pstack/playbooks/bug-fix.md +15 -0
  127. package/extensions/pstack/playbooks/eval.md +25 -0
  128. package/extensions/pstack/playbooks/feature.md +21 -0
  129. package/extensions/pstack/playbooks/hillclimb.md +21 -0
  130. package/extensions/pstack/playbooks/investigation.md +14 -0
  131. package/extensions/pstack/playbooks/multi-phase-plan.md +155 -0
  132. package/extensions/pstack/playbooks/opening-a-pr.md +34 -0
  133. package/extensions/pstack/playbooks/orchestrate.md +111 -0
  134. package/extensions/pstack/playbooks/pause-safely.md +10 -0
  135. package/extensions/pstack/playbooks/perf-issue.md +25 -0
  136. package/extensions/pstack/playbooks/prototype.md +14 -0
  137. package/extensions/pstack/playbooks/refactoring.md +16 -0
  138. package/extensions/pstack/playbooks/runtime-forensics.md +11 -0
  139. package/extensions/pstack/playbooks/session-pickup.md +11 -0
  140. package/extensions/pstack/playbooks/shipping.md +17 -0
  141. package/extensions/pstack/playbooks/trace-forensics.md +14 -0
  142. package/extensions/pstack/playbooks/visual-parity.md +11 -0
  143. package/extensions/pstack/playbooks/worktree-cleanup.md +12 -0
  144. package/extensions/pstack/principles/attack-the-premise.md +22 -0
  145. package/extensions/pstack/principles/boundary-discipline.md +33 -0
  146. package/extensions/pstack/principles/build-the-lever.md +22 -0
  147. package/extensions/pstack/principles/encode-lessons-in-structure.md +10 -0
  148. package/extensions/pstack/principles/exhaust-the-design-space.md +8 -0
  149. package/extensions/pstack/principles/experience-first.md +18 -0
  150. package/extensions/pstack/principles/explain-the-number.md +16 -0
  151. package/extensions/pstack/principles/fix-root-causes.md +23 -0
  152. package/extensions/pstack/principles/foundational-thinking.md +20 -0
  153. package/extensions/pstack/principles/guard-the-context-window.md +11 -0
  154. package/extensions/pstack/principles/laziness-protocol.md +9 -0
  155. package/extensions/pstack/principles/make-operations-idempotent.md +23 -0
  156. package/extensions/pstack/principles/migrate-callers-then-delete-legacy-apis.md +21 -0
  157. package/extensions/pstack/principles/minimize-reader-load.md +17 -0
  158. package/extensions/pstack/principles/model-the-domain.md +23 -0
  159. package/extensions/pstack/principles/never-block-on-the-human.md +14 -0
  160. package/extensions/pstack/principles/outcome-oriented-execution.md +20 -0
  161. package/extensions/pstack/principles/prove-it-works.md +21 -0
  162. package/extensions/pstack/principles/redesign-from-first-principles.md +15 -0
  163. package/extensions/pstack/principles/separate-before-serializing-shared-state.md +15 -0
  164. package/extensions/pstack/principles/sequence-verifiable-units.md +16 -0
  165. package/extensions/pstack/principles/subtract-before-you-add.md +17 -0
  166. package/extensions/pstack/principles/test-behavior-not-implementation.md +20 -0
  167. package/extensions/pstack/principles/type-system-discipline.md +17 -0
  168. package/extensions/pstack/references/bugbot-triage.md +142 -0
  169. package/extensions/pstack/rule.md +141 -0
  170. package/extensions/pstack/skills/architect/SKILL.md +78 -0
  171. package/extensions/pstack/skills/architect/references/design-red-flags.md +57 -0
  172. package/extensions/pstack/skills/architect/references/rationale-template.md +35 -0
  173. package/extensions/pstack/skills/architect/references/runner-prompt.md +20 -0
  174. package/extensions/pstack/skills/arena/SKILL.md +47 -0
  175. package/extensions/pstack/skills/benchmark-checklist/SKILL.md +38 -0
  176. package/extensions/pstack/skills/blast-radius/SKILL.md +49 -0
  177. package/extensions/pstack/skills/bro/SKILL.md +6 -0
  178. package/extensions/pstack/skills/correct/SKILL.md +31 -0
  179. package/extensions/pstack/skills/figure-it-out/SKILL.md +51 -0
  180. package/extensions/pstack/skills/how/SKILL.md +35 -0
  181. package/extensions/pstack/skills/how/references/explainer-prompt.md +55 -0
  182. package/extensions/pstack/skills/how/references/explorer-prompt.md +35 -0
  183. package/extensions/pstack/skills/interrogate/SKILL.md +73 -0
  184. package/extensions/pstack/skills/interrogate/references/code-quality-review.md +47 -0
  185. package/extensions/pstack/skills/interrogate/references/lead-judgment.md +58 -0
  186. package/extensions/pstack/skills/interrogate/references/reviewer-prompt.md +62 -0
  187. package/extensions/pstack/skills/interrogate/references/rubric.md +77 -0
  188. package/extensions/pstack/skills/no-comments/SKILL.md +23 -0
  189. package/extensions/pstack/skills/reflect/SKILL.md +66 -0
  190. package/extensions/pstack/skills/reflect/references/divergent-reviewer.md +38 -0
  191. package/extensions/pstack/skills/reflect/references/judgment-reviewer.md +37 -0
  192. package/extensions/pstack/skills/reflect/references/synthesizer.md +56 -0
  193. package/extensions/pstack/skills/reflect/references/tooling-reviewer.md +48 -0
  194. package/extensions/pstack/skills/show-me-your-work/SKILL.md +81 -0
  195. package/extensions/pstack/skills/swarm/SKILL.md +45 -0
  196. package/extensions/pstack/skills/tdd/SKILL.md +41 -0
  197. package/extensions/pstack/skills/technical-writing/SKILL.md +105 -0
  198. package/extensions/pstack/skills/unslop/SKILL.md +61 -0
  199. package/extensions/pstack/skills/why/SKILL.md +97 -0
  200. package/extensions/pstack/skills/why/references/epistemics.md +71 -0
  201. package/extensions/pstack/skills/why/references/investigator-prompt.md +84 -0
  202. package/extensions/pstack/skills/why/references/source-playbook.md +17 -0
  203. package/extensions/pstack/skills/why/references/sources/code-archaeology.md +51 -0
  204. package/extensions/pstack/skills/why/references/sources/databricks.md +70 -0
  205. package/extensions/pstack/skills/why/references/sources/datadog.md +97 -0
  206. package/extensions/pstack/skills/why/references/sources/incident-postmortem.md +5 -0
  207. package/extensions/pstack/skills/why/references/sources/linear.md +48 -0
  208. package/extensions/pstack/skills/why/references/sources/notion.md +55 -0
  209. package/extensions/pstack/skills/why/references/sources/sentry.md +66 -0
  210. package/extensions/pstack/skills/why/references/sources/slack.md +17 -0
  211. package/extensions/pstack/skills/why/references/synthesizer-prompt.md +129 -0
  212. package/extensions/rtk/filter.ts +0 -1
  213. package/extensions/rtk/session.ts +19 -11
  214. package/extensions/server.ts +72 -7
  215. package/extensions/shared/agent-stats.ts +226 -278
  216. package/extensions/shared/brand-marks.ts +34 -34
  217. package/extensions/shared/carbon.ts +1 -1
  218. package/extensions/shared/plugin-settings.ts +12 -6
  219. package/extensions/shared/pricing.ts +10 -26
  220. package/extensions/shared/rtk-eligible.ts +83 -0
  221. package/extensions/shared/rtk-gain.ts +5 -10
  222. package/extensions/shared/session-state.ts +18 -13
  223. package/extensions/shared/sqlite.ts +162 -0
  224. package/extensions/shared/status.ts +9 -9
  225. package/extensions/shared/types.ts +2 -1
  226. package/extensions/shared/usage-ledger.ts +157 -66
  227. package/extensions/shared/usage-store.ts +120 -52
  228. package/extensions/shared/utils.ts +5 -5
  229. package/extensions/timing/index.ts +44 -0
  230. package/extensions/updater/index.ts +3 -8
  231. package/package.json +21 -9
@@ -1,14 +1,18 @@
1
1
  // cli/doctor.ts — installation health checks.
2
- import { existsSync, statSync, 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';
7
+ import { GENERATED_TREE_FILES } from './manifest.js';
6
8
  import { askInteractiveChoice, askInteractiveConfirm, runInteractivePhase } from './interactive.js';
7
9
  import { usageDbPath } from '../extensions/shared/usage-store.js';
8
10
  import { pricesCachePath } from '../extensions/shared/pricing.js';
9
- import { readTextIfExists, resolveRtkBinary, ruleBodyProblem } from '../extensions/shared/utils.js';
11
+ import { findHoistedPackage, fetchJson, piAgentDir, readTextIfExists, resolveHostBinary, resolveRtkBinary, ruleBodyProblem } from '../extensions/shared/utils.js';
10
12
  import { sqliteScript, statsDbPath } from '../extensions/shared/agent-stats.js';
11
- import { TREE_FILES } from './manifest.js';
13
+ import { OMP_SUBAGENT_MARKER } from '../extensions/shared/session-state.js';
14
+ import { PI_PONYTAIL_FILES, PONYTAIL_BUNDLE_FILES, TREE_FILES, TREE_PSTACK_FILES, isObsoleteTreeFile, treeFileText, treeFilesFor } from './manifest.js';
15
+ import { serviceRunning, serviceState } from './doctor-fix.js';
12
16
  /** Bytes at the scale a transcript gap reads in: `5.6M`, not `5571992`. */
13
17
  function shortBytes(bytes) {
14
18
  if (bytes >= 1e9)
@@ -21,19 +25,98 @@ function shortBytes(bytes) {
21
25
  }
22
26
  async function runDoctor(recheck = false) {
23
27
  console.log('\n=== Tersio Doctor ===');
24
- // 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.
28
+ // OMP loads the plugin from its plugins dir; pi from its npm dir, per its settings.json.
25
29
  const pluginsDir = OMP_PLUGINS_DIR;
26
30
  const rtkBin = resolveRtkBinary();
27
- // Ponytail
28
- const ponytailPkg = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
31
+ // OpenCode's own binary, when installed: `~/.opencode/bin` first, then PATH.
32
+ const opencodeBin = resolveHostBinary('opencode', path.join(HOME, '.opencode', 'bin'));
29
33
  // One source for the installed plugin path, so the doctor follows a package rename.
30
34
  const tersioPluginDir = ompPackageDir();
35
+ // Node resolves npm's nested copy before the top-level one, so the top-level copy is a decoy no host loads.
36
+ const ponytailPkg = findHoistedPackage('@dietrichgebert/ponytail', tersioPluginDir, 'package.json')
37
+ ?? path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
31
38
  const cavemanIndex = path.join(tersioPluginDir, 'extensions', 'caveman', 'index.ts');
32
39
  const cavemanRule = (name) => path.join(tersioPluginDir, 'extensions', 'caveman', name);
40
+ // The pstack body is a Tersio fork with no remote, so the check proves it is usable, not current.
41
+ const pstackRule = path.join(tersioPluginDir, 'extensions', 'pstack', 'rule.md');
33
42
  const rtkIndex = path.join(tersioPluginDir, 'extensions', 'rtk', 'session.ts');
34
43
  const updaterIndex = path.join(tersioPluginDir, 'extensions', 'updater', 'index.ts');
35
- // Independent probes start concurrently; sections report in fixed order as their data settles. Every probe resolves instead of rejecting.
44
+ // Probes start concurrently and report in fixed order as data settles; each resolves instead of rejecting.
36
45
  const hosts = detectHosts();
46
+ // Only hosts with their own copy are compared: omp's copy is the reference. Paths hang off the package root.
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 any file of this build is compared even when the host reads as not installed: pi auto-discovers every index.ts it finds, so a half-written tree needs reporting.
74
+ const treeHoldsOwnFiles = (host) => treeFilesFor(host.id === 'omp' ? 'opencode' : host.id).some((rel) => existsSync(path.join(treeBaseFor(host), ...rel.split('/'))));
75
+ for (const host of hosts.filter((h) => h.installed || treeHoldsOwnFiles(h))) {
76
+ const treeId = host.id === 'omp' ? 'opencode' : host.id;
77
+ const base = treeBaseFor(host);
78
+ const onDisk = new Set(hostTreeFiles(base).filter((rel) => path.extname(rel) !== '.js'));
79
+ const expected = new Set(treeFilesFor(treeId));
80
+ const every = new Set([...onDisk, ...expected]);
81
+ for (const rel of every) {
82
+ // Reference is this CLI's own definition, not `tersioPluginDir`, which compares the tree to itself.
83
+ const reference = treeFileText(rel);
84
+ const at = path.join(base, ...rel.split('/'));
85
+ // One debris definition for detection and repair, so a row can only name what a repair removes.
86
+ if (isObsoleteTreeFile(rel, treeId))
87
+ treeOrphan.push(`${host.id}/${rel}`);
88
+ else if (!expected.has(rel))
89
+ continue;
90
+ else if (!onDisk.has(rel))
91
+ treeMissing.push(`${host.id}/${rel}`);
92
+ // No reference means this build dropped the entry: debris, not a mismatch.
93
+ else if (reference === null)
94
+ continue;
95
+ else {
96
+ const theirs = await readTextIfExists(at);
97
+ if (theirs === null)
98
+ treeMissing.push(`${host.id}/${rel}`);
99
+ else if (theirs !== reference)
100
+ treeStale.push(`${host.id}/${rel}`);
101
+ }
102
+ }
103
+ }
104
+ // A host reading as installed whose tree is short of this build's declared files: the pair loop above cannot see a file that is absent on both sides.
105
+ const unwrittenTreeFiles = async () => {
106
+ const out = [];
107
+ for (const host of hosts.filter((h) => h.installed)) {
108
+ const treeId = host.id === 'omp' ? 'opencode' : host.id;
109
+ const base = treeBaseFor(host);
110
+ for (const rel of treeFilesFor(treeId)) {
111
+ if (existsSync(path.join(base, ...rel.split('/'))))
112
+ continue;
113
+ if (host.id === 'omp' && GENERATED_TREE_FILES[rel] !== undefined)
114
+ continue;
115
+ out.push(`${host.id}/${rel}`);
116
+ }
117
+ }
118
+ return out;
119
+ };
37
120
  const probes = {
38
121
  ompPkgText: readTextIfExists(path.join(ompPackageDir(), 'package.json')),
39
122
  configText: readTextIfExists(path.join(OMP_AGENT_DIR, 'config.yml')),
@@ -41,8 +124,13 @@ async function runDoctor(recheck = false) {
41
124
  rtkBinText: rtkBin ? readTextIfExists(rtkBin) : Promise.resolve(null),
42
125
  cavemanIndexText: readTextIfExists(cavemanIndex),
43
126
  cavemanRuleText: readTextIfExists(cavemanRule('rule.md')),
127
+ cavemanLiteText: readTextIfExists(cavemanRule('rule-lite.md')),
128
+ cavemanMegacaveLiteText: readTextIfExists(cavemanRule('rule-megacave-lite.md')),
129
+ cavemanMegacaveUltraText: readTextIfExists(cavemanRule('rule-megacave-ultra.md')),
44
130
  cavemanUltraText: readTextIfExists(cavemanRule('rule-ultra.md')),
45
131
  cavemanMegacaveText: readTextIfExists(cavemanRule('rule-megacave.md')),
132
+ pstackRuleText: readTextIfExists(pstackRule),
133
+ pstackTreeFiles: Promise.all(TREE_PSTACK_FILES.map((rel) => readTextIfExists(path.join(tersioPluginDir, "extensions", ...rel.split("/"))))),
46
134
  rtkIndexText: readTextIfExists(rtkIndex),
47
135
  updaterIndexText: readTextIfExists(updaterIndex),
48
136
  rtkMtime: rtkBin ? fs.stat(rtkBin).catch(() => null) : Promise.resolve(null),
@@ -54,7 +142,11 @@ async function runDoctor(recheck = false) {
54
142
  const err = e;
55
143
  return err.stdout?.trim() || err.stderr?.trim() || null;
56
144
  }) : Promise.resolve(null);
57
- const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText], [cavemanRuleText, cavemanUltraText, cavemanMegacaveText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
145
+ // A manager that lost its daemonized process reads stopped, while the orphan still holds the port.
146
+ const opencodeServiceProbe = opencodeBin
147
+ ? Promise.resolve(serviceState())
148
+ : Promise.resolve(null);
149
+ const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText], [cavemanRuleText, cavemanLiteText, cavemanMegacaveLiteText, cavemanMegacaveUltraText, cavemanUltraText, cavemanMegacaveText, pstackRuleText, pstackTreeFiles, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime], [opencodeService]] = await runInteractivePhase('Checking installation', () => Promise.all([
58
150
  Promise.all([
59
151
  probes.ompPkgText,
60
152
  probes.configText,
@@ -67,8 +159,13 @@ async function runDoctor(recheck = false) {
67
159
  ]),
68
160
  Promise.all([
69
161
  probes.cavemanRuleText,
162
+ probes.cavemanLiteText,
163
+ probes.cavemanMegacaveLiteText,
164
+ probes.cavemanMegacaveUltraText,
70
165
  probes.cavemanUltraText,
71
166
  probes.cavemanMegacaveText,
167
+ probes.pstackRuleText,
168
+ probes.pstackTreeFiles,
72
169
  probes.ruleMtime,
73
170
  probes.rtkBinText,
74
171
  probes.rtkMtime,
@@ -76,8 +173,9 @@ async function runDoctor(recheck = false) {
76
173
  probes.ponytailMtime,
77
174
  probes.pricesMtime,
78
175
  ]),
176
+ Promise.all([opencodeServiceProbe]),
79
177
  ]));
80
- // Categorized output with a tally; success rows stay quiet (no path echoes) while failures print the expected path or fix so they stay actionable.
178
+ // Success rows stay quiet (no path echoes); failures print the expected path or fix so they stay actionable.
81
179
  const tally = { ok: 0, missing: 0, warn: 0 };
82
180
  function absDate(ms) {
83
181
  const d = new Date(ms);
@@ -99,7 +197,7 @@ async function runDoctor(recheck = false) {
99
197
  tally.warn++;
100
198
  console.log(` ⚠️ ${label}: warn ${detail}`);
101
199
  }
102
- // 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.
200
+ // An unused host is not a fault: out of the tally, carrying only the one line that installs it.
103
201
  function unused(label, detail) {
104
202
  console.log(` — ${label}: not installed ${detail}`);
105
203
  }
@@ -114,7 +212,7 @@ async function runDoctor(recheck = false) {
114
212
  continue;
115
213
  }
116
214
  if (!host.installed) {
117
- // 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".
215
+ // A pi declaration without tree or package is a broken install: a counted row, not "not installed".
118
216
  if (host.declared)
119
217
  check(host.label, false, `declared (${host.declared}) but not installed`);
120
218
  else
@@ -125,6 +223,16 @@ async function runDoctor(recheck = false) {
125
223
  const kind = host.via === 'tree' ? 'extensions' : 'package';
126
224
  check(host.label, true, `${kind} ${PACKAGE_NAME}${host.version ? ` ${host.version}` : ''}`);
127
225
  }
226
+ // A healthy service adds no row: the host row already names the install, and this row only shows when the manager's report and the live process disagree.
227
+ if (opencodeService) {
228
+ const running = serviceRunning(opencodeService.status);
229
+ if (!running && opencodeService.live) {
230
+ warnLine('OpenCode service', `a serve process is alive but the service reads ${opencodeService.status} — run: tersio doctor --fix service`);
231
+ }
232
+ else if (running && !opencodeService.live) {
233
+ warnLine('OpenCode service', 'the service reports running but no serve process is alive — run: tersio doctor --fix service');
234
+ }
235
+ }
128
236
  section('Extensions & plugins');
129
237
  if (hosts.find((host) => host.id === 'omp')?.via === 'package') {
130
238
  const explicitEntries = (configText ?? '').split('\n')
@@ -143,14 +251,13 @@ async function runDoctor(recheck = false) {
143
251
  console.log(` Usage DB (tersio-owned · local hosted): ${usageDbPath()}`);
144
252
  const pricesAge = pricesMtime ? `(pulled ${relTime(Date.now() - pricesMtime.mtimeMs)} · ${absDate(pricesMtime.mtimeMs)})` : '';
145
253
  check('Prices feed', pricesMtime !== null, pricesAge || pricesCachePath());
146
- // The pages read omp's own index, which only advances while an omp process owns it. A stalled
147
- // index reads as an empty dashboard, so name the gap instead of leaving a user to guess.
254
+ // Pages read omp's own index, which advances only while an omp process owns it, so a stall reads as empty.
148
255
  const indexLag = statsIndexLag();
149
256
  if (indexLag)
150
257
  warnLine('omp stats index', indexLag);
151
258
  section('Add-ons');
152
259
  const ruleAge = ruleMtime ? `(updated ${relTime(Date.now() - ruleMtime.mtimeMs)} · ${absDate(ruleMtime.mtimeMs)})` : '';
153
- const cavemanRules = [['rule.md', cavemanRuleText], ['rule-ultra.md', cavemanUltraText], ['rule-megacave.md', cavemanMegacaveText]];
260
+ const cavemanRules = [['rule.md', cavemanRuleText], ['rule-lite.md', cavemanLiteText], ['rule-megacave-lite.md', cavemanMegacaveLiteText], ['rule-megacave-ultra.md', cavemanMegacaveUltraText], ['rule-ultra.md', cavemanUltraText], ['rule-megacave.md', cavemanMegacaveText]];
154
261
  const missingRules = cavemanRules.filter(([, text]) => text === null).map(([name]) => name);
155
262
  check('Caveman rule', missingRules.length === 0, missingRules.length ? `missing: ${missingRules.join(', ')}` : ruleAge);
156
263
  for (const [name, text] of cavemanRules) {
@@ -160,6 +267,45 @@ async function runDoctor(recheck = false) {
160
267
  if (problem)
161
268
  warnLine(`Caveman ${name}`, `unusable body (${problem}) — run: tersio install`);
162
269
  }
270
+ // Every preset above off turns pstack on, so a body that is missing or unusable costs a live mode.
271
+ check('pstack rule', pstackRuleText !== null, pstackRuleText === null ? 'missing — run: tersio install' : '');
272
+ if (pstackRuleText !== null) {
273
+ const problem = ruleBodyProblem(pstackRuleText);
274
+ if (problem)
275
+ warnLine('pstack rule.md', `unusable body (${problem}) — run: tersio install`);
276
+ }
277
+ // The index routes to files, so a tree with the rule but no leaves serves a mode that cannot apply.
278
+ const pstackOnDisk = {};
279
+ TREE_PSTACK_FILES.forEach((rel, i) => {
280
+ if (pstackTreeFiles[i] !== null)
281
+ pstackOnDisk[rel] = true;
282
+ });
283
+ const pstackMissing = TREE_PSTACK_FILES.filter((rel) => !pstackOnDisk[rel]).map((rel) => rel.replace(/^pstack\//, ''));
284
+ if (pstackMissing.length > 0) {
285
+ warnLine('pstack tree', `missing ${pstackMissing.length} file${pstackMissing.length === 1 ? '' : 's'}: ${pstackMissing.slice(0, 6).join(', ')}${pstackMissing.length > 6 ? ` +${pstackMissing.length - 6} more` : ''} — run: tersio install`);
286
+ }
287
+ // A verbatim sentence read out of omp's own prompt, so a re-worded release stops Caveman reaching subagents.
288
+ const subagentGap = subagentMarkerStatus(ompBinary());
289
+ if (subagentGap)
290
+ warnLine('Caveman subagent marker', subagentGap);
291
+ // A tree on an older body serves until its install step runs; debris needs the sweeper instead.
292
+ if (treeStale.length > 0) {
293
+ warnLine('Per-host add-on files', `stale in ${treeStale.length} file${treeStale.length === 1 ? '' : 's'}: ${treeStale.slice(0, 6).join(', ')}` +
294
+ `${treeStale.length > 6 ? ` +${treeStale.length - 6} more` : ''} — run: tersio install --host <host>`);
295
+ }
296
+ if (treeMissing.length > 0) {
297
+ warnLine('Per-host add-on files', `never installed in ${treeMissing.length} file${treeMissing.length === 1 ? '' : 's'}: ${treeMissing.join(', ')}` +
298
+ ` — run: tersio install --host <host>`);
299
+ }
300
+ if (treeOrphan.length > 0) {
301
+ warnLine('Per-host add-on files', `orphaned in ${treeOrphan.length} file${treeOrphan.length === 1 ? '' : 's'}: ${treeOrphan.join(', ')}` +
302
+ ` — run: tersio doctor --fix extensions`);
303
+ }
304
+ // A count of pairs compared cannot show a file that is absent from disk and absent from the declaration, so assert every declared file landed.
305
+ const unwritten = await unwrittenTreeFiles();
306
+ if (unwritten.length > 0) {
307
+ check('Add-on tree content', false, `${unwritten.length} declared file${unwritten.length === 1 ? '' : 's'} not written for a host reading as installed: ${unwritten.slice(0, 6).join(', ')}${unwritten.length > 6 ? ` +${unwritten.length - 6} more` : ''} — run: tersio doctor --fix extensions`);
308
+ }
163
309
  const rtkAge = rtkMtime ? `(updated ${relTime(Date.now() - rtkMtime.mtimeMs)} · ${absDate(rtkMtime.mtimeMs)})` : '';
164
310
  check('RTK binary', rtkBin !== null, rtkBinText === null ? 'not found in PATH' : [rtkVersion, rtkAge].filter(Boolean).join(' '));
165
311
  // rtk_run needs a host exec API, which doctor cannot assume.
@@ -172,6 +318,14 @@ async function runDoctor(recheck = false) {
172
318
  const ponytailAge = ponytailMtime ? `(updated ${relTime(Date.now() - ponytailMtime.mtimeMs)} · ${absDate(ponytailMtime.mtimeMs)})` : '';
173
319
  const ponytailVer = parseJsonObject(ponytailPkgText)?.version ?? '';
174
320
  check('Ponytail', ponytailPkgText !== null, [ponytailVer, ponytailAge].filter(Boolean).join(' '));
321
+ if (ponytailStale.length > 0) {
322
+ warnLine('Ponytail per-host bodies', `stale in ${ponytailStale.length} file${ponytailStale.length === 1 ? '' : 's'}: ${ponytailStale.slice(0, 6).join(', ')}` +
323
+ `${ponytailStale.length > 6 ? ` +${ponytailStale.length - 6} more` : ''} — run: tersio install --host <host>`);
324
+ }
325
+ // Offline-safe: the registry call is best-effort, and silence means no row rather than a stale one.
326
+ const ponytailVersionGap = await ponytailLatestGap(ponytailPkgText);
327
+ if (ponytailVersionGap)
328
+ warnLine('Ponytail version', `${ponytailVersionGap} — run: tersio update --apply-update --yes`);
175
329
  const total = tally.ok + tally.missing + tally.warn;
176
330
  console.log(`\n Summary: ${total} checks — ✅ ${tally.ok} ok, ⚠️ ${tally.warn} warn, ❌ ${tally.missing} missing`);
177
331
  if (!recheck && tally.missing + tally.warn > 0 && (fix || dryRun)) {
@@ -199,39 +353,113 @@ function fixScope() {
199
353
  return checkFixScope(next.trim().toLowerCase());
200
354
  }
201
355
  function checkFixScope(raw) {
202
- const valid = ['extensions', 'registrations', 'rtk', 'ponytail', 'cli'];
356
+ const valid = ['extensions', 'registrations', 'rtk', 'ponytail', 'cli', 'service'];
203
357
  if (!valid.includes(raw)) {
204
358
  console.error(`[fail] Invalid --fix scope: ${raw}. Valid: ${valid.join(', ')}`);
205
359
  process.exit(1);
206
360
  }
207
361
  return raw;
208
362
  }
363
+ /** omp's binary on PATH, or null, resolved against omp's own name the way `resolveRtkBinary` walks for rtk. */
364
+ function ompBinary() {
365
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
366
+ if (!dir)
367
+ continue;
368
+ const candidate = path.join(dir, OMP_BIN);
369
+ if (existsSync(candidate))
370
+ return candidate;
371
+ }
372
+ const managed = path.join(BUN_BIN_DIR, OMP_BIN);
373
+ return existsSync(managed) ? managed : null;
374
+ }
375
+ // The rule bodies a per-host tree carries. rule.md and rule-lite.md are bundled forks; the others track upstream.
376
+ const CAVEMAN_BODIES = ['rule.md', 'rule-lite.md', 'rule-megacave-lite.md', 'rule-megacave-ultra.md', 'rule-ultra.md', 'rule-megacave.md'];
377
+ /** `"installed < latest"` when npm answers, else null: silence means "no row", not "stale". */
378
+ async function ponytailLatestGap(installedPkgText) {
379
+ const local = parseJsonObject(installedPkgText)?.version;
380
+ if (!local)
381
+ return null;
382
+ try {
383
+ const meta = await fetchJson('https://registry.npmjs.org/@dietrichgebert/ponytail/latest');
384
+ const remote = meta?.version;
385
+ if (!remote || remote === local)
386
+ return null;
387
+ return `${local} installed, ${remote} is the latest`;
388
+ }
389
+ catch {
390
+ return null;
391
+ }
392
+ }
393
+ /** Add-on files under a host's tree, repo-relative, empty when absent: `node_modules` is skipped as a hoisted dep. */
394
+ function hostTreeFiles(base) {
395
+ const out = [];
396
+ const walk = (dir, rel) => {
397
+ let entries;
398
+ try {
399
+ entries = readdirSync(dir, { withFileTypes: true });
400
+ }
401
+ catch {
402
+ return;
403
+ }
404
+ for (const entry of entries) {
405
+ if (entry.name === 'node_modules' || entry.name === '.DS_Store')
406
+ continue;
407
+ const child = rel ? `${rel}/${entry.name}` : entry.name;
408
+ if (entry.isDirectory())
409
+ walk(path.join(dir, entry.name), child);
410
+ else
411
+ out.push(child);
412
+ }
413
+ };
414
+ walk(base, '');
415
+ return out;
416
+ }
417
+ /** Whether omp's strings still carry Caveman's marker sentence: null = no gap, string = warning; pi unchecked. */
418
+ function subagentMarkerStatus(ompBin) {
419
+ if (!ompBin)
420
+ return null;
421
+ let haystack;
422
+ try {
423
+ haystack = execFileSync(ompBin, ['--version'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] })
424
+ + readFileSync(ompBin, 'utf8');
425
+ }
426
+ catch {
427
+ // A binary that will not run is the version row's problem, not this one's.
428
+ return null;
429
+ }
430
+ if (haystack.includes(OMP_SUBAGENT_MARKER))
431
+ return null;
432
+ return `not found in ${path.basename(ompBin)} — Caveman will not reach subagents until OMP_SUBAGENT_MARKER in extensions/shared/session-state.ts is updated`;
433
+ }
209
434
  async function runDoctorFix(tally) {
210
435
  const problems = [];
211
436
  if (tally.missing > 0)
212
437
  problems.push(`${tally.missing} missing`);
213
438
  if (tally.warn > 0)
214
439
  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
440
  const scoped = fixScope();
220
441
  let targets;
221
442
  if (scoped) {
222
443
  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
444
  }
231
- else if (yes) {
445
+ else {
232
446
  targets = ['all'];
233
447
  }
234
- else {
448
+ // A dry run still reaches the repairs: each write is guarded, so the flag only skips prompts a preview cannot ask.
449
+ if (dryRun) {
450
+ console.log(`\n [dry-run] would repair ${problems.join(' + ') || 'nothing'} (extensions, stale config.yml entries, rtk wiring, ponytail refresh, CLI update, opencode service)`);
451
+ const { runDoctorRepairs } = await import('./doctor-fix.js');
452
+ await runDoctorRepairs(targets);
453
+ return;
454
+ }
455
+ if (scoped && !yes) {
456
+ const go = await askInteractiveConfirm(`Repair "${scoped}" now?`);
457
+ if (go.status !== 'confirmed' || !go.value) {
458
+ process.exitCode = 130;
459
+ return;
460
+ }
461
+ }
462
+ else if (!scoped && !yes) {
235
463
  const picked = await askInteractiveChoice('Doctor — what should I repair?', [
236
464
  { value: 'all', label: 'Fix everything', hint: `${tally.missing + tally.warn} rows` },
237
465
  { value: 'extensions', label: 'Missing extension files', hint: 'copy sources from this CLI' },
@@ -239,6 +467,7 @@ async function runDoctorFix(tally) {
239
467
  { value: 'rtk', label: 'RTK binary + wiring', hint: 'download checksum-verified binary, rtk init' },
240
468
  { value: 'ponytail', label: 'Ponytail package', hint: 'bundled reinstall via tersio dep' },
241
469
  { value: 'cli', label: 'CLI update', hint: 'latest tersio + delegated refresh' },
470
+ { value: 'service', label: 'OpenCode service', hint: 'stop an orphaned serve process, then start it' },
242
471
  ], 'all');
243
472
  if (picked.status !== 'selected') {
244
473
  process.exitCode = 130;
@@ -273,26 +502,20 @@ async function runDoctorFix(tally) {
273
502
  else
274
503
  console.log(` Doctor --fix: still ${again.missing} missing + ${again.warn} warn — rerun or repair manually.`);
275
504
  }
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
- */
505
+ /** How far omp's transcript index lags the transcripts on disk, or null when behind or unreadable. */
281
506
  function statsIndexLag() {
282
507
  const statsDb = statsDbPath();
283
508
  if (!existsSync(statsDb))
284
509
  return null;
285
510
  try {
286
- // The same WAL-safe reader the aggregate uses, so this probe never creates side files beside a
287
- // database omp has open, and never trips over them either.
511
+ // Same WAL-safe reader as the aggregate, so this probe creates no side files beside a database omp has open.
288
512
  const rows = sqliteScript(statsDb, 'SELECT session_file, offset FROM file_offsets;\n')
289
513
  .split('\n')
290
514
  .filter((line) => line.includes('\t'))
291
515
  .map((line) => line.split('\t'));
292
516
  if (rows.length === 0)
293
517
  return null;
294
- // The gap behind the freshest transcript is what a page shows as "no requests", so the newest
295
- // file sets the number rather than the sum of every file's gap.
518
+ // A page shows the gap behind the freshest transcript, so the newest file sets the number, not the sum.
296
519
  let worstBytes = 0;
297
520
  let freshest = '';
298
521
  for (const [file, offsetText] of rows) {
@@ -315,7 +538,7 @@ function statsIndexLag() {
315
538
  return `${shortBytes(worstBytes)} of ${path.basename(freshest)} unread, last written ${when === 'just now' ? 'moments ago' : when} — run: omp stats`;
316
539
  }
317
540
  catch {
318
- return null; // no sqlite3, or an unreadable index: nothing to report
541
+ return null; // no SQLite engine, or an unreadable index: nothing to report
319
542
  }
320
543
  }
321
544
  function section(name) {
package/dist/cli/hosts.js CHANGED
@@ -1,4 +1,4 @@
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';
@@ -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}` },
@@ -67,8 +86,8 @@ function detect(agentDir = piAgentDir()) {
67
86
  return HOSTS.map((host) => {
68
87
  const pkgDir = host.id === 'omp' ? ompPackageDir() : host.id === 'pi' ? piPackageDir(agentDir) : null;
69
88
  const tree = hostExtensionsDir(host.id, agentDir);
70
- // 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
89
+ // A tree wins: the installer writes it and it carries no package.json, so its version is ours. OpenCode keeps no package to fall back to, so a present tree is the install and drift gets the per-host warning.
90
+ const via = missingTreeFiles(host.id, agentDir).length === 0 || (host.id === 'opencode' && existsSync(tree))
72
91
  ? 'tree'
73
92
  : pkgDir && existsSync(pkgDir) ? 'package' : null;
74
93
  return {
@@ -101,7 +120,7 @@ export function hostHint(host) {
101
120
  return host.via === 'tree' ? 'extension tree' : 'plugin package';
102
121
  return host.installCmd;
103
122
  }
104
- // `--host omp|pi` from raw argv; undefined when absent. Throws on an unknown value so the caller can exit(1) with the message.
123
+ // `--host omp|pi` from raw argv, undefined when absent; throws on an unknown value so the caller exits(1).
105
124
  export function parseHostArg(argv) {
106
125
  const i = argv.indexOf('--host');
107
126
  const raw = (i === -1 ? undefined : argv[i + 1]) ?? argv.find((a) => a.startsWith('--host='))?.slice('--host='.length);