@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
package/cli/manifest.js CHANGED
@@ -2,10 +2,16 @@
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  export const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
5
+ /** Root entrypoint OpenCode resolves for the installed tree; re-exports the versioned entry beside it. Built by join so the path never reads as a relative import (repo-integrity scans literals). */
6
+ const SERVER_SPEC = `./${['opencode', 'server.ts'].join('/')}`;
7
+ export const OPENCODE_SERVER_SHIM = `// Generated by \`tersio install --host opencode\`: OpenCode resolves <pluginDir>/server.ts.
8
+ export { default } from "${SERVER_SPEC}";
9
+ `;
5
10
  /** Every file in the tree, as paths relative to the extensions directory. */
6
11
  export const TREE_FILES = [
7
12
  'shared/host.ts',
8
13
  'shared/session-state.ts',
14
+ 'shared/status.ts',
9
15
  'shared/types.ts',
10
16
  'shared/plugin-settings.ts',
11
17
  'shared/usage-ledger.ts',
@@ -18,6 +24,7 @@ export const TREE_FILES = [
18
24
  'combo-toggle/index.ts',
19
25
  'tersio-commands/index.ts',
20
26
  'ai-addons-updater/index.ts',
27
+ 'opencode/server.ts',
21
28
  ];
22
29
  export function sourcePath(relative) {
23
30
  return path.join(EXT_DIR, ...relative.split('/'));
package/cli/profile.js CHANGED
@@ -1,12 +1,19 @@
1
1
  // cli/profile.ts — session-start defaults and the display-currency default,
2
- // stored in ~/.tersio/settings.json. Shared by install and settings.
3
- import { existsSync, promises as fs } from 'node:fs';
2
+ import { existsSync, promises as fs, readFileSync } from 'node:fs';
4
3
  import path from 'node:path';
5
4
  import { CAVEMAN_DEFAULTS, COMBO_PRESET_MODES, PACKAGE_NAME, PONYTAIL_DEFAULTS, verbose, } from './common.js';
6
5
  import os from 'node:os';
7
6
  import { DEFAULT_CURRENCY, isCurrencyCode } from './currency.js';
8
7
  import { readTextIfExists } from '../extensions/lib/utils.js';
9
8
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
9
+ function readTextIfExistsSync(file) {
10
+ try {
11
+ return existsSync(file) ? readFileSync(file, 'utf8') : null;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
10
17
  export const BACKUP_SCHEDULES = new Set(['manual', 'daily', 'weekly', 'monthly']);
11
18
  function defaultProfile() {
12
19
  return {
@@ -16,16 +23,19 @@ function defaultProfile() {
16
23
  ponytailDefault: 'off',
17
24
  currency: DEFAULT_CURRENCY,
18
25
  backupSchedule: 'monthly',
26
+ subagentMarkers: [],
19
27
  };
20
28
  }
21
- // Seed once from OMP plugin settings so a pre-~/.tersio install keeps its values.
22
- async function storedProfile() {
29
+ // Seed once from OMP plugin settings so a pre-~/.tersio install keeps its values. Sync: awaiting let piped stdin arrive before `ask()` attached, so a scripted answer was swallowed and a destructive confirm defaulted to abort.
30
+ function storedProfileSync() {
23
31
  const base = defaultProfile();
24
- const stored = (await readTextIfExists(tersioSettingsFile())) !== null
25
- ? parseStored(await readTextIfExists(tersioSettingsFile()))
26
- : parseStored(await readTextIfExists(legacyOmpLockPath()));
32
+ const stored = parseStored(readTextIfExistsSync(tersioSettingsFile()) ?? readTextIfExistsSync(legacyOmpLockPath()));
27
33
  if (!stored)
28
34
  return base;
35
+ applyStored(base, stored);
36
+ return base;
37
+ }
38
+ function applyStored(base, stored) {
29
39
  if (typeof stored.comboDefault === 'string' && stored.comboDefault in COMBO_PRESET_MODES)
30
40
  base.comboDefault = stored.comboDefault;
31
41
  if (typeof stored.cavemanDefault === 'string' && CAVEMAN_DEFAULTS.has(stored.cavemanDefault))
@@ -40,7 +50,13 @@ async function storedProfile() {
40
50
  if (typeof stored.backupSchedule === 'string' && BACKUP_SCHEDULES.has(stored.backupSchedule)) {
41
51
  base.backupSchedule = stored.backupSchedule;
42
52
  }
43
- return base;
53
+ if (Array.isArray(stored.subagentMarkers)) {
54
+ // An empty marker would match every turn.
55
+ base.subagentMarkers = stored.subagentMarkers.filter((m) => typeof m === 'string' && m.trim() !== '');
56
+ }
57
+ }
58
+ async function storedProfile() {
59
+ return storedProfileSync();
44
60
  }
45
61
  function legacyOmpLockPath() {
46
62
  const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
@@ -64,8 +80,7 @@ function parseStored(raw) {
64
80
  return null;
65
81
  }
66
82
  }
67
- // Persist the profile where every host reads it. `omp plugin config get` no
68
- // longer reports it; `tersio settings` does.
83
+ // Persist the profile where every host reads it. `omp plugin config get` no longer reports it; `tersio settings` does.
69
84
  async function writePluginSettings(profile, options) {
70
85
  const file = tersioSettingsFile();
71
86
  const values = {
@@ -77,6 +92,7 @@ async function writePluginSettings(profile, options) {
77
92
  ponytailDefault: profile.ponytailDefault,
78
93
  currency: profile.currency,
79
94
  backupSchedule: profile.backupSchedule,
95
+ ...(profile.subagentMarkers.length > 0 ? { subagentMarkers: profile.subagentMarkers } : {}),
80
96
  };
81
97
  if (options.dryRun) {
82
98
  if (verbose && !options.quiet)
@@ -90,4 +106,19 @@ async function writePluginSettings(profile, options) {
90
106
  function formatProfile(profile) {
91
107
  return `combo=${profile.comboDefault} (caveman=${profile.cavemanDefault} · rtk=${profile.rtkDefault ? 'on' : 'off'} · ponytail=${profile.ponytailDefault}) · currency=${profile.currency}`;
92
108
  }
93
- export { defaultProfile, formatProfile, storedProfile, writePluginSettings };
109
+ export { defaultProfile, formatProfile, storedProfile, storedProfileSync, writePluginSettings };
110
+ /** The status line the extensions print, read from the persisted profile. */
111
+ export function formatCliStatus(profile) {
112
+ const { comboDefault, cavemanDefault, rtkDefault, ponytailDefault } = profile;
113
+ if (comboDefault === 'off' && cavemanDefault === 'off' && !rtkDefault && ponytailDefault === 'off') {
114
+ return '🧩 combo OFF';
115
+ }
116
+ const preset = COMBO_PRESET_MODES[comboDefault];
117
+ const isPreset = preset !== undefined
118
+ && preset.caveman === cavemanDefault
119
+ && preset.rtk === rtkDefault
120
+ && preset.ponytail === ponytailDefault;
121
+ const level = isPreset ? comboDefault.toUpperCase() : 'CUSTOM';
122
+ const rtk = rtkDefault ? 'ON' : 'OFF';
123
+ return `🧩 combo ${level}: 🪨caveman=${cavemanDefault.toUpperCase()} ⚡rtk=${rtk} 🦥ponytail=${ponytailDefault.toUpperCase()}`;
124
+ }
package/cli/rtk-wiring.js CHANGED
@@ -26,25 +26,39 @@ function execFileP(cmd, args, timeout) {
26
26
  });
27
27
  });
28
28
  }
29
- // Idempotent: rtk rewrites its file on every init run.
30
- export async function wireRtkOmp(rtkBin, options = {}) {
29
+ async function runWire(rtkBin, spec, options) {
31
30
  if (!options.dryRun)
32
- debugWire(options, 'Wiring rtk → OMP (bash tool_call rewrite)…');
31
+ debugWire(options, `Wiring rtk → ${spec.host} (${spec.mechanism} rewrite)…`);
33
32
  if (options.dryRun)
34
33
  return true;
35
- const wired = await runRtkInit(rtkBin, options);
36
- if (!wired)
34
+ try {
35
+ await tryRtkInit(rtkBin, spec.initArgs);
36
+ }
37
+ catch (e) {
38
+ console.log(` [warn] could not wire rtk → ${spec.host}: ${shortWiringError(e)}`);
39
+ console.log(` [hint] Manual: ${spec.hint}`);
40
+ return false;
41
+ }
42
+ return true;
43
+ }
44
+ export async function wireRtkOmp(rtkBin, options = {}) {
45
+ if (!(await runWire(rtkBin, { host: 'OMP', mechanism: 'bash tool_call', initArgs: ['init', '-g', '--agent', 'omp'], hint: 'rtk init -g --agent omp (needs rtk >= 0.49)' }, options)))
37
46
  return false;
38
47
  await ensureRtkInConfig(options);
39
48
  return true;
40
49
  }
50
+ export async function wireRtkOpencode(rtkBin, options = {}) {
51
+ return runWire(rtkBin, { host: 'OpenCode', mechanism: 'tool.execute.before', initArgs: ['init', '-g', '--opencode'], hint: 'rtk init -g --opencode' }, options);
52
+ }
53
+ export async function wireRtkPi(rtkBin, options = {}) {
54
+ return runWire(rtkBin, { host: 'pi', mechanism: 'bash tool_call', initArgs: ['init', '-g', '--agent', 'pi'], hint: 'rtk init -g --agent pi' }, options);
55
+ }
41
56
  // The rtk.ts path rtk init writes (OMP_AGENT_DIR inlined; see header note).
42
57
  function rtkExtensionPath() {
43
58
  const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
44
59
  return path.join(home, '.omp', 'agent', 'extensions', 'rtk.ts');
45
60
  }
46
- // Append rtk.ts after the existing entries (or create the key) so the wire
47
- // takes effect on the next OMP start.
61
+ // Append rtk.ts after the existing entries (or create the key) so the wire takes effect on the next OMP start.
48
62
  export async function ensureRtkInConfig(options) {
49
63
  const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
50
64
  const configPath = path.join(home, '.omp', 'agent', 'config.yml');
@@ -81,26 +95,14 @@ export async function ensureRtkInConfig(options) {
81
95
  console.log(` [warn] could not register rtk in config.yml: ${e.message}`);
82
96
  }
83
97
  }
84
- async function runRtkInit(rtkBin, options) {
98
+ async function tryRtkInit(rtkBin, initArgs) {
85
99
  try {
86
- await execFileP(rtkBin, ['init', '-g', '--agent', 'omp'], 30000);
87
- debugWire(options, 'rtk OMP extension wired');
88
- return true;
100
+ await execFileP(rtkBin, initArgs, 30000);
101
+ return;
89
102
  }
90
- catch (first) {
91
- // A fresh binary can lose its first exec to macOS Gatekeeper, so settle and
92
- // retry once before reporting failure.
93
- await new Promise((resolve) => setTimeout(resolve, 2000));
94
- try {
95
- await execFileP(rtkBin, ['init', '-g', '--agent', 'omp'], 30000);
96
- debugWire(options, 'rtk OMP extension wired');
97
- return true;
98
- }
99
- catch (e) {
100
- void first;
101
- console.log(` [warn] could not wire rtk → OMP: ${shortWiringError(e)}`);
102
- console.log(' [hint] Manual: rtk init -g --agent omp (needs rtk >= 0.49)');
103
- return false;
104
- }
103
+ catch {
104
+ // A fresh binary can lose its first exec to macOS Gatekeeper: settle, then retry once.
105
105
  }
106
+ await new Promise((resolve) => setTimeout(resolve, 2000));
107
+ await execFileP(rtkBin, initArgs, 30000);
106
108
  }
package/cli/settings.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // cli/settings.ts — view/edit session-start defaults and the display currency.
2
2
  import path from 'node:path';
3
- import { CAVEMAN_DEFAULTS, COMBO_DEFAULTS, COMBO_PRESET_MODES, OMP_PLUGINS_DIR, PACKAGE_NAME, PONYTAIL_DEFAULTS, cavemanDefaultFlag, comboDefaultFlag, currency, currencyGiven, diagScheduleFlag, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, settingArg, } from './common.js';
3
+ import { CAVEMAN_DEFAULTS, COMBO_DEFAULTS, COMBO_PRESET_MODES, OMP_PLUGINS_DIR, PACKAGE_NAME, PONYTAIL_DEFAULTS, cavemanDefaultFlag, comboDefaultFlag, currency, currencyGiven, diagScheduleFlag, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, settingArg, subagentMarkerFlags, subagentMarkersGiven, } from './common.js';
4
4
  import { CURRENCY_CODES } from './currency.js';
5
5
  import { textTable } from './usage.js';
6
- import { askInteractiveChoice, closeRL, tty } from './interactive.js';
7
- import { formatProfile, storedProfile, writePluginSettings } from './profile.js';
6
+ import { ask, askInteractiveChoice, closeRL, tty } from './interactive.js';
7
+ import { formatCliStatus, formatProfile, storedProfile, writePluginSettings } from './profile.js';
8
8
  import { readDiagSchedule, setDiagSchedule } from './dashboard.js';
9
9
  function applyFlags(base) {
10
10
  const next = { ...base };
@@ -20,6 +20,8 @@ function applyFlags(base) {
20
20
  next.rtkDefault = rtkDefaultFlag === 'on';
21
21
  if (ponytailDefaultFlag !== undefined)
22
22
  next.ponytailDefault = ponytailDefaultFlag;
23
+ if (subagentMarkersGiven)
24
+ next.subagentMarkers = subagentMarkerFlags;
23
25
  if (currencyGiven)
24
26
  next.currency = currency;
25
27
  return next;
@@ -41,6 +43,7 @@ async function askProfile(current) {
41
43
  ponytailDefault: preset.ponytail,
42
44
  currency: current.currency,
43
45
  backupSchedule: current.backupSchedule,
46
+ subagentMarkers: current.subagentMarkers,
44
47
  };
45
48
  const caveman = await askInteractiveChoice('Caveman default', [...CAVEMAN_DEFAULTS].map((v) => ({
46
49
  value: v, label: v,
@@ -67,8 +70,26 @@ async function askProfile(current) {
67
70
  if (cur.status !== 'selected')
68
71
  return null;
69
72
  next.currency = cur.value;
73
+ const markers = await askSubagentMarkers(next.subagentMarkers);
74
+ if (markers === null)
75
+ return null;
76
+ next.subagentMarkers = markers;
70
77
  return next;
71
78
  }
79
+ // One marker per line; an empty line ends the list. Null aborts.
80
+ async function askSubagentMarkers(current) {
81
+ console.log(' Subagent prompt markers (one per line, empty line to finish).');
82
+ if (current.length > 0)
83
+ console.log(` Current: ${current.join(' | ')}`);
84
+ const entered = [];
85
+ for (;;) {
86
+ const line = (await ask(` marker ${entered.length + 1}: `)).trim();
87
+ if (line === '')
88
+ break;
89
+ entered.push(line);
90
+ }
91
+ return entered;
92
+ }
72
93
  async function askDiagSchedule(current) {
73
94
  const picked = await askInteractiveChoice('Diagnosis auto-check', [
74
95
  { value: 'manual', label: 'manual', hint: 'check on open' },
@@ -80,12 +101,11 @@ async function askDiagSchedule(current) {
80
101
  return null;
81
102
  return picked.value;
82
103
  }
83
- const SETTING_NAMES = ['combo', 'caveman', 'rtk', 'ponytail', 'currency', 'diagnosis'];
104
+ const SETTING_NAMES = ['combo', 'caveman', 'rtk', 'ponytail', 'currency', 'diagnosis', 'markers'];
84
105
  function settingsUsage() {
85
- console.log(' Usage: tersio settings [combo|caveman|rtk|ponytail|currency|diagnosis] [--combo-default off|medium|balanced|max] [--caveman-default off|lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra] [--rtk-default on|off] [--ponytail-default off|lite|full|ultra] [--currency USD|PHP|EUR|GBP|JPY|KRW|SGD|AUD|CAD|INR] [--diag-schedule manual|daily|weekly|monthly] [--dry-run]');
106
+ console.log(' Usage: tersio settings [combo|caveman|rtk|ponytail|currency|diagnosis|markers] [--combo-default off|medium|balanced|max] [--caveman-default off|lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra] [--rtk-default on|off] [--ponytail-default off|lite|full|ultra] [--currency USD|PHP|EUR|GBP|JPY|KRW|SGD|AUD|CAD|INR] [--diag-schedule manual|daily|weekly|monthly] [--subagent-marker <text>]... [--dry-run]');
86
107
  }
87
- // Single-setting jump: `tersio settings diagnosis` prompts only that value
88
- // instead of walking the whole chain. Returns false when aborted.
108
+ // Single-setting jump: `tersio settings diagnosis` prompts only that value instead of walking the whole chain. Returns false when aborted.
89
109
  async function runSingleSetting(name, current, nextDiag) {
90
110
  const next = { ...current };
91
111
  let diag = nextDiag;
@@ -150,6 +170,13 @@ async function runSingleSetting(name, current, nextDiag) {
150
170
  diag = picked;
151
171
  break;
152
172
  }
173
+ case 'markers': {
174
+ const markers = await askSubagentMarkers(next.subagentMarkers);
175
+ if (markers === null)
176
+ return null;
177
+ next.subagentMarkers = markers;
178
+ break;
179
+ }
153
180
  default: {
154
181
  console.error(`[fail] Invalid setting: ${name}. Valid: ${SETTING_NAMES.join(', ')}`);
155
182
  process.exit(1);
@@ -166,6 +193,7 @@ function printSettingsTable(current) {
166
193
  ['rtk', current.rtkDefault ? 'on' : 'off', 'on | off'],
167
194
  ['ponytail', current.ponytailDefault, [...PONYTAIL_DEFAULTS].join(' | ')],
168
195
  ['diagnosis', readDiagSchedule(), 'manual | daily | weekly | monthly'],
196
+ ['markers', current.subagentMarkers.length === 0 ? 'default' : current.subagentMarkers.join(' | '), 'host subagent prompt text'],
169
197
  ];
170
198
  for (const l of textTable(['Setting', 'Current', 'Valid values'], rows, [false, false, false], 64)) {
171
199
  console.log(l);
@@ -201,7 +229,7 @@ async function runSettings() {
201
229
  next = single.profile;
202
230
  nextDiag = single.diag;
203
231
  }
204
- else if (profileFlagsGiven || currencyGiven || diagScheduleFlag !== undefined) {
232
+ else if (profileFlagsGiven || currencyGiven || diagScheduleFlag !== undefined || subagentMarkersGiven) {
205
233
  next = applyFlags(current);
206
234
  }
207
235
  else if (tty()) {
@@ -232,6 +260,7 @@ async function runSettings() {
232
260
  }
233
261
  await writePluginSettings(next, {});
234
262
  setDiagSchedule(nextDiag);
263
+ console.log(` ${formatCliStatus(next)}`);
235
264
  console.log(` Defaults: ${formatProfile(next)} · diagnosis=${nextDiag}`);
236
265
  console.log(' Applies to fresh sessions only — anything persisted with /combo, /caveman, or /rtk wins.');
237
266
  console.log(' Currency applies to usage/dashboard reports; --currency overrides it per run.');
package/cli/uninstall.js CHANGED
@@ -1,14 +1,13 @@
1
1
  // cli/uninstall.ts — remove managed extensions, plugins, and binaries.
2
2
  import { promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, RTK_BINARY_NAME, args, dryRun, keepPonytail, removePonytail, removeRtk, yes, debug, writeConfigLines, } from './common.js';
4
+ import { BUN_BIN_DIR, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, RTK_BINARY_NAME, args, dryRun, keepPonytail, removePonytail, removeRtk, yes, debug, writeConfigLines, } from './common.js';
5
5
  import { askInteractiveChoice, closeRL, confirmDestructive, tty, sayTagged } from './interactive.js';
6
6
  import { detectHosts, hostHint, hostLabel, parseHostArg } from './hosts.js';
7
7
  import { findHoistedPackage, piAgentDir, readTextIfExists } from '../extensions/lib/utils.js';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
10
- // The pi tree mirrors the OMP one. Ponytail and rtk stay: a package and a
11
- // shared binary are not ours to delete.
10
+ // The pi tree mirrors the OMP one. Ponytail and rtk stay: a package and a shared binary are not ours to delete.
12
11
  const PONYTAIL_PKG = '@dietrichgebert/ponytail';
13
12
  const PI_TREE_DIRS = [
14
13
  'caveman-session',
@@ -18,6 +17,7 @@ const PI_TREE_DIRS = [
18
17
  'tersio-commands',
19
18
  'shared',
20
19
  'lib',
20
+ 'opencode',
21
21
  ];
22
22
  // One `pi remove`, for both packages the installer adds there.
23
23
  async function piRemove(spec, shouldDryRun) {
@@ -79,8 +79,7 @@ async function removePiLayer(host, shouldDryRun, shouldRemovePonytail, shouldRem
79
79
  await removeCopiedPonytailSkills(shouldDryRun);
80
80
  }
81
81
  await Promise.all(targets.map((t) => removeUninstallTarget(t, shouldDryRun)));
82
- // rtk's wiring is a loose file, not one of the tree dirs, so it survived
83
- // --remove-rtk on pi while OMP removed its copy.
82
+ // rtk's wiring is a loose file, not one of the tree dirs, so it survived --remove-rtk on pi while OMP removed its copy.
84
83
  if (shouldRemoveRtk) {
85
84
  await removeUninstallTarget(rtkWiring, shouldDryRun, false);
86
85
  await removeUninstallTarget(path.join(BUN_BIN_DIR, RTK_BINARY_NAME), shouldDryRun, false);
@@ -186,6 +185,39 @@ async function runUninstall(options = {}) {
186
185
  closeRL();
187
186
  return removed;
188
187
  }
188
+ if (host === 'opencode') {
189
+ const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
190
+ const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
191
+ if (!selected.installed && !selected.declared) {
192
+ sayTagged(` [skip] ${selected.label} — nothing installed (${selected.installCmd})`);
193
+ closeRL();
194
+ return false;
195
+ }
196
+ console.log('Will remove:');
197
+ console.log(` ${dir}`);
198
+ console.log(` ${path.join(HOME, '.config', 'opencode', 'plugins', 'rtk.ts')} (rtk auto-wrap; reinstall with: rtk init -g --opencode)`);
199
+ console.log(` the tersio entry in ${configPath}`);
200
+ if (!confirmed && !(await confirmDestructive('Remove these Tersio files?'))) {
201
+ closeRL();
202
+ return false;
203
+ }
204
+ await removeUninstallTarget(dir, shouldDryRun);
205
+ await removeUninstallTarget(path.join(HOME, '.config', 'opencode', 'plugins', 'rtk.ts'), shouldDryRun, false);
206
+ if (!shouldDryRun) {
207
+ try {
208
+ const raw = await readTextIfExists(configPath);
209
+ const config = raw ? JSON.parse(raw) : {};
210
+ if (Array.isArray(config.plugins)) {
211
+ config.plugins = config.plugins.filter((p) => typeof p !== 'string' || !p.includes('/opencode/plugins/tersio/'));
212
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
213
+ }
214
+ }
215
+ catch { /* config missing or not JSON; nothing to scrub */ }
216
+ }
217
+ console.log('\nDone. Restart OpenCode to drop the plugin.');
218
+ closeRL();
219
+ return true;
220
+ }
189
221
  if (!selected.installed)
190
222
  sayTagged(` [note] ${selected.label} has no tersio install; removing what is left behind.`);
191
223
  const extDir = path.join(OMP_AGENT_DIR, 'extensions');
@@ -204,6 +236,8 @@ async function runUninstall(options = {}) {
204
236
  'lib',
205
237
  // Legacy: imports session-state.js, so it warns once that module is gone.
206
238
  'aaa-combo-boot',
239
+ // Foreign to the OMP loader; never shipped here on purpose.
240
+ 'opencode',
207
241
  ].map((dir) => path.join(extDir, dir));
208
242
  console.log('Will remove:');
209
243
  for (const t of targets) {
@@ -230,8 +264,7 @@ async function runUninstall(options = {}) {
230
264
  return false;
231
265
  if (shouldRemovePonytail && l.includes('ponytail') && l.includes('pi-extension'))
232
266
  return false;
233
- // Our rtk wiring, absolute or relative. Leaving it listed would keep
234
- // OMP loading a hook for a package that is no longer installed.
267
+ // Our rtk wiring, absolute or relative. Leaving it listed would keep OMP loading a hook for a package that is no longer installed.
235
268
  const entry = l.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, '');
236
269
  if (entry.endsWith(`extensions${path.sep}rtk.ts`) || entry.endsWith('extensions/rtk.ts'))
237
270
  return false;
package/cli/update.js CHANGED
@@ -2,11 +2,12 @@
2
2
  import { promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
- import { IS_WINDOWS, OMP_PLUGINS_DIR, PACKAGE_BIN, PACKAGE_NAME, PACKAGE_VERSION, dryRun, execP, parseJsonObject, verbose, } from './common.js';
5
+ import { IS_WINDOWS, OMP_PLUGINS_DIR, PACKAGE_BIN, PACKAGE_NAME, PACKAGE_VERSION, args, dryRun, execP, parseJsonObject, verbose, } from './common.js';
6
6
  import { execNetwork, sayTagged } from './interactive.js';
7
7
  import { readTextIfExists, resolveRtkBinary, tersioDataPath } from '../extensions/lib/utils.js';
8
8
  import { refreshPrices } from '../extensions/shared/pricing.js';
9
9
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, httpsGet, normalizeRtkVersion, sha256Hex, } from '../extensions/lib/utils.js';
10
+ import { parseHostArg } from './hosts.js';
10
11
  const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
11
12
  function newerThan(a, b) {
12
13
  const pa = a.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
@@ -34,8 +35,7 @@ async function latestPublishedVersion() {
34
35
  return null;
35
36
  }
36
37
  }
37
- // A newer published version, null when up to date, 'unknown' when the registry
38
- // is unreachable. Cached for a TTL unless force, which must not trust a cache.
38
+ // A newer published version, null when up to date, 'unknown' when the registry is unreachable. Cached for a TTL unless force, which must not trust a cache.
39
39
  async function checkForUpdate(force = false) {
40
40
  const cachePath = tersioDataPath('update-check.json', 'tersio-update-check.json');
41
41
  const cached = parseJsonObject(await readTextIfExists(cachePath));
@@ -48,8 +48,7 @@ async function checkForUpdate(force = false) {
48
48
  await fs.writeFile(cachePath, JSON.stringify({ latest, lastCheck: Date.now() }) + '\n', 'utf8').catch(() => { });
49
49
  return newerThan(latest, PACKAGE_VERSION) ? latest : null;
50
50
  }
51
- // Unreachable registry: a stale cache naming a newer release is still
52
- // actionable, else report unknown so nobody claims "latest".
51
+ // Unreachable registry: a stale cache naming a newer release is still actionable, else report unknown so nobody claims "latest".
53
52
  return cached?.latest && newerThan(cached.latest, PACKAGE_VERSION) ? cached.latest : 'unknown';
54
53
  }
55
54
  // Race a probe against a timeout; failures resolve null so nothing blocks.
@@ -75,8 +74,7 @@ async function execInherit(cmd, args) {
75
74
  if (code !== 0)
76
75
  throw new Error(`${cmd} exited with code ${code}`);
77
76
  }
78
- // Current → latest per add-on. Every probe is capped and nullable, and the
79
- // plan prints `unknown` for anything unreachable.
77
+ // Current → latest per add-on. Every probe is capped and nullable, and the plan prints `unknown` for anything unreachable.
80
78
  async function probeUpdatePlan(cliLatest) {
81
79
  const rtkBin = resolveRtkBinary();
82
80
  const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
@@ -97,8 +95,7 @@ async function probeUpdatePlan(cliLatest) {
97
95
  ponytail: ponytailLocalText ? parseJsonObject(ponytailLocalText)?.version ?? null : null,
98
96
  };
99
97
  }
100
- // Every add-on prints a version jump when stale, "up to date" otherwise, and
101
- // "unknown" when its source cannot be reached.
98
+ // Every add-on prints a version jump when stale, "up to date" otherwise, and "unknown" when its source cannot be reached.
102
99
  function planLines(plan) {
103
100
  const stale = [];
104
101
  const status = [];
@@ -131,6 +128,18 @@ async function runLatestUpdate() {
131
128
  forwardedArgs.push('--dry-run');
132
129
  if (verbose)
133
130
  forwardedArgs.push('--verbose');
131
+ // A pinned host refreshes that host's tree; bare update keeps the OMP default.
132
+ let pinned;
133
+ try {
134
+ pinned = parseHostArg(args);
135
+ }
136
+ catch (e) {
137
+ console.error(`[fail] ${e.message}`);
138
+ process.exitCode = 1;
139
+ return;
140
+ }
141
+ if (pinned)
142
+ forwardedArgs.push('--host', pinned);
134
143
  const npmCommand = IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm';
135
144
  // `--prefer-online` beats the local cache, so a stale @latest cannot pin down.
136
145
  const cliLatest = await latestPublishedVersion();
@@ -169,8 +178,7 @@ async function runLatestUpdate() {
169
178
  process.exitCode = 1;
170
179
  return;
171
180
  }
172
- // The npx delegation below only refreshes the OMP-side files; the globally
173
- // installed CLI keeps its old version until npm -g runs. Refresh both.
181
+ // The npx delegation below only refreshes the OMP-side files; the globally installed CLI keeps its old version until npm -g runs. Refresh both.
174
182
  const globalArgs = IS_WINDOWS
175
183
  ? ['/d', '/s', '/c', 'npm', 'install', '-g', `${PACKAGE_NAME}${target}`, '--no-audit', '--no-fund', '--prefer-online']
176
184
  : ['install', '-g', `${PACKAGE_NAME}${target}`, '--no-audit', '--no-fund', '--prefer-online'];
@@ -200,11 +208,11 @@ async function runLatestUpdate() {
200
208
  ...forwardedArgs,
201
209
  ];
202
210
  const npmCommandArgs = IS_WINDOWS ? ['/d', '/s', '/c', 'npm', ...npmArgs] : npmArgs;
203
- // Inherited stdio, no outer spinner; the delegated installer runs quiet and
204
- // this parent owns both the plan line and the closing summary.
211
+ // Inherited stdio, no outer spinner; the delegated installer runs quiet and this parent owns both the plan line and the closing summary.
205
212
  try {
206
213
  await execInherit(npmCommand, npmCommandArgs);
207
- console.log(`Done — tersio ${plan.cli ?? PACKAGE_VERSION}. Restart OMP.`);
214
+ const hostName = pinned === 'pi' ? 'pi' : pinned === 'opencode' ? 'OpenCode' : 'OMP';
215
+ console.log(`Done — tersio ${plan.cli ?? PACKAGE_VERSION}. Restart ${hostName}.`);
208
216
  if (!dryRun) {
209
217
  try {
210
218
  await refreshPrices();
package/cli/usage.js CHANGED
@@ -1,13 +1,12 @@
1
1
  // cli/usage.ts — ledger + session-token usage report, tokscale-style.
2
- import { co2GramsFor, displayModelId, energyWhFor, importSessionTokens, ledgerPath, priceFor, readResetWatermark, readRtkAdoption, readRtkRecallDiagnostics, readUsage, refreshPricesIfStale, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
2
+ import { co2GramsFor, displayModelId, energyWhFor, foldModelsByLabel, importSessionTokens, ledgerPath, priceFor, readResetWatermark, readRtkAdoption, readRtkRecallDiagnostics, readUsage, refreshPricesIfStale, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
3
3
  import { resolveRtkBinary } from '../extensions/lib/utils.js';
4
4
  import { readRtkGain } from '../extensions/shared/rtk-gain.js';
5
- import { readUsageDb, syncUsageDb, usageDbPath } from '../extensions/shared/usage-store.js';
5
+ import { readUsageDb, reparseGuardReport, syncUsageDb, usageDbPath } from '../extensions/shared/usage-store.js';
6
6
  import { withInteractiveSpinner } from './interactive.js';
7
7
  import { PACKAGE_VERSION, currency } from './common.js';
8
8
  import { formatCurrency } from './currency.js';
9
- // RTK's history is machine-wide; rows group by command alone. 2000 groups
10
- // covers a long history while still bounding /data.json.
9
+ // RTK's history is machine-wide; rows group by command alone. 2000 groups covers a long history while still bounding /data.json.
11
10
  const RTK_COMMAND_ROWS = 2000;
12
11
  export function summarizeUsage(rows) {
13
12
  refreshPricesIfStale();
@@ -18,8 +17,7 @@ export function summarizeUsage(rows) {
18
17
  detailCounts[r.detail] = (detailCounts[r.detail] ?? 0) + 1;
19
18
  }
20
19
  const byDetail = Object.entries(detailCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
21
- // usage.db is a cache of the same per-message rows: sync best-effort, read
22
- // stored, fall back to the live parse when the store is missing or stale.
20
+ // usage.db is a cache of the same per-message rows: sync best-effort, read stored, fall back to the live parse when the store is missing or stale.
23
21
  let synced = false;
24
22
  try {
25
23
  synced = syncUsageDb();
@@ -27,6 +25,9 @@ export function summarizeUsage(rows) {
27
25
  catch {
28
26
  synced = false;
29
27
  }
28
+ const guard = reparseGuardReport();
29
+ if (guard)
30
+ console.warn(`[warn] usage mirror: ${guard}`);
30
31
  let stored = null;
31
32
  try {
32
33
  stored = readUsageDb()?.tokens ?? null;
@@ -42,19 +43,43 @@ export function summarizeUsage(rows) {
42
43
  let savedUsd = 0;
43
44
  const byModelUsd = {};
44
45
  const byModelBucketUsd = {};
46
+ const byHost = {};
47
+ const byDayModel = {};
45
48
  let co2g = 0;
46
49
  let energyWh = 0;
50
+ const addTok = (into, t) => {
51
+ into.input += t.input;
52
+ into.output += t.output;
53
+ into.cacheRead += t.cacheRead;
54
+ into.cacheWrite += t.cacheWrite;
55
+ };
56
+ // Price by exact key first (gateways can differ), then fold every map under the display label.
57
+ const folded = foldModelsByLabel(session.byModel, session.byModelMessages);
47
58
  for (const [model, t] of Object.entries(session.byModel)) {
48
59
  const c = usdCost(t, model);
49
60
  usd += c.usd;
50
- byModelUsd[model] = c.usd;
51
- byModelBucketUsd[model] = c.buckets;
61
+ const label = displayModelId(model);
62
+ byModelUsd[label] = (byModelUsd[label] ?? 0) + c.usd;
63
+ byModelBucketUsd[label] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
64
+ addTok(byModelBucketUsd[label], c.buckets);
65
+ for (const [host, hb] of Object.entries(session.byHost[model] ?? {})) {
66
+ byHost[label] ??= {};
67
+ byHost[label][host] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
68
+ addTok(byHost[label][host], hb);
69
+ }
52
70
  co2g += co2GramsFor(model, t.output);
53
71
  energyWh += energyWhFor(model, t.output);
54
72
  if (!c.priced)
55
73
  priced = false;
56
74
  savedUsd += (t.cacheRead / 1e6) * priceFor(model).price.input;
57
75
  }
76
+ for (const [day, per] of Object.entries(session.byDayModel)) {
77
+ for (const [model, n] of Object.entries(per)) {
78
+ const label = displayModelId(model);
79
+ byDayModel[day] ??= {};
80
+ byDayModel[day][label] = (byDayModel[day][label] ?? 0) + n;
81
+ }
82
+ }
58
83
  const byTool = Object.entries(session.byTool).sort((a, b) => b[1] - a[1]).slice(0, 10);
59
84
  return {
60
85
  total: rows.length,
@@ -64,12 +89,13 @@ export function summarizeUsage(rows) {
64
89
  empty: rows.length === 0 && session.messages === 0,
65
90
  messages: session.messages,
66
91
  tokens: session.totals,
67
- byModel: session.byModel,
92
+ byModel: folded.byModel,
68
93
  byModelUsd,
69
94
  byModelBucketUsd,
70
- byModelMessages: session.byModelMessages,
95
+ byModelMessages: folded.byModelMessages,
96
+ byHost,
71
97
  byDay: session.byDay,
72
- byDayModel: session.byDayModel,
98
+ byDayModel,
73
99
  byTool,
74
100
  recent: session.recent.map((r) => ({
75
101
  ...r,
@@ -117,8 +143,7 @@ const ROW_CAP = 15;
117
143
  function pad(s, n) {
118
144
  return s.length >= n ? s : s + ' '.repeat(n - s.length);
119
145
  }
120
- // Box-drawing table, plain text (piped-safe, byte-stable). Numeric columns
121
- // right-align; long cells truncate with an ellipsis. Shared with cli/settings.ts.
146
+ // Box-drawing table, plain text (piped-safe, byte-stable). Numeric columns right-align; long cells truncate with an ellipsis. Shared with cli/settings.ts.
122
147
  function textTable(headers, rows, right = [], maxW = 32) {
123
148
  const cells = [headers, ...rows].map((r) => r.map((c) => (c.length > maxW ? c.slice(0, maxW - 1) + '…' : c)));
124
149
  const widths = headers.map((_, i) => Math.max(...cells.map((r) => r[i].length)));
@@ -153,7 +178,7 @@ function printReport(report) {
153
178
  const mt = b.input + b.output + b.cacheRead + b.cacheWrite;
154
179
  const hit = b.input + b.cacheRead ? (b.cacheRead / (b.input + b.cacheRead)) * 100 : 0;
155
180
  const usd = report.byModelUsd[model] ?? 0;
156
- return [displayModelId(model), fmt(b.input), fmt(b.output), `${fmt(b.cacheRead)}/${fmt(b.cacheWrite)}`, formatCurrency(usd, report.currency), `${hit.toFixed(1)}%`, `${bar(mt / top, 8)} ${fmtShort(mt)}`];
181
+ return [model, fmt(b.input), fmt(b.output), `${fmt(b.cacheRead)}/${fmt(b.cacheWrite)}`, formatCurrency(usd, report.currency), `${hit.toFixed(1)}%`, `${bar(mt / top, 8)} ${fmtShort(mt)}`];
157
182
  });
158
183
  for (const l of textTable(['Model', 'Input', 'Output', 'Cache r/w', report.currency, 'Hit', 'Share'], mrows, [false, true, true, true, true, true, false])) {
159
184
  console.log(l);