@genee/omp-opsx-addon 0.1.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 ADDED
@@ -0,0 +1,1327 @@
1
+ // ── subagent usage tracking ───────────────────────────────────────────
2
+ // The TASK_SUBAGENT_EVENT_CHANNEL is used to forward subagent events to
3
+ // the primary session's eventBus. Subagent message_update/message_end events
4
+ // are wrapped in SubagentEventPayload { id, event } and sent through this channel.
5
+ // The primary session subscribes to this channel to record subagent usage into
6
+ // the primary's recorder. This avoids the module-level singleton issue where
7
+ // each plugin instance has its own recorder.
8
+ //
9
+ // IMPORTANT: Plugins cannot directly subscribe to eventBus channels via the
10
+ // official ExtensionAPI. We access the eventBus through internal properties as a
11
+ // pragmatic workaround. The channel string is hardcoded from harness source:
12
+ // src/task/types.ts:59 -> "task:subagent:event"
13
+ //
14
+ // NOTE: This is a hack that depends on internal harness implementation details.
15
+ // Future harness versions may break this if internal properties change.
16
+ //
17
+ // Reference:
18
+ // - emitSubagentFrame: src/task/executor.ts:1431-1432
19
+ // - SubagentEventPayload: src/task/types.ts:82-84
20
+ // - EventBus.on(): src/utils/event-bus.ts:15-28
21
+ const TASK_SUBAGENT_EVENT_CHANNEL = "task:subagent:event";
22
+
23
+ // Track unsubscribers for cleanup on session_shutdown
24
+ const subagentEventUnsubscribers = new Map<string, () => void>();
25
+
26
+ // Helper to safely access eventBus from ctx
27
+ function getEventBusFromCtx(ctx: ExtensionContext | ExtensionCommandContext): unknown {
28
+ // Try to access eventBus through various internal properties
29
+ // This is a hack that depends on harness implementation details
30
+ const sessionManager = ctx.sessionManager as Record<string, unknown>;
31
+ // Try common property names
32
+ for (const key of ['session', '_session', '__session', 'eventBus', '_eventBus']) {
33
+ const val = (sessionManager as Record<string, unknown>)[key];
34
+ if (val && typeof val === 'object') {
35
+ const obj = val as Record<string, unknown>;
36
+ if (typeof obj.eventBus !== 'undefined') {
37
+ return obj.eventBus;
38
+ }
39
+ if (key === 'eventBus' || key === '_eventBus') {
40
+ return val;
41
+ }
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ // Helper to subscribe to eventBus channel
48
+ function subscribeToEventBusChannel(
49
+ eventBus: unknown,
50
+ channel: string,
51
+ handler: (data: unknown) => void,
52
+ ): () => void {
53
+ const bus = eventBus as Record<string, unknown>;
54
+ const onMethod = bus.on as ((channel: string, handler: (data: unknown) => void) => () => void) | undefined;
55
+ if (typeof onMethod === 'function') {
56
+ return onMethod.call(bus, channel, handler);
57
+ }
58
+ // Return no-op if eventBus doesn't support subscription
59
+ return () => {};
60
+ }
61
+
62
+ // Process subagent event payload
63
+ function handleSubagentEvent(payload: unknown, primarySessionId: string): void {
64
+ try {
65
+ if (!payload || typeof payload !== 'object') {
66
+ return;
67
+ }
68
+ // The channel frame carries the subagent's AgentSessionEvent
69
+ // ({ type, message, assistantMessageEvent }), not a flat assistant
70
+ // message, so the top level has no role/provider/usage. Dispatch by
71
+ // event type, mirroring the primary message_update/message_end handlers.
72
+ const p = payload as { id?: string; event?: { type?: string; message?: unknown; assistantMessageEvent?: unknown } };
73
+ const ev = p.event;
74
+ if (!ev || typeof ev !== 'object') {
75
+ return;
76
+ }
77
+
78
+ let usageResult: { provider: string; usage: UsageBucket } | undefined;
79
+ if (ev.type === 'message_update') {
80
+ // assistantMessageEvent may be shapes other than partial
81
+ // (toolcall_start etc.); only 'partial' carries usage.
82
+ const ame = ev.assistantMessageEvent;
83
+ if (ame && typeof ame === 'object' && 'partial' in ame) {
84
+ usageResult = assistantUsageOf(ame.partial);
85
+ } else {
86
+ return;
87
+ }
88
+ } else if (ev.type === 'message_end') {
89
+ usageResult = assistantUsageOf(ev.message);
90
+ } else {
91
+ // Other agent-session event types carry no billable usage.
92
+ return;
93
+ }
94
+ if (!usageResult) {
95
+ return;
96
+ }
97
+
98
+ const { provider, usage } = usageResult;
99
+
100
+ // Use subagent-specific key for stream tracking (streamKey only, NOT setSessionId)
101
+ // This prevents cross-stream contamination while keeping global totals intact
102
+ const subagentStreamKey = `${primarySessionId}:subagent:${p.id}`;
103
+
104
+ // Record usage with subagent-specific stream key (NO setSessionId call)
105
+ sessionUsageRecorder.record(provider, usage, subagentStreamKey);
106
+ } catch (err) {
107
+ // Silently fail to avoid breaking the plugin
108
+ console.error('Failed to handle subagent event:', err);
109
+ }
110
+ }
111
+ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@oh-my-pi/pi-coding-agent';
112
+ import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
113
+ import type { UsageReport } from '@oh-my-pi/pi-ai';
114
+ import { createCachedScanner } from './lib/change-scanner.js';
115
+ import { installAgents } from './lib/agent-defs.js';
116
+ import { buildStaticPrompt, buildDynamicPrompt } from './lib/system-prompt.js';
117
+ import { type ProviderHealth, canonicalizeProvider, listDetectableProviders, healthCacheKey } from './lib/usage-resolver.js';
118
+ import { readOpsxSettings, type ResolvedOpsxConfig, type RoleValue, OPSX_ROLES } from './lib/unified-config.js';
119
+ import { selectAllRoles, mergePlanProviders, type SelectorResult } from './lib/model-selector.js';
120
+ import { scoreModelWithProvider } from './lib/model-tiers.js';
121
+ import { DIRECT_FETCHERS } from './lib/direct-fetchers.js';
122
+ import { createUsageWidget } from './lib/usage-widget.js';
123
+ import { renderUsageReports, type ConsumptionTrack } from './lib/usage-render.js';
124
+ import {
125
+ startSharedPoller,
126
+ getUnconfirmedProviders,
127
+ isResetImminent,
128
+ getSharedUsage,
129
+ pokeProvider,
130
+ hasTickOwner,
131
+ getSessionUsageTotals,
132
+ getSessionUsagePerSecond,
133
+ writeSessionUsage,
134
+ removeSessionUsage,
135
+ mirrorSessionDelta,
136
+ hasManagedSessionUsage,
137
+ } from './lib/usage-poller.js';
138
+ import {
139
+ createSessionUsageRecorder,
140
+ assistantUsageOf,
141
+ SESSION_USAGE_WRITE_MS,
142
+ type SessionUsageSink,
143
+ type SessionUsageRecorder,
144
+ } from './lib/session-usage.js';
145
+ import { updateTiers, autoRefreshTiersIfStale } from './lib/tiers-updater.js';
146
+ import { appendSample, getConsumptionSamples, RING_SIZE } from './lib/usage-sampler.js';
147
+ import { buildBalanceEstimates, type UsageBucket } from './lib/usage-estimator.js';
148
+ import { matchDataEntry, TIERS_DATA_PATH, isTiersDataLoaded, tiersDataAge } from './lib/tiers-data.js';
149
+ import {
150
+ loadBlockedModels,
151
+ persistBlockedModels,
152
+ getUnreachableProviders,
153
+ } from './lib/reachability-cache.js';
154
+ import { refreshReachability } from './lib/reachability-refresh.js';
155
+ import { loadAvailableByProvider } from './lib/reachability-refresh.js';
156
+ import { filterSelectableModels, reachabilityFingerprint } from './lib/selection-filters.js';
157
+ import { parseAutoModelArgs, CHINA_EXCLUDE_FAMILIES, listFamilies, listProviders } from './lib/family-filter.js';
158
+ import { hasRateLimitError, hasRegionError, buildErrorDiagnosis, exclusionReasonStats } from './lib/error-scan.js';
159
+ import { applyEditVariantPin } from './lib/edit-variant-pin.js';
160
+ type Role = 'coder' | 'reviewer' | 'planner' | 'proposal-reviewer';
161
+
162
+ /** Session-scoped selector constraint for `/pick-model <selector> [--china]`. */
163
+ export interface FamilyConstraint {
164
+ selectors: string[] | null;
165
+ excludeSelectors: string[];
166
+ }
167
+
168
+ /** Stable fingerprint of the active selector constraint (design Decision 3/9). */
169
+ function familyConstraintFingerprint(fc: FamilyConstraint | null): string {
170
+ if (!fc) return 'none';
171
+ return `fam:${fc.selectors ? fc.selectors.join(',') : '*'}|excl:${fc.excludeSelectors.join(',')}`;
172
+ }
173
+
174
+ /**
175
+ * Selection health key for a given blocked set — shared by the session closure's
176
+ * buildSelectionHealthKey (used by every manual recompute path).
177
+ */
178
+ function selectionHealthKeyFor(
179
+ health: Map<string, ProviderHealth> | null,
180
+ reach: { unreachable: Set<string>; availableByProvider: Map<string, string[] | null> },
181
+ fc: FamilyConstraint | null,
182
+ blocked: ReadonlySet<string>,
183
+ ): string {
184
+ return `${healthCacheKey(health)}|${reachabilityFingerprint({
185
+ unreachable: reach.unreachable,
186
+ availableByProvider: reach.availableByProvider,
187
+ blocked,
188
+ })}|${familyConstraintFingerprint(fc)}`;
189
+ }
190
+
191
+ /** Suggested id segment for the ambiguity hint (design Decision 8). */
192
+ function buildSuggestedIdSeg(selectors: readonly string[]): string {
193
+ if (selectors.length === 1) {
194
+ const s = selectors[0];
195
+ if (s.includes('/')) {
196
+ const idSeg = s.slice(s.indexOf('/') + 1);
197
+ return idSeg === '' ? '*' : idSeg;
198
+ }
199
+ return s.includes('*') ? '*' : `${s}*`;
200
+ }
201
+ for (const s of selectors) {
202
+ if (s.includes('/')) {
203
+ const idSeg = s.slice(s.indexOf('/') + 1);
204
+ return idSeg === '' ? '*' : idSeg;
205
+ }
206
+ }
207
+ return '*';
208
+ }
209
+
210
+ /** Two-dimension error detail: provider counts (desc) + id family words + example. */
211
+ function selectorErrorDetail(models: readonly { provider: string; id: string }[]): string {
212
+ const prov = [...listProviders(models).entries()]
213
+ .sort((a, b) => b[1] - a[1])
214
+ .map(([p, c]) => `${p}(${c})`)
215
+ .join(', ');
216
+ const fams = listFamilies(models).join(', ');
217
+ return `providers: ${prov}\nid families: ${fams}\n示例: /pick-model zhipu*/glm* (provider glob / id glob)`;
218
+ }
219
+
220
+ /** Human-readable reasons a selector candidate set came up empty. */
221
+ function exclusionReasonText(
222
+ models: readonly { provider: string; id: string }[],
223
+ regionBlockedModels: ReadonlySet<string>,
224
+ unreachableProviders: ReadonlySet<string>,
225
+ ): string {
226
+ const { blockedCount, blockedExamples, unreachableProviderCount } = exclusionReasonStats(
227
+ models,
228
+ regionBlockedModels,
229
+ unreachableProviders,
230
+ );
231
+ const lines: string[] = [];
232
+ if (blockedCount > 0) {
233
+ const ex = blockedExamples.join(', ');
234
+ lines.push(`被 403 屏蔽: ${blockedCount} 个模型 (${ex})`);
235
+ }
236
+ if (unreachableProviderCount > 0) {
237
+ lines.push(`不可达 provider: ${unreachableProviderCount} 个`);
238
+ }
239
+ if (lines.length === 0) {
240
+ lines.push('(无 403 屏蔽、无不可达 provider)');
241
+ }
242
+ lines.push('提示: 不可达 provider 由探测决定;403 不再自动屏蔽模型。');
243
+ return lines.join('\n');
244
+ }
245
+
246
+ const AGENT_MAP: Record<Role, string> = {
247
+ coder: 'coder',
248
+ reviewer: 'code-reviewer',
249
+ planner: 'planner',
250
+ 'proposal-reviewer': 'proposal-reviewer',
251
+ };
252
+ function findRoleForModel(provider: string, modelId: string, sel: SelectionContext): Role | null {
253
+ for (const role of OPSX_ROLES) {
254
+ const r = sel.selectorResults[role];
255
+ if (!r) continue;
256
+ if (r.allCandidates.some((c) => c.selector === `${provider}/${modelId}`)) {
257
+ return role;
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+
263
+ export interface SelectionContext {
264
+ selectorResults: Record<Role, SelectorResult>;
265
+ roleModels: Record<Role, string | undefined>;
266
+ /** Display label for each role from config, e.g. "auto", "smol", "deepseek-v4-pro". */
267
+ roleConfigs: Record<Role, string | undefined>;
268
+ }
269
+
270
+ const DEFAULT_PLAN_PROVIDERS = new Set(['minimax-code-cn', 'opencode-go', 'zhipu-coding-plan', 'kimi', 'openai-codex']);
271
+
272
+ /** OPSX role → OMP role alias used in agent frontmatter `model: @<alias>`. */
273
+ const ROLE_TO_OMP_ALIAS: Record<Role, string> = {
274
+ coder: 'smol',
275
+ reviewer: 'default',
276
+ planner: 'default',
277
+ 'proposal-reviewer': 'default',
278
+ };
279
+
280
+ /** OMP role alias → expected tier (without the `@`). */
281
+ const OMP_ROLE_TO_TIER: Record<string, string> = {
282
+ smol: 'mid',
283
+ default: 'high',
284
+ slow: 'top',
285
+ };
286
+
287
+ function roleValueToModelString(value: RoleValue): string | undefined {
288
+ if (value.kind === 'auto') return undefined;
289
+ if (value.kind === 'omp-alias') return `@${value.alias}`;
290
+ if (value.kind === 'provider-model') return `${value.provider}/${value.modelId}`;
291
+ if (value.kind === 'model') return value.modelId;
292
+ return undefined;
293
+ }
294
+
295
+ function normalizeRole(input: string): Role {
296
+ const lower = input.toLowerCase();
297
+ if (lower === 'code-reviewer') return 'reviewer';
298
+ return lower as Role;
299
+ }
300
+
301
+ function synthPinnedResult(role: string, modelString: string | undefined): SelectorResult {
302
+ return {
303
+ role,
304
+ expectedTier: 'mid',
305
+ picked: null,
306
+ candidates: [],
307
+ allCandidates: [],
308
+ decision: modelString ? `pinned: ${modelString}` : 'pinned: <no model specified>',
309
+ pickedReason: modelString ? `pinned to ${modelString}` : null,
310
+ };
311
+ }
312
+
313
+ export function buildDispatchPrompt(role: Role, task: string, selection: SelectionContext): string {
314
+ const agent = AGENT_MAP[role];
315
+ const sel = selection.selectorResults[role];
316
+ const modelHint = sel?.picked ? `${sel.picked.provider}/${sel.picked.id}` : selection.roleModels[role];
317
+ const parts = [`你的 opsx.${role} 已自动选择 model。`];
318
+ if (modelHint) parts.push(`请通过 task 工具调用 ${agent} 并指定 model="${modelHint}"。`);
319
+ else parts.push(`请通过 task 工具调用 ${agent}。`);
320
+ if (sel?.decision) parts.push(`原因: ${sel.decision}`);
321
+ parts.push('直接用内置工具实现。');
322
+ return `请使用 task 工具委派给 ${agent}:\ntask(agent:"${agent}"${modelHint ? `, model:"${modelHint}"` : ''}, task:"${parts.join(' ')}\\n\\n${task}")`;
323
+ }
324
+
325
+ function computeSelectionSync(
326
+ config: ResolvedOpsxConfig,
327
+ models: Model<Api>[] | null,
328
+ health: Map<string, ProviderHealth> | null,
329
+ regionBlockedModels?: Set<string>,
330
+ unreachableProviders?: Set<string>,
331
+ availableByProvider?: Map<string, string[] | null>,
332
+ familyConstraint?: FamilyConstraint | null,
333
+ ): SelectionContext {
334
+ const filteredModels = models
335
+ ? filterSelectableModels(models, {
336
+ blockedSelectors: regionBlockedModels,
337
+ unreachableProviders,
338
+ excludedProviders: config.excluded_providers,
339
+ availableByProvider,
340
+ selectors: familyConstraint?.selectors ?? undefined,
341
+ excludeSelectors:
342
+ familyConstraint?.excludeSelectors && familyConstraint.excludeSelectors.length > 0
343
+ ? familyConstraint.excludeSelectors
344
+ : undefined,
345
+ })
346
+ : models;
347
+ const selectorResults = {} as Record<Role, SelectorResult>;
348
+ const roleModels = {} as Record<Role, string | undefined>;
349
+ const roleConfigs = {} as Record<Role, string | undefined>;
350
+ const planSet = mergePlanProviders(config.planProviders);
351
+ for (const role of OPSX_ROLES) {
352
+ const value = config.roles[role];
353
+ roleConfigs[role] = value.kind === 'auto' ? ROLE_TO_OMP_ALIAS[role] : (roleValueToModelString(value) ?? '?');
354
+ const alias = `@${ROLE_TO_OMP_ALIAS[role]}`;
355
+ if (value.kind === 'auto') {
356
+ if (filteredModels && filteredModels.length > 0) {
357
+ const ompRole = ROLE_TO_OMP_ALIAS[role];
358
+ const tier = (config.roleTiers[role] ?? OMP_ROLE_TO_TIER[ompRole] ?? 'mid') as
359
+ 'top' | 'high' | 'mid' | 'low' | 'tiny';
360
+ const roleTiers = { ...config.roleTiers, [role]: tier };
361
+ const result = selectAllRoles(
362
+ [role],
363
+ filteredModels,
364
+ roleTiers,
365
+ config.tierOverrides,
366
+ health ?? undefined,
367
+ config.modelAllowlist,
368
+ planSet,
369
+ )[role];
370
+ selectorResults[role] = result;
371
+ roleModels[role] = result.picked ? alias : undefined;
372
+ } else {
373
+ selectorResults[role] = {
374
+ role,
375
+ expectedTier: 'mid',
376
+ picked: null,
377
+ candidates: [],
378
+ allCandidates: [],
379
+ decision: 'auto: modelRegistry unavailable',
380
+ pickedReason: null,
381
+ };
382
+ roleModels[role] = undefined;
383
+ }
384
+ } else {
385
+ const modelString = roleValueToModelString(value);
386
+ selectorResults[role] = synthPinnedResult(role, modelString);
387
+ roleModels[role] = modelString?.includes('/') ? modelString : undefined;
388
+ }
389
+ }
390
+ return { selectorResults, roleModels, roleConfigs };
391
+ }
392
+
393
+ function formatModelChoicesMarkdown(sel: SelectionContext, suffix: string = '', primaryModel?: Model<Api>): string {
394
+ const rows: string[] = [];
395
+ let totalModelCount = 0;
396
+ if (primaryModel) {
397
+ const score = scoreModelWithProvider(primaryModel.provider, primaryModel.id, primaryModel.name, undefined);
398
+ rows.push(
399
+ `| 🏠 **主会话** | **${primaryModel.provider}/${primaryModel.id}** | ${score.tier} | 当前活跃模型 (tier=${score.tier}) |`,
400
+ );
401
+ }
402
+ for (const role of OPSX_ROLES) {
403
+ const r = sel.selectorResults[role];
404
+ if (!r) continue;
405
+ totalModelCount += r.allCandidates.length;
406
+ const icon = r.picked ? '✅' : '❌';
407
+ const configLabel = sel.roleConfigs[role];
408
+ const displayRole = configLabel ? `${role} (${configLabel})` : role;
409
+ const model = r.picked ? `${r.picked.provider}/${r.picked.id}` : (sel.roleModels[role] ?? '(none)');
410
+ const detail = r.pickedReason ?? r.decision;
411
+ rows.push(`| ${icon} **${displayRole}** | **${model}** | ${r.expectedTier} | ${detail} |`);
412
+ }
413
+ if (rows.length === 0) return `*模型选择${suffix}: no data*`;
414
+ return [
415
+ '| 智能体 | 模型 | 期望档位 | 详情 |',
416
+ '|---|---|---|---|',
417
+ ...rows,
418
+ '',
419
+ `*${totalModelCount} 个 model 参与评分 · \`/pick-model refresh\` 强制重选*`,
420
+ ].join('\n');
421
+ }
422
+
423
+ /** Format per-family per-tier model choices as a markdown table. */
424
+ function formatFamilyTierMarkdown(sel: SelectionContext): string {
425
+ // Group (provider, tier) → best model by reputation
426
+ const byFamily = new Map<string, Map<string, { modelId: string; rep: number; rank: number }>>();
427
+ for (const role of OPSX_ROLES) {
428
+ const r = sel.selectorResults[role];
429
+ if (!r) continue;
430
+ for (const c of r.allCandidates) {
431
+ if (c.exhausted) continue;
432
+ const fam = c.model.provider;
433
+ const tier = c.tier;
434
+ if (!byFamily.has(fam)) byFamily.set(fam, new Map());
435
+ const tierMap = byFamily.get(fam)!;
436
+ const existing = tierMap.get(tier);
437
+ if (!existing || c.reputation > existing.rep || (c.reputation === existing.rep && c.rank > existing.rank)) {
438
+ tierMap.set(tier, { modelId: c.model.id, rep: c.reputation, rank: c.rank });
439
+ }
440
+ }
441
+ }
442
+
443
+ const rows: string[] = [];
444
+ const families = [...byFamily.keys()].sort();
445
+ for (const fam of families) {
446
+ const tierMap = byFamily.get(fam)!;
447
+ const parts: string[] = [];
448
+ for (const tier of ['top', 'high', 'mid', 'low', 'tiny']) {
449
+ const m = tierMap.get(tier);
450
+ if (!m) continue;
451
+ const shortId = m.modelId.length > 35 ? m.modelId.slice(0, 32) + '...' : m.modelId;
452
+ parts.push(`${tier}→${shortId}`);
453
+ }
454
+ if (parts.length === 0) continue;
455
+ rows.push(`**${fam}**: ${parts.join(' ')}`);
456
+ }
457
+ if (rows.length === 0) return '*No models available*';
458
+ return rows.join('\n');
459
+ }
460
+
461
+ // ── per-plugin-instance usage recorder ─────────────────────────────
462
+ // NOT a process-wide singleton: the harness loads a fresh plugin instance
463
+ // for every session, including in-process task subagents (task/executor.ts),
464
+ // so each instance builds its own recorder here. Subagent sessions therefore
465
+ // cannot write to the primary's recorder directly. Instead their usage is
466
+ // captured through the eventBus mechanism:
467
+ // - task/executor.ts:1431-1432 forwards subagent events to the primary session's
468
+ // eventBus via TASK_SUBAGENT_EVENT_CHANNEL ("task:subagent:event")
469
+ // - The primary plugin subscribes to this channel and records subagent usage
470
+ // with subagent-specific stream keys (never re-keys the recorder, so the
471
+ // owner's cumulative baseline survives and streams don't double-count)
472
+ // Each session's recorder persists its own session totals into the shared
473
+ // usage cache, which every process aggregates on read.
474
+ const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
475
+ write: writeSessionUsage,
476
+ remove: removeSessionUsage,
477
+ onDelta: mirrorSessionDelta,
478
+ });
479
+
480
+ // ── plugin entry ────────────────────────────────────────────────────
481
+
482
+ export default (pi: ExtensionAPI) => {
483
+ // Runtime edit-variant pin for deepseek-v4-flash. The host hard-maps the
484
+ // model to the sloppy §/» marker grammar (it misreads hashline ranges),
485
+ // but the model repeatedly breaks sloppy control lines too — retry loops
486
+ // burn tokens and abort agents. Point it at the simplest old_string/
487
+ // new_string "replace" variant instead.
488
+ // Preferred path: `settings.override` (in-memory, per-session). omp
489
+ // 18.0.11 removed `edit.modelVariants` from the settings schema, so on
490
+ // that host the pin is persisted into the global config.yml once — the
491
+ // host reads the merged view directly, making the file equivalent.
492
+ applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
493
+ const directory = process.cwd();
494
+ const warn = (msg: string) => console.warn(msg);
495
+ const opsxConfig = readOpsxSettings(directory, warn);
496
+ // Static orchestration rules — built once, reused every turn for cache stability.
497
+ const STATIC_PROMPT = buildStaticPrompt();
498
+ let initialModel: { provider: string; id: string } | null = null;
499
+ // Active resize handler — tracked so each session_start can off the previous one.
500
+ let activeResizeHandler: (() => void) | null = null;
501
+ let selection: SelectionContext | null = null;
502
+ // 403 no longer auto-blocks; the set is always empty in the running session.
503
+ // A one-time startup migration clears stale blockedModels written by the old
504
+ // auto-switch path (loadBlockedModels/persistBlockedModels are kept for it).
505
+ const regionBlockedModels: Set<string> = new Set();
506
+ let selectionPromise: Promise<SelectionContext> | null = null;
507
+ let firstUnreachableProviders: Set<string> = getUnreachableProviders(opsxConfig.probe_ttl_ms);
508
+ let availableByProvider: Map<string, string[] | null> = loadAvailableByProvider(opsxConfig.probe_ttl_ms);
509
+ let selectionHealthKey = '';
510
+
511
+ // Session-scoped family constraint (never persisted). `effectiveConfig` is the
512
+ // config consulted by every recompute path during a family constraint: family
513
+ // commands deep-copy opsxConfig with all roles forced to auto (covering pinned
514
+ // roles that would otherwise synthPinnedResult and bypass filterSelectableModels),
515
+ // and `/pick-model reset` restores it to opsxConfig.
516
+ let familyConstraint: FamilyConstraint | null = null;
517
+ let effectiveConfig: ResolvedOpsxConfig = opsxConfig;
518
+
519
+ const buildSelectionHealthKey = (
520
+ health: Map<string, ProviderHealth> | null,
521
+ reach: { unreachable: Set<string>; availableByProvider: Map<string, string[] | null> },
522
+ fc: FamilyConstraint | null,
523
+ ): string => {
524
+ return selectionHealthKeyFor(health, reach, fc, regionBlockedModels);
525
+ };
526
+
527
+ const formatFamilyConstraint = (fc: FamilyConstraint | null): string => {
528
+ if (!fc) return '';
529
+ const parts: string[] = [];
530
+ if (fc.selectors && fc.selectors.length > 0) parts.push(`selectors=${fc.selectors.join(',')}`);
531
+ if (fc.excludeSelectors.length > 0) parts.push('china=on');
532
+ return parts.join(' · ');
533
+ };
534
+
535
+ /** Copy of opsxConfig with every role forced to auto — covers pinned roles. */
536
+ const buildEffectiveConfig = (): ResolvedOpsxConfig => {
537
+ const roles = {} as Record<Role, RoleValue>;
538
+ for (const role of OPSX_ROLES) roles[role] = { kind: 'auto' };
539
+ return { ...opsxConfig, roles };
540
+ };
541
+ const startPollerFor = async (ctx: ExtensionContext, onTick?: () => void): Promise<void> => {
542
+ const auth = ctx.modelRegistry?.authStorage;
543
+ const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
544
+ ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
545
+ : undefined;
546
+ const cfg = buildEffectiveConfig();
547
+ await startSharedPoller(auth, DIRECT_FETCHERS, getApiKey, {
548
+ ...(onTick ? { onTick } : undefined),
549
+ redis: {
550
+ enabled: cfg.redis_enabled,
551
+ host: cfg.redis_host,
552
+ port: cfg.redis_port,
553
+ connectTimeoutMs: cfg.redis_connect_timeout_ms,
554
+ commandTimeoutMs: cfg.redis_command_timeout_ms,
555
+ reconcileIntervalMs: cfg.redis_reconcile_interval_ms,
556
+ probeIntervalMs: cfg.redis_probe_interval_ms,
557
+ unhealthyRetryMs: cfg.redis_unhealthy_retry_ms,
558
+ ttlMs: cfg.redis_ttl_ms,
559
+ },
560
+ });
561
+ };
562
+
563
+ const ensureSelection = (ctx: ExtensionContext, force = false): Promise<SelectionContext> => {
564
+ if (selectionPromise && !force) return selectionPromise;
565
+ selectionPromise = (async () => {
566
+ await startPollerFor(ctx);
567
+ let models: Model<Api>[] | null = null;
568
+ try {
569
+ models = ctx.modelRegistry.getAvailable();
570
+ } catch (e) {
571
+ warn(`[omp-opsx-addon] modelRegistry error: ${e}`);
572
+ }
573
+ const reach = await refreshReachability(models, opsxConfig);
574
+ firstUnreachableProviders = reach.unreachable;
575
+ availableByProvider = reach.availableByProvider;
576
+ const health = getSharedUsage()?.health ?? null;
577
+ const key = buildSelectionHealthKey(health, reach, familyConstraint);
578
+ if (!force && selection && selectionHealthKey === key) return selection;
579
+ // Thread effectiveConfig + familyConstraint so family/pinned constraints
580
+ // survive this recompute (ensureSelection is one of the shared recompute paths).
581
+ const sel = computeSelectionSync(
582
+ effectiveConfig,
583
+ models,
584
+ health,
585
+ regionBlockedModels,
586
+ reach.unreachable,
587
+ reach.availableByProvider,
588
+ familyConstraint,
589
+ );
590
+ selection = sel;
591
+ selectionHealthKey = key;
592
+ return sel;
593
+ })().finally(() => {
594
+ selectionPromise = null;
595
+ });
596
+ return selectionPromise;
597
+ };
598
+ // Push selector-picked models to native task subagents via task.agentModelOverrides.
599
+ // pi.pi.settings is the host-process Settings singleton (the require()'d copy in
600
+ // node_modules is a separate, uninitialized instance — writing to it was a no-op).
601
+ const applyAgentModelOverrides = (sel: SelectionContext, force = false): void => {
602
+ const settings = pi.pi?.settings;
603
+ if (!settings) {
604
+ warn('[omp-opsx-addon] pi.pi.settings unavailable; subagent model overrides skipped');
605
+ return;
606
+ }
607
+ const overrides: Record<string, string> = {};
608
+ for (const role of OPSX_ROLES) {
609
+ const picked = sel.selectorResults[role]?.picked;
610
+ if (picked) {
611
+ overrides[AGENT_MAP[role]] = `${picked.provider}/${picked.id}`;
612
+ }
613
+ }
614
+ if (Object.keys(overrides).length > 0 || force) {
615
+ settings.override('task.agentModelOverrides', overrides);
616
+ warn(
617
+ `[omp-opsx-addon] task.agentModelOverrides: ${Object.entries(overrides)
618
+ .map(([k, v]) => `${k}=${v}`)
619
+ .join(', ')}`,
620
+ );
621
+ }
622
+ };
623
+
624
+ // ── shared usage poller + widget ──────────────────────────────
625
+ pi.on('session_start', async (_event, ctx) => {
626
+ try {
627
+ initialModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null;
628
+ // One-time migration: wipe stalled blockedModels persisted by the old
629
+ // auto-switch path (a single unrelated 403 could permanently shadow other
630
+ // providers). The runtime set is empty; only this startup cleanup writes.
631
+ const stale = loadBlockedModels();
632
+ if (stale.length > 0) persistBlockedModels(new Set());
633
+ // Process-wide singletons (tick render + usage recorder) belong to ONE
634
+ // owning session. Task subagents run in THIS process and also emit
635
+ // session_start (task/executor.ts); their ctx.ui is a no-op, so letting
636
+ // them claim the render callback freezes the host widget, and letting
637
+ // them re-key the recorder would clear the owner's cumulative baseline
638
+ // (setSessionId resets totals) AND detach their records from the
639
+ // owner's entry. Claim only when this session has a UI, or when no
640
+ // owner exists yet (headless first session). Subagent consumption is
641
+ // captured via the eventBus channel mechanism (see file header for
642
+ // details) and recorded into the primary's recorder with subagent-
643
+ // specific keys.
644
+ const ownsUi = ctx.hasUI;
645
+ const claimsOwnership = ownsUi || !hasTickOwner();
646
+ if (claimsOwnership) {
647
+ sessionUsageRecorder.setSessionId(ctx.sessionManager?.getSessionId?.() ?? '');
648
+ }
649
+ // Subscribe to subagent events via eventBus to capture usage
650
+ // Only primary sessions (with UI or no owner) should subscribe
651
+ if (claimsOwnership) {
652
+ const sessionId = ctx.sessionManager?.getSessionId?.() ?? '';
653
+ const eventBus = getEventBusFromCtx(ctx);
654
+ if (eventBus) {
655
+ // Clean up any existing subscription for this session to prevent leaks
656
+ const existingUnsubscribe = subagentEventUnsubscribers.get(sessionId);
657
+ if (existingUnsubscribe) {
658
+ try {
659
+ existingUnsubscribe();
660
+ } catch {
661
+ // Silently ignore errors in unsubscribe
662
+ }
663
+ }
664
+ const unsubscribe = subscribeToEventBusChannel(
665
+ eventBus,
666
+ TASK_SUBAGENT_EVENT_CHANNEL,
667
+ (payload) => handleSubagentEvent(payload, sessionId),
668
+ );
669
+ if (unsubscribe) {
670
+ subagentEventUnsubscribers.set(sessionId, unsubscribe);
671
+ }
672
+ }
673
+ }
674
+ const auth = ctx.modelRegistry?.authStorage;
675
+ const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
676
+ ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
677
+ : undefined;
678
+
679
+ const key = 'omp-opsx-addon-usage';
680
+ // Second-level estimate baseline — lives OUTSIDE render so drift
681
+ // accumulates across renders and only resets on a fresh snapshot.
682
+ // Active provider set shared by the tick sampler and the renderer:
683
+ // every reported/logged-in provider gets a chart track (blank strip
684
+ // when unsampled) so the mini-chart area never appears/disappears.
685
+ const resolveActiveProviders = (): {
686
+ reports: UsageReport[];
687
+ placeholderIds: string[];
688
+ active: string[];
689
+ show: boolean;
690
+ } => {
691
+ const state = getSharedUsage();
692
+ const reports = state?.reports ?? [];
693
+ let loggedIn: string[] = [];
694
+ let authAvailable = false;
695
+ try {
696
+ if (auth?.list) {
697
+ authAvailable = true;
698
+ const list = auth.list();
699
+ loggedIn = Array.isArray(list) ? list : [];
700
+ }
701
+ } catch {
702
+ /* treat authStorage as unavailable */
703
+ }
704
+ try {
705
+ const detectable = listDetectableProviders(DIRECT_FETCHERS);
706
+ if (detectable.length > 0) {
707
+ authAvailable = true;
708
+ loggedIn = [...new Set([...loggedIn, ...detectable])];
709
+ }
710
+ } catch {
711
+ /* ignore */
712
+ }
713
+ // reports[].provider is already canonical (the resolver
714
+ // normalizes built-in ids); canonicalize loggedIn too so a login
715
+ // alias doesn't surface as BOTH a real report and a placeholder.
716
+ const reported = new Set(reports.map((r) => r.provider));
717
+ const placeholderIds = [...new Set(loggedIn.map(canonicalizeProvider))].filter((p) => !reported.has(p));
718
+ // With authStorage: show iff any provider is logged in (hide even if
719
+ // stale reports linger). Without it (tests / offline), fall back to
720
+ // "has report".
721
+ const show = authAvailable ? loggedIn.length > 0 : reports.length > 0;
722
+ return { reports, placeholderIds, active: [...reported, ...placeholderIds], show };
723
+ };
724
+ // Second-level estimate baseline — lives OUTSIDE render so drift
725
+ // accumulates across renders and only resets on a fresh snapshot.
726
+ const estimateState: { baseline: { fetchedAt: number; totals: Record<string, { total: number; input: number; output: number }> } | null } = { baseline: null };
727
+ // Last unix second already sampled by onTick (per session). Drives
728
+ // the catch-up loop that backfills every skipped second with zeros.
729
+ let lastSampledSec: number | undefined;
730
+ const render = () => {
731
+ const { reports, placeholderIds, active, show } = resolveActiveProviders();
732
+ const state = getSharedUsage();
733
+ const loadingProviders = state?.loadingProviders ?? new Set<string>();
734
+ if (!show) {
735
+ if (ctx.ui) {
736
+ ctx.ui.setWidget(key, undefined);
737
+ }
738
+ return;
739
+ }
740
+ // Second-level balance estimates: snapshot − sessionUsage flow ×
741
+ // price. Baseline resets whenever a fresh snapshot lands (fetch
742
+ // alignment), so drift only ever spans one fetch TTL.
743
+ const totals = getSessionUsageTotals();
744
+ const snapshotFetchedAt = state?.fetchedAt ?? 0;
745
+ const hit = estimateState.baseline && estimateState.baseline.fetchedAt === snapshotFetchedAt;
746
+ const baselineTotals = hit && estimateState.baseline ? estimateState.baseline.totals : totals;
747
+ if (!hit) {
748
+ estimateState.baseline = { fetchedAt: snapshotFetchedAt, totals: { ...totals } };
749
+ }
750
+ const models = (ctx.modelRegistry && typeof ctx.modelRegistry.getAll === 'function'
751
+ ? ctx.modelRegistry.getAll()
752
+ : []) as { provider: string; cost?: { input: number; output: number } }[];
753
+ const usageEstimates = buildBalanceEstimates(reports, totals, models, baselineTotals);
754
+ // Per-provider Braille consumption mini-charts — read-only, never
755
+ // touches selection. Every active provider gets a track; an
756
+ // unsampled provider renders as a blank placeholder strip.
757
+ const consumptionTracks = new Map<string, ConsumptionTrack>();
758
+ for (const p of active) {
759
+ consumptionTracks.set(p, { samples: getConsumptionSamples(p) });
760
+ }
761
+ if (ctx.hasUI) {
762
+ ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates), {
763
+ placement: 'belowEditor',
764
+ });
765
+ } else {
766
+ if (ctx.ui) {
767
+ const cols = (process.stdout.columns ?? 120) - 3;
768
+ const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates);
769
+ ctx.ui.setWidget(key, lines.length > 0 ? lines : undefined, { placement: 'belowEditor' });
770
+ }
771
+ }
772
+ };
773
+ // Tick path is strictly read-only wrt selection: it flushes this
774
+ // session's usage totals to the shared cache, samples every
775
+ // provider's aggregated consumption RATE (all sessions, main +
776
+ // subagent via cache backflow), and re-renders the widget. It never
777
+ // recomputes a selection, never applies overrides, never setModel.
778
+ const onTick = () => {
779
+ sessionUsageRecorder.flush();
780
+ const { active } = resolveActiveProviders();
781
+ // Waveform source = per-second consumption sequence (timestamped
782
+ // deltas aggregated across sessions). Sample EVERY elapsed second
783
+ // for EVERY active provider — zeros when a second had no
784
+ // consumption or the tick was delayed — so all buffers stay
785
+ // aligned on the same 1px-per-second axis and the strip slides
786
+ // uniformly. A gap wider than the ring (sleep/resume) backfills
787
+ // only the window's most recent seconds.
788
+ const perSec = getSessionUsagePerSecond(60);
789
+ // Sample with a 2s lag: a consuming session's flush can land up
790
+ // to ~1s after the consumed second (1s write throttle + 1s
791
+ // tick, plus a queued lock wait), so a second is only sampled
792
+ // once every writer's data for it is on disk. Without the lag,
793
+ // a late flush would freeze the waveform at a partial bar.
794
+ const lastSec = Math.floor(Date.now() / 1_000) - 2;
795
+ let from = lastSampledSec === undefined ? lastSec : lastSampledSec + 1;
796
+ if (lastSec - from >= RING_SIZE) from = lastSec - RING_SIZE + 1;
797
+ for (let s = from; s <= lastSec; s++) {
798
+ for (const p of active) {
799
+ const secMap = perSec[p];
800
+ appendSample(p, secMap ? secMap[s] ?? 0 : 0);
801
+ }
802
+ }
803
+ lastSampledSec = lastSec;
804
+ render();
805
+ };
806
+ const cfg = buildEffectiveConfig();
807
+ await startSharedPoller(auth, DIRECT_FETCHERS, getApiKey, {
808
+ onTick: claimsOwnership ? onTick : undefined,
809
+ ownsUi,
810
+ redis: {
811
+ enabled: cfg.redis_enabled,
812
+ host: cfg.redis_host,
813
+ port: cfg.redis_port,
814
+ connectTimeoutMs: cfg.redis_connect_timeout_ms,
815
+ commandTimeoutMs: cfg.redis_command_timeout_ms,
816
+ reconcileIntervalMs: cfg.redis_reconcile_interval_ms,
817
+ probeIntervalMs: cfg.redis_probe_interval_ms,
818
+ unhealthyRetryMs: cfg.redis_unhealthy_retry_ms,
819
+ ttlMs: cfg.redis_ttl_ms,
820
+ },
821
+ });
822
+ // One resize listener per process: each new owning session replaces
823
+ // the previous handler instead of accumulating. (ExtensionContext
824
+ // exposes no `signal`, so per-session abort cleanup never fires —
825
+ // ownership above, not teardown hooks, keeps the singletons sane.)
826
+ if (activeResizeHandler) process.stdout.off('resize', activeResizeHandler);
827
+ const onResize = () => render();
828
+ activeResizeHandler = onResize;
829
+ process.stdout.on('resize', onResize);
830
+ render();
831
+ } catch (e) {
832
+ warn(`[omp-opsx-addon] usage poller error: ${e}`);
833
+ }
834
+ });
835
+
836
+ // A completed turn just consumed quota on the CURRENT model — mark that
837
+ // provider dirty and run the fetch-due kernel immediately, so the widget
838
+ // reflects new consumption within one floor window (10s) instead of the
839
+ // 60s idle fallback. The per-provider floor prevents rapid back-to-back
840
+ // turns from hammering the API. A direct-fetcher provider
841
+ // (zhipu/minimax/deepseek/cursor) issues a single HTTP and never touches
842
+ // the bulk API; a non-direct provider falls back to bulk (5-min internal
843
+ // cache mitigates). Shares the poller's render callback. Silent on failure
844
+ // or when no authStorage.
845
+ pi.on('turn_end', async (_event, ctx) => {
846
+ try {
847
+ const currentModel = ctx.model;
848
+ const auth = ctx.modelRegistry?.authStorage;
849
+ const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
850
+ ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
851
+ : undefined;
852
+ if (auth && currentModel?.provider) {
853
+ await pokeProvider(auth, DIRECT_FETCHERS, getApiKey, { providers: [currentModel.provider] });
854
+ }
855
+ } catch {
856
+ /* turn_end refresh is best-effort; stay silent */
857
+ }
858
+ });
859
+ // PRIMARY usage source, so the waveform reflects in-progress consumption
860
+ // and stays dense (message_end alone is sparse). `record` is monotonic MAX,
861
+ // so repeated chunk calls never double-count; the final message_end value
862
+ // (equal or higher) is a harmless re-record. In-process subagent sessions
863
+ // forward their usage through the eventBus channel (TASK_SUBAGENT_EVENT_CHANNEL)
864
+ // which this plugin subscribes to in session_start. Subagent consumption is
865
+ // recorded with subagent-specific keys and merged into the primary waveform.
866
+ pi.on('message_update', async (event) => {
867
+ try {
868
+ const ev = event.assistantMessageEvent;
869
+ if ('partial' in ev) {
870
+ const usage = assistantUsageOf(ev.partial);
871
+ if (usage) sessionUsageRecorder.record(usage.provider, usage.usage);
872
+ }
873
+ } catch {
874
+ /* usage recording is best-effort; stay silent */
875
+ }
876
+ });
877
+ // Final per-turn total — fallback for any message that ended without a
878
+ // nonzero streamed total (same monotonic MAX, no double count).
879
+ pi.on('message_end', async (event) => {
880
+ try {
881
+ const usage = assistantUsageOf(event.message);
882
+ if (usage) sessionUsageRecorder.record(usage.provider, usage.usage);
883
+ } catch {
884
+ /* usage recording is best-effort; stay silent */
885
+ }
886
+ });
887
+
888
+ // Session teardown: drop the cache entry THIS process wrote for the ending
889
+ // session. An in-process subagent never wrote one, so its shutdown is a
890
+ // no-op and the owner's aggregated consumption stays. A UI session ending
891
+ // in a multi-session process drops its own entry even when the recorder
892
+ // has since been re-keyed to another session.
893
+ pi.on('session_shutdown', async (_event, ctx) => {
894
+ try {
895
+ const sid = ctx?.sessionManager?.getSessionId?.();
896
+ if (sid && hasManagedSessionUsage(sid)) {
897
+ if (sid === sessionUsageRecorder.sessionId()) {
898
+ sessionUsageRecorder.remove();
899
+ } else {
900
+ removeSessionUsage(sid);
901
+ }
902
+ // Clean up eventBus subscription for subagent events
903
+ const unsubscribe = subagentEventUnsubscribers.get(sid);
904
+ if (unsubscribe) {
905
+ try {
906
+ unsubscribe();
907
+ subagentEventUnsubscribers.delete(sid);
908
+ } catch {
909
+ /* best-effort */
910
+ }
911
+ }
912
+ }
913
+ // Always clean up eventBus subscription, even if no usage was recorded
914
+ const cleanupUnsubscribe = subagentEventUnsubscribers.get(sid!);
915
+ if (cleanupUnsubscribe) {
916
+ try {
917
+ cleanupUnsubscribe();
918
+ subagentEventUnsubscribers.delete(sid!);
919
+ } catch {
920
+ /* best-effort */
921
+ }
922
+ }
923
+ } catch {
924
+ /* best-effort */
925
+ }
926
+ });
927
+ // ── session lifecycle ─────────────────────────────────────────
928
+ // Pick-model is manual: `/pick-model refresh`. Session start does not pick or setModel.
929
+ const changeScanner = createCachedScanner(30_000);
930
+
931
+ pi.on('before_agent_start', async (event, ctx) => {
932
+ try {
933
+ // This fallback call is intentionally NOT threaded with familyConstraint:
934
+ // it prefers the cached `selection`, and the fallback passes models=null so
935
+ // filterSelectableModels' family branch never runs — it is not a reselect path.
936
+ const sel = selection ?? computeSelectionSync(opsxConfig, null, null);
937
+ const changes = await changeScanner.scanChanges(directory, 'doing');
938
+ // Layered injection: STATIC_PROMPT is byte-identical every turn → prefix cache
939
+ // hit for [omp-base + skills + static]. Only the dynamic tail re-prefills.
940
+ const dynamic = buildDynamicPrompt(changes, opsxConfig, sel.selectorResults);
941
+ return { systemPrompt: [...event.systemPrompt, STATIC_PROMPT, dynamic] };
942
+ } catch (e) {
943
+ warn(`[omp-opsx-addon] before_agent_start error: ${e}`);
944
+ return { systemPrompt: event.systemPrompt };
945
+ }
946
+ });
947
+
948
+ pi.on('session_start', async (_event, ctx) => {
949
+ try {
950
+ const pinned = (role: Role): string | undefined => {
951
+ const v = opsxConfig.roles[role];
952
+ if (v.kind === 'auto') return undefined;
953
+ return roleValueToModelString(v);
954
+ };
955
+ await installAgents(
956
+ directory,
957
+ pinned('coder'),
958
+ pinned('reviewer'),
959
+ pinned('planner'),
960
+ pinned('proposal-reviewer'),
961
+ );
962
+ warn('[omp-opsx-addon] agents ready (pick-model idle until /pick-model refresh)');
963
+ } catch (e) {
964
+ warn('[omp-opsx-addon] installAgents error: ' + e);
965
+ }
966
+ });
967
+ // ── Diagnostic-only error path (429/403; never auto-switches) ─
968
+ const diagnoseProviderError = async (ctx: ExtensionContext, source: string, errorType: '429' | '403'): Promise<void> => {
969
+ const currentModel = ctx.model;
970
+ if (!currentModel) return;
971
+ const canonical = canonicalizeProvider(currentModel.provider);
972
+ const auth = ctx.modelRegistry?.authStorage;
973
+ const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
974
+ ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
975
+ : undefined;
976
+ // Force-refresh just this provider (bypassing the 10s floor) so the
977
+ // diagnosis reflects current quota.
978
+ if (auth) await pokeProvider(auth, DIRECT_FETCHERS, getApiKey, { providers: [canonical], force: true });
979
+ const health = getSharedUsage()?.health ?? null;
980
+ pi.sendMessage(
981
+ { customType: 'pick-model', content: buildErrorDiagnosis(errorType, source, canonical, health), display: true },
982
+ { triggerTurn: false },
983
+ );
984
+ };
985
+ // 主要路径:provider API 响应直接返回 429/403(覆盖 primary session + 子 agent)
986
+ pi.on('after_provider_response', async (event, ctx) => {
987
+ try {
988
+ if (event.status === 429) {
989
+ await diagnoseProviderError(ctx, `HTTP ${event.status}`, '429');
990
+ } else if (event.status === 403) {
991
+ await diagnoseProviderError(ctx, `HTTP ${event.status}`, '403');
992
+ }
993
+ } catch (e) {
994
+ warn(`[omp-opsx-addon] after_provider_response error: ${e}`);
995
+ }
996
+ });
997
+ // 兜底路径:工具执行结果中包含 429/403 文本(子 agent 输出等场景)
998
+ pi.on('tool_execution_end', async (event, ctx) => {
999
+ try {
1000
+ if (!event.isError) return;
1001
+ const resultStr = typeof event.result === 'string' ? event.result : JSON.stringify(event.result ?? '');
1002
+ const has429 =
1003
+ hasRateLimitError(resultStr);
1004
+ const has403 =
1005
+ hasRegionError(resultStr);
1006
+ if (has429) {
1007
+ await diagnoseProviderError(ctx, event.toolName, '429');
1008
+ } else if (has403) {
1009
+ await diagnoseProviderError(ctx, event.toolName, '403');
1010
+ }
1011
+ } catch (e) {
1012
+ warn(`[omp-opsx-addon] tool_execution_end error: ${e}`);
1013
+ }
1014
+ });
1015
+ pi.on('input', async (event) => {
1016
+ const match = event.text.match(/^@(coder|reviewer|planner|proposal-reviewer|code-reviewer)\b(.*)$/i);
1017
+ if (!match) return;
1018
+ const role = normalizeRole(match[1]);
1019
+ const task = match[2].trim() || '开始工作';
1020
+ if (!selection) {
1021
+ return {
1022
+ action: 'transform' as const,
1023
+ text: `(model selector not ready)\n请使用 task 工具调用 ${AGENT_MAP[role]}。`,
1024
+ };
1025
+ }
1026
+ return { action: 'transform' as const, text: buildDispatchPrompt(role, task, selection) };
1027
+ });
1028
+
1029
+
1030
+ pi.setLabel('pick-model', '模型选择');
1031
+ pi.registerCommand('pick-model', {
1032
+ description: '模型管理:choices 展示 / refresh 重选 / update 更新档位 / <selector> [--china] 按选择器约束(裸词·glob·provider/id 双段,如 zhipu*/glm*)/ reset 清除约束',
1033
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
1034
+ try {
1035
+ const parsed = parseAutoModelArgs(args);
1036
+ if (parsed.kind === 'error') {
1037
+ pi.sendMessage({ customType: 'pick-model', content: `❌ ${parsed.message}`, display: true }, { triggerTurn: false });
1038
+ return;
1039
+ }
1040
+
1041
+ // ── /pick-model update ──────────────────────────
1042
+ if (parsed.kind === 'update') {
1043
+ const result = updateTiers();
1044
+ const msg = result.ok
1045
+ ? `✅ Tiers data updated: ${result.count} entries`
1046
+ : `❌ Update failed: ${result.error}`;
1047
+ pi.sendMessage({ customType: 'pick-model', content: msg, display: true }, { triggerTurn: false });
1048
+ return;
1049
+ }
1050
+
1051
+ // ── /pick-model refresh (was model-switch) ──────
1052
+ if (parsed.kind === 'refresh') {
1053
+ const auth = ctx.modelRegistry?.authStorage;
1054
+ const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
1055
+ ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
1056
+ : undefined;
1057
+ if (auth) await pokeProvider(auth, DIRECT_FETCHERS, getApiKey, { force: true });
1058
+ const models = ctx.modelRegistry?.getAvailable() ?? [];
1059
+ const reach = await refreshReachability(models, opsxConfig);
1060
+ firstUnreachableProviders = reach.unreachable;
1061
+ availableByProvider = reach.availableByProvider;
1062
+ const health = getSharedUsage()?.health ?? null;
1063
+ const freshSel = computeSelectionSync(
1064
+ effectiveConfig,
1065
+ models,
1066
+ health,
1067
+ regionBlockedModels,
1068
+ reach.unreachable,
1069
+ reach.availableByProvider,
1070
+ familyConstraint,
1071
+ );
1072
+ const changes: string[] = [];
1073
+ // Check primary session model
1074
+ const currentModel = ctx.model;
1075
+ if (currentModel) {
1076
+ const primaryRole = findRoleForModel(currentModel.provider, currentModel.id, freshSel);
1077
+ const primaryResult = primaryRole ? freshSel.selectorResults[primaryRole] : undefined;
1078
+ const primaryBest = primaryResult?.candidates[0];
1079
+ if (primaryBest && `${currentModel.provider}/${currentModel.id}` !== `${primaryBest.model.provider}/${primaryBest.model.id}`) {
1080
+ await pi.setModel(primaryBest.model);
1081
+ changes.push(`🏠 主会话 → ${primaryBest.model.provider}/${primaryBest.model.id}`);
1082
+ }
1083
+ }
1084
+ // Check all agent models
1085
+ for (const role of OPSX_ROLES) {
1086
+ const result = freshSel.selectorResults[role];
1087
+ const picked = result?.picked;
1088
+ if (!picked) continue;
1089
+ const agent = AGENT_MAP[role];
1090
+ const pickedSel = `${picked.provider}/${picked.id}`;
1091
+ const roleCfg = opsxConfig.roles[role];
1092
+ if (roleCfg.kind === 'model' || roleCfg.kind === 'provider-model') {
1093
+ const pinned = roleValueToModelString(roleCfg);
1094
+ if (pinned && pinned !== pickedSel) {
1095
+ changes.push(`${agent} pinned ${pinned} → auto-pick ${pickedSel}`);
1096
+ }
1097
+ } else {
1098
+ changes.push(`${agent} → ${pickedSel}`);
1099
+ }
1100
+ }
1101
+ selection = freshSel;
1102
+ applyAgentModelOverrides(freshSel);
1103
+ if (changes.length > 0) {
1104
+ pi.sendMessage({ customType: 'pick-model', content: `✅ 模型已刷新:\n${changes.join('\n')}`, display: true }, { triggerTurn: false });
1105
+ } else {
1106
+ pi.sendMessage({ customType: 'pick-model', content: '✅ 所有模型状态正常,无需切换', display: true }, { triggerTurn: false });
1107
+ }
1108
+ return;
1109
+ }
1110
+
1111
+ // ── /pick-model reset ────────────────────────────────
1112
+ if (parsed.kind === 'reset') {
1113
+ familyConstraint = null;
1114
+ effectiveConfig = opsxConfig;
1115
+ const auth = ctx.modelRegistry?.authStorage;
1116
+ const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
1117
+ ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
1118
+ : undefined;
1119
+ if (auth) await pokeProvider(auth, DIRECT_FETCHERS, getApiKey, { force: true });
1120
+ const models = ctx.modelRegistry?.getAvailable() ?? [];
1121
+ const reach = await refreshReachability(models, opsxConfig);
1122
+ firstUnreachableProviders = reach.unreachable;
1123
+ availableByProvider = reach.availableByProvider;
1124
+ const health = getSharedUsage()?.health ?? null;
1125
+ const sel = computeSelectionSync(
1126
+ effectiveConfig,
1127
+ models,
1128
+ health,
1129
+ regionBlockedModels,
1130
+ reach.unreachable,
1131
+ reach.availableByProvider,
1132
+ familyConstraint,
1133
+ );
1134
+ const changes: string[] = [];
1135
+ const currentModel = ctx.model;
1136
+ const newPrimary = sel.selectorResults.reviewer?.picked;
1137
+ if (newPrimary) {
1138
+ const newSel = `${newPrimary.provider}/${newPrimary.id}`;
1139
+ const oldSel = currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined;
1140
+ changes.push(oldSel && oldSel !== newSel ? `🏠 主会话 ${oldSel} → ${newSel}` : `🏠 主会话 → ${newSel}`);
1141
+ }
1142
+ for (const role of OPSX_ROLES) {
1143
+ const picked = sel.selectorResults[role]?.picked;
1144
+ if (!picked) continue;
1145
+ const newSel = `${picked.provider}/${picked.id}`;
1146
+ const prev = selection?.selectorResults[role]?.picked;
1147
+ const oldSel = prev ? `${prev.provider}/${prev.id}` : undefined;
1148
+ changes.push(`${AGENT_MAP[role]} ${oldSel ?? '(none)'} → ${newSel}`);
1149
+ }
1150
+ if (newPrimary) await pi.setModel(newPrimary);
1151
+ selection = sel;
1152
+ selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
1153
+ applyAgentModelOverrides(sel, true);
1154
+ pi.sendMessage({
1155
+ customType: 'pick-model',
1156
+ content: changes.length > 0
1157
+ ? `✅ 已清除 selector 约束,恢复默认选择:\n${changes.join('\n')}`
1158
+ : `✅ 已清除 selector 约束,恢复默认选择(无变化)`,
1159
+ display: true,
1160
+ }, { triggerTurn: false });
1161
+ return;
1162
+ }
1163
+
1164
+ // ── /pick-model <selector> [--china] ──────────────
1165
+ if (parsed.kind === 'selector') {
1166
+ const auth = ctx.modelRegistry?.authStorage;
1167
+ const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
1168
+ ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
1169
+ : undefined;
1170
+ if (auth) await pokeProvider(auth, DIRECT_FETCHERS, getApiKey, { force: true });
1171
+ const models = ctx.modelRegistry?.getAvailable() ?? [];
1172
+ const reach = await refreshReachability(models, opsxConfig);
1173
+ firstUnreachableProviders = reach.unreachable;
1174
+ availableByProvider = reach.availableByProvider;
1175
+ const health = getSharedUsage()?.health ?? null;
1176
+ const excludeSelectors = parsed.china ? CHINA_EXCLUDE_FAMILIES : [];
1177
+
1178
+ // Switch the session-scoped state BEFORE any recompute so every
1179
+ // later recompute path (ensureSelection, 429/403 auto-switch,
1180
+ // refresh) obeys the constraint.
1181
+ // Snapshot the previously-active constraint so a failed selector
1182
+ // command restores it rather than silently dropping an active one.
1183
+ const prevConstraint = familyConstraint;
1184
+ const prevEffective = effectiveConfig;
1185
+ familyConstraint = { selectors: parsed.selectors, excludeSelectors };
1186
+ effectiveConfig = buildEffectiveConfig();
1187
+
1188
+ // Fail fast: an empty candidate set (or a role that cannot pick)
1189
+ // is a hard error — never silently pick nothing or change models.
1190
+ const preFiltered = filterSelectableModels(models, {
1191
+ blockedSelectors: regionBlockedModels,
1192
+ unreachableProviders: reach.unreachable,
1193
+ excludedProviders: effectiveConfig.excluded_providers,
1194
+ availableByProvider: reach.availableByProvider,
1195
+ selectors: parsed.selectors ?? undefined,
1196
+ excludeSelectors: excludeSelectors.length > 0 ? excludeSelectors : undefined,
1197
+ });
1198
+ if (preFiltered.length === 0) {
1199
+ familyConstraint = prevConstraint;
1200
+ effectiveConfig = prevEffective;
1201
+ pi.sendMessage({
1202
+ customType: 'pick-model',
1203
+ content: `❌ selector 约束下无可用模型。\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
1204
+ display: true,
1205
+ }, { triggerTurn: false });
1206
+ return;
1207
+ }
1208
+
1209
+ const sel = computeSelectionSync(
1210
+ effectiveConfig,
1211
+ models,
1212
+ health,
1213
+ regionBlockedModels,
1214
+ reach.unreachable,
1215
+ reach.availableByProvider,
1216
+ familyConstraint,
1217
+ );
1218
+ const emptyRole = OPSX_ROLES.find((r) => !sel.selectorResults[r]?.picked);
1219
+ if (emptyRole) {
1220
+ familyConstraint = prevConstraint;
1221
+ effectiveConfig = prevEffective;
1222
+ pi.sendMessage({
1223
+ customType: 'pick-model',
1224
+ content: `❌ selector 约束下无法为 role "${emptyRole}" 选择模型。\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
1225
+ display: true,
1226
+ }, { triggerTurn: false });
1227
+ return;
1228
+ }
1229
+
1230
+ const currentModel = ctx.model;
1231
+ const oldPrimary = currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined;
1232
+ const oldRolePicks: Record<string, string | undefined> = {};
1233
+ for (const role of OPSX_ROLES) {
1234
+ const prev = selection?.selectorResults[role]?.picked;
1235
+ oldRolePicks[role] = prev ? `${prev.provider}/${prev.id}` : undefined;
1236
+ }
1237
+
1238
+ const newPrimary = sel.selectorResults.reviewer?.picked;
1239
+ const changes: string[] = [];
1240
+ if (newPrimary) {
1241
+ const newSel = `${newPrimary.provider}/${newPrimary.id}`;
1242
+ changes.push(oldPrimary && oldPrimary !== newSel ? `🏠 主会话 ${oldPrimary} → ${newSel}` : `🏠 主会话 → ${newSel}`);
1243
+ }
1244
+ for (const role of OPSX_ROLES) {
1245
+ const picked = sel.selectorResults[role]?.picked;
1246
+ if (!picked) continue;
1247
+ const newSel = `${picked.provider}/${picked.id}`;
1248
+ const oldSel = oldRolePicks[role];
1249
+ changes.push(`${AGENT_MAP[role]} ${oldSel ?? '(none)'} → ${newSel}`);
1250
+ }
1251
+
1252
+ // Cross-provider ambiguity hint (design Decision 8): only when a
1253
+ // selector is present and the candidate set spans >1 provider.
1254
+ let hint = '';
1255
+ const selectors = parsed.selectors;
1256
+ if (selectors) {
1257
+ const byProvider = new Map<string, number>();
1258
+ for (const m of preFiltered) {
1259
+ const p = m.provider.toLowerCase();
1260
+ byProvider.set(p, (byProvider.get(p) ?? 0) + 1);
1261
+ }
1262
+ if (byProvider.size > 1) {
1263
+ const sorted = [...byProvider.entries()].sort((a, b) => b[1] - a[1]);
1264
+ const counts = sorted.map(([p, c]) => `${p}(${c})`).join(' ');
1265
+ hint = `提示: ${selectors.join(' ')} 命中 ${counts};用 ${sorted[0][0]}*/${buildSuggestedIdSeg(selectors)} 可锁定单一 provider`;
1266
+ }
1267
+ }
1268
+
1269
+ if (newPrimary) await pi.setModel(newPrimary);
1270
+ selection = sel;
1271
+ selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
1272
+ applyAgentModelOverrides(sel);
1273
+ const content = `✅ 已切换 selector 约束 (${formatFamilyConstraint(familyConstraint)}):\n${changes.join('\n')}${hint ? `\n${hint}` : ''}`;
1274
+ pi.sendMessage({
1275
+ customType: 'pick-model',
1276
+ content,
1277
+ display: true,
1278
+ }, { triggerTurn: false });
1279
+ return;
1280
+ }
1281
+
1282
+ // ── /pick-model choices (default) ────────────────
1283
+ const sel = await getSelectionForCommand(ctx);
1284
+ const tierStatus = (() => {
1285
+ const loaded = isTiersDataLoaded();
1286
+ return loaded === null ? 'not loaded' : loaded ? '' : '(tier data corrupt, using heuristics)';
1287
+ })();
1288
+ const lines: string[] = [];
1289
+ const constraintLabel = formatFamilyConstraint(familyConstraint);
1290
+ if (constraintLabel) lines.push(`constraint: ${constraintLabel}`);
1291
+ lines.push(formatModelChoicesMarkdown(sel, '', ctx.model));
1292
+ lines.push('');
1293
+ lines.push(formatFamilyTierMarkdown(sel));
1294
+ lines.push('');
1295
+ lines.push('`/pick-model refresh` 切换耗尽模型 · `/pick-model update` 更新 tier 数据');
1296
+ pi.sendMessage({ customType: 'pick-model', content: lines.join('\n'), display: true }, { triggerTurn: false });
1297
+ } catch (e) {
1298
+ warn(`[omp-opsx-addon] /pick-model error: ${e}`);
1299
+ pi.sendMessage({ customType: 'pick-model', content: `❌ Error: ${e}`, display: true }, { triggerTurn: false });
1300
+ }
1301
+ },
1302
+ });
1303
+
1304
+ // Auto-refresh tier data on first load if missing or stale
1305
+ if (autoRefreshTiersIfStale()) {
1306
+ warn('[omp-opsx-addon] Auto-refreshed tiers data (missing or stale)');
1307
+ }
1308
+ for (const role of Object.keys(AGENT_MAP) as Role[]) {
1309
+ const agent = AGENT_MAP[role];
1310
+ pi.registerCommand(role, {
1311
+ description: `Dispatch to ${agent} via task`,
1312
+ handler: async (args: string, _ctx: ExtensionCommandContext) => {
1313
+ try {
1314
+ const sel = await getSelectionForCommand(_ctx);
1315
+ pi.sendUserMessage(buildDispatchPrompt(role, args.trim() || '开始工作', sel));
1316
+ } catch (e) {
1317
+ warn(`[omp-opsx-addon] /${role} error: ${e}`);
1318
+ }
1319
+ },
1320
+ });
1321
+ }
1322
+
1323
+ async function getSelectionForCommand(ctx: ExtensionCommandContext): Promise<SelectionContext> {
1324
+ if (selection) return selection;
1325
+ return ensureSelection(ctx);
1326
+ }
1327
+ };