@genee/omp-opsx-addon 0.9.0 → 0.11.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 +10 -9
- package/index.ts +123 -65
- package/lib/agent-defs.ts +26 -8
- package/lib/concurrency-control.ts +6 -6
- package/lib/concurrency-writer.ts +25 -13
- package/lib/edit-variant-pin.ts +34 -26
- package/lib/host-settings-adapter.ts +227 -0
- package/lib/model-roles.ts +43 -14
- package/lib/pipe-push.ts +83 -8
- package/lib/reachability-refresh.ts +10 -2
- package/lib/system-prompt.ts +15 -6
- package/lib/tiers-data.ts +8 -1
- package/lib/unified-config.ts +53 -91
- package/package.json +1 -1
- package/types/host-settings-modules.d.ts +48 -0
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
|
|
52
52
|
**共享协议技能** `skills/opsx-orchestration-protocol/SKILL.md` 是四角色共享编排协议的单一真源:scratchpad 四区共享缓存与角色读写矩阵(append-only、planner 建+写 / coder 读+写 / 两个 reviewer 只读)、supersede 权威语义与两档客观判据(档一继续 + supersede 修正,档二 `STATUS: blocked` 交主 agent 裁决)、无关状态硬栅栏(节内首行角色门——仅 coder 适用,reviewer / planner / proposal-reviewer MUST NOT 受本节限制)、P0/P1 评审闭环与轮次上限(Loop 1 ≤2 轮、Loop 2 最多 3 轮含首次实现与修复)、报告契约、生效与兜底。模板不再复述这些正文,每处只留指向技能的一行指针。
|
|
53
53
|
|
|
54
|
-
- **位置与发现**:技能在包根 `skills/`,由宿主对扩展根的技能发现通道自动收录,**无需安装动作**——`installAgents()` 的写入集合仍只有 4 个 `.omp/agents/*.md` 与 `.addon-version
|
|
54
|
+
- **位置与发现**:技能在包根 `skills/`,由宿主对扩展根的技能发现通道自动收录,**无需安装动作**——`installAgents()` 的写入集合仍只有 4 个 `.omp/agents/*.md` 与 `.addon-version`(戳记录安装时插件包版本,不参与安装/更新判定),不落项目目录、不建 `.omp/skills/`,技能也不参与 MARKER / 版本 / conflict 状态机。
|
|
55
55
|
- **生效方式**:frontmatter `autoloadSkills` 在子 agent 首个 prompt 之前把技能正文注入其上下文,等价于 `/skill:opsx-orchestration-protocol`(coder / planner 另各注入一个官方技能)。
|
|
56
56
|
- **frontmatter**:`name` / `description` / `tools` / `autoloadSkills`(+ 可选 `model`)。原 `skill:` 字段是宿主 agent frontmatter 白名单不解析的死配置,已移除。
|
|
57
57
|
- **兜底**:`autoloadSkills` 解析失败是静默的,故每份模板对**每个**技能各留一行 `skill://<name>` 指针(Skill tool 手动读取,逐行同构、不合并);技能名不可解析且 `skill://<name>` 亦不可读时视为前提缺失,子 agent 输出 `STATUS: blocked`(无 SESSION)并报缺失技能名交由主 agent 决策,不凭记忆复述其流程。
|
|
@@ -117,11 +117,10 @@ payg_last_resort: true
|
|
|
117
117
|
autoselect_on_start: true
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
>
|
|
121
|
-
> `role_tiers:`
|
|
122
|
-
>
|
|
123
|
-
> `
|
|
124
|
-
> 顶层旧键、`agent_tiers` > `role_tiers`);跨层合并语义不变(agent 值逐键覆盖、tier 表整键覆盖)。
|
|
120
|
+
> **旧键已移除(≥0.10.0)**:顶层的 `coder:` / `reviewer:` / `planner:` / `proposal-reviewer:` 与
|
|
121
|
+
> `role_tiers:` 不再被读取。读到时仍按层文件各 warn 一条(单文件多个旧键合并为一条、列出全部旧键名,
|
|
122
|
+
> 声明 no longer read),并指明迁移路径:顶层 role 键挪进 `agents:` mapping、`role_tiers:` 改名
|
|
123
|
+
> `agent_tiers:`(schema 不变)。旧键值失效后受影响 agent 回落 `auto`、tier 期望回落默认档位。
|
|
125
124
|
|
|
126
125
|
### `agents` / `agent_tiers` 与 `model_role_tiers` 消歧义
|
|
127
126
|
|
|
@@ -170,7 +169,7 @@ reviewer pick(high 档选中模型;与当前模型一致则不调用 setMode
|
|
|
170
169
|
实例只应用一次,之后用户显式 `/model` 切换保持权威——启动路径不会再次触发、不回写主会话模型。pinned role
|
|
171
170
|
(`agents.*` 显式钉值)照常优先于自动 pick(钉值旁路选择器,不被自动写入覆盖)。启动应用路径的任何错误
|
|
172
171
|
(选择计算抛错、settings 不可用、setModel 失败)只 warn(`[omp-opsx-addon] session-start autoselect error:
|
|
173
|
-
|
|
172
|
+
...`),不阻塞会话启动,也不进入重试。若首次尝试无异常完成但 reviewer pick 为空(冷启动探测超时 / 注册表晚就绪的典型形态),插件以有界退避 `[5s, 15s, 30s]` 后台重试(不阻塞 handler / 首 turn),重试前仅对当前不可达 provider 绕过 TTL 重探,并在 `ctx.models.current()` 显示用户已接管时静默让位;预算耗尽则 warn 一条并建议手动 `/pick-model`。`autoselect_on_start: false`(任一层 opsx.yml)完全退出(含重试),行为与本键引入前一致。
|
|
174
173
|
手动 `/pick-model` 命令照常可用、立即生效;task 子 agent 不触发启动应用。
|
|
175
174
|
|
|
176
175
|
### `/pick-model`:selector 约束与 role 定向(`[roleList:]`)
|
|
@@ -459,8 +458,10 @@ quality_preference: balanced # balanced(缺省,逐字节现状)| cost |
|
|
|
459
458
|
- top 补选没有可用模型(候选耗尽/allowlist 全过滤)时,slow 不写覆盖、warn 汇总
|
|
460
459
|
(`no-pick`),命令不中断;无任何模型时计划为空,与既有「模型注册表不可用」分支一致。
|
|
461
460
|
- 覆盖为 **session 作用域、不写盘**:`overrideModelRoles` 只写 runtime overlay,
|
|
462
|
-
session 结束即还原;不会修改你的 `~/.omp/agent/config.yml
|
|
463
|
-
|
|
461
|
+
session 结束即还原;不会修改你的 `~/.omp/agent/config.yml`。每次应用前先清除本 session
|
|
462
|
+
的 modelRoles 运行时覆盖(宿主 18.3+ 走注册表句柄 `clearOverrideValue(cfgModelRoles)`,
|
|
463
|
+
≤18.1 走路径式 `clearOverride('modelRoles')`,两者由 `lib/host-settings-adapter.ts`
|
|
464
|
+
统一选取),避免上一 selector 的旧 role 值残留;`reset` 重算无约束
|
|
464
465
|
默认并覆盖(**不清空**,否则回落 config 硬钉)。
|
|
465
466
|
|
|
466
467
|
> **手动清理建议**:若全局 `~/.omp/agent/config.yml` 里有 `modelRoles.smol` 钉在会耗尽的
|
package/index.ts
CHANGED
|
@@ -114,7 +114,7 @@ import {
|
|
|
114
114
|
} from './lib/pick-model-render.js';
|
|
115
115
|
import { buildStaticPrompt, buildDynamicPrompt } from './lib/system-prompt.js';
|
|
116
116
|
import { type ProviderHealth, canonicalizeProvider, listDetectableProviders, healthCacheKey } from './lib/usage-resolver.js';
|
|
117
|
-
import { readOpsxSettings, resolveManagedProviders, type ResolvedOpsxConfig, type
|
|
117
|
+
import { readOpsxSettings, resolveManagedProviders, type ResolvedOpsxConfig, type AgentValue, OPSX_AGENTS } from './lib/unified-config.js';
|
|
118
118
|
import {
|
|
119
119
|
createConcurrencyController,
|
|
120
120
|
createNodeFsPort,
|
|
@@ -362,7 +362,7 @@ const AGENT_MAP: Record<Role, string> = {
|
|
|
362
362
|
'proposal-reviewer': 'proposal-reviewer',
|
|
363
363
|
};
|
|
364
364
|
function findRoleForModel(provider: string, modelId: string, sel: SelectionContext): Role | null {
|
|
365
|
-
for (const role of
|
|
365
|
+
for (const role of OPSX_AGENTS) {
|
|
366
366
|
const r = sel.selectorResults[role];
|
|
367
367
|
if (!r) continue;
|
|
368
368
|
if (r.allCandidates.some((c) => c.selector === `${provider}/${modelId}`)) {
|
|
@@ -375,10 +375,8 @@ function findRoleForModel(provider: string, modelId: string, sel: SelectionConte
|
|
|
375
375
|
export interface SelectionContext {
|
|
376
376
|
selectorResults: Record<Role, SelectorResult>;
|
|
377
377
|
roleModels: Record<Role, string | undefined>;
|
|
378
|
-
/** Display label for each role from config, e.g. "auto", "smol", "deepseek-v4-pro". */
|
|
379
|
-
roleConfigs: Record<Role, string | undefined>;
|
|
380
378
|
/** Parsed role value kind for each role (auto | omp-alias | model | provider-model). */
|
|
381
|
-
roleKind: Record<Role,
|
|
379
|
+
roleKind: Record<Role, AgentValue['kind']>;
|
|
382
380
|
/** Full-role session overlay payload written to `overrideModelRoles` (all known OMP roles). */
|
|
383
381
|
roleOverrides: Record<string, string>;
|
|
384
382
|
/** Known roles left out of the overlay payload (config skip / capability gate / no pick). */
|
|
@@ -389,7 +387,7 @@ export interface SelectionContext {
|
|
|
389
387
|
routingBand?: TaskBand;
|
|
390
388
|
}
|
|
391
389
|
|
|
392
|
-
function roleValueToModelString(value:
|
|
390
|
+
function roleValueToModelString(value: AgentValue): string | undefined {
|
|
393
391
|
if (value.kind === 'auto') return undefined;
|
|
394
392
|
if (value.kind === 'omp-alias') return `@${value.alias}`;
|
|
395
393
|
if (value.kind === 'provider-model') return `${value.provider}/${value.modelId}`;
|
|
@@ -509,8 +507,7 @@ function computeSelectionSync(
|
|
|
509
507
|
scopeRoles === null || scopedSlots.has(slot) ? filteredModels : basePool;
|
|
510
508
|
const selectorResults = {} as Record<Role, SelectorResult>;
|
|
511
509
|
const roleModels = {} as Record<Role, string | undefined>;
|
|
512
|
-
const
|
|
513
|
-
const roleKind = {} as Record<Role, RoleValue['kind']>;
|
|
510
|
+
const roleKind = {} as Record<Role, AgentValue['kind']>;
|
|
514
511
|
const planSet = mergePlanProviders(config.planProviders, config.planProvidersRemove);
|
|
515
512
|
// Speed-aware selection: undefined when disabled — zero DB IO, and the
|
|
516
513
|
// selector keeps the frozen W1 order byte-for-byte.
|
|
@@ -547,14 +544,9 @@ function computeSelectionSync(
|
|
|
547
544
|
if (routing.stallActive && ROLE_TO_OMP_ALIAS[role] === 'default') parts.push('stall+1');
|
|
548
545
|
return parts.length > 0 ? parts.join(', ') : undefined;
|
|
549
546
|
};
|
|
550
|
-
for (const role of
|
|
551
|
-
const value = config.
|
|
547
|
+
for (const role of OPSX_AGENTS) {
|
|
548
|
+
const value = config.agents[role];
|
|
552
549
|
roleKind[role] = value.kind;
|
|
553
|
-
// Config label shown in /pick-model choices markdown (`角色 (label)`):
|
|
554
|
-
// auto roles display the OMP tier-role alias; pinned roles the pin.
|
|
555
|
-
roleConfigs[role] = value.kind === 'auto'
|
|
556
|
-
? ROLE_TO_OMP_ALIAS[role]
|
|
557
|
-
: (roleValueToModelString(value) ?? '?');
|
|
558
550
|
const alias = `@${ROLE_TO_OMP_ALIAS[role]}`;
|
|
559
551
|
if (value.kind === 'auto') {
|
|
560
552
|
// coder's pick feeds the mid slot, the other three feed high —
|
|
@@ -563,10 +555,10 @@ function computeSelectionSync(
|
|
|
563
555
|
const pool = poolForSlot(role === 'coder' ? 'mid' : 'high');
|
|
564
556
|
if (pool && pool.length > 0) {
|
|
565
557
|
const ompRole = ROLE_TO_OMP_ALIAS[role];
|
|
566
|
-
const tier = routedTierFor(role, ompRole, (config.
|
|
558
|
+
const tier = routedTierFor(role, ompRole, (config.agentTiers[role]
|
|
567
559
|
?? (OMP_ROLE_TO_TIER as Record<string, TierName>)[ompRole]
|
|
568
560
|
?? 'mid') as TierName);
|
|
569
|
-
const roleTiers = { ...config.
|
|
561
|
+
const roleTiers = { ...config.agentTiers, [role]: tier };
|
|
570
562
|
const result = selectAllRoles(
|
|
571
563
|
[role],
|
|
572
564
|
pool,
|
|
@@ -611,7 +603,7 @@ function computeSelectionSync(
|
|
|
611
603
|
// roleOverrides; explicit paths bypass (显式指令 > 守门).
|
|
612
604
|
if (routing?.guard && !routing.explicit) {
|
|
613
605
|
const incumbentResults = routing.guard.incumbent?.selectorResults ?? null;
|
|
614
|
-
for (const role of
|
|
606
|
+
for (const role of OPSX_AGENTS) {
|
|
615
607
|
const prev = incumbentResults?.[role];
|
|
616
608
|
if (!prev?.picked) continue;
|
|
617
609
|
const cur = selectorResults[role];
|
|
@@ -709,7 +701,7 @@ function computeSelectionSync(
|
|
|
709
701
|
roleOverrides = plan.overrides;
|
|
710
702
|
skippedRoles = plan.skipped;
|
|
711
703
|
}
|
|
712
|
-
return { selectorResults, roleModels,
|
|
704
|
+
return { selectorResults, roleModels, roleKind, roleOverrides, skippedRoles, paygLastResort: config.paygLastResort, routingBand: routing?.band };
|
|
713
705
|
}
|
|
714
706
|
// change: pick-model-ux — choices/switch/refresh/reset output rendering lives
|
|
715
707
|
// in lib/pick-model-render.ts (shared compact block renderer); the former
|
|
@@ -758,6 +750,14 @@ interface OwnedConcurrency {
|
|
|
758
750
|
|
|
759
751
|
let ownedConcurrency: OwnedConcurrency | null = null;
|
|
760
752
|
|
|
753
|
+
// Process-wide resize-handler slot: the host re-runs the plugin factory per
|
|
754
|
+
// session binding WITHOUT re-evaluating the module graph, so a closure-level
|
|
755
|
+
// "previous handler" variable is per-instance — each binding would attach its
|
|
756
|
+
// own stdout resize listener and nobody could off the others (observed as
|
|
757
|
+
// MaxListenersExceededWarning: 11 resize listeners after enough bindings).
|
|
758
|
+
// Module scope gives every binding the same single slot.
|
|
759
|
+
let activeResizeHandler: (() => void) | null = null;
|
|
760
|
+
|
|
761
761
|
/** Test-only: reset the module-level control-loop owner (production never calls this). */
|
|
762
762
|
export function __resetOwnedConcurrencyForTests(): void {
|
|
763
763
|
ownedConcurrency = null;
|
|
@@ -843,7 +843,7 @@ export interface ConcurrencyAssemblyDeps {
|
|
|
843
843
|
configPath: string;
|
|
844
844
|
/** Agent dir the log reader / `model_perf` path derive from. */
|
|
845
845
|
agentDir?: string;
|
|
846
|
-
/** Host settings
|
|
846
|
+
/** Host settings singleton for the hot-apply (API generation resolved inside the adapter). */
|
|
847
847
|
settings?: SettingsPort | null;
|
|
848
848
|
/** Cross-process ① aggregation channel (`null` → local-only + warn). */
|
|
849
849
|
redis?: ConcurrencyRedisPort | null;
|
|
@@ -898,6 +898,9 @@ export function createConcurrencyControllerIfEnabled(
|
|
|
898
898
|
|
|
899
899
|
// ── plugin entry ────────────────────────────────────────────────────
|
|
900
900
|
|
|
901
|
+
/** Bounded backoff for session-start autoselect cold-start recovery (precedent: RESET_IMMINENT_MS). Not a config key. */
|
|
902
|
+
export const AUTOSELECT_RETRY_DELAYS_MS = [5_000, 15_000, 30_000] as const;
|
|
903
|
+
|
|
901
904
|
export default (pi: ExtensionAPI) => {
|
|
902
905
|
// Route plugin logging through the host logger (rolling file) instead of
|
|
903
906
|
// console.*, which would corrupt the TUI. Injected before any log path runs.
|
|
@@ -907,19 +910,21 @@ export default (pi: ExtensionAPI) => {
|
|
|
907
910
|
// but the model repeatedly breaks sloppy control lines too — retry loops
|
|
908
911
|
// burn tokens and abort agents. Point it at the simplest old_string/
|
|
909
912
|
// new_string "replace" variant instead.
|
|
910
|
-
// Preferred path:
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
|
|
913
|
+
// Preferred path: the runtime override (registry handle on omp >= 18.3,
|
|
914
|
+
// `settings.override` before that), in-memory and per-session. omp 18.0.11
|
|
915
|
+
// dropped `edit.modelVariants` from the path-based settings schema, so on
|
|
916
|
+
// hosts where neither runtime API reaches it the pin is persisted into the
|
|
917
|
+
// global config.yml once — the host reads the merged view directly, making
|
|
918
|
+
// the file equivalent. Fire-and-forget: the pin applies from the first
|
|
919
|
+
// edit turn, nothing here depends on its completion, and the function
|
|
920
|
+
// never rejects (it warns through the host logger).
|
|
921
|
+
void applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
|
|
915
922
|
const directory = process.cwd();
|
|
916
923
|
const warn = (msg: string) => pi.logger.warn(msg);
|
|
917
924
|
const opsxConfig = readOpsxSettings(directory, warn, opsxGlobalHomeForTests ?? homedir());
|
|
918
925
|
// Static orchestration rules — built once, reused every turn for cache stability.
|
|
919
926
|
const STATIC_PROMPT = buildStaticPrompt();
|
|
920
927
|
let initialModel: { provider: string; id: string } | null = null;
|
|
921
|
-
// Active resize handler — tracked so each session_start can off the previous one.
|
|
922
|
-
let activeResizeHandler: (() => void) | null = null;
|
|
923
928
|
let selection: SelectionContext | null = null;
|
|
924
929
|
// 403 no longer auto-blocks; the set is always empty in the running session.
|
|
925
930
|
// A one-time startup migration clears stale blockedModels written by the old
|
|
@@ -1070,12 +1075,12 @@ export default (pi: ExtensionAPI) => {
|
|
|
1070
1075
|
* poller/assembly call sites keep the all-auto semantics.
|
|
1071
1076
|
*/
|
|
1072
1077
|
const buildEffectiveConfig = (scopedSlots: ReadonlySet<TierName> | null = null): ResolvedOpsxConfig => {
|
|
1073
|
-
const
|
|
1074
|
-
for (const role of
|
|
1078
|
+
const agents = {} as Record<Role, AgentValue>;
|
|
1079
|
+
for (const role of OPSX_AGENTS) {
|
|
1075
1080
|
const slot: TierName = role === 'coder' ? 'mid' : 'high';
|
|
1076
|
-
|
|
1081
|
+
agents[role] = scopedSlots === null || scopedSlots.has(slot) ? { kind: 'auto' } : opsxConfig.agents[role];
|
|
1077
1082
|
}
|
|
1078
|
-
return { ...opsxConfig,
|
|
1083
|
+
return { ...opsxConfig, agents };
|
|
1079
1084
|
};
|
|
1080
1085
|
const startPollerFor = async (ctx: ExtensionContext, onTick?: () => void): Promise<void> => {
|
|
1081
1086
|
const auth = ctx.modelRegistry?.authStorage;
|
|
@@ -1099,7 +1104,12 @@ export default (pi: ExtensionAPI) => {
|
|
|
1099
1104
|
});
|
|
1100
1105
|
};
|
|
1101
1106
|
|
|
1102
|
-
const ensureSelection = (
|
|
1107
|
+
const ensureSelection = (
|
|
1108
|
+
ctx: ExtensionContext,
|
|
1109
|
+
force = false,
|
|
1110
|
+
explicit = false,
|
|
1111
|
+
opts?: { reprobeUnreachable?: boolean },
|
|
1112
|
+
): Promise<SelectionContext> => {
|
|
1103
1113
|
if (selectionPromise && !force) return selectionPromise;
|
|
1104
1114
|
selectionPromise = (async () => {
|
|
1105
1115
|
await startPollerFor(ctx);
|
|
@@ -1109,7 +1119,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1109
1119
|
} catch (e) {
|
|
1110
1120
|
warn(`[omp-opsx-addon] modelRegistry error: ${e}`);
|
|
1111
1121
|
}
|
|
1112
|
-
const reach = await refreshReachability(models, opsxConfig);
|
|
1122
|
+
const reach = await refreshReachability(models, opsxConfig, opts);
|
|
1113
1123
|
firstUnreachableProviders = reach.unreachable;
|
|
1114
1124
|
availableByProvider = reach.availableByProvider;
|
|
1115
1125
|
const health = getSharedUsage()?.health ?? null;
|
|
@@ -1149,8 +1159,8 @@ export default (pi: ExtensionAPI) => {
|
|
|
1149
1159
|
// copy in node_modules is a separate, uninitialized instance — writing to
|
|
1150
1160
|
// it was a no-op). This thin wrapper is the single call site the
|
|
1151
1161
|
// constrained-period tick-reselect path (auto-model-family) also uses.
|
|
1152
|
-
const applyRoleModel = (sel: SelectionContext): void => {
|
|
1153
|
-
applyRoleModelOverrides(pi.pi?.settings, sel, warn);
|
|
1162
|
+
const applyRoleModel = async (sel: SelectionContext): Promise<void> => {
|
|
1163
|
+
await applyRoleModelOverrides(pi.pi?.settings, sel, warn);
|
|
1154
1164
|
};
|
|
1155
1165
|
|
|
1156
1166
|
// Instance-level usage-record claim: true only while this instance's
|
|
@@ -1164,10 +1174,15 @@ export default (pi: ExtensionAPI) => {
|
|
|
1164
1174
|
let recordsOwnUsage = false;
|
|
1165
1175
|
|
|
1166
1176
|
// ── adaptive provider concurrency: host seams ─────────────────
|
|
1167
|
-
/**
|
|
1177
|
+
/**
|
|
1178
|
+
* Host settings port for the control loop's hot-apply. The API shape is the
|
|
1179
|
+
* adapter's business (`lib/host-settings-adapter.ts`: registry handles on
|
|
1180
|
+
* omp >= 18.3, path-based `override` before that), so any object — i.e.
|
|
1181
|
+
* every host generation — qualifies here.
|
|
1182
|
+
*/
|
|
1168
1183
|
const hostSettingsPort = (): SettingsPort | null => {
|
|
1169
|
-
const settings = pi.pi?.settings as unknown
|
|
1170
|
-
return settings && typeof settings
|
|
1184
|
+
const settings = pi.pi?.settings as unknown;
|
|
1185
|
+
return settings && typeof settings === 'object' ? (settings as SettingsPort) : null;
|
|
1171
1186
|
};
|
|
1172
1187
|
/** Config root + agent dir for the control loop (host settings, else `~/.omp/agent`). */
|
|
1173
1188
|
const resolveConcurrencyPaths = (): { agentDir: string; configPath: string } => {
|
|
@@ -1507,7 +1522,8 @@ export default (pi: ExtensionAPI) => {
|
|
|
1507
1522
|
},
|
|
1508
1523
|
});
|
|
1509
1524
|
// One resize listener per process: each new owning session replaces
|
|
1510
|
-
// the previous handler instead of
|
|
1525
|
+
// the previous handler in the module-level slot instead of
|
|
1526
|
+
// accumulating across factory re-bindings. (ExtensionContext
|
|
1511
1527
|
// exposes no `signal`, so per-session abort cleanup never fires —
|
|
1512
1528
|
// ownership above, not teardown hooks, keeps the singletons sane.)
|
|
1513
1529
|
if (activeResizeHandler) process.stdout.off('resize', activeResizeHandler);
|
|
@@ -1696,7 +1712,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1696
1712
|
pi.on('session_start', async (_event, ctx) => {
|
|
1697
1713
|
try {
|
|
1698
1714
|
const frontmatter = (role: Role): string | undefined =>
|
|
1699
|
-
frontmatterModel(role, opsxConfig.
|
|
1715
|
+
frontmatterModel(role, opsxConfig.agents[role]);
|
|
1700
1716
|
await installAgents(
|
|
1701
1717
|
directory,
|
|
1702
1718
|
frontmatter('coder'),
|
|
@@ -1719,25 +1735,67 @@ export default (pi: ExtensionAPI) => {
|
|
|
1719
1735
|
// choice. Owner gate before the once-flag: subagent bindings re-run the
|
|
1720
1736
|
// factory with independent closures (a closure once-flag cannot see
|
|
1721
1737
|
// them), so the process-level owner rule decides — same rule as the
|
|
1722
|
-
// usage/render claim in the first handler.
|
|
1723
|
-
//
|
|
1724
|
-
//
|
|
1725
|
-
//
|
|
1738
|
+
// usage/render claim in the first handler. First attempt is awaited
|
|
1739
|
+
// (first-turn-before-prompt contract); cold-start no-pick recovery is a
|
|
1740
|
+
// bounded fire-and-forget retry chain that does not block the handler.
|
|
1741
|
+
// Pins (opsx.yml agents.*) bypass selection via synthPinnedResult and are
|
|
1742
|
+
// never touched here.
|
|
1726
1743
|
pi.on('session_start', async (_event, ctx) => {
|
|
1727
1744
|
if (!opsxConfig.autoselectOnStart) return;
|
|
1728
1745
|
if (!(ctx.hasUI || !hasTickOwner())) return;
|
|
1729
1746
|
if (autoApplied) return;
|
|
1730
1747
|
autoApplied = true;
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1748
|
+
|
|
1749
|
+
const modelKeyOf = (m: { provider?: string; id?: string } | null | undefined): string | null =>
|
|
1750
|
+
m?.provider && m?.id ? `${m.provider}/${m.id}` : null;
|
|
1751
|
+
const entryModelKey = modelKeyOf(ctx.model);
|
|
1752
|
+
|
|
1753
|
+
const attemptOnce = async (reprobeUnreachable: boolean): Promise<'picked' | 'no-pick' | 'error'> => {
|
|
1754
|
+
try {
|
|
1755
|
+
const sel = await ensureSelection(
|
|
1756
|
+
ctx,
|
|
1757
|
+
false,
|
|
1758
|
+
false,
|
|
1759
|
+
reprobeUnreachable ? { reprobeUnreachable: true } : undefined,
|
|
1760
|
+
);
|
|
1761
|
+
await applyRoleModel(sel);
|
|
1762
|
+
const picked = sel.selectorResults.reviewer?.picked;
|
|
1763
|
+
if (picked && ctx.model && `${ctx.model.provider}/${ctx.model.id}` !== `${picked.provider}/${picked.id}`) {
|
|
1764
|
+
await pi.setModel(picked);
|
|
1765
|
+
}
|
|
1766
|
+
return picked ? 'picked' : 'no-pick';
|
|
1767
|
+
} catch (e) {
|
|
1768
|
+
warn(`[omp-opsx-addon] session-start autoselect error: ${e}`);
|
|
1769
|
+
return 'error';
|
|
1737
1770
|
}
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
|
|
1771
|
+
};
|
|
1772
|
+
|
|
1773
|
+
const first = await attemptOnce(false);
|
|
1774
|
+
if (first !== 'no-pick') return;
|
|
1775
|
+
|
|
1776
|
+
// Fire-and-forget bounded recovery: first attempt already failed with
|
|
1777
|
+
// no reviewer pick (typical cold-start probe/registry lag). Do not
|
|
1778
|
+
// await this chain in the handler — that would delay the first turn.
|
|
1779
|
+
void (async () => {
|
|
1780
|
+
try {
|
|
1781
|
+
for (const delay of AUTOSELECT_RETRY_DELAYS_MS) {
|
|
1782
|
+
await new Promise<void>((r) => setTimeout(r, delay));
|
|
1783
|
+
// Yield to an explicit user /model or manual /pick-model
|
|
1784
|
+
// (both change the live session model via setModel).
|
|
1785
|
+
const live = typeof ctx.models?.current === 'function' ? ctx.models.current() : undefined;
|
|
1786
|
+
if (live !== undefined && live !== null && modelKeyOf(live) !== entryModelKey) {
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
const outcome = await attemptOnce(true);
|
|
1790
|
+
if (outcome !== 'no-pick') return;
|
|
1791
|
+
}
|
|
1792
|
+
warn(
|
|
1793
|
+
'[omp-opsx-addon] session-start autoselect: retries exhausted; run /pick-model to apply manually',
|
|
1794
|
+
);
|
|
1795
|
+
} catch (e) {
|
|
1796
|
+
warn(`[omp-opsx-addon] session-start autoselect retry error: ${e}`);
|
|
1797
|
+
}
|
|
1798
|
+
})();
|
|
1741
1799
|
});
|
|
1742
1800
|
// ── Diagnostic-only error path (429/403; never auto-switches) ─
|
|
1743
1801
|
const diagnoseProviderError = async (ctx: ExtensionContext, source: string, errorType: '429' | '403'): Promise<void> => {
|
|
@@ -1919,13 +1977,13 @@ export default (pi: ExtensionAPI) => {
|
|
|
1919
1977
|
}
|
|
1920
1978
|
}
|
|
1921
1979
|
// Check all agent models
|
|
1922
|
-
for (const role of
|
|
1980
|
+
for (const role of OPSX_AGENTS) {
|
|
1923
1981
|
const result = freshSel.selectorResults[role];
|
|
1924
1982
|
const picked = result?.picked;
|
|
1925
1983
|
if (!picked) continue;
|
|
1926
1984
|
const agent = AGENT_MAP[role];
|
|
1927
1985
|
const pickedSel = `${picked.provider}/${picked.id}`;
|
|
1928
|
-
const roleCfg = opsxConfig.
|
|
1986
|
+
const roleCfg = opsxConfig.agents[role];
|
|
1929
1987
|
if (roleCfg.kind === 'model' || roleCfg.kind === 'provider-model') {
|
|
1930
1988
|
const pinned = roleValueToModelString(roleCfg);
|
|
1931
1989
|
if (pinned && pinned !== pickedSel) {
|
|
@@ -1946,7 +2004,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1946
2004
|
}
|
|
1947
2005
|
}
|
|
1948
2006
|
selection = freshSel;
|
|
1949
|
-
applyRoleModel(freshSel);
|
|
2007
|
+
await applyRoleModel(freshSel);
|
|
1950
2008
|
const content = changes.length > 0
|
|
1951
2009
|
? renderPickModelReport('refresh', { action: '已刷新', constraint: formatFamilyConstraint(familyConstraint) || undefined, detail: [changes.join('\n')] }, symbols)
|
|
1952
2010
|
: renderPickModelReport('refresh', { action: '已刷新 · 所有模型状态正常,无需切换' }, symbols);
|
|
@@ -1996,7 +2054,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1996
2054
|
...annotationOfPicked(sel.selectorResults.reviewer, newPrimary),
|
|
1997
2055
|
}));
|
|
1998
2056
|
}
|
|
1999
|
-
for (const role of
|
|
2057
|
+
for (const role of OPSX_AGENTS) {
|
|
2000
2058
|
const picked = sel.selectorResults[role]?.picked;
|
|
2001
2059
|
if (!picked) continue;
|
|
2002
2060
|
const newSel = `${picked.provider}/${picked.id}`;
|
|
@@ -2013,7 +2071,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
2013
2071
|
if (newPrimary) await pi.setModel(newPrimary);
|
|
2014
2072
|
selection = sel;
|
|
2015
2073
|
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
|
|
2016
|
-
applyRoleModel(sel);
|
|
2074
|
+
await applyRoleModel(sel);
|
|
2017
2075
|
pi.sendMessage({
|
|
2018
2076
|
customType: 'pick-model',
|
|
2019
2077
|
content: renderPickModelReport('reset', {
|
|
@@ -2117,7 +2175,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
2117
2175
|
// fail to pick. Out-of-scope pinned/omp-alias roles keep their
|
|
2118
2176
|
// config value and synthPinnedResult never carries a picked —
|
|
2119
2177
|
// counting them would roll back every scoped constraint.
|
|
2120
|
-
const emptyRole =
|
|
2178
|
+
const emptyRole = OPSX_AGENTS.find(
|
|
2121
2179
|
(r) => sel.roleKind[r] === 'auto' && !sel.selectorResults[r]?.picked,
|
|
2122
2180
|
);
|
|
2123
2181
|
if (emptyRole) {
|
|
@@ -2136,7 +2194,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
2136
2194
|
const currentModel = ctx.model;
|
|
2137
2195
|
const oldPrimary = currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined;
|
|
2138
2196
|
const oldRolePicks: Record<string, string | undefined> = {};
|
|
2139
|
-
for (const role of
|
|
2197
|
+
for (const role of OPSX_AGENTS) {
|
|
2140
2198
|
const prev = selection?.selectorResults[role]?.picked;
|
|
2141
2199
|
oldRolePicks[role] = prev ? `${prev.provider}/${prev.id}` : undefined;
|
|
2142
2200
|
}
|
|
@@ -2152,7 +2210,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
2152
2210
|
...annotationOfPicked(sel.selectorResults.reviewer, newPrimary),
|
|
2153
2211
|
}));
|
|
2154
2212
|
}
|
|
2155
|
-
for (const role of
|
|
2213
|
+
for (const role of OPSX_AGENTS) {
|
|
2156
2214
|
const picked = sel.selectorResults[role]?.picked;
|
|
2157
2215
|
if (!picked) continue;
|
|
2158
2216
|
const newSel = `${picked.provider}/${picked.id}`;
|
|
@@ -2186,7 +2244,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
2186
2244
|
if (newPrimary) await pi.setModel(newPrimary);
|
|
2187
2245
|
selection = sel;
|
|
2188
2246
|
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
|
|
2189
|
-
applyRoleModel(sel);
|
|
2247
|
+
await applyRoleModel(sel);
|
|
2190
2248
|
const content = renderPickModelReport('switch', {
|
|
2191
2249
|
action: '已切换 selector 约束',
|
|
2192
2250
|
constraint: formatFamilyConstraint(familyConstraint) || undefined,
|
|
@@ -2204,7 +2262,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
2204
2262
|
// ── /pick-model choices (default) ────────────────
|
|
2205
2263
|
const sel = await getSelectionForCommand(ctx);
|
|
2206
2264
|
const roleLines = formatRoleLines(
|
|
2207
|
-
|
|
2265
|
+
OPSX_AGENTS.map((role) => ({
|
|
2208
2266
|
name: AGENT_MAP[role],
|
|
2209
2267
|
kind: sel.roleKind[role],
|
|
2210
2268
|
fallbackModel: sel.roleModels[role],
|
|
@@ -2248,9 +2306,9 @@ export default (pi: ExtensionAPI) => {
|
|
|
2248
2306
|
action: '当前选择',
|
|
2249
2307
|
constraint: formatFamilyConstraint(familyConstraint) || undefined,
|
|
2250
2308
|
detail,
|
|
2251
|
-
overview: formatCandidateOverview(
|
|
2309
|
+
overview: formatCandidateOverview(OPSX_AGENTS.map((role) => sel.selectorResults[role])),
|
|
2252
2310
|
appendix: appendixParts.length > 0 ? appendixParts.join('\n') : undefined,
|
|
2253
|
-
footnote: formatFootnote(
|
|
2311
|
+
footnote: formatFootnote(OPSX_AGENTS.map((role) => sel.selectorResults[role]), sel.paygLastResort),
|
|
2254
2312
|
}, symbols);
|
|
2255
2313
|
pi.sendMessage({ customType: 'pick-model', content, display: true }, { triggerTurn: false });
|
|
2256
2314
|
} catch (e) {
|
package/lib/agent-defs.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
function readAddonVersion(): string {
|
|
7
|
+
try {
|
|
8
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
9
|
+
const raw = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
10
|
+
const v = raw?.version;
|
|
11
|
+
return typeof v === 'string' && v.trim() !== '' ? v : '0.0.0';
|
|
12
|
+
} catch {
|
|
13
|
+
return '0.0.0';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const ADDON_VERSION = readAddonVersion();
|
|
2
18
|
|
|
3
19
|
const MARKER = '<!-- @genee/omp-opsx-addon -->';
|
|
4
20
|
|
|
@@ -120,11 +136,12 @@ autoloadSkills: opsx-orchestration-protocol${model ? `\nmodel: "${model}"` : ''}
|
|
|
120
136
|
${MARKER}
|
|
121
137
|
`;
|
|
122
138
|
|
|
123
|
-
const CODER_MD = (model?: string) => `---
|
|
139
|
+
export const CODER_MD = (model?: string) => `---
|
|
124
140
|
name: coder
|
|
125
141
|
description: OpenSpec 变更实现 agent,同步 tasks.md 进度
|
|
126
142
|
tools: read, write, edit, bash, glob, grep, todo
|
|
127
|
-
autoloadSkills: opsx-orchestration-protocol, openspec-apply-change
|
|
143
|
+
autoloadSkills: opsx-orchestration-protocol, openspec-apply-change
|
|
144
|
+
thinkingLevel: low${model ? `\nmodel: "${model}"` : ''}
|
|
128
145
|
---
|
|
129
146
|
|
|
130
147
|
你是一个快速、专注的代码实现 agent。你从 prompt 和对话上下文推断要做什么。
|
|
@@ -146,6 +163,10 @@ autoloadSkills: opsx-orchestration-protocol, openspec-apply-change${model ? `\nm
|
|
|
146
163
|
- E2E 不属于自验证门槛,开发过程中不跑;整个变更只在终审轮由 code-reviewer 跑一次。
|
|
147
164
|
- 项目级全量校验(全仓库 lint/typecheck/\`bun test\`)不属于自验证门槛:若失败可归因于并发 sibling 的半成品改动(与本改动无关),把失败连同「已排查与本改动无关」的证据作为上下文上报(交付摘要中注明),不要被其阻塞;最终由 code-reviewer 的全局验证兜底确认。
|
|
148
165
|
|
|
166
|
+
## 执行效率
|
|
167
|
+
- 相互独立的只读调用(read/grep/glob)MUST 在同一轮内并行打包发起,不得一次一轮串行。
|
|
168
|
+
- 顺序无关的验证命令(lint/typecheck/单测)用 \`&&\` 或 \`;\` 串联在单次 bash 里执行,不得每条命令单独一轮。
|
|
169
|
+
|
|
149
170
|
## 协作流程(由你编排)
|
|
150
171
|
三条义务(报告 → 修复 → 轮次上限)见技能 \`opsx-orchestration-protocol\` 的「评审闭环与轮次上限」节。
|
|
151
172
|
|
|
@@ -166,7 +187,7 @@ FILES: <逗号分隔的修改/创建文件列表>
|
|
|
166
187
|
${MARKER}
|
|
167
188
|
`;
|
|
168
189
|
|
|
169
|
-
const PLANNER_MD = (model?: string) => `---
|
|
190
|
+
export const PLANNER_MD = (model?: string) => `---
|
|
170
191
|
name: planner
|
|
171
192
|
description: OpenSpec 提案规划 agent
|
|
172
193
|
tools: read, write, edit, bash, glob, grep, todo
|
|
@@ -186,6 +207,7 @@ autoloadSkills: opsx-orchestration-protocol, openspec-propose${model ? `\nmodel:
|
|
|
186
207
|
## 提案拆分
|
|
187
208
|
- 接大需求先评估契约边界:能拆则拆成多个小变更,先输出拆分建议(契约边界+提案清单)待主 agent 确认后再开写。对冲:同批串行落地、共享编辑区的强耦合改动合并为一个提案不拆;拆分收益仅在分批落地时成立。
|
|
188
209
|
- 纲领/roadmap change 只做层级管理(阶段/依赖/验收口径),不含实现细节。
|
|
210
|
+
- tasks.md 单个任务预估执行轮次超过约 40 轮(多文件、多验证环节)时 MUST 拆分为可独立交付的子任务——上下文重放随轮数二次增长,长任务是重放大头。
|
|
189
211
|
|
|
190
212
|
## Budget 估算
|
|
191
213
|
|
|
@@ -220,10 +242,6 @@ autoloadSkills: opsx-orchestration-protocol, openspec-propose${model ? `\nmodel:
|
|
|
220
242
|
${MARKER}
|
|
221
243
|
`;
|
|
222
244
|
|
|
223
|
-
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
224
|
-
import { existsSync } from 'fs';
|
|
225
|
-
import { join } from 'path';
|
|
226
|
-
|
|
227
245
|
type Status = 'unchanged' | 'created' | 'updated' | 'conflict';
|
|
228
246
|
|
|
229
247
|
async function agentStatus(file: string, want: string): Promise<Status> {
|
|
@@ -471,7 +471,7 @@ export interface ConcurrencyControlOptions {
|
|
|
471
471
|
dbPath?: string;
|
|
472
472
|
/** Cross-process ① aggregation channel; `null`/omitted disables it (warn + local only). */
|
|
473
473
|
redis?: ConcurrencyRedisPort | null;
|
|
474
|
-
/**
|
|
474
|
+
/** Host settings singleton for the hot-apply seam; omitted → file write only. */
|
|
475
475
|
settings?: SettingsPort | null;
|
|
476
476
|
/** Per-provider ceiling overrides (`concurrency_ceiling_by_provider`). */
|
|
477
477
|
ceilingByProvider?: Record<string, number>;
|
|
@@ -868,7 +868,7 @@ export class ConcurrencyController {
|
|
|
868
868
|
const shared = sharedMap[provider];
|
|
869
869
|
const divergedFromShared = typeof shared === 'number' && shared !== next.limit;
|
|
870
870
|
if ((evaluation.increased || evaluation.decreased || decay.moved || divergedFromShared) && isWriter) {
|
|
871
|
-
view.written = this.persistLimit(provider, next.limit, sharedMap, now);
|
|
871
|
+
view.written = await this.persistLimit(provider, next.limit, sharedMap, now);
|
|
872
872
|
}
|
|
873
873
|
result.providers[provider] = view;
|
|
874
874
|
}
|
|
@@ -1145,12 +1145,12 @@ export class ConcurrencyController {
|
|
|
1145
1145
|
return Math.max(1, Math.ceil((interval * AGGREGATION_TTL_INTERVALS) / 1000));
|
|
1146
1146
|
}
|
|
1147
1147
|
|
|
1148
|
-
private persistLimit(
|
|
1148
|
+
private async persistLimit(
|
|
1149
1149
|
provider: string,
|
|
1150
1150
|
limit: number,
|
|
1151
1151
|
sharedMap: Record<string, number>,
|
|
1152
1152
|
now: number,
|
|
1153
|
-
): boolean {
|
|
1153
|
+
): Promise<boolean> {
|
|
1154
1154
|
// Write gate (task 4.2): |Δ| >= 1 AND more than
|
|
1155
1155
|
// concurrency_write_min_interval_ms since this provider's last write.
|
|
1156
1156
|
const gate = shouldWriteLimit({
|
|
@@ -1179,7 +1179,7 @@ export class ConcurrencyController {
|
|
|
1179
1179
|
return false;
|
|
1180
1180
|
}
|
|
1181
1181
|
// Adopt the in-lock read: it is the freshest view of every provider, so
|
|
1182
|
-
// the rest of this tick and the whole-key
|
|
1182
|
+
// the rest of this tick and the whole-key hot-apply override below never
|
|
1183
1183
|
// carry a snapshot taken before another process's write landed.
|
|
1184
1184
|
if (outcome.limits) {
|
|
1185
1185
|
for (const [id, value] of Object.entries(outcome.limits)) sharedMap[id] = value;
|
|
@@ -1188,7 +1188,7 @@ export class ConcurrencyController {
|
|
|
1188
1188
|
this.lastWriteAt.set(provider, now);
|
|
1189
1189
|
if (this.settings) {
|
|
1190
1190
|
try {
|
|
1191
|
-
const applied = applyLimit({
|
|
1191
|
+
const applied = await applyLimit({
|
|
1192
1192
|
settings: this.settings,
|
|
1193
1193
|
merged: outcome.limits ?? merged,
|
|
1194
1194
|
provider,
|