@genee/omp-opsx-addon 0.7.0 → 0.9.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.
package/index.ts CHANGED
@@ -82,30 +82,71 @@ function handleSubagentEvent(payload: unknown, primarySessionId: string): void {
82
82
  getLogger().error('Failed to handle subagent event', { err });
83
83
  }
84
84
  }
85
- import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, Settings } from '@oh-my-pi/pi-coding-agent';
86
- import { getKnownRoleIds, MODEL_ROLE_IDS } from '@oh-my-pi/pi-coding-agent/config/model-roles';
85
+ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@oh-my-pi/pi-coding-agent';
86
+ import { discoverAgents } from '@oh-my-pi/pi-coding-agent/task/discovery';
87
87
  import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
88
88
  import type { UsageReport } from '@oh-my-pi/pi-ai';
89
89
  import { createCachedScanner } from './lib/change-scanner.js';
90
- import { installAgents } from './lib/agent-defs.js';
90
+ import { installAgents, SCOPE_FENCE_TAG } from './lib/agent-defs.js';
91
91
  import {
92
92
  applyRoleModelOverrides,
93
93
  buildRoleOverridePlan,
94
94
  dispatchModelHint,
95
95
  frontmatterModel,
96
+ resolveOmpRoleTier,
96
97
  ROLE_TO_OMP_ALIAS,
97
98
  OMP_ROLE_TO_TIER,
98
99
  ROLE_CAPABILITY,
99
100
  type SkippedRole,
100
101
  } from './lib/model-roles.js';
102
+ import {
103
+ annotationOfPicked,
104
+ coverageLabel,
105
+ formatAgentInventory,
106
+ formatCandidateOverview,
107
+ formatChangeLine,
108
+ formatFootnote,
109
+ formatRoleLines,
110
+ renderPickModelReport,
111
+ resolveSymbols,
112
+ roleKindLabel,
113
+ tierLabel,
114
+ } from './lib/pick-model-render.js';
101
115
  import { buildStaticPrompt, buildDynamicPrompt } from './lib/system-prompt.js';
102
116
  import { type ProviderHealth, canonicalizeProvider, listDetectableProviders, healthCacheKey } from './lib/usage-resolver.js';
103
- import { readOpsxSettings, type ResolvedOpsxConfig, type RoleValue, OPSX_ROLES } from './lib/unified-config.js';
117
+ import { readOpsxSettings, resolveManagedProviders, type ResolvedOpsxConfig, type RoleValue, OPSX_ROLES } from './lib/unified-config.js';
118
+ import {
119
+ createConcurrencyController,
120
+ createNodeFsPort,
121
+ type ConcurrencyController,
122
+ type ConcurrencyLeaseFactory,
123
+ type ConcurrencyRedisPort,
124
+ type SettingsPort,
125
+ } from './lib/concurrency-control.js';
126
+ import { TUNER_DEFAULTS, type TunerOptions } from './lib/concurrency-tuner.js';
127
+ import { nodeWriterFileSystem } from './lib/concurrency-writer.js';
128
+ import { RedisClient } from './lib/usage-redis-client.js';
104
129
  import { selectAllRoles, selectModel, mergePlanProviders, type SelectorResult } from './lib/model-selector.js';
130
+ import { resolveSpeedAware } from './lib/model-speed.js';
105
131
  import { scoreModelWithProvider, type TierName } from './lib/model-tiers.js';
132
+ import {
133
+ applyBandOffset,
134
+ classifyTaskSafe,
135
+ type RoutingRuntime,
136
+ type TaskBand,
137
+ } from './lib/complexity-router.js';
138
+ import {
139
+ createStallRuntime,
140
+ evaluateStall,
141
+ recordToolCall,
142
+ rebuildFromEntries,
143
+ toolSignature,
144
+ type StallRuntime,
145
+ } from './lib/stall-detector.js';
146
+ import { decideGuard, pinPriorPick } from './lib/continuity-guard.js';
106
147
  import { DIRECT_FETCHERS } from './lib/direct-fetchers.js';
107
148
  import { createUsageWidget } from './lib/usage-widget.js';
108
- import { renderUsageReports, type ConsumptionTrack } from './lib/usage-render.js';
149
+ import { renderUsageReports, type ConsumptionTrack, type ConcurrencyInlineView } from './lib/usage-render.js';
109
150
  import { markExhausted, markAvailable, getExhaustedProviders } from './lib/provider-status.js';
110
151
  import { registerPipeCore } from './lib/pipe-core.js';
111
152
  import { registerPipePush } from './lib/pipe-push.js';
@@ -134,7 +175,6 @@ import {
134
175
  import { updateTiers, autoRefreshTiersIfStale } from './lib/tiers-updater.js';
135
176
  import { appendSample, getConsumptionSamples, RING_SIZE } from './lib/usage-sampler.js';
136
177
  import { buildBalanceEstimates, type UsageBucket } from './lib/usage-estimator.js';
137
- import { matchDataEntry, TIERS_DATA_PATH, isTiersDataLoaded, tiersDataAge } from './lib/tiers-data.js';
138
178
  import {
139
179
  loadBlockedModels,
140
180
  persistBlockedModels,
@@ -144,21 +184,102 @@ import { refreshReachability } from './lib/reachability-refresh.js';
144
184
  import { loadAvailableByProvider } from './lib/reachability-refresh.js';
145
185
  import { filterSelectableModels, reachabilityFingerprint } from './lib/selection-filters.js';
146
186
  import { parseAutoModelArgs, CHINA_EXCLUDE_FAMILIES, listFamilies, listProviders } from './lib/family-filter.js';
187
+ import {
188
+ REGION_VARIANT_GROUPS,
189
+ resolveVariantCollapse,
190
+ variantCollapseAnnotation,
191
+ collapseUsageView,
192
+ providerSurvivesAllowlist,
193
+ type VariantCollapseResult,
194
+ } from './lib/provider-variants.js';
147
195
  import { hasRateLimitError, hasRegionError, buildErrorDiagnosis, exclusionReasonStats } from './lib/error-scan.js';
148
- import { applyEditVariantPin } from './lib/edit-variant-pin.js';
196
+ import { applyEditVariantPin, resolveGlobalConfigPath } from './lib/edit-variant-pin.js';
149
197
  import { setOpsxLogger, getLogger } from './lib/logger.js';
198
+ import { homedir } from 'node:os';
199
+ import { join } from 'node:path';
200
+ import { YAML } from 'bun';
150
201
  type Role = 'coder' | 'reviewer' | 'planner' | 'proposal-reviewer';
151
202
 
152
203
  /** Session-scoped selector constraint for `/pick-model <selector> [--china]`. */
153
204
  export interface FamilyConstraint {
154
205
  selectors: string[] | null;
155
206
  excludeSelectors: string[];
207
+ /** Canonical modelRole ids the constraint narrows; null/absent = global (all roles). */
208
+ roles?: string[] | null;
156
209
  }
157
210
 
158
- /** Stable fingerprint of the active selector constraint (design Decision 3/9). */
159
- function familyConstraintFingerprint(fc: FamilyConstraint | null): string {
211
+ /**
212
+ * Stable fingerprint of the active selector constraint (design Decision 3/9;
213
+ * pick-model-role-scope: roles participate so global↔scoped switches and scope
214
+ * changes invalidate the selection health cache). Exported for tests — the
215
+ * fingerprint IS the documented cache-invalidation contract.
216
+ */
217
+ export function familyConstraintFingerprint(fc: FamilyConstraint | null): string {
160
218
  if (!fc) return 'none';
161
- return `fam:${fc.selectors ? fc.selectors.join(',') : '*'}|excl:${fc.excludeSelectors.join(',')}`;
219
+ return `fam:${fc.selectors ? fc.selectors.join(',') : '*'}|excl:${fc.excludeSelectors.join(',')}|roles:${fc.roles ? fc.roles.join(',') : '*'}`;
220
+ }
221
+
222
+ /**
223
+ * Fingerprint of the regional-variant collapse set (china-config). Exported
224
+ * for tests — same contract as familyConstraintFingerprint: the segment is the
225
+ * documented cache-invalidation channel for auth-state flips (re-login can
226
+ * change which side of a variant pair collapses, so the set must participate
227
+ * in the selection health key).
228
+ */
229
+ export function variantCollapseFingerprint(collapsed: ReadonlySet<string>): string {
230
+ return `variants:${[...collapsed].sort().join(',')}`;
231
+ }
232
+
233
+ /**
234
+ * Pre-resolve the API keys of the variant-group members present in `present`
235
+ * (≤2 awaits) into the sync key view `resolveVariantCollapse` gates on. A
236
+ * missing getter, a rejected/throwing lookup, or an empty key all land as
237
+ * `undefined` → the gate stays conservative (both doors coexist).
238
+ */
239
+ async function resolveVariantKeyLookup(
240
+ present: ReadonlySet<string>,
241
+ getApiKey: ((provider: string) => Promise<string | undefined>) | undefined,
242
+ ): Promise<(provider: string) => string | undefined> {
243
+ const keys = new Map<string, string | undefined>();
244
+ for (const group of REGION_VARIANT_GROUPS) {
245
+ for (const member of [group.domestic, group.intl]) {
246
+ if (!present.has(member) || keys.has(member)) continue;
247
+ try {
248
+ keys.set(member, (await getApiKey?.(member)) ?? undefined);
249
+ } catch {
250
+ keys.set(member, undefined);
251
+ }
252
+ }
253
+ }
254
+ return (provider) => keys.get(provider);
255
+ }
256
+
257
+ /**
258
+ * Scope roles → tier slots (pick-model-role-scope D3). Resolved with the same
259
+ * effective mapping batch-all-roles uses (`model_role_tiers` override + the
260
+ * built-in default table). `vision` contributes no slot (its scope semantics
261
+ * gate the capability candidate set instead); a role resolving to the `skip`
262
+ * sentinel throws ScopedRoleSkipError — a skip role sits on no tier slot, so a
263
+ * pool constraint on it is inexpressible.
264
+ */
265
+ export class ScopedRoleSkipError extends Error {
266
+ constructor(readonly role: string) {
267
+ super(`role 定向条目 "${role}" 的有效档位为 skip(model_role_tiers),无法对其施加池约束`);
268
+ }
269
+ }
270
+
271
+ export function scopedTierSlots(
272
+ roles: readonly string[] | null | undefined,
273
+ ompRoleTiers: Record<string, TierName | 'skip'>,
274
+ ): Set<TierName> {
275
+ const slots = new Set<TierName>();
276
+ for (const role of roles ?? []) {
277
+ const tier = resolveOmpRoleTier(role, ompRoleTiers);
278
+ if (tier === 'skip') throw new ScopedRoleSkipError(role);
279
+ if (role === 'vision') continue;
280
+ slots.add(tier);
281
+ }
282
+ return slots;
162
283
  }
163
284
 
164
285
  /**
@@ -170,12 +291,13 @@ function selectionHealthKeyFor(
170
291
  reach: { unreachable: Set<string>; availableByProvider: Map<string, string[] | null> },
171
292
  fc: FamilyConstraint | null,
172
293
  blocked: ReadonlySet<string>,
294
+ collapsed: ReadonlySet<string>,
173
295
  ): string {
174
296
  return `${healthCacheKey(health)}|${reachabilityFingerprint({
175
297
  unreachable: reach.unreachable,
176
298
  availableByProvider: reach.availableByProvider,
177
299
  blocked,
178
- })}|${familyConstraintFingerprint(fc)}`;
300
+ })}|${familyConstraintFingerprint(fc)}|${variantCollapseFingerprint(collapsed)}`;
179
301
  }
180
302
 
181
303
  /** Suggested id segment for the ambiguity hint (design Decision 8). */
@@ -261,12 +383,12 @@ export interface SelectionContext {
261
383
  roleOverrides: Record<string, string>;
262
384
  /** Known roles left out of the overlay payload (config skip / capability gate / no pick). */
263
385
  skippedRoles: SkippedRole[];
386
+ /** payg_last_resort the selection was computed with — drives the 分区 fold annotation in choices markdown. Default true. */
387
+ paygLastResort?: boolean;
388
+ /** W4 routing: the task band this selection was computed with (undefined = routing runtime absent). */
389
+ routingBand?: TaskBand;
264
390
  }
265
391
 
266
- const DEFAULT_PLAN_PROVIDERS = new Set(['minimax-code-cn', 'opencode-go', 'zhipu-coding-plan', 'kimi', 'openai-codex']);
267
-
268
-
269
-
270
392
  function roleValueToModelString(value: RoleValue): string | undefined {
271
393
  if (value.kind === 'auto') return undefined;
272
394
  if (value.kind === 'omp-alias') return `@${value.alias}`;
@@ -305,53 +427,126 @@ export function buildDispatchPrompt(role: Role, task: string, selection: Selecti
305
427
  else parts.push(`请直接通过 task 工具调用 ${agent}(无需指定 model,模型按 role 自动解析)。`);
306
428
  if (sel?.decision) parts.push(`原因: ${sel.decision}`);
307
429
  parts.push('直接用内置工具实现。');
430
+ if (role === 'coder') parts.push(`${SCOPE_FENCE_TAG}与本任务无关的异常状态(别人未提交的改动、无关失败测试、无关脏文件、工作树状态与预期不符):最小相关性核查(如 git status、确认被改动文件是否落在本次改动面内)不受限;判定为无关后 MUST NOT 调查改动来源、MUST NOT 暂停当前任务,改为记录假设、写入交付摘要 SUMMARY、继续执行当前任务;真阻塞(缺依赖 / 契约动摇 / 权限不足 / 需用户决策)仍立即 STATUS: blocked。`);
308
431
  return `请使用 task 工具委派给 ${agent}:\ntask(agent:"${agent}"${modelHint ? `, model:"${modelHint}"` : ''}, task:"${parts.join(' ')}\\n\\n${task}")`;
309
432
  }
310
433
 
311
- /**
312
- * All model roles the host currently knows (10 built-in + custom roles from
313
- * cycleOrder/modelRoles/modelTags). Host settings can throw during early
314
- * startup or partial init; fall back to the built-in set rather than
315
- * aborting selection (later recomputes pick up custom roles once ready).
316
- */
317
- function safeGetKnownRoleIds(settings: Settings | null | undefined): string[] {
318
- if (!settings) return [...MODEL_ROLE_IDS];
319
- try {
320
- return getKnownRoleIds(settings);
321
- } catch (err) {
322
- getLogger().warn(`[omp-opsx-addon] getKnownRoleIds failed; using built-in model roles only: ${err instanceof Error ? err.message : String(err)}`);
323
- return [...MODEL_ROLE_IDS];
324
- }
325
- }
326
-
327
434
  function computeSelectionSync(
328
435
  config: ResolvedOpsxConfig,
329
436
  models: Model<Api>[] | null,
330
437
  health: Map<string, ProviderHealth> | null,
331
- knownRoleIds: string[],
332
438
  regionBlockedModels?: Set<string>,
333
439
  unreachableProviders?: Set<string>,
334
440
  availableByProvider?: Map<string, string[] | null>,
335
441
  familyConstraint?: FamilyConstraint | null,
442
+ collapsedProviders?: ReadonlySet<string>,
443
+ onWarn?: (msg: string) => void,
444
+ /** W4 routing runtime (design D8.5); undefined = all switches off + balanced → byte-identical legacy path. */
445
+ routing?: RoutingRuntime,
336
446
  ): SelectionContext {
337
- const filteredModels = models
447
+ const fc = familyConstraint ?? null;
448
+ // pick-model-role-scope: pool split. basePool = reachability/provider
449
+ // filtering plus account-level regional-variant dedup (china-config: the
450
+ // collapse set is resolved at the call sites and narrows EVERY slot,
451
+ // scoped or not — account-level, orthogonal to constraints);
452
+ // filteredModels = basePool further narrowed by the selector filter and
453
+ // the effective china exclusion (explicit `--china` constraint first, else
454
+ // the `china: true` config default). Global constraints feed every slot
455
+ // from the single filtered pool (status-quo shape); scoped constraints
456
+ // only the slots in scope — everything else draws basePool, which
457
+ // deliberately KEEPS china families (role-scope contract: out-of-scope
458
+ // slots never inherit the standing exclusion).
459
+ const scopeRoles = fc?.roles && fc.roles.length > 0 ? fc.roles : null;
460
+ const scopedSlots = scopedTierSlots(scopeRoles, config.ompRoleTiers);
461
+ // provider-auto-targets: config-static per-provider narrowing at the pool
462
+ // layer — every downstream slot (tierPicks / vision / role pools) inherits it.
463
+ const providerModelMap = Object.keys(config.providerModels).length > 0
464
+ ? new Map(Object.entries(config.providerModels))
465
+ : undefined;
466
+ const providerModelWipes = new Set<string>();
467
+ const basePool = models
338
468
  ? filterSelectableModels(models, {
339
469
  blockedSelectors: regionBlockedModels,
340
470
  unreachableProviders,
341
- excludedProviders: config.excluded_providers,
471
+ excludedProviders: [...config.excluded_providers, ...(collapsedProviders ?? [])],
342
472
  availableByProvider,
343
- selectors: familyConstraint?.selectors ?? undefined,
344
- excludeSelectors:
345
- familyConstraint?.excludeSelectors && familyConstraint.excludeSelectors.length > 0
346
- ? familyConstraint.excludeSelectors
347
- : undefined,
473
+ providerModels: providerModelMap,
474
+ zeroHitProviders: providerModelWipes,
348
475
  })
349
476
  : models;
477
+ if (onWarn) {
478
+ for (const p of providerModelWipes) {
479
+ onWarn(`[omp-opsx-addon] provider_models.${p}: 收窄零命中(候选全部未命中模式,检查模式拼写是否与 catalog id 一致)`);
480
+ }
481
+ // Sentinel id drift (catalog renamed its auto entry): marking silently
482
+ // falls back to heuristic scoring — surface it instead of staying quiet.
483
+ for (const [p, id] of Object.entries(config.providerAuto)) {
484
+ const inPool = basePool?.some((m) => canonicalizeProvider(m.provider) === p);
485
+ if (!inPool) continue;
486
+ const hit = basePool?.some((m) => canonicalizeProvider(m.provider) === p && m.id.toLowerCase() === id.toLowerCase());
487
+ if (!hit) onWarn(`[omp-opsx-addon] provider_auto.${p}: 声明的哨兵 id "${id}" 不在 ${p} 的存活候选中(catalog id 已漂移?标记失效,回落普通评分)`);
488
+ }
489
+ }
490
+ // 命令显式 > 配置缺省: a constraint carrying excludeSelectors wins; the
491
+ // config default only fills in when the constraint carries none. Invariant:
492
+ // excludeSelectors has exactly one write site (parsed.china →
493
+ // CHINA_EXCLUDE_FAMILIES in the selector branch), so non-empty ≡ explicit.
494
+ const effectiveExclude =
495
+ fc?.excludeSelectors && fc.excludeSelectors.length > 0
496
+ ? fc.excludeSelectors
497
+ : config.china
498
+ ? CHINA_EXCLUDE_FAMILIES
499
+ : undefined;
500
+ const filteredModels = basePool
501
+ ? filterSelectableModels(basePool, {
502
+ selectors: fc?.selectors ?? undefined,
503
+ excludeSelectors: effectiveExclude,
504
+ })
505
+ : basePool;
506
+ // Pool a slot draws from: global constraints (roles null) constrain every
507
+ // slot; scoped constraints only the slots the scope roles map onto.
508
+ const poolForSlot = (slot: TierName): Model<Api>[] | null =>
509
+ scopeRoles === null || scopedSlots.has(slot) ? filteredModels : basePool;
350
510
  const selectorResults = {} as Record<Role, SelectorResult>;
351
511
  const roleModels = {} as Record<Role, string | undefined>;
352
512
  const roleConfigs = {} as Record<Role, string | undefined>;
353
513
  const roleKind = {} as Record<Role, RoleValue['kind']>;
354
- const planSet = mergePlanProviders(config.planProviders);
514
+ const planSet = mergePlanProviders(config.planProviders, config.planProvidersRemove);
515
+ // Speed-aware selection: undefined when disabled — zero DB IO, and the
516
+ // selector keeps the frozen W1 order byte-for-byte.
517
+ const speedAware = resolveSpeedAware(config.speedAware);
518
+ // ── W4 difficulty routing (design D3/D5) ──────────────────────
519
+ // `routing === undefined` keeps every map/value at its legacy identity —
520
+ // the zero-drift structure is "not entering" the new paths, not an
521
+ // equivalent re-derivation. Explicit command paths KEEP the session-band
522
+ // offsets (design D5: 沿用当前会话级 band) and skip only guard pinning and
523
+ // the band annotation (显式指令 > 守门;显式路径不做新分类).
524
+ // Offsets are SELECTOR-TARGET pre-corrections only: the role→slot mapping
525
+ // (buildRoleOverridePlan's input) stays structurally untouched — smol
526
+ // always consumes the coder pick, slow the top supplemental, so shifted
527
+ // targets flow through the existing slots.
528
+ // Selector-side offsets key on the OMP write-set alias (smol/default/
529
+ // slow/vision); opsx roles translate via ROLE_TO_OMP_ALIAS.
530
+ const routedTierFor = (role: string, ompRole: string | undefined, base: TierName): TierName => {
531
+ if (!routing || !ompRole) return base;
532
+ // The per-role map carries only this role's base tier, so a `skip`
533
+ // sentinel can never appear here — but the offset table's value type
534
+ // admits it, and falling back to the base keeps that safe.
535
+ const routed = applyBandOffset({ [ompRole]: base }, routing.band, { stallActive: routing.stallActive });
536
+ const value = routed[ompRole];
537
+ return value === 'skip' || value === undefined ? base : value;
538
+ };
539
+ // Data-layer annotations (design D9): `band=<band>` when routing drives
540
+ // the recompute, `stall+1` on the roles aliased to default while stall is
541
+ // active. Explicit paths suppress the band segment (no fresh
542
+ // classification).
543
+ const noteFor = (role: string): string | undefined => {
544
+ if (!routing) return undefined;
545
+ const parts: string[] = [];
546
+ if (!routing.explicit && config.difficultyRouting.enabled) parts.push(`band=${routing.band}`);
547
+ if (routing.stallActive && ROLE_TO_OMP_ALIAS[role] === 'default') parts.push('stall+1');
548
+ return parts.length > 0 ? parts.join(', ') : undefined;
549
+ };
355
550
  for (const role of OPSX_ROLES) {
356
551
  const value = config.roles[role];
357
552
  roleKind[role] = value.kind;
@@ -362,20 +557,31 @@ function computeSelectionSync(
362
557
  : (roleValueToModelString(value) ?? '?');
363
558
  const alias = `@${ROLE_TO_OMP_ALIAS[role]}`;
364
559
  if (value.kind === 'auto') {
365
- if (filteredModels && filteredModels.length > 0) {
560
+ // coder's pick feeds the mid slot, the other three feed high —
561
+ // the same consumed-slot mapping buildEffectiveConfig and the
562
+ // tierPicks assembly below use.
563
+ const pool = poolForSlot(role === 'coder' ? 'mid' : 'high');
564
+ if (pool && pool.length > 0) {
366
565
  const ompRole = ROLE_TO_OMP_ALIAS[role];
367
- const tier = (config.roleTiers[role]
566
+ const tier = routedTierFor(role, ompRole, (config.roleTiers[role]
368
567
  ?? (OMP_ROLE_TO_TIER as Record<string, TierName>)[ompRole]
369
- ?? 'mid') as TierName;
568
+ ?? 'mid') as TierName);
370
569
  const roleTiers = { ...config.roleTiers, [role]: tier };
371
570
  const result = selectAllRoles(
372
571
  [role],
373
- filteredModels,
572
+ pool,
374
573
  roleTiers,
375
574
  config.tierOverrides,
376
575
  health ?? undefined,
377
576
  config.modelAllowlist,
378
577
  planSet,
578
+ undefined,
579
+ config.paygLastResort,
580
+ config.providerAuto,
581
+ config.providerAutoTiers,
582
+ speedAware,
583
+ routing?.qualityPreference,
584
+ noteFor(role),
379
585
  )[role];
380
586
  selectorResults[role] = result;
381
587
  roleModels[role] = result.picked ? alias : undefined;
@@ -397,123 +603,118 @@ function computeSelectionSync(
397
603
  roleModels[role] = modelString?.includes('/') ? modelString : undefined;
398
604
  }
399
605
  }
400
- // change: pick-model-batch-all-roles — build the full-role session overlay:
401
- // every host-known role (10 built-in + custom) follows one tier's picked
402
- // model. mid/high reuse the opsx picks (coder/reviewer); tiny/low/top are
403
- // supplemental picks over the same filtered candidate set; vision picks
404
- // from image-capable candidates only. See design D2/D3.
606
+ // ── W4 continuity guard (design D5) ───────────────────────────
607
+ // Pin viable incumbents on auto recomputes — the pin outranks every
608
+ // tie-break; a switch needs a strict band upgrade at sufficient
609
+ // confidence, a stall escalation, or an incumbent hard-filtered out of
610
+ // the pool. Runs BEFORE the overlay plan so pinned picks flow into
611
+ // roleOverrides; explicit paths bypass (显式指令 > 守门).
612
+ if (routing?.guard && !routing.explicit) {
613
+ const incumbentResults = routing.guard.incumbent?.selectorResults ?? null;
614
+ for (const role of OPSX_ROLES) {
615
+ const prev = incumbentResults?.[role];
616
+ if (!prev?.picked) continue;
617
+ const cur = selectorResults[role];
618
+ const picked = prev.picked;
619
+ // Viability = the incumbent's selector still sits in the fresh
620
+ // candidate pool (zero registry queries — pool membership only).
621
+ const viable = cur.candidates.some((c) => c.model.provider === picked.provider && c.model.id === picked.id);
622
+ // Stall escalates the `default` alias only (reviewer/planner/
623
+ // proposal-reviewer); coder maps to smol and stays sticky.
624
+ const outcome = decideGuard({
625
+ enabled: true,
626
+ weight: routing.guard.weight,
627
+ explicit: false,
628
+ incumbent: prev,
629
+ incumbentViable: viable,
630
+ newBand: routing.band,
631
+ incumbentBand: routing.guard.priorBand ?? null,
632
+ confidence: routing.confidence,
633
+ stallEscalated: routing.stallActive && ROLE_TO_OMP_ALIAS[role] === 'default',
634
+ });
635
+ if (outcome.decision === 'pin' && outcome.pinned) {
636
+ selectorResults[role] = pinPriorPick(outcome.pinned, routing.band);
637
+ }
638
+ }
639
+ }
640
+ // change: pick-model-ux — minimal write-set session overlay {smol, default,
641
+ // slow, vision}: mid/high reuse the opsx picks (coder/reviewer), top is the
642
+ // sole supplemental pick (slow), vision picks from image-capable candidates
643
+ // only. Roles outside the set resolve through OMP native chains. See
644
+ // lib/model-roles.ts design D5.
405
645
  let roleOverrides: Record<string, string> = {};
406
646
  let skippedRoles: SkippedRole[] = [];
407
- if (filteredModels && filteredModels.length > 0) {
647
+ if (basePool && basePool.length > 0) {
408
648
  const pickString = (r: SelectorResult): string | undefined =>
409
649
  r.picked ? `${r.picked.provider}/${r.picked.id}` : undefined;
410
650
  const tierPicks: Partial<Record<TierName, string | undefined>> = {
651
+ // Guard pinning ran above: a pinned pick replaces the role result
652
+ // and therefore feeds the overlay payload (T4: application stays
653
+ // at the existing apply points — this only refreshes the cache).
411
654
  mid: pickString(selectorResults.coder),
412
655
  high: pickString(selectorResults.reviewer),
413
656
  };
414
- for (const supplementalTier of ['tiny', 'low', 'top'] as const) {
415
- const synthRole = `__tier_${supplementalTier}`;
657
+ const synthRole = '__tier_top';
658
+ const topPool = poolForSlot('top');
659
+ const topSlotTier: TierName = routing
660
+ ? applyBandOffset({ slow: 'top' }, routing.band, { stallActive: routing.stallActive }).slow as TierName
661
+ : 'top';
662
+ if (topPool && topPool.length > 0) {
416
663
  const result = selectModel({
417
664
  role: synthRole,
418
- models: filteredModels,
419
- roleTiers: { [synthRole]: supplementalTier },
665
+ models: topPool,
666
+ // This slot feeds the write-set `slow` role: its target shifts
667
+ // with the band (top−1 under simple etc., design D3), while the
668
+ // role→slot mapping itself stays fixed.
669
+ roleTiers: { [synthRole]: topSlotTier },
420
670
  tierOverrides: config.tierOverrides,
421
671
  health: health ?? undefined,
422
672
  allowlist: config.modelAllowlist,
423
673
  planProviders: planSet,
674
+ paygLastResort: config.paygLastResort,
675
+ providerAuto: config.providerAuto,
676
+ providerAutoTiers: config.providerAutoTiers,
677
+ speedAware,
678
+ qualityPreference: routing?.qualityPreference,
424
679
  });
425
- tierPicks[supplementalTier] = pickString(result);
680
+ tierPicks.top = pickString(result);
426
681
  }
427
682
  const capabilityPicks: Record<string, string | undefined> = {};
428
- const visionModels = filteredModels.filter((m) => ROLE_CAPABILITY.vision(m));
683
+ // vision scope gates its capability candidate set only — it never
684
+ // contributes a tier slot.
685
+ const visionSource = scopeRoles?.includes('vision') ? filteredModels : basePool;
686
+ const visionModels = (visionSource ?? []).filter((m) => ROLE_CAPABILITY.vision(m));
429
687
  if (visionModels.length > 0) {
430
688
  const visionResult = selectModel({
431
689
  role: 'vision',
432
690
  models: visionModels,
433
- roleTiers: { vision: 'high' },
691
+ // Write-set member: band offset shifts the in-pool preference
692
+ // (capability filter already ran — no incapable-model risk).
693
+ roleTiers: routing
694
+ ? applyBandOffset({ vision: 'high' } as Record<string, TierName>, routing.band, { stallActive: routing.stallActive }) as Record<string, TierName>
695
+ : { vision: 'high' },
434
696
  tierOverrides: config.tierOverrides,
435
697
  health: health ?? undefined,
436
698
  allowlist: config.modelAllowlist,
437
699
  planProviders: planSet,
700
+ paygLastResort: config.paygLastResort,
701
+ providerAuto: config.providerAuto,
702
+ providerAutoTiers: config.providerAutoTiers,
703
+ speedAware,
704
+ qualityPreference: routing?.qualityPreference,
438
705
  });
439
706
  capabilityPicks.vision = pickString(visionResult);
440
707
  }
441
- const plan = buildRoleOverridePlan(
442
- { knownRoleIds, ompRoleTiers: config.ompRoleTiers, tierPicks, capabilityPicks },
443
- (m) => getLogger().warn(m),
444
- );
708
+ const plan = buildRoleOverridePlan({ ompRoleTiers: config.ompRoleTiers, tierPicks, capabilityPicks });
445
709
  roleOverrides = plan.overrides;
446
710
  skippedRoles = plan.skipped;
447
711
  }
448
- return { selectorResults, roleModels, roleConfigs, roleKind, roleOverrides, skippedRoles };
449
- }
450
- function formatModelChoicesMarkdown(sel: SelectionContext, suffix: string = '', primaryModel?: Model<Api>): string {
451
- const rows: string[] = [];
452
- let totalModelCount = 0;
453
- if (primaryModel) {
454
- const score = scoreModelWithProvider(primaryModel.provider, primaryModel.id, primaryModel.name, undefined);
455
- rows.push(
456
- `| 🏠 **主会话** | **${primaryModel.provider}/${primaryModel.id}** | ${score.tier} | 当前活跃模型 (tier=${score.tier}) |`,
457
- );
458
- }
459
- for (const role of OPSX_ROLES) {
460
- const r = sel.selectorResults[role];
461
- if (!r) continue;
462
- totalModelCount += r.allCandidates.length;
463
- const icon = r.picked ? '✅' : '❌';
464
- const configLabel = sel.roleConfigs[role];
465
- const displayRole = configLabel ? `${role} (${configLabel})` : role;
466
- const model = r.picked ? `${r.picked.provider}/${r.picked.id}` : (sel.roleModels[role] ?? '(none)');
467
- const detail = r.pickedReason ?? r.decision;
468
- rows.push(`| ${icon} **${displayRole}** | **${model}** | ${r.expectedTier} | ${detail} |`);
469
- }
470
- if (rows.length === 0) return `*模型选择${suffix}: no data*`;
471
- return [
472
- '| 智能体 | 模型 | 期望档位 | 详情 |',
473
- '|---|---|---|---|',
474
- ...rows,
475
- '',
476
- `*${totalModelCount} 个 model 参与评分 · \`/pick-model refresh\` 强制重选*`,
477
- ].join('\n');
478
- }
479
-
480
- /** Format per-family per-tier model choices as a markdown table. */
481
- function formatFamilyTierMarkdown(sel: SelectionContext): string {
482
- // Group (provider, tier) → best model by reputation
483
- const byFamily = new Map<string, Map<string, { modelId: string; rep: number; rank: number }>>();
484
- for (const role of OPSX_ROLES) {
485
- const r = sel.selectorResults[role];
486
- if (!r) continue;
487
- for (const c of r.allCandidates) {
488
- if (c.exhausted) continue;
489
- const fam = c.model.provider;
490
- const tier = c.tier;
491
- if (!byFamily.has(fam)) byFamily.set(fam, new Map());
492
- const tierMap = byFamily.get(fam)!;
493
- const existing = tierMap.get(tier);
494
- if (!existing || c.reputation > existing.rep || (c.reputation === existing.rep && c.rank > existing.rank)) {
495
- tierMap.set(tier, { modelId: c.model.id, rep: c.reputation, rank: c.rank });
496
- }
497
- }
498
- }
499
-
500
- const rows: string[] = [];
501
- const families = [...byFamily.keys()].sort();
502
- for (const fam of families) {
503
- const tierMap = byFamily.get(fam)!;
504
- const parts: string[] = [];
505
- for (const tier of ['top', 'high', 'mid', 'low', 'tiny']) {
506
- const m = tierMap.get(tier);
507
- if (!m) continue;
508
- const shortId = m.modelId.length > 35 ? m.modelId.slice(0, 32) + '...' : m.modelId;
509
- parts.push(`${tier}→${shortId}`);
510
- }
511
- if (parts.length === 0) continue;
512
- rows.push(`**${fam}**: ${parts.join(' ')}`);
513
- }
514
- if (rows.length === 0) return '*No models available*';
515
- return rows.join('\n');
712
+ return { selectorResults, roleModels, roleConfigs, roleKind, roleOverrides, skippedRoles, paygLastResort: config.paygLastResort, routingBand: routing?.band };
516
713
  }
714
+ // change: pick-model-ux — choices/switch/refresh/reset output rendering lives
715
+ // in lib/pick-model-render.ts (shared compact block renderer); the former
716
+ // formatModelChoicesMarkdown / formatFamilyTierMarkdown markdown tables were
717
+ // migrated there.
517
718
 
518
719
  // ── process-wide usage recorder singleton ──────────────────────────
519
720
  // This IS a process-wide singleton: the harness rebinds the same evaluated
@@ -540,6 +741,161 @@ const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
540
741
  onDelta: mirrorSessionDelta,
541
742
  });
542
743
 
744
+ // ── adaptive provider concurrency (process-wide owner) ──────────────
745
+ // Ownership model (the same P0 invariant as plugins/omp-whip/index.ts): the
746
+ // host re-runs this factory once per in-process subagent binding, so an owner
747
+ // kept in the closure would be claimed by EVERY binding — each starting its
748
+ // own control loop, lease and state file against the same config root. The
749
+ // owner therefore lives at module scope (shared by every binding in this
750
+ // process, and re-imported fresh by isolated workers), and the session id
751
+ // captured at claim time is the identity gate for `session_shutdown`: without
752
+ // it, a subagent exiting would tear down the root's loop and release its lease.
753
+ interface OwnedConcurrency {
754
+ /** Session id captured when this process claimed the control loop. */
755
+ sessionId: string;
756
+ controller: ConcurrencyController;
757
+ }
758
+
759
+ let ownedConcurrency: OwnedConcurrency | null = null;
760
+
761
+ /** Test-only: reset the module-level control-loop owner (production never calls this). */
762
+ export function __resetOwnedConcurrencyForTests(): void {
763
+ ownedConcurrency = null;
764
+ }
765
+
766
+ /** Test-only: read the module-level control-loop owner (production never calls this). */
767
+ export function __getOwnedConcurrencyForTests(): OwnedConcurrency | null {
768
+ return ownedConcurrency;
769
+ }
770
+
771
+ /**
772
+ * Test-only seam (mirrors usage-poller's `_set*ForTest`): overrides the
773
+ * transport the assembly injects into the control loop. `undefined` derives it
774
+ * from `redis_*` config (production), `null` forces the Redis-free path so the
775
+ * file lease / degradation behavior is testable without a local Redis.
776
+ */
777
+ let concurrencyRedisPortForTests: ConcurrencyRedisPort | null | undefined;
778
+ export function _setConcurrencyRedisPortForTest(port: ConcurrencyRedisPort | null | undefined): void {
779
+ concurrencyRedisPortForTests = port;
780
+ }
781
+
782
+ /**
783
+ * Test-only seam: the home directory the assembly reads its **global**
784
+ * `opsx.yml` layer from (`<home>/.omp/agent/opsx.yml`). `undefined` = the real
785
+ * `homedir()` (production); a path makes the factory hermetic, so a suite that
786
+ * starts the loop cannot inherit whatever the developer happens to have set
787
+ * globally — `concurrency_enabled: false` there would otherwise silently disable
788
+ * every factory-level case.
789
+ */
790
+ let opsxGlobalHomeForTests: string | undefined;
791
+ export function _setOpsxGlobalHomeForTest(homeDir: string | undefined): void {
792
+ opsxGlobalHomeForTests = homeDir;
793
+ }
794
+
795
+ /** Process-stable lease owner id: `pid` + process start time (design D5). */
796
+ const PROCESS_OWNER = `${process.pid}:${Math.floor(Date.now() - process.uptime() * 1000)}`;
797
+
798
+ /**
799
+ * Keys declared under `providers.maxInFlightRequests` in the shared
800
+ * `config.yml`. Only key *presence* is read here — the writer owns value
801
+ * validation, so a key whose value is not a positive integer still enters the
802
+ * managed set (its baseline reads as「无现值」→ `concurrency_start`).
803
+ */
804
+ function readLimitKeys(configPath: string): string[] {
805
+ let text: string | null;
806
+ try {
807
+ text = nodeWriterFileSystem.readFile(configPath);
808
+ } catch {
809
+ return [];
810
+ }
811
+ if (text === null) return [];
812
+ try {
813
+ const limits = (YAML.parse(text) as { providers?: { maxInFlightRequests?: unknown } } | null)
814
+ ?.providers?.maxInFlightRequests;
815
+ if (!limits || typeof limits !== 'object' || Array.isArray(limits)) return [];
816
+ return Object.keys(limits as Record<string, unknown>);
817
+ } catch {
818
+ return [];
819
+ }
820
+ }
821
+
822
+ /** Map the resolved `concurrency_*` config onto the tuner's injected knobs. */
823
+ function tunerOptionsFromConfig(cfg: ResolvedOpsxConfig): TunerOptions {
824
+ return {
825
+ floor: cfg.concurrency_floor,
826
+ ceiling: cfg.concurrency_ceiling,
827
+ start: cfg.concurrency_start,
828
+ upMs: cfg.concurrency_up_ms,
829
+ downMs: cfg.concurrency_down_ms,
830
+ k: cfg.concurrency_latency_k,
831
+ minSamples: cfg.concurrency_min_samples,
832
+ queueWaitHighMs: cfg.concurrency_queue_wait_high_ms,
833
+ queueWaitWindowMs: cfg.concurrency_queue_wait_window_ms,
834
+ evalIntervalMs: cfg.concurrency_eval_interval_ms,
835
+ decayAfterMs: cfg.concurrency_decay_after_ms,
836
+ ewmaAlpha: TUNER_DEFAULTS.ewmaAlpha,
837
+ };
838
+ }
839
+
840
+ /** Assembly seams for the control loop; production passes host/plugin values, tests inject. */
841
+ export interface ConcurrencyAssemblyDeps {
842
+ /** Config root `config.yml` the writer reads/merges/writes. */
843
+ configPath: string;
844
+ /** Agent dir the log reader / `model_perf` path derive from. */
845
+ agentDir?: string;
846
+ /** Host settings port for the hot-apply (`settings.override`). */
847
+ settings?: SettingsPort | null;
848
+ /** Cross-process ① aggregation channel (`null` → local-only + warn). */
849
+ redis?: ConcurrencyRedisPort | null;
850
+ /** Warn sink (the host logger). */
851
+ warn: (message: string) => void;
852
+ /** Injected clock (tests). */
853
+ now?: () => number;
854
+ /** Lease owner id (defaults to this process's `pid:startTime`). */
855
+ owner?: string;
856
+ /** Managed-set override (tests). */
857
+ managedProviders?: readonly string[];
858
+ /** Lease factory seam (tests). */
859
+ leaseFactory?: ConcurrencyLeaseFactory;
860
+ }
861
+
862
+ /**
863
+ * Build the control loop for this config root, or `null` when it MUST NOT run:
864
+ * `concurrency_enabled: false` (spec「配置面与降级」: no setting write, no lease
865
+ * competition, no state-file write) or no managed provider to control. The
866
+ * managed set is `concurrency_providers` ∪ the shared `providers.
867
+ * maxInFlightRequests` keys (spec「受管集合」).
868
+ *
869
+ * Ports are wired here: the node fs port, the derived sqlite `model_perf` port
870
+ * and the file/Redis lease come from the controller's production defaults; the
871
+ * host settings `override` and the existing Redis client are injected.
872
+ */
873
+ export function createConcurrencyControllerIfEnabled(
874
+ cfg: ResolvedOpsxConfig,
875
+ deps: ConcurrencyAssemblyDeps,
876
+ ): ConcurrencyController | null {
877
+ if (!cfg.concurrency_enabled) return null;
878
+ const managedProviders = deps.managedProviders
879
+ ?? resolveManagedProviders(cfg.concurrency_providers, readLimitKeys(deps.configPath));
880
+ if (managedProviders.length === 0) return null;
881
+ return createConcurrencyController({
882
+ configPath: deps.configPath,
883
+ managedProviders,
884
+ tuner: tunerOptionsFromConfig(cfg),
885
+ now: deps.now ?? (() => Date.now()),
886
+ fs: createNodeFsPort(),
887
+ owner: deps.owner ?? PROCESS_OWNER,
888
+ ...(deps.agentDir ? { agentDir: deps.agentDir } : {}),
889
+ redis: deps.redis ?? null,
890
+ settings: deps.settings ?? null,
891
+ ceilingByProvider: cfg.concurrency_ceiling_by_provider,
892
+ warn: deps.warn,
893
+ leaseTtlMs: cfg.concurrency_lease_ttl_ms,
894
+ writeMinIntervalMs: cfg.concurrency_write_min_interval_ms,
895
+ ...(deps.leaseFactory ? { leaseFactory: deps.leaseFactory } : {}),
896
+ });
897
+ }
898
+
543
899
  // ── plugin entry ────────────────────────────────────────────────────
544
900
 
545
901
  export default (pi: ExtensionAPI) => {
@@ -558,11 +914,7 @@ export default (pi: ExtensionAPI) => {
558
914
  applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
559
915
  const directory = process.cwd();
560
916
  const warn = (msg: string) => pi.logger.warn(msg);
561
- // Roles known to the host at the moment of a (re)selection: computed per
562
- // call so custom roles appearing after startup are covered on
563
- // refresh/reset/selector/tick.
564
- const currentKnownRoleIds = (): string[] => safeGetKnownRoleIds(pi.pi?.settings);
565
- const opsxConfig = readOpsxSettings(directory, warn);
917
+ const opsxConfig = readOpsxSettings(directory, warn, opsxGlobalHomeForTests ?? homedir());
566
918
  // Static orchestration rules — built once, reused every turn for cache stability.
567
919
  const STATIC_PROMPT = buildStaticPrompt();
568
920
  let initialModel: { provider: string; id: string } | null = null;
@@ -585,27 +937,144 @@ export default (pi: ExtensionAPI) => {
585
937
  // and `/pick-model reset` restores it to opsxConfig.
586
938
  let familyConstraint: FamilyConstraint | null = null;
587
939
  let effectiveConfig: ResolvedOpsxConfig = opsxConfig;
940
+ // Session-start autoselect once-flag. Factory closure, not module-level:
941
+ // rebind instances (in-process task subagents) re-run the factory and each
942
+ // gets its own flag — the owner gate below is what actually excludes them.
943
+ let autoApplied = false;
944
+
945
+ // ── W4 difficulty routing state (change: difficulty-routed-selection) ──
946
+ // Factory closure, lazily keyed by session id (design D4: NOT hung on any
947
+ // session_start handler — that region belongs to the usage/autoselect
948
+ // owners). `band/confidence` come from classifyTaskSafe on the
949
+ // before_agent_start path; `stall` is the read-only sampling window fed
950
+ // by the tool_execution_start handler below.
951
+ let routingState: {
952
+ sessionId: string;
953
+ band: TaskBand;
954
+ confidence: number;
955
+ stallActive: boolean;
956
+ stall: StallRuntime | null;
957
+ } | null = null;
958
+
959
+ const routingStateFor = (sessionId: string) => {
960
+ if (!routingState || routingState.sessionId !== sessionId) {
961
+ routingState = {
962
+ sessionId,
963
+ band: 'standard',
964
+ confidence: 0,
965
+ stallActive: false,
966
+ stall: opsxConfig.stallEscalation.enabled ? createStallRuntime(opsxConfig.stallEscalation) : null,
967
+ };
968
+ }
969
+ return routingState;
970
+ };
971
+
972
+ /**
973
+ * Routing runtime handed to computeSelectionSync (design D8.5). All
974
+ * switches off + balanced → `undefined` (the zero-drift contract: the
975
+ * selection path never even sees a routing object). `explicit` marks the
976
+ * /pick-model command paths — guard bypassed, no fresh classification,
977
+ * band annotations suppressed; the session band still drives offsets.
978
+ */
979
+ const routingRuntimeFor = (ctx: ExtensionContext, explicit: boolean): RoutingRuntime | undefined => {
980
+ const qp = opsxConfig.qualityPreference;
981
+ const anyOn = opsxConfig.difficultyRouting.enabled
982
+ || opsxConfig.stallEscalation.enabled
983
+ || opsxConfig.continuityGuard.enabled
984
+ || qp !== 'balanced';
985
+ if (!anyOn) return undefined;
986
+ const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
987
+ return {
988
+ band: st.band,
989
+ confidence: st.confidence,
990
+ stallActive: st.stallActive,
991
+ explicit,
992
+ qualityPreference: qp,
993
+ // Incumbent read BEFORE the recompute reassigns `selection` — the
994
+ // pin decision compares against the previous context only.
995
+ guard: opsxConfig.continuityGuard.enabled && !explicit
996
+ ? { weight: opsxConfig.continuityGuard.weight, incumbent: selection, priorBand: selection?.routingBand ?? null }
997
+ : undefined,
998
+ };
999
+ };
1000
+
1001
+ // Same-account regional variant state (china-config): resolved fresh at
1002
+ // every recompute path from the live pool composition — no cache and
1003
+ // therefore no invalidation problem. The result feeds both
1004
+ // computeSelectionSync and the health key so the two cannot drift.
1005
+ // Judgment = variant-table membership + both sides in the effective pool
1006
+ // + strictly equal API keys (same-account proof; unequal/missing keys →
1007
+ // both doors coexist).
1008
+ const resolveVariantState = async (
1009
+ models: { provider: string; id: string }[] | null | undefined,
1010
+ getApiKey: ((provider: string) => Promise<string | undefined>) | undefined,
1011
+ reach: { unreachable?: Set<string>; availableByProvider?: Map<string, string[] | null> } = {},
1012
+ ): Promise<VariantCollapseResult> => {
1013
+ const idle: VariantCollapseResult = { collapsedProviders: new Set<string>(), dropped: null, kept: null };
1014
+ if (!models || models.length === 0) return idle;
1015
+ // Trigger condition evaluates on the EFFECTIVE pool (spec: 双变体同时
1016
+ // 在池才触发——reachability / 静态排除 / provider_models 收窄 /
1017
+ // allowlist 已滤掉一侧时不触发): the same provider-level filters
1018
+ // computeSelectionSync applies to basePool (minus the collapse
1019
+ // itself), plus the allowlist reduction. provider_models participates:
1020
+ // a kept side narrowed to zero must not count as "in pool", or the
1021
+ // collapse would drop the other side and leave the account unservable.
1022
+ const providerModelMap = Object.keys(opsxConfig.providerModels).length > 0
1023
+ ? new Map(Object.entries(opsxConfig.providerModels))
1024
+ : undefined;
1025
+ const providersInPool = new Set(
1026
+ filterSelectableModels(models, {
1027
+ blockedSelectors: regionBlockedModels,
1028
+ unreachableProviders: reach.unreachable,
1029
+ availableByProvider: reach.availableByProvider,
1030
+ excludedProviders: opsxConfig.excluded_providers,
1031
+ providerModels: providerModelMap,
1032
+ })
1033
+ .filter((m) => providerSurvivesAllowlist(m.provider, m.id, opsxConfig.modelAllowlist))
1034
+ .map((m) => m.provider),
1035
+ );
1036
+ return resolveVariantCollapse({
1037
+ china: opsxConfig.china,
1038
+ providersInPool,
1039
+ getApiKey: await resolveVariantKeyLookup(providersInPool, getApiKey),
1040
+ });
1041
+ };
588
1042
 
589
1043
  const buildSelectionHealthKey = (
590
1044
  health: Map<string, ProviderHealth> | null,
591
1045
  reach: { unreachable: Set<string>; availableByProvider: Map<string, string[] | null> },
592
1046
  fc: FamilyConstraint | null,
1047
+ collapsed: ReadonlySet<string> = new Set<string>(),
593
1048
  ): string => {
594
- return selectionHealthKeyFor(health, reach, fc, regionBlockedModels);
1049
+ return selectionHealthKeyFor(health, reach, fc, regionBlockedModels, collapsed);
595
1050
  };
596
1051
 
597
1052
  const formatFamilyConstraint = (fc: FamilyConstraint | null): string => {
598
- if (!fc) return '';
599
1053
  const parts: string[] = [];
600
- if (fc.selectors && fc.selectors.length > 0) parts.push(`selectors=${fc.selectors.join(',')}`);
601
- if (fc.excludeSelectors.length > 0) parts.push('china=on');
1054
+ if (fc?.selectors && fc.selectors.length > 0) parts.push(`selectors=${fc.selectors.join(',')}`);
1055
+ // china=on when the session constraint carries the exclusion OR the
1056
+ // config default is on — the config-derived segment renders even
1057
+ // without any selector constraint (spec: 配置派生 china=on 无约束也显示).
1058
+ if ((fc?.excludeSelectors.length ?? 0) > 0 || effectiveConfig.china) parts.push('china=on');
1059
+ if (fc?.roles && fc.roles.length > 0) parts.push(`scope=${fc.roles.join(',')}`);
602
1060
  return parts.join(' · ');
603
1061
  };
604
1062
 
605
- /** Copy of opsxConfig with every role forced to auto — covers pinned roles. */
606
- const buildEffectiveConfig = (): ResolvedOpsxConfig => {
1063
+ /**
1064
+ * Copy of opsxConfig with the constrained roles forced to auto — covers
1065
+ * pinned roles. `scopedSlots === null` = global constraint (every role
1066
+ * auto, status-quo shape); scoped constraints only force the roles whose
1067
+ * consumed tier slot is in scope (coder feeds mid, the rest high) —
1068
+ * out-of-scope roles keep their opsx.yml value, so synthPinnedResult
1069
+ * keeps their pin and auto roles draw the unconstrained pool. The no-arg
1070
+ * poller/assembly call sites keep the all-auto semantics.
1071
+ */
1072
+ const buildEffectiveConfig = (scopedSlots: ReadonlySet<TierName> | null = null): ResolvedOpsxConfig => {
607
1073
  const roles = {} as Record<Role, RoleValue>;
608
- for (const role of OPSX_ROLES) roles[role] = { kind: 'auto' };
1074
+ for (const role of OPSX_ROLES) {
1075
+ const slot: TierName = role === 'coder' ? 'mid' : 'high';
1076
+ roles[role] = scopedSlots === null || scopedSlots.has(slot) ? { kind: 'auto' } : opsxConfig.roles[role];
1077
+ }
609
1078
  return { ...opsxConfig, roles };
610
1079
  };
611
1080
  const startPollerFor = async (ctx: ExtensionContext, onTick?: () => void): Promise<void> => {
@@ -630,7 +1099,7 @@ export default (pi: ExtensionAPI) => {
630
1099
  });
631
1100
  };
632
1101
 
633
- const ensureSelection = (ctx: ExtensionContext, force = false): Promise<SelectionContext> => {
1102
+ const ensureSelection = (ctx: ExtensionContext, force = false, explicit = false): Promise<SelectionContext> => {
634
1103
  if (selectionPromise && !force) return selectionPromise;
635
1104
  selectionPromise = (async () => {
636
1105
  await startPollerFor(ctx);
@@ -644,7 +1113,11 @@ export default (pi: ExtensionAPI) => {
644
1113
  firstUnreachableProviders = reach.unreachable;
645
1114
  availableByProvider = reach.availableByProvider;
646
1115
  const health = getSharedUsage()?.health ?? null;
647
- const key = buildSelectionHealthKey(health, reach, familyConstraint);
1116
+ const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
1117
+ ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
1118
+ : undefined;
1119
+ const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
1120
+ const key = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
648
1121
  if (!force && selection && selectionHealthKey === key) return selection;
649
1122
  // Thread effectiveConfig + familyConstraint so family/pinned constraints
650
1123
  // survive this recompute (ensureSelection is one of the shared recompute paths).
@@ -652,11 +1125,13 @@ export default (pi: ExtensionAPI) => {
652
1125
  effectiveConfig,
653
1126
  models,
654
1127
  health,
655
- currentKnownRoleIds(),
656
1128
  regionBlockedModels,
657
1129
  reach.unreachable,
658
1130
  reach.availableByProvider,
659
1131
  familyConstraint,
1132
+ collapsed,
1133
+ warn,
1134
+ routingRuntimeFor(ctx, explicit),
660
1135
  );
661
1136
  selection = sel;
662
1137
  selectionHealthKey = key;
@@ -688,6 +1163,35 @@ export default (pi: ExtensionAPI) => {
688
1163
  // would be overwritten by whichever instance starts last.
689
1164
  let recordsOwnUsage = false;
690
1165
 
1166
+ // ── adaptive provider concurrency: host seams ─────────────────
1167
+ /** Host settings port for the control loop's hot-apply (narrowed to `override`). */
1168
+ const hostSettingsPort = (): SettingsPort | null => {
1169
+ const settings = pi.pi?.settings as unknown as { override?: unknown } | undefined;
1170
+ return settings && typeof settings.override === 'function' ? (settings as SettingsPort) : null;
1171
+ };
1172
+ /** Config root + agent dir for the control loop (host settings, else `~/.omp/agent`). */
1173
+ const resolveConcurrencyPaths = (): { agentDir: string; configPath: string } => {
1174
+ let agentDir = '';
1175
+ try {
1176
+ agentDir = (pi.pi?.settings as { getAgentDir?: () => string } | undefined)?.getAgentDir?.() ?? '';
1177
+ } catch {
1178
+ agentDir = '';
1179
+ }
1180
+ if (!agentDir) agentDir = join(homedir(), '.omp', 'agent');
1181
+ return { agentDir, configPath: resolveGlobalConfigPath(agentDir) ?? join(agentDir, 'config.yml') };
1182
+ };
1183
+ /** The existing Redis channel as the ① aggregation transport (no new command surface). */
1184
+ const aggregationRedisPort = (cfg: ResolvedOpsxConfig): ConcurrencyRedisPort | null => {
1185
+ if (!cfg.redis_enabled) return null;
1186
+ const client = new RedisClient({
1187
+ host: cfg.redis_host,
1188
+ port: cfg.redis_port,
1189
+ connectTimeoutMs: cfg.redis_connect_timeout_ms,
1190
+ commandTimeoutMs: cfg.redis_command_timeout_ms,
1191
+ });
1192
+ return { pipeline: (commands) => client.pipeline(commands) };
1193
+ };
1194
+
691
1195
  // ── shared usage poller + widget ──────────────────────────────
692
1196
  pi.on('session_start', async (_event, ctx) => {
693
1197
  try {
@@ -821,8 +1325,23 @@ export default (pi: ExtensionAPI) => {
821
1325
  // Last unix second already sampled by onTick (per session). Drives
822
1326
  // the catch-up loop that backfills every skipped second with zeros.
823
1327
  let lastSampledSec: number | undefined;
824
- const render = () => {
825
- const { reports, placeholderIds, active, show } = resolveActiveProviders();
1328
+ const render = async () => {
1329
+ const resolved = resolveActiveProviders();
1330
+ // Same-account regional collapse on the usage bar (china-config):
1331
+ // a collapsed side is a duplicate door into the quota the kept
1332
+ // side already displays, so dropping its row loses nothing.
1333
+ // In-pool for usage = usage data or credentials present (the
1334
+ // `active` union) — model candidates are not required. The
1335
+ // strict key-equality gate matches the pool path: unequal or
1336
+ // missing keys keep BOTH rows (labels 智谱 / Z.AI distinguish).
1337
+ const activeSet = new Set(resolved.active);
1338
+ const collapsed = resolveVariantCollapse({
1339
+ china: opsxConfig.china,
1340
+ providersInPool: activeSet,
1341
+ getApiKey: await resolveVariantKeyLookup(activeSet, getApiKey),
1342
+ }).collapsedProviders;
1343
+ const { reports, placeholderIds, active } = collapseUsageView(resolved.reports, resolved.placeholderIds, resolved.active, collapsed);
1344
+ const show = resolved.show;
826
1345
  const state = getSharedUsage();
827
1346
  const loadingProviders = state?.loadingProviders ?? new Set<string>();
828
1347
  if (!show) {
@@ -852,14 +1371,28 @@ export default (pi: ExtensionAPI) => {
852
1371
  for (const p of active) {
853
1372
  consumptionTracks.set(p, { samples: getConsumptionSamples(p) });
854
1373
  }
1374
+ // Adaptive-concurrency inline tokens: the controller's per-provider
1375
+ // view (`stateFor`) rendered as `L/ceiling` right after the provider
1376
+ // name on BOTH render paths. Providers the controller does not manage
1377
+ // stay absent from the map — and an empty map keeps the widget
1378
+ // byte-identical to the pre-token output.
1379
+ const concurrencyViews = new Map<string, ConcurrencyInlineView>();
1380
+ if (ownedConcurrency) {
1381
+ const degraded = ownedConcurrency.controller.leaseMode() === 'readonly';
1382
+ for (const p of active) {
1383
+ const view = ownedConcurrency.controller.stateFor(p);
1384
+ if (view) concurrencyViews.set(p, { limit: view.limit, ceiling: view.ceiling, degraded });
1385
+ }
1386
+ }
1387
+ const inlineConcurrency = concurrencyViews.size > 0 ? concurrencyViews : undefined;
855
1388
  if (ctx.hasUI) {
856
- ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders()), {
1389
+ ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders(), inlineConcurrency), {
857
1390
  placement: 'belowEditor',
858
1391
  });
859
1392
  } else {
860
1393
  if (ctx.ui) {
861
1394
  const cols = (process.stdout.columns ?? 120) - 3;
862
- const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders());
1395
+ const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders(), inlineConcurrency);
863
1396
  ctx.ui.setWidget(key, lines.length > 0 ? lines : undefined, { placement: 'belowEditor' });
864
1397
  }
865
1398
  }
@@ -870,8 +1403,41 @@ export default (pi: ExtensionAPI) => {
870
1403
  // subagent via cache backflow), and re-renders the widget. It never
871
1404
  // recomputes a selection, never applies overrides, never setModel.
872
1405
  const cfg = buildEffectiveConfig();
1406
+ // ── claim the process-wide adaptive-concurrency control loop ──
1407
+ // Only the owning session starts it (same rule as the render tick
1408
+ // above), and the module-level `ownedConcurrency` gate keeps a
1409
+ // subagent binding — or a re-entrant session_start — from starting a
1410
+ // second loop and lease against the same config root. The
1411
+ // `concurrency_enabled` check also short-circuits here so the
1412
+ // disabled path allocates nothing (no transport, no config read).
1413
+ if (claimsOwnership && ownedConcurrency === null && cfg.concurrency_enabled) {
1414
+ const paths = resolveConcurrencyPaths();
1415
+ const controller = createConcurrencyControllerIfEnabled(cfg, {
1416
+ configPath: paths.configPath,
1417
+ agentDir: paths.agentDir,
1418
+ settings: hostSettingsPort(),
1419
+ redis: concurrencyRedisPortForTests !== undefined ? concurrencyRedisPortForTests : aggregationRedisPort(cfg),
1420
+ warn,
1421
+ });
1422
+ if (controller) {
1423
+ ownedConcurrency = {
1424
+ sessionId: ctx.sessionManager?.getSessionId?.() ?? '',
1425
+ controller,
1426
+ };
1427
+ }
1428
+ }
873
1429
  const onTick = () => {
874
- // Fire-and-forget status probe: do not block the tick.
1430
+ // Adaptive concurrency rides the SAME render tick — it owns no
1431
+ // timer; the controller's absolute-time gate (`concurrency_eval_
1432
+ // interval_ms`) decides whether this tick evaluates.
1433
+ const controller = ownedConcurrency?.controller;
1434
+ if (controller) {
1435
+ void controller.onTick().catch((e) => warn(`[omp-opsx-addon] concurrency tick error: ${e}`));
1436
+ }
1437
+ // Fire-and-forget status probe: do not block the tick. Deliberately
1438
+ // NOT filtered by the regional collapse (china-config): probing the
1439
+ // collapsed side keeps its reachability record fresh, so the
1440
+ // selection-pool premise of the collapse never rides stale data.
875
1441
  const { placeholderIds, active } = resolveActiveProviders();
876
1442
  let models: { id: string; provider: string; baseUrl?: string }[] = [];
877
1443
  try {
@@ -897,7 +1463,7 @@ export default (pi: ExtensionAPI) => {
897
1463
  timeoutMs: cfg.probe_timeout_ms,
898
1464
  excluded: cfg.excluded_providers,
899
1465
  },
900
- }).then((written) => { if (written > 0) render(); }).catch(() => {});
1466
+ }).then((written) => { if (written > 0) void render().catch(() => {}); }).catch(() => {});
901
1467
 
902
1468
  sessionUsageRecorder.flush();
903
1469
  // Waveform source = per-second consumption sequence (timestamped
@@ -923,7 +1489,7 @@ export default (pi: ExtensionAPI) => {
923
1489
  }
924
1490
  }
925
1491
  lastSampledSec = lastSec;
926
- render();
1492
+ void render().catch((e) => warn(`[omp-opsx-addon] usage render error: ${e}`));
927
1493
  };
928
1494
  await startSharedPoller(auth, DIRECT_FETCHERS, getApiKey, {
929
1495
  onTick: claimsOwnership ? onTick : undefined,
@@ -945,10 +1511,10 @@ export default (pi: ExtensionAPI) => {
945
1511
  // exposes no `signal`, so per-session abort cleanup never fires —
946
1512
  // ownership above, not teardown hooks, keeps the singletons sane.)
947
1513
  if (activeResizeHandler) process.stdout.off('resize', activeResizeHandler);
948
- const onResize = () => render();
1514
+ const onResize = () => void render().catch(() => {});
949
1515
  activeResizeHandler = onResize;
950
1516
  process.stdout.on('resize', onResize);
951
- render();
1517
+ void render().catch((e) => warn(`[omp-opsx-addon] usage render error: ${e}`));
952
1518
  } catch (e) {
953
1519
  warn(`[omp-opsx-addon] usage poller error: ${e}`);
954
1520
  }
@@ -1046,25 +1612,76 @@ export default (pi: ExtensionAPI) => {
1046
1612
  }
1047
1613
  }
1048
1614
  }
1615
+ // The control loop belongs to ONE session: only the owner's own exit
1616
+ // stops it. A subagent's shutdown MUST NOT tear down the root's loop
1617
+ // and release its lease (identity gate, see `ownedConcurrency`).
1618
+ if (ownedConcurrency && sid === ownedConcurrency.sessionId) {
1619
+ const owned = ownedConcurrency;
1620
+ ownedConcurrency = null;
1621
+ try {
1622
+ await owned.controller.shutdown();
1623
+ } catch (e) {
1624
+ warn(`[omp-opsx-addon] concurrency shutdown error: ${e}`);
1625
+ }
1626
+ }
1049
1627
  } catch {
1050
1628
  /* best-effort */
1051
1629
  }
1052
1630
  });
1053
1631
  // ── session lifecycle ─────────────────────────────────────────
1054
- // Pick-model is manual: `/pick-model refresh`. Session start does not pick or setModel.
1632
+ // Pick-model stays manual (`/pick-model refresh`); with autoselect_on_start
1633
+ // (default true) the session_start handler below additionally applies the
1634
+ // configured selection once at startup, so it already holds for turn one.
1055
1635
  const changeScanner = createCachedScanner(30_000);
1056
1636
 
1637
+ // ── W4 routing segment (change: difficulty-routed-selection) ──
1638
+ // The gate wraps ONLY this segment: the handler's existing body below
1639
+ // (systemPrompt injection + change scanning) runs unchanged under every
1640
+ // switch combination — gating never retires the whole handler (P1-1).
1641
+ // Both gates mirror the existing instance rules: the feature switches
1642
+ // (both off → straight out, zero behavior) and the main-instance rule
1643
+ // (subagent bindings never classify or sample). The band/stall comparison
1644
+ // decides whether one force recompute happens this turn; otherwise the
1645
+ // existing health-key caching applies untouched.
1646
+ const runRoutingSegment = async (event: { prompt: string }, ctx: ExtensionContext): Promise<void> => {
1647
+ if (!opsxConfig.difficultyRouting.enabled && !opsxConfig.stallEscalation.enabled) return;
1648
+ if (!(ctx.hasUI || !hasTickOwner())) return;
1649
+ const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
1650
+ const classification = opsxConfig.difficultyRouting.enabled
1651
+ ? classifyTaskSafe(event.prompt)
1652
+ : { band: 'standard' as TaskBand, confidence: 0 };
1653
+ // Stall episode evaluation for this turn (design D4: anchored on the
1654
+ // newest signature, escalated latches once per episode). The window
1655
+ // content is maintained by the tool_execution_start handler below.
1656
+ let stallActive = false;
1657
+ if (opsxConfig.stallEscalation.enabled && st.stall) {
1658
+ stallActive = evaluateStall(st.stall).active;
1659
+ }
1660
+ const prevBand = st.band;
1661
+ const prevStallActive = st.stallActive;
1662
+ st.band = classification.band;
1663
+ st.confidence = classification.confidence;
1664
+ st.stallActive = stallActive;
1665
+ const bandMoved = opsxConfig.difficultyRouting.enabled && prevBand !== classification.band;
1666
+ const stallMoved = opsxConfig.stallEscalation.enabled && prevStallActive !== stallActive;
1667
+ if (bandMoved || stallMoved) await ensureSelection(ctx, true);
1668
+ };
1669
+
1057
1670
  pi.on('before_agent_start', async (event, ctx) => {
1058
1671
  try {
1672
+ // Best-effort isolation (review P2): the segment's force-recompute
1673
+ // awaits poller/Redis and cache-fs paths absent from this handler
1674
+ // pre-W4; a rejection must never reach the outer catch and strip
1675
+ // STATIC_PROMPT + the change-scan segment for the turn.
1676
+ try {
1677
+ await runRoutingSegment(event, ctx);
1678
+ } catch (segErr) {
1679
+ warn(`[omp-opsx-addon] routing segment error: ${segErr}`);
1680
+ }
1059
1681
  // This fallback call is intentionally NOT threaded with familyConstraint:
1060
1682
  // it prefers the cached `selection`, and the fallback passes models=null so
1061
1683
  // filterSelectableModels' family branch never runs — it is not a reselect path.
1062
- const sel = selection ?? computeSelectionSync(
1063
- opsxConfig,
1064
- null,
1065
- null,
1066
- safeGetKnownRoleIds(pi.pi?.settings),
1067
- );
1684
+ const sel = selection ?? computeSelectionSync(opsxConfig, null, null);
1068
1685
  const changes = await changeScanner.scanChanges(directory, 'doing');
1069
1686
  // Layered injection: STATIC_PROMPT is byte-identical every turn → prefix cache
1070
1687
  // hit for [omp-base + skills + static]. Only the dynamic tail re-prefills.
@@ -1087,11 +1704,41 @@ export default (pi: ExtensionAPI) => {
1087
1704
  frontmatter('planner'),
1088
1705
  frontmatter('proposal-reviewer'),
1089
1706
  );
1090
- warn('[omp-opsx-addon] agents ready (pick-model idle until /pick-model refresh)');
1707
+ warn('[omp-opsx-addon] agents ready (selection auto-applies at session start unless autoselect_on_start: false; /pick-model to re-run)');
1091
1708
  } catch (e) {
1092
1709
  warn('[omp-opsx-addon] installAgents error: ' + e);
1093
1710
  }
1094
1711
  });
1712
+
1713
+ // Session-start autoselect: apply the configured selection once so the
1714
+ // first turn already follows it — role overrides through the same apply
1715
+ // path as /pick-model, and the main session moved to the reviewer pick
1716
+ // (no-op when already equal; undefined pick → skip). The once-flag is also
1717
+ // what keeps a later in-session /model switch authoritative: this path
1718
+ // never runs again for the instance, so nothing rewrites the user's
1719
+ // choice. Owner gate before the once-flag: subagent bindings re-run the
1720
+ // factory with independent closures (a closure once-flag cannot see
1721
+ // them), so the process-level owner rule decides — same rule as the
1722
+ // usage/render claim in the first handler. Awaited, never fire-and-forget:
1723
+ // a detached apply would race the user's first prompt onto the native
1724
+ // model. Pins (opsx.yml agents.*) bypass selection via synthPinnedResult
1725
+ // and are never touched here.
1726
+ pi.on('session_start', async (_event, ctx) => {
1727
+ if (!opsxConfig.autoselectOnStart) return;
1728
+ if (!(ctx.hasUI || !hasTickOwner())) return;
1729
+ if (autoApplied) return;
1730
+ autoApplied = true;
1731
+ try {
1732
+ const sel = await ensureSelection(ctx);
1733
+ applyRoleModel(sel);
1734
+ const picked = sel.selectorResults.reviewer?.picked;
1735
+ if (picked && ctx.model && `${ctx.model.provider}/${ctx.model.id}` !== `${picked.provider}/${picked.id}`) {
1736
+ await pi.setModel(picked);
1737
+ }
1738
+ } catch (e) {
1739
+ warn(`[omp-opsx-addon] session-start autoselect error: ${e}`);
1740
+ }
1741
+ });
1095
1742
  // ── Diagnostic-only error path (429/403; never auto-switches) ─
1096
1743
  const diagnoseProviderError = async (ctx: ExtensionContext, source: string, errorType: '429' | '403'): Promise<void> => {
1097
1744
  const currentModel = ctx.model;
@@ -1105,6 +1752,10 @@ export default (pi: ExtensionAPI) => {
1105
1752
  // without a usage fetcher — record it in the process-local binary status
1106
1753
  // so the placeholder column reads 耗尽 (see lib/provider-status.ts).
1107
1754
  markExhausted(canonical);
1755
+ // Adaptive-concurrency signal ①: hand the observed explicit failure to
1756
+ // the control loop as a COUNTED event only. The diagnosis text and the
1757
+ // "never auto-switch provider" policy below are unchanged (task 6.5).
1758
+ ownedConcurrency?.controller.noteExplicitFailure(canonical, errorType);
1108
1759
  // Force-refresh just this provider (bypassing the 10s floor) so the
1109
1760
  // diagnosis reflects current quota.
1110
1761
  if (auth) await pokeProvider(auth, DIRECT_FETCHERS, getApiKey, { providers: [canonical], force: true });
@@ -1148,6 +1799,44 @@ export default (pi: ExtensionAPI) => {
1148
1799
  warn(`[omp-opsx-addon] tool_execution_end error: ${e}`);
1149
1800
  }
1150
1801
  });
1802
+ // ── W4 stall sampling (change: difficulty-routed-selection; read-only) ──
1803
+ // Freshly registered handlers (unlike before_agent_start, which predates
1804
+ // W4): the first-line switch gate IS the whole disabled behavior — zero
1805
+ // sampling, zero state writes. Main-instance gate mirrors autoselect:
1806
+ // subagent bindings never sample (design D4). The window only shapes the
1807
+ // NEXT selection recompute's inputs; nothing here switches a live model
1808
+ // and the 429/403 diagnostics above are untouched (T4).
1809
+ pi.on('tool_execution_start', async (event, ctx) => {
1810
+ if (!opsxConfig.stallEscalation.enabled) return;
1811
+ if (!(ctx.hasUI || !hasTickOwner())) return;
1812
+ try {
1813
+ const sessionId = ctx.sessionManager?.getSessionId?.() ?? '';
1814
+ const st = routingStateFor(sessionId);
1815
+ if (!st.stall) return;
1816
+ recordToolCall(st.stall, sessionId, toolSignature(event.toolName, event.args));
1817
+ } catch {
1818
+ /* stall sampling is best-effort; stay silent */
1819
+ }
1820
+ });
1821
+ pi.on('turn_end', async (_event, ctx) => {
1822
+ if (!opsxConfig.stallEscalation.enabled) return;
1823
+ if (!(ctx.hasUI || !hasTickOwner())) return;
1824
+ try {
1825
+ const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
1826
+ if (!st.stall) return;
1827
+ // Transcription fallback (design D4): the event stream is the
1828
+ // primary source; when it delivered nothing for this session
1829
+ // (process reload), rebuild the near window from the read-only
1830
+ // transcript. Empty/corrupt entries idle the feature — never a
1831
+ // downgrade, no warn noise into the selection path.
1832
+ if (st.stall.signatures.length === 0) {
1833
+ rebuildFromEntries(st.stall, ctx.sessionManager.getEntries());
1834
+ }
1835
+ evaluateStall(st.stall);
1836
+ } catch {
1837
+ /* stall evaluation is best-effort; stay silent */
1838
+ }
1839
+ });
1151
1840
  pi.on('input', async (event) => {
1152
1841
  const match = event.text.match(/^@(coder|reviewer|planner|proposal-reviewer|code-reviewer)\b(.*)$/i);
1153
1842
  if (!match) return;
@@ -1165,12 +1854,13 @@ export default (pi: ExtensionAPI) => {
1165
1854
 
1166
1855
  pi.setLabel('pick-model', '模型选择');
1167
1856
  pi.registerCommand('pick-model', {
1168
- description: '模型管理:choices 展示 / refresh 重选 / update 更新档位 / <selector> [--china] 按选择器约束(裸词·glob·provider/id 双段,如 zhipu*/glm*)/ reset 清除约束',
1857
+ description: '模型管理:choices 展示 / refresh 重选 / update 更新档位 / [roleList:]<selector> [--china] 按 selector 约束(可 role 定向,如 smol:glm*;词表 smol/default/…+agent 别名/all;裸词·glob·provider/id 双段)/ reset 清除约束',
1169
1858
  handler: async (args: string, ctx: ExtensionCommandContext) => {
1170
1859
  try {
1860
+ const symbols = resolveSymbols(pi.pi?.settings);
1171
1861
  const parsed = parseAutoModelArgs(args);
1172
1862
  if (parsed.kind === 'error') {
1173
- pi.sendMessage({ customType: 'pick-model', content: `❌ ${parsed.message}`, display: true }, { triggerTurn: false });
1863
+ pi.sendMessage({ customType: 'pick-model', content: `${symbols.error} ${parsed.message}`, display: true }, { triggerTurn: false });
1174
1864
  return;
1175
1865
  }
1176
1866
 
@@ -1178,8 +1868,8 @@ export default (pi: ExtensionAPI) => {
1178
1868
  if (parsed.kind === 'update') {
1179
1869
  const result = updateTiers();
1180
1870
  const msg = result.ok
1181
- ? `✅ Tiers data updated: ${result.count} entries`
1182
- : `❌ Update failed: ${result.error}`;
1871
+ ? `${symbols.success} Tiers data updated: ${result.count} entries`
1872
+ : `${symbols.error} Update failed: ${result.error}`;
1183
1873
  pi.sendMessage({ customType: 'pick-model', content: msg, display: true }, { triggerTurn: false });
1184
1874
  return;
1185
1875
  }
@@ -1196,15 +1886,20 @@ export default (pi: ExtensionAPI) => {
1196
1886
  firstUnreachableProviders = reach.unreachable;
1197
1887
  availableByProvider = reach.availableByProvider;
1198
1888
  const health = getSharedUsage()?.health ?? null;
1889
+ const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
1199
1890
  const freshSel = computeSelectionSync(
1200
1891
  effectiveConfig,
1201
1892
  models,
1202
1893
  health,
1203
- currentKnownRoleIds(),
1204
1894
  regionBlockedModels,
1205
1895
  reach.unreachable,
1206
1896
  reach.availableByProvider,
1207
1897
  familyConstraint,
1898
+ collapsed,
1899
+ warn,
1900
+ // /pick-model refresh: explicit path (design D5) —
1901
+ // guard bypassed, no fresh classification.
1902
+ routingRuntimeFor(ctx, true),
1208
1903
  );
1209
1904
  const changes: string[] = [];
1210
1905
  // Check primary session model
@@ -1215,7 +1910,12 @@ export default (pi: ExtensionAPI) => {
1215
1910
  const primaryBest = primaryResult?.candidates[0];
1216
1911
  if (primaryBest && `${currentModel.provider}/${currentModel.id}` !== `${primaryBest.model.provider}/${primaryBest.model.id}`) {
1217
1912
  await pi.setModel(primaryBest.model);
1218
- changes.push(`🏠 主会话 → ${primaryBest.model.provider}/${primaryBest.model.id}`);
1913
+ changes.push(formatChangeLine({
1914
+ name: '主会话',
1915
+ to: `${primaryBest.model.provider}/${primaryBest.model.id}`,
1916
+ coverage: coverageLabel(primaryBest.coverageClass),
1917
+ tier: tierLabel(primaryBest.tier),
1918
+ }));
1219
1919
  }
1220
1920
  }
1221
1921
  // Check all agent models
@@ -1229,19 +1929,28 @@ export default (pi: ExtensionAPI) => {
1229
1929
  if (roleCfg.kind === 'model' || roleCfg.kind === 'provider-model') {
1230
1930
  const pinned = roleValueToModelString(roleCfg);
1231
1931
  if (pinned && pinned !== pickedSel) {
1232
- changes.push(`${agent} pinned ${pinned} → auto-pick ${pickedSel}`);
1932
+ changes.push(formatChangeLine({
1933
+ name: `${agent}(已指定 ${pinned})`,
1934
+ to: pickedSel,
1935
+ ...annotationOfPicked(result, picked),
1936
+ kind: roleKindLabel(freshSel.roleKind[role]),
1937
+ }));
1233
1938
  }
1234
1939
  } else {
1235
- changes.push(`${agent} → ${pickedSel}`);
1940
+ changes.push(formatChangeLine({
1941
+ name: agent,
1942
+ to: pickedSel,
1943
+ ...annotationOfPicked(result, picked),
1944
+ kind: roleKindLabel(freshSel.roleKind[role]),
1945
+ }));
1236
1946
  }
1237
1947
  }
1238
1948
  selection = freshSel;
1239
1949
  applyRoleModel(freshSel);
1240
- if (changes.length > 0) {
1241
- pi.sendMessage({ customType: 'pick-model', content: `✅ 模型已刷新:\n${changes.join('\n')}`, display: true }, { triggerTurn: false });
1242
- } else {
1243
- pi.sendMessage({ customType: 'pick-model', content: '✅ 所有模型状态正常,无需切换', display: true }, { triggerTurn: false });
1244
- }
1950
+ const content = changes.length > 0
1951
+ ? renderPickModelReport('refresh', { action: '已刷新', constraint: formatFamilyConstraint(familyConstraint) || undefined, detail: [changes.join('\n')] }, symbols)
1952
+ : renderPickModelReport('refresh', { action: '已刷新 · 所有模型状态正常,无需切换' }, symbols);
1953
+ pi.sendMessage({ customType: 'pick-model', content, display: true }, { triggerTurn: false });
1245
1954
  return;
1246
1955
  }
1247
1956
 
@@ -1259,15 +1968,20 @@ export default (pi: ExtensionAPI) => {
1259
1968
  firstUnreachableProviders = reach.unreachable;
1260
1969
  availableByProvider = reach.availableByProvider;
1261
1970
  const health = getSharedUsage()?.health ?? null;
1971
+ const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
1262
1972
  const sel = computeSelectionSync(
1263
1973
  effectiveConfig,
1264
1974
  models,
1265
1975
  health,
1266
- currentKnownRoleIds(),
1267
1976
  regionBlockedModels,
1268
1977
  reach.unreachable,
1269
1978
  reach.availableByProvider,
1270
1979
  familyConstraint,
1980
+ collapsed,
1981
+ warn,
1982
+ // /pick-model reset: explicit path (design D5) —
1983
+ // guard bypassed, no fresh classification.
1984
+ routingRuntimeFor(ctx, true),
1271
1985
  );
1272
1986
  const changes: string[] = [];
1273
1987
  const currentModel = ctx.model;
@@ -1275,7 +1989,12 @@ export default (pi: ExtensionAPI) => {
1275
1989
  if (newPrimary) {
1276
1990
  const newSel = `${newPrimary.provider}/${newPrimary.id}`;
1277
1991
  const oldSel = currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined;
1278
- changes.push(oldSel && oldSel !== newSel ? `🏠 主会话 ${oldSel} → ${newSel}` : `🏠 主会话 → ${newSel}`);
1992
+ changes.push(formatChangeLine({
1993
+ name: '主会话',
1994
+ from: oldSel && oldSel !== newSel ? oldSel : undefined,
1995
+ to: newSel,
1996
+ ...annotationOfPicked(sel.selectorResults.reviewer, newPrimary),
1997
+ }));
1279
1998
  }
1280
1999
  for (const role of OPSX_ROLES) {
1281
2000
  const picked = sel.selectorResults[role]?.picked;
@@ -1283,17 +2002,24 @@ export default (pi: ExtensionAPI) => {
1283
2002
  const newSel = `${picked.provider}/${picked.id}`;
1284
2003
  const prev = selection?.selectorResults[role]?.picked;
1285
2004
  const oldSel = prev ? `${prev.provider}/${prev.id}` : undefined;
1286
- changes.push(`${AGENT_MAP[role]} ${oldSel ?? '(none)'} → ${newSel}`);
2005
+ changes.push(formatChangeLine({
2006
+ name: AGENT_MAP[role],
2007
+ from: oldSel ?? '(none)',
2008
+ to: newSel,
2009
+ ...annotationOfPicked(sel.selectorResults[role], picked),
2010
+ kind: roleKindLabel(sel.roleKind[role]),
2011
+ }));
1287
2012
  }
1288
2013
  if (newPrimary) await pi.setModel(newPrimary);
1289
2014
  selection = sel;
1290
- selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
2015
+ selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
1291
2016
  applyRoleModel(sel);
1292
2017
  pi.sendMessage({
1293
2018
  customType: 'pick-model',
1294
- content: changes.length > 0
1295
- ? `✅ 已清除 selector 约束,恢复默认选择:\n${changes.join('\n')}`
1296
- : `✅ 已清除 selector 约束,恢复默认选择(无变化)`,
2019
+ content: renderPickModelReport('reset', {
2020
+ action: changes.length > 0 ? '已清除 selector 约束,恢复默认选择' : '已清除 selector 约束,恢复默认选择(无变化)',
2021
+ detail: changes.length > 0 ? [changes.join('\n')] : undefined,
2022
+ }, symbols),
1297
2023
  display: true,
1298
2024
  }, { triggerTurn: false });
1299
2025
  return;
@@ -1312,6 +2038,24 @@ export default (pi: ExtensionAPI) => {
1312
2038
  availableByProvider = reach.availableByProvider;
1313
2039
  const health = getSharedUsage()?.health ?? null;
1314
2040
  const excludeSelectors = parsed.china ? CHINA_EXCLUDE_FAMILIES : [];
2041
+ const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
2042
+
2043
+ // Fail fast on an inexpressible scope (model_role_tiers skip
2044
+ // sentinel) BEFORE touching any session state: a skip role
2045
+ // sits on no tier slot, so a pool constraint on it cannot be
2046
+ // expressed — never silently narrow the scope or no-op.
2047
+ let scopedSlots: ReadonlySet<TierName> | null = null;
2048
+ if (parsed.roleScope) {
2049
+ try {
2050
+ scopedSlots = scopedTierSlots(parsed.roleScope, opsxConfig.ompRoleTiers);
2051
+ } catch (e) {
2052
+ if (e instanceof ScopedRoleSkipError) {
2053
+ pi.sendMessage({ customType: 'pick-model', content: `${symbols.error} ${e.message}`, display: true }, { triggerTurn: false });
2054
+ return;
2055
+ }
2056
+ throw e;
2057
+ }
2058
+ }
1315
2059
 
1316
2060
  // Switch the session-scoped state BEFORE any recompute so every
1317
2061
  // later recompute path (ensureSelection, 429/403 auto-switch,
@@ -1320,25 +2064,35 @@ export default (pi: ExtensionAPI) => {
1320
2064
  // command restores it rather than silently dropping an active one.
1321
2065
  const prevConstraint = familyConstraint;
1322
2066
  const prevEffective = effectiveConfig;
1323
- familyConstraint = { selectors: parsed.selectors, excludeSelectors };
1324
- effectiveConfig = buildEffectiveConfig();
2067
+ familyConstraint = { selectors: parsed.selectors, excludeSelectors, roles: parsed.roleScope ?? null };
2068
+ effectiveConfig = buildEffectiveConfig(scopedSlots);
1325
2069
 
1326
2070
  // Fail fast: an empty candidate set (or a role that cannot pick)
1327
2071
  // is a hard error — never silently pick nothing or change models.
2072
+ // Mirror computeSelectionSync's pool shape exactly (variant
2073
+ // collapse on the provider axis + command exclude else standing
2074
+ // china default) so the precheck cannot pass on a pool the real
2075
+ // recompute would keep, or vice versa.
2076
+ const standingExclude = opsxConfig.china ? CHINA_EXCLUDE_FAMILIES : undefined;
1328
2077
  const preFiltered = filterSelectableModels(models, {
1329
2078
  blockedSelectors: regionBlockedModels,
1330
2079
  unreachableProviders: reach.unreachable,
1331
- excludedProviders: effectiveConfig.excluded_providers,
2080
+ excludedProviders: [...effectiveConfig.excluded_providers, ...collapsed],
1332
2081
  availableByProvider: reach.availableByProvider,
1333
2082
  selectors: parsed.selectors ?? undefined,
1334
- excludeSelectors: excludeSelectors.length > 0 ? excludeSelectors : undefined,
2083
+ excludeSelectors: excludeSelectors.length > 0 ? excludeSelectors : standingExclude,
2084
+ providerModels: Object.keys(effectiveConfig.providerModels).length > 0
2085
+ ? new Map(Object.entries(effectiveConfig.providerModels))
2086
+ : undefined,
1335
2087
  });
1336
2088
  if (preFiltered.length === 0) {
1337
2089
  familyConstraint = prevConstraint;
1338
2090
  effectiveConfig = prevEffective;
1339
2091
  pi.sendMessage({
1340
2092
  customType: 'pick-model',
1341
- content: `❌ selector 约束下无可用模型。\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
2093
+ content: renderPickModelReport('error', {
2094
+ action: `selector 约束下无可用模型。\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
2095
+ }, symbols),
1342
2096
  display: true,
1343
2097
  }, { triggerTurn: false });
1344
2098
  return;
@@ -1348,19 +2102,32 @@ export default (pi: ExtensionAPI) => {
1348
2102
  effectiveConfig,
1349
2103
  models,
1350
2104
  health,
1351
- currentKnownRoleIds(),
1352
2105
  regionBlockedModels,
1353
2106
  reach.unreachable,
1354
2107
  reach.availableByProvider,
1355
2108
  familyConstraint,
2109
+ collapsed,
2110
+ warn,
2111
+ // /pick-model <selector>: explicit path (design D5) —
2112
+ // guard bypassed, no fresh classification.
2113
+ routingRuntimeFor(ctx, true),
2114
+ );
2115
+ // pick-model-role-scope: only roles this command actually put
2116
+ // into the pool (roleKind 'auto' in the effective config) can
2117
+ // fail to pick. Out-of-scope pinned/omp-alias roles keep their
2118
+ // config value and synthPinnedResult never carries a picked —
2119
+ // counting them would roll back every scoped constraint.
2120
+ const emptyRole = OPSX_ROLES.find(
2121
+ (r) => sel.roleKind[r] === 'auto' && !sel.selectorResults[r]?.picked,
1356
2122
  );
1357
- const emptyRole = OPSX_ROLES.find((r) => !sel.selectorResults[r]?.picked);
1358
2123
  if (emptyRole) {
1359
2124
  familyConstraint = prevConstraint;
1360
2125
  effectiveConfig = prevEffective;
1361
2126
  pi.sendMessage({
1362
2127
  customType: 'pick-model',
1363
- content: `❌ selector 约束下无法为 role "${emptyRole}" 选择模型。\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
2128
+ content: renderPickModelReport('error', {
2129
+ action: `selector 约束下无法为 role "${emptyRole}" 选择模型。\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
2130
+ }, symbols),
1364
2131
  display: true,
1365
2132
  }, { triggerTurn: false });
1366
2133
  return;
@@ -1378,14 +2145,25 @@ export default (pi: ExtensionAPI) => {
1378
2145
  const changes: string[] = [];
1379
2146
  if (newPrimary) {
1380
2147
  const newSel = `${newPrimary.provider}/${newPrimary.id}`;
1381
- changes.push(oldPrimary && oldPrimary !== newSel ? `🏠 主会话 ${oldPrimary} → ${newSel}` : `🏠 主会话 → ${newSel}`);
2148
+ changes.push(formatChangeLine({
2149
+ name: '主会话',
2150
+ from: oldPrimary && oldPrimary !== newSel ? oldPrimary : undefined,
2151
+ to: newSel,
2152
+ ...annotationOfPicked(sel.selectorResults.reviewer, newPrimary),
2153
+ }));
1382
2154
  }
1383
2155
  for (const role of OPSX_ROLES) {
1384
2156
  const picked = sel.selectorResults[role]?.picked;
1385
2157
  if (!picked) continue;
1386
2158
  const newSel = `${picked.provider}/${picked.id}`;
1387
2159
  const oldSel = oldRolePicks[role];
1388
- changes.push(`${AGENT_MAP[role]} ${oldSel ?? '(none)'} → ${newSel}`);
2160
+ changes.push(formatChangeLine({
2161
+ name: AGENT_MAP[role],
2162
+ from: oldSel ?? '(none)',
2163
+ to: newSel,
2164
+ ...annotationOfPicked(sel.selectorResults[role], picked),
2165
+ kind: roleKindLabel(sel.roleKind[role]),
2166
+ }));
1389
2167
  }
1390
2168
 
1391
2169
  // Cross-provider ambiguity hint (design Decision 8): only when a
@@ -1407,9 +2185,14 @@ export default (pi: ExtensionAPI) => {
1407
2185
 
1408
2186
  if (newPrimary) await pi.setModel(newPrimary);
1409
2187
  selection = sel;
1410
- selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
2188
+ selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
1411
2189
  applyRoleModel(sel);
1412
- const content = `✅ 已切换 selector 约束 (${formatFamilyConstraint(familyConstraint)}):\n${changes.join('\n')}${hint ? `\n${hint}` : ''}`;
2190
+ const content = renderPickModelReport('switch', {
2191
+ action: '已切换 selector 约束',
2192
+ constraint: formatFamilyConstraint(familyConstraint) || undefined,
2193
+ detail: changes.length > 0 ? [changes.join('\n')] : undefined,
2194
+ footnote: hint || undefined,
2195
+ }, symbols);
1413
2196
  pi.sendMessage({
1414
2197
  customType: 'pick-model',
1415
2198
  content,
@@ -1420,22 +2203,59 @@ export default (pi: ExtensionAPI) => {
1420
2203
 
1421
2204
  // ── /pick-model choices (default) ────────────────
1422
2205
  const sel = await getSelectionForCommand(ctx);
1423
- const tierStatus = (() => {
1424
- const loaded = isTiersDataLoaded();
1425
- return loaded === null ? 'not loaded' : loaded ? '' : '(tier data corrupt, using heuristics)';
1426
- })();
1427
- const lines: string[] = [];
1428
- const constraintLabel = formatFamilyConstraint(familyConstraint);
1429
- if (constraintLabel) lines.push(`constraint: ${constraintLabel}`);
1430
- lines.push(formatModelChoicesMarkdown(sel, '', ctx.model));
1431
- lines.push('');
1432
- lines.push(formatFamilyTierMarkdown(sel));
1433
- lines.push('');
1434
- lines.push('`/pick-model refresh` 切换耗尽模型 · `/pick-model update` 更新 tier 数据');
1435
- pi.sendMessage({ customType: 'pick-model', content: lines.join('\n'), display: true }, { triggerTurn: false });
2206
+ const roleLines = formatRoleLines(
2207
+ OPSX_ROLES.map((role) => ({
2208
+ name: AGENT_MAP[role],
2209
+ kind: sel.roleKind[role],
2210
+ fallbackModel: sel.roleModels[role],
2211
+ result: sel.selectorResults[role],
2212
+ })),
2213
+ ctx.model
2214
+ ? {
2215
+ model: { provider: ctx.model.provider, id: ctx.model.id },
2216
+ tier: scoreModelWithProvider(ctx.model.provider, ctx.model.id, ctx.model.name, undefined).tier,
2217
+ }
2218
+ : undefined,
2219
+ );
2220
+ // Full agent inventory (project + user + bundled, host-merged).
2221
+ // Failure downgrades to omitting the section + warn; never blocks.
2222
+ let agentInventory: string | undefined;
2223
+ try {
2224
+ agentInventory = formatAgentInventory((await discoverAgents(directory)).agents, symbols);
2225
+ } catch (err) {
2226
+ warn(`[omp-opsx-addon] agent inventory unavailable: ${err instanceof Error ? err.message : String(err)}`);
2227
+ }
2228
+ const detail: string[] = [];
2229
+ if (roleLines.length > 0) detail.push(roleLines.join('\n'));
2230
+ if (agentInventory) detail.push(agentInventory);
2231
+ const concurrencySummary = ownedConcurrency?.controller.summaryLines() ?? [];
2232
+ // Regional-variant appendix line (china-config): resolved fresh from
2233
+ // the live registry with the same resolution the recompute paths use.
2234
+ const poolModels = ctx.modelRegistry?.getAvailable() ?? [];
2235
+ const choicesGetApiKey = ctx.modelRegistry?.getApiKeyForProvider
2236
+ ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
2237
+ : undefined;
2238
+ const variantLine = variantCollapseAnnotation(
2239
+ await resolveVariantState(poolModels, choicesGetApiKey, {
2240
+ unreachable: firstUnreachableProviders,
2241
+ availableByProvider,
2242
+ }),
2243
+ );
2244
+ const appendixParts: string[] = [];
2245
+ if (concurrencySummary.length > 0) appendixParts.push('**并发控制**', ...concurrencySummary);
2246
+ if (variantLine) appendixParts.push(variantLine);
2247
+ const content = renderPickModelReport('choices', {
2248
+ action: '当前选择',
2249
+ constraint: formatFamilyConstraint(familyConstraint) || undefined,
2250
+ detail,
2251
+ overview: formatCandidateOverview(OPSX_ROLES.map((role) => sel.selectorResults[role])),
2252
+ appendix: appendixParts.length > 0 ? appendixParts.join('\n') : undefined,
2253
+ footnote: formatFootnote(OPSX_ROLES.map((role) => sel.selectorResults[role]), sel.paygLastResort),
2254
+ }, symbols);
2255
+ pi.sendMessage({ customType: 'pick-model', content, display: true }, { triggerTurn: false });
1436
2256
  } catch (e) {
1437
2257
  warn(`[omp-opsx-addon] /pick-model error: ${e}`);
1438
- pi.sendMessage({ customType: 'pick-model', content: `❌ Error: ${e}`, display: true }, { triggerTurn: false });
2258
+ pi.sendMessage({ customType: 'pick-model', content: `${resolveSymbols(pi.pi?.settings).error} Error: ${e}`, display: true }, { triggerTurn: false });
1439
2259
  }
1440
2260
  },
1441
2261
  });
@@ -1461,7 +2281,9 @@ export default (pi: ExtensionAPI) => {
1461
2281
 
1462
2282
  async function getSelectionForCommand(ctx: ExtensionCommandContext): Promise<SelectionContext> {
1463
2283
  if (selection) return selection;
1464
- return ensureSelection(ctx);
2284
+ // Explicit command path: guard bypassed, no fresh classification
2285
+ // (design D5 — the lazy compute exists only for a cache miss).
2286
+ return ensureSelection(ctx, false, true);
1465
2287
  }
1466
2288
 
1467
2289
  // Cross-broker project-scoped pipe core: registers opsx_pipe_list /