@krtclcdy/tersio 2.20.0 → 2.21.0

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.
@@ -0,0 +1,331 @@
1
+ // OMP extension: /ai-addons manual updater for Ponytail, RTK, Caveman.
2
+ // Built-in Node modules only. Default off; registers a single slash command.
3
+ // ponytail: `skipped: none` — semantics match one-liner: fetch + compare + run install.
4
+ // rtk: `skipped: signature verification` — checksums.txt ships only SHA256 of release assets; add sigchain when upstream publishes a signing key.
5
+ // caveman: `skipped: none` — exactly the ask: write rule.md, report old/new hash.
6
+ import { execFileSync } from 'node:child_process';
7
+ import { createHash } from 'node:crypto';
8
+ import fs from 'node:fs/promises';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { CAVEMAN_REMOTE_RULE as CAVEMAN_REMOTE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, sha256Hex, parseChecksum, normalizeRtkVersion, readTextIfExists, rtkPlatformSpec, } from '../lib/utils.js';
12
+ const IS_WINDOWS = process.platform === 'win32';
13
+ const HOME = os.homedir();
14
+ const PONYTAIL_REMOTE = 'https://raw.githubusercontent.com/DietrichGebert/ponytail/main/package.json';
15
+ const PONYTAIL_NPM_SPEC = '@dietrichgebert/ponytail@latest';
16
+ const PONYTAIL_LOCAL = path.join(HOME, '.omp', 'plugins', 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
17
+ const RTK_BINARY = path.join(HOME, '.bun', 'bin', IS_WINDOWS ? 'rtk.exe' : 'rtk');
18
+ const CAVEMAN_LOCAL = path.join(HOME, '.omp', 'agent', 'extensions', 'caveman-session', 'rule.md');
19
+ const RELOAD_MSG = 'Reminder: restart OMP (or reload extensions) for updates to take effect.';
20
+ function notify(ctx, msg, level) {
21
+ ctx?.ui?.notify?.(String(msg), level);
22
+ }
23
+ function report(ctx, msg, level) {
24
+ notify(ctx, msg, level);
25
+ return msg;
26
+ }
27
+ function shortHash(text) {
28
+ return sha256Hex(text).slice(0, 16);
29
+ }
30
+ function checkFailed(name, e) {
31
+ return { text: `${name} check failed: ${e.message}`, level: 'warning' };
32
+ }
33
+ // Single error-handling source for the three probes; messages unchanged.
34
+ function runCheck(name, probe) {
35
+ return probe().then((text) => ({ text, level: 'info' }), (e) => checkFailed(name, e));
36
+ }
37
+ // Check: no mutation. Probes run concurrently via checkAddons below.
38
+ function checkPonytail() {
39
+ return runCheck('Ponytail', async () => {
40
+ const remoteJson = await fetchJson(PONYTAIL_REMOTE);
41
+ const localRaw = await readTextIfExists(PONYTAIL_LOCAL);
42
+ const localVer = localRaw ? JSON.parse(localRaw).version ?? null : null;
43
+ const remoteVer = remoteJson.version;
44
+ const status = !localVer ? 'not installed'
45
+ : localVer === remoteVer ? 'up to date'
46
+ : 'update available';
47
+ return `Ponytail ${status}: local=${localVer || '—'} latest=${remoteVer}`;
48
+ });
49
+ }
50
+ async function fetchRelease() {
51
+ return fetchJson(RTK_RELEASE_API);
52
+ }
53
+ function checkRtk() {
54
+ return runCheck('RTK', async () => {
55
+ const release = await fetchRelease();
56
+ const latestTag = release.tag_name || null;
57
+ let localVer = null;
58
+ try {
59
+ const out = execFileSync(RTK_BINARY, ['--version'], { encoding: 'utf8', windowsHide: true, shell: false, timeout: 10000 }) || '';
60
+ if (out)
61
+ localVer = out.trim().split(/\r?\n/)[0];
62
+ }
63
+ catch {
64
+ localVer = null;
65
+ }
66
+ const status = localVer === null ? 'not installed'
67
+ : normalizeRtkVersion(localVer) === normalizeRtkVersion(latestTag ?? undefined) ? 'up to date'
68
+ : 'update available';
69
+ return `RTK ${status}: local=${localVer || '—'} latest=${latestTag || '—'}`;
70
+ });
71
+ }
72
+ // Caveman (rule.md)
73
+ function checkCaveman() {
74
+ return runCheck('Caveman', async () => {
75
+ const remote = await httpsGet(CAVEMAN_REMOTE);
76
+ const remoteHash = shortHash(remote);
77
+ const local = await readTextIfExists(CAVEMAN_LOCAL);
78
+ const localHash = local ? shortHash(local) : null;
79
+ const status = !local ? 'rule.md missing'
80
+ : localHash === remoteHash ? 'rule.md up to date'
81
+ : 'rule.md update available';
82
+ return `Caveman ${status}: local=${localHash || '—'} remote=${remoteHash}`;
83
+ });
84
+ }
85
+ export async function checkAddonsSummary(ctx) {
86
+ return checkAddons(ctx);
87
+ }
88
+ export async function runAddonUpdate(pi, ctx, target, dryRun = false) {
89
+ const results = [];
90
+ const updaters = {
91
+ ponytail: () => updatePonytail(pi, ctx, dryRun),
92
+ rtk: () => updateRtk(ctx, dryRun),
93
+ caveman: () => updateCaveman(ctx, dryRun),
94
+ };
95
+ if (target === 'all') {
96
+ notify(ctx, `ai-addons update all${dryRun ? ' dry-run' : ''}: starting ponytail → rtk → caveman sequentially…`, 'info');
97
+ for (const name of ['ponytail', 'rtk', 'caveman'])
98
+ results.push(await updaters[name]());
99
+ if (!dryRun)
100
+ results.push(RELOAD_MSG);
101
+ notify(ctx, `ai-addons update all ${dryRun ? 'dry-run ' : ''}complete.${dryRun ? '' : ` ${RELOAD_MSG}`}`, 'info');
102
+ }
103
+ else if (Object.hasOwn(updaters, target)) {
104
+ results.push(await updaters[target]());
105
+ }
106
+ else {
107
+ const m = 'Usage: /ai-addons update <ponytail|rtk|caveman|all> [--dry-run]';
108
+ return report(ctx, m, 'warning');
109
+ }
110
+ return results.join('\n\n');
111
+ }
112
+ async function checkAddons(ctx) {
113
+ const [ponytail, rtk, caveman] = await Promise.all([checkPonytail(), checkRtk(), checkCaveman()]);
114
+ const lines = [];
115
+ for (const status of [ponytail, rtk, caveman]) {
116
+ lines.push(status.text);
117
+ notify(ctx, status.text, status.level);
118
+ }
119
+ return lines.join('\n');
120
+ }
121
+ async function updatePonytail(pi, ctx, dryRun = false) {
122
+ const pluginsDir = path.join(HOME, '.omp', 'plugins');
123
+ if (dryRun) {
124
+ const m = `Ponytail dry-run: would run \`npm install ${PONYTAIL_NPM_SPEC} --save --no-audit --no-fund\` in ${pluginsDir}.`;
125
+ return report(ctx, m, 'info');
126
+ }
127
+ notify(ctx, 'Ponytail: ensuring plugin directory exists…', 'info');
128
+ try {
129
+ await fs.mkdir(pluginsDir, { recursive: true });
130
+ }
131
+ catch (e) {
132
+ const m = `Ponytail update failed: failed to create ${pluginsDir}: ${e.message}`;
133
+ return report(ctx, m, 'warning');
134
+ }
135
+ if (!pi.exec) {
136
+ const m = 'Ponytail update failed: extension host does not provide exec';
137
+ return report(ctx, m, 'warning');
138
+ }
139
+ notify(ctx, 'Ponytail: running npm install…', 'info');
140
+ let out = '';
141
+ try {
142
+ const r = await pi.exec('npm', ['install', PONYTAIL_NPM_SPEC, '--save', '--no-audit', '--no-fund'], { cwd: pluginsDir });
143
+ out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
144
+ if (r.code !== 0)
145
+ throw new Error(r.stderr || `npm exited ${r.code}`);
146
+ }
147
+ catch (e) {
148
+ const m = `Ponytail update failed: ${e.message}`;
149
+ return report(ctx, m, 'warning');
150
+ }
151
+ const m = `Ponytail update finished.${out ? `\n${out}` : ''}\n${RELOAD_MSG}`;
152
+ notify(ctx, 'Ponytail update finished. ' + RELOAD_MSG, 'info');
153
+ return m;
154
+ }
155
+ async function updateRtk(ctx, dryRun = false) {
156
+ let release;
157
+ try {
158
+ release = await fetchRelease();
159
+ }
160
+ catch (e) {
161
+ const m = `RTK: cannot fetch release info: ${e.message}`;
162
+ return report(ctx, m, 'warning');
163
+ }
164
+ const tag = release.tag_name || 'unknown';
165
+ const assets = Array.isArray(release.assets) ? release.assets : [];
166
+ // Cross-platform asset selection (mirrors installer stepRtk)
167
+ const PLATFORM = process.platform;
168
+ const ARCH = process.arch;
169
+ const spec = rtkPlatformSpec(PLATFORM, ARCH);
170
+ if (!spec) {
171
+ const m = `RTK: unsupported platform ${PLATFORM}/${ARCH}`;
172
+ return report(ctx, m, 'warning');
173
+ }
174
+ const { triple: assetTriple, ext: assetExt, binary: binaryName } = spec;
175
+ const asset = assets.find((a) => a.name === `rtk-${assetTriple}${assetExt}`);
176
+ const checksAsset = assets.find((a) => a.name === 'checksums.txt');
177
+ if (!asset || !checksAsset) {
178
+ const m = `RTK: required assets not found in release ${tag} (need rtk-${assetTriple}${assetExt} and checksums.txt)`;
179
+ return report(ctx, m, 'warning');
180
+ }
181
+ if (dryRun) {
182
+ const m = `RTK dry-run: would download ${asset.name} (${tag}), verify checksums.txt, and replace ${RTK_BINARY}.`;
183
+ return report(ctx, m, 'info');
184
+ }
185
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'rtk-update-'));
186
+ const archivePath = path.join(tmp, asset.name);
187
+ const checksPath = path.join(tmp, 'checksums.txt');
188
+ try {
189
+ notify(ctx, `RTK: downloading ${asset.name} (${tag})…`, 'info');
190
+ notify(ctx, 'RTK: downloading checksums.txt…', 'info');
191
+ await Promise.all([
192
+ httpsDownload(asset.browser_download_url, archivePath),
193
+ httpsDownload(checksAsset.browser_download_url, checksPath),
194
+ ]);
195
+ // Verify SHA256 against checksums.txt
196
+ const checks = await fs.readFile(checksPath, 'utf8');
197
+ const expected = parseChecksum(checks, asset.name);
198
+ if (!expected) {
199
+ const m = `RTK: checksums.txt has no entry for ${asset.name}`;
200
+ return report(ctx, m, 'warning');
201
+ }
202
+ const archiveBuf = await fs.readFile(archivePath);
203
+ const actual = createHash('sha256').update(archiveBuf).digest('hex').toLowerCase();
204
+ if (actual !== expected) {
205
+ const m = `RTK: checksum mismatch! expected=${expected.slice(0, 12)}… actual=${actual.slice(0, 12)}…`;
206
+ return report(ctx, m, 'warning');
207
+ }
208
+ notify(ctx, 'RTK: checksum verified.', 'info');
209
+ // Extract by archive format
210
+ const extractDir = path.join(tmp, 'extracted');
211
+ await fs.mkdir(extractDir, { recursive: true });
212
+ if (asset.name.endsWith('.zip')) {
213
+ if (IS_WINDOWS) {
214
+ execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
215
+ `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${extractDir}' -Force`], { encoding: 'utf8', windowsHide: true, shell: false });
216
+ }
217
+ else {
218
+ execFileSync('unzip', [archivePath, '-d', extractDir], { encoding: 'utf8', shell: false });
219
+ }
220
+ }
221
+ else if (asset.name.endsWith('.tar.gz') || asset.name.endsWith('.tgz')) {
222
+ try {
223
+ execFileSync('tar', ['xzf', archivePath, '-C', extractDir], { encoding: 'utf8', shell: false });
224
+ }
225
+ catch (e) {
226
+ throw new Error(`tar could not extract ${asset.name}: ${e.message}`);
227
+ }
228
+ }
229
+ else {
230
+ throw new Error(`Unknown archive format: ${asset.name}`);
231
+ }
232
+ const rtkExtracted = await findFile(extractDir, binaryName);
233
+ if (!rtkExtracted) {
234
+ const m = `RTK: ${binaryName} not found in extracted archive`;
235
+ return report(ctx, m, 'warning');
236
+ }
237
+ await fs.mkdir(path.dirname(RTK_BINARY), { recursive: true });
238
+ const backupPath = `${RTK_BINARY}.bak`;
239
+ const backedUp = await fs.copyFile(RTK_BINARY, backupPath).then(() => true, () => false);
240
+ await fs.copyFile(rtkExtracted, RTK_BINARY);
241
+ // Set executable bit on Unix
242
+ if (!IS_WINDOWS) {
243
+ await fs.chmod(RTK_BINARY, 0o755);
244
+ }
245
+ let versionOut = '';
246
+ try {
247
+ versionOut = execFileSync(RTK_BINARY, ['--version'], { encoding: 'utf8', windowsHide: true, shell: false, timeout: 10000 }).trim();
248
+ }
249
+ catch (e) {
250
+ if (backedUp)
251
+ await fs.copyFile(backupPath, RTK_BINARY);
252
+ throw new Error(`new ${binaryName} failed --version${backedUp ? '; restored backup' : ''}: ${e.message}`);
253
+ }
254
+ if (normalizeRtkVersion(versionOut) !== normalizeRtkVersion(tag)) {
255
+ if (backedUp)
256
+ await fs.copyFile(backupPath, RTK_BINARY);
257
+ throw new Error(`new ${binaryName} reports ${versionOut}, expected ${tag}${backedUp ? '; restored backup' : ''}`);
258
+ }
259
+ const m = `RTK updated to ${tag} → ${RTK_BINARY}\nbackup=${backedUp ? backupPath : '—'}\n${RELOAD_MSG}`;
260
+ notify(ctx, 'RTK update finished. ' + RELOAD_MSG, 'info');
261
+ return m;
262
+ }
263
+ catch (e) {
264
+ const m = `RTK update failed: ${e.message}`;
265
+ return report(ctx, m, 'warning');
266
+ }
267
+ finally {
268
+ fs.rm(tmp, { recursive: true, force: true }).catch(() => { });
269
+ }
270
+ }
271
+ async function updateCaveman(ctx, dryRun = false) {
272
+ let remote;
273
+ try {
274
+ remote = await httpsGet(CAVEMAN_REMOTE);
275
+ }
276
+ catch (e) {
277
+ return report(ctx, `Caveman update failed: ${e.message}`, 'warning');
278
+ }
279
+ const remoteHash = shortHash(remote);
280
+ const oldLocal = await readTextIfExists(CAVEMAN_LOCAL);
281
+ const oldHash = oldLocal ? shortHash(oldLocal) : null;
282
+ if (dryRun) {
283
+ const m = `Caveman dry-run: would write ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}.`;
284
+ return report(ctx, m, 'info');
285
+ }
286
+ try {
287
+ await fs.mkdir(path.dirname(CAVEMAN_LOCAL), { recursive: true });
288
+ const backupPath = `${CAVEMAN_LOCAL}.bak`;
289
+ if (oldLocal !== null)
290
+ await fs.writeFile(backupPath, oldLocal, 'utf8');
291
+ await fs.writeFile(CAVEMAN_LOCAL, remote, 'utf8');
292
+ const written = await fs.readFile(CAVEMAN_LOCAL, 'utf8');
293
+ const writtenHash = shortHash(written);
294
+ if (writtenHash !== remoteHash) {
295
+ if (oldLocal !== null)
296
+ await fs.writeFile(CAVEMAN_LOCAL, oldLocal, 'utf8');
297
+ throw new Error(`written hash ${writtenHash} did not match remote ${remoteHash}${oldLocal !== null ? '; restored backup' : ''}`);
298
+ }
299
+ const m = `Caveman rule.md updated → ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}\nbackup=${oldLocal !== null ? backupPath : '—'}\n${RELOAD_MSG}`;
300
+ notify(ctx, 'Caveman rule.md updated. ' + RELOAD_MSG, 'info');
301
+ return m;
302
+ }
303
+ catch (e) {
304
+ const m = `Caveman update failed: ${e.message}`;
305
+ return report(ctx, m, 'warning');
306
+ }
307
+ }
308
+ export default function aiAddonsUpdaterExtension(pi) {
309
+ pi.setLabel?.('AI add-ons updater');
310
+ pi.registerCommand?.('ai-addons', {
311
+ description: 'Check or update AI add-ons (ponytail/rtk/caveman/all). Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]',
312
+ handler: async (args, ctx) => {
313
+ const arg = String(args || '').trim().toLowerCase();
314
+ const parts = arg.split(/\s+/).filter(Boolean);
315
+ const dryRun = parts.includes('--dry-run') || parts.includes('dry-run');
316
+ const cleanParts = parts.filter((p) => p !== '--dry-run' && p !== 'dry-run');
317
+ const sub = cleanParts[0];
318
+ if (sub === 'check' || sub === 'status') {
319
+ const summary = await checkAddons(ctx);
320
+ notify(ctx, 'ai-addons check complete.', 'info');
321
+ return summary;
322
+ }
323
+ if (sub === 'update' && cleanParts[1]) {
324
+ return runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
325
+ }
326
+ const m = 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]';
327
+ return report(ctx, m, 'warning');
328
+ },
329
+ });
330
+ }
331
+ export { parseChecksum };
@@ -0,0 +1,152 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
4
+ import { dirname, join } from 'node:path';
5
+ import { readCavemanDefault } from '../shared/plugin-settings.js';
6
+ const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
7
+ const RULE_PATH = join(CAVERN_DIR, 'rule.md');
8
+ const FALLBACK_FULL_RULE = `Respond terse like smart caveman. All technical substance stay. Only fluff die.
9
+
10
+ Rules:
11
+ - Drop articles (a/an/the), filler (just/really/basically), pleasantries, hedging.
12
+ - Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
13
+ - Pattern: [thing] [action] [reason]. [next step].
14
+ - Not: 'Sure! I'd be happy to help you with that.'
15
+ - Yes: 'Bug in auth middleware. Fix:'
16
+
17
+ Auto-clarity: drop caveman for security warnings, irreversible actions, or when user seems confused. Resume after.`;
18
+ // ponytail: synchronous read each full-mode injection; ceiling = small file, cold session start. Upgrade path: cache file contents + mtime, invalidate on change.
19
+ function readFullRule() {
20
+ try {
21
+ return readFileSync(RULE_PATH, 'utf8');
22
+ }
23
+ catch {
24
+ return FALLBACK_FULL_RULE;
25
+ }
26
+ }
27
+ const DEFAULT_MODE = 'off';
28
+ const INSTRUCTIONS = {
29
+ lite: `Caveman lite active for this session.
30
+ Respond concise. Drop pleasantries, filler, and hedging. Keep complete technical substance. Code, commands, paths, errors, commits, and PR text stay normal/exact.`,
31
+ full: () => `Caveman full active for this session.\n${readFullRule()}`,
32
+ ultra: `Caveman ultra active for this session.
33
+ Maximum terse prose. Fragments preferred. No pleasantries, no tour, no recap unless needed. Keep all technical substance exact. Code, commands, commits, PR text, paths, and errors stay normal/exact. Drop caveman for security warnings, irreversible actions, or user confusion.`,
34
+ wenyan: `Caveman wenyan active for this session.
35
+ Use ultra-terse classical-Chinese-style prose only where it preserves clarity for the user. Keep technical terms, code, commands, commits, PR text, paths, and errors exact. If clarity would suffer, use caveman full instead.`,
36
+ };
37
+ function resolveMode(entries, fallback = DEFAULT_MODE) {
38
+ return lastCustomValue(entries, 'caveman-mode', (data) => normalizeMode('caveman', data?.mode)) ?? fallback;
39
+ }
40
+ function isOffCommand(text) {
41
+ const t = normalizeInputCommand(text);
42
+ return t === 'stop caveman' || t === 'normal mode' || t === 'caveman off';
43
+ }
44
+ export default function cavemanSessionExtension(pi) {
45
+ let currentMode = DEFAULT_MODE;
46
+ let isActive = false;
47
+ let lastCtx = undefined;
48
+ function syncStatus(ctx) {
49
+ lastCtx = paintableCtx(lastCtx, ctx);
50
+ const c = lastCtx;
51
+ if (!c?.ui?.setStatus)
52
+ return;
53
+ // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
54
+ if (isComboPresetActive() || currentMode === 'off') {
55
+ c.ui.setStatus('caveman', undefined);
56
+ return;
57
+ }
58
+ paintStatusBar(c.ui, 'caveman', '🪨', `caveman: ${currentMode.toUpperCase()}`, isActive);
59
+ }
60
+ function setMode(mode, ctx) {
61
+ const normalized = normalizeMode('caveman', mode);
62
+ if (!normalized)
63
+ return false;
64
+ currentMode = normalized;
65
+ pi.appendEntry?.('caveman-mode', { mode: normalized });
66
+ setSharedComboMode('caveman', normalized);
67
+ syncStatus(ctx);
68
+ const active = activeModesSummary(getSharedComboState());
69
+ const msg = normalized === 'off'
70
+ ? `Caveman off. Active: ${active}.`
71
+ : `Caveman ${normalized} on — terse replies for this session. Active: ${active}.`;
72
+ ctx?.ui?.notify?.(msg, 'info');
73
+ return true;
74
+ }
75
+ pi.setLabel?.('Caveman session toggle');
76
+ // Live mirror: a /tersio or /combo switch publishes shared state — adopt
77
+ // it at once so the next turn injects the new mode with no session reload.
78
+ // Stable identity, so the bridge set dedupes across re-inits.
79
+ function syncFromShared(state) {
80
+ const mode = normalizeMode('caveman', state.caveman);
81
+ if (mode) {
82
+ currentMode = mode;
83
+ syncStatus();
84
+ }
85
+ }
86
+ setSharedComboListener(syncFromShared);
87
+ pi.registerCommand?.('caveman', {
88
+ description: 'Toggle terse caveman replies for this session',
89
+ handler: async (args, ctx) => {
90
+ const arg = String(args || '').trim().toLowerCase();
91
+ if (!arg || arg === 'on') {
92
+ setMode('full', ctx);
93
+ return;
94
+ }
95
+ if (arg === 'status') {
96
+ ctx?.ui?.notify?.(`Caveman: ${currentMode}`, 'info');
97
+ return;
98
+ }
99
+ if (!setMode(arg, ctx)) {
100
+ ctx?.ui?.notify?.('Usage: /caveman [lite|full|ultra|wenyan|off|status]', 'warning');
101
+ }
102
+ },
103
+ });
104
+ pi.on('input', async (event) => {
105
+ if (event?.source === 'extension')
106
+ return;
107
+ if (currentMode !== 'off' && isOffCommand(event?.text))
108
+ setMode('off');
109
+ });
110
+ function restoreMode(ctx) {
111
+ const entries = sessionEntries(ctx);
112
+ // Publish the persisted combo state (incl. combo-level) before painting:
113
+ // bar suppression reads the in-process bridge, which is empty in a fresh
114
+ // host until the combo extension reconciles — and its reconcile is
115
+ // UI-gated. Deriving it here makes suppression independent of load order.
116
+ reconcileSharedComboEntries(entries);
117
+ // Persisted session state wins; a fresh session falls back to the
118
+ // installer/user-configured default (off unless configured).
119
+ const persisted = resolveMode(entries, '');
120
+ currentMode = persisted || readCavemanDefault();
121
+ syncStatus(ctx);
122
+ }
123
+ pi.on('session_start', async (_event, ctx) => {
124
+ restoreMode(ctx);
125
+ ctx?.ui?.notify?.(`Caveman loaded: ${currentMode}`, 'info');
126
+ });
127
+ pi.on('session_branch', async (_event, ctx) => {
128
+ restoreMode(ctx);
129
+ });
130
+ pi.on('session_tree', async (_event, ctx) => {
131
+ restoreMode(ctx);
132
+ });
133
+ pi.on('agent_start', async (_event, ctx) => {
134
+ isActive = true;
135
+ syncStatus(ctx);
136
+ });
137
+ pi.on('agent_end', async (_event, ctx) => {
138
+ isActive = false;
139
+ syncStatus(ctx);
140
+ });
141
+ pi.on('before_agent_start', async (event) => {
142
+ const mode = isOmpSubagentPrompt(event.systemPrompt) ? getSharedComboState().caveman : currentMode;
143
+ if (!mode || mode === 'off')
144
+ return;
145
+ const def = INSTRUCTIONS[mode];
146
+ if (!def)
147
+ return;
148
+ const instruction = typeof def === 'function' ? def() : def;
149
+ const base = asPromptArray(event.systemPrompt);
150
+ return { systemPrompt: [...base, instruction] };
151
+ });
152
+ }
@@ -0,0 +1,165 @@
1
+ // /combo session toggle — set all three (caveman, rtk, ponytail) at once.
2
+ // Modes: off | medium | balanced | max
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { createRequire } from 'node:module';
6
+ import { activeModesSummary, asPromptArray, COMBO_LEVELS, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, normalizeComboLevel, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboListener, setSharedComboMode, systemPromptIncludes, } from '../shared/session-state.js';
7
+ import { readComboDefault, readPonytailDefault } from '../shared/plugin-settings.js';
8
+ const require = createRequire(import.meta.url);
9
+ const PONYTAIL_FALLBACK_INTENSITY = {
10
+ lite: 'Prefer the simplest correct solution.',
11
+ review: 'Review only for avoidable complexity; recommend the smallest correct replacement.',
12
+ };
13
+ function ponytailFallback(mode) {
14
+ const intensity = PONYTAIL_FALLBACK_INTENSITY[mode] ?? 'Use the minimum correct solution. Delete or reuse before adding.';
15
+ return `🦥 PONYTAIL MODE ACTIVE — level: ${mode}\n${intensity} Understand the path first and fix root causes, not symptoms. Prefer the standard library and YAGNI. Avoid speculative abstractions and dependencies. Preserve correctness. Verify changed behavior.`;
16
+ }
17
+ function levelSummary(state) {
18
+ return `caveman=${state.caveman} rtk=${state.rtk} ponytail=${state.ponytail}`;
19
+ }
20
+ function loadPonytailInstructions(mode) {
21
+ try {
22
+ const installed = path.join(os.homedir(), '.omp', 'plugins', 'node_modules', '@dietrichgebert', 'ponytail', 'hooks', 'ponytail-instructions.js');
23
+ const { getPonytailInstructions } = require(installed);
24
+ if (typeof getPonytailInstructions === 'function')
25
+ return getPonytailInstructions(mode);
26
+ }
27
+ catch { }
28
+ return ponytailFallback(mode);
29
+ }
30
+ export default function comboToggleExtension(pi) {
31
+ pi.setLabel?.('Combo session toggle (all 3 add-ons)');
32
+ let lastCtx = undefined;
33
+ function syncStatus(ctx) {
34
+ lastCtx = paintableCtx(lastCtx, ctx);
35
+ const c = lastCtx;
36
+ if (!c?.ui?.setStatus)
37
+ return;
38
+ // Single unified bar replaces the three per-extension bars while a preset is
39
+ // active. In custom/off, the individual bars come back and combo stays clear.
40
+ if (!isComboPresetActive()) {
41
+ c.ui.setStatus('combo', undefined);
42
+ return;
43
+ }
44
+ // Read the live bridge, not cached extension state: sibling extensions
45
+ // reconcile it from persisted entries before this one runs.
46
+ const state = getSharedComboState();
47
+ const theme = c.ui.theme;
48
+ const c0 = state.caveman.toUpperCase();
49
+ const r = state.rtk.toUpperCase();
50
+ const p = state.ponytail.toUpperCase();
51
+ const lvl = state.level.toUpperCase();
52
+ const label = `combo ${lvl}: 🪨caveman=${c0} ⚡rtk=${r} 🦥ponytail=${p}`;
53
+ c.ui.setStatus('combo', theme?.fg ? `${theme.fg('accent', '🧩')} ${theme.fg('muted', label)}` : `🧩 ${label}`);
54
+ // Clobber any sibling bars another extension may have painted in a race
55
+ // during session_start — our bar is canonical for the duration of the preset.
56
+ c.ui.setStatus('caveman', undefined);
57
+ c.ui.setStatus('rtk', undefined);
58
+ c.ui.setStatus('ponytail', undefined);
59
+ }
60
+ // ponytail: same persistence as /combo — siblings (incl. upstream ponytail)
61
+ // restore from these entries, so the fallback must write them too or the
62
+ // preset evaporates on resume and ponytail never activates.
63
+ function persistPreset(level) {
64
+ const modes = COMBO_LEVELS[level];
65
+ pi.appendEntry?.('caveman-mode', { mode: modes.caveman });
66
+ pi.appendEntry?.('rtk-mode', { enabled: modes.rtk === 'on' });
67
+ pi.appendEntry?.('ponytail-mode', { mode: modes.ponytail });
68
+ pi.appendEntry?.('combo-level', { level });
69
+ }
70
+ function useState(state, ctx) {
71
+ syncStatus(ctx);
72
+ return state;
73
+ }
74
+ function reconcile(ctx) {
75
+ if (!ctx?.hasUI)
76
+ return getSharedComboState();
77
+ return useState(reconcileSharedComboEntries(sessionEntries(ctx)), ctx);
78
+ }
79
+ function listen(ctx) {
80
+ // Stable identity: the bridge set dedupes, so repeated track()/command
81
+ // calls register once instead of stacking duplicate listeners.
82
+ if (ctx?.hasUI)
83
+ setSharedComboListener(useState);
84
+ }
85
+ function track(ctx) {
86
+ listen(ctx);
87
+ if (ctx?.hasUI)
88
+ reconcile(ctx);
89
+ }
90
+ pi.registerCommand?.('combo', {
91
+ description: 'Toggle all 3 OMP add-ons at once. Usage: /combo <off|medium|balanced|max|status>',
92
+ handler: async (args, ctx) => {
93
+ listen(ctx);
94
+ const arg = String(args || '').trim().toLowerCase();
95
+ if (!arg || arg === 'status') {
96
+ const state = reconcile(ctx);
97
+ ctx?.ui?.notify?.(`Combo: ${state.level === 'custom' ? 'INACTIVE' : state.level.toUpperCase()} (${levelSummary(state)})`, 'info');
98
+ return;
99
+ }
100
+ if (arg === 'help') {
101
+ ctx?.ui?.notify?.('/combo off — disables all 3 (caveman, rtk, ponytail)\n' +
102
+ '/combo medium — light: caveman=lite, rtk=on, ponytail=lite\n' +
103
+ '/combo balanced — middle: caveman=full, rtk=on, ponytail=full\n' +
104
+ '/combo max — aggressive: caveman=ultra, rtk=on, ponytail=ultra', 'info');
105
+ return;
106
+ }
107
+ const level = normalizeComboLevel(arg);
108
+ if (!level) {
109
+ ctx?.ui?.notify?.(`Unknown combo level: ${arg}. Use: off | medium | balanced | max`, 'warning');
110
+ return;
111
+ }
112
+ persistPreset(level);
113
+ useState(setSharedComboLevel(level), ctx);
114
+ const active = activeModesSummary(getSharedComboState());
115
+ ctx?.ui?.notify?.(level === 'off' ? `Combo off — all tersio modes inactive for this session. Active: ${active}.` : `Combo ${level} on — ${active} active for this session.`, 'info');
116
+ },
117
+ });
118
+ pi.on('session_start', async (_event, ctx) => {
119
+ track(ctx);
120
+ if (!ctx?.hasUI)
121
+ syncStatus(ctx);
122
+ // Installer/user-configured default applies only when no persisted *mode*
123
+ // state exists — unrelated session entries must not block it, or the
124
+ // combo bar never paints on sessions that already carry other entries.
125
+ const entries = sessionEntries(ctx);
126
+ const hasModeState = entries.some((e) => e?.type === 'custom' && (e.customType === 'combo-level' || e.customType === 'caveman-mode' ||
127
+ e.customType === 'rtk-mode' || e.customType === 'ponytail-mode'));
128
+ if (getSharedComboState().level === 'off' && !hasModeState) {
129
+ const fallback = readComboDefault();
130
+ if (fallback !== 'off') {
131
+ persistPreset(fallback);
132
+ useState(setSharedComboLevel(fallback), ctx);
133
+ ctx?.ui?.notify?.(`Combo default applied: ${fallback} — ${activeModesSummary(getSharedComboState())} active for this session.`, 'info');
134
+ }
135
+ }
136
+ // Standalone ponytail default: no sibling extension restores it (upstream
137
+ // owns the command), so apply it here when nothing persisted a
138
+ // ponytail-mode entry. Skipped when it matches the active preset — the
139
+ // preset entry already covers that, no redundant write.
140
+ if (!entries.some((e) => e?.type === 'custom' && e.customType === 'ponytail-mode')) {
141
+ const ponytailFallback = readPonytailDefault();
142
+ if (ponytailFallback !== 'off' && ponytailFallback !== getSharedComboState().ponytail) {
143
+ pi.appendEntry?.('ponytail-mode', { mode: ponytailFallback });
144
+ useState(setSharedComboMode('ponytail', ponytailFallback), ctx);
145
+ }
146
+ }
147
+ });
148
+ for (const event of ['session_branch', 'session_tree', 'agent_start']) {
149
+ pi.on(event, async (_event, ctx) => {
150
+ track(ctx);
151
+ });
152
+ }
153
+ pi.on('before_agent_start', async (event, ctx) => {
154
+ if (ctx?.hasUI)
155
+ reconcile(ctx);
156
+ if (!isOmpSubagentPrompt(event.systemPrompt))
157
+ return;
158
+ const mode = getSharedComboState().ponytail;
159
+ if (mode === 'off' || systemPromptIncludes(event.systemPrompt, 'PONYTAIL MODE ACTIVE'))
160
+ return;
161
+ const base = asPromptArray(event.systemPrompt);
162
+ return { systemPrompt: [...base, loadPonytailInstructions(mode)] };
163
+ });
164
+ // Slash commands only; natural-language input caused accidental toggles and has no reload context.
165
+ }