@krtclcdy/tersio 2.24.0 → 2.24.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 (49) hide show
  1. package/README.md +1 -3
  2. package/cli/banner.js +2 -3
  3. package/cli/currency.js +3 -7
  4. package/cli/dashboard.js +167 -3
  5. package/cli/doctor-fix.js +15 -12
  6. package/cli/doctor.js +1 -12
  7. package/cli/hosts.js +7 -11
  8. package/cli/install.js +113 -71
  9. package/cli/interactive.js +36 -2
  10. package/cli/layer.js +1 -3
  11. package/cli/manifest.js +1 -4
  12. package/cli/profile.js +9 -7
  13. package/cli/reset.js +3 -7
  14. package/cli/rtk-wiring.js +1 -6
  15. package/cli/settings.js +3 -8
  16. package/cli/uninstall.js +43 -24
  17. package/cli/update.js +7 -8
  18. package/cli/usage.js +15 -5
  19. package/dashboard/dist/index.html +18 -18
  20. package/extensions/ai-addons-updater/index.js +6 -5
  21. package/extensions/ai-addons-updater/index.ts +7 -6
  22. package/extensions/caveman-session/index.js +14 -29
  23. package/extensions/caveman-session/index.ts +15 -30
  24. package/extensions/combo-toggle/index.js +10 -14
  25. package/extensions/combo-toggle/index.ts +10 -15
  26. package/extensions/lib/utils.js +4 -0
  27. package/extensions/lib/utils.ts +3 -0
  28. package/extensions/rtk-session/index.js +27 -31
  29. package/extensions/rtk-session/index.ts +25 -29
  30. package/extensions/shared/carbon.js +3 -8
  31. package/extensions/shared/carbon.ts +4 -8
  32. package/extensions/shared/host.js +1 -11
  33. package/extensions/shared/host.ts +1 -11
  34. package/extensions/shared/plugin-settings.js +4 -14
  35. package/extensions/shared/plugin-settings.ts +4 -14
  36. package/extensions/shared/pricing.js +4 -6
  37. package/extensions/shared/pricing.ts +4 -6
  38. package/extensions/shared/rtk-gain.js +2 -7
  39. package/extensions/shared/rtk-gain.ts +2 -7
  40. package/extensions/shared/session-state.js +30 -18
  41. package/extensions/shared/session-state.ts +33 -16
  42. package/extensions/shared/usage-ledger.js +138 -17
  43. package/extensions/shared/usage-ledger.ts +143 -19
  44. package/extensions/shared/usage-store.js +175 -23
  45. package/extensions/shared/usage-store.ts +171 -22
  46. package/extensions/tersio-commands/index.js +1 -5
  47. package/extensions/tersio-commands/index.ts +1 -5
  48. package/package.json +6 -7
  49. package/tersio.js +1 -3
@@ -1,11 +1,4 @@
1
- // extensions/shared/carbon.ts — CO2/energy footprints, EcoLogits-style.
2
- //
3
- // Port of the EcoLogits v0.8.2 LLM impact model (genai-impact/ecologits,
4
- // MPL-2.0 — see ECO_NOTICE.md): the single-stream (batch-1) ceiling divided by
5
- // serving_concurrency for continuous-batching amortization. Model params come
6
- // from EcoLogits' registry, else a documented class borrow; unknown ids fall
7
- // to the gpt-4o default. Render everything from here as ~est., never merged
8
- // with measured figures.
1
+ // EcoLogits v0.8.2 port; always render as ~est, never merged with measured.
9
2
 
10
3
  export interface ModelCarbon {
11
4
  activeB: number;
@@ -39,6 +32,9 @@ const GRIDS: Record<string, number> = {
39
32
  // Serving amortization: single-stream ceiling ÷ concurrency ≈ served impact.
40
33
  export const SERVING_CONCURRENCY = 32;
41
34
 
35
+ /** EcoLogits release this port tracks, so a UI can name it instead of guessing. */
36
+ export const ECOLOGITS_VERSION = '0.8.2';
37
+
42
38
  interface RegistryEntry {
43
39
  match?: string;
44
40
  exact?: string;
@@ -1,14 +1,4 @@
1
- // extensions/shared/host.ts — one extension source, two agent hosts.
2
- //
3
- // OMP is a fork of pi, and six surfaces differ: tool schemas (OMP injects
4
- // `pi.zod`, pi takes any object it forwards as JSON Schema), `setLabel`
5
- // (pi's takes an entryId, so an extension name must never reach it),
6
- // `session_branch` (pi reports that switch as `session_tree`), select options
7
- // (pi takes plain strings), the status-bar theme (pi has no ctx.ui.theme), and
8
- // prompt injection (pi appends to its sections rather than replacing them).
9
- //
10
- // The host is read from the API shape, never a name or an env marker: OMP is
11
- // the fork that injects zod, and pi does not, including through the SDK.
1
+ // One source for both hosts; OMP differs in six APIs, detected by shape not name.
12
2
  import { asPromptArray } from './session-state.js';
13
3
  export function isPiHost(pi) {
14
4
  return !pi.zod;
@@ -1,14 +1,4 @@
1
- // extensions/shared/host.ts — one extension source, two agent hosts.
2
- //
3
- // OMP is a fork of pi, and six surfaces differ: tool schemas (OMP injects
4
- // `pi.zod`, pi takes any object it forwards as JSON Schema), `setLabel`
5
- // (pi's takes an entryId, so an extension name must never reach it),
6
- // `session_branch` (pi reports that switch as `session_tree`), select options
7
- // (pi takes plain strings), the status-bar theme (pi has no ctx.ui.theme), and
8
- // prompt injection (pi appends to its sections rather than replacing them).
9
- //
10
- // The host is read from the API shape, never a name or an env marker: OMP is
11
- // the fork that injects zod, and pi does not, including through the SDK.
1
+ // One source for both hosts; OMP differs in six APIs, detected by shape not name.
12
2
 
13
3
  import { asPromptArray } from './session-state.ts';
14
4
  import type { ExtensionApi, ExtensionCtx, PromptInjection, SystemPromptEvent, ToolParams, UiApi } from './types.ts';
@@ -1,25 +1,15 @@
1
- // Settings reader for the Tersio extensions.
2
- //
3
- // One file, under Tersio's own home (~/.tersio/settings.json), serves every
4
- // host: OMP has no reason to keep its own copy and pi never had a store. The
5
- // profile CLI writes the same file, so the extensions, `tersio settings`, and
6
- // both hosts always agree.
7
- //
8
- // Tolerant: any parse failure yields {} so every caller falls back to its own
9
- // default.
1
+ // One settings file for every host; parse failure yields {} with per-caller defaults.
10
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
3
  import os from 'node:os';
12
4
  import path from 'node:path';
13
5
  import { tersioHome } from '../lib/utils.js';
14
6
  export const PLUGIN_NAME = '@krtclcdy/tersio';
15
- // Session-start defaults, shared by every host: one file under Tersio's own
16
- // home, so OMP and pi read the same values. This module owns the path.
7
+ // The one store both hosts read; this module owns its path.
17
8
  export function tersioSettingsFile() {
18
9
  return path.join(tersioHome(), 'settings.json');
19
10
  }
20
- // Older installs stored the same values as OMP plugin settings, and an install
21
- // that predates ~/.tersio still has them there. Read once and keep them, so
22
- // switching the store does not reset a user's defaults.
11
+ // Older installs kept these as OMP plugin settings; still read so switching
12
+ // the store does not reset a user's defaults.
23
13
  function legacyOmpSettings() {
24
14
  const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
25
15
  for (const p of [
@@ -1,12 +1,4 @@
1
- // Settings reader for the Tersio extensions.
2
- //
3
- // One file, under Tersio's own home (~/.tersio/settings.json), serves every
4
- // host: OMP has no reason to keep its own copy and pi never had a store. The
5
- // profile CLI writes the same file, so the extensions, `tersio settings`, and
6
- // both hosts always agree.
7
- //
8
- // Tolerant: any parse failure yields {} so every caller falls back to its own
9
- // default.
1
+ // One settings file for every host; parse failure yields {} with per-caller defaults.
10
2
 
11
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
4
  import os from 'node:os';
@@ -15,15 +7,13 @@ import { tersioHome } from '../lib/utils.ts';
15
7
 
16
8
  export const PLUGIN_NAME = '@krtclcdy/tersio';
17
9
 
18
- // Session-start defaults, shared by every host: one file under Tersio's own
19
- // home, so OMP and pi read the same values. This module owns the path.
10
+ // The one store both hosts read; this module owns its path.
20
11
  export function tersioSettingsFile(): string {
21
12
  return path.join(tersioHome(), 'settings.json');
22
13
  }
23
14
 
24
- // Older installs stored the same values as OMP plugin settings, and an install
25
- // that predates ~/.tersio still has them there. Read once and keep them, so
26
- // switching the store does not reset a user's defaults.
15
+ // Older installs kept these as OMP plugin settings; still read so switching
16
+ // the store does not reset a user's defaults.
27
17
  function legacyOmpSettings(): Record<string, unknown> | null {
28
18
  const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
29
19
  for (const p of [
@@ -1,6 +1,5 @@
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
2
+ // → provider-prefix strip → default, reported with known:false when unpriced.
4
3
  import fs from 'node:fs';
5
4
  import path from 'node:path';
6
5
  import { tersioDataPath } from '../lib/utils.js';
@@ -70,9 +69,8 @@ export function loadLivePrices() {
70
69
  function num(v) {
71
70
  return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
72
71
  }
73
- // One model, two spellings: transcripts say `space-bunny`, the feed keys it
74
- // `openrouter/stealth/space-bunny-alpha` at zero. Aliases fold to the first, so
75
- // a free stealth model never reports the default price.
72
+ // Transcripts and the feed spell one model differently; aliases stop a free
73
+ // model reporting the default price.
76
74
  export const MODEL_ALIASES = [
77
75
  { id: 'space-bunny', feed: 'stealth/space-bunny-alpha' },
78
76
  ];
@@ -1,6 +1,5 @@
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
2
+ // → provider-prefix strip → default, reported with known:false when unpriced.
4
3
  import fs from 'node:fs';
5
4
  import path from 'node:path';
6
5
  import { tersioDataPath } from '../lib/utils.ts';
@@ -88,9 +87,8 @@ function num(v: unknown): number | null {
88
87
  return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
89
88
  }
90
89
 
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.
90
+ // Transcripts and the feed spell one model differently; aliases stop a free
91
+ // model reporting the default price.
94
92
  export const MODEL_ALIASES: ReadonlyArray<{ id: string; feed: string }> = [
95
93
  { id: 'space-bunny', feed: 'stealth/space-bunny-alpha' },
96
94
  ];
@@ -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 BENCHMARK.md.
5
2
  import { execFileSync } from 'node:child_process';
6
3
  import fs from 'node:fs';
7
4
  import os from 'node:os';
@@ -10,9 +7,7 @@ const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byComman
10
7
  // The table shows one clipped cell, so bound the command before it crosses the
11
8
  // sqlite3 CLI and lands in every /data.json poll.
12
9
  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.
10
+ // A multi-line stored command would derail every row after it; flatten on read.
16
11
  const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
17
12
  export function rtkDbPath() {
18
13
  const override = process.env.TERSIO_RTK_DB;
@@ -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 BENCHMARK.md.
5
2
  import { execFileSync } from 'node:child_process';
6
3
  import fs from 'node:fs';
7
4
  import os from 'node:os';
@@ -30,9 +27,7 @@ const EMPTY: RtkGain = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0,
30
27
  // sqlite3 CLI and lands in every /data.json poll.
31
28
  const MAX_CMD_CHARS = 200;
32
29
 
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.
30
+ // A multi-line stored command would derail every row after it; flatten on read.
36
31
  const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
37
32
 
38
33
  export function rtkDbPath(): string {
@@ -1,4 +1,6 @@
1
+ import { readPluginSettings } from './plugin-settings.js';
1
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.
2
4
  export const OMP_SUBAGENT_MARKER = 'You are operating on a piece of work assigned to you by the main agent.';
3
5
  export const COMBO_LEVELS = Object.freeze({
4
6
  off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
@@ -51,12 +53,12 @@ function bridge() {
51
53
  if (existing?.state)
52
54
  return existing;
53
55
  const initial = normalizedState((existing || COMBO_LEVELS.off));
54
- return (globalThis[BRIDGE_KEY] = { state: initial, listeners: new Set() });
56
+ return (globalThis[BRIDGE_KEY] = { state: initial, listeners: new Map() });
55
57
  }
56
58
  function publish(state) {
57
59
  const shared = bridge();
58
60
  shared.state = state;
59
- for (const listener of shared.listeners)
61
+ for (const listener of shared.listeners.values())
60
62
  listener(state);
61
63
  return state;
62
64
  }
@@ -70,11 +72,19 @@ export function systemPromptIncludes(systemPrompt, marker) {
70
72
  return asPromptArray(systemPrompt).some((prompt) => typeof prompt === 'string' && prompt.includes(marker));
71
73
  }
72
74
  export function isOmpSubagentPrompt(systemPrompt) {
73
- return systemPromptIncludes(systemPrompt, OMP_SUBAGENT_MARKER);
74
- }
75
+ const raw = readPluginSettings().subagentMarkers;
76
+ const configured = Array.isArray(raw) ? raw.filter((m) => typeof m === 'string' && m.trim() !== '') : [];
77
+ // A malformed setting falls back rather than matching every turn.
78
+ const markers = configured.length ? configured : [OMP_SUBAGENT_MARKER];
79
+ return markers.some((marker) => systemPromptIncludes(systemPrompt, marker));
80
+ }
81
+ // ultra maps to max, lite/full to nearest, rather than erroring.
82
+ const COMBO_ALIASES = { ultra: 'max', lite: 'medium', full: 'balanced' };
75
83
  export function normalizeComboLevel(value) {
76
84
  const level = String(value || '').trim().toLowerCase();
77
- return isPresetLevel(level) ? level : null;
85
+ if (isPresetLevel(level))
86
+ return level;
87
+ return COMBO_ALIASES[level] ?? null;
78
88
  }
79
89
  // OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
80
90
  // the same string renders plain there.
@@ -87,13 +97,19 @@ export function themeStatus(ui, indicator, label, highlight = false) {
87
97
  export function paintStatusBar(ui, key, emoji, label, isActive) {
88
98
  ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
89
99
  }
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.
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.
95
111
  export function paintableCtx(remembered, next) {
96
- return next?.ui?.setStatus ? next : remembered;
112
+ return statusUi(next) ? next : remembered;
97
113
  }
98
114
  // Last-wins scan for a custom session entry; skips entries whose value fails
99
115
  // to parse so a corrupt write never shadows an older valid one.
@@ -162,13 +178,9 @@ export function reconcileSharedComboEntries(entries) {
162
178
  export function activeModesSummary(state) {
163
179
  return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
164
180
  }
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();
181
+ // Keyed registration drops stale closures; hosts offer no teardown hook.
182
+ export function setSharedComboListener(key, listener) {
183
+ bridge().listeners.set(key, listener);
172
184
  }
173
185
  export function resetSharedComboState() {
174
186
  return setSharedComboLevel('off');
@@ -1,7 +1,9 @@
1
+ import { readPluginSettings } from './plugin-settings.ts';
1
2
  import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry, UiApi } from './types.ts';
2
3
 
3
4
  const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
4
5
 
6
+ // A verbatim sentence from OMP's subagent prompt, so it can drift; settings.json overrides it.
5
7
  export const OMP_SUBAGENT_MARKER = 'You are operating on a piece of work assigned to you by the main agent.';
6
8
 
7
9
  export const COMBO_LEVELS: Record<string, Readonly<ComboState>> = Object.freeze({
@@ -28,7 +30,7 @@ const MODE_ENTRY_TYPES: Record<string, ModeName> = {
28
30
 
29
31
  interface Bridge {
30
32
  state: Readonly<ComboState>;
31
- listeners: Set<(state: Readonly<ComboState>) => void>;
33
+ listeners: Map<string, (state: Readonly<ComboState>) => void>;
32
34
  }
33
35
 
34
36
  export function normalizeMode(name: ModeName, value: unknown): string | null {
@@ -67,13 +69,13 @@ function bridge(): Bridge {
67
69
  const existing = (globalThis as Record<symbol, unknown>)[BRIDGE_KEY] as Bridge | undefined;
68
70
  if (existing?.state) return existing;
69
71
  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>() });
72
+ return ((globalThis as Record<symbol, unknown>)[BRIDGE_KEY] = { state: initial, listeners: new Map() });
71
73
  }
72
74
 
73
75
  function publish(state: Readonly<ComboState>): Readonly<ComboState> {
74
76
  const shared = bridge();
75
77
  shared.state = state;
76
- for (const listener of shared.listeners) listener(state);
78
+ for (const listener of shared.listeners.values()) listener(state);
77
79
  return state;
78
80
  }
79
81
 
@@ -90,12 +92,20 @@ export function systemPromptIncludes(systemPrompt: string | string[], marker: st
90
92
  }
91
93
 
92
94
  export function isOmpSubagentPrompt(systemPrompt: string | string[]): boolean {
93
- return systemPromptIncludes(systemPrompt, OMP_SUBAGENT_MARKER);
95
+ const raw = readPluginSettings().subagentMarkers;
96
+ const configured = Array.isArray(raw) ? raw.filter((m): m is string => typeof m === 'string' && m.trim() !== '') : [];
97
+ // A malformed setting falls back rather than matching every turn.
98
+ const markers = configured.length ? configured : [OMP_SUBAGENT_MARKER];
99
+ return markers.some((marker) => systemPromptIncludes(systemPrompt, marker));
94
100
  }
95
101
 
102
+ // ultra maps to max, lite/full to nearest, rather than erroring.
103
+ const COMBO_ALIASES: Record<string, ComboLevel> = { ultra: 'max', lite: 'medium', full: 'balanced' };
104
+
96
105
  export function normalizeComboLevel(value: unknown): ComboLevel | null {
97
106
  const level = String(value || '').trim().toLowerCase();
98
- return isPresetLevel(level) ? level : null;
107
+ if (isPresetLevel(level)) return level;
108
+ return COMBO_ALIASES[level] ?? null;
99
109
  }
100
110
 
101
111
  // OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
@@ -110,13 +120,22 @@ export function paintStatusBar(ui: UiApi | undefined, key: string, emoji: string
110
120
  ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
111
121
  }
112
122
 
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.
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.
118
137
  export function paintableCtx(remembered: ExtensionCtx | undefined, next: ExtensionCtx | undefined): ExtensionCtx | undefined {
119
- return next?.ui?.setStatus ? next : remembered;
138
+ return statusUi(next) ? next : remembered;
120
139
  }
121
140
 
122
141
  // Last-wins scan for a custom session entry; skips entries whose value fails
@@ -186,11 +205,9 @@ export function activeModesSummary(state: { caveman: string; rtk: string; ponyta
186
205
  return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
187
206
  }
188
207
 
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();
208
+ // Keyed registration drops stale closures; hosts offer no teardown hook.
209
+ export function setSharedComboListener(key: string, listener: (state: Readonly<ComboState>) => void): void {
210
+ bridge().listeners.set(key, listener);
194
211
  }
195
212
 
196
213
  export function resetSharedComboState(): Readonly<ComboState> {