@genee/omp-opsx-addon 0.10.0 → 0.12.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
@@ -458,8 +458,10 @@ quality_preference: balanced # balanced(缺省,逐字节现状)| cost |
458
458
  - top 补选没有可用模型(候选耗尽/allowlist 全过滤)时,slow 不写覆盖、warn 汇总
459
459
  (`no-pick`),命令不中断;无任何模型时计划为空,与既有「模型注册表不可用」分支一致。
460
460
  - 覆盖为 **session 作用域、不写盘**:`overrideModelRoles` 只写 runtime overlay,
461
- session 结束即还原;不会修改你的 `~/.omp/agent/config.yml`。每次应用前先
462
- `clearOverride('modelRoles')`,避免上一 selector 的旧 role 值残留;`reset` 重算无约束
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` 重算无约束
463
465
  默认并覆盖(**不清空**,否则回落 config 硬钉)。
464
466
 
465
467
  > **手动清理建议**:若全局 `~/.omp/agent/config.yml` 里有 `modelRoles.smol` 钉在会耗尽的
package/index.ts CHANGED
@@ -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 port for the hot-apply (`settings.override`). */
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;
@@ -910,11 +910,15 @@ export default (pi: ExtensionAPI) => {
910
910
  // but the model repeatedly breaks sloppy control lines too — retry loops
911
911
  // burn tokens and abort agents. Point it at the simplest old_string/
912
912
  // new_string "replace" variant instead.
913
- // Preferred path: `settings.override` (in-memory, per-session). omp
914
- // 18.0.11 removed `edit.modelVariants` from the settings schema, so on
915
- // that host the pin is persisted into the global config.yml once — the
916
- // host reads the merged view directly, making the file equivalent.
917
- applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
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' });
918
922
  const directory = process.cwd();
919
923
  const warn = (msg: string) => pi.logger.warn(msg);
920
924
  const opsxConfig = readOpsxSettings(directory, warn, opsxGlobalHomeForTests ?? homedir());
@@ -1155,8 +1159,8 @@ export default (pi: ExtensionAPI) => {
1155
1159
  // copy in node_modules is a separate, uninitialized instance — writing to
1156
1160
  // it was a no-op). This thin wrapper is the single call site the
1157
1161
  // constrained-period tick-reselect path (auto-model-family) also uses.
1158
- const applyRoleModel = (sel: SelectionContext): void => {
1159
- applyRoleModelOverrides(pi.pi?.settings, sel, warn);
1162
+ const applyRoleModel = async (sel: SelectionContext): Promise<void> => {
1163
+ await applyRoleModelOverrides(pi.pi?.settings, sel, warn);
1160
1164
  };
1161
1165
 
1162
1166
  // Instance-level usage-record claim: true only while this instance's
@@ -1170,10 +1174,15 @@ export default (pi: ExtensionAPI) => {
1170
1174
  let recordsOwnUsage = false;
1171
1175
 
1172
1176
  // ── adaptive provider concurrency: host seams ─────────────────
1173
- /** Host settings port for the control loop's hot-apply (narrowed to `override`). */
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
+ */
1174
1183
  const hostSettingsPort = (): SettingsPort | null => {
1175
- const settings = pi.pi?.settings as unknown as { override?: unknown } | undefined;
1176
- return settings && typeof settings.override === 'function' ? (settings as SettingsPort) : null;
1184
+ const settings = pi.pi?.settings as unknown;
1185
+ return settings && typeof settings === 'object' ? (settings as SettingsPort) : null;
1177
1186
  };
1178
1187
  /** Config root + agent dir for the control loop (host settings, else `~/.omp/agent`). */
1179
1188
  const resolveConcurrencyPaths = (): { agentDir: string; configPath: string } => {
@@ -1749,7 +1758,7 @@ export default (pi: ExtensionAPI) => {
1749
1758
  false,
1750
1759
  reprobeUnreachable ? { reprobeUnreachable: true } : undefined,
1751
1760
  );
1752
- applyRoleModel(sel);
1761
+ await applyRoleModel(sel);
1753
1762
  const picked = sel.selectorResults.reviewer?.picked;
1754
1763
  if (picked && ctx.model && `${ctx.model.provider}/${ctx.model.id}` !== `${picked.provider}/${picked.id}`) {
1755
1764
  await pi.setModel(picked);
@@ -1995,7 +2004,7 @@ export default (pi: ExtensionAPI) => {
1995
2004
  }
1996
2005
  }
1997
2006
  selection = freshSel;
1998
- applyRoleModel(freshSel);
2007
+ await applyRoleModel(freshSel);
1999
2008
  const content = changes.length > 0
2000
2009
  ? renderPickModelReport('refresh', { action: '已刷新', constraint: formatFamilyConstraint(familyConstraint) || undefined, detail: [changes.join('\n')] }, symbols)
2001
2010
  : renderPickModelReport('refresh', { action: '已刷新 · 所有模型状态正常,无需切换' }, symbols);
@@ -2062,7 +2071,7 @@ export default (pi: ExtensionAPI) => {
2062
2071
  if (newPrimary) await pi.setModel(newPrimary);
2063
2072
  selection = sel;
2064
2073
  selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
2065
- applyRoleModel(sel);
2074
+ await applyRoleModel(sel);
2066
2075
  pi.sendMessage({
2067
2076
  customType: 'pick-model',
2068
2077
  content: renderPickModelReport('reset', {
@@ -2235,7 +2244,7 @@ export default (pi: ExtensionAPI) => {
2235
2244
  if (newPrimary) await pi.setModel(newPrimary);
2236
2245
  selection = sel;
2237
2246
  selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
2238
- applyRoleModel(sel);
2247
+ await applyRoleModel(sel);
2239
2248
  const content = renderPickModelReport('switch', {
2240
2249
  action: '已切换 selector 约束',
2241
2250
  constraint: formatFamilyConstraint(familyConstraint) || undefined,
@@ -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
- /** Settings hot-apply seam; omitted → file write only. */
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 `settings.override` below never
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,
@@ -38,6 +38,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statS
38
38
  import { dirname } from 'path';
39
39
  import { YAML } from 'bun';
40
40
  import { clampLimit } from './concurrency-tuner.js';
41
+ import { overrideProviderMaxInFlight } from './host-settings-adapter.js';
41
42
  import { canonicalizeProvider } from './usage-resolver.js';
42
43
 
43
44
  /** Sink for degradation notices; defaults to a no-op. */
@@ -218,8 +219,8 @@ export function readManagedLimits(
218
219
  /**
219
220
  * Merge one provider's new limit into the **complete** per-provider map.
220
221
  * `providers.maxInFlightRequests` is a whole-key override on the host side, so
221
- * every caller MUST pass the full map (this is the map that goes to
222
- * `settings.override` and to {@link writeManagedLimit}).
222
+ * every caller MUST pass the full map (this is the map that goes to the
223
+ * hot-apply override — {@link applyLimit} — and to {@link writeManagedLimit}).
223
224
  */
224
225
  export function buildMergedMap(
225
226
  current: Record<string, number>,
@@ -582,10 +583,15 @@ export function writeManagedLimit(args: WriteManagedLimitArgs): WriteManagedLimi
582
583
  }
583
584
 
584
585
  /**
585
- * Injected host settings port (`pi.pi.settings`), narrowed to the call we make.
586
+ * Injected host settings port (`pi.pi.settings`).
587
+ *
588
+ * Only the identity of the host singleton matters here: the hot-apply goes
589
+ * through `lib/host-settings-adapter.ts`, which resolves the API generation
590
+ * (registry handle on omp >= 18.3, path-based `override` before that). Hence
591
+ * `override` is optional — a host that dropped it is still a usable port.
586
592
  */
587
593
  export interface SettingsPort {
588
- override(path: string, value: unknown): void;
594
+ override?(path: string, value: unknown): void;
589
595
  }
590
596
 
591
597
  export interface ApplyLimitArgs {
@@ -604,8 +610,10 @@ export interface ApplyLimitResult {
604
610
  }
605
611
 
606
612
  /**
607
- * Hot-apply the shared map in this process via
608
- * `settings.override('providers.maxInFlightRequests', merged)`.
613
+ * Hot-apply the shared map in this process through the host settings runtime
614
+ * override (`providers.maxInFlightRequests`: registry handle on omp >= 18.3,
615
+ * path-based `settings.override` before that — both via
616
+ * `lib/host-settings-adapter.ts`).
609
617
  *
610
618
  * `providers.maxInFlightRequests` is a **whole-key override**, so the second
611
619
  * argument MUST be the complete per-provider map — passing only the target
@@ -614,13 +622,14 @@ export interface ApplyLimitResult {
614
622
  * (the caller keeps the previous value), while a partial map would silently
615
623
  * make the omitted providers unlimited. When the API throws (host schema moved
616
624
  * on), the persisted config is untouched — the host config watcher picks the
617
- * value up — and the failure is warned, never fatal.
625
+ * value up — and the failure is warned, never fatal. Async because the adapter
626
+ * loads host handles by dynamic import.
618
627
  */
619
- export function applyLimit(args: ApplyLimitArgs): ApplyLimitResult {
628
+ export async function applyLimit(args: ApplyLimitArgs): Promise<ApplyLimitResult> {
620
629
  const warn = args.warn ?? NO_WARN;
621
630
  const settings = args.settings;
622
- if (!settings || typeof settings.override !== 'function') {
623
- warn('[omp-opsx-addon] concurrency: settings.override unavailable; skipping hot apply (config file value still applies)');
631
+ if (!settings) {
632
+ warn('[omp-opsx-addon] concurrency: settings unavailable; skipping hot apply (config file value still applies)');
624
633
  return { applied: false, reason: 'settings-unavailable' };
625
634
  }
626
635
  const provider = canonicalizeProvider(args.provider.trim());
@@ -631,15 +640,18 @@ export function applyLimit(args: ApplyLimitArgs): ApplyLimitResult {
631
640
  const payload = buildMergedMap(args.merged ?? {}, provider, args.value);
632
641
  for (const [id, value] of Object.entries(payload)) {
633
642
  if (!id || typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
634
- warn(`[omp-opsx-addon] concurrency: refusing settings.override — invalid map entry ${JSON.stringify(id)}: ${JSON.stringify(value)}`);
643
+ warn(`[omp-opsx-addon] concurrency: refusing the hot-apply override — invalid map entry ${JSON.stringify(id)}: ${JSON.stringify(value)}`);
635
644
  return { applied: false, reason: 'invalid-merged' };
636
645
  }
637
646
  }
638
647
  try {
639
- settings.override('providers.maxInFlightRequests', payload);
648
+ if (!(await overrideProviderMaxInFlight(settings, payload))) {
649
+ warn('[omp-opsx-addon] concurrency: no settings runtime-override API available (host handle / settings.override); skipping hot apply (config file value still applies)');
650
+ return { applied: false, reason: 'settings-unavailable' };
651
+ }
640
652
  return { applied: true };
641
653
  } catch (err) {
642
- warn(`[omp-opsx-addon] concurrency: settings.override failed (${message(err)}); the host config watcher will apply the persisted value`);
654
+ warn(`[omp-opsx-addon] concurrency: settings hot-apply failed (${message(err)}); the host config watcher will apply the persisted value`);
643
655
  return { applied: false, reason: 'override-failed' };
644
656
  }
645
657
  }
@@ -84,30 +84,46 @@ export function isJwtExpired(token: string, nowMs: number = Date.now()): boolean
84
84
  return exp * 1000 <= nowMs;
85
85
  }
86
86
 
87
+ function queryTokensFromDb(db: Database): CursorTokenStore | null {
88
+ const row = (key: string): string | undefined => {
89
+ const r = db.query('SELECT value FROM ItemTable WHERE key = ?').get(key) as
90
+ | { value?: unknown }
91
+ | null
92
+ | undefined;
93
+ if (!r || r.value == null) return undefined;
94
+ const v = r.value;
95
+ return typeof v === 'string' ? v : Buffer.isBuffer(v) ? v.toString('utf8') : String(v);
96
+ };
97
+ const accessToken = row('cursorAuth/accessToken')?.trim() || undefined;
98
+ const refreshToken = row('cursorAuth/refreshToken')?.trim() || undefined;
99
+ const membershipType = row('cursorAuth/stripeMembershipType')?.trim() || undefined;
100
+ if (!accessToken && !refreshToken) return null;
101
+ return { accessToken, refreshToken, membershipType };
102
+ }
103
+
87
104
  function defaultReadTokensFromDb(dbPath: string): CursorTokenStore | null {
88
- try {
89
- const db = new Database(dbPath, { readonly: true });
105
+ // Read-only first. Bun's bundled SQLite defers the open to the first
106
+ // prepare, where a live Cursor DB (active WAL/locks on APFS) can raise
107
+ // SQLITE_CANTOPEN even though the file is readable — plain python3 and a
108
+ // default read-write handle both work. So a failed readonly pass falls
109
+ // back to the default open; the SHARED lock is only held for the three
110
+ // key reads before we close, same footprint as any external reader.
111
+ for (const opts of [{ readonly: true } as const, undefined]) {
112
+ let db: Database;
90
113
  try {
91
- const row = (key: string): string | undefined => {
92
- const r = db.query('SELECT value FROM ItemTable WHERE key = ?').get(key) as
93
- | { value?: unknown }
94
- | null
95
- | undefined;
96
- if (!r || r.value == null) return undefined;
97
- const v = r.value;
98
- return typeof v === 'string' ? v : Buffer.isBuffer(v) ? v.toString('utf8') : String(v);
99
- };
100
- const accessToken = row('cursorAuth/accessToken')?.trim() || undefined;
101
- const refreshToken = row('cursorAuth/refreshToken')?.trim() || undefined;
102
- const membershipType = row('cursorAuth/stripeMembershipType')?.trim() || undefined;
103
- if (!accessToken && !refreshToken) return null;
104
- return { accessToken, refreshToken, membershipType };
114
+ db = new Database(dbPath, opts);
115
+ } catch {
116
+ continue;
117
+ }
118
+ try {
119
+ return queryTokensFromDb(db);
120
+ } catch {
121
+ // Try the next open mode.
105
122
  } finally {
106
123
  db.close();
107
124
  }
108
- } catch {
109
- return null;
110
125
  }
126
+ return null;
111
127
  }
112
128
 
113
129
  function readStore(env: NodeJS.ProcessEnv = process.env): CursorTokenStore {
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Cursor usage-limit / slow-pool status via Connect RPC
3
+ * (`GetUsageLimitStatusAndActiveGrants`), plus note encode/decode for
4
+ * attaching the parsed status to a `UsageReport` without extending pi-ai types.
5
+ *
6
+ * Fail-soft everywhere: field drift / network errors / missing token → null /
7
+ * undefined, never throw. Module-level TTL cache absorbs intermittent RPC
8
+ * failures so the widget does not flap exhausted↔warning.
9
+ */
10
+
11
+ import type { UsageReport } from '@oh-my-pi/pi-ai';
12
+ import { resolveCursorAccessToken } from './cursor-auth.js';
13
+
14
+ export const CURSOR_LIMIT_STATUS_URL =
15
+ 'https://api2.cursor.sh/aiserver.v1.DashboardService/GetUsageLimitStatusAndActiveGrants';
16
+
17
+ export const LIMIT_STATUS_TTL_MS = 5 * 60_000;
18
+ export const LIMIT_STATUS_TIMEOUT_MS = 5000;
19
+
20
+ export const CURSOR_LIMIT_STATUS_NOTE_PREFIX = 'cursorLimitStatus=';
21
+
22
+ export interface CursorLimitStatus {
23
+ isInSlowPool: boolean;
24
+ stage?: string;
25
+ slownessMs?: number;
26
+ grantsRemainingCents: number;
27
+ grantsExpiresAtMs?: number;
28
+ }
29
+
30
+ export interface FetchCursorLimitStatusOpts {
31
+ timeoutMs?: number;
32
+ fetchImpl?: typeof fetch;
33
+ resolveCursorToken?: () => Promise<{ accessToken: string } | null>;
34
+ nowMs?: () => number;
35
+ }
36
+
37
+ type CacheEntry = { status: CursorLimitStatus; atMs: number };
38
+
39
+ let cache: CacheEntry | null = null;
40
+
41
+ /** Module-level seam defaults (opts override these). Cleared by `_resetLimitStatusCacheForTest`. */
42
+ let seamFetchImpl: typeof fetch | undefined;
43
+ let seamResolveCursorToken: (() => Promise<{ accessToken: string } | null>) | undefined;
44
+ let seamNowMs: (() => number) | undefined;
45
+
46
+ export function _setLimitStatusSeamsForTest(seams: {
47
+ fetchImpl?: typeof fetch;
48
+ resolveCursorToken?: () => Promise<{ accessToken: string } | null>;
49
+ nowMs?: () => number;
50
+ } | null): void {
51
+ if (!seams) {
52
+ seamFetchImpl = undefined;
53
+ seamResolveCursorToken = undefined;
54
+ seamNowMs = undefined;
55
+ return;
56
+ }
57
+ if ('fetchImpl' in seams) seamFetchImpl = seams.fetchImpl;
58
+ if ('resolveCursorToken' in seams) seamResolveCursorToken = seams.resolveCursorToken;
59
+ if ('nowMs' in seams) seamNowMs = seams.nowMs;
60
+ }
61
+
62
+ export function _resetLimitStatusCacheForTest(): void {
63
+ cache = null;
64
+ seamFetchImpl = undefined;
65
+ seamResolveCursorToken = undefined;
66
+ seamNowMs = undefined;
67
+ }
68
+
69
+ /**
70
+ * Defensive parse of the Connect JSON body. Returns null on any structural
71
+ * drift (missing policy status, non-boolean isInSlowPool, non-object body).
72
+ * Invalid grant entries are skipped; cents are summed from valid ones.
73
+ */
74
+ export function parseCursorLimitStatusResponse(data: unknown): CursorLimitStatus | null {
75
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) return null;
76
+ const root = data as Record<string, unknown>;
77
+ const policy = root.usageLimitPolicyStatus;
78
+ if (policy === null || typeof policy !== 'object' || Array.isArray(policy)) return null;
79
+ const pol = policy as Record<string, unknown>;
80
+ if (typeof pol.isInSlowPool !== 'boolean') return null;
81
+
82
+ const stage = typeof pol.stage === 'string' ? pol.stage : undefined;
83
+ const slownessMs =
84
+ typeof pol.slownessMs === 'number' && Number.isFinite(pol.slownessMs) ? pol.slownessMs : undefined;
85
+
86
+ let grantsRemainingCents = 0;
87
+ let grantsExpiresAtMs: number | undefined;
88
+ const grants = root.activeGrants;
89
+ if (Array.isArray(grants)) {
90
+ for (const entry of grants) {
91
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue;
92
+ const g = entry as Record<string, unknown>;
93
+ const raw = g.remainingCents;
94
+ if (typeof raw !== 'string') continue;
95
+ // Integer cents string only — reject floats / signs / whitespace-padded junk.
96
+ if (!/^\d+$/.test(raw)) continue;
97
+ const cents = Number(raw);
98
+ if (!Number.isSafeInteger(cents) || cents < 0) continue;
99
+ grantsRemainingCents += cents;
100
+ if (cents > 0) {
101
+ const expRaw = g.expiresAtMs;
102
+ const exp =
103
+ typeof expRaw === 'string' && /^\d+$/.test(expRaw)
104
+ ? Number(expRaw)
105
+ : typeof expRaw === 'number'
106
+ ? expRaw
107
+ : NaN;
108
+ if (Number.isFinite(exp) && Number.isSafeInteger(exp)) {
109
+ grantsExpiresAtMs =
110
+ grantsExpiresAtMs === undefined ? exp : Math.max(grantsExpiresAtMs, exp);
111
+ }
112
+ }
113
+ }
114
+ } else if (grants !== undefined) {
115
+ // Present but not an array → drift; treat whole parse as unknown.
116
+ return null;
117
+ }
118
+
119
+ const out: CursorLimitStatus = {
120
+ isInSlowPool: pol.isInSlowPool,
121
+ grantsRemainingCents,
122
+ };
123
+ if (stage !== undefined) out.stage = stage;
124
+ if (slownessMs !== undefined) out.slownessMs = slownessMs;
125
+ if (grantsExpiresAtMs !== undefined) out.grantsExpiresAtMs = grantsExpiresAtMs;
126
+ return out;
127
+ }
128
+
129
+ /** Encode status into the structured note (omit absent optional fields). */
130
+ export function encodeCursorLimitStatusNote(status: CursorLimitStatus): string {
131
+ const parts: string[] = [`isInSlowPool:${status.isInSlowPool ? 1 : 0}`];
132
+ if (status.stage !== undefined) parts.push(`stage:${status.stage}`);
133
+ if (status.slownessMs !== undefined) parts.push(`slownessMs:${status.slownessMs}`);
134
+ parts.push(`grantsCents:${status.grantsRemainingCents}`);
135
+ if (status.grantsExpiresAtMs !== undefined) parts.push(`grantsExpiryMs:${status.grantsExpiresAtMs}`);
136
+ return CURSOR_LIMIT_STATUS_NOTE_PREFIX + parts.join(';');
137
+ }
138
+
139
+ /**
140
+ * Tolerant note parse: unknown fields ignored, truncated/malformed note →
141
+ * undefined (never throws). Requires at least a recognizable isInSlowPool bit.
142
+ */
143
+ export function parseCursorLimitStatusNote(notes?: string[]): CursorLimitStatus | undefined {
144
+ if (!notes) return undefined;
145
+ for (const n of notes) {
146
+ if (!n.startsWith(CURSOR_LIMIT_STATUS_NOTE_PREFIX)) continue;
147
+ const body = n.slice(CURSOR_LIMIT_STATUS_NOTE_PREFIX.length);
148
+ if (!body) return undefined;
149
+ const fields = new Map<string, string>();
150
+ for (const part of body.split(';')) {
151
+ if (!part) continue;
152
+ const colon = part.indexOf(':');
153
+ if (colon <= 0) continue;
154
+ fields.set(part.slice(0, colon), part.slice(colon + 1));
155
+ }
156
+ const slowRaw = fields.get('isInSlowPool');
157
+ if (slowRaw !== '0' && slowRaw !== '1') return undefined;
158
+ const grantsRaw = fields.get('grantsCents');
159
+ let grantsRemainingCents = 0;
160
+ if (grantsRaw !== undefined) {
161
+ if (!/^\d+$/.test(grantsRaw)) return undefined;
162
+ const g = Number(grantsRaw);
163
+ if (!Number.isSafeInteger(g) || g < 0) return undefined;
164
+ grantsRemainingCents = g;
165
+ }
166
+ const out: CursorLimitStatus = {
167
+ isInSlowPool: slowRaw === '1',
168
+ grantsRemainingCents,
169
+ };
170
+ const stage = fields.get('stage');
171
+ if (stage !== undefined && stage.length > 0) out.stage = stage;
172
+ const slowMs = fields.get('slownessMs');
173
+ if (slowMs !== undefined && /^-?\d+(\.\d+)?$/.test(slowMs)) {
174
+ const v = Number(slowMs);
175
+ if (Number.isFinite(v)) out.slownessMs = v;
176
+ }
177
+ const exp = fields.get('grantsExpiryMs');
178
+ if (exp !== undefined && /^\d+$/.test(exp)) {
179
+ const v = Number(exp);
180
+ if (Number.isSafeInteger(v)) out.grantsExpiresAtMs = v;
181
+ }
182
+ return out;
183
+ }
184
+ return undefined;
185
+ }
186
+
187
+ /**
188
+ * Return a new report whose notes carry the encoded status. Replaces any
189
+ * existing `cursorLimitStatus=` note; preserves all other notes. Idempotent.
190
+ */
191
+ export function enhanceCursorReport(report: UsageReport, status: CursorLimitStatus): UsageReport {
192
+ const note = encodeCursorLimitStatusNote(status);
193
+ const prev = report.notes ?? [];
194
+ const kept = prev.filter((n) => !n.startsWith(CURSOR_LIMIT_STATUS_NOTE_PREFIX));
195
+ return { ...report, notes: [...kept, note] };
196
+ }
197
+
198
+ /**
199
+ * Fetch + parse Cursor limit status. Fail-soft: network / non-OK / no token /
200
+ * parse failure → null (or cached value within TTL). Success overwrites cache.
201
+ */
202
+ export async function fetchCursorLimitStatus(
203
+ opts?: FetchCursorLimitStatusOpts,
204
+ ): Promise<CursorLimitStatus | null> {
205
+ const now = (opts?.nowMs ?? seamNowMs ?? Date.now)();
206
+ const fetchFn = opts?.fetchImpl ?? seamFetchImpl ?? fetch;
207
+ const resolveToken =
208
+ opts?.resolveCursorToken ??
209
+ seamResolveCursorToken ??
210
+ (async () => {
211
+ const resolved = await resolveCursorAccessToken();
212
+ return resolved?.accessToken ? { accessToken: resolved.accessToken } : null;
213
+ });
214
+
215
+ try {
216
+ const auth = await resolveToken();
217
+ if (!auth?.accessToken) {
218
+ return softFail(now);
219
+ }
220
+ const res = await fetchFn(CURSOR_LIMIT_STATUS_URL, {
221
+ method: 'POST',
222
+ headers: {
223
+ 'content-type': 'application/json',
224
+ authorization: `Bearer ${auth.accessToken}`,
225
+ 'Connect-Protocol-Version': '1',
226
+ 'x-cursor-client-version': '0.1.0',
227
+ },
228
+ body: JSON.stringify({}),
229
+ signal: AbortSignal.timeout(opts?.timeoutMs ?? LIMIT_STATUS_TIMEOUT_MS),
230
+ });
231
+ if (!res.ok) return softFail(now);
232
+ let data: unknown;
233
+ try {
234
+ data = await res.json();
235
+ } catch {
236
+ return softFail(now);
237
+ }
238
+ const parsed = parseCursorLimitStatusResponse(data);
239
+ if (!parsed) return softFail(now);
240
+ cache = { status: parsed, atMs: now };
241
+ return parsed;
242
+ } catch {
243
+ return softFail(now);
244
+ }
245
+ }
246
+
247
+ function softFail(now: number): CursorLimitStatus | null {
248
+ if (cache && now - cache.atMs < LIMIT_STATUS_TTL_MS) return cache.status;
249
+ return null;
250
+ }
@@ -2,9 +2,11 @@ import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import type { ExtensionAPI } from '@oh-my-pi/pi-coding-agent';
5
+ import { overrideEditModelVariants } from './host-settings-adapter.js';
5
6
 
6
7
  /**
7
- * Runtime edit-variant pin fallback for omp >= 18.0.11.
8
+ * Runtime edit-variant pin, with a persisted fallback for hosts that no longer
9
+ * expose `edit.modelVariants` to the runtime settings API.
8
10
  *
9
11
  * The host hard-maps deepseek-v4-flash to the sloppy §/» marker grammar (it
10
12
  * misreads hashline ranges), but the model repeatedly breaks sloppy control
@@ -12,11 +14,14 @@ import type { ExtensionAPI } from '@oh-my-pi/pi-coding-agent';
12
14
  * plain old_string/new_string "replace" variant instead.
13
15
  *
14
16
  * Up to 17.x this extension did that via
15
- * `settings.override("edit.modelVariants", …)`. omp 18.0.11 dropped
16
- * `edit.modelVariants` from the settings schema, so `override()` throws
17
- * inside `get()`: the path-segment table has no entry for it and `getByPath`
18
- * iterates `undefined`. The host still honors the key when it arrives through
19
- * the global config file — `#getEditVariantEntries` reads the merged view
17
+ * `settings.override("edit.modelVariants", …)`; omp 18.3 replaced that
18
+ * path-based API with registry handles (`edit.modelVariants` handle →
19
+ * `handle.override(settings, …)`, see `lib/host-settings-adapter.ts`), so the
20
+ * runtime path lives on. On 18.0.11–18.x hosts whose path-based schema
21
+ * dropped `edit.modelVariants`, `override()` throws inside `get()`: the
22
+ * path-segment table has no entry for it and `getByPath` iterates
23
+ * `undefined`. Those hosts still honor the key when it arrives through the
24
+ * global config file — `#getEditVariantEntries` reads the merged view
20
25
  * directly and `resolveEditMode` prefers the per-model variant over
21
26
  * `edit.mode` — so persisting once is functionally equivalent to the runtime
22
27
  * pin.
@@ -102,38 +107,41 @@ export function persistEditVariantPin(
102
107
  }
103
108
 
104
109
  /**
105
- * Pin the edit variant for the target models.
110
+ * Pin the edit variant for the target models. Never rejects: every failure is
111
+ * warned through the host logger (the caller fires this and forgets it).
106
112
  *
107
- * Preferred path: in-memory `settings.override` (omp < 18.0.11). When that
108
- * throws (18.0.11 removed the schema path), persist into the global
109
- * config.yml once — idempotent, effective from the next session.
113
+ * Preferred path: the in-memory runtime override — the `edit.modelVariants`
114
+ * registry handle on omp >= 18.3, the path-based `settings.override` before
115
+ * that (both resolved inside `lib/host-settings-adapter.ts`). When this host
116
+ * offers no runtime path that reaches the setting (18.0.11+ dropped it from
117
+ * the path-based schema), persist into the global config.yml once —
118
+ * idempotent, effective from the next session.
110
119
  */
111
- export function applyEditVariantPin(pi: ExtensionAPI, pin: Record<string, string>): void {
120
+ export async function applyEditVariantPin(pi: ExtensionAPI, pin: Record<string, string>): Promise<void> {
112
121
  // `pi.pi` is the whole SDK module namespace; its `settings` field has no
113
- // declared runtime instance type on the extension-facing surface, so
114
- // narrow to the two methods we call (the same shape the host's Settings
115
- // class exposes publicly).
116
- const settings = pi.pi?.settings as unknown as
117
- | { override(path: string, value: unknown): void; getAgentDir?: () => string }
118
- | undefined;
119
- if (!settings) {
122
+ // declared runtime instance type on the extension-facing surface, so the
123
+ // `in`/`typeof` checks below are the whole contract.
124
+ const settings: unknown = pi.pi?.settings;
125
+ if (!settings || typeof settings !== 'object') {
120
126
  pi.logger?.warn('[omp-opsx-addon] pi.pi.settings unavailable; edit variant pin skipped');
121
127
  return;
122
128
  }
123
129
 
124
130
  try {
125
- settings.override('edit.modelVariants', pin);
126
- return;
131
+ if (await overrideEditModelVariants(settings, pin)) return;
127
132
  } catch {
128
- /* settings schema no longer knows this path — persist instead */
133
+ /* host schema no longer knows the path — persist instead */
129
134
  }
130
135
 
131
136
  try {
132
- let agentDir: string;
133
- try {
134
- agentDir = settings.getAgentDir?.() ?? '';
135
- } catch {
136
- agentDir = '';
137
+ let agentDir = '';
138
+ if ('getAgentDir' in settings && typeof settings.getAgentDir === 'function') {
139
+ try {
140
+ const dir = settings.getAgentDir();
141
+ if (typeof dir === 'string') agentDir = dir;
142
+ } catch {
143
+ agentDir = '';
144
+ }
137
145
  }
138
146
  if (!agentDir) agentDir = path.join(os.homedir(), '.omp', 'agent');
139
147
 
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Host Settings API compatibility adapter (change: omp-18.3-settings-api-compat).
3
+ *
4
+ * omp 18.3.2 removed the path-based Settings API (`settings.override(path, value)`
5
+ * / `settings.clearOverride(path)`) in favour of registry handles: a handle
6
+ * carries the setting's identity — `handle.override(settings, value)` writes the
7
+ * runtime overlay and clearing goes through
8
+ * `settings.clearOverrideValue(handle)` (equivalent to
9
+ * `handle.clearOverride(settings)`). Every path-based call this plugin made
10
+ * (`clearOverride('modelRoles')`, `override('task.agentModelOverrides', …)`,
11
+ * `override('providers.maxInFlightRequests', …)`,
12
+ * `override('edit.modelVariants', …)`) now throws
13
+ * (`settings.clearOverride is not a function`), which took down the whole
14
+ * `/pick-model` apply path and silently disabled the concurrency hot-apply.
15
+ *
16
+ * Handles live on deep host subpaths and are loaded LAZILY via dynamic
17
+ * `import()` — never a static import:
18
+ * - this package's own dev dependency is older (18.1.2) and ships no such
19
+ * subpaths, so a static import would break typecheck/build;
20
+ * - the host's legacy-pi-compat redirect rewrites these specifiers to the
21
+ * host's own module instances, which is the singleton the session reads
22
+ * (a separately `require()`d copy would be a no-op target for writes).
23
+ *
24
+ * The specifiers MUST BE STRING LITERALS — `import('@oh-my-pi/pi-coding-agent/
25
+ * config/model-settings')` — never a computed/concatenated string. The host
26
+ * rewrites extension sources textually (it collects string-literal import
27
+ * references and swaps them for absolute host paths), so a runtime-built
28
+ * specifier is not seen by the rewriter and resolves natively against this
29
+ * package's own 18.1.2 copy, i.e. the handle silently stays `undefined` on
30
+ * exactly the hosts this adapter exists for. `types/host-settings-modules.d.ts`
31
+ * supplies the build-time declarations those literal specifiers need.
32
+ * Each specifier is attempted independently of the others and the result (hit
33
+ * or miss) is cached for the process lifetime, so a host without these
34
+ * subpaths pays each failed import exactly once.
35
+ *
36
+ * Every helper falls back to the path-based API when the handle is missing, so
37
+ * hosts <= 18.1 keep their previous behaviour byte-for-byte.
38
+ */
39
+
40
+ /** Minimal host registry-handle surface (omp >= 18.3 settings API). */
41
+ export interface HostSettingHandle {
42
+ /** Write this setting's runtime override (never persisted). */
43
+ override(settings: unknown, value: unknown): void;
44
+ /**
45
+ * Clear this setting's runtime override. Present on handles, but the
46
+ * documented clear path is `settings.clearOverrideValue(handle)`, so the
47
+ * adapter tries that first (see {@link clearModelRolesRuntimeOverride}).
48
+ */
49
+ clearOverride?(settings: unknown): void;
50
+ }
51
+
52
+ /** Resolved handle per setting this plugin touches; a miss stays `undefined`. */
53
+ export interface HostSettingsHandles {
54
+ /** `cfgModelRoles` — `config/model-settings`. */
55
+ modelRoles?: HostSettingHandle;
56
+ /** `cfgTaskAgentModelOverrides` — `task/settings`. */
57
+ taskAgentModelOverrides?: HostSettingHandle;
58
+ /** `cfgProvidersMaxInFlightRequests` — `session/settings`. */
59
+ providersMaxInFlight?: HostSettingHandle;
60
+ /** `cfgEditModelVariants` — `edit/settings`. */
61
+ editModelVariants?: HostSettingHandle;
62
+ }
63
+
64
+ interface HandleSpec {
65
+ key: keyof HostSettingsHandles;
66
+ exportName: string;
67
+ /**
68
+ * Literal-specifier loader (see the header: a computed specifier would not
69
+ * be rewritten by the host loader). Dynamic, so an older host's missing
70
+ * subpath rejects instead of breaking module evaluation.
71
+ */
72
+ load: () => Promise<unknown>;
73
+ }
74
+
75
+ const HANDLE_SPECS: readonly HandleSpec[] = [
76
+ {
77
+ key: 'modelRoles',
78
+ exportName: 'cfgModelRoles',
79
+ load: () => import('@oh-my-pi/pi-coding-agent/config/model-settings'),
80
+ },
81
+ {
82
+ key: 'taskAgentModelOverrides',
83
+ exportName: 'cfgTaskAgentModelOverrides',
84
+ load: () => import('@oh-my-pi/pi-coding-agent/task/settings'),
85
+ },
86
+ {
87
+ key: 'providersMaxInFlight',
88
+ exportName: 'cfgProvidersMaxInFlightRequests',
89
+ load: () => import('@oh-my-pi/pi-coding-agent/session/settings'),
90
+ },
91
+ {
92
+ key: 'editModelVariants',
93
+ exportName: 'cfgEditModelVariants',
94
+ load: () => import('@oh-my-pi/pi-coding-agent/edit/settings'),
95
+ },
96
+ ];
97
+
98
+ async function loadHandle(spec: HandleSpec): Promise<HostSettingHandle | undefined> {
99
+ try {
100
+ const mod = (await spec.load()) as Record<string, unknown> | null | undefined;
101
+ const candidate = mod?.[spec.exportName] as Partial<HostSettingHandle> | undefined;
102
+ if (candidate && typeof candidate.override === 'function') return candidate as HostSettingHandle;
103
+ } catch {
104
+ /* subpath absent on this host (<= 18.1) — the path-based fallback applies */
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ let handlesPromise: Promise<HostSettingsHandles> | null = null;
110
+ let handlesForTest: HostSettingsHandles | null | undefined;
111
+
112
+ /** Test-only seam: inject fake handles, bypassing the dynamic imports. */
113
+ export function _setHandlesForTest(handles: HostSettingsHandles | null | undefined): void {
114
+ handlesForTest = handles;
115
+ }
116
+
117
+ /** Test-only seam: drop injected handles and any cached load. */
118
+ export function _resetForTest(): void {
119
+ handlesForTest = undefined;
120
+ handlesPromise = null;
121
+ }
122
+
123
+ /**
124
+ * Handles of the running host, loaded once per process. A host that lacks the
125
+ * subpaths yields `{}` — cached, so the failed imports run exactly once.
126
+ */
127
+ export function hostSettingsHandles(): Promise<HostSettingsHandles> {
128
+ if (handlesForTest !== undefined) return Promise.resolve(handlesForTest ?? {});
129
+ handlesPromise ??= (async () => {
130
+ const out: HostSettingsHandles = {};
131
+ await Promise.all(
132
+ HANDLE_SPECS.map(async (spec) => {
133
+ const handle = await loadHandle(spec);
134
+ if (handle) out[spec.key] = handle;
135
+ }),
136
+ );
137
+ return out;
138
+ })();
139
+ return handlesPromise;
140
+ }
141
+
142
+ /** Path-based Settings API on hosts <= 18.1. */
143
+ interface PathSettings {
144
+ override?(path: string, value: unknown): void;
145
+ clearOverride?(path: string): void;
146
+ }
147
+
148
+ /** Handle-clearing Settings API on hosts >= 18.3. */
149
+ interface HandleClearingSettings {
150
+ clearOverrideValue?(handle: unknown): void;
151
+ }
152
+
153
+ /** Shared write path: registry handle first, path-based `override` second. */
154
+ async function writeOverride(
155
+ handleKey: keyof HostSettingsHandles,
156
+ legacyPath: string,
157
+ settings: unknown,
158
+ value: unknown,
159
+ ): Promise<boolean> {
160
+ const handle = (await hostSettingsHandles())[handleKey];
161
+ if (handle) {
162
+ handle.override(settings, value);
163
+ return true;
164
+ }
165
+ const legacy = typeof settings === 'object' && settings !== null ? (settings as PathSettings) : null;
166
+ if (legacy && typeof legacy.override === 'function') {
167
+ legacy.override(legacyPath, value);
168
+ return true;
169
+ }
170
+ return false;
171
+ }
172
+
173
+ /**
174
+ * Clear the session-scoped `modelRoles` overlay.
175
+ *
176
+ * omp >= 18.3: `settings.clearOverrideValue(cfgModelRoles)` (falling back to
177
+ * `handle.clearOverride(settings)`). <= 18.1: `settings.clearOverride('modelRoles')`.
178
+ *
179
+ * Returns `false` when neither API is present — callers warn and keep writing
180
+ * (an absent *clear* API does not mean settings are unusable).
181
+ */
182
+ export async function clearModelRolesRuntimeOverride(settings: unknown): Promise<boolean> {
183
+ const handle = (await hostSettingsHandles()).modelRoles;
184
+ if (handle) {
185
+ const clearing = typeof settings === 'object' && settings !== null
186
+ ? (settings as HandleClearingSettings)
187
+ : null;
188
+ if (clearing && typeof clearing.clearOverrideValue === 'function') {
189
+ clearing.clearOverrideValue(handle);
190
+ return true;
191
+ }
192
+ if (typeof handle.clearOverride === 'function') {
193
+ handle.clearOverride(settings);
194
+ return true;
195
+ }
196
+ }
197
+ const legacy = typeof settings === 'object' && settings !== null ? (settings as PathSettings) : null;
198
+ if (legacy && typeof legacy.clearOverride === 'function') {
199
+ legacy.clearOverride('modelRoles');
200
+ return true;
201
+ }
202
+ return false;
203
+ }
204
+
205
+ /** Write the `task.agentModelOverrides` neutralization (handle API, else path API). */
206
+ export function applyAgentModelOverrides(
207
+ settings: unknown,
208
+ value: Record<string, string>,
209
+ ): Promise<boolean> {
210
+ return writeOverride('taskAgentModelOverrides', 'task.agentModelOverrides', settings, value);
211
+ }
212
+
213
+ /** Hot-apply the complete `providers.maxInFlightRequests` map (handle API, else path API). */
214
+ export function overrideProviderMaxInFlight(
215
+ settings: unknown,
216
+ value: Record<string, number>,
217
+ ): Promise<boolean> {
218
+ return writeOverride('providersMaxInFlight', 'providers.maxInFlightRequests', settings, value);
219
+ }
220
+
221
+ /** Pin `edit.modelVariants` at runtime (handle API, else path API). */
222
+ export function overrideEditModelVariants(
223
+ settings: unknown,
224
+ value: Record<string, string>,
225
+ ): Promise<boolean> {
226
+ return writeOverride('editModelVariants', 'edit.modelVariants', settings, value);
227
+ }
@@ -17,13 +17,16 @@
17
17
  * commit/tiny via enumeration fallbacks terminating at smol, task via
18
18
  * primary-session inheritance, plan as a graceful no-op, advisor via the
19
19
  * static slow chain, custom roles via the config layer. The landing sequence
20
- * is unchanged: clearOverride('modelRoles') → overrideModelRoles(payload) →
21
- * empty-string neutralization of persisted per-agent pins.
20
+ * is unchanged: clear the modelRoles session overlay → overrideModelRoles(payload)
21
+ * → empty-string neutralization of persisted per-agent pins (the clear and the
22
+ * neutralization go through `lib/host-settings-adapter.ts`: registry handles on
23
+ * omp >= 18.3, path-based settings calls before that).
22
24
  */
23
25
  import { type ModelRole } from '@oh-my-pi/pi-coding-agent/config/model-roles';
24
26
  import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
25
27
  import type { SelectionContext } from '../index.js';
26
28
  import type { TierName } from './model-tiers.js';
29
+ import { applyAgentModelOverrides, clearModelRolesRuntimeOverride } from './host-settings-adapter.js';
27
30
  import { OPSX_AGENTS } from './unified-config.js';
28
31
 
29
32
  /** Per-agent names written into `task.agentModelOverrides` neutralization. */
@@ -138,8 +141,8 @@ export function resolveOmpRoleTier(
138
141
  * Build the minimal write-set overlay plan (design D5): for each role in
139
142
  * `OMP_WRITE_SET_ROLES`, resolve its tier (or `skip`), apply capability
140
143
  * gating, then assign the single picked model for the tier. Roles without a
141
- * pick are recorded with a reason and omitted from the payload — after
142
- * `clearOverride` they fall back to the user's config layer (or stay
144
+ * pick are recorded with a reason and omitted from the payload — after the
145
+ * pre-clear they fall back to the user's config layer (or stay
143
146
  * unconfigured). Roles outside the write set are never iterated.
144
147
  */
145
148
  export function buildRoleOverridePlan(input: BuildRoleOverridePlanInput): RoleOverridePlan {
@@ -226,11 +229,26 @@ export function agentOverrideNeutralization(): Record<string, string> {
226
229
  return neutral;
227
230
  }
228
231
 
229
- /** Minimal Settings surface this module needs (host-process `pi.pi.settings`). */
232
+ /**
233
+ * Minimal Settings surface this module needs (host-process `pi.pi.settings`).
234
+ *
235
+ * Deliberately a union of both host generations: `overrideModelRoles` (the
236
+ * session overlay write) exists in every version, while clearing and the
237
+ * path-based writes moved from `clearOverride(path)` / `override(path, value)`
238
+ * (<= 18.1) to registry handles (`clearOverrideValue(handle)`, see
239
+ * `lib/host-settings-adapter.ts`) in 18.3. Optional members keep every
240
+ * generation assignable; `applyRoleModelOverrides` never assumes a method
241
+ * exists.
242
+ */
230
243
  export interface RoleSettings {
231
- clearOverride(key: string): void;
244
+ /** Session overlay write (limited to `{smol, default, slow, vision}` by the plan). */
232
245
  overrideModelRoles(roles: Record<string, string>): void;
233
- override(key: string, value: unknown): void;
246
+ /** omp <= 18.1: clear the `modelRoles` runtime overlay by path. */
247
+ clearOverride?(key: string): void;
248
+ /** omp >= 18.3: clear a runtime overlay through its registry handle. */
249
+ clearOverrideValue?(handle: unknown): void;
250
+ /** omp <= 18.1: path-based runtime override. */
251
+ override?(key: string, value: unknown): void;
234
252
  }
235
253
 
236
254
  /**
@@ -243,25 +261,36 @@ export interface RoleSettings {
243
261
  * picked model are omitted and fall back to the config layer after the
244
262
  * clear) → neutralize persisted per-agent pins with empty strings. Never writes
245
263
  * disk; the runtime overlay is released with the session.
264
+ *
265
+ * Async because clearing/writing goes through `lib/host-settings-adapter.ts`,
266
+ * whose host handles are loaded by dynamic import; every call site awaits.
267
+ * Clearing and the neutralization write degrade to a warn when this host
268
+ * offers neither API generation — the missing *clear* is not a reason to skip
269
+ * the overlay write itself (the plan payload still applies).
246
270
  */
247
- export function applyRoleModelOverrides(
271
+ export async function applyRoleModelOverrides(
248
272
  settings: RoleSettings | null | undefined,
249
273
  sel: SelectionContext,
250
274
  warn: (msg: string) => void = () => {},
251
- ): void {
275
+ ): Promise<void> {
252
276
  if (!settings) {
253
277
  warn('[omp-opsx-addon] pi.pi.settings unavailable; model role overrides skipped');
254
278
  return;
255
279
  }
256
- settings.clearOverride('modelRoles');
280
+ if (!(await clearModelRolesRuntimeOverride(settings))) {
281
+ warn('[omp-opsx-addon] no settings API to clear the modelRoles session overlay (host handles and path-based clearOverride both unavailable); writing the overlay without a pre-clear — stale roles from a previous selection may linger');
282
+ }
257
283
  settings.overrideModelRoles(sel.roleOverrides);
258
- settings.override('task.agentModelOverrides', agentOverrideNeutralization());
284
+ const neutralized = await applyAgentModelOverrides(settings, agentOverrideNeutralization());
285
+ if (!neutralized) {
286
+ warn('[omp-opsx-addon] no settings API for task.agentModelOverrides (host handles and path-based override both unavailable)');
287
+ }
259
288
  const applied = Object.keys(sel.roleOverrides);
260
289
  const skippedText = sel.skippedRoles.length > 0
261
290
  ? `; skipped: ${sel.skippedRoles.map((s) => `${s.role}(${s.reason})`).join(', ')}`
262
291
  : '';
263
292
  const skipWarns = sel.skippedRoles.filter((s) => s.reason !== 'skip');
264
- warn(`[omp-opsx-addon] modelRoles applied (${applied.length} roles: ${applied.join(', ') || 'none'})${skippedText}; per-agent pins neutralized`);
293
+ warn(`[omp-opsx-addon] modelRoles applied (${applied.length} roles: ${applied.join(', ') || 'none'})${skippedText}; per-agent pins ${neutralized ? 'neutralized' : 'NOT neutralized'}`);
265
294
  for (const s of skipWarns) {
266
295
  if (s.reason === 'capability') {
267
296
  warn(`[omp-opsx-addon] model role "${s.role}": no capable model in selection; skipped (role keeps configured/default value; change selector/allowlist or set model_role_tiers.${s.role}: skip)`);
@@ -130,7 +130,11 @@ export function candidateQuota(
130
130
  const win = health.windows.find((w) => w.id === bucket) ?? health.windows.find((w) => w.id === 'other');
131
131
  const ondemand = health.windows.find((w) => w.id === 'ondemand');
132
132
  if (!win) {
133
- return { exhausted: health.exhausted, remainingFraction: health.worstRemainingFraction };
133
+ // D6: provider-level exhaustion was relaxed for slow-pool cursor; the
134
+ // !win fallback must NOT get more permissive than pre-change behavior.
135
+ const cls = health.cursorLimitStatus;
136
+ const slowPoolAlive = !!cls && (cls.isInSlowPool || cls.grantsRemainingCents > 0);
137
+ return { exhausted: health.exhausted || slowPoolAlive, remainingFraction: health.worstRemainingFraction };
134
138
  }
135
139
  if (win.exhausted) {
136
140
  if (ondemand && !ondemand.exhausted) {
@@ -56,6 +56,7 @@ import { homedir } from 'os';
56
56
  import type { UsageReport } from '@oh-my-pi/pi-ai';
57
57
  import type { AuthStorageLike, ProviderHealth, DirectFetcher, ApiKeyResolver } from './usage-resolver.js';
58
58
  import { resolveProviderReports, buildHealthMap, canonicalizeProvider, listDetectableProviders } from './usage-resolver.js';
59
+ import { fetchCursorLimitStatus, enhanceCursorReport } from './cursor-limit-status.js';
59
60
  import { RedisRelay } from './usage-redis-probe.js';
60
61
  import type { RedisTransport } from './usage-redis-client.js';
61
62
  import type { UsageBucket } from './usage-estimator.js';
@@ -979,8 +980,16 @@ async function fetchProvider(
979
980
  lastFetchedByProvider.set(canonical, fetchedAt);
980
981
  // Replace (or insert) just this provider's report; keep all others.
981
982
  const byId = new Map(cachedReports.map((r) => [r.provider, r]));
982
- const newReport = reports.find((r) => r.provider === canonical);
983
+ let newReport = reports.find((r) => r.provider === canonical);
983
984
  if (newReport) {
985
+ // Cursor slow-pool/grants enhancement (D1): rides the existing fetch
986
+ // cadence; failure is fail-soft inside fetchCursorLimitStatus and must
987
+ // NOT re-mark dirty (the outer catch only guards the report fetch).
988
+ // On null the report lands as-is — current behavior (exhausted).
989
+ if (canonical === 'cursor') {
990
+ const limitStatus = await fetchCursorLimitStatus();
991
+ if (limitStatus) newReport = enhanceCursorReport(newReport, limitStatus);
992
+ }
984
993
  byId.set(canonical, newReport);
985
994
  // Fresh report landed → the provider's health is CONFIRMED.
986
995
  unconfirmedProviders.delete(canonical);
@@ -42,6 +42,8 @@ export interface Painter {
42
42
  pct(p: number): string;
43
43
  /** Exhausted-window marker text, error-colored. */
44
44
  exhausted(text: string): string;
45
+ /** Warning / soft-degraded marker (e.g. Cursor ⚠降速), warning-colored. */
46
+ warning(text: string): string;
45
47
  /** Inter-column separator, already dyed incl. flanking spaces (3 cells). */
46
48
  sep: string;
47
49
  /** Provider header. `brandAnsi` is a raw brand-color SGR (or "" when none). */
@@ -101,6 +103,7 @@ export const ansiPainter: Painter = {
101
103
  return c(col === 'error' ? X : col === 'warning' ? Y : G, pctValue(p));
102
104
  },
103
105
  exhausted: (text) => c(X, text),
106
+ warning: (text) => c(Y, text),
104
107
  sep: ` ${D}│${R} `,
105
108
  header: (label, brandAnsi) => c(brandAnsi ? BOLD + brandAnsi : BOLD, label),
106
109
  balance: (text) => c(WHITE, text),
@@ -113,6 +116,7 @@ export function themePainter(theme: PainterTheme): Painter {
113
116
  return {
114
117
  pct: (p) => theme.fg(pctColor(p), pctValue(p)),
115
118
  exhausted: (text) => theme.fg('error', text),
119
+ warning: (text) => theme.fg('warning', text),
116
120
  sep: ` ${theme.fg('dim', '│')} `,
117
121
  header: (label, brandAnsi) => (brandAnsi ? c(brandAnsi, label) : theme.fg('accent', label)),
118
122
  balance: (text) => theme.fg('text', text),
@@ -135,6 +139,7 @@ const PROVIDER: Record<string, { label: string; color: string }> = {
135
139
  'kimi-code': { label: 'Kimi', color: CYAN },
136
140
  'minimax-code-cn': { label: 'MiniMax', color: X },
137
141
  'ark-coding-plan': { label: '火山方舟', color: CYAN },
142
+ 'alibaba-token-plan': { label: '千问云', color: PURPLE },
138
143
  deepseek: { label: 'DeepSeek', color: BLUE },
139
144
  cursor: { label: 'Cursor', color: CYAN },
140
145
  'opencode-go': { label: 'OpenCode', color: G },
@@ -349,6 +354,30 @@ export const buildColumn = (r: UsageReport, painter: Painter = ansiPainter, load
349
354
  // column visible rather than dropping a logged-in provider.
350
355
  return isCursor ? [header, painter.balance('—')] : undefined;
351
356
  }
357
+ // Cursor slow-pool / grants markers (provider not exhausted — chips path).
358
+ // Status lives on its own line: bucket chips first, then ⚠降速 / Grants /
359
+ // reset countdown. No cursorLimitStatus note → single chips line, exactly
360
+ // as before the change.
361
+ if (isCursor && health.cursorLimitStatus) {
362
+ const cls = health.cursorLimitStatus;
363
+ const status: string[] = [];
364
+ if (cls.isInSlowPool) status.push(painter.warning('⚠降速'));
365
+ if (cls.grantsRemainingCents > 0) {
366
+ status.push(painter.balance(`Grants $${(cls.grantsRemainingCents / 100).toFixed(2)}`));
367
+ }
368
+ // Included buckets reset with the billing period — surface the soonest
369
+ // reset so a slow-pooled user can see when quota revives. Reuses the
370
+ // existing exhausted-path formatter; hidden when no resetsAt.
371
+ let soonest: number | undefined;
372
+ for (const l of r.limits) {
373
+ const at = l.window?.resetsAt;
374
+ if (typeof at !== 'number') continue;
375
+ soonest = soonest === undefined ? at : Math.min(soonest, at);
376
+ }
377
+ if (soonest !== undefined) status.push(`${resetIn(soonest)}重置`);
378
+ if (status.length === 0) return [header, parts.join(' · ')];
379
+ return [header, parts.join(' · '), status.join(' · ')];
380
+ }
352
381
  return [header, parts.join(' · ')];
353
382
  };
354
383
 
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { UsageReport, UsageStatus } from '@oh-my-pi/pi-ai';
16
+ import { parseCursorLimitStatusNote, type CursorLimitStatus } from './cursor-limit-status.js';
16
17
 
17
18
  export type ApiKeyResolver = (provider: string) => Promise<string | undefined>;
18
19
 
@@ -100,6 +101,8 @@ export interface ProviderHealth {
100
101
  exhaustedWindows: string[];
101
102
  fetchedAt: number;
102
103
  notes?: string[];
104
+ /** Cursor slow-pool / grants echo (from `cursorLimitStatus=` note); render + selector consume this. */
105
+ cursorLimitStatus?: CursorLimitStatus;
103
106
  }
104
107
 
105
108
  const HEALTHY_REMAINING_THRESHOLD = 0.1;
@@ -262,6 +265,8 @@ export function summarizeReport(report: UsageReport): ProviderHealth {
262
265
 
263
266
  let exhausted = false;
264
267
  let worstRemaining: number | undefined;
268
+ const limitStatus =
269
+ report.provider === 'cursor' ? parseCursorLimitStatusNote(report.notes) : undefined;
265
270
 
266
271
  if (policy === 'bucket') {
267
272
  const byId = new Map(windows.map((w) => [w.id, w]));
@@ -277,6 +282,17 @@ export function summarizeReport(report: UsageReport): ProviderHealth {
277
282
  if (w.remaining === undefined) continue;
278
283
  if (worstRemaining === undefined || w.remaining > worstRemaining) worstRemaining = w.remaining;
279
284
  }
285
+ // D3: slow-pool / grants keep provider usable (warning) instead of exhausted.
286
+ if (
287
+ exhausted &&
288
+ limitStatus &&
289
+ (limitStatus.isInSlowPool || limitStatus.grantsRemainingCents > 0)
290
+ ) {
291
+ exhausted = false;
292
+ status = 'warning';
293
+ } else if (!exhausted && limitStatus?.isInSlowPool && status === 'ok') {
294
+ status = 'warning';
295
+ }
280
296
  } else if (policy === 'balance') {
281
297
  const win = windows[0];
282
298
  exhausted = windows.some((w) => w.exhausted) || (win?.remaining !== undefined && win.remaining <= 0);
@@ -315,6 +331,7 @@ export function summarizeReport(report: UsageReport): ProviderHealth {
315
331
  exhaustedWindows,
316
332
  fetchedAt: report.fetchedAt,
317
333
  notes: report.notes,
334
+ cursorLimitStatus: limitStatus,
318
335
  };
319
336
  }
320
337
 
@@ -327,7 +344,11 @@ export function healthCacheKey(health: Map<string, ProviderHealth> | null | unde
327
344
  const h = health.get(p)!;
328
345
  const wins = h.windows.map((w) => `${w.id}:${w.exhausted ? 1 : 0}:${w.remaining ?? ''}`).join(',');
329
346
  const ab = (h.autoBucketModels ?? []).join('+');
330
- return `${p}=${h.exhausted ? 1 : 0};${wins};${ab}`;
347
+ // Cursor only: boolean bits (not raw cents) so grants churn does not invalidate selection cache.
348
+ const cls = h.cursorLimitStatus
349
+ ? `;sp=${h.cursorLimitStatus.isInSlowPool ? 1 : 0};g=${h.cursorLimitStatus.grantsRemainingCents > 0 ? 1 : 0}`
350
+ : '';
351
+ return `${p}=${h.exhausted ? 1 : 0};${wins};${ab}${cls}`;
331
352
  })
332
353
  .join('|');
333
354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genee/omp-opsx-addon",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "Pi Extension: OpenSpec workflow orchestration - coder/reviewer/planner agents, session title & progress",
6
6
  "main": "./index.ts",
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Ambient declarations for the host Settings registry-handle subpaths
3
+ * (change: omp-18.3-settings-api-compat).
4
+ *
5
+ * `lib/host-settings-adapter.ts` must import these four modules with LITERAL
6
+ * specifiers: the host's legacy-pi-compat loader rewrites extension sources
7
+ * textually (it collects string-literal import/import() references and replaces
8
+ * them with absolute host paths), so a computed specifier would silently resolve
9
+ * against this package's own `@oh-my-pi/pi-coding-agent` dev dependency
10
+ * (18.1.2), which ships none of these subpaths.
11
+ *
12
+ * Literal specifiers, however, are resolved by `tsc` at build time — hence these
13
+ * declarations. Their shape mirrors the host's `Setting` handle: `override` is
14
+ * the write path, `clearOverride` is the (optional) clear path that
15
+ * `Settings.clearOverrideValue(handle)` performs.
16
+ */
17
+
18
+ declare module '@oh-my-pi/pi-coding-agent/config/model-settings' {
19
+ /** Registered setting handle `modelRoles`. */
20
+ export const cfgModelRoles: {
21
+ override(settings: unknown, value: unknown): void;
22
+ clearOverride?(settings: unknown): void;
23
+ };
24
+ }
25
+
26
+ declare module '@oh-my-pi/pi-coding-agent/task/settings' {
27
+ /** Registered setting handle `task.agentModelOverrides`. */
28
+ export const cfgTaskAgentModelOverrides: {
29
+ override(settings: unknown, value: unknown): void;
30
+ clearOverride?(settings: unknown): void;
31
+ };
32
+ }
33
+
34
+ declare module '@oh-my-pi/pi-coding-agent/session/settings' {
35
+ /** Registered setting handle `providers.maxInFlightRequests`. */
36
+ export const cfgProvidersMaxInFlightRequests: {
37
+ override(settings: unknown, value: unknown): void;
38
+ clearOverride?(settings: unknown): void;
39
+ };
40
+ }
41
+
42
+ declare module '@oh-my-pi/pi-coding-agent/edit/settings' {
43
+ /** Registered setting handle `edit.modelVariants`. */
44
+ export const cfgEditModelVariants: {
45
+ override(settings: unknown, value: unknown): void;
46
+ clearOverride?(settings: unknown): void;
47
+ };
48
+ }