@genee/omp-opsx-addon 0.3.0 → 0.5.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.
@@ -0,0 +1,467 @@
1
+ // ── opsx pipe push ───────────────────────────────────────────────────
2
+ // The push/injection layer ON TOP OF pipe-core. pipe-core owns the on-disk
3
+ // protocol (`.omp/opsx/pipe/` layout, message structure, the four LLM tools,
4
+ // the single shared `consume()` primitive). This module is a READ-ONLY
5
+ // CONSUMER of that protocol:
6
+ //
7
+ // fs.watch(mbox/) doorbell ──┐ (events may coalesce/drop — the watch is
8
+ // 30s fallback tick ─────────┘ only a doorbell; readdir is the truth)
9
+ // → readdir + in-memory seen msg-id diff (idempotent)
10
+ // → read + validate candidate message files
11
+ // → pi.sendMessage(custom payload, { deliverAs, triggerTurn })
12
+ // → on success: core's consume() claims the file (atomic
13
+ // rename→read→delete + msg-id dedup). On inject failure the file
14
+ // is left untouched for the next scan (at-least-once).
15
+ //
16
+ // No self-built queue: steer/followUp/nextTurn ordering and idle wake-ups
17
+ // are entirely the harness's native deliverAs/triggerTurn semantics.
18
+ import { promises as fs, watch } from 'fs';
19
+ import { join } from 'path';
20
+ import type { ExtensionAPI, ExtensionContext } from '@oh-my-pi/pi-coding-agent';
21
+ import {
22
+ pipeRootDir,
23
+ ensureLayouts,
24
+ normalizeDeliver,
25
+ consume,
26
+ type PipeMessage,
27
+ type DeliverMode,
28
+ } from './pipe-core.js';
29
+
30
+ // ── constants ────────────────────────────────────────────────────────
31
+
32
+ /** Fallback scan cadence; the fs.watch doorbell normally fires instantly. */
33
+ export const PUSH_TICK_MS = 30_000;
34
+
35
+ /** customType for injected pipe messages (traceability, never user-spoofed). */
36
+ export const PIPE_CUSTOM_TYPE = 'opsx-pipe';
37
+
38
+ /** Process-global injector ownership: one push injector per process (the
39
+ * primary session). Mirrors usage-poller's Symbol.for tick-owner claim —
40
+ * subagent sessions also emit session_start in this process but must never
41
+ * start their own watcher/injector. */
42
+ const OWNER_KEY = Symbol.for('omp-opsx-addon.pipe-push.owner');
43
+
44
+ // ── injectable seams (tests; production defaults) ────────────────────
45
+
46
+ export type SendFn = (
47
+ payload: {
48
+ customType: string;
49
+ content: string;
50
+ display: boolean;
51
+ attribution: 'agent';
52
+ details: Record<string, unknown>;
53
+ },
54
+ options: { deliverAs: DeliverMode; triggerTurn: boolean },
55
+ ) => void | Promise<void>;
56
+
57
+ /** Minimal structural surface over node/Bun FSWatcher used by the doorbell. */
58
+ export interface WatchHandle {
59
+ close(): void;
60
+ on(event: 'error', cb: (err: unknown) => void): unknown;
61
+ }
62
+
63
+ export type WatchFactory = (path: string, opts: { recursive: boolean }, cb: () => void) => WatchHandle;
64
+
65
+ const nodeWatchFactory: WatchFactory = (path, opts, cb) =>
66
+ watch(path, opts, () => cb()) as unknown as WatchHandle;
67
+
68
+ let rootOverride: string | null = null;
69
+ let watchOverride: WatchFactory | null = null;
70
+
71
+ /** Test seam: redirect the pipe root (defaults to `<ctx.cwd>/.omp/opsx/pipe`). */
72
+ export function _setRootForTest(root: string | null): void {
73
+ rootOverride = root;
74
+ }
75
+
76
+ /** Test seam: inject a fake fs.watch factory for deterministic degradation. */
77
+ export function _setWatchFactoryForTest(factory: WatchFactory | null): void {
78
+ watchOverride = factory;
79
+ }
80
+
81
+ // ── pure helpers (unit-tested) ───────────────────────────────────────
82
+
83
+ /**
84
+ * Fixed last-mile routing template (design D7): when `to.session` names a
85
+ * sub-agent inside the receiving broker, the injected text appends a hub
86
+ * relay instruction, `\n`-separated. Deterministic and exactly assertable.
87
+ */
88
+ export function injectText(msg: PipeMessage): string {
89
+ return msg.to.session ? `${msg.body}\n经 hub send 转给 ${msg.to.session}` : msg.body;
90
+ }
91
+
92
+ /** Structural validation for a message parsed off disk. Mirrors the minimal
93
+ * shape check core's consume() does; `deliver` is re-validated through
94
+ * core's normalizeDeliver at injection time. */
95
+ function isPipeMessage(value: unknown): value is PipeMessage {
96
+ if (!value || typeof value !== 'object') return false;
97
+ const m = value as Record<string, unknown>;
98
+ if (typeof m.id !== 'string' || typeof m.body !== 'string') return false;
99
+ if (!m.from || typeof m.from !== 'object') return false;
100
+ if (!m.to || typeof m.to !== 'object') return false;
101
+ const from = m.from as Record<string, unknown>;
102
+ const to = m.to as Record<string, unknown>;
103
+ return typeof from.broker === 'string' && typeof to.broker === 'string';
104
+ }
105
+
106
+ // ── per-broker runtime state ─────────────────────────────────────────
107
+
108
+ interface PushState {
109
+ brokerId: string;
110
+ root: string;
111
+ /** Stop-injection flag; default false = injection enabled. */
112
+ muted: boolean;
113
+ watcher?: WatchHandle;
114
+ /** True once the watcher failed; the 30s tick then carries all scans. */
115
+ degraded: boolean;
116
+ tickTimer?: NodeJS.Timeout;
117
+ /** msg-ids already injected (idempotent diff against readdir). */
118
+ seen: Set<string>;
119
+ /** Coalesces concurrent doorbell/tick triggers into one scan chain. */
120
+ scanChain: Promise<unknown>;
121
+ stopped: boolean;
122
+ }
123
+
124
+ const states = new Map<string, PushState>();
125
+
126
+ function getOwner(): string | undefined {
127
+ return (globalThis as Record<symbol, unknown>)[OWNER_KEY] as string | undefined;
128
+ }
129
+
130
+ function setOwner(brokerId: string | undefined): void {
131
+ (globalThis as Record<symbol, unknown>)[OWNER_KEY] = brokerId;
132
+ }
133
+
134
+ // ── injection ────────────────────────────────────────────────────────
135
+ /**
136
+ * Inject one message via pi.sendMessage (custom entry, never spoofed as a
137
+ * human). deliver.mode maps 1:1 to deliverAs; triggerTurn is passed through;
138
+ * missing deliver defaults to followUp/true (core normalizeDeliver).
139
+ */
140
+ async function injectMessage(msg: PipeMessage, send: SendFn): Promise<void> {
141
+ const deliver = normalizeDeliver(msg.deliver);
142
+ await Promise.resolve(
143
+ send(
144
+ {
145
+ customType: PIPE_CUSTOM_TYPE,
146
+ content: injectText(msg),
147
+ display: true,
148
+ attribution: 'agent',
149
+ details: {
150
+ id: msg.id,
151
+ from: msg.from,
152
+ to: msg.to,
153
+ ts: msg.ts,
154
+ ...(msg.replyTo ? { replyTo: msg.replyTo } : {}),
155
+ deliver,
156
+ },
157
+ },
158
+ { deliverAs: deliver.mode, triggerTurn: deliver.triggerTurn },
159
+ ),
160
+ );
161
+ }
162
+
163
+ // ── the one shared scan (watcher doorbell + fallback tick converge here) ─
164
+
165
+ /**
166
+ * Scan this broker's mbox and inject every not-yet-seen message.
167
+ *
168
+ * Ordering (design D5): list → read/validate → inject → THEN claim via the
169
+ * single shared core `consume()` primitive. A failed injection leaves the
170
+ * file untouched (no claim this scan) so the next scan retries; a crash
171
+ * after injection but before claim redelivers at most once. When any
172
+ * candidate injection fails, the claim step is skipped entirely so failed
173
+ * files are never renamed away.
174
+ *
175
+ * Mute short-circuits BEFORE any filesystem read: no read, no inject, no
176
+ * consume — messages stay in the mbox for the pull tools to recover.
177
+ */
178
+ export async function scanOnce(state: PushState, send: SendFn): Promise<{ scanned: number; injected: number }> {
179
+ if (state.stopped || state.muted) return { scanned: 0, injected: 0 };
180
+
181
+ const dir = join(state.root, 'mbox', state.brokerId);
182
+ const before = await listMessageIds(dir);
183
+ if (before === null) return { scanned: 0, injected: 0 }; // mailbox slot not created yet
184
+
185
+ const beforeSet = new Set(before);
186
+ const candidates = before.filter((id) => !state.seen.has(id)).sort();
187
+
188
+ let injected = 0;
189
+ /** Set only by INJECTION failures: those files must stay for retry.
190
+ * Corrupt/unreadable files are NOT retentions — core's consume()
191
+ * renames them aside and drops them, so they never block the claim. */
192
+ let injectFailed = false;
193
+
194
+ for (const id of candidates) {
195
+ const msg = await readMessage(join(dir, `${id}.json`));
196
+ if (!msg) continue; // corrupt: removed by the claim step, never injected
197
+ state.seen.add(id);
198
+ try {
199
+ await injectMessage(msg, send);
200
+ injected += 1;
201
+ } catch {
202
+ // Injection failed: do NOT claim — the file stays for the next
203
+ // scan, and the id is unmarked so it is retried.
204
+ state.seen.delete(id);
205
+ injectFailed = true;
206
+ }
207
+ }
208
+
209
+ // Claim via core's single consume() primitive. consume() claims the WHOLE
210
+ // slot (rename→read→delete for every .json), so:
211
+ // 1. an injection failure this scan skips the claim entirely (retry),
212
+ // 2. the claim runs only while the listing is STABLE — a file that
213
+ // races in during the inject pass is swept unseen otherwise. When
214
+ // names changed, the next scan injects the newcomer and claims once
215
+ // settled (already-seen ids are not injected twice).
216
+ if (!injectFailed) {
217
+ const after = await listMessageIds(dir);
218
+ // Stable = the exact same name set as at scan start (no newcomer
219
+ // racing in, no pull-tool consumption racing out). Corrupt files
220
+ // count as settled: consume() drops them.
221
+ const stable = after !== null && after.length === before.length && after.every((id) => beforeSet.has(id));
222
+ if (stable) {
223
+ const claimed = await consume(state.root, state.brokerId);
224
+ for (const m of claimed) {
225
+ if (state.seen.has(m.id)) continue;
226
+ // Not injected because our own read failed transiently; the
227
+ // file is valid (consume parsed it) — deliver it now.
228
+ state.seen.add(m.id);
229
+ try {
230
+ await injectMessage(m, send);
231
+ injected += 1;
232
+ } catch {
233
+ state.seen.delete(m.id);
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ return { scanned: candidates.length, injected };
240
+ }
241
+
242
+ /** List settled message ids in a broker mbox; null when the slot is absent. */
243
+ async function listMessageIds(dir: string): Promise<string[] | null> {
244
+ try {
245
+ const names = await fs.readdir(dir);
246
+ return names.filter((n) => n.endsWith('.json')).map((n) => n.slice(0, -'.json'.length));
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ /** Read + validate one message file; corrupt/unreadable → null. */
253
+ async function readMessage(file: string): Promise<PipeMessage | null> {
254
+ try {
255
+ const parsed = JSON.parse(await fs.readFile(file, 'utf-8')) as unknown;
256
+ return isPipeMessage(parsed) ? parsed : null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ function triggerScan(state: PushState, send: SendFn): void {
263
+ state.scanChain = state.scanChain
264
+ .then(() => scanOnce(state, send))
265
+ .catch(() => {
266
+ /* scan failures must never kill the timer/watcher chain */
267
+ });
268
+ }
269
+
270
+ // ── lifecycle ────────────────────────────────────────────────────────
271
+
272
+ /**
273
+ * Start the push injector for the primary session of `ctx`. Idempotent per
274
+ * broker; no-ops for secondary sessions in a process that already has an
275
+ * owner (subagents emit session_start in-process but never inject).
276
+ */
277
+ export async function startPipePush(
278
+ ctx: ExtensionContext,
279
+ send: SendFn,
280
+ opts?: { watchFactory?: WatchFactory; tickMs?: number },
281
+ ): Promise<void> {
282
+ const brokerId = ctx.sessionManager?.getSessionId?.() ?? '';
283
+ if (!brokerId || states.has(brokerId)) return;
284
+ const owner = getOwner();
285
+ if (owner && owner !== brokerId) return;
286
+
287
+ const root = rootOverride ?? pipeRootDir(ctx.cwd);
288
+ await ensureLayouts(root);
289
+ // Make sure our own slot exists before watching so doorbell events on
290
+ // newly-landed files fire in a directory that already exists.
291
+ await fs.mkdir(join(root, 'mbox', brokerId), { recursive: true });
292
+
293
+ const state: PushState = {
294
+ brokerId,
295
+ root,
296
+ muted: false,
297
+ degraded: false,
298
+ seen: new Set(),
299
+ scanChain: Promise.resolve(),
300
+ stopped: false,
301
+ };
302
+ states.set(brokerId, state);
303
+ setOwner(brokerId);
304
+
305
+ const tickMs = opts?.tickMs ?? PUSH_TICK_MS;
306
+ state.tickTimer = ctx.setInterval(() => triggerScan(state, send), tickMs);
307
+
308
+ // Doorbell: fs.watch over the whole mbox/ tree (recursive on macOS per
309
+ // the Bun/macOS spike). Construction failures (e.g. ENOENT) throw
310
+ // synchronously on Bun; error events arrive asynchronously. Either way
311
+ // we degrade to pure polling — the tick shares the exact same scan.
312
+ const factory = opts?.watchFactory ?? watchOverride ?? nodeWatchFactory;
313
+ try {
314
+ const watcher = factory(join(root, 'mbox'), { recursive: true }, () => triggerScan(state, send));
315
+ watcher.on('error', () => {
316
+ state.degraded = true;
317
+ try {
318
+ watcher.close();
319
+ } catch {
320
+ /* best-effort */
321
+ }
322
+ });
323
+ state.watcher = watcher;
324
+ } catch {
325
+ state.degraded = true;
326
+ }
327
+
328
+ // Immediate scan: drains a backlog after restart (crash-redelivery) and
329
+ // mirrors core's immediate heartbeat — never wait for the first tick.
330
+ triggerScan(state, send);
331
+ }
332
+
333
+ /** Stop the injector (session_shutdown): close watcher, clear managed timer. */
334
+ export async function stopPipePush(ctx: ExtensionContext): Promise<void> {
335
+ const brokerId = ctx.sessionManager?.getSessionId?.() ?? '';
336
+ const state = brokerId ? states.get(brokerId) : undefined;
337
+ if (!state) return;
338
+ state.stopped = true;
339
+ states.delete(brokerId);
340
+ if (getOwner() === brokerId) setOwner(undefined);
341
+ try {
342
+ state.watcher?.close();
343
+ } catch {
344
+ /* best-effort */
345
+ }
346
+ if (state.tickTimer) ctx.clearTimer(state.tickTimer);
347
+ await state.scanChain.catch(() => {});
348
+ }
349
+
350
+ /** Set the stop-injection flag for a broker (the /opsx-pipe mute command). */
351
+ export function setMuted(brokerId: string, muted: boolean): void {
352
+ const state = states.get(brokerId);
353
+ if (state) state.muted = muted;
354
+ }
355
+
356
+ export function isMuted(brokerId: string): boolean {
357
+ return states.get(brokerId)?.muted ?? false;
358
+ }
359
+
360
+ // ── plugin wiring ────────────────────────────────────────────────────
361
+
362
+ export function registerPipePush(pi: ExtensionAPI): void {
363
+ pi.registerCommand('opsx-pipe', {
364
+ description:
365
+ '跨 broker pipe 推送开关:/opsx-pipe mute 翻转停注标志(默认注入开启,mute 后消息保留在 mbox 可用 opsx_pipe_recv/wait 拉取);无参数显示当前状态。',
366
+ handler: async (args: string, ctx) => {
367
+ const brokerId = ctx.sessionManager?.getSessionId?.() ?? '';
368
+ const state = brokerId ? states.get(brokerId) : undefined;
369
+ const arg = args.trim();
370
+ if (arg === 'mute') {
371
+ if (!state) {
372
+ // push 尚未初始化(session_start 未完成或启动失败):不能谎报翻转结果
373
+ pi.sendMessage(
374
+ {
375
+ customType: PIPE_CUSTOM_TYPE,
376
+ content:
377
+ '⚠️ opsx-pipe 推送尚未启动(初始化未完成或失败),停注开关暂不可用。到达的消息仍可通过 opsx_pipe_recv/opsx_pipe_wait 拉取。',
378
+ display: true,
379
+ attribution: 'agent',
380
+ },
381
+ { triggerTurn: false },
382
+ );
383
+ return;
384
+ }
385
+ state.muted = !state.muted;
386
+ pi.sendMessage(
387
+ {
388
+ customType: PIPE_CUSTOM_TYPE,
389
+ content: state.muted
390
+ ? '🔇 opsx-pipe 推送已停注(mute):消息保留在 mbox,可用 opsx_pipe_recv/opsx_pipe_wait 拉取。再次 /opsx-pipe mute 恢复注入。'
391
+ : '🔈 opsx-pipe 推送注入已开启。',
392
+ display: true,
393
+ attribution: 'agent',
394
+ },
395
+ { triggerTurn: false },
396
+ );
397
+ return;
398
+ }
399
+ pi.sendMessage(
400
+ {
401
+ customType: PIPE_CUSTOM_TYPE,
402
+ content: `opsx-pipe 状态:${state?.muted ? '🔇 已停注(mute)' : '🔈 注入开启'}${
403
+ state?.degraded ? ';watcher 不可用,30s 兜底轮询中' : ''
404
+ }。用法:/opsx-pipe mute 翻转停注开关。`,
405
+ display: true,
406
+ attribution: 'agent',
407
+ },
408
+ { triggerTurn: false },
409
+ );
410
+ },
411
+ });
412
+
413
+ pi.on('session_start', async (_event, ctx) => {
414
+ try {
415
+ await startPipePush(ctx, (payload, options) => pi.sendMessage(payload, options));
416
+ } catch {
417
+ /* pipe push is best-effort; never break session start */
418
+ }
419
+ });
420
+ pi.on('session_shutdown', async (_event, ctx) => {
421
+ try {
422
+ await stopPipePush(ctx);
423
+ } catch {
424
+ /* best-effort */
425
+ }
426
+ });
427
+ }
428
+
429
+ // ── test-only reset ──────────────────────────────────────────────────
430
+
431
+ /** Test seam: clear all runtime state, ownership, and overrides. */
432
+ export function _resetForTest(): void {
433
+ states.clear();
434
+ rootOverride = null;
435
+ watchOverride = null;
436
+ setOwner(undefined);
437
+ }
438
+
439
+ /** Test seam: inspect a broker's push state (degraded flag, seen set). */
440
+ export function _getStateForTest(brokerId: string): PushState | undefined {
441
+ return states.get(brokerId);
442
+ }
443
+
444
+ /** Test seam: await the settled scan chain (doorbell/tick scans are async). */
445
+ export async function _flushForTest(brokerId: string): Promise<void> {
446
+ const state = states.get(brokerId);
447
+ if (state) await state.scanChain.catch(() => {});
448
+ }
449
+
450
+ /** Test seam: create a quiescent push state (no timer/watcher/auto-scan) so
451
+ * scanOnce logic can be driven deterministically without the lifecycle. */
452
+ export async function _makeStateForTest(brokerId: string, cwd: string): Promise<PushState> {
453
+ const root = rootOverride ?? pipeRootDir(cwd);
454
+ await ensureLayouts(root);
455
+ await fs.mkdir(join(root, 'mbox', brokerId), { recursive: true });
456
+ const state: PushState = {
457
+ brokerId,
458
+ root,
459
+ muted: false,
460
+ degraded: true, // no watcher in this harness
461
+ seen: new Set(),
462
+ scanChain: Promise.resolve(),
463
+ stopped: false,
464
+ };
465
+ states.set(brokerId, state);
466
+ return state;
467
+ }
@@ -17,6 +17,7 @@
17
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
18
  import { join, dirname } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
+ import { getLogger } from './logger.js';
20
21
 
21
22
  // ── 缓存契约 ─────────────────────────────────────────────────────────────
22
23
 
@@ -132,7 +133,7 @@ export function writeReachabilityCache(cache: ReachabilityCacheFile): void {
132
133
  ensureDir(dirname(path));
133
134
  writeFileSync(path, JSON.stringify(cache, null, 2), 'utf-8');
134
135
  } catch (e) {
135
- console.warn(`[omp-opsx-addon] failed to write reachability cache: ${e}`);
136
+ getLogger().warn(`[omp-opsx-addon] failed to write reachability cache: ${e}`);
136
137
  }
137
138
  }
138
139
 
@@ -18,6 +18,7 @@ import { connect as tlsConnect } from 'node:tls';
18
18
  import type { TLSSocket } from 'node:tls';
19
19
  import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
20
20
  import { resolveCursorAccessToken } from './cursor-auth.js';
21
+ import { getLogger } from './logger.js';
21
22
 
22
23
  // ── 类型 ────────────────────────────────────────────────────────────────
23
24
 
@@ -424,7 +425,7 @@ export async function probeProviders(
424
425
 
425
426
  for (const s of settled) {
426
427
  if (s.status === 'rejected') {
427
- console.warn(`[omp-opsx-addon] probe error: ${s.reason}`);
428
+ getLogger().warn(`[omp-opsx-addon] probe error: ${s.reason}`);
428
429
  continue;
429
430
  }
430
431
  const [provider, result] = s.value;
@@ -439,7 +440,7 @@ export async function probeProviders(
439
440
  }
440
441
 
441
442
  if (providerReasons.length > 0) {
442
- console.warn(`[omp-opsx-addon] unreachable endpoints:\n ${providerReasons.join('\n ')}`);
443
+ getLogger().warn(`[omp-opsx-addon] unreachable endpoints:\n ${providerReasons.join('\n ')}`);
443
444
  }
444
445
 
445
446
  // 构建返回结果
@@ -21,6 +21,7 @@
21
21
  import { REGION_BLOCK_TOKENS } from './reachability-probe.js';
22
22
  import { canonicalizeProvider } from './usage-resolver.js';
23
23
  import { markExhausted, markAvailable } from './provider-status.js';
24
+ import { getLogger } from './logger.js';
24
25
 
25
26
  // ── 类型 ────────────────────────────────────────────────────────────────
26
27
 
@@ -269,14 +270,14 @@ export async function scheduleStatusProbes(opts: ScheduleOptions): Promise<numbe
269
270
  if (probeResult.class === 'exhausted' || probeResult.class === 'region-blocked') {
270
271
  markExhausted(probeResult.provider);
271
272
  written++;
272
- console.info(`[omp-opsx-addon] status probe: ${canonical} -> ${probeResult.class}`);
273
+ getLogger().info(`[omp-opsx-addon] status probe: ${canonical} -> ${probeResult.class}`);
273
274
  } else if (probeResult.class === 'ok') {
274
275
  markAvailable(probeResult.provider);
275
276
  written++;
276
- console.info(`[omp-opsx-addon] status probe: ${canonical} -> ok`);
277
+ getLogger().info(`[omp-opsx-addon] status probe: ${canonical} -> ok`);
277
278
  } else {
278
279
  // auth-error/model-not-found/network-error 不写状态
279
- console.warn(
280
+ getLogger().warn(
280
281
  `[omp-opsx-addon] status probe: ${canonical} -> ${probeResult.class}${probeResult.detail ? ` (${probeResult.detail})` : ''}`,
281
282
  );
282
283
  }
@@ -20,7 +20,11 @@
20
20
  * which models the auto-selector may pick from.
21
21
  * - `tiers`: list of `{ pattern, tier }` overrides for tier scoring.
22
22
  * - `role_tiers`: `role → tier_name` map overriding the default expected
23
- * tier (DEFAULT_ROLE_TIER in model-tiers).
23
+ * tier (DEFAULT_ROLE_TIER in model-tiers) for the four opsx agents.
24
+ * - `model_role_tiers`: `omp_role → tier_name | 'skip'` map controlling
25
+ * which selector tier each OMP model role (default/smol/slow/vision/plan/
26
+ * designer/commit/tiny/task/advisor + custom roles) follows after
27
+ * /pick-model; `skip` excludes the role from the session overlay.
24
28
  */
25
29
 
26
30
  import { existsSync, readFileSync } from 'fs';
@@ -60,6 +64,8 @@ export interface OpsxYamlShape {
60
64
  model_allowlist?: string[];
61
65
  tiers?: TierOverride[];
62
66
  role_tiers?: Partial<Record<OpsxRole, TierName>>;
67
+ /** OMP model role → tier override; `skip` excludes the role from the overlay. Keys are arbitrary strings (custom roles allowed). */
68
+ model_role_tiers?: Record<string, string>;
63
69
  plan_providers?: string[];
64
70
  probe_enabled?: boolean;
65
71
  probe_timeout_ms?: number;
@@ -90,6 +96,8 @@ export interface ResolvedOpsxConfig {
90
96
  modelAllowlist: string[];
91
97
  tierOverrides: TierOverride[];
92
98
  roleTiers: Partial<Record<OpsxRole, TierName>>;
99
+ /** OMP model role → tier (or `'skip'`); drives the pick-model role overlay. */
100
+ ompRoleTiers: Record<string, TierName | 'skip'>;
93
101
  /** Provider ids that have monthly plan subscriptions. */
94
102
  planProviders: string[];
95
103
  warnings: string[];
@@ -217,6 +225,36 @@ export function parseRoleTiers(
217
225
  return out;
218
226
  }
219
227
 
228
+ /**
229
+ * Validate `model_role_tiers` entries. Keys are OMP model role names
230
+ * (built-in OR custom — never checked against the built-in set); values
231
+ * must be a tier name or the sentinel `'skip'`. Invalid entries are dropped
232
+ * with a warning.
233
+ */
234
+ export function parseOmpRoleTiers(
235
+ raw: unknown,
236
+ warn: (msg: string) => void,
237
+ ): Record<string, TierName | 'skip'> {
238
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
239
+ const out: Record<string, TierName | 'skip'> = {};
240
+ for (const [role, v] of Object.entries(raw as Record<string, unknown>)) {
241
+ if (typeof v !== 'string') {
242
+ warn(`[omp-opsx-addon] model_role_tiers.${role}: expected tier name or 'skip', got ${typeof v}`);
243
+ continue;
244
+ }
245
+ if (v === 'skip') {
246
+ out[role] = 'skip';
247
+ continue;
248
+ }
249
+ if (!TIER_NAMES.includes(v as TierName)) {
250
+ warn(`[omp-opsx-addon] model_role_tiers.${role}: expected tier name or 'skip' (got "${v}"; use one of ${TIER_NAMES.join('/')})`);
251
+ continue;
252
+ }
253
+ out[role] = v as TierName;
254
+ }
255
+ return out;
256
+ }
257
+
220
258
  /** Validate the model_allowlist (array of glob strings). */
221
259
  export function parseModelAllowlist(
222
260
  raw: unknown,
@@ -305,6 +343,7 @@ export function parseOpsxConfig(
305
343
  modelAllowlist: parseModelAllowlist(obj.model_allowlist, localWarn),
306
344
  tierOverrides: parseTierOverrides(obj.tiers, localWarn),
307
345
  roleTiers: parseRoleTiers(obj.role_tiers, localWarn),
346
+ ompRoleTiers: parseOmpRoleTiers(obj.model_role_tiers, localWarn),
308
347
  planProviders: sanitizePlanProviders(obj.plan_providers),
309
348
  warnings,
310
349
  probe_enabled: parseProbeBool(obj.probe_enabled, 'probe_enabled', true, localWarn),
@@ -357,6 +396,7 @@ function pickTopLevelKeys(raw: OpsxYamlShape): OpsxYamlShape {
357
396
  model_allowlist: raw.model_allowlist,
358
397
  tiers: raw.tiers,
359
398
  role_tiers: raw.role_tiers,
399
+ model_role_tiers: raw.model_role_tiers,
360
400
  plan_providers: raw.plan_providers,
361
401
  probe_enabled: raw.probe_enabled,
362
402
  probe_timeout_ms: raw.probe_timeout_ms,
@@ -448,6 +488,7 @@ export function readOpsxSettingsFromPaths(
448
488
  merged.model_allowlist = projectTop.model_allowlist ?? globalTop.model_allowlist;
449
489
  merged.tiers = projectTop.tiers ?? globalTop.tiers;
450
490
  merged.role_tiers = projectTop.role_tiers ?? globalTop.role_tiers;
491
+ merged.model_role_tiers = projectTop.model_role_tiers ?? globalTop.model_role_tiers;
451
492
  merged.plan_providers = projectTop.plan_providers ?? globalTop.plan_providers;
452
493
  merged.probe_enabled = projectTop.probe_enabled ?? globalTop.probe_enabled;
453
494
  merged.probe_timeout_ms = projectTop.probe_timeout_ms ?? globalTop.probe_timeout_ms;