@krtclcdy/tersio 2.25.4 → 2.25.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +27 -63
  2. package/dashboard/dist/index.html +18 -16
  3. package/dist/cli/backups.js +187 -0
  4. package/dist/cli/banner.js +4 -3
  5. package/dist/cli/common.js +29 -13
  6. package/dist/cli/currency.js +1 -1
  7. package/dist/cli/dashboard.js +168 -110
  8. package/dist/cli/doctor-fix.js +232 -50
  9. package/dist/cli/doctor.js +281 -32
  10. package/dist/cli/hosts.js +29 -9
  11. package/dist/cli/install.js +204 -69
  12. package/dist/cli/interactive.js +3 -3
  13. package/dist/cli/manifest.js +125 -17
  14. package/dist/cli/profile.js +14 -9
  15. package/dist/cli/rtk-wiring.js +1 -1
  16. package/dist/cli/settings.js +20 -5
  17. package/dist/cli/uninstall.js +24 -8
  18. package/dist/cli/update.js +9 -10
  19. package/dist/cli/usage.js +75 -7
  20. package/dist/extensions/{caveman-session → caveman}/index.js +4 -2
  21. package/{extensions/caveman-session → dist/extensions/caveman}/rule-megacave.md +5 -3
  22. package/{extensions/caveman-session → dist/extensions/caveman}/rule-ultra.md +4 -4
  23. package/dist/extensions/caveman/rule.md +89 -0
  24. package/dist/extensions/{combo-toggle → combo}/index.js +62 -21
  25. package/dist/extensions/{tersio-commands → commands}/index.js +2 -2
  26. package/dist/extensions/pstack/index.js +119 -0
  27. package/dist/extensions/pstack/rule.md +65 -0
  28. package/dist/extensions/{rtk-filter/index.js → rtk/filter.js} +1 -2
  29. package/dist/extensions/{rtk-session/index.js → rtk/session.js} +5 -5
  30. package/dist/extensions/{opencode/server.js → server.js} +98 -14
  31. package/dist/extensions/shared/agent-stats.js +1198 -0
  32. package/dist/extensions/shared/brand-marks.js +153 -0
  33. package/dist/extensions/shared/carbon.js +1 -1
  34. package/dist/extensions/shared/plugin-settings.js +12 -7
  35. package/dist/extensions/shared/pricing.js +86 -30
  36. package/dist/extensions/shared/rtk-gain.js +4 -9
  37. package/dist/extensions/shared/session-state.js +15 -10
  38. package/dist/extensions/shared/sqlite.js +168 -0
  39. package/dist/extensions/shared/status.js +8 -8
  40. package/dist/extensions/shared/types.js +1 -1
  41. package/dist/extensions/shared/usage-ledger.js +113 -39
  42. package/dist/extensions/shared/usage-store.js +191 -48
  43. package/dist/extensions/{lib → shared}/utils.js +6 -6
  44. package/dist/extensions/timing/index.js +31 -0
  45. package/dist/extensions/{ai-addons-updater → updater}/index.js +13 -6
  46. package/dist/probe-catalog.js +48 -0
  47. package/dist/probe-catalog188.js +44 -0
  48. package/dist/tersio.js +9 -4
  49. package/extensions/{caveman-session → caveman}/index.ts +4 -2
  50. package/{dist/extensions/caveman-session → extensions/caveman}/rule-megacave.md +5 -3
  51. package/{dist/extensions/caveman-session → extensions/caveman}/rule-ultra.md +4 -4
  52. package/extensions/caveman/rule.md +89 -0
  53. package/extensions/{combo-toggle → combo}/index.ts +62 -19
  54. package/extensions/{tersio-commands → commands}/index.ts +2 -2
  55. package/extensions/pstack/index.ts +128 -0
  56. package/extensions/pstack/rule.md +65 -0
  57. package/extensions/{rtk-filter/index.ts → rtk/filter.ts} +1 -2
  58. package/extensions/{rtk-session/index.ts → rtk/session.ts} +5 -5
  59. package/extensions/{opencode/server.ts → server.ts} +81 -16
  60. package/extensions/shared/agent-stats.ts +1605 -0
  61. package/extensions/shared/brand-marks.ts +163 -0
  62. package/extensions/shared/carbon.ts +1 -1
  63. package/extensions/shared/plugin-settings.ts +13 -7
  64. package/extensions/shared/pricing.ts +95 -32
  65. package/extensions/shared/rtk-gain.ts +4 -9
  66. package/extensions/shared/session-state.ts +18 -13
  67. package/extensions/shared/sqlite.ts +168 -0
  68. package/extensions/shared/status.ts +9 -8
  69. package/extensions/shared/types.ts +2 -1
  70. package/extensions/shared/usage-ledger.ts +134 -46
  71. package/extensions/shared/usage-store.ts +202 -50
  72. package/extensions/{lib → shared}/utils.ts +6 -6
  73. package/extensions/timing/index.ts +45 -0
  74. package/extensions/{ai-addons-updater → updater}/index.ts +15 -9
  75. package/package.json +27 -18
  76. package/dist/extensions/caveman-session/rule.md +0 -79
  77. package/extensions/caveman-session/rule.md +0 -79
@@ -1,63 +1,181 @@
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, writeIfChanged, } 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/lib/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 { EXT_DIR, OPENCODE_SERVER_SHIM, TREE_FILES, sourcePath } 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
- async function fixExtensionTrees() {
13
- // The opencode entrypoint is foreign to pi/omp loaders: never copy it there, and remove strays.
14
- const forHost = (id) => TREE_FILES.filter((file) => id === 'opencode' || !file.startsWith('opencode/'));
15
- const scrubOpencodeDir = async (dest) => {
16
- const dir = path.join(dest, 'opencode');
17
- if (!existsSync(dir))
18
- return;
19
- if (dryRun) {
20
- sayTagged(` [dry-run] would remove foreign ${dir}`);
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 {
21
74
  return;
22
75
  }
23
- await fs.rm(dir, { recursive: true, force: true });
24
- sayTagged(` [rm] foreign ${dir}`);
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.
113
+ async function fixExtensionTrees() {
114
+ // The entry imports @opencode/plugin, which pi and omp lack, so one shared filter keeps repairs out of their trees.
115
+ const forHost = (id) => treeFilesFor(id);
116
+ // Older trees nested the entry; a repair under the old filter may have left it at the root, so remove both strays.
117
+ const scrubOpencodeEntry = async (dest) => {
118
+ for (const target of [path.join(dest, OPENCODE_ENTRY), path.join(dest, 'opencode')]) {
119
+ if (!existsSync(target))
120
+ continue;
121
+ if (dryRun) {
122
+ sayTagged(` [dry-run] would remove foreign ${target}`);
123
+ continue;
124
+ }
125
+ await fs.rm(target, { recursive: true, force: true });
126
+ sayTagged(` [rm] foreign ${target}`);
127
+ }
25
128
  };
26
129
  for (const host of detectHosts()) {
27
130
  const dest = hostExtensionsDir(host.id);
28
- // 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".
29
132
  const present = forHost(host.id).filter((file) => existsSync(path.join(dest, file))).length;
30
133
  if (present === 0) {
31
134
  if (host.id !== 'opencode')
32
- await scrubOpencodeDir(dest);
135
+ await scrubOpencodeEntry(dest);
33
136
  continue;
34
137
  }
35
138
  await fs.mkdir(dest, { recursive: true });
36
- for (const file of forHost(host.id)) {
37
- const from = path.join(EXT_DIR, ...file.split('/'));
38
- const text = await readTextIfExists(from);
39
- if (text === null)
40
- sayTagged(` [warn] bundled source missing: ${from}`);
41
- else
42
- await writeIfChanged(path.join(dest, file), text, { dryRun, verbose });
139
+ const missing = await syncTreeFiles(dest, forHost(host.id), { dryRun, verbose });
140
+ for (const from of missing)
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}`);
43
161
  }
44
162
  if (host.id !== 'opencode')
45
- await scrubOpencodeDir(dest);
163
+ await scrubOpencodeEntry(dest);
46
164
  if (!dryRun)
47
165
  sayTagged(` [ok] ${host.label} extension tree: ${dest}`);
48
166
  }
49
167
  }
50
- // The tree alone does nothing on OpenCode without its entrypoint registered in opencode.json.
51
- // 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.
52
169
  async function fixOpencodeEntry() {
53
170
  const { HOME } = await import('./common.js');
54
171
  const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
55
172
  const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
56
- const nested = path.join(dir, 'opencode', 'server.ts');
57
- if (!(await readTextIfExists(nested)))
58
- return;
59
- // The loader resolves <pluginDir>/server.ts, so mirror the entry at the tree root.
60
- await writeIfChanged(path.join(dir, 'server.ts'), OPENCODE_SERVER_SHIM, { dryRun, verbose });
173
+ // The loader resolves <pluginDir>/server.ts: an entry at the extensions root leaves a shim to a nested copy.
174
+ const nested = path.join(dir, 'opencode');
175
+ const staleNested = (await readTextIfExists(path.join(nested, 'server.ts'))) !== null;
176
+ await syncTreeFiles(dir, TREE_FILES.filter((file) => !file.includes('/')), { dryRun, verbose });
177
+ if (staleNested && !dryRun)
178
+ await fs.rm(nested, { recursive: true, force: true }).catch(() => { });
61
179
  if (dryRun)
62
180
  sayTagged(` [dry-run] would remove the redundant rtk plugin entry`);
63
181
  else
@@ -91,23 +209,38 @@ async function fixExtensions(pluginsDir) {
91
209
  const pluginExtDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio', 'extensions');
92
210
  await fs.mkdir(pluginExtDir, { recursive: true });
93
211
  // Rule bodies are never overwritten: they can be newer upstream fetches.
94
- const ruleFile = (f) => /^caveman-session\/rule.*\.md$/.test(f);
95
- for (const file of TREE_FILES) {
96
- const dest = path.join(pluginExtDir, ...file.split('/'));
97
- if (ruleFile(file) && existsSync(dest))
212
+ const ruleFile = (f) => /^caveman\/rule.*\.md$/.test(f);
213
+ const missing = await syncTreeFiles(pluginExtDir, TREE_FILES, { dryRun, verbose }, (file) => ruleFile(file) && existsSync(path.join(pluginExtDir, ...file.split('/'))));
214
+ for (const from of missing)
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}`);
98
222
  continue;
99
- const text = await readTextIfExists(sourcePath(file));
100
- if (text === null)
101
- sayTagged(` [warn] bundled source missing: ${sourcePath(file)}`);
102
- else
103
- await writeIfChanged(dest, text, { dryRun, verbose });
223
+ }
224
+ await fs.rm(at, { force: true });
225
+ sayTagged(` [rm] debris ${at}`);
104
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 });
105
238
  // Writing is not atomic, so re-read rather than assume the tree is whole.
106
239
  if (dryRun)
107
240
  return;
108
- const missing = TREE_FILES.filter((file) => !existsSync(path.join(pluginExtDir, ...file.split('/'))));
109
- if (missing.length > 0)
110
- sayTagged(` [fail] tree incomplete: ${missing.join(', ')}`);
241
+ const stillMissing = TREE_FILES.filter((file) => !existsSync(path.join(pluginExtDir, ...file.split('/'))));
242
+ if (stillMissing.length > 0)
243
+ sayTagged(` [fail] tree incomplete: ${stillMissing.join(', ')}`);
111
244
  else
112
245
  sayTagged(` [ok] extension tree: ${TREE_FILES.length} files`);
113
246
  }
@@ -127,7 +260,9 @@ async function fixRegistrations(agentDir, pluginsDir) {
127
260
  if ((await readTextIfExists(rtkTs)) !== null)
128
261
  await ensureRtkInConfig({ dryRun, verbose });
129
262
  const ponytailExtPath = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
130
- await removeExtensionFromConfig(configPath, path.join(extDir, 'combo-toggle', 'index.ts'), 'manifest-loaded combo', { dryRun, verbose });
263
+ // A tree from before the fold still names `combo-toggle`; both lines name the same module.
264
+ await removeExtensionFromConfig(configPath, path.join(extDir, 'combo', 'index.ts'), 'manifest-loaded combo', { dryRun, verbose });
265
+ await removeExtensionFromConfig(configPath, path.join(extDir, 'combo-toggle', 'index.ts'), 'retired manifest-loaded combo', { dryRun, verbose });
131
266
  await removeExtensionFromConfig(configPath, ponytailExtPath, 'manifest-loaded ponytail', { dryRun, verbose });
132
267
  await removeExtensionFromConfig(configPath, path.join(extDir, 'shared', 'mode-reinforcement.ts'), 'retired mode reinforcement', { dryRun, verbose });
133
268
  const pkgPath = path.join(pluginsDir, 'package.json');
@@ -209,13 +344,22 @@ async function fixPonytail(pluginsDir) {
209
344
  await fs.mkdir(pluginsDir, { recursive: true });
210
345
  const pkgPath = path.join(pluginsDir, 'package.json');
211
346
  const pkg = await readPluginsPackage(pkgPath);
212
- // Drop the legacy separate entry: ponytail is a tersio dependency now.
213
- if ('@dietrichgebert/ponytail' in pkg.dependencies) {
214
- delete pkg.dependencies['@dietrichgebert/ponytail'];
215
- console.log(' [migrate] dropped separate @dietrichgebert/ponytail dependency (now bundled with tersio)');
216
- }
217
347
  if (!(PACKAGE_NAME in pkg.dependencies))
218
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
+ }
219
363
  if (dryRun) {
220
364
  console.log(' [dry-run] would run: npm install --no-audit --no-fund (in plugins dir)');
221
365
  return;
@@ -225,10 +369,46 @@ async function fixPonytail(pluginsDir) {
225
369
  throw new Error(`bundled ponytail restore failed: ${e.message}`);
226
370
  });
227
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
+ }
228
376
  async function fixCli() {
229
377
  console.log(' Doctor --fix: updating CLI + add-ons');
230
378
  await runLatestUpdate();
231
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
+ }
232
412
  async function runDoctorRepairs(targets) {
233
413
  const all = targets.includes('all');
234
414
  const want = (t) => all || targets.includes(t);
@@ -256,6 +436,8 @@ async function runDoctorRepairs(targets) {
256
436
  await attempt('ponytail', () => fixPonytail(OMP_PLUGINS_DIR));
257
437
  if (want('cli'))
258
438
  await attempt('cli', fixCli);
439
+ if (want('service'))
440
+ await attempt('opencode service', fixOpencodeService);
259
441
  return failed;
260
442
  }
261
443
  export { runDoctorRepairs };