@genee/omp-opsx-addon 0.9.0 → 0.11.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.
@@ -38,6 +38,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statS
38
38
  import { dirname } from 'path';
39
39
  import { YAML } from 'bun';
40
40
  import { clampLimit } from './concurrency-tuner.js';
41
+ import { overrideProviderMaxInFlight } from './host-settings-adapter.js';
41
42
  import { canonicalizeProvider } from './usage-resolver.js';
42
43
 
43
44
  /** Sink for degradation notices; defaults to a no-op. */
@@ -218,8 +219,8 @@ export function readManagedLimits(
218
219
  /**
219
220
  * Merge one provider's new limit into the **complete** per-provider map.
220
221
  * `providers.maxInFlightRequests` is a whole-key override on the host side, so
221
- * every caller MUST pass the full map (this is the map that goes to
222
- * `settings.override` and to {@link writeManagedLimit}).
222
+ * every caller MUST pass the full map (this is the map that goes to the
223
+ * hot-apply override — {@link applyLimit} — and to {@link writeManagedLimit}).
223
224
  */
224
225
  export function buildMergedMap(
225
226
  current: Record<string, number>,
@@ -582,10 +583,15 @@ export function writeManagedLimit(args: WriteManagedLimitArgs): WriteManagedLimi
582
583
  }
583
584
 
584
585
  /**
585
- * Injected host settings port (`pi.pi.settings`), narrowed to the call we make.
586
+ * Injected host settings port (`pi.pi.settings`).
587
+ *
588
+ * Only the identity of the host singleton matters here: the hot-apply goes
589
+ * through `lib/host-settings-adapter.ts`, which resolves the API generation
590
+ * (registry handle on omp >= 18.3, path-based `override` before that). Hence
591
+ * `override` is optional — a host that dropped it is still a usable port.
586
592
  */
587
593
  export interface SettingsPort {
588
- override(path: string, value: unknown): void;
594
+ override?(path: string, value: unknown): void;
589
595
  }
590
596
 
591
597
  export interface ApplyLimitArgs {
@@ -604,8 +610,10 @@ export interface ApplyLimitResult {
604
610
  }
605
611
 
606
612
  /**
607
- * Hot-apply the shared map in this process via
608
- * `settings.override('providers.maxInFlightRequests', merged)`.
613
+ * Hot-apply the shared map in this process through the host settings runtime
614
+ * override (`providers.maxInFlightRequests`: registry handle on omp >= 18.3,
615
+ * path-based `settings.override` before that — both via
616
+ * `lib/host-settings-adapter.ts`).
609
617
  *
610
618
  * `providers.maxInFlightRequests` is a **whole-key override**, so the second
611
619
  * argument MUST be the complete per-provider map — passing only the target
@@ -614,13 +622,14 @@ export interface ApplyLimitResult {
614
622
  * (the caller keeps the previous value), while a partial map would silently
615
623
  * make the omitted providers unlimited. When the API throws (host schema moved
616
624
  * on), the persisted config is untouched — the host config watcher picks the
617
- * value up — and the failure is warned, never fatal.
625
+ * value up — and the failure is warned, never fatal. Async because the adapter
626
+ * loads host handles by dynamic import.
618
627
  */
619
- export function applyLimit(args: ApplyLimitArgs): ApplyLimitResult {
628
+ export async function applyLimit(args: ApplyLimitArgs): Promise<ApplyLimitResult> {
620
629
  const warn = args.warn ?? NO_WARN;
621
630
  const settings = args.settings;
622
- if (!settings || typeof settings.override !== 'function') {
623
- warn('[omp-opsx-addon] concurrency: settings.override unavailable; skipping hot apply (config file value still applies)');
631
+ if (!settings) {
632
+ warn('[omp-opsx-addon] concurrency: settings unavailable; skipping hot apply (config file value still applies)');
624
633
  return { applied: false, reason: 'settings-unavailable' };
625
634
  }
626
635
  const provider = canonicalizeProvider(args.provider.trim());
@@ -631,15 +640,18 @@ export function applyLimit(args: ApplyLimitArgs): ApplyLimitResult {
631
640
  const payload = buildMergedMap(args.merged ?? {}, provider, args.value);
632
641
  for (const [id, value] of Object.entries(payload)) {
633
642
  if (!id || typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
634
- warn(`[omp-opsx-addon] concurrency: refusing settings.override — invalid map entry ${JSON.stringify(id)}: ${JSON.stringify(value)}`);
643
+ warn(`[omp-opsx-addon] concurrency: refusing the hot-apply override — invalid map entry ${JSON.stringify(id)}: ${JSON.stringify(value)}`);
635
644
  return { applied: false, reason: 'invalid-merged' };
636
645
  }
637
646
  }
638
647
  try {
639
- settings.override('providers.maxInFlightRequests', payload);
648
+ if (!(await overrideProviderMaxInFlight(settings, payload))) {
649
+ warn('[omp-opsx-addon] concurrency: no settings runtime-override API available (host handle / settings.override); skipping hot apply (config file value still applies)');
650
+ return { applied: false, reason: 'settings-unavailable' };
651
+ }
640
652
  return { applied: true };
641
653
  } catch (err) {
642
- warn(`[omp-opsx-addon] concurrency: settings.override failed (${message(err)}); the host config watcher will apply the persisted value`);
654
+ warn(`[omp-opsx-addon] concurrency: settings hot-apply failed (${message(err)}); the host config watcher will apply the persisted value`);
643
655
  return { applied: false, reason: 'override-failed' };
644
656
  }
645
657
  }
@@ -2,9 +2,11 @@ import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import type { ExtensionAPI } from '@oh-my-pi/pi-coding-agent';
5
+ import { overrideEditModelVariants } from './host-settings-adapter.js';
5
6
 
6
7
  /**
7
- * Runtime edit-variant pin fallback for omp >= 18.0.11.
8
+ * Runtime edit-variant pin, with a persisted fallback for hosts that no longer
9
+ * expose `edit.modelVariants` to the runtime settings API.
8
10
  *
9
11
  * The host hard-maps deepseek-v4-flash to the sloppy §/» marker grammar (it
10
12
  * misreads hashline ranges), but the model repeatedly breaks sloppy control
@@ -12,11 +14,14 @@ import type { ExtensionAPI } from '@oh-my-pi/pi-coding-agent';
12
14
  * plain old_string/new_string "replace" variant instead.
13
15
  *
14
16
  * Up to 17.x this extension did that via
15
- * `settings.override("edit.modelVariants", …)`. omp 18.0.11 dropped
16
- * `edit.modelVariants` from the settings schema, so `override()` throws
17
- * inside `get()`: the path-segment table has no entry for it and `getByPath`
18
- * iterates `undefined`. The host still honors the key when it arrives through
19
- * the global config file — `#getEditVariantEntries` reads the merged view
17
+ * `settings.override("edit.modelVariants", …)`; omp 18.3 replaced that
18
+ * path-based API with registry handles (`edit.modelVariants` handle →
19
+ * `handle.override(settings, …)`, see `lib/host-settings-adapter.ts`), so the
20
+ * runtime path lives on. On 18.0.11–18.x hosts whose path-based schema
21
+ * dropped `edit.modelVariants`, `override()` throws inside `get()`: the
22
+ * path-segment table has no entry for it and `getByPath` iterates
23
+ * `undefined`. Those hosts still honor the key when it arrives through the
24
+ * global config file — `#getEditVariantEntries` reads the merged view
20
25
  * directly and `resolveEditMode` prefers the per-model variant over
21
26
  * `edit.mode` — so persisting once is functionally equivalent to the runtime
22
27
  * pin.
@@ -102,38 +107,41 @@ export function persistEditVariantPin(
102
107
  }
103
108
 
104
109
  /**
105
- * Pin the edit variant for the target models.
110
+ * Pin the edit variant for the target models. Never rejects: every failure is
111
+ * warned through the host logger (the caller fires this and forgets it).
106
112
  *
107
- * Preferred path: in-memory `settings.override` (omp < 18.0.11). When that
108
- * throws (18.0.11 removed the schema path), persist into the global
109
- * config.yml once — idempotent, effective from the next session.
113
+ * Preferred path: the in-memory runtime override — the `edit.modelVariants`
114
+ * registry handle on omp >= 18.3, the path-based `settings.override` before
115
+ * that (both resolved inside `lib/host-settings-adapter.ts`). When this host
116
+ * offers no runtime path that reaches the setting (18.0.11+ dropped it from
117
+ * the path-based schema), persist into the global config.yml once —
118
+ * idempotent, effective from the next session.
110
119
  */
111
- export function applyEditVariantPin(pi: ExtensionAPI, pin: Record<string, string>): void {
120
+ export async function applyEditVariantPin(pi: ExtensionAPI, pin: Record<string, string>): Promise<void> {
112
121
  // `pi.pi` is the whole SDK module namespace; its `settings` field has no
113
- // declared runtime instance type on the extension-facing surface, so
114
- // narrow to the two methods we call (the same shape the host's Settings
115
- // class exposes publicly).
116
- const settings = pi.pi?.settings as unknown as
117
- | { override(path: string, value: unknown): void; getAgentDir?: () => string }
118
- | undefined;
119
- if (!settings) {
122
+ // declared runtime instance type on the extension-facing surface, so the
123
+ // `in`/`typeof` checks below are the whole contract.
124
+ const settings: unknown = pi.pi?.settings;
125
+ if (!settings || typeof settings !== 'object') {
120
126
  pi.logger?.warn('[omp-opsx-addon] pi.pi.settings unavailable; edit variant pin skipped');
121
127
  return;
122
128
  }
123
129
 
124
130
  try {
125
- settings.override('edit.modelVariants', pin);
126
- return;
131
+ if (await overrideEditModelVariants(settings, pin)) return;
127
132
  } catch {
128
- /* settings schema no longer knows this path — persist instead */
133
+ /* host schema no longer knows the path — persist instead */
129
134
  }
130
135
 
131
136
  try {
132
- let agentDir: string;
133
- try {
134
- agentDir = settings.getAgentDir?.() ?? '';
135
- } catch {
136
- agentDir = '';
137
+ let agentDir = '';
138
+ if ('getAgentDir' in settings && typeof settings.getAgentDir === 'function') {
139
+ try {
140
+ const dir = settings.getAgentDir();
141
+ if (typeof dir === 'string') agentDir = dir;
142
+ } catch {
143
+ agentDir = '';
144
+ }
137
145
  }
138
146
  if (!agentDir) agentDir = path.join(os.homedir(), '.omp', 'agent');
139
147
 
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Host Settings API compatibility adapter (change: omp-18.3-settings-api-compat).
3
+ *
4
+ * omp 18.3.2 removed the path-based Settings API (`settings.override(path, value)`
5
+ * / `settings.clearOverride(path)`) in favour of registry handles: a handle
6
+ * carries the setting's identity — `handle.override(settings, value)` writes the
7
+ * runtime overlay and clearing goes through
8
+ * `settings.clearOverrideValue(handle)` (equivalent to
9
+ * `handle.clearOverride(settings)`). Every path-based call this plugin made
10
+ * (`clearOverride('modelRoles')`, `override('task.agentModelOverrides', …)`,
11
+ * `override('providers.maxInFlightRequests', …)`,
12
+ * `override('edit.modelVariants', …)`) now throws
13
+ * (`settings.clearOverride is not a function`), which took down the whole
14
+ * `/pick-model` apply path and silently disabled the concurrency hot-apply.
15
+ *
16
+ * Handles live on deep host subpaths and are loaded LAZILY via dynamic
17
+ * `import()` — never a static import:
18
+ * - this package's own dev dependency is older (18.1.2) and ships no such
19
+ * subpaths, so a static import would break typecheck/build;
20
+ * - the host's legacy-pi-compat redirect rewrites these specifiers to the
21
+ * host's own module instances, which is the singleton the session reads
22
+ * (a separately `require()`d copy would be a no-op target for writes).
23
+ *
24
+ * The specifiers MUST BE STRING LITERALS — `import('@oh-my-pi/pi-coding-agent/
25
+ * config/model-settings')` — never a computed/concatenated string. The host
26
+ * rewrites extension sources textually (it collects string-literal import
27
+ * references and swaps them for absolute host paths), so a runtime-built
28
+ * specifier is not seen by the rewriter and resolves natively against this
29
+ * package's own 18.1.2 copy, i.e. the handle silently stays `undefined` on
30
+ * exactly the hosts this adapter exists for. `types/host-settings-modules.d.ts`
31
+ * supplies the build-time declarations those literal specifiers need.
32
+ * Each specifier is attempted independently of the others and the result (hit
33
+ * or miss) is cached for the process lifetime, so a host without these
34
+ * subpaths pays each failed import exactly once.
35
+ *
36
+ * Every helper falls back to the path-based API when the handle is missing, so
37
+ * hosts <= 18.1 keep their previous behaviour byte-for-byte.
38
+ */
39
+
40
+ /** Minimal host registry-handle surface (omp >= 18.3 settings API). */
41
+ export interface HostSettingHandle {
42
+ /** Write this setting's runtime override (never persisted). */
43
+ override(settings: unknown, value: unknown): void;
44
+ /**
45
+ * Clear this setting's runtime override. Present on handles, but the
46
+ * documented clear path is `settings.clearOverrideValue(handle)`, so the
47
+ * adapter tries that first (see {@link clearModelRolesRuntimeOverride}).
48
+ */
49
+ clearOverride?(settings: unknown): void;
50
+ }
51
+
52
+ /** Resolved handle per setting this plugin touches; a miss stays `undefined`. */
53
+ export interface HostSettingsHandles {
54
+ /** `cfgModelRoles` — `config/model-settings`. */
55
+ modelRoles?: HostSettingHandle;
56
+ /** `cfgTaskAgentModelOverrides` — `task/settings`. */
57
+ taskAgentModelOverrides?: HostSettingHandle;
58
+ /** `cfgProvidersMaxInFlightRequests` — `session/settings`. */
59
+ providersMaxInFlight?: HostSettingHandle;
60
+ /** `cfgEditModelVariants` — `edit/settings`. */
61
+ editModelVariants?: HostSettingHandle;
62
+ }
63
+
64
+ interface HandleSpec {
65
+ key: keyof HostSettingsHandles;
66
+ exportName: string;
67
+ /**
68
+ * Literal-specifier loader (see the header: a computed specifier would not
69
+ * be rewritten by the host loader). Dynamic, so an older host's missing
70
+ * subpath rejects instead of breaking module evaluation.
71
+ */
72
+ load: () => Promise<unknown>;
73
+ }
74
+
75
+ const HANDLE_SPECS: readonly HandleSpec[] = [
76
+ {
77
+ key: 'modelRoles',
78
+ exportName: 'cfgModelRoles',
79
+ load: () => import('@oh-my-pi/pi-coding-agent/config/model-settings'),
80
+ },
81
+ {
82
+ key: 'taskAgentModelOverrides',
83
+ exportName: 'cfgTaskAgentModelOverrides',
84
+ load: () => import('@oh-my-pi/pi-coding-agent/task/settings'),
85
+ },
86
+ {
87
+ key: 'providersMaxInFlight',
88
+ exportName: 'cfgProvidersMaxInFlightRequests',
89
+ load: () => import('@oh-my-pi/pi-coding-agent/session/settings'),
90
+ },
91
+ {
92
+ key: 'editModelVariants',
93
+ exportName: 'cfgEditModelVariants',
94
+ load: () => import('@oh-my-pi/pi-coding-agent/edit/settings'),
95
+ },
96
+ ];
97
+
98
+ async function loadHandle(spec: HandleSpec): Promise<HostSettingHandle | undefined> {
99
+ try {
100
+ const mod = (await spec.load()) as Record<string, unknown> | null | undefined;
101
+ const candidate = mod?.[spec.exportName] as Partial<HostSettingHandle> | undefined;
102
+ if (candidate && typeof candidate.override === 'function') return candidate as HostSettingHandle;
103
+ } catch {
104
+ /* subpath absent on this host (<= 18.1) — the path-based fallback applies */
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ let handlesPromise: Promise<HostSettingsHandles> | null = null;
110
+ let handlesForTest: HostSettingsHandles | null | undefined;
111
+
112
+ /** Test-only seam: inject fake handles, bypassing the dynamic imports. */
113
+ export function _setHandlesForTest(handles: HostSettingsHandles | null | undefined): void {
114
+ handlesForTest = handles;
115
+ }
116
+
117
+ /** Test-only seam: drop injected handles and any cached load. */
118
+ export function _resetForTest(): void {
119
+ handlesForTest = undefined;
120
+ handlesPromise = null;
121
+ }
122
+
123
+ /**
124
+ * Handles of the running host, loaded once per process. A host that lacks the
125
+ * subpaths yields `{}` — cached, so the failed imports run exactly once.
126
+ */
127
+ export function hostSettingsHandles(): Promise<HostSettingsHandles> {
128
+ if (handlesForTest !== undefined) return Promise.resolve(handlesForTest ?? {});
129
+ handlesPromise ??= (async () => {
130
+ const out: HostSettingsHandles = {};
131
+ await Promise.all(
132
+ HANDLE_SPECS.map(async (spec) => {
133
+ const handle = await loadHandle(spec);
134
+ if (handle) out[spec.key] = handle;
135
+ }),
136
+ );
137
+ return out;
138
+ })();
139
+ return handlesPromise;
140
+ }
141
+
142
+ /** Path-based Settings API on hosts <= 18.1. */
143
+ interface PathSettings {
144
+ override?(path: string, value: unknown): void;
145
+ clearOverride?(path: string): void;
146
+ }
147
+
148
+ /** Handle-clearing Settings API on hosts >= 18.3. */
149
+ interface HandleClearingSettings {
150
+ clearOverrideValue?(handle: unknown): void;
151
+ }
152
+
153
+ /** Shared write path: registry handle first, path-based `override` second. */
154
+ async function writeOverride(
155
+ handleKey: keyof HostSettingsHandles,
156
+ legacyPath: string,
157
+ settings: unknown,
158
+ value: unknown,
159
+ ): Promise<boolean> {
160
+ const handle = (await hostSettingsHandles())[handleKey];
161
+ if (handle) {
162
+ handle.override(settings, value);
163
+ return true;
164
+ }
165
+ const legacy = typeof settings === 'object' && settings !== null ? (settings as PathSettings) : null;
166
+ if (legacy && typeof legacy.override === 'function') {
167
+ legacy.override(legacyPath, value);
168
+ return true;
169
+ }
170
+ return false;
171
+ }
172
+
173
+ /**
174
+ * Clear the session-scoped `modelRoles` overlay.
175
+ *
176
+ * omp >= 18.3: `settings.clearOverrideValue(cfgModelRoles)` (falling back to
177
+ * `handle.clearOverride(settings)`). <= 18.1: `settings.clearOverride('modelRoles')`.
178
+ *
179
+ * Returns `false` when neither API is present — callers warn and keep writing
180
+ * (an absent *clear* API does not mean settings are unusable).
181
+ */
182
+ export async function clearModelRolesRuntimeOverride(settings: unknown): Promise<boolean> {
183
+ const handle = (await hostSettingsHandles()).modelRoles;
184
+ if (handle) {
185
+ const clearing = typeof settings === 'object' && settings !== null
186
+ ? (settings as HandleClearingSettings)
187
+ : null;
188
+ if (clearing && typeof clearing.clearOverrideValue === 'function') {
189
+ clearing.clearOverrideValue(handle);
190
+ return true;
191
+ }
192
+ if (typeof handle.clearOverride === 'function') {
193
+ handle.clearOverride(settings);
194
+ return true;
195
+ }
196
+ }
197
+ const legacy = typeof settings === 'object' && settings !== null ? (settings as PathSettings) : null;
198
+ if (legacy && typeof legacy.clearOverride === 'function') {
199
+ legacy.clearOverride('modelRoles');
200
+ return true;
201
+ }
202
+ return false;
203
+ }
204
+
205
+ /** Write the `task.agentModelOverrides` neutralization (handle API, else path API). */
206
+ export function applyAgentModelOverrides(
207
+ settings: unknown,
208
+ value: Record<string, string>,
209
+ ): Promise<boolean> {
210
+ return writeOverride('taskAgentModelOverrides', 'task.agentModelOverrides', settings, value);
211
+ }
212
+
213
+ /** Hot-apply the complete `providers.maxInFlightRequests` map (handle API, else path API). */
214
+ export function overrideProviderMaxInFlight(
215
+ settings: unknown,
216
+ value: Record<string, number>,
217
+ ): Promise<boolean> {
218
+ return writeOverride('providersMaxInFlight', 'providers.maxInFlightRequests', settings, value);
219
+ }
220
+
221
+ /** Pin `edit.modelVariants` at runtime (handle API, else path API). */
222
+ export function overrideEditModelVariants(
223
+ settings: unknown,
224
+ value: Record<string, string>,
225
+ ): Promise<boolean> {
226
+ return writeOverride('editModelVariants', 'edit.modelVariants', settings, value);
227
+ }
@@ -17,14 +17,17 @@
17
17
  * commit/tiny via enumeration fallbacks terminating at smol, task via
18
18
  * primary-session inheritance, plan as a graceful no-op, advisor via the
19
19
  * static slow chain, custom roles via the config layer. The landing sequence
20
- * is unchanged: clearOverride('modelRoles') → overrideModelRoles(payload) →
21
- * empty-string neutralization of persisted per-agent pins.
20
+ * is unchanged: clear the modelRoles session overlay → overrideModelRoles(payload)
21
+ * → empty-string neutralization of persisted per-agent pins (the clear and the
22
+ * neutralization go through `lib/host-settings-adapter.ts`: registry handles on
23
+ * omp >= 18.3, path-based settings calls before that).
22
24
  */
23
25
  import { type ModelRole } from '@oh-my-pi/pi-coding-agent/config/model-roles';
24
26
  import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
25
27
  import type { SelectionContext } from '../index.js';
26
28
  import type { TierName } from './model-tiers.js';
27
- import { OPSX_ROLES } from './unified-config.js';
29
+ import { applyAgentModelOverrides, clearModelRolesRuntimeOverride } from './host-settings-adapter.js';
30
+ import { OPSX_AGENTS } from './unified-config.js';
28
31
 
29
32
  /** Per-agent names written into `task.agentModelOverrides` neutralization. */
30
33
  const AGENT_MODEL_KEYS: Record<string, string> = {
@@ -138,8 +141,8 @@ export function resolveOmpRoleTier(
138
141
  * Build the minimal write-set overlay plan (design D5): for each role in
139
142
  * `OMP_WRITE_SET_ROLES`, resolve its tier (or `skip`), apply capability
140
143
  * gating, then assign the single picked model for the tier. Roles without a
141
- * pick are recorded with a reason and omitted from the payload — after
142
- * `clearOverride` they fall back to the user's config layer (or stay
144
+ * pick are recorded with a reason and omitted from the payload — after the
145
+ * pre-clear they fall back to the user's config layer (or stay
143
146
  * unconfigured). Roles outside the write set are never iterated.
144
147
  */
145
148
  export function buildRoleOverridePlan(input: BuildRoleOverridePlanInput): RoleOverridePlan {
@@ -222,15 +225,30 @@ export function dispatchModelHint(role: string, selection: SelectionContext): st
222
225
  */
223
226
  export function agentOverrideNeutralization(): Record<string, string> {
224
227
  const neutral: Record<string, string> = {};
225
- for (const role of OPSX_ROLES) neutral[AGENT_MODEL_KEYS[role]] = '';
228
+ for (const role of OPSX_AGENTS) neutral[AGENT_MODEL_KEYS[role]] = '';
226
229
  return neutral;
227
230
  }
228
231
 
229
- /** Minimal Settings surface this module needs (host-process `pi.pi.settings`). */
232
+ /**
233
+ * Minimal Settings surface this module needs (host-process `pi.pi.settings`).
234
+ *
235
+ * Deliberately a union of both host generations: `overrideModelRoles` (the
236
+ * session overlay write) exists in every version, while clearing and the
237
+ * path-based writes moved from `clearOverride(path)` / `override(path, value)`
238
+ * (<= 18.1) to registry handles (`clearOverrideValue(handle)`, see
239
+ * `lib/host-settings-adapter.ts`) in 18.3. Optional members keep every
240
+ * generation assignable; `applyRoleModelOverrides` never assumes a method
241
+ * exists.
242
+ */
230
243
  export interface RoleSettings {
231
- clearOverride(key: string): void;
244
+ /** Session overlay write (limited to `{smol, default, slow, vision}` by the plan). */
232
245
  overrideModelRoles(roles: Record<string, string>): void;
233
- override(key: string, value: unknown): void;
246
+ /** omp <= 18.1: clear the `modelRoles` runtime overlay by path. */
247
+ clearOverride?(key: string): void;
248
+ /** omp >= 18.3: clear a runtime overlay through its registry handle. */
249
+ clearOverrideValue?(handle: unknown): void;
250
+ /** omp <= 18.1: path-based runtime override. */
251
+ override?(key: string, value: unknown): void;
234
252
  }
235
253
 
236
254
  /**
@@ -243,25 +261,36 @@ export interface RoleSettings {
243
261
  * picked model are omitted and fall back to the config layer after the
244
262
  * clear) → neutralize persisted per-agent pins with empty strings. Never writes
245
263
  * disk; the runtime overlay is released with the session.
264
+ *
265
+ * Async because clearing/writing goes through `lib/host-settings-adapter.ts`,
266
+ * whose host handles are loaded by dynamic import; every call site awaits.
267
+ * Clearing and the neutralization write degrade to a warn when this host
268
+ * offers neither API generation — the missing *clear* is not a reason to skip
269
+ * the overlay write itself (the plan payload still applies).
246
270
  */
247
- export function applyRoleModelOverrides(
271
+ export async function applyRoleModelOverrides(
248
272
  settings: RoleSettings | null | undefined,
249
273
  sel: SelectionContext,
250
274
  warn: (msg: string) => void = () => {},
251
- ): void {
275
+ ): Promise<void> {
252
276
  if (!settings) {
253
277
  warn('[omp-opsx-addon] pi.pi.settings unavailable; model role overrides skipped');
254
278
  return;
255
279
  }
256
- settings.clearOverride('modelRoles');
280
+ if (!(await clearModelRolesRuntimeOverride(settings))) {
281
+ warn('[omp-opsx-addon] no settings API to clear the modelRoles session overlay (host handles and path-based clearOverride both unavailable); writing the overlay without a pre-clear — stale roles from a previous selection may linger');
282
+ }
257
283
  settings.overrideModelRoles(sel.roleOverrides);
258
- settings.override('task.agentModelOverrides', agentOverrideNeutralization());
284
+ const neutralized = await applyAgentModelOverrides(settings, agentOverrideNeutralization());
285
+ if (!neutralized) {
286
+ warn('[omp-opsx-addon] no settings API for task.agentModelOverrides (host handles and path-based override both unavailable)');
287
+ }
259
288
  const applied = Object.keys(sel.roleOverrides);
260
289
  const skippedText = sel.skippedRoles.length > 0
261
290
  ? `; skipped: ${sel.skippedRoles.map((s) => `${s.role}(${s.reason})`).join(', ')}`
262
291
  : '';
263
292
  const skipWarns = sel.skippedRoles.filter((s) => s.reason !== 'skip');
264
- warn(`[omp-opsx-addon] modelRoles applied (${applied.length} roles: ${applied.join(', ') || 'none'})${skippedText}; per-agent pins neutralized`);
293
+ warn(`[omp-opsx-addon] modelRoles applied (${applied.length} roles: ${applied.join(', ') || 'none'})${skippedText}; per-agent pins ${neutralized ? 'neutralized' : 'NOT neutralized'}`);
265
294
  for (const s of skipWarns) {
266
295
  if (s.reason === 'capability') {
267
296
  warn(`[omp-opsx-addon] model role "${s.role}": no capable model in selection; skipped (role keeps configured/default value; change selector/allowlist or set model_role_tiers.${s.role}: skip)`);