@krtclcdy/tersio 2.24.1 → 2.25.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 (62) hide show
  1. package/README.md +27 -21
  2. package/cli/banner.js +7 -4
  3. package/cli/common.js +14 -13
  4. package/cli/currency.js +1 -2
  5. package/cli/dashboard.js +57 -14
  6. package/cli/doctor-fix.js +88 -21
  7. package/cli/doctor.js +32 -24
  8. package/cli/hosts.js +19 -18
  9. package/cli/install.js +123 -52
  10. package/cli/interactive.js +29 -19
  11. package/cli/manifest.js +7 -0
  12. package/cli/profile.js +42 -11
  13. package/cli/rtk-wiring.js +28 -26
  14. package/cli/settings.js +37 -8
  15. package/cli/uninstall.js +40 -7
  16. package/cli/update.js +22 -14
  17. package/cli/usage.js +39 -14
  18. package/dashboard/dist/index.html +16 -16
  19. package/extensions/ai-addons-updater/index.js +69 -15
  20. package/extensions/ai-addons-updater/index.ts +60 -17
  21. package/extensions/caveman-session/index.js +28 -55
  22. package/extensions/caveman-session/index.ts +28 -55
  23. package/extensions/combo-toggle/index.js +40 -58
  24. package/extensions/combo-toggle/index.ts +38 -67
  25. package/extensions/lib/utils.js +60 -15
  26. package/extensions/lib/utils.ts +59 -15
  27. package/extensions/opencode/index.js +104 -0
  28. package/extensions/opencode/server.js +190 -0
  29. package/extensions/opencode/server.ts +200 -0
  30. package/extensions/rtk-session/index.js +20 -35
  31. package/extensions/rtk-session/index.ts +19 -34
  32. package/extensions/shared/carbon.js +1 -2
  33. package/extensions/shared/carbon.ts +1 -2
  34. package/extensions/shared/host.js +95 -46
  35. package/extensions/shared/host.ts +106 -50
  36. package/extensions/shared/plugin-settings.js +4 -6
  37. package/extensions/shared/plugin-settings.ts +4 -6
  38. package/extensions/shared/pricing.js +79 -25
  39. package/extensions/shared/pricing.ts +80 -26
  40. package/extensions/shared/rtk-gain.js +3 -5
  41. package/extensions/shared/rtk-gain.ts +3 -5
  42. package/extensions/shared/session-state.js +10 -33
  43. package/extensions/shared/session-state.ts +12 -38
  44. package/extensions/shared/status.js +38 -0
  45. package/extensions/shared/status.ts +45 -0
  46. package/extensions/shared/types.js +1 -2
  47. package/extensions/shared/types.ts +2 -2
  48. package/extensions/shared/usage-ledger.js +190 -56
  49. package/extensions/shared/usage-ledger.ts +194 -58
  50. package/extensions/shared/usage-store.js +228 -64
  51. package/extensions/shared/usage-store.ts +235 -67
  52. package/extensions/tersio-commands/index.js +4 -9
  53. package/extensions/tersio-commands/index.ts +4 -9
  54. package/package.json +8 -2
  55. package/tersio.js +3 -3
  56. package/cli/agent-hosts.js +0 -42
  57. package/cli/agents.js +0 -266
  58. package/cli/layer.js +0 -22
  59. package/cli/omp-layer.js +0 -51
  60. package/cli/pi-layer.js +0 -37
  61. package/cli/pi-wiring.js +0 -92
  62. package/extensions/shared/omp-prompt.js +0 -12
@@ -8,11 +8,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  return path;
9
9
  };
10
10
  import path from 'node:path';
11
+ import { existsSync } from 'node:fs';
11
12
  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 { activeModesSummary, COMBO_LEVELS, getSharedComboState, hasModeState, normalizeComboLevel, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboListener, setSharedComboMode, systemPromptIncludes, } from '../shared/session-state.js';
13
14
  import { hostSelect, injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host.js';
15
+ import { announceStatus } from '../shared/status.js';
14
16
  import { isComboSetupComplete, readComboDefault, readPonytailDefault, saveComboSetup, } from '../shared/plugin-settings.js';
15
- import { findHoistedPackage } from '../lib/utils.js';
17
+ import { findHoistedPackage, isPiProcess } from '../lib/utils.js';
16
18
  const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
17
19
  const PONYTAIL_FALLBACK_INTENSITY = {
18
20
  lite: 'Prefer the simplest correct solution.',
@@ -22,16 +24,15 @@ export function ponytailFallback(mode) {
22
24
  const intensity = PONYTAIL_FALLBACK_INTENSITY[mode] ?? 'Use the minimum correct solution. Delete or reuse before adding.';
23
25
  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.`;
24
26
  }
25
- function levelSummary(state) {
26
- return `caveman=${state.caveman} rtk=${state.rtk} ponytail=${state.ponytail}`;
27
- }
28
- // Ponytail is hoisted, so one upward walk covers every install layout. The hook
29
- // is CommonJS, so both namespace shapes are tried.
27
+ // Ponytail is hoisted, so one upward walk covers every install layout. The hook is CommonJS, so both namespace shapes are tried.
30
28
  async function loadPonytailInstructions(mode) {
31
29
  const installed = findHoistedPackage('@dietrichgebert/ponytail', EXTENSION_DIR, 'hooks', 'ponytail-instructions.js');
32
- if (installed) {
30
+ // The OpenCode tree ships no node_modules; the installer bundles the ruleset beside the extensions.
31
+ const bundled = path.join(EXTENSION_DIR, '..', 'ponytail-bundle', 'hooks', 'ponytail-instructions.js');
32
+ const treeLocal = installed ?? (existsSync(bundled) ? bundled : null);
33
+ if (treeLocal) {
33
34
  try {
34
- const loaded = await import(__rewriteRelativeImportExtension(pathToFileURL(installed).href));
35
+ const loaded = await import(__rewriteRelativeImportExtension(pathToFileURL(treeLocal).href));
35
36
  const build = loaded.getPonytailInstructions ?? loaded.default?.getPonytailInstructions;
36
37
  if (build)
37
38
  return build(mode);
@@ -42,30 +43,8 @@ async function loadPonytailInstructions(mode) {
42
43
  }
43
44
  export default function comboToggleExtension(pi) {
44
45
  setExtensionLabel(pi, 'Combo session toggle (all 3 add-ons)');
45
- let lastCtx = undefined;
46
46
  let setupPrompted = false;
47
- function syncStatus(ctx) {
48
- lastCtx = paintableCtx(lastCtx, ctx);
49
- const ui = statusUi(lastCtx);
50
- if (!ui)
51
- return;
52
- // One unified bar replaces the per-extension bars while a preset is active.
53
- if (!isComboPresetActive()) {
54
- ui.setStatus('combo', undefined);
55
- return;
56
- }
57
- // Read the live bridge, not a cache: siblings reconcile it first.
58
- const state = getSharedComboState();
59
- const c0 = state.caveman.toUpperCase();
60
- const r = state.rtk.toUpperCase();
61
- const p = state.ponytail.toUpperCase();
62
- const lvl = state.level.toUpperCase();
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);
68
- }
47
+ let lastInjected = undefined;
69
48
  // Siblings restore from these entries, so the fallback must write them too.
70
49
  function persistPreset(level) {
71
50
  const modes = COMBO_LEVELS[level];
@@ -74,18 +53,15 @@ export default function comboToggleExtension(pi) {
74
53
  pi.appendEntry?.('ponytail-mode', { mode: modes.ponytail });
75
54
  pi.appendEntry?.('combo-level', { level });
76
55
  }
77
- function useState(state, ctx) {
78
- syncStatus(ctx);
79
- return state;
80
- }
81
56
  function reconcile(ctx) {
82
57
  if (!ctx?.hasUI)
83
58
  return getSharedComboState();
84
- return useState(reconcileSharedComboEntries(sessionEntries(ctx)), ctx);
59
+ return reconcileSharedComboEntries(sessionEntries(ctx));
85
60
  }
86
61
  function listen(ctx) {
87
- if (ctx?.hasUI)
88
- setSharedComboListener('combo', useState);
62
+ if (!ctx?.hasUI)
63
+ return;
64
+ setSharedComboListener('combo', (state) => { announceStatus(ctx); return state; });
89
65
  }
90
66
  function track(ctx) {
91
67
  listen(ctx);
@@ -98,8 +74,8 @@ export default function comboToggleExtension(pi) {
98
74
  listen(ctx);
99
75
  const arg = String(args || '').trim().toLowerCase();
100
76
  if (!arg || arg === 'status') {
101
- const state = reconcile(ctx);
102
- ctx?.ui?.notify?.(`Combo: ${state.level === 'custom' ? 'INACTIVE' : state.level.toUpperCase()} (${levelSummary(state)})`, 'info');
77
+ reconcile(ctx);
78
+ announceStatus(ctx);
103
79
  return;
104
80
  }
105
81
  if (arg === 'help') {
@@ -115,9 +91,8 @@ export default function comboToggleExtension(pi) {
115
91
  return;
116
92
  }
117
93
  persistPreset(level);
118
- useState(setSharedComboLevel(level), ctx);
119
- const active = activeModesSummary(getSharedComboState());
120
- 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');
94
+ setSharedComboLevel(level);
95
+ announceStatus(ctx);
121
96
  },
122
97
  });
123
98
  async function runFirstRunSetup(ctx) {
@@ -133,40 +108,39 @@ export default function comboToggleExtension(pi) {
133
108
  const level = choice || 'off';
134
109
  if (saveComboSetup(level) && level !== 'off') {
135
110
  persistPreset(level);
136
- useState(setSharedComboLevel(level), ctx);
111
+ setSharedComboLevel(level);
137
112
  ctx.ui.notify?.(`Combo default saved: ${level} — ${activeModesSummary(getSharedComboState())} will activate on fresh sessions.`, 'info');
138
113
  }
139
114
  }
140
115
  pi.on('session_start', async (_event, ctx) => {
141
116
  track(ctx);
142
- if (!ctx?.hasUI)
143
- syncStatus(ctx);
144
117
  await runFirstRunSetup(ctx);
145
118
  // The configured default applies only when no persisted mode state exists.
146
119
  const entries = sessionEntries(ctx);
147
- const hasModeState = entries.some((e) => e?.type === 'custom' && (e.customType === 'combo-level' || e.customType === 'caveman-mode' ||
148
- e.customType === 'rtk-mode' || e.customType === 'ponytail-mode'));
149
- if (getSharedComboState().level === 'off' && !hasModeState) {
120
+ const persisted = hasModeState(entries);
121
+ if (getSharedComboState().level === 'off' && !persisted) {
150
122
  const fallback = readComboDefault();
151
123
  if (fallback !== 'off') {
152
124
  persistPreset(fallback);
153
- useState(setSharedComboLevel(fallback), ctx);
154
- ctx?.ui?.notify?.(`Combo default applied: ${fallback} — ${activeModesSummary(getSharedComboState())} active for this session.`, 'info');
125
+ setSharedComboLevel(fallback);
155
126
  }
156
127
  }
157
- // No sibling restores a standalone ponytail default, so apply it here; skip
158
- // it when the active preset already writes the same mode.
128
+ // No sibling restores a standalone ponytail default, so apply it here; skip it when the active preset already writes the same mode.
159
129
  if (!entries.some((e) => e?.type === 'custom' && e.customType === 'ponytail-mode')) {
160
130
  const ponytailFallback = readPonytailDefault();
161
131
  if (ponytailFallback !== 'off' && ponytailFallback !== getSharedComboState().ponytail) {
162
132
  pi.appendEntry?.('ponytail-mode', { mode: ponytailFallback });
163
- useState(setSharedComboMode('ponytail', ponytailFallback), ctx);
133
+ setSharedComboMode('ponytail', ponytailFallback);
164
134
  }
165
135
  }
136
+ // Announced last, so any default above is reflected.
137
+ announceStatus(ctx);
166
138
  });
167
- for (const event of ['session_branch', 'session_tree', 'agent_start']) {
139
+ // Resume, branch, and compaction re-announce; dedupe keeps repeats silent.
140
+ for (const event of ['session_branch', 'session_tree', 'agent_start', 'session_switch', 'session_compact']) {
168
141
  onHostEvent(pi, event, async (_event, ctx) => {
169
142
  track(ctx);
143
+ announceStatus(ctx);
170
144
  if (event === 'agent_start')
171
145
  await runFirstRunSetup(ctx);
172
146
  });
@@ -175,9 +149,17 @@ export default function comboToggleExtension(pi) {
175
149
  if (ctx?.hasUI)
176
150
  reconcile(ctx);
177
151
  const mode = getSharedComboState().ponytail;
178
- if (mode === 'off' || systemPromptIncludes(event.systemPrompt, 'PONYTAIL MODE ACTIVE'))
152
+ // On pi the upstream ponytail pi-extension is the single injector; it reads these 'ponytail-mode' entries at session start, so Tersio only persists.
153
+ if (isPiProcess())
154
+ return;
155
+ // Match the level's own header: the shared phrase alone makes every level block every other, so a `/combo` level switch is ignored all session.
156
+ if (mode === 'off' || systemPromptIncludes(event.systemPrompt, `PONYTAIL MODE ACTIVE — level: ${mode}`))
179
157
  return;
180
- return injectPromptText(pi, event, await loadPonytailInstructions(mode));
158
+ const instruction = await loadPonytailInstructions(mode);
159
+ // The previous level's block is replaced, not stacked on.
160
+ const stale = lastInjected;
161
+ lastInjected = instruction;
162
+ return injectPromptText(pi, event, instruction, stale);
181
163
  });
182
164
  // Slash commands only; natural-language input caused accidental toggles and has no reload context.
183
165
  }
@@ -1,31 +1,30 @@
1
1
  // /combo session toggle: off | medium | balanced | max.
2
2
 
3
3
  import path from 'node:path';
4
+ import { existsSync } from 'node:fs';
4
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
6
  import {
6
7
  activeModesSummary,
7
8
  COMBO_LEVELS,
8
9
  getSharedComboState,
9
- isComboPresetActive,
10
+ hasModeState,
10
11
  normalizeComboLevel,
11
- paintableCtx,
12
12
  reconcileSharedComboEntries,
13
13
  sessionEntries,
14
14
  setSharedComboLevel,
15
- statusUi,
16
15
  setSharedComboListener,
17
16
  setSharedComboMode,
18
17
  systemPromptIncludes,
19
- themeStatus,
20
18
  } from '../shared/session-state.ts';
21
19
  import { hostSelect, injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host.ts';
20
+ import { announceStatus } from '../shared/status.ts';
22
21
  import {
23
22
  isComboSetupComplete,
24
23
  readComboDefault,
25
24
  readPonytailDefault,
26
25
  saveComboSetup,
27
26
  } from '../shared/plugin-settings.ts';
28
- import { findHoistedPackage } from '../lib/utils.ts';
27
+ import { findHoistedPackage, isPiProcess } from '../lib/utils.ts';
29
28
  import type { ComboState, ExtensionApi, ExtensionCtx, SystemPromptEvent } from '../shared/types.ts';
30
29
 
31
30
  const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -40,17 +39,16 @@ export function ponytailFallback(mode: string): string {
40
39
  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.`;
41
40
  }
42
41
 
43
- function levelSummary(state: ComboState): string {
44
- return `caveman=${state.caveman} rtk=${state.rtk} ponytail=${state.ponytail}`;
45
- }
46
42
 
47
- // Ponytail is hoisted, so one upward walk covers every install layout. The hook
48
- // is CommonJS, so both namespace shapes are tried.
43
+ // Ponytail is hoisted, so one upward walk covers every install layout. The hook is CommonJS, so both namespace shapes are tried.
49
44
  async function loadPonytailInstructions(mode: string): Promise<string> {
50
45
  const installed = findHoistedPackage('@dietrichgebert/ponytail', EXTENSION_DIR, 'hooks', 'ponytail-instructions.js');
51
- if (installed) {
46
+ // The OpenCode tree ships no node_modules; the installer bundles the ruleset beside the extensions.
47
+ const bundled = path.join(EXTENSION_DIR, '..', 'ponytail-bundle', 'hooks', 'ponytail-instructions.js');
48
+ const treeLocal = installed ?? (existsSync(bundled) ? bundled : null);
49
+ if (treeLocal) {
52
50
  try {
53
- const loaded = await import(pathToFileURL(installed).href) as {
51
+ const loaded = await import(pathToFileURL(treeLocal).href) as {
54
52
  getPonytailInstructions?: (level: string) => string;
55
53
  default?: { getPonytailInstructions?: (level: string) => string };
56
54
  };
@@ -64,30 +62,9 @@ async function loadPonytailInstructions(mode: string): Promise<string> {
64
62
  export default function comboToggleExtension(pi: ExtensionApi): void {
65
63
  setExtensionLabel(pi, 'Combo session toggle (all 3 add-ons)');
66
64
 
67
- let lastCtx: ExtensionCtx | undefined = undefined;
68
65
  let setupPrompted = false;
66
+ let lastInjected: string | undefined = undefined;
69
67
 
70
- function syncStatus(ctx?: ExtensionCtx): void {
71
- lastCtx = paintableCtx(lastCtx, ctx);
72
- const ui = statusUi(lastCtx);
73
- if (!ui) return;
74
- // One unified bar replaces the per-extension bars while a preset is active.
75
- if (!isComboPresetActive()) {
76
- ui.setStatus('combo', undefined);
77
- return;
78
- }
79
- // Read the live bridge, not a cache: siblings reconcile it first.
80
- const state = getSharedComboState();
81
- const c0 = state.caveman.toUpperCase();
82
- const r = state.rtk.toUpperCase();
83
- const p = state.ponytail.toUpperCase();
84
- const lvl = state.level.toUpperCase();
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);
90
- }
91
68
 
92
69
  // Siblings restore from these entries, so the fallback must write them too.
93
70
  function persistPreset(level: string): void {
@@ -98,17 +75,13 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
98
75
  pi.appendEntry?.('combo-level', { level });
99
76
  }
100
77
 
101
- function useState(state: Readonly<ComboState>, ctx?: ExtensionCtx): Readonly<ComboState> {
102
- syncStatus(ctx);
103
- return state;
104
- }
105
-
106
78
  function reconcile(ctx?: ExtensionCtx): Readonly<ComboState> {
107
79
  if (!ctx?.hasUI) return getSharedComboState();
108
- return useState(reconcileSharedComboEntries(sessionEntries(ctx)), ctx);
80
+ return reconcileSharedComboEntries(sessionEntries(ctx));
109
81
  }
110
82
  function listen(ctx?: ExtensionCtx): void {
111
- if (ctx?.hasUI) setSharedComboListener('combo', useState);
83
+ if (!ctx?.hasUI) return;
84
+ setSharedComboListener('combo', (state) => { announceStatus(ctx); return state; });
112
85
  }
113
86
 
114
87
  function track(ctx?: ExtensionCtx): void {
@@ -123,11 +96,8 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
123
96
  const arg = String(args || '').trim().toLowerCase();
124
97
 
125
98
  if (!arg || arg === 'status') {
126
- const state = reconcile(ctx);
127
- ctx?.ui?.notify?.(
128
- `Combo: ${state.level === 'custom' ? 'INACTIVE' : state.level.toUpperCase()} (${levelSummary(state)})`,
129
- 'info'
130
- );
99
+ reconcile(ctx);
100
+ announceStatus(ctx);
131
101
  return;
132
102
  }
133
103
 
@@ -152,13 +122,8 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
152
122
  }
153
123
 
154
124
  persistPreset(level);
155
- useState(setSharedComboLevel(level), ctx);
156
-
157
- const active = activeModesSummary(getSharedComboState());
158
- ctx?.ui?.notify?.(
159
- level === 'off' ? `Combo off — all tersio modes inactive for this session. Active: ${active}.` : `Combo ${level} on — ${active} active for this session.`,
160
- 'info'
161
- );
125
+ setSharedComboLevel(level);
126
+ announceStatus(ctx);
162
127
  },
163
128
  });
164
129
 
@@ -174,42 +139,41 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
174
139
  const level = choice || 'off';
175
140
  if (saveComboSetup(level) && level !== 'off') {
176
141
  persistPreset(level);
177
- useState(setSharedComboLevel(level), ctx);
142
+ setSharedComboLevel(level);
178
143
  ctx.ui.notify?.(`Combo default saved: ${level} — ${activeModesSummary(getSharedComboState())} will activate on fresh sessions.`, 'info');
179
144
  }
180
145
  }
181
146
 
182
147
  pi.on('session_start', async (_event, ctx) => {
183
148
  track(ctx);
184
- if (!ctx?.hasUI) syncStatus(ctx);
185
149
  await runFirstRunSetup(ctx);
186
150
  // The configured default applies only when no persisted mode state exists.
187
151
  const entries = sessionEntries(ctx);
188
- const hasModeState = entries.some((e) => e?.type === 'custom' && (
189
- e.customType === 'combo-level' || e.customType === 'caveman-mode' ||
190
- e.customType === 'rtk-mode' || e.customType === 'ponytail-mode'));
191
- if (getSharedComboState().level === 'off' && !hasModeState) {
152
+ const persisted = hasModeState(entries);
153
+ if (getSharedComboState().level === 'off' && !persisted) {
192
154
  const fallback = readComboDefault();
193
155
  if (fallback !== 'off') {
194
156
  persistPreset(fallback);
195
- useState(setSharedComboLevel(fallback), ctx);
196
- ctx?.ui?.notify?.(`Combo default applied: ${fallback} — ${activeModesSummary(getSharedComboState())} active for this session.`, 'info');
157
+ setSharedComboLevel(fallback);
197
158
  }
198
159
  }
199
- // No sibling restores a standalone ponytail default, so apply it here; skip
200
- // it when the active preset already writes the same mode.
160
+ // No sibling restores a standalone ponytail default, so apply it here; skip it when the active preset already writes the same mode.
201
161
  if (!entries.some((e) => e?.type === 'custom' && e.customType === 'ponytail-mode')) {
202
162
  const ponytailFallback = readPonytailDefault();
203
163
  if (ponytailFallback !== 'off' && ponytailFallback !== getSharedComboState().ponytail) {
204
164
  pi.appendEntry?.('ponytail-mode', { mode: ponytailFallback });
205
- useState(setSharedComboMode('ponytail', ponytailFallback), ctx);
165
+ setSharedComboMode('ponytail', ponytailFallback);
206
166
  }
207
167
  }
168
+ // Announced last, so any default above is reflected.
169
+ announceStatus(ctx);
208
170
  });
209
171
 
210
- for (const event of ['session_branch', 'session_tree', 'agent_start']) {
172
+ // Resume, branch, and compaction re-announce; dedupe keeps repeats silent.
173
+ for (const event of ['session_branch', 'session_tree', 'agent_start', 'session_switch', 'session_compact']) {
211
174
  onHostEvent(pi, event, async (_event, ctx) => {
212
175
  track(ctx);
176
+ announceStatus(ctx);
213
177
  if (event === 'agent_start') await runFirstRunSetup(ctx);
214
178
  });
215
179
  }
@@ -217,8 +181,15 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
217
181
  pi.on<SystemPromptEvent>('before_agent_start', async (event, ctx) => {
218
182
  if (ctx?.hasUI) reconcile(ctx);
219
183
  const mode = getSharedComboState().ponytail;
220
- if (mode === 'off' || systemPromptIncludes(event.systemPrompt, 'PONYTAIL MODE ACTIVE')) return;
221
- return injectPromptText(pi, event, await loadPonytailInstructions(mode));
184
+ // On pi the upstream ponytail pi-extension is the single injector; it reads these 'ponytail-mode' entries at session start, so Tersio only persists.
185
+ if (isPiProcess()) return;
186
+ // Match the level's own header: the shared phrase alone makes every level block every other, so a `/combo` level switch is ignored all session.
187
+ if (mode === 'off' || systemPromptIncludes(event.systemPrompt, `PONYTAIL MODE ACTIVE — level: ${mode}`)) return;
188
+ const instruction = await loadPonytailInstructions(mode);
189
+ // The previous level's block is replaced, not stacked on.
190
+ const stale = lastInjected;
191
+ lastInjected = instruction;
192
+ return injectPromptText(pi, event, instruction, stale);
222
193
  });
223
194
 
224
195
  // Slash commands only; natural-language input caused accidental toggles and has no reload context.
@@ -1,5 +1,4 @@
1
- // Shared helpers for the installer and the AI add-ons updater. Node built-ins
2
- // only — this module must stay dependency-free.
1
+ // Shared helpers for the installer and the AI add-ons updater. Node built-ins only — this module must stay dependency-free.
3
2
  import { createHash } from 'node:crypto';
4
3
  import { accessSync, constants, createWriteStream, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
5
4
  import fs from 'node:fs/promises';
@@ -13,6 +12,11 @@ export function withResolvers() {
13
12
  const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
14
13
  return { promise, resolve, reject };
15
14
  }
15
+ // os.homedir() can ignore a runtime HOME, so a redirected home would read the real one.
16
+ export function homeDir() {
17
+ const home = process.env.HOME || process.env.USERPROFILE;
18
+ return home && home.trim() !== '' ? home : os.homedir();
19
+ }
16
20
  export const RTK_RELEASE_API = 'https://api.github.com/repos/rtk-ai/rtk/releases/latest';
17
21
  export const CAVEMAN_REMOTE_RULE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/caveman/SKILL.md';
18
22
  const RTK_PLATFORM_SPECS = {
@@ -22,11 +26,13 @@ const RTK_PLATFORM_SPECS = {
22
26
  'darwin/x64': { triple: 'x86_64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
23
27
  'darwin/arm64': { triple: 'aarch64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
24
28
  };
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));
29
+ // First executable `name` on PATH, else the managed Bun bin dir.
30
+ function firstExecutable(name, managedDir) {
31
+ const ext = process.platform === 'win32' ? '.cmd' : '';
32
+ const candidates = (process.env.PATH || '').split(path.delimiter).filter(Boolean)
33
+ .flatMap((dir) => [path.join(dir, name + ext), path.join(dir, name)]);
28
34
  if (managedDir)
29
- candidates.push(path.join(managedDir, name));
35
+ candidates.push(path.join(managedDir, name + ext), path.join(managedDir, name));
30
36
  for (const candidate of candidates) {
31
37
  try {
32
38
  if (!statSync(candidate).isFile())
@@ -38,6 +44,13 @@ export function resolveRtkBinary(managedDir = path.join(os.homedir(), '.bun', 'b
38
44
  }
39
45
  return null;
40
46
  }
47
+ export function resolveRtkBinary(managedDir = path.join(homeDir(), '.bun', 'bin')) {
48
+ return firstExecutable(process.platform === 'win32' ? 'rtk.exe' : 'rtk', managedDir);
49
+ }
50
+ // A host binary to scan for its subagent marker.
51
+ export function resolveHostBinary(name, managedDir = path.join(homeDir(), '.bun', 'bin')) {
52
+ return firstExecutable(name, managedDir);
53
+ }
41
54
  export function rtkPlatformSpec(platform = process.platform, arch = process.arch) {
42
55
  return RTK_PLATFORM_SPECS[`${platform}/${arch}`] || null;
43
56
  }
@@ -48,23 +61,25 @@ function redirectNext(res, url) {
48
61
  }
49
62
  return null;
50
63
  }
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.
64
+ // pi sets PI_CODING_AGENT; OMP does not. OMP does read PI_CODING_AGENT_DIR as its own relocation variable, so the override only counts under that marker.
53
65
  export function isPiProcess() {
54
66
  return process.env.PI_CODING_AGENT === 'true';
55
67
  }
56
68
  export function piAgentDir() {
57
69
  const override = process.env.PI_CODING_AGENT_DIR;
58
- return isPiProcess() && override ? override : path.join(os.homedir(), '.pi', 'agent');
70
+ return isPiProcess() && override ? override : path.join(homeDir(), '.pi', 'agent');
59
71
  }
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.
72
+ // Ponytail is a Tersio dependency that npm hoists, so it can sit in Tersio's node_modules, a host's plugin dir, or beside it. Walking up covers all.
62
73
  export function findHoistedPackage(pkg, fromDir, ...relInside) {
63
74
  let dir = fromDir;
64
75
  for (;;) {
65
76
  const candidate = path.join(dir, 'node_modules', ...pkg.split('/'), ...relInside);
66
77
  if (existsSync(candidate))
67
78
  return candidate;
79
+ // pi installs npm packages under ~/.pi/agent/npm/node_modules.
80
+ const piCandidate = path.join(dir, 'npm', 'node_modules', ...pkg.split('/'), ...relInside);
81
+ if (existsSync(piCandidate))
82
+ return piCandidate;
68
83
  const parent = path.dirname(dir);
69
84
  if (parent === dir)
70
85
  return null;
@@ -194,15 +209,45 @@ export async function readTextIfExists(p) {
194
209
  return null;
195
210
  }
196
211
  }
197
- // Tersio-owned data home: ~/.tersio. First use moves legacy
198
- // ~/.omp/plugins/tersio-* files over so existing installs keep their history.
212
+ // Chunked so a 200 MiB host binary is never held in memory. `tail` keeps the last needle.length-1 bytes of the previous chunk, because a match can begin near the end of one chunk and finish inside the next.
213
+ export async function fileContains(filePath, needleText) {
214
+ const needle = Buffer.from(needleText, 'utf8');
215
+ if (needle.length === 0)
216
+ return false;
217
+ const keep = needle.length - 1;
218
+ let handle;
219
+ try {
220
+ handle = await fs.open(filePath, 'r');
221
+ const { size } = await handle.stat();
222
+ const chunk = Buffer.allocUnsafe(1 << 20);
223
+ let tail = Buffer.alloc(0);
224
+ for (let position = 0; position < size;) {
225
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
226
+ if (bytesRead === 0)
227
+ break;
228
+ const window = Buffer.concat([tail, chunk.subarray(0, bytesRead)]);
229
+ if (window.includes(needle))
230
+ return true;
231
+ tail = window.subarray(Math.max(0, window.length - keep));
232
+ position += bytesRead;
233
+ }
234
+ return false;
235
+ }
236
+ catch {
237
+ return false;
238
+ }
239
+ finally {
240
+ await handle?.close().catch(() => { });
241
+ }
242
+ }
243
+ // Tersio-owned data home: ~/.tersio. First use moves legacy ~/.omp/plugins/tersio-* files over so existing installs keep their history.
199
244
  const migratedTersioFiles = new Set();
200
245
  export function tersioHome() {
201
246
  // Matches the other TERSIO_* overrides so a test never reads the real home.
202
247
  const override = process.env.TERSIO_HOME;
203
248
  if (override && override.trim() !== '')
204
249
  return override;
205
- return path.join(os.homedir(), '.tersio');
250
+ return path.join(homeDir(), '.tersio');
206
251
  }
207
252
  export function tersioDataPath(name, legacy) {
208
253
  const dest = path.join(tersioHome(), name);
@@ -210,7 +255,7 @@ export function tersioDataPath(name, legacy) {
210
255
  migratedTersioFiles.add(name);
211
256
  try {
212
257
  if (!existsSync(dest)) {
213
- const src = path.join(os.homedir(), '.omp', 'plugins', legacy);
258
+ const src = path.join(homeDir(), '.omp', 'plugins', legacy);
214
259
  if (existsSync(src)) {
215
260
  mkdirSync(tersioHome(), { recursive: true });
216
261
  renameSync(src, dest);