@krtclcdy/tersio 2.25.2 → 2.25.3

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 (55) hide show
  1. package/README.md +1 -1
  2. package/dashboard/dist/index.html +1 -1
  3. package/{cli → dist/cli}/common.js +4 -3
  4. package/{cli → dist/cli}/dashboard.js +30 -13
  5. package/{cli → dist/cli}/doctor-fix.js +1 -3
  6. package/{cli → dist/cli}/doctor.js +12 -5
  7. package/dist/cli/headroom-install.js +266 -0
  8. package/dist/cli/headroom.js +492 -0
  9. package/{cli → dist/cli}/install.js +19 -7
  10. package/{cli → dist/cli}/manifest.js +3 -2
  11. package/{cli → dist/cli}/settings.js +5 -23
  12. package/{cli → dist/cli}/uninstall.js +1 -1
  13. package/{cli → dist/cli}/update.js +4 -5
  14. package/{extensions → dist/extensions}/ai-addons-updater/index.js +2 -2
  15. package/{extensions → dist/extensions}/caveman-session/index.js +2 -2
  16. package/dist/extensions/caveman-session/rule-ultra.md +63 -0
  17. package/dist/extensions/caveman-session/rule.md +79 -0
  18. package/{extensions → dist/extensions}/combo-toggle/index.js +37 -27
  19. package/dist/extensions/headroom-session/index.js +93 -0
  20. package/{extensions → dist/extensions}/lib/utils.js +26 -1
  21. package/dist/extensions/shared/headroom.js +131 -0
  22. package/{extensions → dist/extensions}/shared/plugin-settings.js +1 -1
  23. package/{extensions → dist/extensions}/shared/session-state.js +14 -2
  24. package/{extensions → dist/extensions}/shared/status.js +7 -1
  25. package/{tersio.js → dist/tersio.js} +1 -1
  26. package/extensions/ai-addons-updater/index.ts +2 -2
  27. package/extensions/caveman-session/index.ts +2 -2
  28. package/extensions/caveman-session/rule-megacave.md +63 -0
  29. package/extensions/caveman-session/rule.md +4 -2
  30. package/extensions/combo-toggle/index.ts +35 -27
  31. package/extensions/lib/utils.ts +22 -1
  32. package/extensions/shared/plugin-settings.ts +1 -1
  33. package/extensions/shared/session-state.ts +15 -2
  34. package/extensions/shared/status.ts +8 -1
  35. package/package.json +15 -15
  36. package/extensions/opencode/index.js +0 -104
  37. /package/{cli → dist/cli}/banner.js +0 -0
  38. /package/{cli → dist/cli}/currency.js +0 -0
  39. /package/{cli → dist/cli}/hosts.js +0 -0
  40. /package/{cli → dist/cli}/interactive.js +0 -0
  41. /package/{cli → dist/cli}/profile.js +0 -0
  42. /package/{cli → dist/cli}/reset.js +0 -0
  43. /package/{cli → dist/cli}/rtk-wiring.js +0 -0
  44. /package/{cli → dist/cli}/usage.js +0 -0
  45. /package/{extensions/caveman-session/rule-wenyan.md → dist/extensions/caveman-session/rule-megacave.md} +0 -0
  46. /package/{extensions → dist/extensions}/opencode/server.js +0 -0
  47. /package/{extensions → dist/extensions}/rtk-session/index.js +0 -0
  48. /package/{extensions → dist/extensions}/shared/carbon.js +0 -0
  49. /package/{extensions → dist/extensions}/shared/host.js +0 -0
  50. /package/{extensions → dist/extensions}/shared/pricing.js +0 -0
  51. /package/{extensions → dist/extensions}/shared/rtk-gain.js +0 -0
  52. /package/{extensions → dist/extensions}/shared/types.js +0 -0
  53. /package/{extensions → dist/extensions}/shared/usage-ledger.js +0 -0
  54. /package/{extensions → dist/extensions}/shared/usage-store.js +0 -0
  55. /package/{extensions → dist/extensions}/tersio-commands/index.js +0 -0
@@ -0,0 +1,492 @@
1
+ // cli/headroom.ts — the Headroom add-on: bundle, detect, wrap, unwrap, status, doctor facts.
2
+ // Tersio installs the bundle itself; a Headroom the user installed is detected and left alone.
3
+ import { existsSync, promises as fs, readFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { execFileSync, spawn } from 'node:child_process';
6
+ import { HOME, args, commandArg, dryRun, execP, flagValue, verbose } from './common.js';
7
+ import { detectHosts } from './hosts.js';
8
+ import { installHeadroom, updateHeadroom, removeHeadroomBundle } from './headroom-install.js';
9
+ import { readTextIfExists, resolveHeadroomBinary, sha256File, tersioDataPath } from '../extensions/lib/utils.js';
10
+ import { appendUsage } from '../extensions/shared/usage-ledger.js';
11
+ import { HEADROOM_AGENTS, HEADROOM_INSTALL_HINT, HEADROOM_MIN_VERSION, HEADROOM_TESTED_VERSION, headroomAgentState, headroomCapability, headroomProxyUp, headroomProxyUrl, headroomVersionIssue, headroomWiringPresent, parseHeadroomVersion, readHeadroomConfig, writeHeadroomConfig, } from '../extensions/shared/headroom.js';
12
+ const configHome = () => process.env.XDG_CONFIG_HOME || path.join(HOME, '.config');
13
+ // The spike verified these two rewrites. pi has no wrap entry, so it stays manual.
14
+ export const HEADROOM_ADAPTERS = {
15
+ omp: {
16
+ label: 'Oh My Pi',
17
+ files: () => [path.join(HOME, '.omp', 'agent', 'models.yml')],
18
+ wrapArgs: () => ['wrap', 'omp', '--prepare-only'],
19
+ manualHint: '',
20
+ },
21
+ opencode: {
22
+ label: 'OpenCode',
23
+ files: () => [path.join(configHome(), 'opencode', 'opencode.json')],
24
+ // No MCP server and no Serena: Headroom's memory features are not part of this add-on.
25
+ wrapArgs: () => ['wrap', 'opencode', '--prepare-only', '--no-mcp', '--no-serena'],
26
+ manualHint: '',
27
+ },
28
+ pi: {
29
+ label: 'Pi',
30
+ files: () => [path.join(HOME, '.pi', 'agent', 'models-store.json')],
31
+ wrapArgs: () => [],
32
+ manualHint: 'Pi has no `headroom wrap` entry. Point one model baseUrl at '
33
+ + `${headroomProxyUrl()}/v1 in ~/.pi/agent/models-store.json, then set HEADROOM_BEACON=off for that session.`,
34
+ },
35
+ };
36
+ function proxyRecordPath() {
37
+ return tersioDataPath('headroom-proxy.json', 'tersio-headroom-proxy.json');
38
+ }
39
+ function readProxyRecord() {
40
+ try {
41
+ const raw = JSON.parse(readFileSync(proxyRecordPath(), 'utf8'));
42
+ if (typeof raw.port !== 'number' || typeof raw.pid !== 'number')
43
+ return null;
44
+ return { port: raw.port, pid: raw.pid, startedAt: raw.startedAt ?? 0 };
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ async function writeProxyRecord(record) {
51
+ try {
52
+ await fs.mkdir(path.dirname(proxyRecordPath()), { recursive: true });
53
+ await fs.writeFile(proxyRecordPath(), `${JSON.stringify(record, null, 2)}\n`, 'utf8');
54
+ }
55
+ catch { /* best-effort; a missing record only means an orphan proxy */ }
56
+ }
57
+ async function clearProxyRecord() {
58
+ try {
59
+ await fs.rm(proxyRecordPath(), { force: true });
60
+ }
61
+ catch { /* nothing to clear */ }
62
+ }
63
+ async function waitForProxy(port, timeoutMs = proxyTimeoutMs()) {
64
+ const deadline = Date.now() + timeoutMs;
65
+ for (;;) {
66
+ if (await headroomProxyUp(port))
67
+ return true;
68
+ if (Date.now() >= deadline)
69
+ return false;
70
+ await new Promise((resolve) => setTimeout(resolve, 250));
71
+ }
72
+ }
73
+ /** How long enable polls a freshly started proxy. Overridable so a test or CI run does not wait the full window. */
74
+ function proxyTimeoutMs() {
75
+ const raw = Number.parseInt(process.env.TERSIO_HEADROOM_PROXY_TIMEOUT_MS ?? '', 10);
76
+ return Number.isInteger(raw) && raw > 0 ? raw : 6000;
77
+ }
78
+ /** Beacon and update check off: Tersio is local-only, and the user opted into neither. */
79
+ async function startProxy(bin, port) {
80
+ try {
81
+ const child = spawn(bin, ['proxy', '--port', String(port)], {
82
+ detached: true,
83
+ stdio: 'ignore',
84
+ env: { ...process.env, HEADROOM_BEACON: 'off', HEADROOM_UPDATE_CHECK: 'off' },
85
+ });
86
+ child.unref();
87
+ await writeProxyRecord({ port, pid: child.pid ?? -1, startedAt: Date.now() });
88
+ }
89
+ catch (e) {
90
+ console.log(` [warn] could not start the Headroom proxy: ${e.message}`);
91
+ return false;
92
+ }
93
+ const up = await waitForProxy(port);
94
+ if (!up)
95
+ console.log(` [warn] the Headroom proxy did not answer on port ${port}.`);
96
+ return up;
97
+ }
98
+ async function stopProxy() {
99
+ const record = readProxyRecord();
100
+ if (record && record.pid > 0) {
101
+ try {
102
+ process.kill(record.pid, 'SIGTERM');
103
+ }
104
+ catch { /* already gone */ }
105
+ }
106
+ await clearProxyRecord();
107
+ }
108
+ function backupStorePath() {
109
+ return tersioDataPath('headroom-backup.json', 'tersio-headroom-backup.json');
110
+ }
111
+ async function readBackupStore() {
112
+ const raw = await readTextIfExists(backupStorePath());
113
+ if (!raw)
114
+ return {};
115
+ try {
116
+ const parsed = JSON.parse(raw);
117
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
118
+ return {};
119
+ return parsed;
120
+ }
121
+ catch {
122
+ return {};
123
+ }
124
+ }
125
+ async function writeBackupRecord(record) {
126
+ const store = await readBackupStore();
127
+ store[record.agent] = record;
128
+ try {
129
+ await fs.mkdir(path.dirname(backupStorePath()), { recursive: true });
130
+ await fs.writeFile(backupStorePath(), `${JSON.stringify(store, null, 2)}\n`, 'utf8');
131
+ }
132
+ catch { /* best-effort */ }
133
+ }
134
+ async function snapshotAgent(agent) {
135
+ const files = [];
136
+ for (const file of HEADROOM_ADAPTERS[agent].files()) {
137
+ files.push({ path: file, sha256: existsSync(file) ? await sha256File(file).catch(() => null) : null });
138
+ }
139
+ const record = { agent, at: Date.now(), files };
140
+ await writeBackupRecord(record);
141
+ return record;
142
+ }
143
+ /** True when every snapshot file is back to the hash recorded before the wrap. */
144
+ async function verifyRestore(record) {
145
+ for (const file of record.files) {
146
+ if (file.sha256 === null) {
147
+ if (existsSync(file.path))
148
+ return false;
149
+ continue;
150
+ }
151
+ if (!existsSync(file.path))
152
+ return false;
153
+ if (await sha256File(file.path).catch(() => null) !== file.sha256)
154
+ return false;
155
+ }
156
+ return true;
157
+ }
158
+ // --- headroom binary -------------------------------------------------------------------
159
+ async function headroomVersion(bin) {
160
+ try {
161
+ const result = await execP(bin, ['--version'], { timeout: 5000 });
162
+ return parseHeadroomVersion(result.stdout || result.stderr);
163
+ }
164
+ catch (e) {
165
+ const err = e;
166
+ return parseHeadroomVersion(err.stdout || err.stderr);
167
+ }
168
+ }
169
+ /** Sync variant for callers that cannot await (the dashboard's doctor rows). */
170
+ export function headroomVersionSync(bin) {
171
+ try {
172
+ return parseHeadroomVersion(execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000 }));
173
+ }
174
+ catch (e) {
175
+ const err = e;
176
+ return parseHeadroomVersion(err.stdout || err.stderr);
177
+ }
178
+ }
179
+ /** True when the proxy Tersio started is still running. Sync, so the dashboard can use it inside row building. */
180
+ export function headroomProxyAlive() {
181
+ const record = readProxyRecord();
182
+ if (!record || record.pid <= 0)
183
+ return false;
184
+ try {
185
+ process.kill(record.pid, 0);
186
+ return true;
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ }
192
+ /** The port Tersio recorded for the running proxy, or null when nothing was started. */
193
+ export function headroomProxyPort() {
194
+ const record = readProxyRecord();
195
+ return record && record.port > 0 ? record.port : null;
196
+ }
197
+ async function runHeadroomArgv(bin, argv) {
198
+ try {
199
+ await execP(bin, argv, { timeout: 60000 });
200
+ return true;
201
+ }
202
+ catch (e) {
203
+ const err = e;
204
+ console.log(` [fail] headroom ${argv.join(' ')}: ${(err.stderr || err.message || '').trim().slice(0, 200)}`);
205
+ return false;
206
+ }
207
+ }
208
+ // --- selectors -------------------------------------------------------------------------
209
+ function installedAgents() {
210
+ const installed = detectHosts().filter((host) => host.installed).map((host) => host.id);
211
+ const found = HEADROOM_AGENTS.filter((agent) => installed.includes(agent));
212
+ return found.length > 0 ? found : [...HEADROOM_AGENTS];
213
+ }
214
+ function parseAgentArg() {
215
+ const raw = flagValue('--agent');
216
+ if (raw === undefined)
217
+ return null;
218
+ const value = raw.trim().toLowerCase();
219
+ if (HEADROOM_AGENTS.includes(value))
220
+ return value;
221
+ console.error(`[fail] Invalid --agent: ${raw}. Valid: ${HEADROOM_AGENTS.join(', ')}`);
222
+ process.exit(1);
223
+ }
224
+ function selectedAgents(explicit = parseAgentArg()) {
225
+ if (explicit)
226
+ return [explicit];
227
+ if (args.includes('--all'))
228
+ return [...HEADROOM_AGENTS];
229
+ return installedAgents();
230
+ }
231
+ // --- enable / disable ------------------------------------------------------------------
232
+ /** What Headroom actually routes for one agent, read from its config. Null when nothing is wired. Only the routed provider is compressed. */
233
+ export function headroomRouteFor(agent, port) {
234
+ const file = HEADROOM_ADAPTERS[agent].files()[0];
235
+ let raw;
236
+ try {
237
+ if (!existsSync(file))
238
+ return null;
239
+ raw = readFileSync(file, 'utf8');
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ if (agent === 'pi')
245
+ return 'manual — point one model baseUrl at the proxy';
246
+ if (agent === 'omp') {
247
+ const lines = raw.split('\n');
248
+ const at = lines.findIndex((line) => line.includes(`127.0.0.1:${port}`));
249
+ if (at === -1)
250
+ return null;
251
+ // The wrapped provider is the block that owns the proxy baseUrl.
252
+ for (let i = at; i >= 0; i -= 1) {
253
+ const match = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(lines[i]);
254
+ if (match)
255
+ return `providers.${match[1]}.baseUrl`;
256
+ }
257
+ return null;
258
+ }
259
+ try {
260
+ const parsed = JSON.parse(raw);
261
+ return parsed.provider?.headroom ? 'provider.headroom — use a headroom/* model' : null;
262
+ }
263
+ catch {
264
+ return null;
265
+ }
266
+ }
267
+ /** The kill switch mirrors the agent flags: on only while at least one agent is wired. */
268
+ function writeAgent(config, agent, enabled, wrappedByTersio) {
269
+ const agents = { ...config.agents, [agent]: { enabled, wrappedByTersio } };
270
+ const next = {
271
+ support: HEADROOM_AGENTS.some((other) => agents[other]?.enabled === true),
272
+ port: config.port,
273
+ agents,
274
+ };
275
+ writeHeadroomConfig(next);
276
+ return next;
277
+ }
278
+ async function enableAgent(agent, bin, port) {
279
+ const config = readHeadroomConfig();
280
+ const { label, wrapArgs, manualHint } = HEADROOM_ADAPTERS[agent];
281
+ const current = headroomAgentState(config, agent);
282
+ const native = headroomCapability(agent) === 'native';
283
+ if (current.enabled) {
284
+ console.log(` [skip] ${label}: already enabled.`);
285
+ return true;
286
+ }
287
+ if (dryRun) {
288
+ console.log(` [dry-run] would ${native ? `run headroom ${wrapArgs().join(' ')}` : 'print the manual base URL'}, start the proxy on port ${port}, and record ${label}`);
289
+ return true;
290
+ }
291
+ // Snapshot before the wrap so the hashes prove a byte-identical restore later.
292
+ const record = await snapshotAgent(agent);
293
+ if (native && !(await runHeadroomArgv(bin, wrapArgs())))
294
+ return false;
295
+ const record2 = readProxyRecord();
296
+ const alreadyUp = record2 ? await headroomProxyUp(record2.port) : false;
297
+ const proxyUp = alreadyUp || await startProxy(bin, port);
298
+ const next = writeAgent(config, agent, true, native);
299
+ appendUsage('headroom', `enable ${agent}${proxyUp ? '' : ' (proxy down)'}`, { source: 'headroom', agent });
300
+ console.log(` [ok] Headroom enabled for ${label} (${native ? 'native wrap' : 'manual base URL'}).`);
301
+ if (native && headroomWiringPresent(HEADROOM_ADAPTERS[agent].files()))
302
+ console.log(` [ok] Wiring present · snapshot ${record.files.length} file(s) recorded.`);
303
+ console.log(` [info] Proxy: ${headroomProxyUrl(next.port)} ${proxyUp ? '(up)' : '(DOWN — run: headroom proxy --port ' + next.port + ')'}`);
304
+ console.log(' [info] Beacon off, update check off.');
305
+ console.log(' [info] Tersio leaves HEADROOM_OUTPUT_SHAPER unset: Caveman owns terse output.');
306
+ if (!native)
307
+ console.log(` [manual] ${manualHint}`);
308
+ console.log(` [info] Headroom sees prompts and tool output on loopback. It is a separate process Tersio starts and stops.`);
309
+ return true;
310
+ }
311
+ async function disableAgent(agent, bin) {
312
+ const config = readHeadroomConfig();
313
+ const { label } = HEADROOM_ADAPTERS[agent];
314
+ const current = headroomAgentState(config, agent);
315
+ if (!current.enabled) {
316
+ console.log(` [skip] ${label}: already off.`);
317
+ return true;
318
+ }
319
+ if (dryRun) {
320
+ console.log(` [dry-run] would ${current.wrappedByTersio ? `run headroom unwrap ${agent}` : 'clear the manual flag'}, verify the snapshot, and stop the proxy when no agent remains`);
321
+ return true;
322
+ }
323
+ const store = await readBackupStore();
324
+ const record = store[agent];
325
+ // Another agent on the same proxy must keep it, so its unwrap may not stop the proxy.
326
+ const othersEnabled = HEADROOM_AGENTS.some((other) => other !== agent && headroomAgentState(config, other).enabled);
327
+ if (current.wrappedByTersio) {
328
+ if (!bin) {
329
+ console.log(` [fail] ${label}: the headroom binary is missing, so unwrap cannot run.`);
330
+ return false;
331
+ }
332
+ const unwrapArgs = ['unwrap', agent, ...(othersEnabled ? ['--no-stop-proxy'] : [])];
333
+ if (!(await runHeadroomArgv(bin, unwrapArgs)))
334
+ return false;
335
+ if (record && !(await verifyRestore(record))) {
336
+ console.log(` [warn] ${label}: the config did not return to its recorded hash.`);
337
+ console.log(` [hint] headroom keeps its own copy at ${HEADROOM_ADAPTERS[agent].files()[0]}.headroom-backup`);
338
+ return false;
339
+ }
340
+ console.log(` [ok] ${label}: restored${record ? ' byte-identical' : ''}.`);
341
+ }
342
+ else {
343
+ console.log(` [ok] ${label}: manual flag cleared. Tersio never edited its config.`);
344
+ }
345
+ const next = writeAgent(config, agent, false, false);
346
+ const stillEnabled = HEADROOM_AGENTS.some((other) => headroomAgentState(next, other).enabled);
347
+ if (!stillEnabled)
348
+ await stopProxy();
349
+ appendUsage('headroom', `disable ${agent}`, { source: 'headroom', agent });
350
+ return true;
351
+ }
352
+ // --- reporting -------------------------------------------------------------------------
353
+ async function statusReport() {
354
+ const bin = resolveHeadroomBinary();
355
+ const version = bin ? await headroomVersion(bin) : null;
356
+ const config = readHeadroomConfig();
357
+ const up = await headroomProxyUp(config.port);
358
+ console.log('\n=== Tersio Headroom ===');
359
+ console.log(` Binary: ${bin ? `${bin}${version ? ` ${version}` : ''}` : `not found — ${HEADROOM_INSTALL_HINT}`}`);
360
+ console.log(` Support: ${config.support ? 'on' : 'off'}`);
361
+ console.log(` Proxy: ${headroomProxyUrl(config.port)} ${up ? '(up)' : '(down)'}`);
362
+ for (const agent of HEADROOM_AGENTS) {
363
+ const state = headroomAgentState(config, agent);
364
+ const kind = headroomCapability(agent);
365
+ const how = state.wrappedByTersio ? 'wrapped by tersio' : kind;
366
+ console.log(` — ${HEADROOM_ADAPTERS[agent].label}: ${state.enabled ? `enabled (${how})` : 'off'}`);
367
+ }
368
+ }
369
+ /** The data doctor renders; it makes no decision of its own. */
370
+ export async function headroomFacts() {
371
+ const bin = resolveHeadroomBinary();
372
+ const version = bin ? await headroomVersion(bin) : null;
373
+ const config = readHeadroomConfig();
374
+ const wiring = {};
375
+ for (const agent of HEADROOM_AGENTS) {
376
+ if (!headroomAgentState(config, agent).enabled)
377
+ continue;
378
+ wiring[agent] = headroomWiringPresent(HEADROOM_ADAPTERS[agent].files());
379
+ }
380
+ return { bin, version, issue: headroomVersionIssue(version), proxyUp: await headroomProxyUp(config.port), config, wiring };
381
+ }
382
+ // --- uninstall hooks -------------------------------------------------------------------
383
+ export async function headroomUnwrapAgents(agents) {
384
+ const config = readHeadroomConfig();
385
+ const bin = resolveHeadroomBinary();
386
+ for (const agent of agents) {
387
+ if (headroomAgentState(config, agent).wrappedByTersio)
388
+ await disableAgent(agent, bin);
389
+ }
390
+ }
391
+ /** Unwraps every agent Tersio wrapped. A manual agent is only unflagged. */
392
+ export async function headroomUnwrapConfigured() {
393
+ await headroomUnwrapAgents([...HEADROOM_AGENTS]);
394
+ }
395
+ /** Removes the Tersio-owned bundle once no agent is left enabled, so uninstalling one host cannot break another agent's proxy. */
396
+ export async function headroomRemoveBundleIfUnused(options = {}) {
397
+ const config = readHeadroomConfig();
398
+ if (HEADROOM_AGENTS.some((agent) => headroomAgentState(config, agent).enabled))
399
+ return false;
400
+ return removeHeadroomBundle(options);
401
+ }
402
+ /** Removes the binary only when the user asks for it. D4: Tersio mirrors `--remove-rtk`. */
403
+ export async function removeHeadroomBinary() {
404
+ const bin = resolveHeadroomBinary();
405
+ if (!bin)
406
+ return true;
407
+ try {
408
+ await fs.rm(bin, { force: true });
409
+ console.log(` [ok] removed ${bin}`);
410
+ return true;
411
+ }
412
+ catch (e) {
413
+ console.log(` [fail] could not remove ${bin}: ${e.message}`);
414
+ return false;
415
+ }
416
+ }
417
+ // --- command ---------------------------------------------------------------------------
418
+ function subcommand() {
419
+ const at = args.indexOf(commandArg ?? '');
420
+ const next = at >= 0 ? args[at + 1] : undefined;
421
+ if (!next || next.startsWith('-'))
422
+ return 'status';
423
+ return next.toLowerCase();
424
+ }
425
+ function usage() {
426
+ console.log(' Usage: tersio headroom <install|enable|disable|status|update> [--agent omp|pi|opencode|--all] [--dry-run]');
427
+ }
428
+ /** Enable or disable with every precheck, for callers that want a boolean instead of an exit code (the dashboard HTTP routes). */
429
+ export async function headroomAction(sub, agent = null) {
430
+ let bin = resolveHeadroomBinary();
431
+ // Only enable needs the binary: disabling a manual flag is a file write, and a wrapped agent still needs its unwrap.
432
+ if (sub === 'enable' && !bin) {
433
+ // The bundle is Tersio's to provide, so enable installs it rather than sending the user to a Python tool.
434
+ console.log(' [info] Headroom is not installed yet — building the Tersio bundle.');
435
+ const installed = await installHeadroom({ dryRun, verbose });
436
+ if (!installed.ok) {
437
+ console.log(` [fail] ${installed.detail}`);
438
+ return false;
439
+ }
440
+ bin = resolveHeadroomBinary();
441
+ if (!bin && !dryRun) {
442
+ console.log(` [fail] Headroom installed, but no entrypoint resolved.`);
443
+ return false;
444
+ }
445
+ }
446
+ const version = bin ? await headroomVersion(bin) : null;
447
+ const issue = headroomVersionIssue(version);
448
+ if (sub === 'enable' && issue === 'old') {
449
+ console.log(` [fail] Headroom ${version} is older than the supported ${HEADROOM_MIN_VERSION}. Update it, then retry.`);
450
+ return false;
451
+ }
452
+ if (sub === 'enable' && issue === 'newer') {
453
+ console.log(` [warn] Headroom ${version} is newer than the tested ${HEADROOM_TESTED_VERSION}. Tersio tests the pinned range only.`);
454
+ }
455
+ const port = readHeadroomConfig().port;
456
+ const targets = agent ? [agent] : selectedAgents();
457
+ let ok = true;
458
+ for (const target of targets) {
459
+ ok = (sub === 'enable' ? await enableAgent(target, bin ?? 'headroom', port) : await disableAgent(target, bin)) && ok;
460
+ }
461
+ return ok;
462
+ }
463
+ /** One entry point for the CLI and the interactive menu: an explicit subcommand, plus an optional single agent. */
464
+ export async function runHeadroomCommand(sub, agent = null) {
465
+ if (!['enable', 'disable', 'status', 'install', 'update'].includes(sub)) {
466
+ console.error(`[fail] Unknown headroom subcommand: ${sub}`);
467
+ usage();
468
+ process.exitCode = 1;
469
+ return;
470
+ }
471
+ if (sub === 'status') {
472
+ await statusReport();
473
+ return;
474
+ }
475
+ if (sub === 'install' || sub === 'update') {
476
+ const result = sub === 'install'
477
+ ? await installHeadroom({ dryRun, verbose })
478
+ : await updateHeadroom({ dryRun, verbose });
479
+ if (result.ok)
480
+ console.log(` [ok] Headroom ${result.version ?? ''} → ${result.bin} (${result.detail})`);
481
+ else {
482
+ console.log(` [fail] ${result.detail}`);
483
+ process.exitCode = 1;
484
+ }
485
+ return;
486
+ }
487
+ if (!(await headroomAction(sub, agent)))
488
+ process.exitCode = 1;
489
+ }
490
+ export async function runHeadroom() {
491
+ await runHeadroomCommand(subcommand(), parseAgentArg());
492
+ }
@@ -13,7 +13,7 @@ import { runReset } from './reset.js';
13
13
  import { runUsage } from './usage.js';
14
14
  import { runDashboard } from './dashboard.js';
15
15
  import { wireRtkOmp, wireRtkOpencode, wireRtkPi } from './rtk-wiring.js';
16
- import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_WENYAN, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, parseChecksum, piAgentDir, readTextIfExists, resolveRtkBinary, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
16
+ import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_MEGACAVE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, findPackageRoot, httpsDownload, parseChecksum, piAgentDir, readTextIfExists, resolveRtkBinary, ruleBodyProblem, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
17
17
  import { formatCliStatus, storedProfile, storedProfileSync, writePluginSettings } from './profile.js';
18
18
  import { runSettings } from './settings.js';
19
19
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
@@ -357,10 +357,11 @@ async function stepRtkSession(extDir, options) {
357
357
  await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
358
358
  }
359
359
  // rule.md ships with the package; the other bodies track upstream skills.
360
+ // rule.md is a deliberate fork of upstream skills/caveman/SKILL.md; a blind sync would drop the mode switch line and the STE100 rules.
360
361
  const CAVEMAN_RULES = [
361
362
  ['rule.md', null],
362
363
  ['rule-ultra.md', CAVEMAN_REMOTE_ULTRA],
363
- ['rule-wenyan.md', CAVEMAN_REMOTE_WENYAN],
364
+ ['rule-megacave.md', CAVEMAN_REMOTE_MEGACAVE],
364
365
  ];
365
366
  async function stepCaveman(extDir, options) {
366
367
  if (!options.quiet)
@@ -373,7 +374,14 @@ async function stepCaveman(extDir, options) {
373
374
  let rule = bundled;
374
375
  if (remote && !options.dryRun) {
375
376
  try {
376
- rule = await withInteractiveSpinner(`Fetching Caveman ${name}`, () => httpsGet(remote));
377
+ const fetched = await withInteractiveSpinner(`Fetching Caveman ${name}`, () => httpsGet(remote));
378
+ const problem = ruleBodyProblem(fetched);
379
+ if (problem && bundled !== null) {
380
+ sayTagged(` [warn] ${name} fetch rejected (${problem}) — keeping the bundled body`);
381
+ rule = bundled;
382
+ }
383
+ else
384
+ rule = fetched;
377
385
  }
378
386
  catch (e) {
379
387
  if (bundled === null)
@@ -392,6 +400,9 @@ async function stepCaveman(extDir, options) {
392
400
  }
393
401
  }
394
402
  await copySources(extDir, filesUnder('caveman-session').filter(([, to]) => to.endsWith('index.ts')), 'caveman-session/index.ts', options);
403
+ // Renamed body: drop the stale copy an older install left behind.
404
+ if (!options.dryRun)
405
+ await fs.rm(path.join(cavemanDir, 'rule-wenyan.md'), { force: true });
395
406
  }
396
407
  async function stepTersioCommands(extDir, options) {
397
408
  if (!options.quiet)
@@ -413,7 +424,7 @@ const PONYTAIL_BUNDLE_FILES = [
413
424
  'skills/ponytail/SKILL.md',
414
425
  ];
415
426
  async function stepPonytailBundle(treeDir, options) {
416
- const root = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)));
427
+ const root = findHoistedPackage('@dietrichgebert/ponytail', findPackageRoot(path.dirname(fileURLToPath(import.meta.url))));
417
428
  if (!root) {
418
429
  if (!options.quiet)
419
430
  console.log(' [skip] Ponytail package not found next to the CLI — OpenCode keeps the built-in text');
@@ -474,7 +485,7 @@ async function stepOpencode(options) {
474
485
  async function resolveProfile(opts = {}) {
475
486
  // Seed from the stored defaults so flag-less runs keep them.
476
487
  const profile = await storedProfile();
477
- // One numbered prompt for all three modes, for a real terminal user with no default flags. Never for --apply-update, and never for a script.
488
+ // The chosen preset owns all three modes.
478
489
  let comboChosen = comboDefaultFlag !== undefined;
479
490
  if (tty() && !profileFlagsGiven && !applyUpdate) {
480
491
  const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
@@ -661,13 +672,14 @@ const PI_PONYTAIL_FILES = [
661
672
  'pi-extension/index.js',
662
673
  'hooks/ponytail-instructions.js',
663
674
  'hooks/ponytail-config.js',
664
- 'skills/ponytail/SKILL.md',
675
+ ...['ponytail', 'ponytail-audit', 'ponytail-debt', 'ponytail-gain', 'ponytail-help', 'ponytail-review']
676
+ .map((skill) => `skills/${skill}/SKILL.md`),
665
677
  ];
666
678
  const PI_PONYTAIL_PACKAGE = `${JSON.stringify({ name: 'ponytail', version: '0.0.0', private: true, pi: { extensions: ['./pi-extension/index.js'], skills: ['./skills'] } }, null, 2)}\n`;
667
679
  async function stepPonytailForPi(agentDir, options) {
668
680
  if (!options.quiet)
669
681
  console.log(' Ponytail — write the local pi package');
670
- const root = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)));
682
+ const root = findHoistedPackage('@dietrichgebert/ponytail', findPackageRoot(path.dirname(fileURLToPath(import.meta.url))));
671
683
  const dest = path.join(agentDir, 'ponytail');
672
684
  if (!root) {
673
685
  console.log(' [skip] Ponytail package not found next to the CLI');
@@ -1,7 +1,8 @@
1
1
  // Single list of install writes, shared by installer, doctor, and tests.
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- export const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
4
+ import { findPackageRoot } from '../extensions/lib/utils.js';
5
+ export const EXT_DIR = path.join(findPackageRoot(path.dirname(fileURLToPath(import.meta.url))), 'extensions');
5
6
  /** Root entrypoint OpenCode resolves for the installed tree; re-exports the versioned entry beside it. Built by join so the path never reads as a relative import (repo-integrity scans literals). */
6
7
  const SERVER_SPEC = `./${['opencode', 'server.ts'].join('/')}`;
7
8
  export const OPENCODE_SERVER_SHIM = `// Generated by \`tersio install --host opencode\`: OpenCode resolves <pluginDir>/server.ts.
@@ -21,7 +22,7 @@ export const TREE_FILES = [
21
22
  'caveman-session/index.ts',
22
23
  'caveman-session/rule.md',
23
24
  'caveman-session/rule-ultra.md',
24
- 'caveman-session/rule-wenyan.md',
25
+ 'caveman-session/rule-megacave.md',
25
26
  'rtk-session/index.ts',
26
27
  'combo-toggle/index.ts',
27
28
  'tersio-commands/index.ts',
@@ -1,6 +1,6 @@
1
1
  // cli/settings.ts — view/edit session-start defaults and the display currency.
2
- import path from 'node:path';
3
- import { CAVEMAN_DEFAULTS, COMBO_DEFAULTS, COMBO_PRESET_MODES, OMP_PLUGINS_DIR, PACKAGE_NAME, PONYTAIL_DEFAULTS, cavemanDefaultFlag, comboDefaultFlag, currency, currencyGiven, diagScheduleFlag, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, settingArg, subagentMarkerFlags, subagentMarkersGiven, } from './common.js';
2
+ import { CAVEMAN_DEFAULTS, COMBO_DEFAULTS, COMBO_PRESET_MODES, PONYTAIL_DEFAULTS, cavemanDefaultFlag, comboDefaultFlag, currency, currencyGiven, diagScheduleFlag, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, settingArg, subagentMarkerFlags, subagentMarkersGiven, } from './common.js';
3
+ import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
4
4
  import { CURRENCY_CODES } from './currency.js';
5
5
  import { textTable } from './usage.js';
6
6
  import { ask, askInteractiveChoice, closeRL, tty } from './interactive.js';
@@ -45,25 +45,6 @@ async function askProfile(current) {
45
45
  backupSchedule: current.backupSchedule,
46
46
  subagentMarkers: current.subagentMarkers,
47
47
  };
48
- const caveman = await askInteractiveChoice('Caveman default', [...CAVEMAN_DEFAULTS].map((v) => ({
49
- value: v, label: v,
50
- })), next.cavemanDefault);
51
- if (caveman.status !== 'selected')
52
- return null;
53
- next.cavemanDefault = caveman.value;
54
- const rtk = await askInteractiveChoice('RTK default', [
55
- { value: 'on', label: 'on' },
56
- { value: 'off', label: 'off' },
57
- ], next.rtkDefault ? 'on' : 'off');
58
- if (rtk.status !== 'selected')
59
- return null;
60
- next.rtkDefault = rtk.value === 'on';
61
- const ponytail = await askInteractiveChoice('Ponytail default', [...PONYTAIL_DEFAULTS].map((v) => ({
62
- value: v, label: v,
63
- })), next.ponytailDefault);
64
- if (ponytail.status !== 'selected')
65
- return null;
66
- next.ponytailDefault = ponytail.value;
67
48
  const cur = await askInteractiveChoice('Display currency (usage/dashboard reports)', CURRENCY_CODES.map((v) => ({
68
49
  value: v, label: v,
69
50
  })), next.currency);
@@ -103,7 +84,7 @@ async function askDiagSchedule(current) {
103
84
  }
104
85
  const SETTING_NAMES = ['combo', 'caveman', 'rtk', 'ponytail', 'currency', 'diagnosis', 'markers'];
105
86
  function settingsUsage() {
106
- console.log(' Usage: tersio settings [combo|caveman|rtk|ponytail|currency|diagnosis|markers] [--combo-default off|medium|balanced|max] [--caveman-default off|lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra] [--rtk-default on|off] [--ponytail-default off|lite|full|ultra] [--currency USD|PHP|EUR|GBP|JPY|KRW|SGD|AUD|CAD|INR] [--diag-schedule manual|daily|weekly|monthly] [--subagent-marker <text>]... [--dry-run]');
87
+ console.log(' Usage: tersio settings [combo|caveman|rtk|ponytail|currency|diagnosis|markers] [--combo-default off|medium|balanced|max] [--caveman-default off|lite|full|ultra|megacave-lite|megacave-full|megacave-ultra] [--rtk-default on|off] [--ponytail-default off|lite|full|ultra] [--currency USD|PHP|EUR|GBP|JPY|KRW|SGD|AUD|CAD|INR] [--diag-schedule manual|daily|weekly|monthly] [--subagent-marker <text>]... [--dry-run]');
107
88
  }
108
89
  // Single-setting jump: `tersio settings diagnosis` prompts only that value instead of walking the whole chain. Returns false when aborted.
109
90
  async function runSingleSetting(name, current, nextDiag) {
@@ -198,7 +179,8 @@ function printSettingsTable(current) {
198
179
  for (const l of textTable(['Setting', 'Current', 'Valid values'], rows, [false, false, false], 64)) {
199
180
  console.log(l);
200
181
  }
201
- console.log(` Stored: ${PACKAGE_NAME} in ${path.join(OMP_PLUGINS_DIR, 'omp-plugins.lock.json')}`);
182
+ console.log(` Stored: ${tersioSettingsFile()}`);
183
+ console.log(` Combo ${current.comboDefault} sets caveman/rtk/ponytail. Tune one: tersio settings <name>`);
202
184
  }
203
185
  async function runSettings() {
204
186
  const current = await storedProfile();
@@ -290,7 +290,7 @@ async function runUninstall(options = {}) {
290
290
  }
291
291
  // Cleared last: writeConfigLines above would just make a fresh one.
292
292
  const cavemanDir = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'extensions', 'caveman-session');
293
- for (const backup of [`${configPath}.bak`, path.join(cavemanDir, 'rule.md.bak'), path.join(cavemanDir, 'rule-ultra.md.bak'), path.join(cavemanDir, 'rule-wenyan.md.bak')]) {
293
+ for (const backup of [`${configPath}.bak`, path.join(cavemanDir, 'rule.md.bak'), path.join(cavemanDir, 'rule-ultra.md.bak'), path.join(cavemanDir, 'rule-megacave.md.bak')]) {
294
294
  await removeUninstallTarget(backup, shouldDryRun, false);
295
295
  }
296
296
  // Remove the bundled Ponytail copy; its config.yml entry is already filtered.