@krtclcdy/tersio 2.23.0 → 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 (55) hide show
  1. package/README.md +58 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +16 -20
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +49 -37
  7. package/cli/doctor-fix.js +28 -17
  8. package/cli/doctor.js +52 -41
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +180 -117
  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 +44 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/uninstall.js +135 -44
  21. package/cli/update.js +12 -19
  22. package/dashboard/dist/index.html +16 -16
  23. package/extensions/ai-addons-updater/index.js +31 -24
  24. package/extensions/ai-addons-updater/index.ts +32 -32
  25. package/extensions/caveman-session/index.js +6 -6
  26. package/extensions/caveman-session/index.ts +6 -6
  27. package/extensions/combo-toggle/index.js +42 -40
  28. package/extensions/combo-toggle/index.ts +36 -47
  29. package/extensions/lib/utils.js +36 -9
  30. package/extensions/lib/utils.ts +37 -9
  31. package/extensions/rtk-session/index.js +9 -9
  32. package/extensions/rtk-session/index.ts +9 -14
  33. package/extensions/shared/carbon.js +5 -8
  34. package/extensions/shared/carbon.ts +5 -8
  35. package/extensions/shared/host.js +68 -0
  36. package/extensions/shared/host.ts +104 -0
  37. package/extensions/shared/omp-prompt.js +12 -0
  38. package/extensions/shared/plugin-settings.js +43 -30
  39. package/extensions/shared/plugin-settings.ts +45 -26
  40. package/extensions/shared/pricing.js +26 -15
  41. package/extensions/shared/pricing.ts +28 -16
  42. package/extensions/shared/rtk-gain.js +11 -22
  43. package/extensions/shared/rtk-gain.ts +11 -22
  44. package/extensions/shared/session-state.js +21 -25
  45. package/extensions/shared/session-state.ts +22 -26
  46. package/extensions/shared/types.ts +10 -3
  47. package/extensions/shared/usage-ledger.js +24 -36
  48. package/extensions/shared/usage-ledger.ts +29 -50
  49. package/extensions/shared/usage-store.js +14 -12
  50. package/extensions/shared/usage-store.ts +16 -14
  51. package/extensions/tersio-commands/index.js +2 -1
  52. package/extensions/tersio-commands/index.ts +2 -1
  53. package/package.json +4 -2
  54. package/tersio.js +4 -4
  55. package/extensions/shared/mode-reinforcement.js +0 -26
@@ -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,22 +17,26 @@ import {
19
17
  setSharedComboListener,
20
18
  setSharedComboMode,
21
19
  systemPromptIncludes,
20
+ themeStatus,
22
21
  } from '../shared/session-state.ts';
22
+ import { hostSelect, injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host.ts';
23
23
  import {
24
24
  isComboSetupComplete,
25
25
  readComboDefault,
26
26
  readPonytailDefault,
27
27
  saveComboSetup,
28
28
  } from '../shared/plugin-settings.ts';
29
+ import { findHoistedPackage } from '../lib/utils.ts';
29
30
  import type { ComboState, ExtensionApi, ExtensionCtx, SystemPromptEvent } from '../shared/types.ts';
30
31
 
31
- const require = createRequire(import.meta.url);
32
+ const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
32
33
 
33
34
  const PONYTAIL_FALLBACK_INTENSITY: Record<string, string> = {
34
35
  lite: 'Prefer the simplest correct solution.',
35
36
  };
36
37
 
37
- 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 {
38
40
  const intensity = PONYTAIL_FALLBACK_INTENSITY[mode] ?? 'Use the minimum correct solution. Delete or reuse before adding.';
39
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.`;
40
42
  }
@@ -43,26 +45,25 @@ function levelSummary(state: ComboState): string {
43
45
  return `caveman=${state.caveman} rtk=${state.rtk} ponytail=${state.ponytail}`;
44
46
  }
45
47
 
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 { }
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
+ }
61
62
  return ponytailFallback(mode);
62
63
  }
63
64
 
64
65
  export default function comboToggleExtension(pi: ExtensionApi): void {
65
- pi.setLabel?.('Combo session toggle (all 3 add-ons)');
66
+ setExtensionLabel(pi, 'Combo session toggle (all 3 add-ons)');
66
67
 
67
68
  let lastCtx: ExtensionCtx | undefined = undefined;
68
69
  let setupPrompted = false;
@@ -71,32 +72,25 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
71
72
  lastCtx = paintableCtx(lastCtx, ctx);
72
73
  const c = lastCtx;
73
74
  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.
75
+ // One unified bar replaces the per-extension bars while a preset is active.
76
76
  if (!isComboPresetActive()) {
77
77
  c.ui.setStatus('combo', undefined);
78
78
  return;
79
79
  }
80
- // Read the live bridge, not cached extension state: sibling extensions
81
- // reconcile it from persisted entries before this one runs.
80
+ // Read the live bridge, not a cache: siblings reconcile it first.
82
81
  const state = getSharedComboState();
83
- const theme = c.ui.theme;
84
82
  const c0 = state.caveman.toUpperCase();
85
83
  const r = state.rtk.toUpperCase();
86
84
  const p = state.ponytail.toUpperCase();
87
85
  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.
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.
92
88
  c.ui.setStatus('caveman', undefined);
93
89
  c.ui.setStatus('rtk', undefined);
94
90
  c.ui.setStatus('ponytail', undefined);
95
91
  }
96
92
 
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.
93
+ // Siblings restore from these entries, so the fallback must write them too.
100
94
  function persistPreset(level: string): void {
101
95
  const modes = COMBO_LEVELS[level];
102
96
  pi.appendEntry?.('caveman-mode', { mode: modes.caveman });
@@ -115,8 +109,7 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
115
109
  return useState(reconcileSharedComboEntries(sessionEntries(ctx)), ctx);
116
110
  }
117
111
  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.
112
+ // Stable identity: the bridge set dedupes, so repeat calls register once.
120
113
  if (ctx?.hasUI) setSharedComboListener(useState);
121
114
  }
122
115
 
@@ -174,7 +167,7 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
174
167
  async function runFirstRunSetup(ctx?: ExtensionCtx): Promise<void> {
175
168
  if (setupPrompted || !ctx?.hasUI || !ctx.ui?.select || isComboSetupComplete()) return;
176
169
  setupPrompted = true;
177
- const choice = await ctx.ui.select('Session-start defaults — Combo preset', [
170
+ const choice = await hostSelect(pi, ctx.ui, 'Session-start defaults — Combo preset', [
178
171
  { label: 'off', description: 'Keep every Tersio mode inactive' },
179
172
  { label: 'medium', description: 'caveman=lite, rtk=on, ponytail=lite' },
180
173
  { label: 'balanced', description: 'caveman=full, rtk=on, ponytail=full' },
@@ -192,9 +185,7 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
192
185
  track(ctx);
193
186
  if (!ctx?.hasUI) syncStatus(ctx);
194
187
  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.
188
+ // The configured default applies only when no persisted mode state exists.
198
189
  const entries = sessionEntries(ctx);
199
190
  const hasModeState = entries.some((e) => e?.type === 'custom' && (
200
191
  e.customType === 'combo-level' || e.customType === 'caveman-mode' ||
@@ -207,10 +198,8 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
207
198
  ctx?.ui?.notify?.(`Combo default applied: ${fallback} — ${activeModesSummary(getSharedComboState())} active for this session.`, 'info');
208
199
  }
209
200
  }
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.
201
+ // No sibling restores a standalone ponytail default, so apply it here; skip
202
+ // it when the active preset already writes the same mode.
214
203
  if (!entries.some((e) => e?.type === 'custom' && e.customType === 'ponytail-mode')) {
215
204
  const ponytailFallback = readPonytailDefault();
216
205
  if (ponytailFallback !== 'off' && ponytailFallback !== getSharedComboState().ponytail) {
@@ -221,7 +210,7 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
221
210
  });
222
211
 
223
212
  for (const event of ['session_branch', 'session_tree', 'agent_start']) {
224
- pi.on(event, async (_event, ctx) => {
213
+ onHostEvent(pi, event, async (_event, ctx) => {
225
214
  track(ctx);
226
215
  if (event === 'agent_start') await runFirstRunSetup(ctx);
227
216
  });
@@ -233,8 +222,8 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
233
222
 
234
223
  const mode = getSharedComboState().ponytail;
235
224
  if (mode === 'off' || systemPromptIncludes(event.systemPrompt, 'PONYTAIL MODE ACTIVE')) return;
236
- const base = asPromptArray(event.systemPrompt);
237
- return { systemPrompt: [...base, loadPonytailInstructions(mode)] };
225
+ const instructions = await loadPonytailInstructions(mode);
226
+ return injectPromptText(pi, event, instructions);
238
227
  });
239
228
 
240
229
  // 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,9 +194,8 @@ 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() {
174
201
  return path.join(os.homedir(), '.tersio');
@@ -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,15 @@ 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 {
181
208
  return path.join(os.homedir(), '.tersio');
182
209
  }
183
210
 
211
+
184
212
  export function tersioDataPath(name: string, legacy: string): string {
185
213
  const dest = path.join(tersioHome(), name);
186
214
  if (!migratedTersioFiles.has(name)) {
@@ -1,4 +1,5 @@
1
- import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
1
+ import { activeModesSummary, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
2
+ import { injectPromptText, onHostEvent, setExtensionLabel, stringArrayToolParams } from '../shared/host.js';
2
3
  import { readRtkDefault } from '../shared/plugin-settings.js';
3
4
  const DEFAULT_ENABLED = false;
4
5
  function asBoolean(value) {
@@ -21,7 +22,6 @@ function setRtkProcessEnabled(enabled) {
21
22
  }
22
23
  const RTK_PROMPT = `RTK guidance active. RTK automatically rewrites eligible Bash calls through the installed rtk hook. Prefer rtk for noisy shell output, but use exact raw output for state changes, checksums, patches, and diagnostics that need full bytes.`;
23
24
  export default function rtkSessionExtension(pi) {
24
- const { z } = pi.zod;
25
25
  let enabled = DEFAULT_ENABLED;
26
26
  let isActive = false;
27
27
  let lastCtx = undefined;
@@ -45,7 +45,7 @@ export default function rtkSessionExtension(pi) {
45
45
  const active = activeModesSummary(getSharedComboState());
46
46
  ctx?.ui?.notify?.(enabled ? `RTK on — compact shell output for this session. Active: ${active}.` : `RTK off. Active: ${active}.`, 'info');
47
47
  }
48
- pi.setLabel?.('RTK session toggle');
48
+ setExtensionLabel(pi, 'RTK session toggle');
49
49
  // Live mirror: a /tersio or /combo switch publishes shared state — adopt
50
50
  // it at once so the next turn and the rtk_run gate see it, no reload.
51
51
  function syncFromShared(state) {
@@ -77,8 +77,9 @@ export default function rtkSessionExtension(pi) {
77
77
  name: 'rtk_run',
78
78
  label: 'RTK Run',
79
79
  description: 'Run the installed `rtk` binary for compact command output when RTK mode is enabled.',
80
- parameters: z.object({
81
- args: z.array(z.string()).min(1).describe("Arguments passed to rtk, e.g. ['git','status'] or ['read','src/index.ts']"),
80
+ parameters: stringArrayToolParams(pi, 'args', {
81
+ minItems: 1,
82
+ description: "Arguments passed to rtk, e.g. ['git','status'] or ['read','src/index.ts']",
82
83
  }),
83
84
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
84
85
  if (!enabled) {
@@ -98,7 +99,7 @@ export default function rtkSessionExtension(pi) {
98
99
  };
99
100
  },
100
101
  });
101
- pi.on('input', async (event) => {
102
+ onHostEvent(pi, 'input', async (event) => {
102
103
  if (event?.source === 'extension')
103
104
  return;
104
105
  const t = normalizeInputCommand(event?.text);
@@ -125,7 +126,7 @@ export default function rtkSessionExtension(pi) {
125
126
  restoreEnabled(ctx);
126
127
  ctx?.ui?.notify?.(`RTK loaded: ${enabled ? 'on' : 'off'}`, 'info');
127
128
  });
128
- pi.on('session_branch', async (_event, ctx) => {
129
+ onHostEvent(pi, 'session_branch', async (_event, ctx) => {
129
130
  restoreEnabled(ctx);
130
131
  });
131
132
  pi.on('session_tree', async (_event, ctx) => {
@@ -143,7 +144,6 @@ export default function rtkSessionExtension(pi) {
143
144
  const active = isOmpSubagentPrompt(event.systemPrompt) ? getSharedComboState().rtk === 'on' : enabled;
144
145
  if (!active)
145
146
  return;
146
- const base = asPromptArray(event.systemPrompt);
147
- return { systemPrompt: [...base, RTK_PROMPT] };
147
+ return injectPromptText(pi, event, RTK_PROMPT);
148
148
  });
149
149
  }
@@ -1,4 +1,5 @@
1
- import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.ts';
1
+ import { activeModesSummary, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.ts';
2
+ import { injectPromptText, onHostEvent, setExtensionLabel, stringArrayToolParams } from '../shared/host.ts';
2
3
  import { readRtkDefault } from '../shared/plugin-settings.ts';
3
4
  import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
4
5
 
@@ -22,13 +23,7 @@ function setRtkProcessEnabled(enabled: boolean): void {
22
23
 
23
24
  const RTK_PROMPT = `RTK guidance active. RTK automatically rewrites eligible Bash calls through the installed rtk hook. Prefer rtk for noisy shell output, but use exact raw output for state changes, checksums, patches, and diagnostics that need full bytes.`;
24
25
 
25
- interface ZodChain {
26
- min: (n: number) => ZodChain;
27
- describe: (text: string) => ZodChain;
28
- }
29
-
30
26
  export default function rtkSessionExtension(pi: ExtensionApi): void {
31
- const { z } = pi.zod as { z: { object: (shape: Record<string, unknown>) => Record<string, unknown>; array: (el: unknown) => ZodChain; string: () => ZodChain } };
32
27
  let enabled = DEFAULT_ENABLED;
33
28
  let isActive = false;
34
29
  let lastCtx: ExtensionCtx | undefined = undefined;
@@ -54,7 +49,7 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
54
49
  ctx?.ui?.notify?.(enabled ? `RTK on — compact shell output for this session. Active: ${active}.` : `RTK off. Active: ${active}.`, 'info');
55
50
  }
56
51
 
57
- pi.setLabel?.('RTK session toggle');
52
+ setExtensionLabel(pi, 'RTK session toggle');
58
53
 
59
54
  // Live mirror: a /tersio or /combo switch publishes shared state — adopt
60
55
  // it at once so the next turn and the rtk_run gate see it, no reload.
@@ -88,8 +83,9 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
88
83
  name: 'rtk_run',
89
84
  label: 'RTK Run',
90
85
  description: 'Run the installed `rtk` binary for compact command output when RTK mode is enabled.',
91
- parameters: z.object({
92
- args: z.array(z.string()).min(1).describe("Arguments passed to rtk, e.g. ['git','status'] or ['read','src/index.ts']"),
86
+ parameters: stringArrayToolParams(pi, 'args', {
87
+ minItems: 1,
88
+ description: "Arguments passed to rtk, e.g. ['git','status'] or ['read','src/index.ts']",
93
89
  }),
94
90
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
95
91
  if (!enabled) {
@@ -110,7 +106,7 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
110
106
  },
111
107
  });
112
108
 
113
- pi.on<InputEvent>('input', async (event) => {
109
+ onHostEvent<InputEvent>(pi, 'input', async (event) => {
114
110
  if (event?.source === 'extension') return;
115
111
  const t = normalizeInputCommand(event?.text);
116
112
  if (t === 'rtk on' || t === 'use rtk') setEnabled(true);
@@ -136,7 +132,7 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
136
132
  ctx?.ui?.notify?.(`RTK loaded: ${enabled ? 'on' : 'off'}`, 'info');
137
133
  });
138
134
 
139
- pi.on('session_branch', async (_event, ctx) => {
135
+ onHostEvent(pi, 'session_branch', async (_event, ctx) => {
140
136
  restoreEnabled(ctx);
141
137
  });
142
138
 
@@ -157,7 +153,6 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
157
153
  pi.on<SystemPromptEvent>('before_agent_start', async (event) => {
158
154
  const active = isOmpSubagentPrompt(event.systemPrompt) ? getSharedComboState().rtk === 'on' : enabled;
159
155
  if (!active) return;
160
- const base = asPromptArray(event.systemPrompt);
161
- return { systemPrompt: [...base, RTK_PROMPT] };
156
+ return injectPromptText(pi, event, RTK_PROMPT);
162
157
  });
163
158
  }
@@ -1,14 +1,11 @@
1
1
  // extensions/shared/carbon.ts — CO2/energy footprints, EcoLogits-style.
2
2
  //
3
3
  // Port of the EcoLogits v0.8.2 LLM impact model (genai-impact/ecologits,
4
- // MPL-2.0 — see ECO_NOTICE.md), following the same served-estimate approach
5
- // as honey-for-devs' hooks/eco.js: the faithful single-stream (batch-1)
6
- // ceiling divided by serving_concurrency for continuous-batching amortization.
7
- //
8
- // Model params come from EcoLogits' registry where it covers the model;
9
- // otherwise a documented class borrow (never a silent guess — every borrow
10
- // carries its source). Unknown ids fall to the gpt-4o default. Everything
11
- // rendered from here must carry ~est. and never merge with measured figures.
4
+ // MPL-2.0 — see ECO_NOTICE.md): the single-stream (batch-1) ceiling divided by
5
+ // serving_concurrency for continuous-batching amortization. Model params come
6
+ // from EcoLogits' registry, else a documented class borrow; unknown ids fall
7
+ // to the gpt-4o default. Render everything from here as ~est., never merged
8
+ // with measured figures.
12
9
  // EcoLogits 0.8.2 constants — kWh, kgCO2eq, seconds, GB.
13
10
  const Q_BITS = 4;
14
11
  const E_ALPHA = 8.91e-8;
@@ -1,14 +1,11 @@
1
1
  // extensions/shared/carbon.ts — CO2/energy footprints, EcoLogits-style.
2
2
  //
3
3
  // Port of the EcoLogits v0.8.2 LLM impact model (genai-impact/ecologits,
4
- // MPL-2.0 — see ECO_NOTICE.md), following the same served-estimate approach
5
- // as honey-for-devs' hooks/eco.js: the faithful single-stream (batch-1)
6
- // ceiling divided by serving_concurrency for continuous-batching amortization.
7
- //
8
- // Model params come from EcoLogits' registry where it covers the model;
9
- // otherwise a documented class borrow (never a silent guess — every borrow
10
- // carries its source). Unknown ids fall to the gpt-4o default. Everything
11
- // rendered from here must carry ~est. and never merge with measured figures.
4
+ // MPL-2.0 — see ECO_NOTICE.md): the single-stream (batch-1) ceiling divided by
5
+ // serving_concurrency for continuous-batching amortization. Model params come
6
+ // from EcoLogits' registry, else a documented class borrow; unknown ids fall
7
+ // to the gpt-4o default. Render everything from here as ~est., never merged
8
+ // with measured figures.
12
9
 
13
10
  export interface ModelCarbon {
14
11
  activeB: number;
@@ -0,0 +1,68 @@
1
+ // extensions/shared/host.ts — one extension source, two agent hosts.
2
+ //
3
+ // OMP is a fork of pi, and six surfaces differ: tool schemas (OMP injects
4
+ // `pi.zod`, pi takes any object it forwards as JSON Schema), `setLabel`
5
+ // (pi's takes an entryId, so an extension name must never reach it),
6
+ // `session_branch` (pi reports that switch as `session_tree`), select options
7
+ // (pi takes plain strings), the status-bar theme (pi has no ctx.ui.theme), and
8
+ // prompt injection (pi appends to its sections rather than replacing them).
9
+ //
10
+ // The host is read from the API shape, never a name or an env marker: OMP is
11
+ // the fork that injects zod, and pi does not, including through the SDK.
12
+ import { asPromptArray } from './session-state.js';
13
+ export function isPiHost(pi) {
14
+ return !pi.zod;
15
+ }
16
+ export function setExtensionLabel(pi, label) {
17
+ if (isPiHost(pi))
18
+ return;
19
+ pi.setLabel?.(label);
20
+ }
21
+ export function onHostEvent(pi, event, handler) {
22
+ if (event === 'session_branch' && isPiHost(pi))
23
+ return;
24
+ pi.on(event, handler);
25
+ }
26
+ // Returns the option's label on both hosts: pi renders plain strings, so the
27
+ // description rides along inside the label and is stripped back out.
28
+ export async function hostSelect(pi, ui, title, options, dialogOptions) {
29
+ if (!ui?.select)
30
+ return undefined;
31
+ if (!isPiHost(pi))
32
+ return ui.select(title, options, dialogOptions);
33
+ const shown = options.map((option) => (option.description ? `${option.label} — ${option.description}` : option.label));
34
+ const picked = await ui.select(title, shown, dialogOptions);
35
+ if (picked === undefined)
36
+ return undefined;
37
+ return options[shown.indexOf(picked)]?.label ?? picked;
38
+ }
39
+ // What a `before_agent_start` handler hands back; undefined once pi was
40
+ // updated in place.
41
+ export function injectPromptText(pi, event, text) {
42
+ if (!isPiHost(pi))
43
+ return { systemPrompt: [...asPromptArray(event.systemPrompt), text] };
44
+ // Appending keeps pi's sections and the transcript diff; replacing the
45
+ // prompt the OMP way would drop every section.
46
+ const options = event.systemPromptOptions;
47
+ if (options && typeof options.appendSystemPrompt === 'string') {
48
+ options.appendSystemPrompt = [options.appendSystemPrompt, text].filter(Boolean).join('\n\n');
49
+ return undefined;
50
+ }
51
+ return { systemPrompt: `${asPromptArray(event.systemPrompt).join('\n\n')}\n\n${text}` };
52
+ }
53
+ // One string-array argument in the active host's dialect. pi only requires an
54
+ // object, so a JSON Schema literal suffices and no `typebox` import is needed.
55
+ export function stringArrayToolParams(pi, name, spec = {}) {
56
+ if (!isPiHost(pi)) {
57
+ const { z } = pi.zod;
58
+ const array = z.array(z.string());
59
+ const constrained = spec.minItems ? array.min(spec.minItems) : array;
60
+ return z.object({ [name]: spec.description ? constrained.describe(spec.description) : constrained });
61
+ }
62
+ const array = { type: 'array', items: { type: 'string' } };
63
+ if (spec.minItems)
64
+ array.minItems = spec.minItems;
65
+ if (spec.description)
66
+ array.description = spec.description;
67
+ return { type: 'object', properties: { [name]: array }, required: [name] };
68
+ }