@genee/omp-opsx-addon 0.3.0 → 0.4.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/README.md CHANGED
@@ -63,6 +63,15 @@ tiers:
63
63
  # 可选:role → tier 期望
64
64
  role_tiers:
65
65
  planner: top
66
+
67
+ # 可选:OMP 内置/自定义 model role → tier 期望(或 skip 排除)
68
+ # 仅影响 /pick-model 后 session 覆盖写入哪些模型;合法值 tiny/low/mid/high/top/skip
69
+ model_role_tiers:
70
+ advisor: skip
71
+ plan: skip
72
+ tiny: skip
73
+ vision: skip # vision 固定 high 档候选,仅 skip 有效(见下文)
74
+ task: low
66
75
  ```
67
76
 
68
77
  ---
@@ -73,6 +82,79 @@ role_tiers:
73
82
 
74
83
  决策日志打印到 stderr:`[omp-opsx-addon] planner → anthropic/claude-opus-4-6 (tier=top, gap=0, remaining=0.80)`
75
84
 
85
+
86
+ ### 子 agent 模型落点:session-scoped modelRoles
87
+
88
+ `/pick-model <selector>` / `refresh` / `reset` 应用选择结果时,插件**不**再按 agent 名写
89
+ `task.agentModelOverrides`,而是写 OMP session 运行时覆盖
90
+ `settings.overrideModelRoles({ …全 role… })`——覆盖**宿主当前已知的全部 model role**
91
+ (10 个内置 role + 自定义 role:`getKnownRoleIds` 从 cycleOrder/modelRoles/modelTags 汇总):
92
+
93
+ - 每个 role 跟随一个 tier 的选中模型;同一 tier 的多个 role 共用同一个模型(同候选集
94
+ 排序结果唯一)。mid/high 复用 opsx 4 role 的选择(coder→mid、reviewer→high),
95
+ tiny/low/top 在同一过滤候选集上补选。
96
+ - **内置 role 默认 tier 映射**:
97
+
98
+ | role | tier | 触发场景 |
99
+ | --- | --- | --- |
100
+ | `tiny` | tiny | 极小任务 |
101
+ | `smol` | mid | scout 等快速任务 |
102
+ | `task` | mid | task 工具委派的全能力子 agent(与 scout 同档,防能力倒挂) |
103
+ | `commit` | low | AI commit |
104
+ | `default` | high | 主会话 / reviewer |
105
+ | `designer` | high | designer agent |
106
+ | `vision` | high | `/vision`、图像输入(**能力门控**,见下) |
107
+ | `slow` | top | 深度思考 |
108
+ | `plan` | top | plan mode |
109
+ | `advisor` | top | advisor 会话 |
110
+
111
+ **激活语义**:advisor/plan/tiny 等默认未配置的 role 被写入覆盖后,**只在对应功能真正
112
+ 触发时**(plan mode、advisor 会话、auto-thinking、AI commit、`/vision`)才会解析到
113
+ selector 模型;功能不触发就不产生消耗。覆盖 session 结束即还原。
114
+ - **自定义 role**(cycleOrder/modelTags 里出现、不在内置表中的 role)默认跟随 `high`,
115
+ 并一次性 warn 提示可用 `model_role_tiers` 调档或排除。
116
+ - **`model_role_tiers`(opsx.yml)**:OMP role 名 → `tiny/low/mid/high/top` 或 `skip`,
117
+ 覆盖上表默认值;自定义 role 也可配。`skip` 是唯一逃生舱——该 role **不写** session
118
+ 覆盖、保留你的 config 值(默认策略是全量覆盖,包含 config 里的 `modelRoles.*` 钉值,
119
+ 不做「自动跳过钉值」)。**例外:`vision` role 固定使用 high 档候选**(仅在具备 image
120
+ 能力的模型中挑选,见下条),`model_role_tiers.vision` **仅 `skip` 生效**——配
121
+ `top/low/…` 等其他 tier 值会被静默接受但**不改变其选档**(仍按 high 档选图像模型)。
122
+ 保守配置示例:
123
+
124
+ ```yaml
125
+ model_role_tiers:
126
+ advisor: skip # 不想让 advisor 会话走 selector 模型
127
+ plan: skip # plan mode 保留 config 钉值
128
+ tiny: skip # 无 tiny 档模型时避免回落
129
+ vision: skip # 不覆盖 vision(vision 固定 high 档选图像模型,仅 skip 有效)
130
+ task: low # 显式降档
131
+ ```
132
+
133
+ - **vision 能力门控**:vision 候选**固定按 high 档**、只从声明 `input` 含 `image` 的模型
134
+ 中挑选(不按 `model_role_tiers.vision` 的 tier 值选档,该键仅 `skip` 有效);当前选择里
135
+ 没有图像模型时,vision role **不写覆盖**(warn 汇总为 `vision(capability)`,绝不写入
136
+ 纯文本模型),vision 功能回落 config/default。恢复手段:换一个含图像模型的 selector
137
+ 或用 `model_allowlist` 纳入图像模型;**`model_role_tiers.vision` 改 tier 绕不过门控、
138
+ 也不改选档**。
139
+ - **auto role 的 agent 定义**在 `.omp/agents/*.md` frontmatter 注入带引号的 tier alias
140
+ (coder `model: "@smol"`,其余三者 `model: "@default"`);task 工具经 modelRoles 解析。
141
+ - 因此**所有经 role 解析的 agent 都跟随切换**——包括插件未枚举的 OMP 内置/bundled/
142
+ project agent(例如内置 scout 走 `smol`,`/pick-model` selector/refresh/reset 后
143
+ scout 自动换到选中模型)。
144
+ - auto role 与 omp-alias(如 `slow`,值无 `/`)的 dispatch 提示词**不传 `model=`**,
145
+ 由 frontmatter alias 声明式接管;pinned 具体 provider/model 仍显式传 model,行为不变。
146
+ - 某 tier 补选没有可用模型(候选耗尽/allowlist 全过滤)时,该 tier 的各 role 不写覆盖、
147
+ warn 汇总(`no-pick`),命令不中断;无任何模型时计划为空,与既有「模型注册表不可用」
148
+ 分支一致。
149
+ - 覆盖为 **session 作用域、不写盘**:`overrideModelRoles` 只写 runtime overlay,
150
+ session 结束即还原;不会修改你的 `~/.omp/agent/config.yml`。每次应用前先
151
+ `clearOverride('modelRoles')`,避免上一 selector 的旧 role 值残留;`reset` 重算无约束
152
+ 默认并覆盖(**不清空**,否则回落 config 硬钉)。
153
+
154
+ > **手动清理建议**:若全局 `~/.omp/agent/config.yml` 里有 `modelRoles.smol` 钉在会耗尽的
155
+ > 套餐模型(scout 会一直打它直到被 session 覆盖压过),或 `task.agentModelOverrides` 残留
156
+ > 已删除的 `tester` 等 key,可手动删除这些钉值;插件运行期间会以 session 空串中和 per-agent
157
+ > 钉值,但不改动你的配置文件。
76
158
  ---
77
159
 
78
160
  ## 开发
package/index.ts CHANGED
@@ -104,23 +104,36 @@ function handleSubagentEvent(payload: unknown, primarySessionId: string): void {
104
104
  sessionUsageRecorder.record(canonicalizeProvider(provider), usage, subagentStreamKey);
105
105
  } catch (err) {
106
106
  // Silently fail to avoid breaking the plugin
107
- console.error('Failed to handle subagent event:', err);
107
+ getLogger().error('Failed to handle subagent event', { err });
108
108
  }
109
109
  }
110
- import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@oh-my-pi/pi-coding-agent';
110
+ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, Settings } from '@oh-my-pi/pi-coding-agent';
111
+ import { getKnownRoleIds, MODEL_ROLE_IDS } from '@oh-my-pi/pi-coding-agent/config/model-roles';
111
112
  import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
112
113
  import type { UsageReport } from '@oh-my-pi/pi-ai';
113
114
  import { createCachedScanner } from './lib/change-scanner.js';
114
115
  import { installAgents } from './lib/agent-defs.js';
116
+ import {
117
+ applyRoleModelOverrides,
118
+ buildRoleOverridePlan,
119
+ dispatchModelHint,
120
+ frontmatterModel,
121
+ ROLE_TO_OMP_ALIAS,
122
+ OMP_ROLE_TO_TIER,
123
+ ROLE_CAPABILITY,
124
+ type SkippedRole,
125
+ } from './lib/model-roles.js';
115
126
  import { buildStaticPrompt, buildDynamicPrompt } from './lib/system-prompt.js';
116
127
  import { type ProviderHealth, canonicalizeProvider, listDetectableProviders, healthCacheKey } from './lib/usage-resolver.js';
117
128
  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';
129
+ import { selectAllRoles, selectModel, mergePlanProviders, type SelectorResult } from './lib/model-selector.js';
130
+ import { scoreModelWithProvider, type TierName } from './lib/model-tiers.js';
120
131
  import { DIRECT_FETCHERS } from './lib/direct-fetchers.js';
121
132
  import { createUsageWidget } from './lib/usage-widget.js';
122
133
  import { renderUsageReports, type ConsumptionTrack } from './lib/usage-render.js';
123
134
  import { markExhausted, markAvailable, getExhaustedProviders } from './lib/provider-status.js';
135
+ import { registerPipeCore } from './lib/pipe-core.js';
136
+ import { registerPipePush } from './lib/pipe-push.js';
124
137
  import { scheduleStatusProbes } from './lib/status-probe.js';
125
138
  import {
126
139
  startSharedPoller,
@@ -158,6 +171,7 @@ import { filterSelectableModels, reachabilityFingerprint } from './lib/selection
158
171
  import { parseAutoModelArgs, CHINA_EXCLUDE_FAMILIES, listFamilies, listProviders } from './lib/family-filter.js';
159
172
  import { hasRateLimitError, hasRegionError, buildErrorDiagnosis, exclusionReasonStats } from './lib/error-scan.js';
160
173
  import { applyEditVariantPin } from './lib/edit-variant-pin.js';
174
+ import { setOpsxLogger, getLogger } from './lib/logger.js';
161
175
  type Role = 'coder' | 'reviewer' | 'planner' | 'proposal-reviewer';
162
176
 
163
177
  /** Session-scoped selector constraint for `/pick-model <selector> [--china]`. */
@@ -266,24 +280,17 @@ export interface SelectionContext {
266
280
  roleModels: Record<Role, string | undefined>;
267
281
  /** Display label for each role from config, e.g. "auto", "smol", "deepseek-v4-pro". */
268
282
  roleConfigs: Record<Role, string | undefined>;
283
+ /** Parsed role value kind for each role (auto | omp-alias | model | provider-model). */
284
+ roleKind: Record<Role, RoleValue['kind']>;
285
+ /** Full-role session overlay payload written to `overrideModelRoles` (all known OMP roles). */
286
+ roleOverrides: Record<string, string>;
287
+ /** Known roles left out of the overlay payload (config skip / capability gate / no pick). */
288
+ skippedRoles: SkippedRole[];
269
289
  }
270
290
 
271
291
  const DEFAULT_PLAN_PROVIDERS = new Set(['minimax-code-cn', 'opencode-go', 'zhipu-coding-plan', 'kimi', 'openai-codex']);
272
292
 
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
293
 
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
294
 
288
295
  function roleValueToModelString(value: RoleValue): string | undefined {
289
296
  if (value.kind === 'auto') return undefined;
@@ -314,19 +321,39 @@ function synthPinnedResult(role: string, modelString: string | undefined): Selec
314
321
  export function buildDispatchPrompt(role: Role, task: string, selection: SelectionContext): string {
315
322
  const agent = AGENT_MAP[role];
316
323
  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。`];
324
+ // auto roles (and omp-alias roles such as @slow) pass NO model: requestModel
325
+ // is the top-priority resolution layer and would shadow the frontmatter
326
+ // tier-role alias. Only concrete provider/model pins stay explicit.
327
+ const modelHint = dispatchModelHint(role, selection);
328
+ const parts = [`你的 opsx.${role} 模型由 role 自动解析。`];
319
329
  if (modelHint) parts.push(`请通过 task 工具调用 ${agent} 并指定 model="${modelHint}"。`);
320
- else parts.push(`请通过 task 工具调用 ${agent}。`);
330
+ else parts.push(`请直接通过 task 工具调用 ${agent}(无需指定 model,模型按 role 自动解析)。`);
321
331
  if (sel?.decision) parts.push(`原因: ${sel.decision}`);
322
332
  parts.push('直接用内置工具实现。');
323
333
  return `请使用 task 工具委派给 ${agent}:\ntask(agent:"${agent}"${modelHint ? `, model:"${modelHint}"` : ''}, task:"${parts.join(' ')}\\n\\n${task}")`;
324
334
  }
325
335
 
336
+ /**
337
+ * All model roles the host currently knows (10 built-in + custom roles from
338
+ * cycleOrder/modelRoles/modelTags). Host settings can throw during early
339
+ * startup or partial init; fall back to the built-in set rather than
340
+ * aborting selection (later recomputes pick up custom roles once ready).
341
+ */
342
+ function safeGetKnownRoleIds(settings: Settings | null | undefined): string[] {
343
+ if (!settings) return [...MODEL_ROLE_IDS];
344
+ try {
345
+ return getKnownRoleIds(settings);
346
+ } catch (err) {
347
+ getLogger().warn(`[omp-opsx-addon] getKnownRoleIds failed; using built-in model roles only: ${err instanceof Error ? err.message : String(err)}`);
348
+ return [...MODEL_ROLE_IDS];
349
+ }
350
+ }
351
+
326
352
  function computeSelectionSync(
327
353
  config: ResolvedOpsxConfig,
328
354
  models: Model<Api>[] | null,
329
355
  health: Map<string, ProviderHealth> | null,
356
+ knownRoleIds: string[],
330
357
  regionBlockedModels?: Set<string>,
331
358
  unreachableProviders?: Set<string>,
332
359
  availableByProvider?: Map<string, string[] | null>,
@@ -348,16 +375,23 @@ function computeSelectionSync(
348
375
  const selectorResults = {} as Record<Role, SelectorResult>;
349
376
  const roleModels = {} as Record<Role, string | undefined>;
350
377
  const roleConfigs = {} as Record<Role, string | undefined>;
378
+ const roleKind = {} as Record<Role, RoleValue['kind']>;
351
379
  const planSet = mergePlanProviders(config.planProviders);
352
380
  for (const role of OPSX_ROLES) {
353
381
  const value = config.roles[role];
354
- roleConfigs[role] = value.kind === 'auto' ? ROLE_TO_OMP_ALIAS[role] : (roleValueToModelString(value) ?? '?');
382
+ roleKind[role] = value.kind;
383
+ // Config label shown in /pick-model choices markdown (`角色 (label)`):
384
+ // auto roles display the OMP tier-role alias; pinned roles the pin.
385
+ roleConfigs[role] = value.kind === 'auto'
386
+ ? ROLE_TO_OMP_ALIAS[role]
387
+ : (roleValueToModelString(value) ?? '?');
355
388
  const alias = `@${ROLE_TO_OMP_ALIAS[role]}`;
356
389
  if (value.kind === 'auto') {
357
390
  if (filteredModels && filteredModels.length > 0) {
358
391
  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';
392
+ const tier = (config.roleTiers[role]
393
+ ?? (OMP_ROLE_TO_TIER as Record<string, TierName>)[ompRole]
394
+ ?? 'mid') as TierName;
361
395
  const roleTiers = { ...config.roleTiers, [role]: tier };
362
396
  const result = selectAllRoles(
363
397
  [role],
@@ -388,9 +422,56 @@ function computeSelectionSync(
388
422
  roleModels[role] = modelString?.includes('/') ? modelString : undefined;
389
423
  }
390
424
  }
391
- return { selectorResults, roleModels, roleConfigs };
425
+ // change: pick-model-batch-all-roles — build the full-role session overlay:
426
+ // every host-known role (10 built-in + custom) follows one tier's picked
427
+ // model. mid/high reuse the opsx picks (coder/reviewer); tiny/low/top are
428
+ // supplemental picks over the same filtered candidate set; vision picks
429
+ // from image-capable candidates only. See design D2/D3.
430
+ let roleOverrides: Record<string, string> = {};
431
+ let skippedRoles: SkippedRole[] = [];
432
+ if (filteredModels && filteredModels.length > 0) {
433
+ const pickString = (r: SelectorResult): string | undefined =>
434
+ r.picked ? `${r.picked.provider}/${r.picked.id}` : undefined;
435
+ const tierPicks: Partial<Record<TierName, string | undefined>> = {
436
+ mid: pickString(selectorResults.coder),
437
+ high: pickString(selectorResults.reviewer),
438
+ };
439
+ for (const supplementalTier of ['tiny', 'low', 'top'] as const) {
440
+ const synthRole = `__tier_${supplementalTier}`;
441
+ const result = selectModel({
442
+ role: synthRole,
443
+ models: filteredModels,
444
+ roleTiers: { [synthRole]: supplementalTier },
445
+ tierOverrides: config.tierOverrides,
446
+ health: health ?? undefined,
447
+ allowlist: config.modelAllowlist,
448
+ planProviders: planSet,
449
+ });
450
+ tierPicks[supplementalTier] = pickString(result);
451
+ }
452
+ const capabilityPicks: Record<string, string | undefined> = {};
453
+ const visionModels = filteredModels.filter((m) => ROLE_CAPABILITY.vision(m));
454
+ if (visionModels.length > 0) {
455
+ const visionResult = selectModel({
456
+ role: 'vision',
457
+ models: visionModels,
458
+ roleTiers: { vision: 'high' },
459
+ tierOverrides: config.tierOverrides,
460
+ health: health ?? undefined,
461
+ allowlist: config.modelAllowlist,
462
+ planProviders: planSet,
463
+ });
464
+ capabilityPicks.vision = pickString(visionResult);
465
+ }
466
+ const plan = buildRoleOverridePlan(
467
+ { knownRoleIds, ompRoleTiers: config.ompRoleTiers, tierPicks, capabilityPicks },
468
+ (m) => getLogger().warn(m),
469
+ );
470
+ roleOverrides = plan.overrides;
471
+ skippedRoles = plan.skipped;
472
+ }
473
+ return { selectorResults, roleModels, roleConfigs, roleKind, roleOverrides, skippedRoles };
392
474
  }
393
-
394
475
  function formatModelChoicesMarkdown(sel: SelectionContext, suffix: string = '', primaryModel?: Model<Api>): string {
395
476
  const rows: string[] = [];
396
477
  let totalModelCount = 0;
@@ -481,6 +562,9 @@ const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
481
562
  // ── plugin entry ────────────────────────────────────────────────────
482
563
 
483
564
  export default (pi: ExtensionAPI) => {
565
+ // Route plugin logging through the host logger (rolling file) instead of
566
+ // console.*, which would corrupt the TUI. Injected before any log path runs.
567
+ setOpsxLogger(pi.logger);
484
568
  // Runtime edit-variant pin for deepseek-v4-flash. The host hard-maps the
485
569
  // model to the sloppy §/» marker grammar (it misreads hashline ranges),
486
570
  // but the model repeatedly breaks sloppy control lines too — retry loops
@@ -492,7 +576,11 @@ export default (pi: ExtensionAPI) => {
492
576
  // host reads the merged view directly, making the file equivalent.
493
577
  applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
494
578
  const directory = process.cwd();
495
- const warn = (msg: string) => console.warn(msg);
579
+ const warn = (msg: string) => pi.logger.warn(msg);
580
+ // Roles known to the host at the moment of a (re)selection: computed per
581
+ // call so custom roles appearing after startup are covered on
582
+ // refresh/reset/selector/tick.
583
+ const currentKnownRoleIds = (): string[] => safeGetKnownRoleIds(pi.pi?.settings);
496
584
  const opsxConfig = readOpsxSettings(directory, warn);
497
585
  // Static orchestration rules — built once, reused every turn for cache stability.
498
586
  const STATIC_PROMPT = buildStaticPrompt();
@@ -583,6 +671,7 @@ export default (pi: ExtensionAPI) => {
583
671
  effectiveConfig,
584
672
  models,
585
673
  health,
674
+ currentKnownRoleIds(),
586
675
  regionBlockedModels,
587
676
  reach.unreachable,
588
677
  reach.availableByProvider,
@@ -596,30 +685,16 @@ export default (pi: ExtensionAPI) => {
596
685
  });
597
686
  return selectionPromise;
598
687
  };
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
- }
688
+ // Apply selector picks by TIER to session-scoped OMP modelRoles (smol =
689
+ // coder/mid, default = reviewer/high), so every role-resolving agent —
690
+ // including built-in scout (smol) — follows selector/refresh/reset.
691
+ // Persisted per-agent pins are neutralized at runtime with empty strings.
692
+ // pi.pi.settings is the host-process Settings singleton (the require()'d
693
+ // copy in node_modules is a separate, uninitialized instance — writing to
694
+ // it was a no-op). This thin wrapper is the single call site the
695
+ // constrained-period tick-reselect path (auto-model-family) also uses.
696
+ const applyRoleModel = (sel: SelectionContext): void => {
697
+ applyRoleModelOverrides(pi.pi?.settings, sel, warn);
623
698
  };
624
699
 
625
700
  // ── shared usage poller + widget ──────────────────────────────
@@ -980,7 +1055,12 @@ export default (pi: ExtensionAPI) => {
980
1055
  // This fallback call is intentionally NOT threaded with familyConstraint:
981
1056
  // it prefers the cached `selection`, and the fallback passes models=null so
982
1057
  // filterSelectableModels' family branch never runs — it is not a reselect path.
983
- const sel = selection ?? computeSelectionSync(opsxConfig, null, null);
1058
+ const sel = selection ?? computeSelectionSync(
1059
+ opsxConfig,
1060
+ null,
1061
+ null,
1062
+ safeGetKnownRoleIds(pi.pi?.settings),
1063
+ );
984
1064
  const changes = await changeScanner.scanChanges(directory, 'doing');
985
1065
  // Layered injection: STATIC_PROMPT is byte-identical every turn → prefix cache
986
1066
  // hit for [omp-base + skills + static]. Only the dynamic tail re-prefills.
@@ -994,17 +1074,14 @@ export default (pi: ExtensionAPI) => {
994
1074
 
995
1075
  pi.on('session_start', async (_event, ctx) => {
996
1076
  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
- };
1077
+ const frontmatter = (role: Role): string | undefined =>
1078
+ frontmatterModel(role, opsxConfig.roles[role]);
1002
1079
  await installAgents(
1003
1080
  directory,
1004
- pinned('coder'),
1005
- pinned('reviewer'),
1006
- pinned('planner'),
1007
- pinned('proposal-reviewer'),
1081
+ frontmatter('coder'),
1082
+ frontmatter('reviewer'),
1083
+ frontmatter('planner'),
1084
+ frontmatter('proposal-reviewer'),
1008
1085
  );
1009
1086
  warn('[omp-opsx-addon] agents ready (pick-model idle until /pick-model refresh)');
1010
1087
  } catch (e) {
@@ -1020,7 +1097,7 @@ export default (pi: ExtensionAPI) => {
1020
1097
  const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
1021
1098
  ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
1022
1099
  : undefined;
1023
- // 429/403 is the only observable exhaustion evidence for providers
1100
+ // Provider exhaustion seen on the response/tool path; mark it locally
1024
1101
  // without a usage fetcher — record it in the process-local binary status
1025
1102
  // so the placeholder column reads 耗尽 (see lib/provider-status.ts).
1026
1103
  markExhausted(canonical);
@@ -1119,6 +1196,7 @@ export default (pi: ExtensionAPI) => {
1119
1196
  effectiveConfig,
1120
1197
  models,
1121
1198
  health,
1199
+ currentKnownRoleIds(),
1122
1200
  regionBlockedModels,
1123
1201
  reach.unreachable,
1124
1202
  reach.availableByProvider,
@@ -1154,7 +1232,7 @@ export default (pi: ExtensionAPI) => {
1154
1232
  }
1155
1233
  }
1156
1234
  selection = freshSel;
1157
- applyAgentModelOverrides(freshSel);
1235
+ applyRoleModel(freshSel);
1158
1236
  if (changes.length > 0) {
1159
1237
  pi.sendMessage({ customType: 'pick-model', content: `✅ 模型已刷新:\n${changes.join('\n')}`, display: true }, { triggerTurn: false });
1160
1238
  } else {
@@ -1181,6 +1259,7 @@ export default (pi: ExtensionAPI) => {
1181
1259
  effectiveConfig,
1182
1260
  models,
1183
1261
  health,
1262
+ currentKnownRoleIds(),
1184
1263
  regionBlockedModels,
1185
1264
  reach.unreachable,
1186
1265
  reach.availableByProvider,
@@ -1205,7 +1284,7 @@ export default (pi: ExtensionAPI) => {
1205
1284
  if (newPrimary) await pi.setModel(newPrimary);
1206
1285
  selection = sel;
1207
1286
  selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
1208
- applyAgentModelOverrides(sel, true);
1287
+ applyRoleModel(sel);
1209
1288
  pi.sendMessage({
1210
1289
  customType: 'pick-model',
1211
1290
  content: changes.length > 0
@@ -1265,6 +1344,7 @@ export default (pi: ExtensionAPI) => {
1265
1344
  effectiveConfig,
1266
1345
  models,
1267
1346
  health,
1347
+ currentKnownRoleIds(),
1268
1348
  regionBlockedModels,
1269
1349
  reach.unreachable,
1270
1350
  reach.availableByProvider,
@@ -1324,7 +1404,7 @@ export default (pi: ExtensionAPI) => {
1324
1404
  if (newPrimary) await pi.setModel(newPrimary);
1325
1405
  selection = sel;
1326
1406
  selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
1327
- applyAgentModelOverrides(sel);
1407
+ applyRoleModel(sel);
1328
1408
  const content = `✅ 已切换 selector 约束 (${formatFamilyConstraint(familyConstraint)}):\n${changes.join('\n')}${hint ? `\n${hint}` : ''}`;
1329
1409
  pi.sendMessage({
1330
1410
  customType: 'pick-model',
@@ -1379,4 +1459,14 @@ export default (pi: ExtensionAPI) => {
1379
1459
  if (selection) return selection;
1380
1460
  return ensureSelection(ctx);
1381
1461
  }
1462
+
1463
+ // Cross-broker project-scoped pipe core: registers opsx_pipe_list /
1464
+ // _send / _recv / _wait and starts presence heartbeat + GC on
1465
+ // session_start (managed timers inside lib/pipe-core).
1466
+ registerPipeCore(pi);
1467
+
1468
+ // Cross-broker push/injector: watches the local mbox slot and injects
1469
+ // inbound pipe messages as custom (opsx-pipe) turns; /opsx-pipe mute
1470
+ // toggles the stop-injection flag (managed timers inside lib/pipe-push).
1471
+ registerPipePush(pi);
1382
1472
  };
package/lib/agent-defs.ts CHANGED
@@ -5,7 +5,7 @@ const MARKER = '<!-- @genee/omp-opsx-addon -->';
5
5
  const CODE_REVIEWER_MD = (model?: string) => `---
6
6
  name: code-reviewer
7
7
  description: OpenSpec 代码审查 agent,审查代码实现并执行全局验证
8
- tools: read, bash, glob, grep${model ? `\nmodel: ${model}` : ''}
8
+ tools: read, bash, glob, grep${model ? `\nmodel: "${model}"` : ''}
9
9
  ---
10
10
 
11
11
  你是一位资深代码审查员。你根据 OpenSpec 提案和设计文档审查代码实现,并在审阅的同时执行最后一次全局验证。
@@ -71,7 +71,7 @@ ${MARKER}
71
71
  const PROPOSAL_REVIEWER_MD = (model?: string) => `---
72
72
  name: proposal-reviewer
73
73
  description: OpenSpec 提案审查 agent,审查提案质量
74
- tools: read, bash, glob, grep${model ? `\nmodel: ${model}` : ''}
74
+ tools: read, bash, glob, grep${model ? `\nmodel: "${model}"` : ''}
75
75
  ---
76
76
 
77
77
  你是一位资深需求审查员。你审查 OpenSpec 提案的完整性和质量。
@@ -123,7 +123,7 @@ ${MARKER}
123
123
  const CODER_MD = (model?: string) => `---
124
124
  name: coder
125
125
  description: OpenSpec 变更实现 agent,同步 tasks.md 进度
126
- tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: ${model}` : ''}
126
+ tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: "${model}"` : ''}
127
127
  skill: openspec-apply-change
128
128
  ---
129
129
 
@@ -184,7 +184,7 @@ ${MARKER}
184
184
  const PLANNER_MD = (model?: string) => `---
185
185
  name: planner
186
186
  description: OpenSpec 提案规划 agent
187
- tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: ${model}` : ''}\nskill: openspec-propose
187
+ tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: "${model}"` : ''}\nskill: openspec-propose
188
188
  ---
189
189
 
190
190
  你是一个 OpenSpec 提案规划 agent。负责创建和更新 OpenSpec 变更提案文档。
@@ -117,7 +117,7 @@ export function applyEditVariantPin(pi: ExtensionAPI, pin: Record<string, string
117
117
  | { override(path: string, value: unknown): void; getAgentDir?: () => string }
118
118
  | undefined;
119
119
  if (!settings) {
120
- console.warn('[omp-opsx-addon] pi.pi.settings unavailable; edit variant pin skipped');
120
+ pi.logger?.warn('[omp-opsx-addon] pi.pi.settings unavailable; edit variant pin skipped');
121
121
  return;
122
122
  }
123
123
 
@@ -139,18 +139,18 @@ export function applyEditVariantPin(pi: ExtensionAPI, pin: Record<string, string
139
139
 
140
140
  const configPath = resolveGlobalConfigPath(agentDir);
141
141
  if (!configPath) {
142
- console.warn(
142
+ pi.logger?.warn(
143
143
  `[omp-opsx-addon] edit variant pin failed: no global config.yml found under ${agentDir}`,
144
144
  );
145
145
  return;
146
146
  }
147
147
  const outcome = persistEditVariantPin(configPath, pin);
148
148
  if (outcome === 'written') {
149
- console.warn(
149
+ pi.logger?.warn(
150
150
  `[omp-opsx-addon] edit variant pin persisted to ${configPath} (effective from the next session)`,
151
151
  );
152
152
  }
153
153
  } catch (err) {
154
- console.warn('[omp-opsx-addon] edit variant pin failed:', err);
154
+ pi.logger?.warn('[omp-opsx-addon] edit variant pin failed', { err });
155
155
  }
156
156
  }
package/lib/logger.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 插件日志通道(模块级注入)。
3
+ *
4
+ * 主进程日志不再走 console.*(会在 omp TUI 里打爆界面),改走宿主
5
+ * pi.logger(滚动文件日志)。lib 模块拿不到 pi 对象,故提供模块级 holder:
6
+ * 入口 setOpsxLogger(pi.logger) 注入,lib 侧 getLogger() 读取。
7
+ * 未注入(测试场景直接调用叶子函数)时默认 console,输出与迁移前一致。
8
+ */
9
+
10
+ /** 窄化日志接口:宿主 pi.logger 与 console 的结构交集,仅本插件使用的四级方法。 */
11
+ export interface OpsxLogger {
12
+ error(message: string, context?: Record<string, unknown>): void;
13
+ warn(message: string, context?: Record<string, unknown>): void;
14
+ info(message: string, context?: Record<string, unknown>): void;
15
+ debug(message: string, context?: Record<string, unknown>): void;
16
+ }
17
+
18
+ /** 模块级当前 logger,默认 console(未注入/测试场景保持旧行为)。 */
19
+ let current: OpsxLogger = console;
20
+
21
+ /** 插件入口注入宿主 pi.logger(进程级单例,多实例重复注入幂等)。
22
+ * 空值注入忽略:保持 getLogger() 恒可用(console 兜底)——测试 mock 的
23
+ * pi 对象可能不含 logger,此时视同未注入,输出与迁移前一致。 */
24
+ export function setOpsxLogger(logger: OpsxLogger): void {
25
+ if (logger) {
26
+ current = logger;
27
+ }
28
+ }
29
+
30
+ /** 读取当前 logger。 */
31
+ export function getLogger(): OpsxLogger {
32
+ return current;
33
+ }