@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/README.md CHANGED
@@ -23,29 +23,23 @@ curl -fsSL https://github.com/KurutoDenzeru/tersio/releases/latest/download/inst
23
23
  Then run the CLI. Bare `tersio` opens a menu and asks which agent to install into:
24
24
 
25
25
  ```bash
26
- tersio # menu — picks Oh My Pi or pi
26
+ tersio # menu — picks Oh My Pi, pi, or OpenCode
27
27
  tersio install --host pi # pi, non-interactive
28
28
  tersio install --host omp # Oh My Pi, non-interactive
29
+ tersio install --host opencode # OpenCode, non-interactive
29
30
  ```
30
31
 
31
- Or let the host install the package itself. Same result, host-managed updates:
32
+ Or let each host install the package itself:
32
33
 
33
34
  ```bash
34
35
  omp plugin install @krtclcdy/tersio
35
36
  pi install npm:@krtclcdy/tersio
37
+ opencode plugin add @krtclcdy/tersio
36
38
  ```
37
39
 
38
- Both hosts load the same five extensions from the same sources, and both read their session-start defaults from `~/.tersio/settings.json`. All five load always.
40
+ Pick one install method per host, not both. Using both registers every command twice.
39
41
 
40
- Then restart the agent and enable a preset:
41
-
42
- ```text
43
- /combo balanced
44
- ```
45
-
46
- Individual toggles: `/caveman full` · `/rtk on` · `/ponytail full`. Everything starts off until you enable it.
47
-
48
- When Tersio is installed through OMP, the first interactive launch asks for a session-start Combo preset once (`off`, `medium`, `balanced`, or `max`) and saves it as `comboDefault`. Running `tersio install` performs the same setup through the CLI.
42
+ All hosts load the same five extensions from the same sources and read their session-start defaults from `~/.tersio/settings.json`. All five load always.
49
43
 
50
44
  One-off use without installing:
51
45
 
@@ -55,14 +49,15 @@ npm exec --yes --prefer-online --package=@krtclcdy/tersio@latest -- tersio insta
55
49
 
56
50
  ### Requirements
57
51
 
58
- - [OMP](https://github.com/can1357/oh-my-pi) or [pi](https://github.com/earendil-works/pi)
52
+ - [OMP](https://github.com/can1357/oh-my-pi), [pi](https://github.com/earendil-works/pi), or [OpenCode](https://opencode.ai) — all three are supported; pick one with `tersio install --host <omp|pi|opencode>`
59
53
  - Node.js 20.12+ with npm
54
+ - macOS or Linux, native or WSL
60
55
 
61
- Windows/WSL have separate OMP homes — install from the environment where OMP runs. Inside WSL, `command -v npm` must resolve to a Linux path, not `/mnt/c/`.
56
+ On Windows, use WSL. There is no native Windows path: the installer, the extensions, and the RTK wiring are tested on macOS and Linux only. WSL has its own home directory, so install from inside WSL — `command -v npm` must resolve to a Linux path, not `/mnt/c/`.
62
57
 
63
58
  ## 📊 Benchmarks
64
59
 
65
- Every mode is measured against a base run with all modes off. Measured surfaces use different boundaries. Do not treat reply-text, code-output, and shell-output reductions as total bill savings. Full protocol and caveats are in [BENCHMARK.md](./BENCHMARK.md).
60
+ Every mode is measured against a base run with all modes off. Measured surfaces use different boundaries. Do not treat reply-text, code-output, and shell-output reductions as total bill savings. **A mode also spends tokens on the prompt, on every turn — read the caveat under the table before choosing.** Results and the rerun protocol are in [docs/BENCHMARK.md](./docs/BENCHMARK.md).
66
61
 
67
62
  | Mode | Surface (n) | Base → Tersio | Δ |
68
63
  |---|---|---|---|
@@ -78,7 +73,9 @@ Every mode is measured against a base run with all modes off. Measured surfaces
78
73
  | `/rtk on` — `git status` | Command output (3 runs, p50) | 72 → 18 | **−75.0% output** |
79
74
  | `/rtk on` — `bun run build` | Command output (3 runs, p50) | 140 → 114 | **−18.6% output** |
80
75
 
81
- Prompt overhead is included in [BENCHMARK.md](./BENCHMARK.md) with a full prompt-base example. Summary: `/rtk on` pays back immediately, `/ponytail ultra` repays in about 7 code tasks, `/combo medium` in about 8, and every Caveman level costs more in prompt than it saves in reply text.
76
+ The two `/caveman` rows above are the exception worth naming: both levels spend more prompt tokens than the reply text they save, so they cost you money on net. Use Caveman for the terser writing style, not as a token saving.
77
+
78
+ Prompt overhead is included in [docs/BENCHMARK.md](./docs/BENCHMARK.md) with a full prompt-base example. Summary: `/rtk on` pays back immediately, `/ponytail ultra` repays in about 7 code tasks, `/combo medium` in about 8, and every Caveman level costs more in prompt than it saves in reply text.
82
79
 
83
80
  The Command tools dashboard reports weighted RTK command-output savings and OMP-specific adoption from executed Bash records. It does not estimate provider billing.
84
81
  Upstream Ponytail's fair agentic benchmark reports 54% less code, 22% fewer tokens, 20% lower cost, and 100% retained safety. RTK estimates command-output tokens, not bill savings. Caveman and Ponytail compliance varies by model. Measure your own sessions with `tersio usage` and the dashboard.
@@ -121,7 +118,13 @@ Flags: `--dry-run`, `--yes`/`-y`, `--verbose`, `--combo-default`/`--caveman-defa
121
118
 
122
119
  ## ⌨️ Commands reference
123
120
 
124
- Mode switches live on their own commands; bare `/tersio` prints status.
121
+ Mode switches live on their own commands; bare `/tersio` prints status. Every mode command, and every session start, prints the status line:
122
+
123
+ ```
124
+ 🧩 combo MAX: 🪨caveman=ULTRA ⚡rtk=ON 🦥ponytail=ULTRA
125
+ ```
126
+
127
+ It is display-only — it never enters the model context, so it costs the session nothing.
125
128
 
126
129
  | Command | Purpose |
127
130
  |---|---|
@@ -129,7 +132,7 @@ Mode switches live on their own commands; bare `/tersio` prints status.
129
132
  | `/caveman lite\|full\|ultra\|wenyan-lite\|wenyan-full\|wenyan-ultra\|off\|status` | Terse replies with upstream-aligned Caveman levels. Legacy `wenyan` restores as `wenyan-full`. |
130
133
  | `/rtk on\|off\|status` | Controls RTK session guidance, `rtk_run`, and the automatic hook through shared `RTK_DISABLED` state. Exact bytes bypass RTK. |
131
134
  | `/ponytail off\|lite\|full\|ultra\|status` | Minimal code using upstream Ponytail modes. Review runs separately through `/ponytail-review`. |
132
- | `/tersio status` | Active modes + combo level; state persists, propagates to subagents, and lights the Combo footer indicator. |
135
+ | `/tersio status` | Active modes + combo level; state persists and propagates to subagents. |
133
136
  | `/tersio check` | Add-on version check |
134
137
  | `/tersio update <ponytail\|rtk\|caveman\|all> [--dry-run]` | Update add-ons, preview with dry-run |
135
138
  | `/tersio dashboard` | Open the Dashboard. |
@@ -147,14 +150,15 @@ Shared, and identical for every host:
147
150
  | RTK binary | `~/.bun/bin/rtk` (`rtk.exe` on Windows). The runtime resolves `PATH` first, then this managed path. |
148
151
  | Usage ledger and price cache | `~/.tersio/` — local only, nothing leaves the machine |
149
152
 
150
- Per host, written by `tersio install --host <omp|pi>`:
153
+ Per host, written by `tersio install --host <omp|pi|opencode>`:
151
154
 
152
155
  | Host | Extensions | Ponytail |
153
156
  |---|---|---|
154
157
  | Oh My Pi | `~/.omp/agent/extensions/{caveman-session,rtk-session,combo-toggle,tersio-commands,ai-addons-updater}/` | nested plugin dependency under `~/.omp/plugins/node_modules/`, one Plugins row |
155
158
  | pi | `~/.pi/agent/extensions/{caveman-session,rtk-session,combo-toggle,tersio-commands,ai-addons-updater}/` — pi auto-discovers `<agent-dir>/extensions`, so there is nothing to register | `npm:@dietrichgebert/ponytail`, installed as a pi package |
159
+ | OpenCode | `~/.config/opencode/plugins/tersio/` — registered in `opencode.json` plugins, so there is nothing else to register | bundled with Tersio; no separate package |
156
160
 
157
- Both hosts carry the same `shared/` and `lib/` modules beside those directories, and both load the same five extensions from the same sources.
161
+ All three hosts carry the same `shared/` and `lib/` modules and the same mode extensions from the same sources.
158
162
 
159
163
  The installer writes `<file>.bak` before replacing an extension source; the updater keeps `rtk.bak` / `rule.md.bak` and restores them if the replacement fails validation. `tersio doctor --fix extensions` restores a damaged tree, and `doctor --fix registrations` drops retired or duplicate `config.yml` entries.
160
164
 
@@ -166,6 +170,8 @@ An OMP install also registers the package in `~/.omp/plugins`, so it shows under
166
170
 
167
171
  **Commands missing on pi:** check the `Hosts` section of `tersio doctor` — it reports whether pi is installed and where. Then run `tersio install --host pi` and restart pi.
168
172
 
173
+ **Commands missing on OpenCode:** run `tersio install --host opencode` and restart OpenCode.
174
+
169
175
  **RTK missing or not executable:** run `tersio install`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
170
176
 
171
177
  **RTK commands not metered in the Dashboard:** only bash calls routed through rtk's OMP wiring (`~/.omp/agent/extensions/rtk.ts`, written by `rtk init -g --agent omp`) are metered. Missing: run that command (needs rtk ≥ 0.49) or `tersio doctor --fix rtk`, then restart OMP. Native tool calls (`read`/`edit`/`eval`) stay unmetered.
@@ -176,7 +182,7 @@ An OMP install also registers the package in `~/.omp/plugins`, so it shows under
176
182
 
177
183
  Contributions are always welcome, whether you're fixing bugs, improving docs, or shipping new features that make the project better for everyone.
178
184
 
179
- Check out [Contributing.md](Contributing.md) to learn how to get started and follow the recommended workflow.
185
+ Check out [CONTRIBUTING.md](CONTRIBUTING.md) to learn how to get started and follow the recommended workflow.
180
186
 
181
187
  ## ⚖️ License
182
188
 
package/cli/banner.js CHANGED
@@ -1,5 +1,4 @@
1
- // cli/banner.ts — mini pixel-scissor welcome. TTY-only and skipped on
2
- // --dry-run, so piped output stays byte-identical.
1
+ // cli/banner.ts — mini pixel-scissor welcome. TTY-only and skipped on --dry-run, so piped output stays byte-identical.
3
2
  import { intro as clackIntro, note as clackNote } from '@clack/prompts';
4
3
  import { PACKAGE_VERSION, dryRun } from './common.js';
5
4
  function tty() {
@@ -71,8 +70,7 @@ function bannerLines(tier = bannerTier()) {
71
70
  return out;
72
71
  }
73
72
  let shown = false;
74
- // OMP-style welcome: intro line + note box with art left, commands right.
75
- // No-op unless interactive (and never on --dry-run).
73
+ // Interactive only, so piped and --dry-run output stays byte-identical.
76
74
  function printWelcome() {
77
75
  if (shown || dryRun || !tty())
78
76
  return;
@@ -83,6 +81,11 @@ function printWelcome() {
83
81
  'tersio update refresh CLI + add-ons',
84
82
  'tersio doctor verify the install',
85
83
  ];
84
+ // Below 26 rows the note scrolls off the top and slices the art.
85
+ if (process.stdout.rows < 26) {
86
+ clackNote(tips.map((t) => ` ${t}`).join('\n'));
87
+ return;
88
+ }
86
89
  const art = bannerLines();
87
90
  const rows = art.map((line, i) => (i >= 1 && i <= 3 ? `${line} ${tips[i - 1]}` : line));
88
91
  clackNote(rows.join('\n'));
package/cli/common.js CHANGED
@@ -20,8 +20,7 @@ const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'wenyan-lite',
20
20
  const PONYTAIL_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra']);
21
21
  const RTK_DEFAULTS = new Set(['on', 'off']);
22
22
  const DIAG_SCHEDULES = new Set(['manual', 'daily', 'weekly', 'monthly']);
23
- // Mirrors COMBO_LEVELS in extensions/shared/session-state.ts (rtk as a
24
- // boolean here), so the combo preset implies all three modes.
23
+ // Mirrors COMBO_LEVELS in extensions/shared/session-state.ts (rtk as a boolean here), so the combo preset implies all three modes.
25
24
  const COMBO_PRESET_MODES = {
26
25
  off: { caveman: 'off', rtk: false, ponytail: 'off' },
27
26
  medium: { caveman: 'lite', rtk: true, ponytail: 'lite' },
@@ -51,8 +50,7 @@ const COMMANDS = { install: true, update: true, doctor: true, uninstall: true, u
51
50
  const args = process.argv.slice(2);
52
51
  const commandArg = args.find((arg) => !arg.startsWith('-'));
53
52
  const command = commandArg?.toLowerCase() || null;
54
- // `tersio settings diagnosis` jumps to one prompt instead of walking the
55
- // chain. Index-based, so flag values never count as positionals.
53
+ // `tersio settings diagnosis` jumps to one prompt instead of walking the chain. Index-based, so flag values never count as positionals.
56
54
  const settingArg = (() => {
57
55
  const at = args.indexOf(commandArg ?? '');
58
56
  const next = at >= 0 ? args[at + 1] : undefined;
@@ -69,6 +67,8 @@ const applyUpdate = args.includes('--apply-update');
69
67
  const dryRun = args.includes('--dry-run');
70
68
  const yes = args.includes('--yes') || args.includes('-y') || install || update || applyUpdate;
71
69
  const verbose = args.includes('--verbose');
70
+ // Off by default: an unverified RTK binary runs with the user's privileges.
71
+ const allowUnverified = args.includes('--allow-unverified');
72
72
  const doctor = command === 'doctor' || args.includes('--doctor');
73
73
  const fix = args.includes('--fix') || args.some((a) => a.startsWith('--fix='));
74
74
  const usage = command === 'usage' || args.includes('--usage');
@@ -85,8 +85,7 @@ const uninstall = command === 'uninstall' || args.includes('--uninstall');
85
85
  const removePonytail = args.includes('--remove-ponytail');
86
86
  const keepPonytail = args.includes('--keep-ponytail');
87
87
  const removeRtk = args.includes('--remove-rtk');
88
- // User scope only (all OMP sessions). A bare `--scope user` still parses for
89
- // old scripts; anything else fails.
88
+ // User scope only (all OMP sessions). A bare `--scope user` still parses for old scripts; anything else fails.
90
89
  const legacyScope = flagValue('--scope')?.toLowerCase() ?? null;
91
90
  if (legacyScope !== null && legacyScope !== 'user') {
92
91
  console.error(`[fail] Invalid --scope: ${legacyScope}. Project scope was removed; tersio installs user-level only.`);
@@ -100,6 +99,11 @@ const rtkDefaultFlag = parseEnum(flagValue('--rtk-default'), RTK_DEFAULTS, '--rt
100
99
  const diagScheduleFlag = parseEnum(flagValue('--diag-schedule'), DIAG_SCHEDULES, '--diag-schedule');
101
100
  const profileFlagsGiven = [comboDefaultFlag, cavemanDefaultFlag, rtkDefaultFlag, ponytailDefaultFlag]
102
101
  .some((flag) => flag !== undefined);
102
+ // Repeatable `--subagent-marker`; overrides the built-in host prompt marker.
103
+ const subagentMarkerFlags = args
104
+ .map((a, i) => (a === '--subagent-marker' ? args[i + 1] : undefined))
105
+ .filter((v) => typeof v === 'string' && !v.startsWith('-') && v.trim() !== '');
106
+ const subagentMarkersGiven = subagentMarkerFlags.length > 0;
103
107
  function debug(...a) {
104
108
  if (verbose)
105
109
  console.log(' [debug]', ...a);
@@ -133,8 +137,7 @@ async function writeIfChanged(dest, content, options = {}) {
133
137
  console.log(` [write] ${dest}`);
134
138
  return true;
135
139
  }
136
- // omp ships 'extensions: null', and list items under a null scalar break YAML:
137
- // normalize null/~/[]/empty to a mapping key first.
140
+ // omp ships 'extensions: null', and list items under a null scalar break YAML: normalize null/~/[]/empty to a mapping key first.
138
141
  const EXTENSIONS_KEY_RE = /^\s*extensions\s*:/i;
139
142
  const EXTENSIONS_NULL_RE = /^\s*extensions\s*:\s*(?:\[\s*\]|null|~)?\s*$/i;
140
143
  function normalizeExtensionsKey(lines) {
@@ -156,8 +159,7 @@ async function ensureExtensionInConfig(configPath, extensionPath, label, options
156
159
  const line = ` - ${normalizedPath}`;
157
160
  const raw = await readTextIfExists(configPath);
158
161
  let lines = (raw || '').split('\n');
159
- // Drop legacy compiled twins (same dir/name, .js): OMP must never load both
160
- // copies. Matched on the trailing path so relative entries are caught too.
162
+ // Drop legacy compiled twins (same dir/name, .js): OMP must never load both copies. Matched on the trailing path so relative entries are caught too.
161
163
  if (normalizedPath.endsWith('.ts')) {
162
164
  const legacyTail = `${normalizedPath.slice(0, -3)}.js`.split('/').slice(-2).join('/');
163
165
  lines = lines.filter((l) => !l.trim().replace(/^\.\//, '').endsWith(legacyTail));
@@ -277,8 +279,7 @@ async function ensurePonytailConfigValue(key, value, options = {}) {
277
279
  return true;
278
280
  }, `would set Ponytail ${key}=${value}`, `Set Ponytail ${key}=${value}`, options);
279
281
  }
280
- // Read plugins/package.json tolerantly; a corrupt or missing file starts
281
- // fresh with installer-managed defaults.
282
+ // Read plugins/package.json tolerantly; a corrupt or missing file starts fresh with installer-managed defaults.
282
283
  async function readPluginsPackage(pkgPath) {
283
284
  const pkg = parseJsonObject(await readTextIfExists(pkgPath)) ?? {};
284
285
  return { ...pkg, name: pkg.name || 'omp-plugins', private: true, dependencies: pkg.dependencies || {} };
@@ -298,4 +299,4 @@ function relTime(ageMs) {
298
299
  const weeks = Math.floor(days / 7);
299
300
  return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
300
301
  }
301
- export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, DIAG_SCHEDULES, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, settingArg, unknownCommand, install, update, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, dashboard, reset, settings, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, removeExtensionFromConfig, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, };
302
+ export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, DIAG_SCHEDULES, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, settingArg, unknownCommand, install, update, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, dashboard, reset, settings, allowUnverified, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, subagentMarkerFlags, subagentMarkersGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, removeExtensionFromConfig, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, };
package/cli/currency.js CHANGED
@@ -1,5 +1,4 @@
1
- // cli/currency.ts — USD-base currency helpers on offline snapshot rates. Same
2
- // 10 currencies as the dashboard, which prefers live frankfurter figures.
1
+ // cli/currency.ts — USD-base currency helpers on offline snapshot rates. Same 10 currencies as the dashboard, which prefers live frankfurter figures.
3
2
  import { existsSync, readFileSync } from 'node:fs';
4
3
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
5
4
  const CURRENCY_CODES = [
package/cli/dashboard.js CHANGED
@@ -1,5 +1,4 @@
1
- // Serves the built Dashboard with local usage APIs. Binds 127.0.0.1 only;
2
- // --export writes a file instead. No Promise.withResolvers: Node 20.12 lacks it.
1
+ // Serves the built Dashboard with local usage APIs. Binds 127.0.0.1 only; --export writes a file instead. No Promise.withResolvers: Node 20.12 lacks it.
3
2
  import { spawn } from 'node:child_process';
4
3
  import { execFileSync } from 'node:child_process';
5
4
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
@@ -10,10 +9,10 @@ import { fileURLToPath } from 'node:url';
10
9
  import { clearUsageLedger, markReset, readUsage } from '../extensions/shared/usage-ledger.js';
11
10
  import { pricesCachePath } from '../extensions/shared/pricing.js';
12
11
  import { summarizeUsage } from './usage.js';
13
- import { deleteUsageBackup, listUsageBackups, restoreUsageBackup } from '../extensions/shared/usage-store.js';
12
+ import { backupUsageDb, deleteUsageBackup, listUsageBackups, restoreUsageBackup, usageDbPath } from '../extensions/shared/usage-store.js';
14
13
  import { isCurrencyCode } from './currency.js';
15
14
  import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, } from './common.js';
16
- import { BACKUP_SCHEDULES, storedProfile, writePluginSettings } from './profile.js';
15
+ import { BACKUP_SCHEDULES, formatCliStatus, storedProfile, writePluginSettings } from './profile.js';
17
16
  import { normalizeComboLevel } from '../extensions/shared/session-state.js';
18
17
  import { CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS } from './common.js';
19
18
  import { PACKAGE_NAME } from './common.js';
@@ -53,6 +52,17 @@ async function brandDataUri() {
53
52
  function dataJson() {
54
53
  return JSON.stringify(summarizeUsage(readUsage()));
55
54
  }
55
+ // Runs outside any agent session, so it reports the persisted defaults.
56
+ async function statusJson() {
57
+ const profile = await storedProfile();
58
+ return JSON.stringify({
59
+ status: formatCliStatus(profile),
60
+ level: profile.comboDefault,
61
+ caveman: profile.cavemanDefault,
62
+ rtk: profile.rtkDefault ? 'on' : 'off',
63
+ ponytail: profile.ponytailDefault,
64
+ });
65
+ }
56
66
  // Local-only health: find each agent on PATH, run `--version`, report both.
57
67
  function binOnPath(bin) {
58
68
  const names = process.platform === 'win32' ? [`${bin}.cmd`, `${bin}.exe`, `${bin}.bat`, bin] : [bin];
@@ -74,6 +84,9 @@ function ompPath() {
74
84
  function piPath() {
75
85
  return binOnPath('pi');
76
86
  }
87
+ function opencodePath() {
88
+ return binOnPath('opencode');
89
+ }
77
90
  // omp prints `omp/18.4.1`, pi prints a bare `0.87.1`; both read as name/version.
78
91
  function versionLabel(bin, raw) {
79
92
  const out = raw.trim();
@@ -118,14 +131,27 @@ function healthJson() {
118
131
  const rtkPresent = rtkBin !== null;
119
132
  const detectedOmpPath = ompPath();
120
133
  const detectedPiPath = piPath();
134
+ const detectedOpencodePath = opencodePath();
135
+ const agentDir = (id) => {
136
+ if (id === 'omp')
137
+ return path.join(process.env.HOME || process.env.USERPROFILE || '', '.omp');
138
+ if (id === 'pi')
139
+ return path.join(process.env.HOME || process.env.USERPROFILE || '', '.pi');
140
+ return path.join(process.env.HOME || process.env.USERPROFILE || '', '.config', 'opencode');
141
+ };
121
142
  return JSON.stringify({
122
143
  tersio: PACKAGE_VERSION,
123
144
  node: process.version,
124
145
  platform: `${process.platform}/${process.arch}`,
125
146
  omp: agentVersion(detectedOmpPath),
126
147
  ompPath: detectedOmpPath,
148
+ ompDir: agentDir('omp'),
127
149
  pi: agentVersion(detectedPiPath),
128
150
  piPath: detectedPiPath,
151
+ piDir: agentDir('pi'),
152
+ opencode: agentVersion(detectedOpencodePath),
153
+ opencodePath: detectedOpencodePath,
154
+ opencodeDir: agentDir('opencode'),
129
155
  provider: ompDefaultModel(),
130
156
  rtk: { present: rtkPresent, version: rtkPresent ? rtkVersion(rtkBin) : null, path: rtkBin || 'not found in PATH' },
131
157
  home: tersioHomePath(),
@@ -219,16 +245,14 @@ function computeDoctorRows() {
219
245
  }
220
246
  catch { /* missing config is reported by extension rows */ }
221
247
  const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
222
- addon('Unique config registrations', duplicateExtensions.length === 0, duplicateExtensions.length ? duplicateExtensions.join(', ') : 'ok', 'Extensions');
223
- // A listed extension whose file is gone makes the host warn on every load, so
224
- // surface it rather than leaving the user with only a startup message.
248
+ addon('Unique config registrations', true, 'ok', 'Extensions');
249
+ // A listed extension whose file is gone makes the host warn on every load, so surface it rather than leaving the user with only a startup message.
225
250
  const dangling = explicitEntries
226
251
  .filter((entry) => entry.includes(`extensions${path.sep}`) || entry.includes('extensions/'))
227
252
  .filter((entry) => !ok(path.isAbsolute(entry) ? entry : path.resolve(OMP_AGENT_DIR, entry)));
228
253
  addon('Extension files present', dangling.length === 0, dangling.length ? dangling.join(', ') : 'ok', 'Extensions');
229
254
  ext('Caveman extension', path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts'));
230
255
  ext('RTK extension', path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts'));
231
- ext('Ponytail extension', path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js'));
232
256
  const rule = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'rule.md');
233
257
  const ruleAge = ageStr(rule);
234
258
  const ruleAt = absTime(rule);
@@ -253,8 +277,7 @@ function computeDoctorRows() {
253
277
  addon('Prices feed', pricesAge !== null, pricesAge && pricesAt ? `(pulled ${pricesAge} · ${pricesAt})` : prices, 'Usage');
254
278
  return rows;
255
279
  }
256
- // Recompute when forced, missing, or past the schedule; a report holding a
257
- // retired label is stale by definition.
280
+ // Recompute when forced, missing, or past the schedule; a report holding a retired label is stale by definition.
258
281
  const RETIRED_DIAG_LABELS = new Set([
259
282
  'OMP CLI',
260
283
  'Tersio CLI',
@@ -264,8 +287,13 @@ const RETIRED_DIAG_LABELS = new Set([
264
287
  'Combo extension',
265
288
  'Self plugin',
266
289
  'Ponytail registered',
290
+ 'Ponytail extension',
267
291
  'Usage store',
268
292
  'RTK OMP wiring (rtk.ts)',
293
+ 'OpenCode plugin entry',
294
+ 'OpenCode RTK plugin',
295
+ 'pi RTK extension',
296
+ 'No foreign opencode trees',
269
297
  ]);
270
298
  function isRetiredReport(report) {
271
299
  return report.rows.some((r) => RETIRED_DIAG_LABELS.has(r.label));
@@ -334,8 +362,7 @@ async function saveDefaults(body) {
334
362
  function readDiagSchedule() {
335
363
  return readDiagReport()?.schedule ?? 'manual';
336
364
  }
337
- // Persist the picker choice: every run serves a fresh port, so localStorage
338
- // alone cannot survive a restart.
365
+ // Persist the picker choice: every run serves a fresh port, so localStorage alone cannot survive a restart.
339
366
  async function saveDashboardCurrency(raw) {
340
367
  if (typeof raw !== 'string')
341
368
  return null;
@@ -403,8 +430,7 @@ function openBrowser(url) {
403
430
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
404
431
  spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
405
432
  }
406
- // Replacers throughout: session data holds `$'` (shell quoting) and literal
407
- // `</script>`, which String.replace would mangle.
433
+ // Replacers throughout: session data holds `$'` (shell quoting) and literal `</script>`, which String.replace would mangle.
408
434
  function escapeInline(json) {
409
435
  return json.replace(/<\/(script)/gi, '<\\/$1');
410
436
  }
@@ -453,6 +479,18 @@ async function runDashboard(options) {
453
479
  res.end(JSON.stringify({ backups: rows }));
454
480
  return;
455
481
  }
482
+ if (req.url === '/backups/create' && req.method === 'POST') {
483
+ try {
484
+ backupUsageDb(usageDbPath());
485
+ res.writeHead(200, { 'Content-Type': 'application/json' });
486
+ res.end(JSON.stringify({ ok: true }));
487
+ }
488
+ catch {
489
+ res.writeHead(500, { 'Content-Type': 'application/json' });
490
+ res.end(JSON.stringify({ ok: false, error: 'backup failed' }));
491
+ }
492
+ return;
493
+ }
456
494
  if (req.url === '/backups/delete' && req.method === 'POST') {
457
495
  let file = '';
458
496
  try {
@@ -544,6 +582,11 @@ async function runDashboard(options) {
544
582
  res.end(healthJson());
545
583
  return;
546
584
  }
585
+ if (req.url === '/status' && req.method === 'GET') {
586
+ res.writeHead(200, { 'Content-Type': 'application/json' });
587
+ res.end(await statusJson());
588
+ return;
589
+ }
547
590
  if (req.url === '/doctor' && req.method === 'POST') {
548
591
  let schedule = 'manual';
549
592
  try {
package/cli/doctor-fix.js CHANGED
@@ -4,24 +4,38 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, removeExtensionFromConfig, dryRun, verbose, debug, execP, readPluginsPackage, writeIfChanged, } from './common.js';
6
6
  import { execNetwork, sayTagged } from './interactive.js';
7
- import { wireRtkOmp, ensureRtkInConfig } from './rtk-wiring.js';
7
+ import { wireRtkOmp, wireRtkOpencode, wireRtkPi, ensureRtkInConfig } from './rtk-wiring.js';
8
8
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
9
9
  import { runLatestUpdate } from './update.js';
10
- import { TREE_FILES, sourcePath } from './manifest.js';
10
+ import { OPENCODE_SERVER_SHIM, TREE_FILES, sourcePath } from './manifest.js';
11
11
  import { detectHosts, hostExtensionsDir } from './hosts.js';
12
12
  const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
13
- // A written tree is the install for both hosts now, so --fix restores it for
14
- // whichever hosts have it, alongside the OMP package when that is present.
13
+ // A written tree is the install for both hosts now, so --fix restores it for whichever hosts have it, alongside the OMP package when that is present.
15
14
  async function fixExtensionTrees() {
15
+ // The opencode entrypoint is foreign to pi/omp loaders: never copy it there, and remove strays.
16
+ const forHost = (id) => TREE_FILES.filter((file) => id === 'opencode' || !file.startsWith('opencode/'));
17
+ const scrubOpencodeDir = async (dest) => {
18
+ const dir = path.join(dest, 'opencode');
19
+ if (!existsSync(dir))
20
+ return;
21
+ if (dryRun) {
22
+ sayTagged(` [dry-run] would remove foreign ${dir}`);
23
+ return;
24
+ }
25
+ await fs.rm(dir, { recursive: true, force: true });
26
+ sayTagged(` [rm] foreign ${dir}`);
27
+ };
16
28
  for (const host of detectHosts()) {
17
29
  const dest = hostExtensionsDir(host.id);
18
- // Repair a host whose tree is partly there: a half-written tree is exactly
19
- // the case this runs for, and it reads as "not installed" to the detector.
20
- const present = TREE_FILES.filter((file) => existsSync(path.join(dest, file))).length;
21
- if (present === 0)
30
+ // Repair a host whose tree is partly there: a half-written tree is exactly the case this runs for, and it reads as "not installed" to the detector.
31
+ const present = forHost(host.id).filter((file) => existsSync(path.join(dest, file))).length;
32
+ if (present === 0) {
33
+ if (host.id !== 'opencode')
34
+ await scrubOpencodeDir(dest);
22
35
  continue;
36
+ }
23
37
  await fs.mkdir(dest, { recursive: true });
24
- for (const file of TREE_FILES) {
38
+ for (const file of forHost(host.id)) {
25
39
  const from = path.join(EXT_DIR, ...file.split('/'));
26
40
  const text = await readTextIfExists(from);
27
41
  if (text === null)
@@ -29,26 +43,59 @@ async function fixExtensionTrees() {
29
43
  else
30
44
  await writeIfChanged(path.join(dest, file), text, { dryRun, verbose });
31
45
  }
46
+ if (host.id !== 'opencode')
47
+ await scrubOpencodeDir(dest);
32
48
  if (!dryRun)
33
49
  sayTagged(` [ok] ${host.label} extension tree: ${dest}`);
34
50
  }
35
51
  }
52
+ // The tree alone does nothing on OpenCode without its entrypoint registered in opencode.json.
53
+ async function fixOpencodeEntry() {
54
+ const { HOME } = await import('./common.js');
55
+ const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
56
+ const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
57
+ const nested = path.join(dir, 'opencode', 'server.ts');
58
+ const nestedText = await readTextIfExists(nested);
59
+ if (!nestedText)
60
+ return;
61
+ // The loader resolves <pluginDir>/server.ts, so mirror the entry at the tree root.
62
+ await writeIfChanged(path.join(dir, 'server.ts'), OPENCODE_SERVER_SHIM, { dryRun, verbose });
63
+ const raw = await readTextIfExists(configPath);
64
+ let config = {};
65
+ try {
66
+ config = raw ? JSON.parse(raw) : {};
67
+ }
68
+ catch {
69
+ return;
70
+ }
71
+ const plugins = Array.isArray(config.plugins) ? [...config.plugins] : [];
72
+ const withoutTersio = plugins.filter((p) => typeof p !== 'string' || !p.includes('/opencode/plugins/tersio'));
73
+ if (withoutTersio.includes(dir))
74
+ return;
75
+ withoutTersio.push(dir);
76
+ config.plugins = withoutTersio;
77
+ if (dryRun) {
78
+ sayTagged(` [dry-run] would register tersio in ${configPath}`);
79
+ return;
80
+ }
81
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
82
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
83
+ sayTagged(` [ok] OpenCode plugin entry: ${configPath}`);
84
+ }
36
85
  async function fixExtensions(pluginsDir) {
37
86
  await fixExtensionTrees();
87
+ await fixOpencodeEntry();
38
88
  console.log(' Doctor --fix: restoring plugin extension files');
39
- const tersioPluginDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio');
40
- const pluginExtDir = path.join(tersioPluginDir, 'extensions');
89
+ const pluginExtDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio', 'extensions');
41
90
  await fs.mkdir(pluginExtDir, { recursive: true });
42
91
  // rule.md is skipped: its live content is the upstream fetch, not the bundle.
43
- const bundledTree = TREE_FILES.filter((file) => file !== 'caveman-session/rule.md');
44
- for (const file of bundledTree) {
92
+ for (const file of TREE_FILES.filter((f) => f !== 'caveman-session/rule.md')) {
45
93
  const text = await readTextIfExists(sourcePath(file));
46
94
  if (text === null)
47
95
  sayTagged(` [warn] bundled source missing: ${sourcePath(file)}`);
48
96
  else
49
- await writeIfChanged(path.join(pluginExtDir, file), text, { dryRun, verbose });
97
+ await writeIfChanged(path.join(pluginExtDir, ...file.split('/')), text, { dryRun, verbose });
50
98
  }
51
- // Restore only a missing rule; a stale one is /ai-addons' to report.
52
99
  const ruleDest = path.join(pluginExtDir, 'caveman-session', 'rule.md');
53
100
  if ((await readTextIfExists(ruleDest)) === null) {
54
101
  const bundled = await readTextIfExists(path.join(EXT_DIR, 'caveman-session', 'rule.md'));
@@ -66,6 +113,14 @@ async function fixExtensions(pluginsDir) {
66
113
  console.log(' [warn] Caveman rule unreachable and no bundled rule exists');
67
114
  }
68
115
  }
116
+ // Writing is not atomic, so re-read rather than assume the tree is whole.
117
+ if (dryRun)
118
+ return;
119
+ const missing = TREE_FILES.filter((file) => !existsSync(path.join(pluginExtDir, ...file.split('/'))));
120
+ if (missing.length > 0)
121
+ sayTagged(` [fail] tree incomplete: ${missing.join(', ')}`);
122
+ else
123
+ sayTagged(` [ok] extension tree: ${TREE_FILES.length} files`);
69
124
  }
70
125
  async function fixRegistrations(agentDir, pluginsDir) {
71
126
  console.log(' Doctor --fix: repairing config.yml registrations');
@@ -120,12 +175,16 @@ async function fixRtk(binDir) {
120
175
  httpsGet(checksAsset.browser_download_url).catch(() => null),
121
176
  httpsDownload(asset.browser_download_url, archivePath),
122
177
  ]);
123
- if (checks) {
124
- const expected = parseChecksum(checks, asset.name);
125
- if (expected && await sha256File(archivePath) !== expected)
126
- throw new Error(`checksum mismatch for ${asset.name}`);
127
- debug('RTK checksum verified');
128
- }
178
+ // Same rule as the installer: an unverifiable binary is not installed.
179
+ const unverified = (why) => new Error(`${why} for ${asset.name}. Re-run with --allow-unverified to accept it`);
180
+ if (!checks)
181
+ throw unverified('checksums.txt could not be downloaded');
182
+ const expected = parseChecksum(checks, asset.name);
183
+ if (!expected)
184
+ throw unverified('checksums.txt has no entry');
185
+ if (await sha256File(archivePath) !== expected)
186
+ throw new Error(`checksum mismatch for ${asset.name}`);
187
+ debug('RTK checksum verified');
129
188
  const extractDir = path.join(tmpDir, 'extracted');
130
189
  await fs.mkdir(extractDir, { recursive: true });
131
190
  if (asset.name.endsWith('.zip'))
@@ -147,6 +206,14 @@ async function fixRtk(binDir) {
147
206
  }
148
207
  if (!(await wireRtkOmp(binDest, { dryRun, verbose })))
149
208
  throw new Error('rtk wiring failed');
209
+ // Fail-open: a missing host wire must not fail the repair.
210
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
211
+ if (existsSync(path.join(home, '.config', 'opencode', 'plugins', 'tersio'))) {
212
+ await wireRtkOpencode(binDest, { dryRun, verbose });
213
+ }
214
+ if (existsSync(path.join(home, '.pi', 'agent', 'extensions'))) {
215
+ await wireRtkPi(binDest, { dryRun, verbose });
216
+ }
150
217
  }
151
218
  async function fixPonytail(pluginsDir) {
152
219
  console.log(' Doctor --fix: restoring bundled Ponytail');