@genee/omp-opsx-addon 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,19 +1,18 @@
1
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.
2
+ // The harness forwards every task-subagent message as a SubagentEventPayload
3
+ // { id, event } frame on the "task:subagent:event" channel, emitted to the
4
+ // parent session's eventBus (plus the root observability bus, which is not
5
+ // reachable from extensions). Each plugin instance subscribes its OWN session
6
+ // bus via the official ExtensionAPI handle `pi.events` — the EventBus the
7
+ // harness injects as ConcreteExtensionAPI's `events` field (loader.ts). The
8
+ // root instance's bus carries depth-1 frames, a depth-1 instance's bus carries
9
+ // depth-2 frames, and so on, so every nesting depth is covered while each
10
+ // frame is consumed exactly once. Frames funnel into the module-level recorder
11
+ // singleton below, attributed to the root session id.
8
12
  //
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:
13
+ // The channel string is hardcoded from harness source:
12
14
  // src/task/types.ts:59 -> "task:subagent:event"
13
15
  //
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
16
  // Reference:
18
17
  // - emitSubagentFrame: src/task/executor.ts:1431-1432
19
18
  // - SubagentEventPayload: src/task/types.ts:82-84
@@ -23,42 +22,18 @@ const TASK_SUBAGENT_EVENT_CHANNEL = "task:subagent:event";
23
22
  // Track unsubscribers for cleanup on session_shutdown
24
23
  const subagentEventUnsubscribers = new Map<string, () => void>();
25
24
 
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
- }
25
+ // Official session-bus handle accessor. `ExtensionAPI` declares
26
+ // `events: EventBus` and the harness injects the session bus as
27
+ // ConcreteExtensionAPI's `events` field, so this is a typed read; the runtime
28
+ // shape check only defends against older hosts whose dist predates the field.
29
+ function getPluginEventBus(pi: ExtensionAPI): ExtensionAPI['events'] | undefined {
30
+ const events = pi.events;
31
+ if (events && typeof events === 'object' && typeof events.on === 'function') {
32
+ return events;
43
33
  }
44
34
  return undefined;
45
35
  }
46
36
 
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
37
  // Process subagent event payload
63
38
  function handleSubagentEvent(payload: unknown, primarySessionId: string): void {
64
39
  try {
@@ -104,23 +79,36 @@ function handleSubagentEvent(payload: unknown, primarySessionId: string): void {
104
79
  sessionUsageRecorder.record(canonicalizeProvider(provider), usage, subagentStreamKey);
105
80
  } catch (err) {
106
81
  // Silently fail to avoid breaking the plugin
107
- console.error('Failed to handle subagent event:', err);
82
+ getLogger().error('Failed to handle subagent event', { err });
108
83
  }
109
84
  }
110
- import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@oh-my-pi/pi-coding-agent';
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';
111
87
  import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
112
88
  import type { UsageReport } from '@oh-my-pi/pi-ai';
113
89
  import { createCachedScanner } from './lib/change-scanner.js';
114
90
  import { installAgents } from './lib/agent-defs.js';
91
+ import {
92
+ applyRoleModelOverrides,
93
+ buildRoleOverridePlan,
94
+ dispatchModelHint,
95
+ frontmatterModel,
96
+ ROLE_TO_OMP_ALIAS,
97
+ OMP_ROLE_TO_TIER,
98
+ ROLE_CAPABILITY,
99
+ type SkippedRole,
100
+ } from './lib/model-roles.js';
115
101
  import { buildStaticPrompt, buildDynamicPrompt } from './lib/system-prompt.js';
116
102
  import { type ProviderHealth, canonicalizeProvider, listDetectableProviders, healthCacheKey } from './lib/usage-resolver.js';
117
103
  import { readOpsxSettings, type ResolvedOpsxConfig, type RoleValue, OPSX_ROLES } from './lib/unified-config.js';
118
- import { selectAllRoles, mergePlanProviders, type SelectorResult } from './lib/model-selector.js';
119
- import { scoreModelWithProvider } from './lib/model-tiers.js';
104
+ import { selectAllRoles, selectModel, mergePlanProviders, type SelectorResult } from './lib/model-selector.js';
105
+ import { scoreModelWithProvider, type TierName } from './lib/model-tiers.js';
120
106
  import { DIRECT_FETCHERS } from './lib/direct-fetchers.js';
121
107
  import { createUsageWidget } from './lib/usage-widget.js';
122
108
  import { renderUsageReports, type ConsumptionTrack } from './lib/usage-render.js';
123
109
  import { markExhausted, markAvailable, getExhaustedProviders } from './lib/provider-status.js';
110
+ import { registerPipeCore } from './lib/pipe-core.js';
111
+ import { registerPipePush } from './lib/pipe-push.js';
124
112
  import { scheduleStatusProbes } from './lib/status-probe.js';
125
113
  import {
126
114
  startSharedPoller,
@@ -158,6 +146,7 @@ import { filterSelectableModels, reachabilityFingerprint } from './lib/selection
158
146
  import { parseAutoModelArgs, CHINA_EXCLUDE_FAMILIES, listFamilies, listProviders } from './lib/family-filter.js';
159
147
  import { hasRateLimitError, hasRegionError, buildErrorDiagnosis, exclusionReasonStats } from './lib/error-scan.js';
160
148
  import { applyEditVariantPin } from './lib/edit-variant-pin.js';
149
+ import { setOpsxLogger, getLogger } from './lib/logger.js';
161
150
  type Role = 'coder' | 'reviewer' | 'planner' | 'proposal-reviewer';
162
151
 
163
152
  /** Session-scoped selector constraint for `/pick-model <selector> [--china]`. */
@@ -266,24 +255,17 @@ export interface SelectionContext {
266
255
  roleModels: Record<Role, string | undefined>;
267
256
  /** Display label for each role from config, e.g. "auto", "smol", "deepseek-v4-pro". */
268
257
  roleConfigs: Record<Role, string | undefined>;
258
+ /** Parsed role value kind for each role (auto | omp-alias | model | provider-model). */
259
+ roleKind: Record<Role, RoleValue['kind']>;
260
+ /** Full-role session overlay payload written to `overrideModelRoles` (all known OMP roles). */
261
+ roleOverrides: Record<string, string>;
262
+ /** Known roles left out of the overlay payload (config skip / capability gate / no pick). */
263
+ skippedRoles: SkippedRole[];
269
264
  }
270
265
 
271
266
  const DEFAULT_PLAN_PROVIDERS = new Set(['minimax-code-cn', 'opencode-go', 'zhipu-coding-plan', 'kimi', 'openai-codex']);
272
267
 
273
- /** OPSX role → OMP role alias used in agent frontmatter `model: @<alias>`. */
274
- const ROLE_TO_OMP_ALIAS: Record<Role, string> = {
275
- coder: 'smol',
276
- reviewer: 'default',
277
- planner: 'default',
278
- 'proposal-reviewer': 'default',
279
- };
280
268
 
281
- /** OMP role alias → expected tier (without the `@`). */
282
- const OMP_ROLE_TO_TIER: Record<string, string> = {
283
- smol: 'mid',
284
- default: 'high',
285
- slow: 'top',
286
- };
287
269
 
288
270
  function roleValueToModelString(value: RoleValue): string | undefined {
289
271
  if (value.kind === 'auto') return undefined;
@@ -314,19 +296,39 @@ function synthPinnedResult(role: string, modelString: string | undefined): Selec
314
296
  export function buildDispatchPrompt(role: Role, task: string, selection: SelectionContext): string {
315
297
  const agent = AGENT_MAP[role];
316
298
  const sel = selection.selectorResults[role];
317
- const modelHint = sel?.picked ? `${sel.picked.provider}/${sel.picked.id}` : selection.roleModels[role];
318
- const parts = [`你的 opsx.${role} 已自动选择 model。`];
299
+ // auto roles (and omp-alias roles such as @slow) pass NO model: requestModel
300
+ // is the top-priority resolution layer and would shadow the frontmatter
301
+ // tier-role alias. Only concrete provider/model pins stay explicit.
302
+ const modelHint = dispatchModelHint(role, selection);
303
+ const parts = [`你的 opsx.${role} 模型由 role 自动解析。`];
319
304
  if (modelHint) parts.push(`请通过 task 工具调用 ${agent} 并指定 model="${modelHint}"。`);
320
- else parts.push(`请通过 task 工具调用 ${agent}。`);
305
+ else parts.push(`请直接通过 task 工具调用 ${agent}(无需指定 model,模型按 role 自动解析)。`);
321
306
  if (sel?.decision) parts.push(`原因: ${sel.decision}`);
322
307
  parts.push('直接用内置工具实现。');
323
308
  return `请使用 task 工具委派给 ${agent}:\ntask(agent:"${agent}"${modelHint ? `, model:"${modelHint}"` : ''}, task:"${parts.join(' ')}\\n\\n${task}")`;
324
309
  }
325
310
 
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
+
326
327
  function computeSelectionSync(
327
328
  config: ResolvedOpsxConfig,
328
329
  models: Model<Api>[] | null,
329
330
  health: Map<string, ProviderHealth> | null,
331
+ knownRoleIds: string[],
330
332
  regionBlockedModels?: Set<string>,
331
333
  unreachableProviders?: Set<string>,
332
334
  availableByProvider?: Map<string, string[] | null>,
@@ -348,16 +350,23 @@ function computeSelectionSync(
348
350
  const selectorResults = {} as Record<Role, SelectorResult>;
349
351
  const roleModels = {} as Record<Role, string | undefined>;
350
352
  const roleConfigs = {} as Record<Role, string | undefined>;
353
+ const roleKind = {} as Record<Role, RoleValue['kind']>;
351
354
  const planSet = mergePlanProviders(config.planProviders);
352
355
  for (const role of OPSX_ROLES) {
353
356
  const value = config.roles[role];
354
- roleConfigs[role] = value.kind === 'auto' ? ROLE_TO_OMP_ALIAS[role] : (roleValueToModelString(value) ?? '?');
357
+ 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) ?? '?');
355
363
  const alias = `@${ROLE_TO_OMP_ALIAS[role]}`;
356
364
  if (value.kind === 'auto') {
357
365
  if (filteredModels && filteredModels.length > 0) {
358
366
  const ompRole = ROLE_TO_OMP_ALIAS[role];
359
- const tier = (config.roleTiers[role] ?? OMP_ROLE_TO_TIER[ompRole] ?? 'mid') as
360
- 'top' | 'high' | 'mid' | 'low' | 'tiny';
367
+ const tier = (config.roleTiers[role]
368
+ ?? (OMP_ROLE_TO_TIER as Record<string, TierName>)[ompRole]
369
+ ?? 'mid') as TierName;
361
370
  const roleTiers = { ...config.roleTiers, [role]: tier };
362
371
  const result = selectAllRoles(
363
372
  [role],
@@ -388,9 +397,56 @@ function computeSelectionSync(
388
397
  roleModels[role] = modelString?.includes('/') ? modelString : undefined;
389
398
  }
390
399
  }
391
- return { selectorResults, roleModels, roleConfigs };
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.
405
+ let roleOverrides: Record<string, string> = {};
406
+ let skippedRoles: SkippedRole[] = [];
407
+ if (filteredModels && filteredModels.length > 0) {
408
+ const pickString = (r: SelectorResult): string | undefined =>
409
+ r.picked ? `${r.picked.provider}/${r.picked.id}` : undefined;
410
+ const tierPicks: Partial<Record<TierName, string | undefined>> = {
411
+ mid: pickString(selectorResults.coder),
412
+ high: pickString(selectorResults.reviewer),
413
+ };
414
+ for (const supplementalTier of ['tiny', 'low', 'top'] as const) {
415
+ const synthRole = `__tier_${supplementalTier}`;
416
+ const result = selectModel({
417
+ role: synthRole,
418
+ models: filteredModels,
419
+ roleTiers: { [synthRole]: supplementalTier },
420
+ tierOverrides: config.tierOverrides,
421
+ health: health ?? undefined,
422
+ allowlist: config.modelAllowlist,
423
+ planProviders: planSet,
424
+ });
425
+ tierPicks[supplementalTier] = pickString(result);
426
+ }
427
+ const capabilityPicks: Record<string, string | undefined> = {};
428
+ const visionModels = filteredModels.filter((m) => ROLE_CAPABILITY.vision(m));
429
+ if (visionModels.length > 0) {
430
+ const visionResult = selectModel({
431
+ role: 'vision',
432
+ models: visionModels,
433
+ roleTiers: { vision: 'high' },
434
+ tierOverrides: config.tierOverrides,
435
+ health: health ?? undefined,
436
+ allowlist: config.modelAllowlist,
437
+ planProviders: planSet,
438
+ });
439
+ capabilityPicks.vision = pickString(visionResult);
440
+ }
441
+ const plan = buildRoleOverridePlan(
442
+ { knownRoleIds, ompRoleTiers: config.ompRoleTiers, tierPicks, capabilityPicks },
443
+ (m) => getLogger().warn(m),
444
+ );
445
+ roleOverrides = plan.overrides;
446
+ skippedRoles = plan.skipped;
447
+ }
448
+ return { selectorResults, roleModels, roleConfigs, roleKind, roleOverrides, skippedRoles };
392
449
  }
393
-
394
450
  function formatModelChoicesMarkdown(sel: SelectionContext, suffix: string = '', primaryModel?: Model<Api>): string {
395
451
  const rows: string[] = [];
396
452
  let totalModelCount = 0;
@@ -459,19 +515,25 @@ function formatFamilyTierMarkdown(sel: SelectionContext): string {
459
515
  return rows.join('\n');
460
516
  }
461
517
 
462
- // ── per-plugin-instance usage recorder ─────────────────────────────
463
- // NOT a process-wide singleton: the harness loads a fresh plugin instance
464
- // for every session, including in-process task subagents (task/executor.ts),
465
- // so each instance builds its own recorder here. Subagent sessions therefore
466
- // cannot write to the primary's recorder directly. Instead their usage is
467
- // captured through the eventBus mechanism:
468
- // - task/executor.ts:1431-1432 forwards subagent events to the primary session's
469
- // eventBus via TASK_SUBAGENT_EVENT_CHANNEL ("task:subagent:event")
470
- // - The primary plugin subscribes to this channel and records subagent usage
471
- // with subagent-specific stream keys (never re-keys the recorder, so the
472
- // owner's cumulative baseline survives and streams don't double-count)
473
- // Each session's recorder persists its own session totals into the shared
474
- // usage cache, which every process aggregates on read.
518
+ // ── process-wide usage recorder singleton ──────────────────────────
519
+ // This IS a process-wide singleton: the harness rebinds the same evaluated
520
+ // plugin module to a fresh ExtensionAPI instance for every session, including
521
+ // in-process task subagents (task/executor.ts), WITHOUT re-evaluating the
522
+ // module graph — so every instance shares the recorder created here. Only the
523
+ // owning session (has a UI, or is the first headless session) re-keys it via
524
+ // setSessionId; rebind instances never re-key and never write usage through
525
+ // their own message_* handlers (gated by recordsOwnUsage below). Subagent
526
+ // consumption reaches this recorder exclusively through the bus-subscription
527
+ // mechanism (see file header):
528
+ // - Each instance subscribes its own session bus (pi.events) to
529
+ // TASK_SUBAGENT_EVENT_CHANNEL ("task:subagent:event"); depth-N frames land
530
+ // on the depth-(N-1) session's bus, so all depths are covered.
531
+ // - Subscription callbacks attribute frames to the recorder's root session id
532
+ // with per-subagent stream keys (stream keys only — the recorder is never
533
+ // re-keyed, so the owner's cumulative baseline survives and streams don't
534
+ // double-count).
535
+ // Session totals persist into the shared usage cache, which every process
536
+ // aggregates on read.
475
537
  const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
476
538
  write: writeSessionUsage,
477
539
  remove: removeSessionUsage,
@@ -481,6 +543,9 @@ const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
481
543
  // ── plugin entry ────────────────────────────────────────────────────
482
544
 
483
545
  export default (pi: ExtensionAPI) => {
546
+ // Route plugin logging through the host logger (rolling file) instead of
547
+ // console.*, which would corrupt the TUI. Injected before any log path runs.
548
+ setOpsxLogger(pi.logger);
484
549
  // Runtime edit-variant pin for deepseek-v4-flash. The host hard-maps the
485
550
  // model to the sloppy §/» marker grammar (it misreads hashline ranges),
486
551
  // but the model repeatedly breaks sloppy control lines too — retry loops
@@ -492,7 +557,11 @@ export default (pi: ExtensionAPI) => {
492
557
  // host reads the merged view directly, making the file equivalent.
493
558
  applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
494
559
  const directory = process.cwd();
495
- const warn = (msg: string) => console.warn(msg);
560
+ 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);
496
565
  const opsxConfig = readOpsxSettings(directory, warn);
497
566
  // Static orchestration rules — built once, reused every turn for cache stability.
498
567
  const STATIC_PROMPT = buildStaticPrompt();
@@ -583,6 +652,7 @@ export default (pi: ExtensionAPI) => {
583
652
  effectiveConfig,
584
653
  models,
585
654
  health,
655
+ currentKnownRoleIds(),
586
656
  regionBlockedModels,
587
657
  reach.unreachable,
588
658
  reach.availableByProvider,
@@ -596,32 +666,28 @@ export default (pi: ExtensionAPI) => {
596
666
  });
597
667
  return selectionPromise;
598
668
  };
599
- // Push selector-picked models to native task subagents via task.agentModelOverrides.
600
- // pi.pi.settings is the host-process Settings singleton (the require()'d copy in
601
- // node_modules is a separate, uninitialized instance — writing to it was a no-op).
602
- const applyAgentModelOverrides = (sel: SelectionContext, force = false): void => {
603
- const settings = pi.pi?.settings;
604
- if (!settings) {
605
- warn('[omp-opsx-addon] pi.pi.settings unavailable; subagent model overrides skipped');
606
- return;
607
- }
608
- const overrides: Record<string, string> = {};
609
- for (const role of OPSX_ROLES) {
610
- const picked = sel.selectorResults[role]?.picked;
611
- if (picked) {
612
- overrides[AGENT_MAP[role]] = `${picked.provider}/${picked.id}`;
613
- }
614
- }
615
- if (Object.keys(overrides).length > 0 || force) {
616
- settings.override('task.agentModelOverrides', overrides);
617
- warn(
618
- `[omp-opsx-addon] task.agentModelOverrides: ${Object.entries(overrides)
619
- .map(([k, v]) => `${k}=${v}`)
620
- .join(', ')}`,
621
- );
622
- }
669
+ // Apply selector picks by TIER to session-scoped OMP modelRoles (smol =
670
+ // coder/mid, default = reviewer/high), so every role-resolving agent —
671
+ // including built-in scout (smol) — follows selector/refresh/reset.
672
+ // Persisted per-agent pins are neutralized at runtime with empty strings.
673
+ // pi.pi.settings is the host-process Settings singleton (the require()'d
674
+ // copy in node_modules is a separate, uninitialized instance — writing to
675
+ // it was a no-op). This thin wrapper is the single call site the
676
+ // constrained-period tick-reselect path (auto-model-family) also uses.
677
+ const applyRoleModel = (sel: SelectionContext): void => {
678
+ applyRoleModelOverrides(pi.pi?.settings, sel, warn);
623
679
  };
624
680
 
681
+ // Instance-level usage-record claim: true only while this instance's
682
+ // session owns the process-wide recorder (claimsOwnership in session_start
683
+ // below). Rebind instances (in-process task subagents) keep it false —
684
+ // their message_* handlers must not write the default stream, because that
685
+ // consumption is captured exactly once by the parent instance's bus
686
+ // subscription with a subagent stream key. Closure variable, not
687
+ // module-level: rebind reuses the evaluated module, so a module-level flag
688
+ // would be overwritten by whichever instance starts last.
689
+ let recordsOwnUsage = false;
690
+
625
691
  // ── shared usage poller + widget ──────────────────────────────
626
692
  pi.on('session_start', async (_event, ctx) => {
627
693
  try {
@@ -647,30 +713,38 @@ export default (pi: ExtensionAPI) => {
647
713
  if (claimsOwnership) {
648
714
  sessionUsageRecorder.setSessionId(ctx.sessionManager?.getSessionId?.() ?? '');
649
715
  }
650
- // Subscribe to subagent events via eventBus to capture usage
651
- // Only primary sessions (with UI or no owner) should subscribe
652
- if (claimsOwnership) {
653
- const sessionId = ctx.sessionManager?.getSessionId?.() ?? '';
654
- const eventBus = getEventBusFromCtx(ctx);
655
- if (eventBus) {
656
- // Clean up any existing subscription for this session to prevent leaks
657
- const existingUnsubscribe = subagentEventUnsubscribers.get(sessionId);
658
- if (existingUnsubscribe) {
659
- try {
660
- existingUnsubscribe();
661
- } catch {
662
- // Silently ignore errors in unsubscribe
663
- }
664
- }
665
- const unsubscribe = subscribeToEventBusChannel(
666
- eventBus,
667
- TASK_SUBAGENT_EVENT_CHANNEL,
668
- (payload) => handleSubagentEvent(payload, sessionId),
669
- );
670
- if (unsubscribe) {
671
- subagentEventUnsubscribers.set(sessionId, unsubscribe);
716
+ recordsOwnUsage = claimsOwnership;
717
+ // Every instance subscribes its OWN session bus (pi.events) to the
718
+ // subagent channel — record ownership and subscription rights are
719
+ // decoupled: the root instance's bus carries depth-1 frames, a
720
+ // depth-1 instance's bus carries depth-2 frames, and each frame is
721
+ // consumed exactly once (see file header). Callbacks attribute
722
+ // frames to the recorder singleton's root session id (the module
723
+ // graph is shared across rebinds), falling back to this session's
724
+ // own id only before the owner has claimed.
725
+ const sessionId = ctx.sessionManager?.getSessionId?.() ?? '';
726
+ const pluginEventBus = getPluginEventBus(pi);
727
+ if (pluginEventBus) {
728
+ // Clean up any existing subscription for this session to prevent leaks
729
+ const existingUnsubscribe = subagentEventUnsubscribers.get(sessionId);
730
+ if (existingUnsubscribe) {
731
+ try {
732
+ existingUnsubscribe();
733
+ } catch {
734
+ // Silently ignore errors in unsubscribe
672
735
  }
673
736
  }
737
+ const unsubscribe = pluginEventBus.on(TASK_SUBAGENT_EVENT_CHANNEL, (payload) => {
738
+ handleSubagentEvent(payload, sessionUsageRecorder.sessionId() || sessionId);
739
+ });
740
+ subagentEventUnsubscribers.set(sessionId, unsubscribe);
741
+ getLogger().info(
742
+ `[omp-opsx-addon] subagent usage capture subscribed (sid=${sessionId}, channel=${TASK_SUBAGENT_EVENT_CHANNEL})`,
743
+ );
744
+ } else {
745
+ getLogger().warn(
746
+ '[omp-opsx-addon] pi.events handle unavailable — subagent usage capture disabled (subagent consumption will not be recorded)',
747
+ );
674
748
  }
675
749
  const auth = ctx.modelRegistry?.authStorage;
676
750
  const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
@@ -906,12 +980,14 @@ export default (pi: ExtensionAPI) => {
906
980
  // PRIMARY usage source, so the waveform reflects in-progress consumption
907
981
  // and stays dense (message_end alone is sparse). `record` is monotonic MAX,
908
982
  // so repeated chunk calls never double-count; the final message_end value
909
- // (equal or higher) is a harmless re-record. In-process subagent sessions
910
- // forward their usage through the eventBus channel (TASK_SUBAGENT_EVENT_CHANNEL)
911
- // which this plugin subscribes to in session_start. Subagent consumption is
912
- // recorded with subagent-specific keys and merged into the primary waveform.
983
+ // (equal or higher) is a harmless re-record. Only the recorder-owning
984
+ // instance writes here: task-subagent rebind instances skip these handlers,
985
+ // because their consumption reaches the recorder through the parent
986
+ // instance's bus subscription instead (subagent-specific stream keys,
987
+ // merged into the primary waveform).
913
988
  pi.on('message_update', async (event) => {
914
989
  try {
990
+ if (!recordsOwnUsage) return;
915
991
  const ev = event.assistantMessageEvent;
916
992
  if ('partial' in ev) {
917
993
  const usage = assistantUsageOf(ev.partial);
@@ -925,6 +1001,7 @@ export default (pi: ExtensionAPI) => {
925
1001
  // nonzero streamed total (same monotonic MAX, no double count).
926
1002
  pi.on('message_end', async (event) => {
927
1003
  try {
1004
+ if (!recordsOwnUsage) return;
928
1005
  const usage = assistantUsageOf(event.message);
929
1006
  if (usage) sessionUsageRecorder.record(canonicalizeProvider(usage.provider), usage.usage);
930
1007
  } catch {
@@ -958,13 +1035,15 @@ export default (pi: ExtensionAPI) => {
958
1035
  }
959
1036
  }
960
1037
  // Always clean up eventBus subscription, even if no usage was recorded
961
- const cleanupUnsubscribe = subagentEventUnsubscribers.get(sid!);
962
- if (cleanupUnsubscribe) {
963
- try {
964
- cleanupUnsubscribe();
965
- subagentEventUnsubscribers.delete(sid!);
966
- } catch {
967
- /* best-effort */
1038
+ if (sid) {
1039
+ const cleanupUnsubscribe = subagentEventUnsubscribers.get(sid);
1040
+ if (cleanupUnsubscribe) {
1041
+ try {
1042
+ cleanupUnsubscribe();
1043
+ subagentEventUnsubscribers.delete(sid);
1044
+ } catch {
1045
+ /* best-effort */
1046
+ }
968
1047
  }
969
1048
  }
970
1049
  } catch {
@@ -980,7 +1059,12 @@ export default (pi: ExtensionAPI) => {
980
1059
  // This fallback call is intentionally NOT threaded with familyConstraint:
981
1060
  // it prefers the cached `selection`, and the fallback passes models=null so
982
1061
  // filterSelectableModels' family branch never runs — it is not a reselect path.
983
- const sel = selection ?? computeSelectionSync(opsxConfig, null, null);
1062
+ const sel = selection ?? computeSelectionSync(
1063
+ opsxConfig,
1064
+ null,
1065
+ null,
1066
+ safeGetKnownRoleIds(pi.pi?.settings),
1067
+ );
984
1068
  const changes = await changeScanner.scanChanges(directory, 'doing');
985
1069
  // Layered injection: STATIC_PROMPT is byte-identical every turn → prefix cache
986
1070
  // hit for [omp-base + skills + static]. Only the dynamic tail re-prefills.
@@ -994,17 +1078,14 @@ export default (pi: ExtensionAPI) => {
994
1078
 
995
1079
  pi.on('session_start', async (_event, ctx) => {
996
1080
  try {
997
- const pinned = (role: Role): string | undefined => {
998
- const v = opsxConfig.roles[role];
999
- if (v.kind === 'auto') return undefined;
1000
- return roleValueToModelString(v);
1001
- };
1081
+ const frontmatter = (role: Role): string | undefined =>
1082
+ frontmatterModel(role, opsxConfig.roles[role]);
1002
1083
  await installAgents(
1003
1084
  directory,
1004
- pinned('coder'),
1005
- pinned('reviewer'),
1006
- pinned('planner'),
1007
- pinned('proposal-reviewer'),
1085
+ frontmatter('coder'),
1086
+ frontmatter('reviewer'),
1087
+ frontmatter('planner'),
1088
+ frontmatter('proposal-reviewer'),
1008
1089
  );
1009
1090
  warn('[omp-opsx-addon] agents ready (pick-model idle until /pick-model refresh)');
1010
1091
  } catch (e) {
@@ -1020,7 +1101,7 @@ export default (pi: ExtensionAPI) => {
1020
1101
  const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
1021
1102
  ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
1022
1103
  : undefined;
1023
- // 429/403 is the only observable exhaustion evidence for providers
1104
+ // Provider exhaustion seen on the response/tool path; mark it locally
1024
1105
  // without a usage fetcher — record it in the process-local binary status
1025
1106
  // so the placeholder column reads 耗尽 (see lib/provider-status.ts).
1026
1107
  markExhausted(canonical);
@@ -1119,6 +1200,7 @@ export default (pi: ExtensionAPI) => {
1119
1200
  effectiveConfig,
1120
1201
  models,
1121
1202
  health,
1203
+ currentKnownRoleIds(),
1122
1204
  regionBlockedModels,
1123
1205
  reach.unreachable,
1124
1206
  reach.availableByProvider,
@@ -1154,7 +1236,7 @@ export default (pi: ExtensionAPI) => {
1154
1236
  }
1155
1237
  }
1156
1238
  selection = freshSel;
1157
- applyAgentModelOverrides(freshSel);
1239
+ applyRoleModel(freshSel);
1158
1240
  if (changes.length > 0) {
1159
1241
  pi.sendMessage({ customType: 'pick-model', content: `✅ 模型已刷新:\n${changes.join('\n')}`, display: true }, { triggerTurn: false });
1160
1242
  } else {
@@ -1181,6 +1263,7 @@ export default (pi: ExtensionAPI) => {
1181
1263
  effectiveConfig,
1182
1264
  models,
1183
1265
  health,
1266
+ currentKnownRoleIds(),
1184
1267
  regionBlockedModels,
1185
1268
  reach.unreachable,
1186
1269
  reach.availableByProvider,
@@ -1205,7 +1288,7 @@ export default (pi: ExtensionAPI) => {
1205
1288
  if (newPrimary) await pi.setModel(newPrimary);
1206
1289
  selection = sel;
1207
1290
  selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
1208
- applyAgentModelOverrides(sel, true);
1291
+ applyRoleModel(sel);
1209
1292
  pi.sendMessage({
1210
1293
  customType: 'pick-model',
1211
1294
  content: changes.length > 0
@@ -1265,6 +1348,7 @@ export default (pi: ExtensionAPI) => {
1265
1348
  effectiveConfig,
1266
1349
  models,
1267
1350
  health,
1351
+ currentKnownRoleIds(),
1268
1352
  regionBlockedModels,
1269
1353
  reach.unreachable,
1270
1354
  reach.availableByProvider,
@@ -1324,7 +1408,7 @@ export default (pi: ExtensionAPI) => {
1324
1408
  if (newPrimary) await pi.setModel(newPrimary);
1325
1409
  selection = sel;
1326
1410
  selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
1327
- applyAgentModelOverrides(sel);
1411
+ applyRoleModel(sel);
1328
1412
  const content = `✅ 已切换 selector 约束 (${formatFamilyConstraint(familyConstraint)}):\n${changes.join('\n')}${hint ? `\n${hint}` : ''}`;
1329
1413
  pi.sendMessage({
1330
1414
  customType: 'pick-model',
@@ -1379,4 +1463,14 @@ export default (pi: ExtensionAPI) => {
1379
1463
  if (selection) return selection;
1380
1464
  return ensureSelection(ctx);
1381
1465
  }
1466
+
1467
+ // Cross-broker project-scoped pipe core: registers opsx_pipe_list /
1468
+ // _send / _recv / _wait and starts presence heartbeat + GC on
1469
+ // session_start (managed timers inside lib/pipe-core).
1470
+ registerPipeCore(pi);
1471
+
1472
+ // Cross-broker push/injector: watches the local mbox slot and injects
1473
+ // inbound pipe messages as custom (opsx-pipe) turns; /opsx-pipe mute
1474
+ // toggles the stop-injection flag (managed timers inside lib/pipe-push).
1475
+ registerPipePush(pi);
1382
1476
  };