@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
@@ -1,13 +1,11 @@
1
- // extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id
2
- // → provider-prefix strip → default, reported with known:false when unpriced.
1
+ // extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id → provider-prefix strip → default, reported with known:false when unpriced.
3
2
  import fs from 'node:fs';
4
3
  import path from 'node:path';
5
4
  import { tersioDataPath } from '../lib/utils.js';
6
5
  // Sonnet-class default for unpriced models; always paired with known:false.
7
6
  export const DEFAULT_PRICE = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
8
7
  const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
9
- // Readers use the file even when stale, and only one refresh runs per process;
10
- // `tersio update` still forces a fresh fetch.
8
+ // Readers use the file even when stale, and only one refresh runs per process; `tersio update` still forces a fresh fetch.
11
9
  const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
12
10
  let refreshInflight = null;
13
11
  export function pricesCachePath() {
@@ -22,8 +20,7 @@ export function pricesUrl() {
22
20
  function toPrice(t) {
23
21
  return { input: t[0], output: t[1], cacheRead: t[2], cacheWrite: t[3] };
24
22
  }
25
- // Tolerate both cache shapes (compact tuples and ModelPrice objects) so a
26
- // version bump never orphans the cache.
23
+ // Tolerate both cache shapes (compact tuples and ModelPrice objects) so a version bump never orphans the cache.
27
24
  function asPrice(v) {
28
25
  if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
29
26
  return toPrice(v);
@@ -31,14 +28,30 @@ function asPrice(v) {
31
28
  if (v && typeof v === 'object') {
32
29
  const o = v;
33
30
  if (['input', 'output', 'cacheRead', 'cacheWrite'].every((k) => typeof o[k] === 'number' && Number.isFinite(o[k]) && o[k] >= 0)) {
34
- // SAFETY: the line above verified all four keys are finite numbers >= 0,
35
- // which is the whole of ModelPrice.
31
+ // SAFETY: the line above verified all four keys are finite numbers >= 0, which is the whole of ModelPrice.
36
32
  return o;
37
33
  }
38
34
  }
39
35
  return null;
40
36
  }
37
+ let liveCache = null;
41
38
  export function loadLivePrices() {
39
+ // One stat per call beats one full parse: the recent-rows map prices thousands of rows.
40
+ try {
41
+ const file = pricesCachePath();
42
+ const mtime = fs.statSync(file).mtimeMs;
43
+ if (liveCache && liveCache.path === file && liveCache.mtime === mtime)
44
+ return liveCache.prices;
45
+ const prices = readLivePrices();
46
+ if (prices)
47
+ liveCache = { path: file, mtime, prices };
48
+ return prices;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ function readLivePrices() {
42
55
  let raw;
43
56
  try {
44
57
  raw = fs.readFileSync(pricesCachePath(), 'utf8');
@@ -69,36 +82,78 @@ export function loadLivePrices() {
69
82
  function num(v) {
70
83
  return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
71
84
  }
72
- // Transcripts and the feed spell one model differently; aliases stop a free
73
- // model reporting the default price.
85
+ // Transcripts and the feed spell one model differently; aliases stop a free model reporting the default price.
74
86
  export const MODEL_ALIASES = [
75
87
  { id: 'space-bunny', feed: 'stealth/space-bunny-alpha' },
76
88
  ];
77
89
  export function canonicalPriceId(model) {
78
90
  const key = model.toLowerCase();
79
- return MODEL_ALIASES.find((a) => a.id === key || a.feed === key)?.id ?? key;
91
+ // Suffix match: gateways prefix the id (opencode/kilo/stealth/space-bunny-alpha).
92
+ return MODEL_ALIASES.find((a) => a.id === key || a.feed === key || key.endsWith(`/${a.id}`) || key.endsWith(`/${a.feed}`))?.id ?? key;
80
93
  }
81
94
  export function priceFor(model, live) {
82
95
  const table = live === undefined ? loadLivePrices() : live;
83
- // An alias goes through the same exact-then-suffix chain as any other id, so
84
- // it still resolves when the feed keys it under a provider prefix.
96
+ if (live === undefined) {
97
+ const memo = priceMemo(table);
98
+ const hit = memo.get(model);
99
+ if (hit)
100
+ return hit;
101
+ const resolved = resolvePrice(model, table);
102
+ memo.set(model, resolved);
103
+ return resolved;
104
+ }
105
+ return resolvePrice(model, table);
106
+ }
107
+ // Recent rows repeat a handful of models; the memo turns thousands of lookups into one per model. Cleared whenever the table identity changes.
108
+ let memoTable;
109
+ let memo = new Map();
110
+ function priceMemo(table) {
111
+ if (memoTable !== table) {
112
+ memoTable = table;
113
+ memo = new Map();
114
+ }
115
+ return memo;
116
+ }
117
+ // Validated once per table instead of once per lookup.
118
+ let compiledTable = null;
119
+ let compiledEntries = [];
120
+ function priceEntries(table) {
121
+ if (compiledTable === table)
122
+ return compiledEntries;
123
+ const entries = [];
124
+ for (const [k, v] of Object.entries(table.exact)) {
125
+ const price = asPrice(v);
126
+ if (price)
127
+ entries.push([k, price]);
128
+ }
129
+ compiledTable = table;
130
+ compiledEntries = entries;
131
+ return entries;
132
+ }
133
+ function resolvePrice(model, table) {
134
+ // 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.
85
135
  const id = MODEL_ALIASES.find((a) => a.id === model.toLowerCase())?.feed ?? model;
86
136
  if (table) {
87
- const hit = asPrice(table.exact[id] ?? table.exact[id.toLowerCase()]);
137
+ const entries = priceEntries(table);
138
+ const lower = id.toLowerCase();
139
+ const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === lower);
88
140
  if (hit)
89
- return { price: hit, known: true, live: true };
141
+ return { price: hit[1], known: true, live: true };
90
142
  // Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
91
- // first cached id that ends with the bare model name.
92
- const name = id.toLowerCase();
93
- const key = Object.keys(table.exact).find((k) => k.toLowerCase() === name || k.toLowerCase().endsWith(`/${name}`));
94
- const prefixed = key ? asPrice(table.exact[key]) : null;
95
- if (prefixed)
96
- return { price: prefixed, known: true, live: true };
143
+ // first cached key that ends with the full id, then with the bare tail
144
+ // segment, so "codex/openai/gpt-6-luna" still finds "gpt-6-luna".
145
+ const name = lower;
146
+ const tail = name.split('/').pop() ?? name;
147
+ const key = entries.find(([k]) => {
148
+ const lk = k.toLowerCase();
149
+ return lk === name || lk.endsWith(`/${name}`) || lk === tail || lk.endsWith(`/${tail}`);
150
+ });
151
+ if (key)
152
+ return { price: key[1], known: true, live: true };
97
153
  }
98
154
  return { price: DEFAULT_PRICE, known: false, live: false };
99
155
  }
100
- // Fetch LiteLLM pricing and write the cache. False on any failure — callers
101
- // fall back silently.
156
+ // Fetch LiteLLM pricing and write the cache. False on any failure — callers fall back silently.
102
157
  export async function refreshPrices() {
103
158
  if (refreshInflight)
104
159
  return refreshInflight;
@@ -162,8 +217,7 @@ export async function refreshPrices() {
162
217
  refreshInflight = null;
163
218
  }
164
219
  }
165
- // Fire-and-forget refresh when the cache is stale; readers keep the stale
166
- // file, so pricing never blocks.
220
+ // Fire-and-forget refresh when the cache is stale; readers keep the stale file, so pricing never blocks.
167
221
  export function refreshPricesIfStale() {
168
222
  const live = loadLivePrices();
169
223
  if (live && Date.now() - live.fetchedAt <= CACHE_TTL_MS)
@@ -1,5 +1,4 @@
1
- // extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id
2
- // → provider-prefix strip → default, reported with known:false when unpriced.
1
+ // extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id → provider-prefix strip → default, reported with known:false when unpriced.
3
2
  import fs from 'node:fs';
4
3
  import path from 'node:path';
5
4
  import { tersioDataPath } from '../lib/utils.ts';
@@ -15,8 +14,7 @@ export interface ModelPrice {
15
14
  export const DEFAULT_PRICE: ModelPrice = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
16
15
 
17
16
  const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
18
- // Readers use the file even when stale, and only one refresh runs per process;
19
- // `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.
20
18
  const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
21
19
  let refreshInflight: Promise<boolean> | null = null;
22
20
 
@@ -32,8 +30,7 @@ export function pricesUrl(): string {
32
30
 
33
31
  export interface LivePrices {
34
32
  fetchedAt: number;
35
- // Compact tuples [input, output, cacheRead, cacheWrite] per LiteLLM id —
36
- // 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.
37
34
  exact: Record<string, [number, number, number, number]>;
38
35
  deprecated?: string[];
39
36
  }
@@ -42,8 +39,7 @@ function toPrice(t: [number, number, number, number]): ModelPrice {
42
39
  return { input: t[0], output: t[1], cacheRead: t[2], cacheWrite: t[3] };
43
40
  }
44
41
 
45
- // Tolerate both cache shapes (compact tuples and ModelPrice objects) so a
46
- // version bump never orphans the cache.
42
+ // Tolerate both cache shapes (compact tuples and ModelPrice objects) so a version bump never orphans the cache.
47
43
  function asPrice(v: unknown): ModelPrice | null {
48
44
  if (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
49
45
  return toPrice(v as [number, number, number, number]);
@@ -51,15 +47,30 @@ function asPrice(v: unknown): ModelPrice | null {
51
47
  if (v && typeof v === 'object') {
52
48
  const o = v as Record<string, unknown>;
53
49
  if (['input', 'output', 'cacheRead', 'cacheWrite'].every((k) => typeof o[k] === 'number' && Number.isFinite(o[k]) && (o[k] as number) >= 0)) {
54
- // SAFETY: the line above verified all four keys are finite numbers >= 0,
55
- // 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.
56
51
  return o as unknown as ModelPrice;
57
52
  }
58
53
  }
59
54
  return null;
60
55
  }
61
56
 
57
+ let liveCache: { path: string; mtime: number; prices: LivePrices } | null = null;
58
+
62
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 {
63
74
  let raw: string;
64
75
  try {
65
76
  raw = fs.readFileSync(pricesCachePath(), 'utf8');
@@ -87,36 +98,80 @@ function num(v: unknown): number | null {
87
98
  return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
88
99
  }
89
100
 
90
- // Transcripts and the feed spell one model differently; aliases stop a free
91
- // model reporting the default price.
101
+ // Transcripts and the feed spell one model differently; aliases stop a free model reporting the default price.
92
102
  export const MODEL_ALIASES: ReadonlyArray<{ id: string; feed: string }> = [
93
103
  { id: 'space-bunny', feed: 'stealth/space-bunny-alpha' },
94
104
  ];
95
105
 
96
106
  export function canonicalPriceId(model: string): string {
97
107
  const key = model.toLowerCase();
98
- 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;
99
110
  }
100
111
 
101
112
  export function priceFor(model: string, live?: LivePrices | null): { price: ModelPrice; known: boolean; live: boolean } {
102
113
  const table = live === undefined ? loadLivePrices() : live;
103
- // An alias goes through the same exact-then-suffix chain as any other id, so
104
- // 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.
105
155
  const id = MODEL_ALIASES.find((a) => a.id === model.toLowerCase())?.feed ?? model;
106
156
  if (table) {
107
- const hit = asPrice(table.exact[id] ?? table.exact[id.toLowerCase()]);
108
- 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 };
109
161
  // Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
110
- // first cached id that ends with the bare model name.
111
- const name = id.toLowerCase();
112
- const key = Object.keys(table.exact).find((k) => k.toLowerCase() === name || k.toLowerCase().endsWith(`/${name}`));
113
- const prefixed = key ? asPrice(table.exact[key]) : null;
114
- 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 };
115
171
  }
116
172
  return { price: DEFAULT_PRICE, known: false, live: false };
117
173
  }
118
- // Fetch LiteLLM pricing and write the cache. False on any failure — callers
119
- // fall back silently.
174
+ // Fetch LiteLLM pricing and write the cache. False on any failure — callers fall back silently.
120
175
  export async function refreshPrices(): Promise<boolean> {
121
176
  if (refreshInflight) return refreshInflight;
122
177
  refreshInflight = (async () => {
@@ -170,8 +225,7 @@ export async function refreshPrices(): Promise<boolean> {
170
225
  }
171
226
  }
172
227
 
173
- // Fire-and-forget refresh when the cache is stale; readers keep the stale
174
- // file, so pricing never blocks.
228
+ // Fire-and-forget refresh when the cache is stale; readers keep the stale file, so pricing never blocks.
175
229
  export function refreshPricesIfStale(): void {
176
230
  const live = loadLivePrices();
177
231
  if (live && Date.now() - live.fetchedAt <= CACHE_TTL_MS) return;
@@ -1,11 +1,10 @@
1
- // Savings read from rtk history, never estimated; modes bench in BENCHMARK.md.
1
+ // Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK.md.
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
7
- // The table shows one clipped cell, so bound the command before it crosses the
8
- // 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.
9
8
  const MAX_CMD_CHARS = 200;
10
9
  // A multi-line stored command would derail every row after it; flatten on read.
11
10
  const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
@@ -34,8 +33,7 @@ export function readRtkGain(limit = 10, cutoffMs) {
34
33
  try {
35
34
  if (!fs.existsSync(rtkDbPath()))
36
35
  return EMPTY;
37
- // View-level cutoff only: rtk's own rows stay untouched. Timestamps that do
38
- // 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.
39
37
  const where = cutoffMs && cutoffMs > 0
40
38
  ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
41
39
  : '';
@@ -1,4 +1,4 @@
1
- // Savings read from rtk history, never estimated; modes bench in BENCHMARK.md.
1
+ // Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK.md.
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
@@ -23,8 +23,7 @@ export interface RtkGain {
23
23
 
24
24
  const EMPTY: RtkGain = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
25
25
 
26
- // The table shows one clipped cell, so bound the command before it crosses the
27
- // 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.
28
27
  const MAX_CMD_CHARS = 200;
29
28
 
30
29
  // A multi-line stored command would derail every row after it; flatten on read.
@@ -55,8 +54,7 @@ function query(db: string, sql: string): string[][] {
55
54
  export function readRtkGain(limit = 10, cutoffMs?: number): RtkGain {
56
55
  try {
57
56
  if (!fs.existsSync(rtkDbPath())) return EMPTY;
58
- // View-level cutoff only: rtk's own rows stay untouched. Timestamps that do
59
- // 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.
60
58
  const where = cutoffMs && cutoffMs > 0
61
59
  ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
62
60
  : '';
@@ -1,7 +1,7 @@
1
1
  import { readPluginSettings } from './plugin-settings.js';
2
2
  const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
3
- // A verbatim sentence from OMP's subagent prompt, so it can drift; settings.json overrides it.
4
- 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.';
5
5
  export const COMBO_LEVELS = Object.freeze({
6
6
  off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
7
7
  medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite' }),
@@ -18,6 +18,11 @@ const MODE_ENTRY_TYPES = {
18
18
  'rtk-mode': 'rtk',
19
19
  'ponytail-mode': 'ponytail',
20
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
+ }
21
26
  export function normalizeMode(name, value) {
22
27
  if (name === 'rtk' && typeof value === 'boolean')
23
28
  return value ? 'on' : 'off';
@@ -86,33 +91,7 @@ export function normalizeComboLevel(value) {
86
91
  return level;
87
92
  return COMBO_ALIASES[level] ?? null;
88
93
  }
89
- // OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
90
- // the same string renders plain there.
91
- export function themeStatus(ui, indicator, label, highlight = false) {
92
- const theme = ui?.theme;
93
- if (!theme?.fg)
94
- return `${indicator} ${label}`;
95
- return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
96
- }
97
- export function paintStatusBar(ui, key, emoji, label, isActive) {
98
- ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
99
- }
100
- export function statusUi(ctx) {
101
- try {
102
- const ui = ctx?.ui;
103
- return ui?.setStatus ? ui : undefined;
104
- }
105
- catch {
106
- return undefined;
107
- }
108
- }
109
- // Which context a status bar paints through. Never remember a UI-less event:
110
- // that mutes every later bare syncStatus(), freezing the bar on a stale preset.
111
- export function paintableCtx(remembered, next) {
112
- return statusUi(next) ? next : remembered;
113
- }
114
- // Last-wins scan for a custom session entry; skips entries whose value fails
115
- // to parse so a corrupt write never shadows an older valid one.
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.
116
95
  export function lastCustomValue(entries, customType, pick) {
117
96
  if (!Array.isArray(entries))
118
97
  return null;
@@ -145,8 +124,7 @@ export function setSharedComboMode(name, value) {
145
124
  if (!mode)
146
125
  return getSharedComboState();
147
126
  const modes = { ...getSharedComboState(), [name]: mode };
148
- // Derive the level from the final triplet so a redundant same-value write
149
- // 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.
150
128
  return publish(normalizedState(modes));
151
129
  }
152
130
  export function reconcileSharedComboEntries(entries) {
@@ -170,8 +148,7 @@ export function reconcileSharedComboEntries(entries) {
170
148
  modes[name] = mode;
171
149
  }
172
150
  }
173
- // The level reflects the final triplet, never the write order, so a preset
174
- // 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.
175
152
  return publish(normalizedState(modes));
176
153
  }
177
154
  // One-line session-wide mode summary for change confirmations.
@@ -1,10 +1,10 @@
1
1
  import { readPluginSettings } from './plugin-settings.ts';
2
- import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry, UiApi } from './types.ts';
2
+ import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry } from './types.ts';
3
3
 
4
4
  const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
5
5
 
6
- // A verbatim sentence from OMP's subagent prompt, so it can drift; settings.json overrides it.
7
- 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.';
8
8
 
9
9
  export const COMBO_LEVELS: Record<string, Readonly<ComboState>> = Object.freeze({
10
10
  off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
@@ -28,6 +28,12 @@ const MODE_ENTRY_TYPES: Record<string, ModeName> = {
28
28
  'ponytail-mode': 'ponytail',
29
29
  };
30
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
+
31
37
  interface Bridge {
32
38
  state: Readonly<ComboState>;
33
39
  listeners: Map<string, (state: Readonly<ComboState>) => void>;
@@ -108,38 +114,8 @@ export function normalizeComboLevel(value: unknown): ComboLevel | null {
108
114
  return COMBO_ALIASES[level] ?? null;
109
115
  }
110
116
 
111
- // OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
112
- // the same string renders plain there.
113
- export function themeStatus(ui: UiApi | undefined, indicator: string, label: string, highlight = false): string {
114
- const theme = ui?.theme;
115
- if (!theme?.fg) return `${indicator} ${label}`;
116
- return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
117
- }
118
-
119
- export function paintStatusBar(ui: UiApi | undefined, key: string, emoji: string, label: string, isActive: boolean): void {
120
- ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
121
- }
122
-
123
- // Stale ctx access throws, so never let it abort a mode restore.
124
- export type StatusUi = UiApi & { setStatus: NonNullable<UiApi['setStatus']> };
125
-
126
- export function statusUi(ctx: ExtensionCtx | undefined): StatusUi | undefined {
127
- try {
128
- const ui = ctx?.ui;
129
- return ui?.setStatus ? (ui as StatusUi) : undefined;
130
- } catch {
131
- return undefined;
132
- }
133
- }
134
-
135
- // Which context a status bar paints through. Never remember a UI-less event:
136
- // that mutes every later bare syncStatus(), freezing the bar on a stale preset.
137
- export function paintableCtx(remembered: ExtensionCtx | undefined, next: ExtensionCtx | undefined): ExtensionCtx | undefined {
138
- return statusUi(next) ? next : remembered;
139
- }
140
117
 
141
- // Last-wins scan for a custom session entry; skips entries whose value fails
142
- // 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.
143
119
  export function lastCustomValue<T>(entries: SessionEntry[] | null | undefined, customType: string, pick: (data: SessionEntry['data']) => T | null | undefined): T | null {
144
120
  if (!Array.isArray(entries)) return null;
145
121
  for (let i = entries.length - 1; i >= 0; i -= 1) {
@@ -173,8 +149,7 @@ export function setSharedComboMode(name: ModeName, value: unknown): Readonly<Com
173
149
  const mode = normalizeMode(name, value);
174
150
  if (!mode) return getSharedComboState();
175
151
  const modes = { ...getSharedComboState(), [name]: mode } as Modes;
176
- // Derive the level from the final triplet so a redundant same-value write
177
- // 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.
178
153
  return publish(normalizedState(modes));
179
154
  }
180
155
 
@@ -195,8 +170,7 @@ export function reconcileSharedComboEntries(entries: SessionEntry[] | null | und
195
170
  if (mode) modes[name] = mode;
196
171
  }
197
172
  }
198
- // The level reflects the final triplet, never the write order, so a preset
199
- // 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.
200
174
  return publish(normalizedState(modes));
201
175
  }
202
176
 
@@ -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;}