@krtclcdy/tersio 2.22.1 → 2.24.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.
Files changed (59) hide show
  1. package/README.md +62 -46
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +55 -55
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +76 -49
  7. package/cli/doctor-fix.js +61 -44
  8. package/cli/doctor.js +66 -44
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +187 -143
  11. package/cli/interactive.js +27 -1
  12. package/cli/layer.js +24 -0
  13. package/cli/manifest.js +36 -0
  14. package/cli/omp-layer.js +51 -0
  15. package/cli/pi-layer.js +37 -0
  16. package/cli/pi-wiring.js +92 -0
  17. package/cli/profile.js +45 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/settings.js +1 -1
  21. package/cli/uninstall.js +135 -44
  22. package/cli/update.js +24 -30
  23. package/cli/usage.js +6 -1
  24. package/dashboard/dist/index.html +16 -16
  25. package/extensions/ai-addons-updater/index.js +36 -25
  26. package/extensions/ai-addons-updater/index.ts +36 -24
  27. package/extensions/caveman-session/index.js +23 -18
  28. package/extensions/caveman-session/index.ts +23 -18
  29. package/extensions/caveman-session/rule.md +90 -0
  30. package/extensions/combo-toggle/index.js +63 -41
  31. package/extensions/combo-toggle/index.ts +61 -48
  32. package/extensions/lib/utils.js +56 -13
  33. package/extensions/lib/utils.ts +55 -13
  34. package/extensions/rtk-session/index.js +20 -19
  35. package/extensions/rtk-session/index.ts +18 -24
  36. package/extensions/shared/carbon.js +5 -8
  37. package/extensions/shared/carbon.ts +5 -8
  38. package/extensions/shared/host.js +68 -0
  39. package/extensions/shared/host.ts +104 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +63 -16
  42. package/extensions/shared/plugin-settings.ts +67 -17
  43. package/extensions/shared/pricing.js +26 -15
  44. package/extensions/shared/pricing.ts +28 -16
  45. package/extensions/shared/rtk-gain.js +13 -24
  46. package/extensions/shared/rtk-gain.ts +14 -25
  47. package/extensions/shared/session-state.js +26 -28
  48. package/extensions/shared/session-state.ts +26 -29
  49. package/extensions/shared/types.ts +15 -3
  50. package/extensions/shared/usage-ledger.js +136 -38
  51. package/extensions/shared/usage-ledger.ts +149 -52
  52. package/extensions/shared/usage-store.js +14 -12
  53. package/extensions/shared/usage-store.ts +16 -14
  54. package/extensions/tersio-commands/index.js +2 -1
  55. package/extensions/tersio-commands/index.ts +2 -1
  56. package/package.json +15 -7
  57. package/tersio.js +6 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
  59. package/extensions/shared/mode-reinforcement.ts +0 -29
@@ -1,65 +1,72 @@
1
- // /combo session toggle — set all three (caveman, rtk, ponytail) at once.
2
- // Modes: off | medium | balanced | max
3
- import os from 'node:os';
1
+ // /combo session toggle: off | medium | balanced | max.
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
4
10
  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);
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { activeModesSummary, COMBO_LEVELS, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, normalizeComboLevel, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboListener, setSharedComboMode, systemPromptIncludes, themeStatus, } from '../shared/session-state.js';
13
+ import { hostSelect, injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host.js';
14
+ import { isComboSetupComplete, readComboDefault, readPonytailDefault, saveComboSetup, } from '../shared/plugin-settings.js';
15
+ import { findHoistedPackage } from '../lib/utils.js';
16
+ const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
9
17
  const PONYTAIL_FALLBACK_INTENSITY = {
10
18
  lite: 'Prefer the simplest correct solution.',
11
- review: 'Review only for avoidable complexity; recommend the smallest correct replacement.',
12
19
  };
13
- function ponytailFallback(mode) {
20
+ // Fallback text when Ponytail cannot be found; exported for its own test.
21
+ export function ponytailFallback(mode) {
14
22
  const intensity = PONYTAIL_FALLBACK_INTENSITY[mode] ?? 'Use the minimum correct solution. Delete or reuse before adding.';
15
23
  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
24
  }
17
25
  function levelSummary(state) {
18
26
  return `caveman=${state.caveman} rtk=${state.rtk} ponytail=${state.ponytail}`;
19
27
  }
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);
28
+ // Ponytail is hoisted, so one upward walk covers every install layout. The hook
29
+ // is CommonJS, so both namespace shapes are tried.
30
+ async function loadPonytailInstructions(mode) {
31
+ const installed = findHoistedPackage('@dietrichgebert/ponytail', EXTENSION_DIR, 'hooks', 'ponytail-instructions.js');
32
+ if (installed) {
33
+ try {
34
+ const loaded = await import(__rewriteRelativeImportExtension(pathToFileURL(installed).href));
35
+ const build = loaded.getPonytailInstructions ?? loaded.default?.getPonytailInstructions;
36
+ if (build)
37
+ return build(mode);
38
+ }
39
+ catch { /* fall through to the built-in text */ }
26
40
  }
27
- catch { }
28
41
  return ponytailFallback(mode);
29
42
  }
30
43
  export default function comboToggleExtension(pi) {
31
- pi.setLabel?.('Combo session toggle (all 3 add-ons)');
44
+ setExtensionLabel(pi, 'Combo session toggle (all 3 add-ons)');
32
45
  let lastCtx = undefined;
46
+ let setupPrompted = false;
33
47
  function syncStatus(ctx) {
34
48
  lastCtx = paintableCtx(lastCtx, ctx);
35
49
  const c = lastCtx;
36
50
  if (!c?.ui?.setStatus)
37
51
  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.
52
+ // One unified bar replaces the per-extension bars while a preset is active.
40
53
  if (!isComboPresetActive()) {
41
54
  c.ui.setStatus('combo', undefined);
42
55
  return;
43
56
  }
44
- // Read the live bridge, not cached extension state: sibling extensions
45
- // reconcile it from persisted entries before this one runs.
57
+ // Read the live bridge, not a cache: siblings reconcile it first.
46
58
  const state = getSharedComboState();
47
- const theme = c.ui.theme;
48
59
  const c0 = state.caveman.toUpperCase();
49
60
  const r = state.rtk.toUpperCase();
50
61
  const p = state.ponytail.toUpperCase();
51
62
  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.
63
+ c.ui.setStatus('combo', themeStatus(c.ui, '🧩', `combo ${lvl}: 🪨caveman=${c0} ⚡rtk=${r} 🦥ponytail=${p}`, true));
64
+ // Our bar is canonical for the preset, so clear any sibling bar.
56
65
  c.ui.setStatus('caveman', undefined);
57
66
  c.ui.setStatus('rtk', undefined);
58
67
  c.ui.setStatus('ponytail', undefined);
59
68
  }
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.
69
+ // Siblings restore from these entries, so the fallback must write them too.
63
70
  function persistPreset(level) {
64
71
  const modes = COMBO_LEVELS[level];
65
72
  pi.appendEntry?.('caveman-mode', { mode: modes.caveman });
@@ -77,8 +84,7 @@ export default function comboToggleExtension(pi) {
77
84
  return useState(reconcileSharedComboEntries(sessionEntries(ctx)), ctx);
78
85
  }
79
86
  function listen(ctx) {
80
- // Stable identity: the bridge set dedupes, so repeated track()/command
81
- // calls register once instead of stacking duplicate listeners.
87
+ // Stable identity: the bridge set dedupes, so repeat calls register once.
82
88
  if (ctx?.hasUI)
83
89
  setSharedComboListener(useState);
84
90
  }
@@ -115,13 +121,29 @@ export default function comboToggleExtension(pi) {
115
121
  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
122
  },
117
123
  });
124
+ async function runFirstRunSetup(ctx) {
125
+ if (setupPrompted || !ctx?.hasUI || !ctx.ui?.select || isComboSetupComplete())
126
+ return;
127
+ setupPrompted = true;
128
+ const choice = await hostSelect(pi, ctx.ui, 'Session-start defaults — Combo preset', [
129
+ { label: 'off', description: 'Keep every Tersio mode inactive' },
130
+ { label: 'medium', description: 'caveman=lite, rtk=on, ponytail=lite' },
131
+ { label: 'balanced', description: 'caveman=full, rtk=on, ponytail=full' },
132
+ { label: 'max', description: 'caveman=ultra, rtk=on, ponytail=ultra' },
133
+ ], { selectionMarker: 'radio', helpText: 'You can change this later with /tersio settings.' });
134
+ const level = choice || 'off';
135
+ if (saveComboSetup(level) && level !== 'off') {
136
+ persistPreset(level);
137
+ useState(setSharedComboLevel(level), ctx);
138
+ ctx.ui.notify?.(`Combo default saved: ${level} — ${activeModesSummary(getSharedComboState())} will activate on fresh sessions.`, 'info');
139
+ }
140
+ }
118
141
  pi.on('session_start', async (_event, ctx) => {
119
142
  track(ctx);
120
143
  if (!ctx?.hasUI)
121
144
  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.
145
+ await runFirstRunSetup(ctx);
146
+ // The configured default applies only when no persisted mode state exists.
125
147
  const entries = sessionEntries(ctx);
126
148
  const hasModeState = entries.some((e) => e?.type === 'custom' && (e.customType === 'combo-level' || e.customType === 'caveman-mode' ||
127
149
  e.customType === 'rtk-mode' || e.customType === 'ponytail-mode'));
@@ -133,10 +155,8 @@ export default function comboToggleExtension(pi) {
133
155
  ctx?.ui?.notify?.(`Combo default applied: ${fallback} — ${activeModesSummary(getSharedComboState())} active for this session.`, 'info');
134
156
  }
135
157
  }
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.
158
+ // No sibling restores a standalone ponytail default, so apply it here; skip
159
+ // it when the active preset already writes the same mode.
140
160
  if (!entries.some((e) => e?.type === 'custom' && e.customType === 'ponytail-mode')) {
141
161
  const ponytailFallback = readPonytailDefault();
142
162
  if (ponytailFallback !== 'off' && ponytailFallback !== getSharedComboState().ponytail) {
@@ -146,8 +166,10 @@ export default function comboToggleExtension(pi) {
146
166
  }
147
167
  });
148
168
  for (const event of ['session_branch', 'session_tree', 'agent_start']) {
149
- pi.on(event, async (_event, ctx) => {
169
+ onHostEvent(pi, event, async (_event, ctx) => {
150
170
  track(ctx);
171
+ if (event === 'agent_start')
172
+ await runFirstRunSetup(ctx);
151
173
  });
152
174
  }
153
175
  pi.on('before_agent_start', async (event, ctx) => {
@@ -158,8 +180,8 @@ export default function comboToggleExtension(pi) {
158
180
  const mode = getSharedComboState().ponytail;
159
181
  if (mode === 'off' || systemPromptIncludes(event.systemPrompt, 'PONYTAIL MODE ACTIVE'))
160
182
  return;
161
- const base = asPromptArray(event.systemPrompt);
162
- return { systemPrompt: [...base, loadPonytailInstructions(mode)] };
183
+ const instructions = await loadPonytailInstructions(mode);
184
+ return injectPromptText(pi, event, instructions);
163
185
  });
164
186
  // Slash commands only; natural-language input caused accidental toggles and has no reload context.
165
187
  }
@@ -1,12 +1,10 @@
1
- // /combo session toggle — set all three (caveman, rtk, ponytail) at once.
2
- // Modes: off | medium | balanced | max
1
+ // /combo session toggle: off | medium | balanced | max.
3
2
 
4
3
  import os from 'node:os';
5
4
  import path from 'node:path';
6
- import { createRequire } from 'node:module';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
6
  import {
8
7
  activeModesSummary,
9
- asPromptArray,
10
8
  COMBO_LEVELS,
11
9
  getSharedComboState,
12
10
  isComboPresetActive,
@@ -19,18 +17,26 @@ import {
19
17
  setSharedComboListener,
20
18
  setSharedComboMode,
21
19
  systemPromptIncludes,
20
+ themeStatus,
22
21
  } from '../shared/session-state.ts';
23
- import { readComboDefault, readPonytailDefault } from '../shared/plugin-settings.ts';
22
+ import { hostSelect, injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host.ts';
23
+ import {
24
+ isComboSetupComplete,
25
+ readComboDefault,
26
+ readPonytailDefault,
27
+ saveComboSetup,
28
+ } from '../shared/plugin-settings.ts';
29
+ import { findHoistedPackage } from '../lib/utils.ts';
24
30
  import type { ComboState, ExtensionApi, ExtensionCtx, SystemPromptEvent } from '../shared/types.ts';
25
31
 
26
- const require = createRequire(import.meta.url);
32
+ const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
27
33
 
28
34
  const PONYTAIL_FALLBACK_INTENSITY: Record<string, string> = {
29
35
  lite: 'Prefer the simplest correct solution.',
30
- review: 'Review only for avoidable complexity; recommend the smallest correct replacement.',
31
36
  };
32
37
 
33
- function ponytailFallback(mode: string): string {
38
+ // Fallback text when Ponytail cannot be found; exported for its own test.
39
+ export function ponytailFallback(mode: string): string {
34
40
  const intensity = PONYTAIL_FALLBACK_INTENSITY[mode] ?? 'Use the minimum correct solution. Delete or reuse before adding.';
35
41
  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.`;
36
42
  }
@@ -39,59 +45,52 @@ function levelSummary(state: ComboState): string {
39
45
  return `caveman=${state.caveman} rtk=${state.rtk} ponytail=${state.ponytail}`;
40
46
  }
41
47
 
42
- function loadPonytailInstructions(mode: string): string {
43
- try {
44
- const installed = path.join(
45
- os.homedir(),
46
- '.omp',
47
- 'plugins',
48
- 'node_modules',
49
- '@dietrichgebert',
50
- 'ponytail',
51
- 'hooks',
52
- 'ponytail-instructions.js'
53
- );
54
- const { getPonytailInstructions } = require(installed) as { getPonytailInstructions?: (mode: string) => string };
55
- if (typeof getPonytailInstructions === 'function') return getPonytailInstructions(mode);
56
- } catch { }
48
+ // Ponytail is hoisted, so one upward walk covers every install layout. The hook
49
+ // is CommonJS, so both namespace shapes are tried.
50
+ async function loadPonytailInstructions(mode: string): Promise<string> {
51
+ const installed = findHoistedPackage('@dietrichgebert/ponytail', EXTENSION_DIR, 'hooks', 'ponytail-instructions.js');
52
+ if (installed) {
53
+ try {
54
+ const loaded = await import(pathToFileURL(installed).href) as {
55
+ getPonytailInstructions?: (level: string) => string;
56
+ default?: { getPonytailInstructions?: (level: string) => string };
57
+ };
58
+ const build = loaded.getPonytailInstructions ?? loaded.default?.getPonytailInstructions;
59
+ if (build) return build(mode);
60
+ } catch { /* fall through to the built-in text */ }
61
+ }
57
62
  return ponytailFallback(mode);
58
63
  }
59
64
 
60
65
  export default function comboToggleExtension(pi: ExtensionApi): void {
61
- pi.setLabel?.('Combo session toggle (all 3 add-ons)');
66
+ setExtensionLabel(pi, 'Combo session toggle (all 3 add-ons)');
62
67
 
63
68
  let lastCtx: ExtensionCtx | undefined = undefined;
69
+ let setupPrompted = false;
64
70
 
65
71
  function syncStatus(ctx?: ExtensionCtx): void {
66
72
  lastCtx = paintableCtx(lastCtx, ctx);
67
73
  const c = lastCtx;
68
74
  if (!c?.ui?.setStatus) return;
69
- // Single unified bar replaces the three per-extension bars while a preset is
70
- // active. In custom/off, the individual bars come back and combo stays clear.
75
+ // One unified bar replaces the per-extension bars while a preset is active.
71
76
  if (!isComboPresetActive()) {
72
77
  c.ui.setStatus('combo', undefined);
73
78
  return;
74
79
  }
75
- // Read the live bridge, not cached extension state: sibling extensions
76
- // reconcile it from persisted entries before this one runs.
80
+ // Read the live bridge, not a cache: siblings reconcile it first.
77
81
  const state = getSharedComboState();
78
- const theme = c.ui.theme;
79
82
  const c0 = state.caveman.toUpperCase();
80
83
  const r = state.rtk.toUpperCase();
81
84
  const p = state.ponytail.toUpperCase();
82
85
  const lvl = state.level.toUpperCase();
83
- const label = `combo ${lvl}: 🪨caveman=${c0} ⚡rtk=${r} 🦥ponytail=${p}`;
84
- c.ui.setStatus('combo', theme?.fg ? `${theme.fg('accent', '🧩')} ${theme.fg('muted', label)}` : `🧩 ${label}`);
85
- // Clobber any sibling bars another extension may have painted in a race
86
- // during session_start — our bar is canonical for the duration of the preset.
86
+ c.ui.setStatus('combo', themeStatus(c.ui, '🧩', `combo ${lvl}: 🪨caveman=${c0} ⚡rtk=${r} 🦥ponytail=${p}`, true));
87
+ // Our bar is canonical for the preset, so clear any sibling bar.
87
88
  c.ui.setStatus('caveman', undefined);
88
89
  c.ui.setStatus('rtk', undefined);
89
90
  c.ui.setStatus('ponytail', undefined);
90
91
  }
91
92
 
92
- // ponytail: same persistence as /combo — siblings (incl. upstream ponytail)
93
- // restore from these entries, so the fallback must write them too or the
94
- // preset evaporates on resume and ponytail never activates.
93
+ // Siblings restore from these entries, so the fallback must write them too.
95
94
  function persistPreset(level: string): void {
96
95
  const modes = COMBO_LEVELS[level];
97
96
  pi.appendEntry?.('caveman-mode', { mode: modes.caveman });
@@ -110,8 +109,7 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
110
109
  return useState(reconcileSharedComboEntries(sessionEntries(ctx)), ctx);
111
110
  }
112
111
  function listen(ctx?: ExtensionCtx): void {
113
- // Stable identity: the bridge set dedupes, so repeated track()/command
114
- // calls register once instead of stacking duplicate listeners.
112
+ // Stable identity: the bridge set dedupes, so repeat calls register once.
115
113
  if (ctx?.hasUI) setSharedComboListener(useState);
116
114
  }
117
115
 
@@ -166,12 +164,28 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
166
164
  },
167
165
  });
168
166
 
167
+ async function runFirstRunSetup(ctx?: ExtensionCtx): Promise<void> {
168
+ if (setupPrompted || !ctx?.hasUI || !ctx.ui?.select || isComboSetupComplete()) return;
169
+ setupPrompted = true;
170
+ const choice = await hostSelect(pi, ctx.ui, 'Session-start defaults — Combo preset', [
171
+ { label: 'off', description: 'Keep every Tersio mode inactive' },
172
+ { label: 'medium', description: 'caveman=lite, rtk=on, ponytail=lite' },
173
+ { label: 'balanced', description: 'caveman=full, rtk=on, ponytail=full' },
174
+ { label: 'max', description: 'caveman=ultra, rtk=on, ponytail=ultra' },
175
+ ], { selectionMarker: 'radio', helpText: 'You can change this later with /tersio settings.' });
176
+ const level = choice || 'off';
177
+ if (saveComboSetup(level) && level !== 'off') {
178
+ persistPreset(level);
179
+ useState(setSharedComboLevel(level), ctx);
180
+ ctx.ui.notify?.(`Combo default saved: ${level} — ${activeModesSummary(getSharedComboState())} will activate on fresh sessions.`, 'info');
181
+ }
182
+ }
183
+
169
184
  pi.on('session_start', async (_event, ctx) => {
170
185
  track(ctx);
171
186
  if (!ctx?.hasUI) syncStatus(ctx);
172
- // Installer/user-configured default applies only when no persisted *mode*
173
- // state exists — unrelated session entries must not block it, or the
174
- // combo bar never paints on sessions that already carry other entries.
187
+ await runFirstRunSetup(ctx);
188
+ // The configured default applies only when no persisted mode state exists.
175
189
  const entries = sessionEntries(ctx);
176
190
  const hasModeState = entries.some((e) => e?.type === 'custom' && (
177
191
  e.customType === 'combo-level' || e.customType === 'caveman-mode' ||
@@ -184,10 +198,8 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
184
198
  ctx?.ui?.notify?.(`Combo default applied: ${fallback} — ${activeModesSummary(getSharedComboState())} active for this session.`, 'info');
185
199
  }
186
200
  }
187
- // Standalone ponytail default: no sibling extension restores it (upstream
188
- // owns the command), so apply it here when nothing persisted a
189
- // ponytail-mode entry. Skipped when it matches the active preset — the
190
- // preset entry already covers that, no redundant write.
201
+ // No sibling restores a standalone ponytail default, so apply it here; skip
202
+ // it when the active preset already writes the same mode.
191
203
  if (!entries.some((e) => e?.type === 'custom' && e.customType === 'ponytail-mode')) {
192
204
  const ponytailFallback = readPonytailDefault();
193
205
  if (ponytailFallback !== 'off' && ponytailFallback !== getSharedComboState().ponytail) {
@@ -198,8 +210,9 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
198
210
  });
199
211
 
200
212
  for (const event of ['session_branch', 'session_tree', 'agent_start']) {
201
- pi.on(event, async (_event, ctx) => {
213
+ onHostEvent(pi, event, async (_event, ctx) => {
202
214
  track(ctx);
215
+ if (event === 'agent_start') await runFirstRunSetup(ctx);
203
216
  });
204
217
  }
205
218
 
@@ -209,8 +222,8 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
209
222
 
210
223
  const mode = getSharedComboState().ponytail;
211
224
  if (mode === 'off' || systemPromptIncludes(event.systemPrompt, 'PONYTAIL MODE ACTIVE')) return;
212
- const base = asPromptArray(event.systemPrompt);
213
- return { systemPrompt: [...base, loadPonytailInstructions(mode)] };
225
+ const instructions = await loadPonytailInstructions(mode);
226
+ return injectPromptText(pi, event, instructions);
214
227
  });
215
228
 
216
229
  // Slash commands only; natural-language input caused accidental toggles and has no reload context.
@@ -1,13 +1,12 @@
1
- // Shared helpers for the installer and the AI add-ons updater.
2
- // Node built-ins only — this module must stay dependency-free.
1
+ // Shared helpers for the installer and the AI add-ons updater. Node built-ins
2
+ // only — this module must stay dependency-free.
3
3
  import { createHash } from 'node:crypto';
4
- import { createWriteStream, existsSync, mkdirSync, renameSync } from 'node:fs';
4
+ import { accessSync, constants, createWriteStream, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
5
5
  import fs from 'node:fs/promises';
6
6
  import https from 'node:https';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  // Promise.withResolvers is Node 22+; the package supports Node 18+.
10
- // ponytail: swap for the built-in when engines bumps to >=22.
11
10
  export function withResolvers() {
12
11
  let resolve;
13
12
  let reject;
@@ -15,7 +14,7 @@ export function withResolvers() {
15
14
  return { promise, resolve, reject };
16
15
  }
17
16
  export const RTK_RELEASE_API = 'https://api.github.com/repos/rtk-ai/rtk/releases/latest';
18
- export const CAVEMAN_REMOTE_RULE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/rules/caveman-activate.md';
17
+ export const CAVEMAN_REMOTE_RULE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/caveman/SKILL.md';
19
18
  const RTK_PLATFORM_SPECS = {
20
19
  'win32/x64': { triple: 'x86_64-pc-windows-msvc', ext: '.zip', binary: 'rtk.exe' },
21
20
  'linux/x64': { triple: 'x86_64-unknown-linux-musl', ext: '.tar.gz', binary: 'rtk' },
@@ -23,20 +22,59 @@ const RTK_PLATFORM_SPECS = {
23
22
  'darwin/x64': { triple: 'x86_64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
24
23
  'darwin/arm64': { triple: 'aarch64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
25
24
  };
25
+ export function resolveRtkBinary(managedDir = path.join(os.homedir(), '.bun', 'bin')) {
26
+ const name = process.platform === 'win32' ? 'rtk.exe' : 'rtk';
27
+ const candidates = (process.env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, name));
28
+ if (managedDir)
29
+ candidates.push(path.join(managedDir, name));
30
+ for (const candidate of candidates) {
31
+ try {
32
+ if (!statSync(candidate).isFile())
33
+ continue;
34
+ accessSync(candidate, constants.X_OK);
35
+ return candidate;
36
+ }
37
+ catch { /* keep searching */ }
38
+ }
39
+ return null;
40
+ }
26
41
  export function rtkPlatformSpec(platform = process.platform, arch = process.arch) {
27
42
  return RTK_PLATFORM_SPECS[`${platform}/${arch}`] || null;
28
43
  }
29
- // Single source for the 3xx+location redirect rule shared by httpsGet/httpsDownload.
44
+ // The 3xx+location redirect rule shared by httpsGet/httpsDownload.
30
45
  function redirectNext(res, url) {
31
46
  if (res.statusCode !== undefined && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
32
47
  return new URL(res.headers.location, url).href;
33
48
  }
34
49
  return null;
35
50
  }
51
+ // pi sets PI_CODING_AGENT; OMP does not. OMP does read PI_CODING_AGENT_DIR as
52
+ // its own relocation variable, so the override only counts under that marker.
53
+ export function isPiProcess() {
54
+ return process.env.PI_CODING_AGENT === 'true';
55
+ }
56
+ export function piAgentDir() {
57
+ const override = process.env.PI_CODING_AGENT_DIR;
58
+ return isPiProcess() && override ? override : path.join(os.homedir(), '.pi', 'agent');
59
+ }
60
+ // Ponytail is a Tersio dependency that npm hoists, so it can sit in Tersio's
61
+ // node_modules, a host's plugin dir, or beside it. Walking up covers all.
62
+ export function findHoistedPackage(pkg, fromDir, ...relInside) {
63
+ let dir = fromDir;
64
+ for (;;) {
65
+ const candidate = path.join(dir, 'node_modules', ...pkg.split('/'), ...relInside);
66
+ if (existsSync(candidate))
67
+ return candidate;
68
+ const parent = path.dirname(dir);
69
+ if (parent === dir)
70
+ return null;
71
+ dir = parent;
72
+ }
73
+ }
36
74
  export function httpsGet(url, opts = {}) {
37
75
  const { promise, resolve, reject } = withResolvers();
38
76
  const maxRedirects = opts.maxRedirects ?? 5;
39
- const req = https.get(url, { headers: { 'User-Agent': 'tersio', Accept: 'application/json,*/*' } }, (res) => {
77
+ const req = https.get(url, { headers: { 'User-Agent': 'tersio', Accept: 'application/json,*/*' }, signal: opts.signal }, (res) => {
40
78
  const next = redirectNext(res, url);
41
79
  if (next) {
42
80
  if (maxRedirects <= 0) {
@@ -45,7 +83,7 @@ export function httpsGet(url, opts = {}) {
45
83
  return;
46
84
  }
47
85
  res.resume();
48
- resolve(httpsGet(next, { maxRedirects: maxRedirects - 1 }));
86
+ resolve(httpsGet(next, { maxRedirects: maxRedirects - 1, signal: opts.signal }));
49
87
  return;
50
88
  }
51
89
  if (res.statusCode !== 200) {
@@ -54,7 +92,7 @@ export function httpsGet(url, opts = {}) {
54
92
  return;
55
93
  }
56
94
  let body = '';
57
- // ponytail: streamed accumulation — fine for tens of KB; stream-pipe if assets ever exceed a few MB.
95
+ // Streamed accumulation: fine for tens of KB.
58
96
  res.setEncoding('utf8');
59
97
  res.on('data', (chunk) => { body += chunk; });
60
98
  res.on('end', () => resolve(body));
@@ -97,7 +135,13 @@ export function httpsDownload(url, dest, opts = {}) {
97
135
  return promise;
98
136
  }
99
137
  export async function fetchJson(url) {
100
- return JSON.parse(await httpsGet(url));
138
+ const body = await httpsGet(url);
139
+ try {
140
+ return JSON.parse(body);
141
+ }
142
+ catch {
143
+ throw new Error(`${url} did not return JSON`);
144
+ }
101
145
  }
102
146
  export function sha256Hex(text) {
103
147
  return createHash('sha256').update(text, 'utf8').digest('hex');
@@ -150,9 +194,8 @@ export async function readTextIfExists(p) {
150
194
  return null;
151
195
  }
152
196
  }
153
- // Tersio-owned data home: ~/.tersio (Windows: %USERPROFILE%\.tersio).
154
- // First use moves legacy ~/.omp/plugins/tersio-* files over, so existing
155
- // installs keep their history.
197
+ // Tersio-owned data home: ~/.tersio. First use moves legacy
198
+ // ~/.omp/plugins/tersio-* files over so existing installs keep their history.
156
199
  const migratedTersioFiles = new Set();
157
200
  export function tersioHome() {
158
201
  return path.join(os.homedir(), '.tersio');