@genee/omp-opsx-addon 0.2.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,37 @@ 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';
137
+ import { scheduleStatusProbes } from './lib/status-probe.js';
124
138
  import {
125
139
  startSharedPoller,
126
140
  getUnconfirmedProviders,
@@ -157,6 +171,7 @@ import { filterSelectableModels, reachabilityFingerprint } from './lib/selection
157
171
  import { parseAutoModelArgs, CHINA_EXCLUDE_FAMILIES, listFamilies, listProviders } from './lib/family-filter.js';
158
172
  import { hasRateLimitError, hasRegionError, buildErrorDiagnosis, exclusionReasonStats } from './lib/error-scan.js';
159
173
  import { applyEditVariantPin } from './lib/edit-variant-pin.js';
174
+ import { setOpsxLogger, getLogger } from './lib/logger.js';
160
175
  type Role = 'coder' | 'reviewer' | 'planner' | 'proposal-reviewer';
161
176
 
162
177
  /** Session-scoped selector constraint for `/pick-model <selector> [--china]`. */
@@ -265,24 +280,17 @@ export interface SelectionContext {
265
280
  roleModels: Record<Role, string | undefined>;
266
281
  /** Display label for each role from config, e.g. "auto", "smol", "deepseek-v4-pro". */
267
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[];
268
289
  }
269
290
 
270
291
  const DEFAULT_PLAN_PROVIDERS = new Set(['minimax-code-cn', 'opencode-go', 'zhipu-coding-plan', 'kimi', 'openai-codex']);
271
292
 
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
293
 
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
294
 
287
295
  function roleValueToModelString(value: RoleValue): string | undefined {
288
296
  if (value.kind === 'auto') return undefined;
@@ -313,19 +321,39 @@ function synthPinnedResult(role: string, modelString: string | undefined): Selec
313
321
  export function buildDispatchPrompt(role: Role, task: string, selection: SelectionContext): string {
314
322
  const agent = AGENT_MAP[role];
315
323
  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。`];
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 自动解析。`];
318
329
  if (modelHint) parts.push(`请通过 task 工具调用 ${agent} 并指定 model="${modelHint}"。`);
319
- else parts.push(`请通过 task 工具调用 ${agent}。`);
330
+ else parts.push(`请直接通过 task 工具调用 ${agent}(无需指定 model,模型按 role 自动解析)。`);
320
331
  if (sel?.decision) parts.push(`原因: ${sel.decision}`);
321
332
  parts.push('直接用内置工具实现。');
322
333
  return `请使用 task 工具委派给 ${agent}:\ntask(agent:"${agent}"${modelHint ? `, model:"${modelHint}"` : ''}, task:"${parts.join(' ')}\\n\\n${task}")`;
323
334
  }
324
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
+
325
352
  function computeSelectionSync(
326
353
  config: ResolvedOpsxConfig,
327
354
  models: Model<Api>[] | null,
328
355
  health: Map<string, ProviderHealth> | null,
356
+ knownRoleIds: string[],
329
357
  regionBlockedModels?: Set<string>,
330
358
  unreachableProviders?: Set<string>,
331
359
  availableByProvider?: Map<string, string[] | null>,
@@ -347,16 +375,23 @@ function computeSelectionSync(
347
375
  const selectorResults = {} as Record<Role, SelectorResult>;
348
376
  const roleModels = {} as Record<Role, string | undefined>;
349
377
  const roleConfigs = {} as Record<Role, string | undefined>;
378
+ const roleKind = {} as Record<Role, RoleValue['kind']>;
350
379
  const planSet = mergePlanProviders(config.planProviders);
351
380
  for (const role of OPSX_ROLES) {
352
381
  const value = config.roles[role];
353
- 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) ?? '?');
354
388
  const alias = `@${ROLE_TO_OMP_ALIAS[role]}`;
355
389
  if (value.kind === 'auto') {
356
390
  if (filteredModels && filteredModels.length > 0) {
357
391
  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';
392
+ const tier = (config.roleTiers[role]
393
+ ?? (OMP_ROLE_TO_TIER as Record<string, TierName>)[ompRole]
394
+ ?? 'mid') as TierName;
360
395
  const roleTiers = { ...config.roleTiers, [role]: tier };
361
396
  const result = selectAllRoles(
362
397
  [role],
@@ -387,9 +422,56 @@ function computeSelectionSync(
387
422
  roleModels[role] = modelString?.includes('/') ? modelString : undefined;
388
423
  }
389
424
  }
390
- 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 };
391
474
  }
392
-
393
475
  function formatModelChoicesMarkdown(sel: SelectionContext, suffix: string = '', primaryModel?: Model<Api>): string {
394
476
  const rows: string[] = [];
395
477
  let totalModelCount = 0;
@@ -480,6 +562,9 @@ const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
480
562
  // ── plugin entry ────────────────────────────────────────────────────
481
563
 
482
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);
483
568
  // Runtime edit-variant pin for deepseek-v4-flash. The host hard-maps the
484
569
  // model to the sloppy §/» marker grammar (it misreads hashline ranges),
485
570
  // but the model repeatedly breaks sloppy control lines too — retry loops
@@ -491,7 +576,11 @@ export default (pi: ExtensionAPI) => {
491
576
  // host reads the merged view directly, making the file equivalent.
492
577
  applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
493
578
  const directory = process.cwd();
494
- 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);
495
584
  const opsxConfig = readOpsxSettings(directory, warn);
496
585
  // Static orchestration rules — built once, reused every turn for cache stability.
497
586
  const STATIC_PROMPT = buildStaticPrompt();
@@ -582,6 +671,7 @@ export default (pi: ExtensionAPI) => {
582
671
  effectiveConfig,
583
672
  models,
584
673
  health,
674
+ currentKnownRoleIds(),
585
675
  regionBlockedModels,
586
676
  reach.unreachable,
587
677
  reach.availableByProvider,
@@ -595,30 +685,16 @@ export default (pi: ExtensionAPI) => {
595
685
  });
596
686
  return selectionPromise;
597
687
  };
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
- }
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);
622
698
  };
623
699
 
624
700
  // ── shared usage poller + widget ──────────────────────────────
@@ -794,9 +870,37 @@ export default (pi: ExtensionAPI) => {
794
870
  // provider's aggregated consumption RATE (all sessions, main +
795
871
  // subagent via cache backflow), and re-renders the widget. It never
796
872
  // recomputes a selection, never applies overrides, never setModel.
873
+ const cfg = buildEffectiveConfig();
797
874
  const onTick = () => {
875
+ // Fire-and-forget status probe: do not block the tick.
876
+ const { placeholderIds, active } = resolveActiveProviders();
877
+ let models: { id: string; provider: string; baseUrl?: string }[] = [];
878
+ try {
879
+ if (ctx.modelRegistry && typeof ctx.modelRegistry.getAvailable === 'function') {
880
+ models = ctx.modelRegistry.getAvailable() as { id: string; provider: string; baseUrl?: string }[];
881
+ }
882
+ } catch {
883
+ if (ctx.modelRegistry && typeof ctx.modelRegistry.getAll === 'function') {
884
+ try {
885
+ models = ctx.modelRegistry.getAll() as { id: string; provider: string; baseUrl?: string }[];
886
+ } catch {
887
+ models = [];
888
+ }
889
+ }
890
+ }
891
+ void scheduleStatusProbes({
892
+ placeholderIds,
893
+ models,
894
+ getApiKey,
895
+ config: {
896
+ enabled: cfg.status_probe_enabled,
897
+ ttlMs: cfg.probe_ttl_ms,
898
+ timeoutMs: cfg.probe_timeout_ms,
899
+ excluded: cfg.excluded_providers,
900
+ },
901
+ }).then((written) => { if (written > 0) render(); }).catch(() => {});
902
+
798
903
  sessionUsageRecorder.flush();
799
- const { active } = resolveActiveProviders();
800
904
  // Waveform source = per-second consumption sequence (timestamped
801
905
  // deltas aggregated across sessions). Sample EVERY elapsed second
802
906
  // for EVERY active provider — zeros when a second had no
@@ -822,7 +926,6 @@ export default (pi: ExtensionAPI) => {
822
926
  lastSampledSec = lastSec;
823
927
  render();
824
928
  };
825
- const cfg = buildEffectiveConfig();
826
929
  await startSharedPoller(auth, DIRECT_FETCHERS, getApiKey, {
827
930
  onTick: claimsOwnership ? onTick : undefined,
828
931
  ownsUi,
@@ -952,7 +1055,12 @@ export default (pi: ExtensionAPI) => {
952
1055
  // This fallback call is intentionally NOT threaded with familyConstraint:
953
1056
  // it prefers the cached `selection`, and the fallback passes models=null so
954
1057
  // filterSelectableModels' family branch never runs — it is not a reselect path.
955
- const sel = selection ?? computeSelectionSync(opsxConfig, null, null);
1058
+ const sel = selection ?? computeSelectionSync(
1059
+ opsxConfig,
1060
+ null,
1061
+ null,
1062
+ safeGetKnownRoleIds(pi.pi?.settings),
1063
+ );
956
1064
  const changes = await changeScanner.scanChanges(directory, 'doing');
957
1065
  // Layered injection: STATIC_PROMPT is byte-identical every turn → prefix cache
958
1066
  // hit for [omp-base + skills + static]. Only the dynamic tail re-prefills.
@@ -966,17 +1074,14 @@ export default (pi: ExtensionAPI) => {
966
1074
 
967
1075
  pi.on('session_start', async (_event, ctx) => {
968
1076
  try {
969
- const pinned = (role: Role): string | undefined => {
970
- const v = opsxConfig.roles[role];
971
- if (v.kind === 'auto') return undefined;
972
- return roleValueToModelString(v);
973
- };
1077
+ const frontmatter = (role: Role): string | undefined =>
1078
+ frontmatterModel(role, opsxConfig.roles[role]);
974
1079
  await installAgents(
975
1080
  directory,
976
- pinned('coder'),
977
- pinned('reviewer'),
978
- pinned('planner'),
979
- pinned('proposal-reviewer'),
1081
+ frontmatter('coder'),
1082
+ frontmatter('reviewer'),
1083
+ frontmatter('planner'),
1084
+ frontmatter('proposal-reviewer'),
980
1085
  );
981
1086
  warn('[omp-opsx-addon] agents ready (pick-model idle until /pick-model refresh)');
982
1087
  } catch (e) {
@@ -992,7 +1097,7 @@ export default (pi: ExtensionAPI) => {
992
1097
  const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
993
1098
  ? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
994
1099
  : undefined;
995
- // 429/403 is the only observable exhaustion evidence for providers
1100
+ // Provider exhaustion seen on the response/tool path; mark it locally
996
1101
  // without a usage fetcher — record it in the process-local binary status
997
1102
  // so the placeholder column reads 耗尽 (see lib/provider-status.ts).
998
1103
  markExhausted(canonical);
@@ -1091,6 +1196,7 @@ export default (pi: ExtensionAPI) => {
1091
1196
  effectiveConfig,
1092
1197
  models,
1093
1198
  health,
1199
+ currentKnownRoleIds(),
1094
1200
  regionBlockedModels,
1095
1201
  reach.unreachable,
1096
1202
  reach.availableByProvider,
@@ -1126,7 +1232,7 @@ export default (pi: ExtensionAPI) => {
1126
1232
  }
1127
1233
  }
1128
1234
  selection = freshSel;
1129
- applyAgentModelOverrides(freshSel);
1235
+ applyRoleModel(freshSel);
1130
1236
  if (changes.length > 0) {
1131
1237
  pi.sendMessage({ customType: 'pick-model', content: `✅ 模型已刷新:\n${changes.join('\n')}`, display: true }, { triggerTurn: false });
1132
1238
  } else {
@@ -1153,6 +1259,7 @@ export default (pi: ExtensionAPI) => {
1153
1259
  effectiveConfig,
1154
1260
  models,
1155
1261
  health,
1262
+ currentKnownRoleIds(),
1156
1263
  regionBlockedModels,
1157
1264
  reach.unreachable,
1158
1265
  reach.availableByProvider,
@@ -1177,7 +1284,7 @@ export default (pi: ExtensionAPI) => {
1177
1284
  if (newPrimary) await pi.setModel(newPrimary);
1178
1285
  selection = sel;
1179
1286
  selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
1180
- applyAgentModelOverrides(sel, true);
1287
+ applyRoleModel(sel);
1181
1288
  pi.sendMessage({
1182
1289
  customType: 'pick-model',
1183
1290
  content: changes.length > 0
@@ -1237,6 +1344,7 @@ export default (pi: ExtensionAPI) => {
1237
1344
  effectiveConfig,
1238
1345
  models,
1239
1346
  health,
1347
+ currentKnownRoleIds(),
1240
1348
  regionBlockedModels,
1241
1349
  reach.unreachable,
1242
1350
  reach.availableByProvider,
@@ -1296,7 +1404,7 @@ export default (pi: ExtensionAPI) => {
1296
1404
  if (newPrimary) await pi.setModel(newPrimary);
1297
1405
  selection = sel;
1298
1406
  selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
1299
- applyAgentModelOverrides(sel);
1407
+ applyRoleModel(sel);
1300
1408
  const content = `✅ 已切换 selector 约束 (${formatFamilyConstraint(familyConstraint)}):\n${changes.join('\n')}${hint ? `\n${hint}` : ''}`;
1301
1409
  pi.sendMessage({
1302
1410
  customType: 'pick-model',
@@ -1351,4 +1459,14 @@ export default (pi: ExtensionAPI) => {
1351
1459
  if (selection) return selection;
1352
1460
  return ensureSelection(ctx);
1353
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);
1354
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 提案和设计文档审查代码实现,并在审阅的同时执行最后一次全局验证。
@@ -14,6 +14,12 @@ tools: read, bash, glob, grep${model ? `\nmodel: ${model}` : ''}
14
14
  1. 从 prompt 或上下文确定要审查的变更
15
15
  2. 读取 proposal、design 和 tasks 文件
16
16
  3. 运行 \`git diff HEAD~1\` 或 \`git diff --stat\` 查看改动
17
+ ## scratchpad.md 共享缓存
18
+ - 审查前 MUST 先读 \`openspec/changes/<name>/scratchpad.md\`(若存在),只读不改写(工具集无 write/edit)。
19
+ - 以「调研与设计」+「实现探索」两区累计的涉及文件 \`path:line\` 集合为代码审查与增量验证范围:Tier 1(typecheck/lint)与 Tier 3(E2E,如适用)聚焦涉及文件改动面;Tier 2 冒烟套件仍全量执行项目现有测试套件。
20
+ - 无 scratchpad.md 时回退:从 proposal/design/tasks 确定审查范围。
21
+ - 识别 \`- [R<n> coder] supersede:\` 条目:以紧随其后的新结论为权威,旧 \`path:line\` 不再作为验证范围;只读不改写。
22
+ - 发现 scratchpad.md 结论与代码现状不符时,作为 P0/P1 审查发现发回,不直接改写。
17
23
 
18
24
  ## 审查维度
19
25
  1. **规范遵循**:是否精确实现了 proposal/design/tasks 定义的内容?有无缺失或偏离?
@@ -65,7 +71,7 @@ ${MARKER}
65
71
  const PROPOSAL_REVIEWER_MD = (model?: string) => `---
66
72
  name: proposal-reviewer
67
73
  description: OpenSpec 提案审查 agent,审查提案质量
68
- tools: read, bash, glob, grep${model ? `\nmodel: ${model}` : ''}
74
+ tools: read, bash, glob, grep${model ? `\nmodel: "${model}"` : ''}
69
75
  ---
70
76
 
71
77
  你是一位资深需求审查员。你审查 OpenSpec 提案的完整性和质量。
@@ -73,6 +79,11 @@ tools: read, bash, glob, grep${model ? `\nmodel: ${model}` : ''}
73
79
  ## 启动
74
80
  1. 从 prompt 或上下文确定要审查的提案名称
75
81
  2. 读取 proposal.md、design.md(如果存在)
82
+ ## scratchpad.md 共享缓存
83
+ - 审查前 MUST 先读 \`openspec/changes/<name>/scratchpad.md\`(若存在),复用其中调研结论与涉及文件,避免重复 read/grep。
84
+ - 只读,不改写 scratchpad.md;你的工具集不含 write/edit,物理上无法写入。
85
+ - 识别 \`- [R<n> coder] supersede:\` 条目:以紧随其后的新结论为权威,旧结论不再作为调研依据;只读不改写。
86
+ - 发现的 P0/P1 与关注点写入审查报告,由主 agent 中转回流,不直接写 scratchpad.md。
76
87
 
77
88
  ## 审查维度
78
89
  1. **完整性**:Why/What/Capabilities/Impact 是否齐全?有无缺失章节?
@@ -112,7 +123,7 @@ ${MARKER}
112
123
  const CODER_MD = (model?: string) => `---
113
124
  name: coder
114
125
  description: OpenSpec 变更实现 agent,同步 tasks.md 进度
115
- tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: ${model}` : ''}
126
+ tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: "${model}"` : ''}
116
127
  skill: openspec-apply-change
117
128
  ---
118
129
 
@@ -131,6 +142,19 @@ skill: openspec-apply-change
131
142
  - 代码全部完成后将所有未完成项标记为 \`- [x]\`,在 ACTION 报告中说明变更已全部实现
132
143
  - 不修改 \`proposal.md\`、\`design.md\` 或 \`.openspec.yaml\`
133
144
  - 直接用 read/write/edit/bash 等内置工具实现;不要尝试调用 task 二次委派。
145
+ ## scratchpad.md 共享缓存
146
+ - 开工前 MUST 读 \`openspec/changes/<name>/scratchpad.md\`(若存在),复用已记录结论,禁止重复探索。
147
+ - 交付前 MUST 将本轮新探索结论 append 到「实现探索」区(关键符号/数据流、新增涉及文件 \`path:line\`、验证/构建命令、已排除假设),标注 \`- [R<n> coder] <结论>\`。
148
+ - 收到主 agent 中转的 code-reviewer P0/P1 关注点时,开工时先将关注点 append 进「代码审查范围」区,再开始修复。
149
+ - 后续轮次只做增量:仅探索未记录的文件/符号/假设,不重复 read/grep 已记录内容;append-only,不得改写他人结论。
150
+
151
+ ## scratchpad.md 与代码不一致时的两档处置
152
+ 执行中发现代码现实与 scratchpad.md 记录不一致时,按两档客观判据处置;判据仅为「偏差是否影响任何 task 的前提或产出定义」,禁止主观估量「问题大小」:
153
+ - **档一:事实快照过期**——偏差不影响任何 task 的前提或产出定义(纯探索性信息:路径 / 行号 / 符号 / 命令漂移)。处置:继续执行当前任务,不终止;在「实现探索」区 append supersede 修正。
154
+ - **档二:契约动摇**——偏差导致 tasks / proposal / design / specs 的有效性存疑(前提不成立、产出定义变、代码现状与设计决策冲突)。处置:立即停止实现,输出 STATUS: blocked(无 SESSION)并说明冲突点,交由主 agent 裁决(改提案 / 确认「现状即新设计」/ 开新 change)。
155
+ - supersede 标注格式:\`- [R<n> coder] supersede: <旧结论摘要>\`,随后一行以 \`- [R<n> coder]\` 标注新结论;两条均落「实现探索」分区,旧结论保留(append-only),不删除。
156
+ - 交付摘要 SUMMARY 义务:本轮发生过 supersede 时,输出格式中的 SUMMARY 行 MUST 提及本次 supersede 清单。
157
+
134
158
  ## 验证(交付门槛)
135
159
  - 交付前必须跑过 **scoped 到本次改动面** 的 lint、typecheck 与针对性单测(proposal 约定的测试必须通过);任一未通过不得交付,先修到通过再标记 ACTION: REVIEW_REQUIRED。
136
160
  - 项目级全量校验(全仓库 lint/typecheck/\`bun test\`)不属于自验证门槛:若失败可归因于并发 sibling 的半成品改动(与本改动无关),把失败连同「已排查与本改动无关」的证据作为上下文上报(交付摘要中注明),不要被其阻塞;最终由 code-reviewer 的全局验证兜底确认。
@@ -148,6 +172,7 @@ ACTION: REVIEW_REQUIRED
148
172
  CHANGE: <change-name 或 "general">
149
173
  STATUS: success | partial | blocked
150
174
  SESSION: <session_name>(仅 STATUS: blocked 且需 resume 时填写)
175
+ SUMMARY: <交付摘要;若本轮有 supersede,必须包含「supersede: <清单>」>
151
176
  TASKS: N/M complete
152
177
  FILES: <逗号分隔的修改/创建文件列表>
153
178
  ---
@@ -159,7 +184,7 @@ ${MARKER}
159
184
  const PLANNER_MD = (model?: string) => `---
160
185
  name: planner
161
186
  description: OpenSpec 提案规划 agent
162
- 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
163
188
  ---
164
189
 
165
190
  你是一个 OpenSpec 提案规划 agent。负责创建和更新 OpenSpec 变更提案文档。
@@ -173,6 +198,17 @@ tools: read, write, edit, bash, glob, grep, todo${model ? `\nmodel: ${model}` :
173
198
  ## 工作范围
174
199
  - **只负责提案文档**:proposal.md、design.md、tasks.md、specs/(openspec/changes/ 目录下)
175
200
  - 直接用内置工具;不要尝试调用 task 二次委派。
201
+ ## scratchpad.md 共享缓存
202
+ 变更目录下维护 \`openspec/changes/<name>/scratchpad.md\` 作四角色共享探索缓存(与 proposal.md/design.md/tasks.md 同级),固定四阶段分区:
203
+ - \`## 调研与设计\`:调研结论、涉及文件(\`path:line\`)、已排除方案
204
+ - \`## 提案审查关注点\`:proposal-reviewer 审查报告中的 P0/P1 与关注点(经主 agent 中转回流)
205
+ - \`## 实现探索\`:coder 每轮 append 的关键符号/数据流、新增涉及文件、验证/构建命令、已排除假设
206
+ - \`## 代码审查范围\`:code-reviewer 审查报告中的 P0/P1 与验证范围结论(经主 agent 中转回流)
207
+ 规则:
208
+ - **出提案时 MUST 创建** \`openspec/changes/<name>/scratchpad.md\` 并写入四阶段骨架,预填「调研与设计」初始骨架:调研结论、涉及文件、已排除方案。
209
+ - 后续轮次修改提案时,先读回 scratchpad.md:将 proposal-reviewer 关注点 append 进「提案审查关注点」区,新调研结论增量 append 进「调研与设计」区。
210
+ - 条目以 \`- [R<n> planner] <结论>\` 标注轮次与角色;append-only,不得改写或删除他人结论。
211
+ - 读回 scratchpad.md 时,以最新 supersede 条目为权威(识别 \`- [R<n> coder] supersede:\` 标记,其后的新结论优先于旧结论)。
176
212
 
177
213
  ## 提案拆分
178
214
  - 接大需求先评估契约边界:能拆则拆成多个小变更,先输出拆分建议(契约边界+提案清单)待主 agent 确认后再开写。