@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,4 +1,4 @@
1
- // Serves the built Dashboard with local usage APIs. Binds 127.0.0.1 only; --export writes a file instead. No Promise.withResolvers: Node 20.12 lacks it.
1
+ // Dashboard server + usage APIs. Binds 127.0.0.1; --export writes a file. No Promise.withResolvers (Node 20.12).
2
2
  import { spawn } from 'node:child_process';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
@@ -11,11 +11,11 @@ import { isCurrencyCode } from './currency.js';
11
11
  import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, } from './common.js';
12
12
  import { BACKUP_SCHEDULES, formatCliStatus, storedProfile, writePluginSettings } from './profile.js';
13
13
  import { normalizeComboLevel } from '../extensions/shared/session-state.js';
14
- import { CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS } from './common.js';
14
+ import { CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, PSTACK_DEFAULTS } from './common.js';
15
15
  import { PACKAGE_NAME } from './common.js';
16
- import { findPackageRoot, resolveRtkBinary } from '../extensions/shared/utils.js';
16
+ import { findPackageRoot, resolveRtkBinary, ruleBodyProblem } from '../extensions/shared/utils.js';
17
17
  import { BACKUP_KEEP, backupDir, backupDiskBytes, createDataBackup, dataSources, deleteDataBackup, listDataBackups, restoreDataBackup } from './backups.js';
18
- import { readBackupSchedule } from '../extensions/shared/usage-store.js';
18
+ import { readBackupSchedule, syncUsageDb } from '../extensions/shared/usage-store.js';
19
19
  import { agentDbPath, emptyRequestEntry, isAgentView, isRangeKey, readAgentRequestEntry, readAgentStats, readSessionTrace, statsDbPath } from '../extensions/shared/agent-stats.js';
20
20
  const DASHBOARD_DIST = path.join(findPackageRoot(path.dirname(fileURLToPath(import.meta.url))), 'dashboard', 'dist');
21
21
  const DASHBOARD_INDEX = path.join(DASHBOARD_DIST, 'index.html');
@@ -51,9 +51,12 @@ async function brandDataUri() {
51
51
  }
52
52
  /** The range a page opens on, matching the reference dashboard. */
53
53
  const DEFAULT_AGENT_RANGE = '24h';
54
- /** The omp aggregate spawns a sqlite3 process per query group, so a repeat poll reuses it. */
54
+ /** Poll memo TTL: every poll re-reads the aggregate, so a repeat poll reuses the last answer. */
55
55
  const AGENT_REFRESH_MS = 5000;
56
+ /** A sync walks every transcript, so it runs at most this often even while a poll outruns it. */
57
+ const AGENT_SYNC_MS = 15_000;
56
58
  let agentMemo = null;
59
+ let lastSyncAt = 0;
57
60
  function fileStamp(file) {
58
61
  try {
59
62
  const stat = statSync(file);
@@ -63,15 +66,25 @@ function fileStamp(file) {
63
66
  return '-';
64
67
  }
65
68
  }
66
- /** Reads the omp databases, unless the same window and view were read recently and nothing moved. */
67
- function agentStats(range, view = 'all') {
68
- const stamp = `${fileStamp(statsDbPath())}|${fileStamp(agentDbPath())}`;
69
+ /** Both sources: the usage store carries every host's rows, agent.db only omp's quota snapshots. */
70
+ function storeStamp() {
71
+ return `${fileStamp(statsDbPath())}|${fileStamp(agentDbPath())}`;
72
+ }
73
+ /** Syncs before reading past the throttle; without it a page misses pi and OpenCode rows. */
74
+ function agentStats(range, view = 'all', offset = 0) {
69
75
  const held = agentMemo;
70
- if (held && held.range === range && held.view === view && held.stamp === stamp && Date.now() - held.at < AGENT_REFRESH_MS) {
76
+ if (held && held.range === range && held.view === view && held.offset === offset && held.stamp === storeStamp() && Date.now() - held.at < AGENT_REFRESH_MS) {
71
77
  return held.value;
72
78
  }
73
- const value = readAgentStats(range, view);
74
- agentMemo = { range, view, stamp, at: Date.now(), value };
79
+ if (Date.now() - lastSyncAt >= AGENT_SYNC_MS) {
80
+ lastSyncAt = Date.now();
81
+ try {
82
+ syncUsageDb();
83
+ }
84
+ catch { /* a store that will not write still reads the rows it already holds */ }
85
+ }
86
+ const value = readAgentStats(range, view, Date.now(), offset);
87
+ agentMemo = { range, view, offset, stamp: storeStamp(), at: Date.now(), value };
75
88
  return value;
76
89
  }
77
90
  /** `?range=` on any of the omp routes; an unknown value falls back to the default. */
@@ -86,8 +99,14 @@ function viewFrom(url) {
86
99
  const raw = queryParam(url, 'view');
87
100
  return isAgentView(raw) ? raw : 'all';
88
101
  }
89
- function agentJson(range, view) {
90
- return JSON.stringify({ range, view, agent: agentStats(range, view) });
102
+ /** `?offset=` in whole rows, floored; a later page starts further back instead of clamping at the cap. */
103
+ function offsetFrom(url) {
104
+ const raw = queryParam(url, 'offset');
105
+ const value = raw === null ? 0 : Number(raw);
106
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
107
+ }
108
+ function agentJson(range, view, offset) {
109
+ return JSON.stringify({ range, view, offset, agent: agentStats(range, view, offset) });
91
110
  }
92
111
  // Runs outside any agent session, so it reports the persisted defaults.
93
112
  async function statusJson() {
@@ -98,6 +117,7 @@ async function statusJson() {
98
117
  caveman: profile.cavemanDefault,
99
118
  rtk: profile.rtkDefault ? 'on' : 'off',
100
119
  ponytail: profile.ponytailDefault,
120
+ pstack: profile.pstackDefault,
101
121
  });
102
122
  }
103
123
  // Local-only health: find each agent on PATH, run `--version`, report both.
@@ -282,18 +302,29 @@ function computeDoctorRows() {
282
302
  }
283
303
  catch { /* missing config is reported by extension rows */ }
284
304
  const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
285
- addon('Unique config registrations', true, 'ok', 'Extensions');
286
- // A listed extension whose file is gone makes the host warn on every load, so surface it rather than leaving the user with only a startup message.
305
+ addon('Unique config registrations', duplicateExtensions.length === 0, duplicateExtensions.length ? duplicateExtensions.join(', ') : 'ok', 'Extensions');
306
+ // A listed extension whose file is gone makes the host warn on every load, so surface it here.
287
307
  const dangling = explicitEntries
288
308
  .filter((entry) => entry.includes(`extensions${path.sep}`) || entry.includes('extensions/'))
289
309
  .filter((entry) => !ok(path.isAbsolute(entry) ? entry : path.resolve(OMP_AGENT_DIR, entry)));
290
310
  addon('Extension files present', dangling.length === 0, dangling.length ? dangling.join(', ') : 'ok', 'Extensions');
291
- ext('Caveman extension', path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts'));
292
- ext('RTK extension', path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts'));
293
- const rule = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'rule.md');
311
+ ext('Caveman extension', path.join(tersioPluginDir, 'extensions', 'caveman', 'index.ts'));
312
+ ext('pstack extension', path.join(tersioPluginDir, 'extensions', 'pstack', 'index.ts'));
313
+ ext('RTK extension', path.join(tersioPluginDir, 'extensions', 'rtk', 'session.ts'));
314
+ const rule = path.join(tersioPluginDir, 'extensions', 'caveman', 'rule.md');
294
315
  const ruleAge = ageStr(rule);
295
316
  const ruleAt = absTime(rule);
296
317
  addon('Caveman rule', ruleAge !== null, ruleAge && ruleAt ? `(updated ${ruleAge} · ${ruleAt})` : rule);
318
+ // The pstack body is a Tersio fork with no remote, so this row proves it is usable, not current.
319
+ const pstackRule = path.join(tersioPluginDir, 'extensions', 'pstack', 'rule.md');
320
+ let pstackProblem = 'missing — run: tersio install';
321
+ try {
322
+ pstackProblem = ruleBodyProblem(readFileSync(pstackRule, 'utf8'));
323
+ }
324
+ catch { /* missing stays */ }
325
+ const pstackAge = ageStr(pstackRule);
326
+ const pstackAt = absTime(pstackRule);
327
+ addon('pstack rule', pstackProblem === null, pstackProblem ?? `(updated ${pstackAge} · ${pstackAt})`);
297
328
  const rtkVer = rtkBin ? rtkVersion(rtkBin) : null;
298
329
  const rtkAge = rtkBin ? ageStr(rtkBin) : null;
299
330
  const rtkAt = rtkBin ? absTime(rtkBin) : null;
@@ -314,7 +345,7 @@ function computeDoctorRows() {
314
345
  addon('Prices feed', pricesAge !== null, pricesAge && pricesAt ? `(pulled ${pricesAge} · ${pricesAt})` : prices, 'Usage');
315
346
  return rows;
316
347
  }
317
- // Recompute when forced, missing, or past the schedule; a report holding a retired label is stale by definition.
348
+ // A report holding any of these labels is stale by definition.
318
349
  const RETIRED_DIAG_LABELS = new Set([
319
350
  'OMP CLI',
320
351
  'Tersio CLI',
@@ -335,11 +366,10 @@ const RETIRED_DIAG_LABELS = new Set([
335
366
  function isRetiredReport(report) {
336
367
  return report.rows.some((r) => RETIRED_DIAG_LABELS.has(r.label));
337
368
  }
338
- // Recompute when forced, missing, retired, or past the schedule.
369
+ // Recompute when forced, missing, retired, or past schedule; manual schedules recompute on open, dated ones reuse.
339
370
  function getDoctorReport(force) {
340
371
  const saved = readDiagReport();
341
372
  const ttl = saved ? DIAG_TTL_MS[saved.schedule] : 0;
342
- // Manual recomputes on open; a dated schedule reuses the saved report.
343
373
  if (!force && saved && !isRetiredReport(saved) && ttl > 0 && Date.now() - saved.checkedAt < ttl)
344
374
  return saved;
345
375
  const report = { rows: computeDoctorRows(), checkedAt: Date.now(), schedule: saved?.schedule ?? 'manual' };
@@ -373,6 +403,12 @@ async function saveDefaults(body) {
373
403
  return { ok: false, error: 'unknown ponytail level' };
374
404
  profile.ponytailDefault = v;
375
405
  }
406
+ if (body.pstackDefault !== undefined) {
407
+ const v = String(body.pstackDefault);
408
+ if (!PSTACK_DEFAULTS.has(v))
409
+ return { ok: false, error: 'unknown pstack level' };
410
+ profile.pstackDefault = v;
411
+ }
376
412
  if (body.backupSchedule !== undefined) {
377
413
  const v = String(body.backupSchedule);
378
414
  if (!BACKUP_SCHEDULES.has(v))
@@ -392,6 +428,7 @@ async function saveDefaults(body) {
392
428
  cavemanDefault: profile.cavemanDefault,
393
429
  rtkDefault: profile.rtkDefault,
394
430
  ponytailDefault: profile.ponytailDefault,
431
+ pstackDefault: profile.pstackDefault,
395
432
  backupSchedule: profile.backupSchedule,
396
433
  },
397
434
  };
@@ -423,7 +460,7 @@ function openBrowser(url) {
423
460
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
424
461
  spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
425
462
  }
426
- // Replacers throughout: session data holds `$'` (shell quoting) and literal `</script>`, which String.replace would mangle.
463
+ // Replacers throughout: session data holds `$'` (shell quoting) and `</script>`, which String.replace would mangle.
427
464
  function escapeInline(json) {
428
465
  return json.replace(/<\/(script)/gi, '<\\/$1');
429
466
  }
@@ -444,20 +481,13 @@ function parseOwnJson(json, field) {
444
481
  async function exportDashboard(exportFile, currency) {
445
482
  requireDashboardBundle();
446
483
  const [bundle, icon] = await Promise.all([readSegment(DASHBOARD_INDEX), brandDataUri()]);
447
- // The bundle is minified, so the brand fetch reaches the file as `fetch(\`brand.webp\`)` or
448
- // `fetch("brand.webp")` depending on the bundler's quote choice. Match either, or the exported
449
- // card silently loses its mark. `atob` returns one char per byte, and a Blob encodes a string
450
- // as UTF-8, so the bytes go through a Uint8Array first: a webp is binary and every byte at or
451
- // above 0x80 would otherwise double and corrupt the image.
484
+ // Minified: fetch reads `fetch(\`brand.webp\`)` or `fetch("brand.webp")`; a mismatch loses the card's mark.
452
485
  const b64 = icon.split(',')[1] ?? '';
453
- // atob yields one char per byte; a Blob encodes a string as UTF-8, so the bytes pass through a
454
- // Uint8Array or every byte at or above 0x80 doubles and the webp comes out corrupt.
486
+ // atob yields one byte per char, so a Uint8Array is needed or bytes >= 0x80 double and corrupt the webp.
455
487
  const brandBytes = `(() => { const c = atob(${JSON.stringify(b64)}); const v = new Uint8Array(c.length); for (let i = 0; i < c.length; i++) v[i] = c.charCodeAt(i); return v; })()`;
456
488
  const inline = bundle
457
489
  .replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ currency, agent: agentStats(DEFAULT_AGENT_RANGE), health: parseOwnJson(healthJson(), 'health'), doctor: getDoctorReport(false) }))};`)
458
- // A mark reaches the bundle three ways: an HTML attribute in `index.html`, and a JSX prop
459
- // esbuild compiled with a backtick. Match any of `"`, `'`, and `` ` ``, or an exported file
460
- // keeps a bare reference that file:// cannot resolve.
490
+ // A mark is an HTML attribute or JSX prop; match every quote or the file keeps an unresolvable reference.
461
491
  .replace(/((?:src|href)[=:][ \t]*)([`"'])brand\.webp\2/g, (_all, head, quote) => `${head}${quote}${icon}${quote}`)
462
492
  .replace(/fetch\(([`"])brand\.webp\1\)/g, () => `Promise.resolve({ ok: true, blob: async () => new Blob([${brandBytes}], { type: "image/webp" }) })`);
463
493
  await fs.writeFile(exportFile, inline, 'utf8');
@@ -492,7 +522,7 @@ async function runDashboard(options) {
492
522
  }
493
523
  const action = String(body.action ?? '');
494
524
  const name = String(body.name ?? '');
495
- // A missing sqlite3, or a locked file, is a 500 with a message rather than a silent no-op.
525
+ // A missing SQLite engine, or a locked file, is a 500 with a message rather than a silent no-op.
496
526
  try {
497
527
  if (action === 'create') {
498
528
  const made = await createDataBackup();
@@ -530,6 +560,7 @@ async function runDashboard(options) {
530
560
  cavemanDefault: profile.cavemanDefault,
531
561
  rtkDefault: profile.rtkDefault,
532
562
  ponytailDefault: profile.ponytailDefault,
563
+ pstackDefault: profile.pstackDefault,
533
564
  backupSchedule: profile.backupSchedule,
534
565
  currency: options.currency,
535
566
  }));
@@ -576,7 +607,7 @@ async function runDashboard(options) {
576
607
  }
577
608
  if (req.url === '/api/agent' || req.url?.startsWith('/api/agent?')) {
578
609
  res.writeHead(200, { 'Content-Type': 'application/json' });
579
- res.end(agentJson(rangeFrom(req.url), viewFrom(req.url)));
610
+ res.end(agentJson(rangeFrom(req.url), viewFrom(req.url), offsetFrom(req.url)));
580
611
  return;
581
612
  }
582
613
  if (req.url?.startsWith('/api/agent/session')) {
@@ -691,4 +722,4 @@ async function runDashboard(options) {
691
722
  });
692
723
  });
693
724
  }
694
- export { runDashboard, saveDashboardCurrency, readDiagSchedule, setDiagSchedule };
725
+ export { runDashboard, saveDashboardCurrency, readDiagSchedule, setDiagSchedule, offsetFrom };
@@ -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 };