@krtclcdy/tersio 2.24.0 → 2.25.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 (63) hide show
  1. package/README.md +28 -24
  2. package/cli/banner.js +7 -5
  3. package/cli/common.js +14 -13
  4. package/cli/currency.js +2 -7
  5. package/cli/dashboard.js +220 -13
  6. package/cli/doctor-fix.js +99 -29
  7. package/cli/doctor.js +32 -35
  8. package/cli/hosts.js +23 -26
  9. package/cli/install.js +228 -115
  10. package/cli/interactive.js +65 -21
  11. package/cli/manifest.js +8 -4
  12. package/cli/profile.js +49 -16
  13. package/cli/reset.js +3 -7
  14. package/cli/rtk-wiring.js +29 -32
  15. package/cli/settings.js +40 -16
  16. package/cli/uninstall.js +79 -29
  17. package/cli/update.js +27 -20
  18. package/cli/usage.js +54 -19
  19. package/dashboard/dist/index.html +18 -18
  20. package/extensions/ai-addons-updater/index.js +75 -20
  21. package/extensions/ai-addons-updater/index.ts +67 -23
  22. package/extensions/caveman-session/index.js +33 -75
  23. package/extensions/caveman-session/index.ts +35 -77
  24. package/extensions/combo-toggle/index.js +40 -62
  25. package/extensions/combo-toggle/index.ts +38 -72
  26. package/extensions/lib/utils.js +64 -15
  27. package/extensions/lib/utils.ts +62 -15
  28. package/extensions/opencode/index.js +104 -0
  29. package/extensions/opencode/server.js +190 -0
  30. package/extensions/opencode/server.ts +200 -0
  31. package/extensions/rtk-session/index.js +38 -57
  32. package/extensions/rtk-session/index.ts +36 -55
  33. package/extensions/shared/carbon.js +4 -10
  34. package/extensions/shared/carbon.ts +5 -10
  35. package/extensions/shared/host.js +95 -56
  36. package/extensions/shared/host.ts +106 -60
  37. package/extensions/shared/plugin-settings.js +6 -18
  38. package/extensions/shared/plugin-settings.ts +6 -18
  39. package/extensions/shared/pricing.js +79 -27
  40. package/extensions/shared/pricing.ts +80 -28
  41. package/extensions/shared/rtk-gain.js +4 -11
  42. package/extensions/shared/rtk-gain.ts +4 -11
  43. package/extensions/shared/session-state.js +28 -39
  44. package/extensions/shared/session-state.ts +29 -38
  45. package/extensions/shared/status.js +38 -0
  46. package/extensions/shared/status.ts +45 -0
  47. package/extensions/shared/types.js +1 -2
  48. package/extensions/shared/types.ts +2 -2
  49. package/extensions/shared/usage-ledger.js +312 -57
  50. package/extensions/shared/usage-ledger.ts +321 -61
  51. package/extensions/shared/usage-store.js +354 -38
  52. package/extensions/shared/usage-store.ts +360 -43
  53. package/extensions/tersio-commands/index.js +5 -14
  54. package/extensions/tersio-commands/index.ts +5 -14
  55. package/package.json +12 -7
  56. package/tersio.js +4 -6
  57. package/cli/agent-hosts.js +0 -42
  58. package/cli/agents.js +0 -266
  59. package/cli/layer.js +0 -24
  60. package/cli/omp-layer.js +0 -51
  61. package/cli/pi-layer.js +0 -37
  62. package/cli/pi-wiring.js +0 -92
  63. package/extensions/shared/omp-prompt.js +0 -12
@@ -1,6 +1,4 @@
1
- // extensions/shared/pricing.ts — dynamic model pricing from LiteLLM.
2
- // Exact id → provider-prefix strip → default, which reports with known:false so
3
- // the Dashboard labels an unpriced model honestly. No static table: prices rot.
1
+ // extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id → provider-prefix strip → default, reported with known:false when unpriced.
4
2
  import fs from 'node:fs';
5
3
  import path from 'node:path';
6
4
  import { tersioDataPath } from '../lib/utils.ts';
@@ -16,8 +14,7 @@ export interface ModelPrice {
16
14
  export const DEFAULT_PRICE: ModelPrice = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
17
15
 
18
16
  const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
19
- // Readers use the file even when stale, and only one refresh runs per process;
20
- // `tersio update` still forces a fresh fetch.
17
+ // Readers use the file even when stale, and only one refresh runs per process; `tersio update` still forces a fresh fetch.
21
18
  const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
22
19
  let refreshInflight: Promise<boolean> | null = null;
23
20
 
@@ -33,8 +30,7 @@ export function pricesUrl(): string {
33
30
 
34
31
  export interface LivePrices {
35
32
  fetchedAt: number;
36
- // Compact tuples [input, output, cacheRead, cacheWrite] per LiteLLM id —
37
- // the full feed caches to ~1MB instead of multi-MB objects.
33
+ // Compact tuples [input, output, cacheRead, cacheWrite] per LiteLLM id — the full feed caches to ~1MB instead of multi-MB objects.
38
34
  exact: Record<string, [number, number, number, number]>;
39
35
  deprecated?: string[];
40
36
  }
@@ -43,8 +39,7 @@ function toPrice(t: [number, number, number, number]): ModelPrice {
43
39
  return { input: t[0], output: t[1], cacheRead: t[2], cacheWrite: t[3] };
44
40
  }
45
41
 
46
- // Tolerate both cache shapes (compact tuples and ModelPrice objects) so a
47
- // version bump never orphans the cache.
42
+ // Tolerate both cache shapes (compact tuples and ModelPrice objects) so a version bump never orphans the cache.
48
43
  function asPrice(v: unknown): ModelPrice | null {
49
44
  if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
50
45
  return toPrice(v as [number, number, number, number]);
@@ -52,15 +47,30 @@ function asPrice(v: unknown): ModelPrice | null {
52
47
  if (v && typeof v === 'object') {
53
48
  const o = v as Record<string, unknown>;
54
49
  if (['input', 'output', 'cacheRead', 'cacheWrite'].every((k) => typeof o[k] === 'number' && Number.isFinite(o[k]) && (o[k] as number) >= 0)) {
55
- // SAFETY: the line above verified all four keys are finite numbers >= 0,
56
- // which is the whole of ModelPrice.
50
+ // SAFETY: the line above verified all four keys are finite numbers >= 0, which is the whole of ModelPrice.
57
51
  return o as unknown as ModelPrice;
58
52
  }
59
53
  }
60
54
  return null;
61
55
  }
62
56
 
57
+ let liveCache: { path: string; mtime: number; prices: LivePrices } | null = null;
58
+
63
59
  export function loadLivePrices(): LivePrices | null {
60
+ // One stat per call beats one full parse: the recent-rows map prices thousands of rows.
61
+ try {
62
+ const file = pricesCachePath();
63
+ const mtime = fs.statSync(file).mtimeMs;
64
+ if (liveCache && liveCache.path === file && liveCache.mtime === mtime) return liveCache.prices;
65
+ const prices = readLivePrices();
66
+ if (prices) liveCache = { path: file, mtime, prices };
67
+ return prices;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function readLivePrices(): LivePrices | null {
64
74
  let raw: string;
65
75
  try {
66
76
  raw = fs.readFileSync(pricesCachePath(), 'utf8');
@@ -88,37 +98,80 @@ function num(v: unknown): number | null {
88
98
  return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
89
99
  }
90
100
 
91
- // One model, two spellings: transcripts say `space-bunny`, the feed keys it
92
- // `openrouter/stealth/space-bunny-alpha` at zero. Aliases fold to the first, so
93
- // a free stealth model never reports the default price.
101
+ // Transcripts and the feed spell one model differently; aliases stop a free model reporting the default price.
94
102
  export const MODEL_ALIASES: ReadonlyArray<{ id: string; feed: string }> = [
95
103
  { id: 'space-bunny', feed: 'stealth/space-bunny-alpha' },
96
104
  ];
97
105
 
98
106
  export function canonicalPriceId(model: string): string {
99
107
  const key = model.toLowerCase();
100
- return MODEL_ALIASES.find((a) => a.id === key || a.feed === key)?.id ?? key;
108
+ // Suffix match: gateways prefix the id (opencode/kilo/stealth/space-bunny-alpha).
109
+ return MODEL_ALIASES.find((a) => a.id === key || a.feed === key || key.endsWith(`/${a.id}`) || key.endsWith(`/${a.feed}`))?.id ?? key;
101
110
  }
102
111
 
103
112
  export function priceFor(model: string, live?: LivePrices | null): { price: ModelPrice; known: boolean; live: boolean } {
104
113
  const table = live === undefined ? loadLivePrices() : live;
105
- // An alias goes through the same exact-then-suffix chain as any other id, so
106
- // it still resolves when the feed keys it under a provider prefix.
114
+ if (live === undefined) {
115
+ const memo = priceMemo(table);
116
+ const hit = memo.get(model);
117
+ if (hit) return hit;
118
+ const resolved = resolvePrice(model, table);
119
+ memo.set(model, resolved);
120
+ return resolved;
121
+ }
122
+ return resolvePrice(model, table);
123
+ }
124
+
125
+ // Recent rows repeat a handful of models; the memo turns thousands of lookups into one per model. Cleared whenever the table identity changes.
126
+ let memoTable: LivePrices | null | undefined;
127
+ let memo = new Map<string, { price: ModelPrice; known: boolean; live: boolean }>();
128
+
129
+ function priceMemo(table: LivePrices | null): Map<string, { price: ModelPrice; known: boolean; live: boolean }> {
130
+ if (memoTable !== table) {
131
+ memoTable = table;
132
+ memo = new Map();
133
+ }
134
+ return memo;
135
+ }
136
+
137
+ // Validated once per table instead of once per lookup.
138
+ let compiledTable: LivePrices | null = null;
139
+ let compiledEntries: Array<[string, ModelPrice]> = [];
140
+
141
+ function priceEntries(table: LivePrices): Array<[string, ModelPrice]> {
142
+ if (compiledTable === table) return compiledEntries;
143
+ const entries: Array<[string, ModelPrice]> = [];
144
+ for (const [k, v] of Object.entries(table.exact)) {
145
+ const price = asPrice(v);
146
+ if (price) entries.push([k, price]);
147
+ }
148
+ compiledTable = table;
149
+ compiledEntries = entries;
150
+ return entries;
151
+ }
152
+
153
+ function resolvePrice(model: string, table: LivePrices | null): { price: ModelPrice; known: boolean; live: boolean } {
154
+ // An alias goes through the same exact-then-suffix chain as any other id, so it still resolves when the feed keys it under a provider prefix.
107
155
  const id = MODEL_ALIASES.find((a) => a.id === model.toLowerCase())?.feed ?? model;
108
156
  if (table) {
109
- const hit = asPrice(table.exact[id] ?? table.exact[id.toLowerCase()]);
110
- if (hit) return { price: hit, known: true, live: true };
157
+ const entries = priceEntries(table);
158
+ const lower = id.toLowerCase();
159
+ const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === lower);
160
+ if (hit) return { price: hit[1], known: true, live: true };
111
161
  // Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
112
- // first cached id that ends with the bare model name.
113
- const name = id.toLowerCase();
114
- const key = Object.keys(table.exact).find((k) => k.toLowerCase() === name || k.toLowerCase().endsWith(`/${name}`));
115
- const prefixed = key ? asPrice(table.exact[key]) : null;
116
- if (prefixed) return { price: prefixed, known: true, live: true };
162
+ // first cached key that ends with the full id, then with the bare tail
163
+ // segment, so "codex/openai/gpt-6-luna" still finds "gpt-6-luna".
164
+ const name = lower;
165
+ const tail = name.split('/').pop() ?? name;
166
+ const key = entries.find(([k]) => {
167
+ const lk = k.toLowerCase();
168
+ return lk === name || lk.endsWith(`/${name}`) || lk === tail || lk.endsWith(`/${tail}`);
169
+ });
170
+ if (key) return { price: key[1], known: true, live: true };
117
171
  }
118
172
  return { price: DEFAULT_PRICE, known: false, live: false };
119
173
  }
120
- // Fetch LiteLLM pricing and write the cache. False on any failure — callers
121
- // fall back silently.
174
+ // Fetch LiteLLM pricing and write the cache. False on any failure — callers fall back silently.
122
175
  export async function refreshPrices(): Promise<boolean> {
123
176
  if (refreshInflight) return refreshInflight;
124
177
  refreshInflight = (async () => {
@@ -172,8 +225,7 @@ export async function refreshPrices(): Promise<boolean> {
172
225
  }
173
226
  }
174
227
 
175
- // Fire-and-forget refresh when the cache is stale; readers keep the stale
176
- // file, so pricing never blocks.
228
+ // Fire-and-forget refresh when the cache is stale; readers keep the stale file, so pricing never blocks.
177
229
  export function refreshPricesIfStale(): void {
178
230
  const live = loadLivePrices();
179
231
  if (live && Date.now() - live.fetchedAt <= CACHE_TTL_MS) return;
@@ -1,18 +1,12 @@
1
- // extensions/shared/rtk-gain.ts — measured RTK savings read from rtk's own
2
- // history.db with the sqlite3 CLI, so the dashboard shows measured figures and
3
- // never estimates. Missing DB or CLI returns empty. Caveman and Ponytail are
4
- // bench-measured in BENCHMARK.md instead: instruction-following is not metered.
1
+ // Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK.md.
5
2
  import { execFileSync } from 'node:child_process';
6
3
  import fs from 'node:fs';
7
4
  import os from 'node:os';
8
5
  import path from 'node:path';
9
6
  const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
10
- // The table shows one clipped cell, so bound the command before it crosses the
11
- // sqlite3 CLI and lands in every /data.json poll.
7
+ // The table shows one clipped cell, so bound the command before it crosses the sqlite3 CLI and lands in every /data.json poll.
12
8
  const MAX_CMD_CHARS = 200;
13
- // RTK stores heredocs and `node -e` scripts verbatim, so one row can arrive as
14
- // several lines and derail every row after it. The stored command keeps its
15
- // newlines; only the read view flattens.
9
+ // A multi-line stored command would derail every row after it; flatten on read.
16
10
  const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
17
11
  export function rtkDbPath() {
18
12
  const override = process.env.TERSIO_RTK_DB;
@@ -39,8 +33,7 @@ export function readRtkGain(limit = 10, cutoffMs) {
39
33
  try {
40
34
  if (!fs.existsSync(rtkDbPath()))
41
35
  return EMPTY;
42
- // View-level cutoff only: rtk's own rows stay untouched. Timestamps that do
43
- // not parse land on 0 and drop out of a filtered view.
36
+ // View-level cutoff only: rtk's own rows stay untouched. Timestamps that do not parse land on 0 and drop out of a filtered view.
44
37
  const where = cutoffMs && cutoffMs > 0
45
38
  ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
46
39
  : '';
@@ -1,7 +1,4 @@
1
- // extensions/shared/rtk-gain.ts — measured RTK savings read from rtk's own
2
- // history.db with the sqlite3 CLI, so the dashboard shows measured figures and
3
- // never estimates. Missing DB or CLI returns empty. Caveman and Ponytail are
4
- // bench-measured in BENCHMARK.md instead: instruction-following is not metered.
1
+ // Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK.md.
5
2
  import { execFileSync } from 'node:child_process';
6
3
  import fs from 'node:fs';
7
4
  import os from 'node:os';
@@ -26,13 +23,10 @@ export interface RtkGain {
26
23
 
27
24
  const EMPTY: RtkGain = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
28
25
 
29
- // The table shows one clipped cell, so bound the command before it crosses the
30
- // sqlite3 CLI and lands in every /data.json poll.
26
+ // The table shows one clipped cell, so bound the command before it crosses the sqlite3 CLI and lands in every /data.json poll.
31
27
  const MAX_CMD_CHARS = 200;
32
28
 
33
- // RTK stores heredocs and `node -e` scripts verbatim, so one row can arrive as
34
- // several lines and derail every row after it. The stored command keeps its
35
- // newlines; only the read view flattens.
29
+ // A multi-line stored command would derail every row after it; flatten on read.
36
30
  const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
37
31
 
38
32
  export function rtkDbPath(): string {
@@ -60,8 +54,7 @@ function query(db: string, sql: string): string[][] {
60
54
  export function readRtkGain(limit = 10, cutoffMs?: number): RtkGain {
61
55
  try {
62
56
  if (!fs.existsSync(rtkDbPath())) return EMPTY;
63
- // View-level cutoff only: rtk's own rows stay untouched. Timestamps that do
64
- // not parse land on 0 and drop out of a filtered view.
57
+ // View-level cutoff only: rtk's own rows stay untouched. Timestamps that do not parse land on 0 and drop out of a filtered view.
65
58
  const where = cutoffMs && cutoffMs > 0
66
59
  ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
67
60
  : '';
@@ -1,5 +1,7 @@
1
+ import { readPluginSettings } from './plugin-settings.js';
1
2
  const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
2
- export const OMP_SUBAGENT_MARKER = 'You are operating on a piece of work assigned to you by the main agent.';
3
+ // A verbatim sentence from omp's subagent prompt, so it can drift; read from the installed omp binary on 2026-10-01. pi has no built-in subagent prompt.
4
+ export const OMP_SUBAGENT_MARKER = 'Worker agent: delegated tasks.';
3
5
  export const COMBO_LEVELS = Object.freeze({
4
6
  off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
5
7
  medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite' }),
@@ -16,6 +18,11 @@ const MODE_ENTRY_TYPES = {
16
18
  'rtk-mode': 'rtk',
17
19
  'ponytail-mode': 'ponytail',
18
20
  };
21
+ /** True when the branch carries Tersio's own mode state; a subagent starts without it. */
22
+ export function hasModeState(entries) {
23
+ return Array.isArray(entries) && entries.some((entry) => entry?.type === 'custom'
24
+ && (entry.customType === 'combo-level' || MODE_ENTRY_TYPES[entry.customType ?? '']));
25
+ }
19
26
  export function normalizeMode(name, value) {
20
27
  if (name === 'rtk' && typeof value === 'boolean')
21
28
  return value ? 'on' : 'off';
@@ -51,12 +58,12 @@ function bridge() {
51
58
  if (existing?.state)
52
59
  return existing;
53
60
  const initial = normalizedState((existing || COMBO_LEVELS.off));
54
- return (globalThis[BRIDGE_KEY] = { state: initial, listeners: new Set() });
61
+ return (globalThis[BRIDGE_KEY] = { state: initial, listeners: new Map() });
55
62
  }
56
63
  function publish(state) {
57
64
  const shared = bridge();
58
65
  shared.state = state;
59
- for (const listener of shared.listeners)
66
+ for (const listener of shared.listeners.values())
60
67
  listener(state);
61
68
  return state;
62
69
  }
@@ -70,33 +77,21 @@ export function systemPromptIncludes(systemPrompt, marker) {
70
77
  return asPromptArray(systemPrompt).some((prompt) => typeof prompt === 'string' && prompt.includes(marker));
71
78
  }
72
79
  export function isOmpSubagentPrompt(systemPrompt) {
73
- return systemPromptIncludes(systemPrompt, OMP_SUBAGENT_MARKER);
74
- }
80
+ const raw = readPluginSettings().subagentMarkers;
81
+ const configured = Array.isArray(raw) ? raw.filter((m) => typeof m === 'string' && m.trim() !== '') : [];
82
+ // A malformed setting falls back rather than matching every turn.
83
+ const markers = configured.length ? configured : [OMP_SUBAGENT_MARKER];
84
+ return markers.some((marker) => systemPromptIncludes(systemPrompt, marker));
85
+ }
86
+ // ultra maps to max, lite/full to nearest, rather than erroring.
87
+ const COMBO_ALIASES = { ultra: 'max', lite: 'medium', full: 'balanced' };
75
88
  export function normalizeComboLevel(value) {
76
89
  const level = String(value || '').trim().toLowerCase();
77
- return isPresetLevel(level) ? level : null;
78
- }
79
- // OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
80
- // the same string renders plain there.
81
- export function themeStatus(ui, indicator, label, highlight = false) {
82
- const theme = ui?.theme;
83
- if (!theme?.fg)
84
- return `${indicator} ${label}`;
85
- return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
86
- }
87
- export function paintStatusBar(ui, key, emoji, label, isActive) {
88
- ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
89
- }
90
- // Which context a status bar paints through. A UI-less event (a headless child
91
- // session_start, or a top-level one that fires before the TUI attaches) must
92
- // never be remembered: doing so mutes every later bare syncStatus(), which is
93
- // the bridge-listener path a sibling's mode change arrives on. The symptom is a
94
- // bar frozen on a preset the session no longer has.
95
- export function paintableCtx(remembered, next) {
96
- return next?.ui?.setStatus ? next : remembered;
97
- }
98
- // Last-wins scan for a custom session entry; skips entries whose value fails
99
- // to parse so a corrupt write never shadows an older valid one.
90
+ if (isPresetLevel(level))
91
+ return level;
92
+ return COMBO_ALIASES[level] ?? null;
93
+ }
94
+ // Last-wins scan for a custom session entry; skips entries whose value fails to parse so a corrupt write never shadows an older valid one.
100
95
  export function lastCustomValue(entries, customType, pick) {
101
96
  if (!Array.isArray(entries))
102
97
  return null;
@@ -129,8 +124,7 @@ export function setSharedComboMode(name, value) {
129
124
  if (!mode)
130
125
  return getSharedComboState();
131
126
  const modes = { ...getSharedComboState(), [name]: mode };
132
- // Derive the level from the final triplet so a redundant same-value write
133
- // cannot strand a matching preset at 'custom' and drop the combo bar.
127
+ // Derive the level from the final triplet so a redundant same-value write cannot strand a matching preset at 'custom' and drop the combo bar.
134
128
  return publish(normalizedState(modes));
135
129
  }
136
130
  export function reconcileSharedComboEntries(entries) {
@@ -154,21 +148,16 @@ export function reconcileSharedComboEntries(entries) {
154
148
  modes[name] = mode;
155
149
  }
156
150
  }
157
- // The level reflects the final triplet, never the write order, so a preset
158
- // entry followed by the same values reconciles back to the preset.
151
+ // The level reflects the final triplet, never the write order, so a preset entry followed by the same values reconciles back to the preset.
159
152
  return publish(normalizedState(modes));
160
153
  }
161
154
  // One-line session-wide mode summary for change confirmations.
162
155
  export function activeModesSummary(state) {
163
156
  return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
164
157
  }
165
- export function setSharedComboListener(listener) {
166
- // Additive: every sibling syncs its mirror on publish, so a /tersio or
167
- // /combo switch lands next turn with no reload. A null listener clears all.
168
- if (typeof listener === 'function')
169
- bridge().listeners.add(listener);
170
- else
171
- bridge().listeners.clear();
158
+ // Keyed registration drops stale closures; hosts offer no teardown hook.
159
+ export function setSharedComboListener(key, listener) {
160
+ bridge().listeners.set(key, listener);
172
161
  }
173
162
  export function resetSharedComboState() {
174
163
  return setSharedComboLevel('off');
@@ -1,8 +1,10 @@
1
- import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry, UiApi } from './types.ts';
1
+ import { readPluginSettings } from './plugin-settings.ts';
2
+ import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry } from './types.ts';
2
3
 
3
4
  const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
4
5
 
5
- export const OMP_SUBAGENT_MARKER = 'You are operating on a piece of work assigned to you by the main agent.';
6
+ // A verbatim sentence from omp's subagent prompt, so it can drift; read from the installed omp binary on 2026-10-01. pi has no built-in subagent prompt.
7
+ export const OMP_SUBAGENT_MARKER = 'Worker agent: delegated tasks.';
6
8
 
7
9
  export const COMBO_LEVELS: Record<string, Readonly<ComboState>> = Object.freeze({
8
10
  off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
@@ -26,9 +28,15 @@ const MODE_ENTRY_TYPES: Record<string, ModeName> = {
26
28
  'ponytail-mode': 'ponytail',
27
29
  };
28
30
 
31
+ /** True when the branch carries Tersio's own mode state; a subagent starts without it. */
32
+ export function hasModeState(entries: SessionEntry[] | null | undefined): boolean {
33
+ return Array.isArray(entries) && entries.some((entry) => entry?.type === 'custom'
34
+ && (entry.customType === 'combo-level' || MODE_ENTRY_TYPES[entry.customType ?? '']));
35
+ }
36
+
29
37
  interface Bridge {
30
38
  state: Readonly<ComboState>;
31
- listeners: Set<(state: Readonly<ComboState>) => void>;
39
+ listeners: Map<string, (state: Readonly<ComboState>) => void>;
32
40
  }
33
41
 
34
42
  export function normalizeMode(name: ModeName, value: unknown): string | null {
@@ -67,13 +75,13 @@ function bridge(): Bridge {
67
75
  const existing = (globalThis as Record<symbol, unknown>)[BRIDGE_KEY] as Bridge | undefined;
68
76
  if (existing?.state) return existing;
69
77
  const initial = normalizedState((existing || COMBO_LEVELS.off) as Partial<Modes>);
70
- return ((globalThis as Record<symbol, unknown>)[BRIDGE_KEY] = { state: initial, listeners: new Set<(state: Readonly<ComboState>) => void>() });
78
+ return ((globalThis as Record<symbol, unknown>)[BRIDGE_KEY] = { state: initial, listeners: new Map() });
71
79
  }
72
80
 
73
81
  function publish(state: Readonly<ComboState>): Readonly<ComboState> {
74
82
  const shared = bridge();
75
83
  shared.state = state;
76
- for (const listener of shared.listeners) listener(state);
84
+ for (const listener of shared.listeners.values()) listener(state);
77
85
  return state;
78
86
  }
79
87
 
@@ -90,37 +98,24 @@ export function systemPromptIncludes(systemPrompt: string | string[], marker: st
90
98
  }
91
99
 
92
100
  export function isOmpSubagentPrompt(systemPrompt: string | string[]): boolean {
93
- return systemPromptIncludes(systemPrompt, OMP_SUBAGENT_MARKER);
101
+ const raw = readPluginSettings().subagentMarkers;
102
+ const configured = Array.isArray(raw) ? raw.filter((m): m is string => typeof m === 'string' && m.trim() !== '') : [];
103
+ // A malformed setting falls back rather than matching every turn.
104
+ const markers = configured.length ? configured : [OMP_SUBAGENT_MARKER];
105
+ return markers.some((marker) => systemPromptIncludes(systemPrompt, marker));
94
106
  }
95
107
 
108
+ // ultra maps to max, lite/full to nearest, rather than erroring.
109
+ const COMBO_ALIASES: Record<string, ComboLevel> = { ultra: 'max', lite: 'medium', full: 'balanced' };
110
+
96
111
  export function normalizeComboLevel(value: unknown): ComboLevel | null {
97
112
  const level = String(value || '').trim().toLowerCase();
98
- return isPresetLevel(level) ? level : null;
99
- }
100
-
101
- // OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
102
- // the same string renders plain there.
103
- export function themeStatus(ui: UiApi | undefined, indicator: string, label: string, highlight = false): string {
104
- const theme = ui?.theme;
105
- if (!theme?.fg) return `${indicator} ${label}`;
106
- return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
107
- }
108
-
109
- export function paintStatusBar(ui: UiApi | undefined, key: string, emoji: string, label: string, isActive: boolean): void {
110
- ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
113
+ if (isPresetLevel(level)) return level;
114
+ return COMBO_ALIASES[level] ?? null;
111
115
  }
112
116
 
113
- // Which context a status bar paints through. A UI-less event (a headless child
114
- // session_start, or a top-level one that fires before the TUI attaches) must
115
- // never be remembered: doing so mutes every later bare syncStatus(), which is
116
- // the bridge-listener path a sibling's mode change arrives on. The symptom is a
117
- // bar frozen on a preset the session no longer has.
118
- export function paintableCtx(remembered: ExtensionCtx | undefined, next: ExtensionCtx | undefined): ExtensionCtx | undefined {
119
- return next?.ui?.setStatus ? next : remembered;
120
- }
121
117
 
122
- // Last-wins scan for a custom session entry; skips entries whose value fails
123
- // to parse so a corrupt write never shadows an older valid one.
118
+ // Last-wins scan for a custom session entry; skips entries whose value fails to parse so a corrupt write never shadows an older valid one.
124
119
  export function lastCustomValue<T>(entries: SessionEntry[] | null | undefined, customType: string, pick: (data: SessionEntry['data']) => T | null | undefined): T | null {
125
120
  if (!Array.isArray(entries)) return null;
126
121
  for (let i = entries.length - 1; i >= 0; i -= 1) {
@@ -154,8 +149,7 @@ export function setSharedComboMode(name: ModeName, value: unknown): Readonly<Com
154
149
  const mode = normalizeMode(name, value);
155
150
  if (!mode) return getSharedComboState();
156
151
  const modes = { ...getSharedComboState(), [name]: mode } as Modes;
157
- // Derive the level from the final triplet so a redundant same-value write
158
- // cannot strand a matching preset at 'custom' and drop the combo bar.
152
+ // Derive the level from the final triplet so a redundant same-value write cannot strand a matching preset at 'custom' and drop the combo bar.
159
153
  return publish(normalizedState(modes));
160
154
  }
161
155
 
@@ -176,8 +170,7 @@ export function reconcileSharedComboEntries(entries: SessionEntry[] | null | und
176
170
  if (mode) modes[name] = mode;
177
171
  }
178
172
  }
179
- // The level reflects the final triplet, never the write order, so a preset
180
- // entry followed by the same values reconciles back to the preset.
173
+ // The level reflects the final triplet, never the write order, so a preset entry followed by the same values reconciles back to the preset.
181
174
  return publish(normalizedState(modes));
182
175
  }
183
176
 
@@ -186,11 +179,9 @@ export function activeModesSummary(state: { caveman: string; rtk: string; ponyta
186
179
  return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
187
180
  }
188
181
 
189
- export function setSharedComboListener(listener: ((state: Readonly<ComboState>) => void) | null): void {
190
- // Additive: every sibling syncs its mirror on publish, so a /tersio or
191
- // /combo switch lands next turn with no reload. A null listener clears all.
192
- if (typeof listener === 'function') bridge().listeners.add(listener);
193
- else bridge().listeners.clear();
182
+ // Keyed registration drops stale closures; hosts offer no teardown hook.
183
+ export function setSharedComboListener(key: string, listener: (state: Readonly<ComboState>) => void): void {
184
+ bridge().listeners.set(key, listener);
194
185
  }
195
186
 
196
187
  export function resetSharedComboState(): Readonly<ComboState> {
@@ -0,0 +1,38 @@
1
+ // One status string every surface renders: host footer bar, CLI menu title, dashboard banner. Nothing else formats mode state.
2
+ import { getSharedComboState } from './session-state.js';
3
+ /** OFF collapses the three modes into one word; a preset or a mix names itself. */
4
+ export function formatStatus(state) {
5
+ const { level, caveman, rtk, ponytail } = state;
6
+ if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off')
7
+ return '🧩 combo OFF';
8
+ const label = level === 'custom' ? 'CUSTOM' : level.toUpperCase();
9
+ return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()}`;
10
+ }
11
+ // Stale ctx access throws, so never let it abort a mode restore.
12
+ function statusUi(ctx) {
13
+ try {
14
+ const ui = ctx?.ui;
15
+ return ui?.setStatus ? ui : undefined;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ // Subagents and bare restores must not mute the bar on the real session.
22
+ let remembered;
23
+ let lastText;
24
+ let lastUi;
25
+ // Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
26
+ export function announceStatus(ctx) {
27
+ const text = formatStatus(getSharedComboState());
28
+ const ui = statusUi(ctx) ?? statusUi(remembered);
29
+ if (statusUi(ctx))
30
+ remembered = ctx;
31
+ if (!ui)
32
+ return;
33
+ if (text === lastText && ui === lastUi)
34
+ return;
35
+ lastText = text;
36
+ lastUi = ui;
37
+ ui.setStatus?.('tersio', text);
38
+ }
@@ -0,0 +1,45 @@
1
+ // One status string every surface renders: host footer bar, CLI menu title, dashboard banner. Nothing else formats mode state.
2
+ import { getSharedComboState } from './session-state.ts';
3
+ import type { ExtensionCtx, UiApi } from './types.ts';
4
+
5
+ export interface StatusState {
6
+ level: string;
7
+ caveman: string;
8
+ rtk: string;
9
+ ponytail: string;
10
+ }
11
+
12
+ /** OFF collapses the three modes into one word; a preset or a mix names itself. */
13
+ export function formatStatus(state: StatusState): string {
14
+ const { level, caveman, rtk, ponytail } = state;
15
+ if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off') return '🧩 combo OFF';
16
+ const label = level === 'custom' ? 'CUSTOM' : level.toUpperCase();
17
+ return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()}`;
18
+ }
19
+
20
+ // Stale ctx access throws, so never let it abort a mode restore.
21
+ function statusUi(ctx: ExtensionCtx | undefined): UiApi | undefined {
22
+ try {
23
+ const ui = ctx?.ui;
24
+ return ui?.setStatus ? ui : undefined;
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ // Subagents and bare restores must not mute the bar on the real session.
31
+ let remembered: ExtensionCtx | undefined;
32
+ let lastText: string | undefined;
33
+ let lastUi: UiApi | undefined;
34
+
35
+ // Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
36
+ export function announceStatus(ctx: ExtensionCtx | undefined): void {
37
+ const text = formatStatus(getSharedComboState());
38
+ const ui = statusUi(ctx) ?? statusUi(remembered);
39
+ if (statusUi(ctx)) remembered = ctx;
40
+ if (!ui) return;
41
+ if (text === lastText && ui === lastUi) return;
42
+ lastText = text;
43
+ lastUi = ui;
44
+ ui.setStatus?.('tersio', text);
45
+ }
@@ -1,3 +1,2 @@
1
- // Shared OMP extension host types. Structural — the real `pi` object comes
2
- // from the OMP runtime; these describe the surface this package touches.
1
+ // Shared OMP extension host types. Structural — the real `pi` object comes from the OMP runtime; these describe the surface this package touches.
3
2
  export {};
@@ -1,5 +1,4 @@
1
- // Shared OMP extension host types. Structural — the real `pi` object comes
2
- // from the OMP runtime; these describe the surface this package touches.
1
+ // Shared OMP extension host types. Structural — the real `pi` object comes from the OMP runtime; these describe the surface this package touches.
3
2
 
4
3
  export interface ComboState {
5
4
  level: string;
@@ -94,4 +93,5 @@ export interface ExtensionApi {
94
93
  }>;
95
94
  cwd?: string;
96
95
  zod?: { z: unknown };
96
+ hostId?: 'pi' | 'omp' | 'opencode';
97
97
  on<E>(event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void;}