@krtclcdy/tersio 2.23.0 → 2.24.1

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