@genee/omp-opsx-addon 0.8.0 → 0.10.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 AgentValue, OPSX_AGENTS } 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). */
@@ -240,7 +362,7 @@ const AGENT_MAP: Record<Role, string> = {
240
362
  'proposal-reviewer': 'proposal-reviewer',
241
363
  };
242
364
  function findRoleForModel(provider: string, modelId: string, sel: SelectionContext): Role | null {
243
- for (const role of OPSX_ROLES) {
365
+ for (const role of OPSX_AGENTS) {
244
366
  const r = sel.selectorResults[role];
245
367
  if (!r) continue;
246
368
  if (r.allCandidates.some((c) => c.selector === `${provider}/${modelId}`)) {
@@ -253,21 +375,19 @@ function findRoleForModel(provider: string, modelId: string, sel: SelectionConte
253
375
  export interface SelectionContext {
254
376
  selectorResults: Record<Role, SelectorResult>;
255
377
  roleModels: Record<Role, string | undefined>;
256
- /** Display label for each role from config, e.g. "auto", "smol", "deepseek-v4-pro". */
257
- roleConfigs: Record<Role, string | undefined>;
258
378
  /** Parsed role value kind for each role (auto | omp-alias | model | provider-model). */
259
- roleKind: Record<Role, RoleValue['kind']>;
379
+ roleKind: Record<Role, AgentValue['kind']>;
260
380
  /** Full-role session overlay payload written to `overrideModelRoles` (all known OMP roles). */
261
381
  roleOverrides: Record<string, string>;
262
382
  /** Known roles left out of the overlay payload (config skip / capability gate / no pick). */
263
383
  skippedRoles: SkippedRole[];
384
+ /** payg_last_resort the selection was computed with — drives the 分åŒē fold annotation in choices markdown. Default true. */
385
+ paygLastResort?: boolean;
386
+ /** W4 routing: the task band this selection was computed with (undefined = routing runtime absent). */
387
+ routingBand?: TaskBand;
264
388
  }
265
389
 
266
- const DEFAULT_PLAN_PROVIDERS = new Set(['minimax-code-cn', 'opencode-go', 'zhipu-coding-plan', 'kimi', 'openai-codex']);
267
-
268
-
269
-
270
- function roleValueToModelString(value: RoleValue): string | undefined {
390
+ function roleValueToModelString(value: AgentValue): string | undefined {
271
391
  if (value.kind === 'auto') return undefined;
272
392
  if (value.kind === 'omp-alias') return `@${value.alias}`;
273
393
  if (value.kind === 'provider-model') return `${value.provider}/${value.modelId}`;
@@ -305,77 +425,155 @@ export function buildDispatchPrompt(role: Role, task: string, selection: Selecti
305
425
  else parts.push(`蝎ᛴæŽĨ通čŋ‡ task åˇĨå…ˇč°ƒį”¨ ${agent}īŧˆæ— éœ€æŒ‡åޚ modelīŧŒæ¨Ąåž‹æŒ‰ role č‡ĒåŠ¨č§Ŗæžīŧ‰ã€‚`);
306
426
  if (sel?.decision) parts.push(`原因: ${sel.decision}`);
307
427
  parts.push('į›´æŽĨᔍ内įŊŽåˇĨå…ˇåŽžįŽ°ã€‚');
428
+ if (role === 'coder') parts.push(`${SCOPE_FENCE_TAG}与æœŦäģģåŠĄæ— å…ŗįš„åŧ‚常įŠļ态īŧˆåˆĢäē翜ǿäē¤įš„æ”šåŠ¨ã€æ— å…ŗå¤ąč´Ĩæĩ‹č¯•ã€æ— å…ŗč„æ–‡äģļ、åˇĨäŊœæ ‘įŠļ态与éĸ„期不įŦĻīŧ‰īŧšæœ€å°į›¸å…ŗæ€§æ ¸æŸĨīŧˆåĻ‚ git statusã€įĄŽčŽ¤čĸĢæ”šåŠ¨æ–‡äģ￘¯åĻčŊ在æœŦæŦĄæ”šåЍéĸ内īŧ‰ä¸å—限īŧ›åˆ¤åޚä¸ēæ— å…ŗåŽ MUST NOT 调æŸĨ攚动æĨæēã€MUST NOT 暂停åŊ“前äģģåŠĄīŧŒæ”šä¸ē莰åŊ•å‡čŽžã€å†™å…Ĩäē¤ä옿‘˜čρ SUMMARY、įģ§įģ­æ‰§čĄŒåŊ“前äģģåŠĄīŧ›įœŸé˜ģåĄžīŧˆįŧē䞝čĩ– / åĨ‘įēĻ动摇 / 权限不čļŗ / éœ€į”¨æˆˇå†ŗį­–īŧ‰äģįĢ‹åŗ STATUS: blocked。`);
308
429
  return `蝎äŊŋᔍ task åˇĨå…ˇå§”æ´žįģ™ ${agent}īŧš\ntask(agent:"${agent}"${modelHint ? `, model:"${modelHint}"` : ''}, task:"${parts.join(' ')}\\n\\n${task}")`;
309
430
  }
310
431
 
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
432
  function computeSelectionSync(
328
433
  config: ResolvedOpsxConfig,
329
434
  models: Model<Api>[] | null,
330
435
  health: Map<string, ProviderHealth> | null,
331
- knownRoleIds: string[],
332
436
  regionBlockedModels?: Set<string>,
333
437
  unreachableProviders?: Set<string>,
334
438
  availableByProvider?: Map<string, string[] | null>,
335
439
  familyConstraint?: FamilyConstraint | null,
440
+ collapsedProviders?: ReadonlySet<string>,
441
+ onWarn?: (msg: string) => void,
442
+ /** W4 routing runtime (design D8.5); undefined = all switches off + balanced → byte-identical legacy path. */
443
+ routing?: RoutingRuntime,
336
444
  ): SelectionContext {
337
- const filteredModels = models
445
+ const fc = familyConstraint ?? null;
446
+ // pick-model-role-scope: pool split. basePool = reachability/provider
447
+ // filtering plus account-level regional-variant dedup (china-config: the
448
+ // collapse set is resolved at the call sites and narrows EVERY slot,
449
+ // scoped or not — account-level, orthogonal to constraints);
450
+ // filteredModels = basePool further narrowed by the selector filter and
451
+ // the effective china exclusion (explicit `--china` constraint first, else
452
+ // the `china: true` config default). Global constraints feed every slot
453
+ // from the single filtered pool (status-quo shape); scoped constraints
454
+ // only the slots in scope — everything else draws basePool, which
455
+ // deliberately KEEPS china families (role-scope contract: out-of-scope
456
+ // slots never inherit the standing exclusion).
457
+ const scopeRoles = fc?.roles && fc.roles.length > 0 ? fc.roles : null;
458
+ const scopedSlots = scopedTierSlots(scopeRoles, config.ompRoleTiers);
459
+ // provider-auto-targets: config-static per-provider narrowing at the pool
460
+ // layer — every downstream slot (tierPicks / vision / role pools) inherits it.
461
+ const providerModelMap = Object.keys(config.providerModels).length > 0
462
+ ? new Map(Object.entries(config.providerModels))
463
+ : undefined;
464
+ const providerModelWipes = new Set<string>();
465
+ const basePool = models
338
466
  ? filterSelectableModels(models, {
339
467
  blockedSelectors: regionBlockedModels,
340
468
  unreachableProviders,
341
- excludedProviders: config.excluded_providers,
469
+ excludedProviders: [...config.excluded_providers, ...(collapsedProviders ?? [])],
342
470
  availableByProvider,
343
- selectors: familyConstraint?.selectors ?? undefined,
344
- excludeSelectors:
345
- familyConstraint?.excludeSelectors && familyConstraint.excludeSelectors.length > 0
346
- ? familyConstraint.excludeSelectors
347
- : undefined,
471
+ providerModels: providerModelMap,
472
+ zeroHitProviders: providerModelWipes,
348
473
  })
349
474
  : models;
475
+ if (onWarn) {
476
+ for (const p of providerModelWipes) {
477
+ onWarn(`[omp-opsx-addon] provider_models.${p}: æ”ļįĒ„é›ļå‘Ŋ中īŧˆå€™é€‰å…¨éƒ¨æœĒå‘Ŋä¸­æ¨ĄåŧīŧŒæŖ€æŸĨæ¨Ąåŧæ‹ŧ写是åĻ与 catalog id 一致īŧ‰`);
478
+ }
479
+ // Sentinel id drift (catalog renamed its auto entry): marking silently
480
+ // falls back to heuristic scoring — surface it instead of staying quiet.
481
+ for (const [p, id] of Object.entries(config.providerAuto)) {
482
+ const inPool = basePool?.some((m) => canonicalizeProvider(m.provider) === p);
483
+ if (!inPool) continue;
484
+ const hit = basePool?.some((m) => canonicalizeProvider(m.provider) === p && m.id.toLowerCase() === id.toLowerCase());
485
+ if (!hit) onWarn(`[omp-opsx-addon] provider_auto.${p}: åŖ°æ˜Žįš„å“¨å…ĩ id "${id}" 不在 ${p} įš„å­˜æ´ģ候选中īŧˆcatalog id 厞æŧ‚į§ģīŧŸæ ‡čŽ°å¤ąæ•ˆīŧŒå›žčŊæ™Žé€šč¯„åˆ†īŧ‰`);
486
+ }
487
+ }
488
+ // å‘Ŋä줿˜žåŧ > 配įŊŽįŧēᜁ: a constraint carrying excludeSelectors wins; the
489
+ // config default only fills in when the constraint carries none. Invariant:
490
+ // excludeSelectors has exactly one write site (parsed.china →
491
+ // CHINA_EXCLUDE_FAMILIES in the selector branch), so non-empty ≡ explicit.
492
+ const effectiveExclude =
493
+ fc?.excludeSelectors && fc.excludeSelectors.length > 0
494
+ ? fc.excludeSelectors
495
+ : config.china
496
+ ? CHINA_EXCLUDE_FAMILIES
497
+ : undefined;
498
+ const filteredModels = basePool
499
+ ? filterSelectableModels(basePool, {
500
+ selectors: fc?.selectors ?? undefined,
501
+ excludeSelectors: effectiveExclude,
502
+ })
503
+ : basePool;
504
+ // Pool a slot draws from: global constraints (roles null) constrain every
505
+ // slot; scoped constraints only the slots the scope roles map onto.
506
+ const poolForSlot = (slot: TierName): Model<Api>[] | null =>
507
+ scopeRoles === null || scopedSlots.has(slot) ? filteredModels : basePool;
350
508
  const selectorResults = {} as Record<Role, SelectorResult>;
351
509
  const roleModels = {} as Record<Role, string | undefined>;
352
- const roleConfigs = {} as Record<Role, string | undefined>;
353
- const roleKind = {} as Record<Role, RoleValue['kind']>;
354
- const planSet = mergePlanProviders(config.planProviders);
355
- for (const role of OPSX_ROLES) {
356
- const value = config.roles[role];
510
+ const roleKind = {} as Record<Role, AgentValue['kind']>;
511
+ const planSet = mergePlanProviders(config.planProviders, config.planProvidersRemove);
512
+ // Speed-aware selection: undefined when disabled — zero DB IO, and the
513
+ // selector keeps the frozen W1 order byte-for-byte.
514
+ const speedAware = resolveSpeedAware(config.speedAware);
515
+ // ── W4 difficulty routing (design D3/D5) ──────────────────────
516
+ // `routing === undefined` keeps every map/value at its legacy identity —
517
+ // the zero-drift structure is "not entering" the new paths, not an
518
+ // equivalent re-derivation. Explicit command paths KEEP the session-band
519
+ // offsets (design D5: æ˛ŋᔍåŊ“前äŧšč¯įē§ band) and skip only guard pinning and
520
+ // the band annotation (昞åŧæŒ‡äģ¤ > 厈门īŧ›æ˜žåŧčˇ¯åž„不做新分įąģ).
521
+ // Offsets are SELECTOR-TARGET pre-corrections only: the role→slot mapping
522
+ // (buildRoleOverridePlan's input) stays structurally untouched — smol
523
+ // always consumes the coder pick, slow the top supplemental, so shifted
524
+ // targets flow through the existing slots.
525
+ // Selector-side offsets key on the OMP write-set alias (smol/default/
526
+ // slow/vision); opsx roles translate via ROLE_TO_OMP_ALIAS.
527
+ const routedTierFor = (role: string, ompRole: string | undefined, base: TierName): TierName => {
528
+ if (!routing || !ompRole) return base;
529
+ // The per-role map carries only this role's base tier, so a `skip`
530
+ // sentinel can never appear here — but the offset table's value type
531
+ // admits it, and falling back to the base keeps that safe.
532
+ const routed = applyBandOffset({ [ompRole]: base }, routing.band, { stallActive: routing.stallActive });
533
+ const value = routed[ompRole];
534
+ return value === 'skip' || value === undefined ? base : value;
535
+ };
536
+ // Data-layer annotations (design D9): `band=<band>` when routing drives
537
+ // the recompute, `stall+1` on the roles aliased to default while stall is
538
+ // active. Explicit paths suppress the band segment (no fresh
539
+ // classification).
540
+ const noteFor = (role: string): string | undefined => {
541
+ if (!routing) return undefined;
542
+ const parts: string[] = [];
543
+ if (!routing.explicit && config.difficultyRouting.enabled) parts.push(`band=${routing.band}`);
544
+ if (routing.stallActive && ROLE_TO_OMP_ALIAS[role] === 'default') parts.push('stall+1');
545
+ return parts.length > 0 ? parts.join(', ') : undefined;
546
+ };
547
+ for (const role of OPSX_AGENTS) {
548
+ const value = config.agents[role];
357
549
  roleKind[role] = value.kind;
358
- // Config label shown in /pick-model choices markdown (`角色 (label)`):
359
- // auto roles display the OMP tier-role alias; pinned roles the pin.
360
- roleConfigs[role] = value.kind === 'auto'
361
- ? ROLE_TO_OMP_ALIAS[role]
362
- : (roleValueToModelString(value) ?? '?');
363
550
  const alias = `@${ROLE_TO_OMP_ALIAS[role]}`;
364
551
  if (value.kind === 'auto') {
365
- if (filteredModels && filteredModels.length > 0) {
552
+ // coder's pick feeds the mid slot, the other three feed high —
553
+ // the same consumed-slot mapping buildEffectiveConfig and the
554
+ // tierPicks assembly below use.
555
+ const pool = poolForSlot(role === 'coder' ? 'mid' : 'high');
556
+ if (pool && pool.length > 0) {
366
557
  const ompRole = ROLE_TO_OMP_ALIAS[role];
367
- const tier = (config.roleTiers[role]
558
+ const tier = routedTierFor(role, ompRole, (config.agentTiers[role]
368
559
  ?? (OMP_ROLE_TO_TIER as Record<string, TierName>)[ompRole]
369
- ?? 'mid') as TierName;
370
- const roleTiers = { ...config.roleTiers, [role]: tier };
560
+ ?? 'mid') as TierName);
561
+ const roleTiers = { ...config.agentTiers, [role]: tier };
371
562
  const result = selectAllRoles(
372
563
  [role],
373
- filteredModels,
564
+ pool,
374
565
  roleTiers,
375
566
  config.tierOverrides,
376
567
  health ?? undefined,
377
568
  config.modelAllowlist,
378
569
  planSet,
570
+ undefined,
571
+ config.paygLastResort,
572
+ config.providerAuto,
573
+ config.providerAutoTiers,
574
+ speedAware,
575
+ routing?.qualityPreference,
576
+ noteFor(role),
379
577
  )[role];
380
578
  selectorResults[role] = result;
381
579
  roleModels[role] = result.picked ? alias : undefined;
@@ -397,123 +595,118 @@ function computeSelectionSync(
397
595
  roleModels[role] = modelString?.includes('/') ? modelString : undefined;
398
596
  }
399
597
  }
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.
598
+ // ── W4 continuity guard (design D5) ───────────────────────────
599
+ // Pin viable incumbents on auto recomputes — the pin outranks every
600
+ // tie-break; a switch needs a strict band upgrade at sufficient
601
+ // confidence, a stall escalation, or an incumbent hard-filtered out of
602
+ // the pool. Runs BEFORE the overlay plan so pinned picks flow into
603
+ // roleOverrides; explicit paths bypass (昞åŧæŒ‡äģ¤ > 厈门).
604
+ if (routing?.guard && !routing.explicit) {
605
+ const incumbentResults = routing.guard.incumbent?.selectorResults ?? null;
606
+ for (const role of OPSX_AGENTS) {
607
+ const prev = incumbentResults?.[role];
608
+ if (!prev?.picked) continue;
609
+ const cur = selectorResults[role];
610
+ const picked = prev.picked;
611
+ // Viability = the incumbent's selector still sits in the fresh
612
+ // candidate pool (zero registry queries — pool membership only).
613
+ const viable = cur.candidates.some((c) => c.model.provider === picked.provider && c.model.id === picked.id);
614
+ // Stall escalates the `default` alias only (reviewer/planner/
615
+ // proposal-reviewer); coder maps to smol and stays sticky.
616
+ const outcome = decideGuard({
617
+ enabled: true,
618
+ weight: routing.guard.weight,
619
+ explicit: false,
620
+ incumbent: prev,
621
+ incumbentViable: viable,
622
+ newBand: routing.band,
623
+ incumbentBand: routing.guard.priorBand ?? null,
624
+ confidence: routing.confidence,
625
+ stallEscalated: routing.stallActive && ROLE_TO_OMP_ALIAS[role] === 'default',
626
+ });
627
+ if (outcome.decision === 'pin' && outcome.pinned) {
628
+ selectorResults[role] = pinPriorPick(outcome.pinned, routing.band);
629
+ }
630
+ }
631
+ }
632
+ // change: pick-model-ux — minimal write-set session overlay {smol, default,
633
+ // slow, vision}: mid/high reuse the opsx picks (coder/reviewer), top is the
634
+ // sole supplemental pick (slow), vision picks from image-capable candidates
635
+ // only. Roles outside the set resolve through OMP native chains. See
636
+ // lib/model-roles.ts design D5.
405
637
  let roleOverrides: Record<string, string> = {};
406
638
  let skippedRoles: SkippedRole[] = [];
407
- if (filteredModels && filteredModels.length > 0) {
639
+ if (basePool && basePool.length > 0) {
408
640
  const pickString = (r: SelectorResult): string | undefined =>
409
641
  r.picked ? `${r.picked.provider}/${r.picked.id}` : undefined;
410
642
  const tierPicks: Partial<Record<TierName, string | undefined>> = {
643
+ // Guard pinning ran above: a pinned pick replaces the role result
644
+ // and therefore feeds the overlay payload (T4: application stays
645
+ // at the existing apply points — this only refreshes the cache).
411
646
  mid: pickString(selectorResults.coder),
412
647
  high: pickString(selectorResults.reviewer),
413
648
  };
414
- for (const supplementalTier of ['tiny', 'low', 'top'] as const) {
415
- const synthRole = `__tier_${supplementalTier}`;
649
+ const synthRole = '__tier_top';
650
+ const topPool = poolForSlot('top');
651
+ const topSlotTier: TierName = routing
652
+ ? applyBandOffset({ slow: 'top' }, routing.band, { stallActive: routing.stallActive }).slow as TierName
653
+ : 'top';
654
+ if (topPool && topPool.length > 0) {
416
655
  const result = selectModel({
417
656
  role: synthRole,
418
- models: filteredModels,
419
- roleTiers: { [synthRole]: supplementalTier },
657
+ models: topPool,
658
+ // This slot feeds the write-set `slow` role: its target shifts
659
+ // with the band (top−1 under simple etc., design D3), while the
660
+ // role→slot mapping itself stays fixed.
661
+ roleTiers: { [synthRole]: topSlotTier },
420
662
  tierOverrides: config.tierOverrides,
421
663
  health: health ?? undefined,
422
664
  allowlist: config.modelAllowlist,
423
665
  planProviders: planSet,
666
+ paygLastResort: config.paygLastResort,
667
+ providerAuto: config.providerAuto,
668
+ providerAutoTiers: config.providerAutoTiers,
669
+ speedAware,
670
+ qualityPreference: routing?.qualityPreference,
424
671
  });
425
- tierPicks[supplementalTier] = pickString(result);
672
+ tierPicks.top = pickString(result);
426
673
  }
427
674
  const capabilityPicks: Record<string, string | undefined> = {};
428
- const visionModels = filteredModels.filter((m) => ROLE_CAPABILITY.vision(m));
675
+ // vision scope gates its capability candidate set only — it never
676
+ // contributes a tier slot.
677
+ const visionSource = scopeRoles?.includes('vision') ? filteredModels : basePool;
678
+ const visionModels = (visionSource ?? []).filter((m) => ROLE_CAPABILITY.vision(m));
429
679
  if (visionModels.length > 0) {
430
680
  const visionResult = selectModel({
431
681
  role: 'vision',
432
682
  models: visionModels,
433
- roleTiers: { vision: 'high' },
683
+ // Write-set member: band offset shifts the in-pool preference
684
+ // (capability filter already ran — no incapable-model risk).
685
+ roleTiers: routing
686
+ ? applyBandOffset({ vision: 'high' } as Record<string, TierName>, routing.band, { stallActive: routing.stallActive }) as Record<string, TierName>
687
+ : { vision: 'high' },
434
688
  tierOverrides: config.tierOverrides,
435
689
  health: health ?? undefined,
436
690
  allowlist: config.modelAllowlist,
437
691
  planProviders: planSet,
692
+ paygLastResort: config.paygLastResort,
693
+ providerAuto: config.providerAuto,
694
+ providerAutoTiers: config.providerAutoTiers,
695
+ speedAware,
696
+ qualityPreference: routing?.qualityPreference,
438
697
  });
439
698
  capabilityPicks.vision = pickString(visionResult);
440
699
  }
441
- const plan = buildRoleOverridePlan(
442
- { knownRoleIds, ompRoleTiers: config.ompRoleTiers, tierPicks, capabilityPicks },
443
- (m) => getLogger().warn(m),
444
- );
700
+ const plan = buildRoleOverridePlan({ ompRoleTiers: config.ompRoleTiers, tierPicks, capabilityPicks });
445
701
  roleOverrides = plan.overrides;
446
702
  skippedRoles = plan.skipped;
447
703
  }
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');
704
+ return { selectorResults, roleModels, roleKind, roleOverrides, skippedRoles, paygLastResort: config.paygLastResort, routingBand: routing?.band };
516
705
  }
706
+ // change: pick-model-ux — choices/switch/refresh/reset output rendering lives
707
+ // in lib/pick-model-render.ts (shared compact block renderer); the former
708
+ // formatModelChoicesMarkdown / formatFamilyTierMarkdown markdown tables were
709
+ // migrated there.
517
710
 
518
711
  // ── process-wide usage recorder singleton ──────────────────────────
519
712
  // This IS a process-wide singleton: the harness rebinds the same evaluated
@@ -540,8 +733,174 @@ const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
540
733
  onDelta: mirrorSessionDelta,
541
734
  });
542
735
 
736
+ // ── adaptive provider concurrency (process-wide owner) ──────────────
737
+ // Ownership model (the same P0 invariant as plugins/omp-whip/index.ts): the
738
+ // host re-runs this factory once per in-process subagent binding, so an owner
739
+ // kept in the closure would be claimed by EVERY binding — each starting its
740
+ // own control loop, lease and state file against the same config root. The
741
+ // owner therefore lives at module scope (shared by every binding in this
742
+ // process, and re-imported fresh by isolated workers), and the session id
743
+ // captured at claim time is the identity gate for `session_shutdown`: without
744
+ // it, a subagent exiting would tear down the root's loop and release its lease.
745
+ interface OwnedConcurrency {
746
+ /** Session id captured when this process claimed the control loop. */
747
+ sessionId: string;
748
+ controller: ConcurrencyController;
749
+ }
750
+
751
+ let ownedConcurrency: OwnedConcurrency | null = null;
752
+
753
+ // Process-wide resize-handler slot: the host re-runs the plugin factory per
754
+ // session binding WITHOUT re-evaluating the module graph, so a closure-level
755
+ // "previous handler" variable is per-instance — each binding would attach its
756
+ // own stdout resize listener and nobody could off the others (observed as
757
+ // MaxListenersExceededWarning: 11 resize listeners after enough bindings).
758
+ // Module scope gives every binding the same single slot.
759
+ let activeResizeHandler: (() => void) | 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
 
901
+ /** Bounded backoff for session-start autoselect cold-start recovery (precedent: RESET_IMMINENT_MS). Not a config key. */
902
+ export const AUTOSELECT_RETRY_DELAYS_MS = [5_000, 15_000, 30_000] as const;
903
+
545
904
  export default (pi: ExtensionAPI) => {
546
905
  // Route plugin logging through the host logger (rolling file) instead of
547
906
  // console.*, which would corrupt the TUI. Injected before any log path runs.
@@ -558,16 +917,10 @@ export default (pi: ExtensionAPI) => {
558
917
  applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
559
918
  const directory = process.cwd();
560
919
  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);
920
+ const opsxConfig = readOpsxSettings(directory, warn, opsxGlobalHomeForTests ?? homedir());
566
921
  // Static orchestration rules — built once, reused every turn for cache stability.
567
922
  const STATIC_PROMPT = buildStaticPrompt();
568
923
  let initialModel: { provider: string; id: string } | null = null;
569
- // Active resize handler — tracked so each session_start can off the previous one.
570
- let activeResizeHandler: (() => void) | null = null;
571
924
  let selection: SelectionContext | null = null;
572
925
  // 403 no longer auto-blocks; the set is always empty in the running session.
573
926
  // A one-time startup migration clears stale blockedModels written by the old
@@ -585,28 +938,145 @@ export default (pi: ExtensionAPI) => {
585
938
  // and `/pick-model reset` restores it to opsxConfig.
586
939
  let familyConstraint: FamilyConstraint | null = null;
587
940
  let effectiveConfig: ResolvedOpsxConfig = opsxConfig;
941
+ // Session-start autoselect once-flag. Factory closure, not module-level:
942
+ // rebind instances (in-process task subagents) re-run the factory and each
943
+ // gets its own flag — the owner gate below is what actually excludes them.
944
+ let autoApplied = false;
945
+
946
+ // ── W4 difficulty routing state (change: difficulty-routed-selection) ──
947
+ // Factory closure, lazily keyed by session id (design D4: NOT hung on any
948
+ // session_start handler — that region belongs to the usage/autoselect
949
+ // owners). `band/confidence` come from classifyTaskSafe on the
950
+ // before_agent_start path; `stall` is the read-only sampling window fed
951
+ // by the tool_execution_start handler below.
952
+ let routingState: {
953
+ sessionId: string;
954
+ band: TaskBand;
955
+ confidence: number;
956
+ stallActive: boolean;
957
+ stall: StallRuntime | null;
958
+ } | null = null;
959
+
960
+ const routingStateFor = (sessionId: string) => {
961
+ if (!routingState || routingState.sessionId !== sessionId) {
962
+ routingState = {
963
+ sessionId,
964
+ band: 'standard',
965
+ confidence: 0,
966
+ stallActive: false,
967
+ stall: opsxConfig.stallEscalation.enabled ? createStallRuntime(opsxConfig.stallEscalation) : null,
968
+ };
969
+ }
970
+ return routingState;
971
+ };
972
+
973
+ /**
974
+ * Routing runtime handed to computeSelectionSync (design D8.5). All
975
+ * switches off + balanced → `undefined` (the zero-drift contract: the
976
+ * selection path never even sees a routing object). `explicit` marks the
977
+ * /pick-model command paths — guard bypassed, no fresh classification,
978
+ * band annotations suppressed; the session band still drives offsets.
979
+ */
980
+ const routingRuntimeFor = (ctx: ExtensionContext, explicit: boolean): RoutingRuntime | undefined => {
981
+ const qp = opsxConfig.qualityPreference;
982
+ const anyOn = opsxConfig.difficultyRouting.enabled
983
+ || opsxConfig.stallEscalation.enabled
984
+ || opsxConfig.continuityGuard.enabled
985
+ || qp !== 'balanced';
986
+ if (!anyOn) return undefined;
987
+ const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
988
+ return {
989
+ band: st.band,
990
+ confidence: st.confidence,
991
+ stallActive: st.stallActive,
992
+ explicit,
993
+ qualityPreference: qp,
994
+ // Incumbent read BEFORE the recompute reassigns `selection` — the
995
+ // pin decision compares against the previous context only.
996
+ guard: opsxConfig.continuityGuard.enabled && !explicit
997
+ ? { weight: opsxConfig.continuityGuard.weight, incumbent: selection, priorBand: selection?.routingBand ?? null }
998
+ : undefined,
999
+ };
1000
+ };
1001
+
1002
+ // Same-account regional variant state (china-config): resolved fresh at
1003
+ // every recompute path from the live pool composition — no cache and
1004
+ // therefore no invalidation problem. The result feeds both
1005
+ // computeSelectionSync and the health key so the two cannot drift.
1006
+ // Judgment = variant-table membership + both sides in the effective pool
1007
+ // + strictly equal API keys (same-account proof; unequal/missing keys →
1008
+ // both doors coexist).
1009
+ const resolveVariantState = async (
1010
+ models: { provider: string; id: string }[] | null | undefined,
1011
+ getApiKey: ((provider: string) => Promise<string | undefined>) | undefined,
1012
+ reach: { unreachable?: Set<string>; availableByProvider?: Map<string, string[] | null> } = {},
1013
+ ): Promise<VariantCollapseResult> => {
1014
+ const idle: VariantCollapseResult = { collapsedProviders: new Set<string>(), dropped: null, kept: null };
1015
+ if (!models || models.length === 0) return idle;
1016
+ // Trigger condition evaluates on the EFFECTIVE pool (spec: 双变äŊ“同æ—ļ
1017
+ // åœ¨æą æ‰č§Ļ发——reachability / 静态排除 / provider_models æ”ļįĒ„ /
1018
+ // allowlist 厞æģ¤æŽ‰ä¸€äž§æ—ļ不č§Ļ发): the same provider-level filters
1019
+ // computeSelectionSync applies to basePool (minus the collapse
1020
+ // itself), plus the allowlist reduction. provider_models participates:
1021
+ // a kept side narrowed to zero must not count as "in pool", or the
1022
+ // collapse would drop the other side and leave the account unservable.
1023
+ const providerModelMap = Object.keys(opsxConfig.providerModels).length > 0
1024
+ ? new Map(Object.entries(opsxConfig.providerModels))
1025
+ : undefined;
1026
+ const providersInPool = new Set(
1027
+ filterSelectableModels(models, {
1028
+ blockedSelectors: regionBlockedModels,
1029
+ unreachableProviders: reach.unreachable,
1030
+ availableByProvider: reach.availableByProvider,
1031
+ excludedProviders: opsxConfig.excluded_providers,
1032
+ providerModels: providerModelMap,
1033
+ })
1034
+ .filter((m) => providerSurvivesAllowlist(m.provider, m.id, opsxConfig.modelAllowlist))
1035
+ .map((m) => m.provider),
1036
+ );
1037
+ return resolveVariantCollapse({
1038
+ china: opsxConfig.china,
1039
+ providersInPool,
1040
+ getApiKey: await resolveVariantKeyLookup(providersInPool, getApiKey),
1041
+ });
1042
+ };
588
1043
 
589
1044
  const buildSelectionHealthKey = (
590
1045
  health: Map<string, ProviderHealth> | null,
591
1046
  reach: { unreachable: Set<string>; availableByProvider: Map<string, string[] | null> },
592
1047
  fc: FamilyConstraint | null,
1048
+ collapsed: ReadonlySet<string> = new Set<string>(),
593
1049
  ): string => {
594
- return selectionHealthKeyFor(health, reach, fc, regionBlockedModels);
1050
+ return selectionHealthKeyFor(health, reach, fc, regionBlockedModels, collapsed);
595
1051
  };
596
1052
 
597
1053
  const formatFamilyConstraint = (fc: FamilyConstraint | null): string => {
598
- if (!fc) return '';
599
1054
  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');
1055
+ if (fc?.selectors && fc.selectors.length > 0) parts.push(`selectors=${fc.selectors.join(',')}`);
1056
+ // china=on when the session constraint carries the exclusion OR the
1057
+ // config default is on — the config-derived segment renders even
1058
+ // without any selector constraint (spec: 配įŊŽæ´žį”Ÿ china=on 无įēĻæŸä🿘žį¤ē).
1059
+ if ((fc?.excludeSelectors.length ?? 0) > 0 || effectiveConfig.china) parts.push('china=on');
1060
+ if (fc?.roles && fc.roles.length > 0) parts.push(`scope=${fc.roles.join(',')}`);
602
1061
  return parts.join(' ¡ ');
603
1062
  };
604
1063
 
605
- /** Copy of opsxConfig with every role forced to auto — covers pinned roles. */
606
- const buildEffectiveConfig = (): ResolvedOpsxConfig => {
607
- const roles = {} as Record<Role, RoleValue>;
608
- for (const role of OPSX_ROLES) roles[role] = { kind: 'auto' };
609
- return { ...opsxConfig, roles };
1064
+ /**
1065
+ * Copy of opsxConfig with the constrained roles forced to auto — covers
1066
+ * pinned roles. `scopedSlots === null` = global constraint (every role
1067
+ * auto, status-quo shape); scoped constraints only force the roles whose
1068
+ * consumed tier slot is in scope (coder feeds mid, the rest high) —
1069
+ * out-of-scope roles keep their opsx.yml value, so synthPinnedResult
1070
+ * keeps their pin and auto roles draw the unconstrained pool. The no-arg
1071
+ * poller/assembly call sites keep the all-auto semantics.
1072
+ */
1073
+ const buildEffectiveConfig = (scopedSlots: ReadonlySet<TierName> | null = null): ResolvedOpsxConfig => {
1074
+ const agents = {} as Record<Role, AgentValue>;
1075
+ for (const role of OPSX_AGENTS) {
1076
+ const slot: TierName = role === 'coder' ? 'mid' : 'high';
1077
+ agents[role] = scopedSlots === null || scopedSlots.has(slot) ? { kind: 'auto' } : opsxConfig.agents[role];
1078
+ }
1079
+ return { ...opsxConfig, agents };
610
1080
  };
611
1081
  const startPollerFor = async (ctx: ExtensionContext, onTick?: () => void): Promise<void> => {
612
1082
  const auth = ctx.modelRegistry?.authStorage;
@@ -630,7 +1100,12 @@ export default (pi: ExtensionAPI) => {
630
1100
  });
631
1101
  };
632
1102
 
633
- const ensureSelection = (ctx: ExtensionContext, force = false): Promise<SelectionContext> => {
1103
+ const ensureSelection = (
1104
+ ctx: ExtensionContext,
1105
+ force = false,
1106
+ explicit = false,
1107
+ opts?: { reprobeUnreachable?: boolean },
1108
+ ): Promise<SelectionContext> => {
634
1109
  if (selectionPromise && !force) return selectionPromise;
635
1110
  selectionPromise = (async () => {
636
1111
  await startPollerFor(ctx);
@@ -640,11 +1115,15 @@ export default (pi: ExtensionAPI) => {
640
1115
  } catch (e) {
641
1116
  warn(`[omp-opsx-addon] modelRegistry error: ${e}`);
642
1117
  }
643
- const reach = await refreshReachability(models, opsxConfig);
1118
+ const reach = await refreshReachability(models, opsxConfig, opts);
644
1119
  firstUnreachableProviders = reach.unreachable;
645
1120
  availableByProvider = reach.availableByProvider;
646
1121
  const health = getSharedUsage()?.health ?? null;
647
- const key = buildSelectionHealthKey(health, reach, familyConstraint);
1122
+ const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
1123
+ ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
1124
+ : undefined;
1125
+ const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
1126
+ const key = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
648
1127
  if (!force && selection && selectionHealthKey === key) return selection;
649
1128
  // Thread effectiveConfig + familyConstraint so family/pinned constraints
650
1129
  // survive this recompute (ensureSelection is one of the shared recompute paths).
@@ -652,11 +1131,13 @@ export default (pi: ExtensionAPI) => {
652
1131
  effectiveConfig,
653
1132
  models,
654
1133
  health,
655
- currentKnownRoleIds(),
656
1134
  regionBlockedModels,
657
1135
  reach.unreachable,
658
1136
  reach.availableByProvider,
659
1137
  familyConstraint,
1138
+ collapsed,
1139
+ warn,
1140
+ routingRuntimeFor(ctx, explicit),
660
1141
  );
661
1142
  selection = sel;
662
1143
  selectionHealthKey = key;
@@ -688,6 +1169,35 @@ export default (pi: ExtensionAPI) => {
688
1169
  // would be overwritten by whichever instance starts last.
689
1170
  let recordsOwnUsage = false;
690
1171
 
1172
+ // ── adaptive provider concurrency: host seams ─────────────────
1173
+ /** Host settings port for the control loop's hot-apply (narrowed to `override`). */
1174
+ const hostSettingsPort = (): SettingsPort | null => {
1175
+ const settings = pi.pi?.settings as unknown as { override?: unknown } | undefined;
1176
+ return settings && typeof settings.override === 'function' ? (settings as SettingsPort) : null;
1177
+ };
1178
+ /** Config root + agent dir for the control loop (host settings, else `~/.omp/agent`). */
1179
+ const resolveConcurrencyPaths = (): { agentDir: string; configPath: string } => {
1180
+ let agentDir = '';
1181
+ try {
1182
+ agentDir = (pi.pi?.settings as { getAgentDir?: () => string } | undefined)?.getAgentDir?.() ?? '';
1183
+ } catch {
1184
+ agentDir = '';
1185
+ }
1186
+ if (!agentDir) agentDir = join(homedir(), '.omp', 'agent');
1187
+ return { agentDir, configPath: resolveGlobalConfigPath(agentDir) ?? join(agentDir, 'config.yml') };
1188
+ };
1189
+ /** The existing Redis channel as the ① aggregation transport (no new command surface). */
1190
+ const aggregationRedisPort = (cfg: ResolvedOpsxConfig): ConcurrencyRedisPort | null => {
1191
+ if (!cfg.redis_enabled) return null;
1192
+ const client = new RedisClient({
1193
+ host: cfg.redis_host,
1194
+ port: cfg.redis_port,
1195
+ connectTimeoutMs: cfg.redis_connect_timeout_ms,
1196
+ commandTimeoutMs: cfg.redis_command_timeout_ms,
1197
+ });
1198
+ return { pipeline: (commands) => client.pipeline(commands) };
1199
+ };
1200
+
691
1201
  // ── shared usage poller + widget ──────────────────────────────
692
1202
  pi.on('session_start', async (_event, ctx) => {
693
1203
  try {
@@ -821,8 +1331,23 @@ export default (pi: ExtensionAPI) => {
821
1331
  // Last unix second already sampled by onTick (per session). Drives
822
1332
  // the catch-up loop that backfills every skipped second with zeros.
823
1333
  let lastSampledSec: number | undefined;
824
- const render = () => {
825
- const { reports, placeholderIds, active, show } = resolveActiveProviders();
1334
+ const render = async () => {
1335
+ const resolved = resolveActiveProviders();
1336
+ // Same-account regional collapse on the usage bar (china-config):
1337
+ // a collapsed side is a duplicate door into the quota the kept
1338
+ // side already displays, so dropping its row loses nothing.
1339
+ // In-pool for usage = usage data or credentials present (the
1340
+ // `active` union) — model candidates are not required. The
1341
+ // strict key-equality gate matches the pool path: unequal or
1342
+ // missing keys keep BOTH rows (labels æ™ēč°ą / Z.AI distinguish).
1343
+ const activeSet = new Set(resolved.active);
1344
+ const collapsed = resolveVariantCollapse({
1345
+ china: opsxConfig.china,
1346
+ providersInPool: activeSet,
1347
+ getApiKey: await resolveVariantKeyLookup(activeSet, getApiKey),
1348
+ }).collapsedProviders;
1349
+ const { reports, placeholderIds, active } = collapseUsageView(resolved.reports, resolved.placeholderIds, resolved.active, collapsed);
1350
+ const show = resolved.show;
826
1351
  const state = getSharedUsage();
827
1352
  const loadingProviders = state?.loadingProviders ?? new Set<string>();
828
1353
  if (!show) {
@@ -852,14 +1377,28 @@ export default (pi: ExtensionAPI) => {
852
1377
  for (const p of active) {
853
1378
  consumptionTracks.set(p, { samples: getConsumptionSamples(p) });
854
1379
  }
1380
+ // Adaptive-concurrency inline tokens: the controller's per-provider
1381
+ // view (`stateFor`) rendered as `L/ceiling` right after the provider
1382
+ // name on BOTH render paths. Providers the controller does not manage
1383
+ // stay absent from the map — and an empty map keeps the widget
1384
+ // byte-identical to the pre-token output.
1385
+ const concurrencyViews = new Map<string, ConcurrencyInlineView>();
1386
+ if (ownedConcurrency) {
1387
+ const degraded = ownedConcurrency.controller.leaseMode() === 'readonly';
1388
+ for (const p of active) {
1389
+ const view = ownedConcurrency.controller.stateFor(p);
1390
+ if (view) concurrencyViews.set(p, { limit: view.limit, ceiling: view.ceiling, degraded });
1391
+ }
1392
+ }
1393
+ const inlineConcurrency = concurrencyViews.size > 0 ? concurrencyViews : undefined;
855
1394
  if (ctx.hasUI) {
856
- ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders()), {
1395
+ ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders(), inlineConcurrency), {
857
1396
  placement: 'belowEditor',
858
1397
  });
859
1398
  } else {
860
1399
  if (ctx.ui) {
861
1400
  const cols = (process.stdout.columns ?? 120) - 3;
862
- const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders());
1401
+ const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders(), inlineConcurrency);
863
1402
  ctx.ui.setWidget(key, lines.length > 0 ? lines : undefined, { placement: 'belowEditor' });
864
1403
  }
865
1404
  }
@@ -870,8 +1409,41 @@ export default (pi: ExtensionAPI) => {
870
1409
  // subagent via cache backflow), and re-renders the widget. It never
871
1410
  // recomputes a selection, never applies overrides, never setModel.
872
1411
  const cfg = buildEffectiveConfig();
1412
+ // ── claim the process-wide adaptive-concurrency control loop ──
1413
+ // Only the owning session starts it (same rule as the render tick
1414
+ // above), and the module-level `ownedConcurrency` gate keeps a
1415
+ // subagent binding — or a re-entrant session_start — from starting a
1416
+ // second loop and lease against the same config root. The
1417
+ // `concurrency_enabled` check also short-circuits here so the
1418
+ // disabled path allocates nothing (no transport, no config read).
1419
+ if (claimsOwnership && ownedConcurrency === null && cfg.concurrency_enabled) {
1420
+ const paths = resolveConcurrencyPaths();
1421
+ const controller = createConcurrencyControllerIfEnabled(cfg, {
1422
+ configPath: paths.configPath,
1423
+ agentDir: paths.agentDir,
1424
+ settings: hostSettingsPort(),
1425
+ redis: concurrencyRedisPortForTests !== undefined ? concurrencyRedisPortForTests : aggregationRedisPort(cfg),
1426
+ warn,
1427
+ });
1428
+ if (controller) {
1429
+ ownedConcurrency = {
1430
+ sessionId: ctx.sessionManager?.getSessionId?.() ?? '',
1431
+ controller,
1432
+ };
1433
+ }
1434
+ }
873
1435
  const onTick = () => {
874
- // Fire-and-forget status probe: do not block the tick.
1436
+ // Adaptive concurrency rides the SAME render tick — it owns no
1437
+ // timer; the controller's absolute-time gate (`concurrency_eval_
1438
+ // interval_ms`) decides whether this tick evaluates.
1439
+ const controller = ownedConcurrency?.controller;
1440
+ if (controller) {
1441
+ void controller.onTick().catch((e) => warn(`[omp-opsx-addon] concurrency tick error: ${e}`));
1442
+ }
1443
+ // Fire-and-forget status probe: do not block the tick. Deliberately
1444
+ // NOT filtered by the regional collapse (china-config): probing the
1445
+ // collapsed side keeps its reachability record fresh, so the
1446
+ // selection-pool premise of the collapse never rides stale data.
875
1447
  const { placeholderIds, active } = resolveActiveProviders();
876
1448
  let models: { id: string; provider: string; baseUrl?: string }[] = [];
877
1449
  try {
@@ -897,7 +1469,7 @@ export default (pi: ExtensionAPI) => {
897
1469
  timeoutMs: cfg.probe_timeout_ms,
898
1470
  excluded: cfg.excluded_providers,
899
1471
  },
900
- }).then((written) => { if (written > 0) render(); }).catch(() => {});
1472
+ }).then((written) => { if (written > 0) void render().catch(() => {}); }).catch(() => {});
901
1473
 
902
1474
  sessionUsageRecorder.flush();
903
1475
  // Waveform source = per-second consumption sequence (timestamped
@@ -923,7 +1495,7 @@ export default (pi: ExtensionAPI) => {
923
1495
  }
924
1496
  }
925
1497
  lastSampledSec = lastSec;
926
- render();
1498
+ void render().catch((e) => warn(`[omp-opsx-addon] usage render error: ${e}`));
927
1499
  };
928
1500
  await startSharedPoller(auth, DIRECT_FETCHERS, getApiKey, {
929
1501
  onTick: claimsOwnership ? onTick : undefined,
@@ -941,14 +1513,15 @@ export default (pi: ExtensionAPI) => {
941
1513
  },
942
1514
  });
943
1515
  // One resize listener per process: each new owning session replaces
944
- // the previous handler instead of accumulating. (ExtensionContext
1516
+ // the previous handler in the module-level slot instead of
1517
+ // accumulating across factory re-bindings. (ExtensionContext
945
1518
  // exposes no `signal`, so per-session abort cleanup never fires —
946
1519
  // ownership above, not teardown hooks, keeps the singletons sane.)
947
1520
  if (activeResizeHandler) process.stdout.off('resize', activeResizeHandler);
948
- const onResize = () => render();
1521
+ const onResize = () => void render().catch(() => {});
949
1522
  activeResizeHandler = onResize;
950
1523
  process.stdout.on('resize', onResize);
951
- render();
1524
+ void render().catch((e) => warn(`[omp-opsx-addon] usage render error: ${e}`));
952
1525
  } catch (e) {
953
1526
  warn(`[omp-opsx-addon] usage poller error: ${e}`);
954
1527
  }
@@ -1046,25 +1619,76 @@ export default (pi: ExtensionAPI) => {
1046
1619
  }
1047
1620
  }
1048
1621
  }
1622
+ // The control loop belongs to ONE session: only the owner's own exit
1623
+ // stops it. A subagent's shutdown MUST NOT tear down the root's loop
1624
+ // and release its lease (identity gate, see `ownedConcurrency`).
1625
+ if (ownedConcurrency && sid === ownedConcurrency.sessionId) {
1626
+ const owned = ownedConcurrency;
1627
+ ownedConcurrency = null;
1628
+ try {
1629
+ await owned.controller.shutdown();
1630
+ } catch (e) {
1631
+ warn(`[omp-opsx-addon] concurrency shutdown error: ${e}`);
1632
+ }
1633
+ }
1049
1634
  } catch {
1050
1635
  /* best-effort */
1051
1636
  }
1052
1637
  });
1053
1638
  // ── session lifecycle ─────────────────────────────────────────
1054
- // Pick-model is manual: `/pick-model refresh`. Session start does not pick or setModel.
1639
+ // Pick-model stays manual (`/pick-model refresh`); with autoselect_on_start
1640
+ // (default true) the session_start handler below additionally applies the
1641
+ // configured selection once at startup, so it already holds for turn one.
1055
1642
  const changeScanner = createCachedScanner(30_000);
1056
1643
 
1644
+ // ── W4 routing segment (change: difficulty-routed-selection) ──
1645
+ // The gate wraps ONLY this segment: the handler's existing body below
1646
+ // (systemPrompt injection + change scanning) runs unchanged under every
1647
+ // switch combination — gating never retires the whole handler (P1-1).
1648
+ // Both gates mirror the existing instance rules: the feature switches
1649
+ // (both off → straight out, zero behavior) and the main-instance rule
1650
+ // (subagent bindings never classify or sample). The band/stall comparison
1651
+ // decides whether one force recompute happens this turn; otherwise the
1652
+ // existing health-key caching applies untouched.
1653
+ const runRoutingSegment = async (event: { prompt: string }, ctx: ExtensionContext): Promise<void> => {
1654
+ if (!opsxConfig.difficultyRouting.enabled && !opsxConfig.stallEscalation.enabled) return;
1655
+ if (!(ctx.hasUI || !hasTickOwner())) return;
1656
+ const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
1657
+ const classification = opsxConfig.difficultyRouting.enabled
1658
+ ? classifyTaskSafe(event.prompt)
1659
+ : { band: 'standard' as TaskBand, confidence: 0 };
1660
+ // Stall episode evaluation for this turn (design D4: anchored on the
1661
+ // newest signature, escalated latches once per episode). The window
1662
+ // content is maintained by the tool_execution_start handler below.
1663
+ let stallActive = false;
1664
+ if (opsxConfig.stallEscalation.enabled && st.stall) {
1665
+ stallActive = evaluateStall(st.stall).active;
1666
+ }
1667
+ const prevBand = st.band;
1668
+ const prevStallActive = st.stallActive;
1669
+ st.band = classification.band;
1670
+ st.confidence = classification.confidence;
1671
+ st.stallActive = stallActive;
1672
+ const bandMoved = opsxConfig.difficultyRouting.enabled && prevBand !== classification.band;
1673
+ const stallMoved = opsxConfig.stallEscalation.enabled && prevStallActive !== stallActive;
1674
+ if (bandMoved || stallMoved) await ensureSelection(ctx, true);
1675
+ };
1676
+
1057
1677
  pi.on('before_agent_start', async (event, ctx) => {
1058
1678
  try {
1679
+ // Best-effort isolation (review P2): the segment's force-recompute
1680
+ // awaits poller/Redis and cache-fs paths absent from this handler
1681
+ // pre-W4; a rejection must never reach the outer catch and strip
1682
+ // STATIC_PROMPT + the change-scan segment for the turn.
1683
+ try {
1684
+ await runRoutingSegment(event, ctx);
1685
+ } catch (segErr) {
1686
+ warn(`[omp-opsx-addon] routing segment error: ${segErr}`);
1687
+ }
1059
1688
  // This fallback call is intentionally NOT threaded with familyConstraint:
1060
1689
  // it prefers the cached `selection`, and the fallback passes models=null so
1061
1690
  // 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
- );
1691
+ const sel = selection ?? computeSelectionSync(opsxConfig, null, null);
1068
1692
  const changes = await changeScanner.scanChanges(directory, 'doing');
1069
1693
  // Layered injection: STATIC_PROMPT is byte-identical every turn → prefix cache
1070
1694
  // hit for [omp-base + skills + static]. Only the dynamic tail re-prefills.
@@ -1079,7 +1703,7 @@ export default (pi: ExtensionAPI) => {
1079
1703
  pi.on('session_start', async (_event, ctx) => {
1080
1704
  try {
1081
1705
  const frontmatter = (role: Role): string | undefined =>
1082
- frontmatterModel(role, opsxConfig.roles[role]);
1706
+ frontmatterModel(role, opsxConfig.agents[role]);
1083
1707
  await installAgents(
1084
1708
  directory,
1085
1709
  frontmatter('coder'),
@@ -1087,11 +1711,83 @@ export default (pi: ExtensionAPI) => {
1087
1711
  frontmatter('planner'),
1088
1712
  frontmatter('proposal-reviewer'),
1089
1713
  );
1090
- warn('[omp-opsx-addon] agents ready (pick-model idle until /pick-model refresh)');
1714
+ warn('[omp-opsx-addon] agents ready (selection auto-applies at session start unless autoselect_on_start: false; /pick-model to re-run)');
1091
1715
  } catch (e) {
1092
1716
  warn('[omp-opsx-addon] installAgents error: ' + e);
1093
1717
  }
1094
1718
  });
1719
+
1720
+ // Session-start autoselect: apply the configured selection once so the
1721
+ // first turn already follows it — role overrides through the same apply
1722
+ // path as /pick-model, and the main session moved to the reviewer pick
1723
+ // (no-op when already equal; undefined pick → skip). The once-flag is also
1724
+ // what keeps a later in-session /model switch authoritative: this path
1725
+ // never runs again for the instance, so nothing rewrites the user's
1726
+ // choice. Owner gate before the once-flag: subagent bindings re-run the
1727
+ // factory with independent closures (a closure once-flag cannot see
1728
+ // them), so the process-level owner rule decides — same rule as the
1729
+ // usage/render claim in the first handler. First attempt is awaited
1730
+ // (first-turn-before-prompt contract); cold-start no-pick recovery is a
1731
+ // bounded fire-and-forget retry chain that does not block the handler.
1732
+ // Pins (opsx.yml agents.*) bypass selection via synthPinnedResult and are
1733
+ // never touched here.
1734
+ pi.on('session_start', async (_event, ctx) => {
1735
+ if (!opsxConfig.autoselectOnStart) return;
1736
+ if (!(ctx.hasUI || !hasTickOwner())) return;
1737
+ if (autoApplied) return;
1738
+ autoApplied = true;
1739
+
1740
+ const modelKeyOf = (m: { provider?: string; id?: string } | null | undefined): string | null =>
1741
+ m?.provider && m?.id ? `${m.provider}/${m.id}` : null;
1742
+ const entryModelKey = modelKeyOf(ctx.model);
1743
+
1744
+ const attemptOnce = async (reprobeUnreachable: boolean): Promise<'picked' | 'no-pick' | 'error'> => {
1745
+ try {
1746
+ const sel = await ensureSelection(
1747
+ ctx,
1748
+ false,
1749
+ false,
1750
+ reprobeUnreachable ? { reprobeUnreachable: true } : undefined,
1751
+ );
1752
+ applyRoleModel(sel);
1753
+ const picked = sel.selectorResults.reviewer?.picked;
1754
+ if (picked && ctx.model && `${ctx.model.provider}/${ctx.model.id}` !== `${picked.provider}/${picked.id}`) {
1755
+ await pi.setModel(picked);
1756
+ }
1757
+ return picked ? 'picked' : 'no-pick';
1758
+ } catch (e) {
1759
+ warn(`[omp-opsx-addon] session-start autoselect error: ${e}`);
1760
+ return 'error';
1761
+ }
1762
+ };
1763
+
1764
+ const first = await attemptOnce(false);
1765
+ if (first !== 'no-pick') return;
1766
+
1767
+ // Fire-and-forget bounded recovery: first attempt already failed with
1768
+ // no reviewer pick (typical cold-start probe/registry lag). Do not
1769
+ // await this chain in the handler — that would delay the first turn.
1770
+ void (async () => {
1771
+ try {
1772
+ for (const delay of AUTOSELECT_RETRY_DELAYS_MS) {
1773
+ await new Promise<void>((r) => setTimeout(r, delay));
1774
+ // Yield to an explicit user /model or manual /pick-model
1775
+ // (both change the live session model via setModel).
1776
+ const live = typeof ctx.models?.current === 'function' ? ctx.models.current() : undefined;
1777
+ if (live !== undefined && live !== null && modelKeyOf(live) !== entryModelKey) {
1778
+ return;
1779
+ }
1780
+ const outcome = await attemptOnce(true);
1781
+ if (outcome !== 'no-pick') return;
1782
+ }
1783
+ warn(
1784
+ '[omp-opsx-addon] session-start autoselect: retries exhausted; run /pick-model to apply manually',
1785
+ );
1786
+ } catch (e) {
1787
+ warn(`[omp-opsx-addon] session-start autoselect retry error: ${e}`);
1788
+ }
1789
+ })();
1790
+ });
1095
1791
  // ── Diagnostic-only error path (429/403; never auto-switches) ─
1096
1792
  const diagnoseProviderError = async (ctx: ExtensionContext, source: string, errorType: '429' | '403'): Promise<void> => {
1097
1793
  const currentModel = ctx.model;
@@ -1105,6 +1801,10 @@ export default (pi: ExtensionAPI) => {
1105
1801
  // without a usage fetcher — record it in the process-local binary status
1106
1802
  // so the placeholder column reads 耗å°Ŋ (see lib/provider-status.ts).
1107
1803
  markExhausted(canonical);
1804
+ // Adaptive-concurrency signal ①: hand the observed explicit failure to
1805
+ // the control loop as a COUNTED event only. The diagnosis text and the
1806
+ // "never auto-switch provider" policy below are unchanged (task 6.5).
1807
+ ownedConcurrency?.controller.noteExplicitFailure(canonical, errorType);
1108
1808
  // Force-refresh just this provider (bypassing the 10s floor) so the
1109
1809
  // diagnosis reflects current quota.
1110
1810
  if (auth) await pokeProvider(auth, DIRECT_FETCHERS, getApiKey, { providers: [canonical], force: true });
@@ -1148,6 +1848,44 @@ export default (pi: ExtensionAPI) => {
1148
1848
  warn(`[omp-opsx-addon] tool_execution_end error: ${e}`);
1149
1849
  }
1150
1850
  });
1851
+ // ── W4 stall sampling (change: difficulty-routed-selection; read-only) ──
1852
+ // Freshly registered handlers (unlike before_agent_start, which predates
1853
+ // W4): the first-line switch gate IS the whole disabled behavior — zero
1854
+ // sampling, zero state writes. Main-instance gate mirrors autoselect:
1855
+ // subagent bindings never sample (design D4). The window only shapes the
1856
+ // NEXT selection recompute's inputs; nothing here switches a live model
1857
+ // and the 429/403 diagnostics above are untouched (T4).
1858
+ pi.on('tool_execution_start', async (event, ctx) => {
1859
+ if (!opsxConfig.stallEscalation.enabled) return;
1860
+ if (!(ctx.hasUI || !hasTickOwner())) return;
1861
+ try {
1862
+ const sessionId = ctx.sessionManager?.getSessionId?.() ?? '';
1863
+ const st = routingStateFor(sessionId);
1864
+ if (!st.stall) return;
1865
+ recordToolCall(st.stall, sessionId, toolSignature(event.toolName, event.args));
1866
+ } catch {
1867
+ /* stall sampling is best-effort; stay silent */
1868
+ }
1869
+ });
1870
+ pi.on('turn_end', async (_event, ctx) => {
1871
+ if (!opsxConfig.stallEscalation.enabled) return;
1872
+ if (!(ctx.hasUI || !hasTickOwner())) return;
1873
+ try {
1874
+ const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
1875
+ if (!st.stall) return;
1876
+ // Transcription fallback (design D4): the event stream is the
1877
+ // primary source; when it delivered nothing for this session
1878
+ // (process reload), rebuild the near window from the read-only
1879
+ // transcript. Empty/corrupt entries idle the feature — never a
1880
+ // downgrade, no warn noise into the selection path.
1881
+ if (st.stall.signatures.length === 0) {
1882
+ rebuildFromEntries(st.stall, ctx.sessionManager.getEntries());
1883
+ }
1884
+ evaluateStall(st.stall);
1885
+ } catch {
1886
+ /* stall evaluation is best-effort; stay silent */
1887
+ }
1888
+ });
1151
1889
  pi.on('input', async (event) => {
1152
1890
  const match = event.text.match(/^@(coder|reviewer|planner|proposal-reviewer|code-reviewer)\b(.*)$/i);
1153
1891
  if (!match) return;
@@ -1165,12 +1903,13 @@ export default (pi: ExtensionAPI) => {
1165
1903
 
1166
1904
  pi.setLabel('pick-model', 'æ¨Ąåž‹é€‰æ‹Š');
1167
1905
  pi.registerCommand('pick-model', {
1168
- description: 'æ¨Ąåž‹įŽĄį†īŧšchoices åą•į¤ē / refresh 重选 / update æ›´æ–°æĄŖäŊ / <selector> [--china] 按选拊器įēĻæŸīŧˆčŖ¸č¯Âˇglob¡provider/id 双æŽĩīŧŒåĻ‚ zhipu*/glm*īŧ‰/ reset 清除įēĻæŸ',
1906
+ description: 'æ¨Ąåž‹įŽĄį†īŧšchoices åą•į¤ē / refresh 重选 / update æ›´æ–°æĄŖäŊ / [roleList:]<selector> [--china] 按 selector įēĻæŸīŧˆå¯ role 厚向īŧŒåĻ‚ smol:glm*īŧ›č¯čĄ¨ smol/default/â€Ļ+agent åˆĢ名/allīŧ›čŖ¸č¯Âˇglob¡provider/id 双æŽĩīŧ‰/ reset 清除įēĻæŸ',
1169
1907
  handler: async (args: string, ctx: ExtensionCommandContext) => {
1170
1908
  try {
1909
+ const symbols = resolveSymbols(pi.pi?.settings);
1171
1910
  const parsed = parseAutoModelArgs(args);
1172
1911
  if (parsed.kind === 'error') {
1173
- pi.sendMessage({ customType: 'pick-model', content: `❌ ${parsed.message}`, display: true }, { triggerTurn: false });
1912
+ pi.sendMessage({ customType: 'pick-model', content: `${symbols.error} ${parsed.message}`, display: true }, { triggerTurn: false });
1174
1913
  return;
1175
1914
  }
1176
1915
 
@@ -1178,8 +1917,8 @@ export default (pi: ExtensionAPI) => {
1178
1917
  if (parsed.kind === 'update') {
1179
1918
  const result = updateTiers();
1180
1919
  const msg = result.ok
1181
- ? `✅ Tiers data updated: ${result.count} entries`
1182
- : `❌ Update failed: ${result.error}`;
1920
+ ? `${symbols.success} Tiers data updated: ${result.count} entries`
1921
+ : `${symbols.error} Update failed: ${result.error}`;
1183
1922
  pi.sendMessage({ customType: 'pick-model', content: msg, display: true }, { triggerTurn: false });
1184
1923
  return;
1185
1924
  }
@@ -1196,15 +1935,20 @@ export default (pi: ExtensionAPI) => {
1196
1935
  firstUnreachableProviders = reach.unreachable;
1197
1936
  availableByProvider = reach.availableByProvider;
1198
1937
  const health = getSharedUsage()?.health ?? null;
1938
+ const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
1199
1939
  const freshSel = computeSelectionSync(
1200
1940
  effectiveConfig,
1201
1941
  models,
1202
1942
  health,
1203
- currentKnownRoleIds(),
1204
1943
  regionBlockedModels,
1205
1944
  reach.unreachable,
1206
1945
  reach.availableByProvider,
1207
1946
  familyConstraint,
1947
+ collapsed,
1948
+ warn,
1949
+ // /pick-model refresh: explicit path (design D5) —
1950
+ // guard bypassed, no fresh classification.
1951
+ routingRuntimeFor(ctx, true),
1208
1952
  );
1209
1953
  const changes: string[] = [];
1210
1954
  // Check primary session model
@@ -1215,33 +1959,47 @@ export default (pi: ExtensionAPI) => {
1215
1959
  const primaryBest = primaryResult?.candidates[0];
1216
1960
  if (primaryBest && `${currentModel.provider}/${currentModel.id}` !== `${primaryBest.model.provider}/${primaryBest.model.id}`) {
1217
1961
  await pi.setModel(primaryBest.model);
1218
- changes.push(`🏠 ä¸ģäŧšč¯ → ${primaryBest.model.provider}/${primaryBest.model.id}`);
1962
+ changes.push(formatChangeLine({
1963
+ name: 'ä¸ģäŧšč¯',
1964
+ to: `${primaryBest.model.provider}/${primaryBest.model.id}`,
1965
+ coverage: coverageLabel(primaryBest.coverageClass),
1966
+ tier: tierLabel(primaryBest.tier),
1967
+ }));
1219
1968
  }
1220
1969
  }
1221
1970
  // Check all agent models
1222
- for (const role of OPSX_ROLES) {
1971
+ for (const role of OPSX_AGENTS) {
1223
1972
  const result = freshSel.selectorResults[role];
1224
1973
  const picked = result?.picked;
1225
1974
  if (!picked) continue;
1226
1975
  const agent = AGENT_MAP[role];
1227
1976
  const pickedSel = `${picked.provider}/${picked.id}`;
1228
- const roleCfg = opsxConfig.roles[role];
1977
+ const roleCfg = opsxConfig.agents[role];
1229
1978
  if (roleCfg.kind === 'model' || roleCfg.kind === 'provider-model') {
1230
1979
  const pinned = roleValueToModelString(roleCfg);
1231
1980
  if (pinned && pinned !== pickedSel) {
1232
- changes.push(`${agent} pinned ${pinned} → auto-pick ${pickedSel}`);
1981
+ changes.push(formatChangeLine({
1982
+ name: `${agent}īŧˆåˇ˛æŒ‡åޚ ${pinned}īŧ‰`,
1983
+ to: pickedSel,
1984
+ ...annotationOfPicked(result, picked),
1985
+ kind: roleKindLabel(freshSel.roleKind[role]),
1986
+ }));
1233
1987
  }
1234
1988
  } else {
1235
- changes.push(`${agent} → ${pickedSel}`);
1989
+ changes.push(formatChangeLine({
1990
+ name: agent,
1991
+ to: pickedSel,
1992
+ ...annotationOfPicked(result, picked),
1993
+ kind: roleKindLabel(freshSel.roleKind[role]),
1994
+ }));
1236
1995
  }
1237
1996
  }
1238
1997
  selection = freshSel;
1239
1998
  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
- }
1999
+ const content = changes.length > 0
2000
+ ? renderPickModelReport('refresh', { action: 'åˇ˛åˆˇæ–°', constraint: formatFamilyConstraint(familyConstraint) || undefined, detail: [changes.join('\n')] }, symbols)
2001
+ : renderPickModelReport('refresh', { action: 'åˇ˛åˆˇæ–° ¡ æ‰€æœ‰æ¨Ąåž‹įŠļæ€æ­Ŗå¸¸īŧŒæ— éœ€åˆ‡æĸ' }, symbols);
2002
+ pi.sendMessage({ customType: 'pick-model', content, display: true }, { triggerTurn: false });
1245
2003
  return;
1246
2004
  }
1247
2005
 
@@ -1259,15 +2017,20 @@ export default (pi: ExtensionAPI) => {
1259
2017
  firstUnreachableProviders = reach.unreachable;
1260
2018
  availableByProvider = reach.availableByProvider;
1261
2019
  const health = getSharedUsage()?.health ?? null;
2020
+ const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
1262
2021
  const sel = computeSelectionSync(
1263
2022
  effectiveConfig,
1264
2023
  models,
1265
2024
  health,
1266
- currentKnownRoleIds(),
1267
2025
  regionBlockedModels,
1268
2026
  reach.unreachable,
1269
2027
  reach.availableByProvider,
1270
2028
  familyConstraint,
2029
+ collapsed,
2030
+ warn,
2031
+ // /pick-model reset: explicit path (design D5) —
2032
+ // guard bypassed, no fresh classification.
2033
+ routingRuntimeFor(ctx, true),
1271
2034
  );
1272
2035
  const changes: string[] = [];
1273
2036
  const currentModel = ctx.model;
@@ -1275,25 +2038,37 @@ export default (pi: ExtensionAPI) => {
1275
2038
  if (newPrimary) {
1276
2039
  const newSel = `${newPrimary.provider}/${newPrimary.id}`;
1277
2040
  const oldSel = currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined;
1278
- changes.push(oldSel && oldSel !== newSel ? `🏠 ä¸ģäŧšč¯ ${oldSel} → ${newSel}` : `🏠 ä¸ģäŧšč¯ → ${newSel}`);
2041
+ changes.push(formatChangeLine({
2042
+ name: 'ä¸ģäŧšč¯',
2043
+ from: oldSel && oldSel !== newSel ? oldSel : undefined,
2044
+ to: newSel,
2045
+ ...annotationOfPicked(sel.selectorResults.reviewer, newPrimary),
2046
+ }));
1279
2047
  }
1280
- for (const role of OPSX_ROLES) {
2048
+ for (const role of OPSX_AGENTS) {
1281
2049
  const picked = sel.selectorResults[role]?.picked;
1282
2050
  if (!picked) continue;
1283
2051
  const newSel = `${picked.provider}/${picked.id}`;
1284
2052
  const prev = selection?.selectorResults[role]?.picked;
1285
2053
  const oldSel = prev ? `${prev.provider}/${prev.id}` : undefined;
1286
- changes.push(`${AGENT_MAP[role]} ${oldSel ?? '(none)'} → ${newSel}`);
2054
+ changes.push(formatChangeLine({
2055
+ name: AGENT_MAP[role],
2056
+ from: oldSel ?? '(none)',
2057
+ to: newSel,
2058
+ ...annotationOfPicked(sel.selectorResults[role], picked),
2059
+ kind: roleKindLabel(sel.roleKind[role]),
2060
+ }));
1287
2061
  }
1288
2062
  if (newPrimary) await pi.setModel(newPrimary);
1289
2063
  selection = sel;
1290
- selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
2064
+ selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
1291
2065
  applyRoleModel(sel);
1292
2066
  pi.sendMessage({
1293
2067
  customType: 'pick-model',
1294
- content: changes.length > 0
1295
- ? `✅ åˇ˛æ¸…é™¤ selector įēĻæŸīŧŒæĸ复éģ˜čŽ¤é€‰æ‹Š:\n${changes.join('\n')}`
1296
- : `✅ åˇ˛æ¸…é™¤ selector įēĻæŸīŧŒæĸ复éģ˜čŽ¤é€‰æ‹Šīŧˆæ— å˜åŒ–īŧ‰`,
2068
+ content: renderPickModelReport('reset', {
2069
+ action: changes.length > 0 ? 'åˇ˛æ¸…é™¤ selector įēĻæŸīŧŒæĸ复éģ˜čŽ¤é€‰æ‹Š' : 'åˇ˛æ¸…é™¤ selector įēĻæŸīŧŒæĸ复éģ˜čŽ¤é€‰æ‹Šīŧˆæ— å˜åŒ–īŧ‰',
2070
+ detail: changes.length > 0 ? [changes.join('\n')] : undefined,
2071
+ }, symbols),
1297
2072
  display: true,
1298
2073
  }, { triggerTurn: false });
1299
2074
  return;
@@ -1312,6 +2087,24 @@ export default (pi: ExtensionAPI) => {
1312
2087
  availableByProvider = reach.availableByProvider;
1313
2088
  const health = getSharedUsage()?.health ?? null;
1314
2089
  const excludeSelectors = parsed.china ? CHINA_EXCLUDE_FAMILIES : [];
2090
+ const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
2091
+
2092
+ // Fail fast on an inexpressible scope (model_role_tiers skip
2093
+ // sentinel) BEFORE touching any session state: a skip role
2094
+ // sits on no tier slot, so a pool constraint on it cannot be
2095
+ // expressed — never silently narrow the scope or no-op.
2096
+ let scopedSlots: ReadonlySet<TierName> | null = null;
2097
+ if (parsed.roleScope) {
2098
+ try {
2099
+ scopedSlots = scopedTierSlots(parsed.roleScope, opsxConfig.ompRoleTiers);
2100
+ } catch (e) {
2101
+ if (e instanceof ScopedRoleSkipError) {
2102
+ pi.sendMessage({ customType: 'pick-model', content: `${symbols.error} ${e.message}`, display: true }, { triggerTurn: false });
2103
+ return;
2104
+ }
2105
+ throw e;
2106
+ }
2107
+ }
1315
2108
 
1316
2109
  // Switch the session-scoped state BEFORE any recompute so every
1317
2110
  // later recompute path (ensureSelection, 429/403 auto-switch,
@@ -1320,25 +2113,35 @@ export default (pi: ExtensionAPI) => {
1320
2113
  // command restores it rather than silently dropping an active one.
1321
2114
  const prevConstraint = familyConstraint;
1322
2115
  const prevEffective = effectiveConfig;
1323
- familyConstraint = { selectors: parsed.selectors, excludeSelectors };
1324
- effectiveConfig = buildEffectiveConfig();
2116
+ familyConstraint = { selectors: parsed.selectors, excludeSelectors, roles: parsed.roleScope ?? null };
2117
+ effectiveConfig = buildEffectiveConfig(scopedSlots);
1325
2118
 
1326
2119
  // Fail fast: an empty candidate set (or a role that cannot pick)
1327
2120
  // is a hard error — never silently pick nothing or change models.
2121
+ // Mirror computeSelectionSync's pool shape exactly (variant
2122
+ // collapse on the provider axis + command exclude else standing
2123
+ // china default) so the precheck cannot pass on a pool the real
2124
+ // recompute would keep, or vice versa.
2125
+ const standingExclude = opsxConfig.china ? CHINA_EXCLUDE_FAMILIES : undefined;
1328
2126
  const preFiltered = filterSelectableModels(models, {
1329
2127
  blockedSelectors: regionBlockedModels,
1330
2128
  unreachableProviders: reach.unreachable,
1331
- excludedProviders: effectiveConfig.excluded_providers,
2129
+ excludedProviders: [...effectiveConfig.excluded_providers, ...collapsed],
1332
2130
  availableByProvider: reach.availableByProvider,
1333
2131
  selectors: parsed.selectors ?? undefined,
1334
- excludeSelectors: excludeSelectors.length > 0 ? excludeSelectors : undefined,
2132
+ excludeSelectors: excludeSelectors.length > 0 ? excludeSelectors : standingExclude,
2133
+ providerModels: Object.keys(effectiveConfig.providerModels).length > 0
2134
+ ? new Map(Object.entries(effectiveConfig.providerModels))
2135
+ : undefined,
1335
2136
  });
1336
2137
  if (preFiltered.length === 0) {
1337
2138
  familyConstraint = prevConstraint;
1338
2139
  effectiveConfig = prevEffective;
1339
2140
  pi.sendMessage({
1340
2141
  customType: 'pick-model',
1341
- content: `❌ selector įēĻæŸä¸‹æ— å¯į”¨æ¨Ąåž‹ã€‚\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
2142
+ content: renderPickModelReport('error', {
2143
+ action: `selector įēĻæŸä¸‹æ— å¯į”¨æ¨Ąåž‹ã€‚\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
2144
+ }, symbols),
1342
2145
  display: true,
1343
2146
  }, { triggerTurn: false });
1344
2147
  return;
@@ -1348,19 +2151,32 @@ export default (pi: ExtensionAPI) => {
1348
2151
  effectiveConfig,
1349
2152
  models,
1350
2153
  health,
1351
- currentKnownRoleIds(),
1352
2154
  regionBlockedModels,
1353
2155
  reach.unreachable,
1354
2156
  reach.availableByProvider,
1355
2157
  familyConstraint,
2158
+ collapsed,
2159
+ warn,
2160
+ // /pick-model <selector>: explicit path (design D5) —
2161
+ // guard bypassed, no fresh classification.
2162
+ routingRuntimeFor(ctx, true),
2163
+ );
2164
+ // pick-model-role-scope: only roles this command actually put
2165
+ // into the pool (roleKind 'auto' in the effective config) can
2166
+ // fail to pick. Out-of-scope pinned/omp-alias roles keep their
2167
+ // config value and synthPinnedResult never carries a picked —
2168
+ // counting them would roll back every scoped constraint.
2169
+ const emptyRole = OPSX_AGENTS.find(
2170
+ (r) => sel.roleKind[r] === 'auto' && !sel.selectorResults[r]?.picked,
1356
2171
  );
1357
- const emptyRole = OPSX_ROLES.find((r) => !sel.selectorResults[r]?.picked);
1358
2172
  if (emptyRole) {
1359
2173
  familyConstraint = prevConstraint;
1360
2174
  effectiveConfig = prevEffective;
1361
2175
  pi.sendMessage({
1362
2176
  customType: 'pick-model',
1363
- content: `❌ selector įēĻæŸä¸‹æ— æŗ•ä¸ē role "${emptyRole}" é€‰æ‹Šæ¨Ąåž‹ã€‚\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
2177
+ content: renderPickModelReport('error', {
2178
+ action: `selector įēĻæŸä¸‹æ— æŗ•ä¸ē role "${emptyRole}" é€‰æ‹Šæ¨Ąåž‹ã€‚\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
2179
+ }, symbols),
1364
2180
  display: true,
1365
2181
  }, { triggerTurn: false });
1366
2182
  return;
@@ -1369,7 +2185,7 @@ export default (pi: ExtensionAPI) => {
1369
2185
  const currentModel = ctx.model;
1370
2186
  const oldPrimary = currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined;
1371
2187
  const oldRolePicks: Record<string, string | undefined> = {};
1372
- for (const role of OPSX_ROLES) {
2188
+ for (const role of OPSX_AGENTS) {
1373
2189
  const prev = selection?.selectorResults[role]?.picked;
1374
2190
  oldRolePicks[role] = prev ? `${prev.provider}/${prev.id}` : undefined;
1375
2191
  }
@@ -1378,14 +2194,25 @@ export default (pi: ExtensionAPI) => {
1378
2194
  const changes: string[] = [];
1379
2195
  if (newPrimary) {
1380
2196
  const newSel = `${newPrimary.provider}/${newPrimary.id}`;
1381
- changes.push(oldPrimary && oldPrimary !== newSel ? `🏠 ä¸ģäŧšč¯ ${oldPrimary} → ${newSel}` : `🏠 ä¸ģäŧšč¯ → ${newSel}`);
2197
+ changes.push(formatChangeLine({
2198
+ name: 'ä¸ģäŧšč¯',
2199
+ from: oldPrimary && oldPrimary !== newSel ? oldPrimary : undefined,
2200
+ to: newSel,
2201
+ ...annotationOfPicked(sel.selectorResults.reviewer, newPrimary),
2202
+ }));
1382
2203
  }
1383
- for (const role of OPSX_ROLES) {
2204
+ for (const role of OPSX_AGENTS) {
1384
2205
  const picked = sel.selectorResults[role]?.picked;
1385
2206
  if (!picked) continue;
1386
2207
  const newSel = `${picked.provider}/${picked.id}`;
1387
2208
  const oldSel = oldRolePicks[role];
1388
- changes.push(`${AGENT_MAP[role]} ${oldSel ?? '(none)'} → ${newSel}`);
2209
+ changes.push(formatChangeLine({
2210
+ name: AGENT_MAP[role],
2211
+ from: oldSel ?? '(none)',
2212
+ to: newSel,
2213
+ ...annotationOfPicked(sel.selectorResults[role], picked),
2214
+ kind: roleKindLabel(sel.roleKind[role]),
2215
+ }));
1389
2216
  }
1390
2217
 
1391
2218
  // Cross-provider ambiguity hint (design Decision 8): only when a
@@ -1407,9 +2234,14 @@ export default (pi: ExtensionAPI) => {
1407
2234
 
1408
2235
  if (newPrimary) await pi.setModel(newPrimary);
1409
2236
  selection = sel;
1410
- selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
2237
+ selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
1411
2238
  applyRoleModel(sel);
1412
- const content = `✅ åˇ˛åˆ‡æĸ selector įēĻæŸ (${formatFamilyConstraint(familyConstraint)}):\n${changes.join('\n')}${hint ? `\n${hint}` : ''}`;
2239
+ const content = renderPickModelReport('switch', {
2240
+ action: 'åˇ˛åˆ‡æĸ selector įēĻæŸ',
2241
+ constraint: formatFamilyConstraint(familyConstraint) || undefined,
2242
+ detail: changes.length > 0 ? [changes.join('\n')] : undefined,
2243
+ footnote: hint || undefined,
2244
+ }, symbols);
1413
2245
  pi.sendMessage({
1414
2246
  customType: 'pick-model',
1415
2247
  content,
@@ -1420,22 +2252,59 @@ export default (pi: ExtensionAPI) => {
1420
2252
 
1421
2253
  // ── /pick-model choices (default) ────────────────
1422
2254
  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 });
2255
+ const roleLines = formatRoleLines(
2256
+ OPSX_AGENTS.map((role) => ({
2257
+ name: AGENT_MAP[role],
2258
+ kind: sel.roleKind[role],
2259
+ fallbackModel: sel.roleModels[role],
2260
+ result: sel.selectorResults[role],
2261
+ })),
2262
+ ctx.model
2263
+ ? {
2264
+ model: { provider: ctx.model.provider, id: ctx.model.id },
2265
+ tier: scoreModelWithProvider(ctx.model.provider, ctx.model.id, ctx.model.name, undefined).tier,
2266
+ }
2267
+ : undefined,
2268
+ );
2269
+ // Full agent inventory (project + user + bundled, host-merged).
2270
+ // Failure downgrades to omitting the section + warn; never blocks.
2271
+ let agentInventory: string | undefined;
2272
+ try {
2273
+ agentInventory = formatAgentInventory((await discoverAgents(directory)).agents, symbols);
2274
+ } catch (err) {
2275
+ warn(`[omp-opsx-addon] agent inventory unavailable: ${err instanceof Error ? err.message : String(err)}`);
2276
+ }
2277
+ const detail: string[] = [];
2278
+ if (roleLines.length > 0) detail.push(roleLines.join('\n'));
2279
+ if (agentInventory) detail.push(agentInventory);
2280
+ const concurrencySummary = ownedConcurrency?.controller.summaryLines() ?? [];
2281
+ // Regional-variant appendix line (china-config): resolved fresh from
2282
+ // the live registry with the same resolution the recompute paths use.
2283
+ const poolModels = ctx.modelRegistry?.getAvailable() ?? [];
2284
+ const choicesGetApiKey = ctx.modelRegistry?.getApiKeyForProvider
2285
+ ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
2286
+ : undefined;
2287
+ const variantLine = variantCollapseAnnotation(
2288
+ await resolveVariantState(poolModels, choicesGetApiKey, {
2289
+ unreachable: firstUnreachableProviders,
2290
+ availableByProvider,
2291
+ }),
2292
+ );
2293
+ const appendixParts: string[] = [];
2294
+ if (concurrencySummary.length > 0) appendixParts.push('**åšļ发控åˆļ**', ...concurrencySummary);
2295
+ if (variantLine) appendixParts.push(variantLine);
2296
+ const content = renderPickModelReport('choices', {
2297
+ action: 'åŊ“前选拊',
2298
+ constraint: formatFamilyConstraint(familyConstraint) || undefined,
2299
+ detail,
2300
+ overview: formatCandidateOverview(OPSX_AGENTS.map((role) => sel.selectorResults[role])),
2301
+ appendix: appendixParts.length > 0 ? appendixParts.join('\n') : undefined,
2302
+ footnote: formatFootnote(OPSX_AGENTS.map((role) => sel.selectorResults[role]), sel.paygLastResort),
2303
+ }, symbols);
2304
+ pi.sendMessage({ customType: 'pick-model', content, display: true }, { triggerTurn: false });
1436
2305
  } catch (e) {
1437
2306
  warn(`[omp-opsx-addon] /pick-model error: ${e}`);
1438
- pi.sendMessage({ customType: 'pick-model', content: `❌ Error: ${e}`, display: true }, { triggerTurn: false });
2307
+ pi.sendMessage({ customType: 'pick-model', content: `${resolveSymbols(pi.pi?.settings).error} Error: ${e}`, display: true }, { triggerTurn: false });
1439
2308
  }
1440
2309
  },
1441
2310
  });
@@ -1461,7 +2330,9 @@ export default (pi: ExtensionAPI) => {
1461
2330
 
1462
2331
  async function getSelectionForCommand(ctx: ExtensionCommandContext): Promise<SelectionContext> {
1463
2332
  if (selection) return selection;
1464
- return ensureSelection(ctx);
2333
+ // Explicit command path: guard bypassed, no fresh classification
2334
+ // (design D5 — the lazy compute exists only for a cache miss).
2335
+ return ensureSelection(ctx, false, true);
1465
2336
  }
1466
2337
 
1467
2338
  // Cross-broker project-scoped pipe core: registers opsx_pipe_list /