@genee/omp-opsx-addon 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +8 -4
- package/lib/concurrency-multiproc.worker.ts +7 -0
- package/lib/cursor-auth.ts +34 -18
- package/lib/cursor-limit-status.ts +250 -0
- package/lib/host-settings-adapter.ts +61 -0
- package/lib/model-roles.ts +17 -5
- package/lib/model-selector.ts +5 -1
- package/lib/pick-model-render.ts +29 -21
- package/lib/unified-config.ts +16 -18
- package/lib/usage-poller.ts +14 -4
- package/lib/usage-render.ts +42 -0
- package/lib/usage-resolver.ts +70 -8
- package/package.json +4 -2
- package/types/host-settings-modules.d.ts +6 -0
package/index.ts
CHANGED
|
@@ -112,8 +112,9 @@ import {
|
|
|
112
112
|
roleKindLabel,
|
|
113
113
|
tierLabel,
|
|
114
114
|
} from './lib/pick-model-render.js';
|
|
115
|
+
import { primeSymbolPresetReader } from './lib/host-settings-adapter.js';
|
|
115
116
|
import { buildStaticPrompt, buildDynamicPrompt } from './lib/system-prompt.js';
|
|
116
|
-
import { type ProviderHealth, canonicalizeProvider, listDetectableProviders, healthCacheKey } from './lib/usage-resolver.js';
|
|
117
|
+
import { type ProviderHealth, canonicalizeProvider, listDetectableProviders, healthCacheKey, loggedInProviderIds } from './lib/usage-resolver.js';
|
|
117
118
|
import { readOpsxSettings, resolveManagedProviders, type ResolvedOpsxConfig, type AgentValue, OPSX_AGENTS } from './lib/unified-config.js';
|
|
118
119
|
import {
|
|
119
120
|
createConcurrencyController,
|
|
@@ -905,6 +906,9 @@ export default (pi: ExtensionAPI) => {
|
|
|
905
906
|
// Route plugin logging through the host logger (rolling file) instead of
|
|
906
907
|
// console.*, which would corrupt the TUI. Injected before any log path runs.
|
|
907
908
|
setOpsxLogger(pi.logger);
|
|
909
|
+
// Prime the 18.5+ `cfgSymbolPreset` read handle (fire-and-forget lazy
|
|
910
|
+
// import); /pick-model renders read it synchronously via resolveSymbols.
|
|
911
|
+
primeSymbolPresetReader();
|
|
908
912
|
// Runtime edit-variant pin for deepseek-v4-flash. The host hard-maps the
|
|
909
913
|
// model to the sloppy §/» marker grammar (it misreads hashline ranges),
|
|
910
914
|
// but the model repeatedly breaks sloppy control lines too — retry loops
|
|
@@ -1305,10 +1309,10 @@ export default (pi: ExtensionAPI) => {
|
|
|
1305
1309
|
/* modelRegistry unavailable → fall back to auth.list() + detectable */
|
|
1306
1310
|
}
|
|
1307
1311
|
try {
|
|
1308
|
-
|
|
1312
|
+
const loggedInIds = loggedInProviderIds(auth);
|
|
1313
|
+
if (loggedInIds) {
|
|
1309
1314
|
authAvailable = true;
|
|
1310
|
-
const
|
|
1311
|
-
const ids = Array.isArray(list) ? list : [];
|
|
1315
|
+
const ids = Array.isArray(loggedInIds) ? loggedInIds : [];
|
|
1312
1316
|
loggedIn = [...new Set([...loggedIn, ...ids.map(canonicalizeProvider)])];
|
|
1313
1317
|
}
|
|
1314
1318
|
} catch {
|
|
@@ -59,6 +59,13 @@ import {
|
|
|
59
59
|
type WriterFileSystemPort,
|
|
60
60
|
} from './concurrency-writer.js';
|
|
61
61
|
import { RedisClient } from './usage-redis-client.js';
|
|
62
|
+
import { _setHandlesForTest as setHostHandles } from './host-settings-adapter.js';
|
|
63
|
+
|
|
64
|
+
// This harness records hot-applies through the plugin's path-based
|
|
65
|
+
// SettingsPort contract (`override(path, value)`); neutralize the host
|
|
66
|
+
// registry handles for the whole worker process so the adapter's 18.5
|
|
67
|
+
// handle path cannot intercept those recorded calls.
|
|
68
|
+
setHostHandles({});
|
|
62
69
|
|
|
63
70
|
// ── wire protocol ───────────────────────────────────────────────────────────
|
|
64
71
|
|
package/lib/cursor-auth.ts
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
}
|
|
@@ -118,6 +118,8 @@ export function _setHandlesForTest(handles: HostSettingsHandles | null | undefin
|
|
|
118
118
|
export function _resetForTest(): void {
|
|
119
119
|
handlesForTest = undefined;
|
|
120
120
|
handlesPromise = null;
|
|
121
|
+
symbolPresetHandle = undefined;
|
|
122
|
+
symbolPresetLoadStarted = false;
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
/**
|
|
@@ -139,6 +141,65 @@ export function hostSettingsHandles(): Promise<HostSettingsHandles> {
|
|
|
139
141
|
return handlesPromise;
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
/** Minimal read surface of a host registry `Setting` handle. */
|
|
145
|
+
interface SettingReaderHandle {
|
|
146
|
+
get(settings: unknown): unknown;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Read-only handle for the host `symbolPreset` setting.
|
|
151
|
+
*
|
|
152
|
+
* omp 18.5.0 moved `pi.pi.settings` from a key-based handle
|
|
153
|
+
* (`settings.get('symbolPreset')`) to the raw `Settings` instance, where
|
|
154
|
+
* reads go through registered `Setting` objects — `cfgSymbolPreset` on
|
|
155
|
+
* `modes/settings`. As with the write handles above, the subpath is loaded
|
|
156
|
+
* lazily via a LITERAL dynamic import and primed fire-and-forget at
|
|
157
|
+
* extension init; the sync reader below falls back to the legacy key read
|
|
158
|
+
* until (and on hosts where) the handle never lands.
|
|
159
|
+
*/
|
|
160
|
+
let symbolPresetHandle: SettingReaderHandle | undefined;
|
|
161
|
+
let symbolPresetLoadStarted = false;
|
|
162
|
+
|
|
163
|
+
/** Kick off the lazy `modes/settings` import (no await; sync reads follow). */
|
|
164
|
+
export function primeSymbolPresetReader(): void {
|
|
165
|
+
if (symbolPresetLoadStarted) return;
|
|
166
|
+
symbolPresetLoadStarted = true;
|
|
167
|
+
void import('@oh-my-pi/pi-coding-agent/modes/settings')
|
|
168
|
+
.then((mod: Record<string, unknown> | null | undefined) => {
|
|
169
|
+
const candidate = mod?.cfgSymbolPreset as Partial<SettingReaderHandle> | undefined;
|
|
170
|
+
if (candidate && typeof candidate.get === 'function') {
|
|
171
|
+
symbolPresetHandle = candidate as SettingReaderHandle;
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
.catch(() => {
|
|
175
|
+
/* subpath absent on this host — the legacy key read applies */
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Synchronous `symbolPreset` read across host generations: the 18.5+
|
|
181
|
+
* registry handle first, then the <= 18.4 key-based settings object.
|
|
182
|
+
* `undefined` when neither shape can answer — callers keep their fallback.
|
|
183
|
+
*/
|
|
184
|
+
export function readSymbolPreset(settings: unknown): unknown {
|
|
185
|
+
if (symbolPresetHandle) {
|
|
186
|
+
try {
|
|
187
|
+
return symbolPresetHandle.get(settings);
|
|
188
|
+
} catch {
|
|
189
|
+
/* not a host Settings instance (e.g. a test stub) — try the
|
|
190
|
+
* legacy key read below before giving up */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const legacy = typeof settings === 'object' && settings !== null
|
|
194
|
+
? (settings as { get?(key: string): unknown })
|
|
195
|
+
: null;
|
|
196
|
+
try {
|
|
197
|
+
return legacy?.get?.('symbolPreset');
|
|
198
|
+
} catch {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
142
203
|
/** Path-based Settings API on hosts <= 18.1. */
|
|
143
204
|
interface PathSettings {
|
|
144
205
|
override?(path: string, value: unknown): void;
|
package/lib/model-roles.ts
CHANGED
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
* `model_role_tiers`), feeds it the single picked model for that tier, and
|
|
14
14
|
* records skip reasons (`skip` config sentinel, capability gate such as
|
|
15
15
|
* vision/image, or no picked model for the tier). Roles outside the write set
|
|
16
|
-
* resolve through OMP native chains
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* resolve through OMP native chains (omp 18.5.0 inheritance table):
|
|
17
|
+
* tiny/memory via the always-on chain (tiny→smol, memory→tiny→smol),
|
|
18
|
+
* commit via enumeration fallbacks terminating at smol, task via
|
|
19
|
+
* primary-session inheritance, plan as a graceful no-op, advisor via slow
|
|
20
|
+
* only when slow is explicitly configured, custom roles via the config
|
|
21
|
+
* layer. The landing sequence
|
|
20
22
|
* is unchanged: clear the modelRoles session overlay → overrideModelRoles(payload)
|
|
21
23
|
* → empty-string neutralization of persisted per-agent pins (the clear and the
|
|
22
24
|
* neutralization go through `lib/host-settings-adapter.ts`: registry handles on
|
|
@@ -50,18 +52,28 @@ export const ROLE_TO_OMP_ALIAS: Record<string, string> = {
|
|
|
50
52
|
* table to cover every built-in role. Entries outside the write set stay
|
|
51
53
|
* relevant as the tier-key source for role-scope slot resolution
|
|
52
54
|
* (`scopedTierSlots`), which may point any scope role at its slot.
|
|
55
|
+
*
|
|
56
|
+
* omp 18.5.0 role set: `designer` was removed; `memory` plus the kind roles
|
|
57
|
+
* (`image`/`web`/`speech`/`dictation`/`judge`) were added. Kind roles are
|
|
58
|
+
* never in the write set — their entries exist only so the exhaustive
|
|
59
|
+
* `satisfies` clause holds and `scopedTierSlots` finds a tier key.
|
|
53
60
|
*/
|
|
54
61
|
export const OMP_ROLE_TO_TIER = {
|
|
55
62
|
tiny: 'tiny',
|
|
56
63
|
smol: 'mid',
|
|
57
64
|
task: 'mid',
|
|
65
|
+
memory: 'mid',
|
|
58
66
|
commit: 'low',
|
|
59
67
|
default: 'high',
|
|
60
|
-
designer: 'high',
|
|
61
68
|
vision: 'high',
|
|
62
69
|
slow: 'top',
|
|
63
70
|
plan: 'top',
|
|
64
71
|
advisor: 'top',
|
|
72
|
+
image: 'mid',
|
|
73
|
+
web: 'mid',
|
|
74
|
+
speech: 'mid',
|
|
75
|
+
dictation: 'mid',
|
|
76
|
+
judge: 'top',
|
|
65
77
|
} satisfies Record<ModelRole, TierName>;
|
|
66
78
|
|
|
67
79
|
/**
|
package/lib/model-selector.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/lib/pick-model-render.ts
CHANGED
|
@@ -20,14 +20,18 @@
|
|
|
20
20
|
* Everything here is a pure function; index.ts assembles the parts and
|
|
21
21
|
* calls `renderPickModelReport` once per command output.
|
|
22
22
|
*/
|
|
23
|
-
import { SYMBOL_PRESETS, isValidSymbolPreset } from '@oh-my-pi/pi-
|
|
23
|
+
import { SYMBOL_PRESETS, isValidSymbolPreset } from '@oh-my-pi/pi-tui/theme/symbols';
|
|
24
24
|
import type { AgentDefinition } from '@oh-my-pi/pi-coding-agent/task/types';
|
|
25
25
|
import { providerDisplayName } from './provider-variants.js';
|
|
26
|
+
import { readSymbolPreset } from './host-settings-adapter.js';
|
|
26
27
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Settings value passed to the `symbolPreset` read (host `pi.pi.settings`):
|
|
30
|
+
* the key-based handle on hosts <= 18.4; the 18.5+ raw `Settings` instance is
|
|
31
|
+
* read through the registry handle in the adapter. Both shapes are
|
|
32
|
+
* duck-typed there, so the parameter is an opaque `unknown`.
|
|
33
|
+
*/
|
|
34
|
+
export type SymbolSettings = unknown;
|
|
31
35
|
|
|
32
36
|
/** Internal glyph set used by every pick-model output (design D2). */
|
|
33
37
|
export interface RenderSymbols {
|
|
@@ -51,14 +55,13 @@ export interface RenderSymbols {
|
|
|
51
55
|
* Read the current preset per render; undefined/invalid — or a settings
|
|
52
56
|
* read that throws (early startup / partial init) — falls back to unicode
|
|
53
57
|
* without ever surfacing an error into a command output.
|
|
58
|
+
*
|
|
59
|
+
* `settings` accepts both host generations: the key-based handle (<= 18.4)
|
|
60
|
+
* and the raw `Settings` instance (18.5+, read through the registry
|
|
61
|
+
* `cfgSymbolPreset` handle primed by `primeSymbolPresetReader` in index.ts).
|
|
54
62
|
*/
|
|
55
63
|
export function resolveSymbols(settings?: SymbolSettings | null): RenderSymbols {
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
raw = settings?.get('symbolPreset');
|
|
59
|
-
} catch {
|
|
60
|
-
raw = undefined;
|
|
61
|
-
}
|
|
64
|
+
const raw = readSymbolPreset(settings);
|
|
62
65
|
const preset = typeof raw === 'string' && isValidSymbolPreset(raw) ? raw : 'unicode';
|
|
63
66
|
const map = SYMBOL_PRESETS[preset];
|
|
64
67
|
return {
|
|
@@ -131,12 +134,14 @@ export interface AgentModelSource {
|
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
/**
|
|
134
|
-
* Classify an agent by its frontmatter `model` patterns (design D4 table
|
|
135
|
-
* Missing / `default` / `@default` / `@task` →
|
|
136
|
-
* model; `@smol`/`@slow` follow the written
|
|
137
|
-
*
|
|
138
|
-
* (
|
|
139
|
-
*
|
|
137
|
+
* Classify an agent by its frontmatter `model` patterns (design D4 table,
|
|
138
|
+
* omp 18.5.0 semantics). Missing / `default` / `@default` / `@task` →
|
|
139
|
+
* inherit the primary session model; `@smol`/`@slow` follow the written
|
|
140
|
+
* roles; `@tiny` follows via the always-on smol inheritance, `@memory`
|
|
141
|
+
* via tiny→smol (host inheritance table); `@advisor` follows the slow
|
|
142
|
+
* chain only while slow is explicitly configured (pick-model's write set
|
|
143
|
+
* covers slow, so after an apply it follows); literal patterns are pins;
|
|
144
|
+
* other `@role` values stay on the config layer.
|
|
140
145
|
*/
|
|
141
146
|
export function classifyAgentModelSource(model?: string[]): AgentModelSource {
|
|
142
147
|
const first = model?.find((m) => typeof m === 'string' && m.trim() !== '')?.trim();
|
|
@@ -146,11 +151,14 @@ export function classifyAgentModelSource(model?: string[]): AgentModelSource {
|
|
|
146
151
|
if (first === '@smol' || first === '@slow') {
|
|
147
152
|
return { label: `role 链 ${first}`, follows: true };
|
|
148
153
|
}
|
|
149
|
-
if (first === '@
|
|
150
|
-
return { label: 'role 链 @
|
|
154
|
+
if (first === '@tiny') {
|
|
155
|
+
return { label: 'role 链 @tiny(经 smol 继承)', follows: true };
|
|
156
|
+
}
|
|
157
|
+
if (first === '@memory') {
|
|
158
|
+
return { label: 'role 链 @memory(经 tiny→smol 继承)', follows: true };
|
|
151
159
|
}
|
|
152
|
-
if (first === '@
|
|
153
|
-
return { label:
|
|
160
|
+
if (first === '@advisor') {
|
|
161
|
+
return { label: 'role 链 @advisor(经 slow 继承,slow 配置时)', follows: true };
|
|
154
162
|
}
|
|
155
163
|
if (first.startsWith('@')) {
|
|
156
164
|
return { label: `自定义 role ${first}`, follows: false };
|
package/lib/unified-config.ts
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Accepted values for each dispatch agent:
|
|
11
11
|
* - `'auto'` — let the model-selector pick based on tier + usage.
|
|
12
|
-
* - An OMP role alias
|
|
13
|
-
* `
|
|
12
|
+
* - An OMP role alias (any entry of the host `MODEL_ROLE_IDS` list —
|
|
13
|
+
* currently `default`, `smol`, `slow`, `vision`, `plan`, `commit`,
|
|
14
|
+
* `tiny`, `memory`, `task`, `advisor`, `image`, `web`, `speech`,
|
|
15
|
+
* `dictation`, `judge`) — expanded by the
|
|
14
16
|
* OMP `modelRoles` setting (not by this plugin) to a concrete model.
|
|
15
17
|
* - A concrete model id: `gpt-5`, `claude-opus-4-6`.
|
|
16
18
|
* - A provider-scoped selector: `anthropic/claude-opus-4-6`,
|
|
@@ -29,7 +31,7 @@
|
|
|
29
31
|
* rename to `agent_tiers:`. Affected agents fall back to `auto`.
|
|
30
32
|
* - `model_role_tiers`: `omp_role → tier_name | 'skip'` map controlling
|
|
31
33
|
* which selector tier each OMP model role (default/smol/slow/vision/plan/
|
|
32
|
-
*
|
|
34
|
+
* commit/tiny/memory/task/advisor + kind roles + custom roles) follows after
|
|
33
35
|
* /pick-model; `skip` excludes the role from the session overlay.
|
|
34
36
|
* Disambiguation: those are the OMP host *model roles*, not the four
|
|
35
37
|
* opsx dispatch agents configured via `agents` / `agent_tiers`.
|
|
@@ -50,21 +52,17 @@ import { canonicalizeProvider } from './usage-resolver.js';
|
|
|
50
52
|
import { DEFAULT_MIN_SAMPLES, SPEED_MODES, type SpeedAwareConfig, type SpeedMode } from './model-speed.js';
|
|
51
53
|
import { QUALITY_PREFERENCES, type QualityPreference, type RoutingConfig } from './complexity-router.js';
|
|
52
54
|
import { DEFAULT_THRESHOLD, DEFAULT_WINDOW, type StallEscalationConfig } from './stall-detector.js';
|
|
55
|
+
import { MODEL_ROLE_IDS } from '@oh-my-pi/pi-coding-agent/config/model-roles';
|
|
53
56
|
import { DEFAULT_WEIGHT, type ContinuityGuardConfig } from './continuity-guard.js';
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
'commit',
|
|
64
|
-
'tiny',
|
|
65
|
-
'task',
|
|
66
|
-
'advisor',
|
|
67
|
-
]);
|
|
57
|
+
/**
|
|
58
|
+
* OMP-documented role aliases. Plugins MAY pass these to
|
|
59
|
+
* `task(..., model: '@alias')`. Derived from the host's canonical
|
|
60
|
+
* `MODEL_ROLE_IDS` list (omp 18.5.0: designer removed; memory + kind roles
|
|
61
|
+
* added), so the accepted-alias set cannot drift from the running host.
|
|
62
|
+
*/
|
|
63
|
+
export const OMP_ROLE_ALIASES: Record<string, true> = Object.fromEntries(
|
|
64
|
+
MODEL_ROLE_IDS.map((role) => [role, true as const]),
|
|
65
|
+
);
|
|
68
66
|
|
|
69
67
|
/** Special "let the plugin decide" marker. */
|
|
70
68
|
export const AUTO_TOKEN = 'auto';
|
|
@@ -289,7 +287,7 @@ export function parseAgentValue(raw: string | undefined, _warn: (msg: string) =>
|
|
|
289
287
|
_warn(`[omp-opsx-addon] legacy "${trimmed}" → "${legacyStripped}" (pi:/local: prefix no longer needed)`);
|
|
290
288
|
return parseAgentValue(legacyStripped, _warn);
|
|
291
289
|
}
|
|
292
|
-
if (OMP_ROLE_ALIASES
|
|
290
|
+
if (OMP_ROLE_ALIASES[trimmed.toLowerCase()]) {
|
|
293
291
|
return { kind: 'omp-alias', alias: trimmed.toLowerCase() };
|
|
294
292
|
}
|
|
295
293
|
const { provider, modelId } = splitProviderModel(trimmed);
|
package/lib/usage-poller.ts
CHANGED
|
@@ -55,7 +55,8 @@ import { join, dirname, extname } from 'path';
|
|
|
55
55
|
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
|
-
import { resolveProviderReports, buildHealthMap, canonicalizeProvider, listDetectableProviders } from './usage-resolver.js';
|
|
58
|
+
import { resolveProviderReports, buildHealthMap, canonicalizeProvider, listDetectableProviders, loggedInProviderIds } 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';
|
|
@@ -934,8 +935,9 @@ function getLoggedInProviders(
|
|
|
934
935
|
directFetchers: DirectFetcher[],
|
|
935
936
|
): string[] {
|
|
936
937
|
const detectable = listDetectableProviders(directFetchers);
|
|
937
|
-
|
|
938
|
-
|
|
938
|
+
const loggedIn = loggedInProviderIds(authStorage);
|
|
939
|
+
if (loggedIn) {
|
|
940
|
+
return [...new Set([...loggedIn.map(canonicalizeProvider), ...detectable])];
|
|
939
941
|
}
|
|
940
942
|
// No authStorage (tests / offline): direct fetchers' providers are the universe,
|
|
941
943
|
// with detectable probes still honored when present.
|
|
@@ -979,8 +981,16 @@ async function fetchProvider(
|
|
|
979
981
|
lastFetchedByProvider.set(canonical, fetchedAt);
|
|
980
982
|
// Replace (or insert) just this provider's report; keep all others.
|
|
981
983
|
const byId = new Map(cachedReports.map((r) => [r.provider, r]));
|
|
982
|
-
|
|
984
|
+
let newReport = reports.find((r) => r.provider === canonical);
|
|
983
985
|
if (newReport) {
|
|
986
|
+
// Cursor slow-pool/grants enhancement (D1): rides the existing fetch
|
|
987
|
+
// cadence; failure is fail-soft inside fetchCursorLimitStatus and must
|
|
988
|
+
// NOT re-mark dirty (the outer catch only guards the report fetch).
|
|
989
|
+
// On null the report lands as-is — current behavior (exhausted).
|
|
990
|
+
if (canonical === 'cursor') {
|
|
991
|
+
const limitStatus = await fetchCursorLimitStatus();
|
|
992
|
+
if (limitStatus) newReport = enhanceCursorReport(newReport, limitStatus);
|
|
993
|
+
}
|
|
984
994
|
byId.set(canonical, newReport);
|
|
985
995
|
// Fresh report landed → the provider's health is CONFIRMED.
|
|
986
996
|
unconfirmedProviders.delete(canonical);
|
package/lib/usage-render.ts
CHANGED
|
@@ -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
|
+
// ⚠降速 joins the bucket chips on the first line; 赠金 and the reset
|
|
359
|
+
// countdown live on a second status line (countdown in the error color).
|
|
360
|
+
// No cursorLimitStatus note → single chips line, exactly as before.
|
|
361
|
+
if (isCursor && health.cursorLimitStatus) {
|
|
362
|
+
const cls = health.cursorLimitStatus;
|
|
363
|
+
if (cls.isInSlowPool) parts.push(painter.warning('⚠降速'));
|
|
364
|
+
const status: string[] = [];
|
|
365
|
+
if (cls.grantsRemainingCents > 0) {
|
|
366
|
+
status.push(painter.balance(`赠金 $${(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. Bare
|
|
370
|
+
// countdown, error-colored; 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(painter.exhausted(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
|
|
|
@@ -479,6 +508,19 @@ export function renderUsageReports(
|
|
|
479
508
|
const valid = cols.filter((c) => c.col.length > 0);
|
|
480
509
|
if (valid.length === 0) return [];
|
|
481
510
|
|
|
511
|
+
// Status-row parity: the cursor slow-pool block carries a third text row
|
|
512
|
+
// (⚠降速 / Grants / reset countdown). When ANY block has it, pad every
|
|
513
|
+
// other block with a blank status row so the per-block consumption
|
|
514
|
+
// waveform (pushed below) starts on the same line across the whole bar —
|
|
515
|
+
// otherwise the charts sit one row apart. Without any status row the
|
|
516
|
+
// output stays byte-identical to the two-row layout.
|
|
517
|
+
const STATUS_ROW = 2;
|
|
518
|
+
if (valid.some((c) => c.col.length > STATUS_ROW)) {
|
|
519
|
+
for (const c of valid) {
|
|
520
|
+
while (c.col.length <= STATUS_ROW) c.col.push('');
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
482
524
|
for (const c of valid) {
|
|
483
525
|
// Per-provider consumption waveform at the FIXED WAVEFORM_COLS width:
|
|
484
526
|
// every chart renders 20 Braille columns regardless of the block's
|
package/lib/usage-resolver.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -345,14 +366,51 @@ export function buildHealthMap(reports: UsageReport[]): Map<string, ProviderHeal
|
|
|
345
366
|
return map;
|
|
346
367
|
}
|
|
347
368
|
|
|
348
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* Structural view of the host auth storage (`ctx.modelRegistry.authStorage`)
|
|
371
|
+
* across host generations. omp <= 18.4 exposed the bulk fetch and the
|
|
372
|
+
* logged-in listing as top-level methods; 18.5.0 regrouped them behind
|
|
373
|
+
* namespace getters (`usage.reports()`, `credentials.list()`). Both shapes
|
|
374
|
+
* are optional so one interface serves either generation, and the shared
|
|
375
|
+
* `usage`/`credentials` member names keep host instances assignable.
|
|
376
|
+
*/
|
|
349
377
|
|
|
350
378
|
export interface AuthStorageLike {
|
|
379
|
+
/** omp <= 18.4: top-level bulk fetch. */
|
|
351
380
|
fetchUsageReports?: (params?: { signal?: AbortSignal }) => Promise<UsageReport[] | undefined | null>;
|
|
352
|
-
/**
|
|
381
|
+
/** omp 18.5+: namespaced bulk fetch. */
|
|
382
|
+
usage?: { reports?: (options?: { signal?: AbortSignal }) => Promise<UsageReport[] | null> };
|
|
383
|
+
/** omp <= 18.4: logged-in provider ids. */
|
|
353
384
|
list?: () => string[];
|
|
354
|
-
/**
|
|
355
|
-
|
|
385
|
+
/** omp 18.5+: credential rows namespace. */
|
|
386
|
+
credentials?: {
|
|
387
|
+
list?: (provider?: string) => { provider: string }[];
|
|
388
|
+
has?: (provider: string) => boolean;
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Bulk-fetch usage reports from whichever generation the host storage
|
|
394
|
+
* exposes. Returns the promise to await, or `undefined` when neither shape
|
|
395
|
+
* is present (tests / offline — callers proceed with direct fetchers only).
|
|
396
|
+
*/
|
|
397
|
+
export function bulkFetchUsageReports(
|
|
398
|
+
storage: AuthStorageLike | undefined,
|
|
399
|
+
signal?: AbortSignal,
|
|
400
|
+
): Promise<UsageReport[] | null | undefined> | undefined {
|
|
401
|
+
if (storage?.fetchUsageReports) return storage.fetchUsageReports({ signal });
|
|
402
|
+
if (storage?.usage?.reports) return storage.usage.reports({ signal });
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Logged-in provider ids from whichever generation the host storage
|
|
408
|
+
* exposes; `undefined` when no listing is available.
|
|
409
|
+
*/
|
|
410
|
+
export function loggedInProviderIds(storage: AuthStorageLike | undefined): string[] | undefined {
|
|
411
|
+
if (storage?.list) return storage.list();
|
|
412
|
+
const rows = storage?.credentials?.list?.();
|
|
413
|
+
return rows ? rows.map((row) => row.provider) : undefined;
|
|
356
414
|
}
|
|
357
415
|
|
|
358
416
|
/**
|
|
@@ -420,7 +478,8 @@ async function gatherReports(
|
|
|
420
478
|
// `zai-coding-cn` a fetcher returns, so dedup/override keys are stable).
|
|
421
479
|
const directFetcherByProvider = new Map(directFetchers.map((f) => [canonicalizeProvider(f.provider), f]));
|
|
422
480
|
const directIds = new Set(directFetcherByProvider.keys());
|
|
423
|
-
const
|
|
481
|
+
const loggedInIds = loggedInProviderIds(authStorage);
|
|
482
|
+
const loggedInSet = loggedInIds ? new Set(loggedInIds.map(canonicalizeProvider)) : null;
|
|
424
483
|
|
|
425
484
|
// Classify target providers into direct (have a fetcher) vs bulk (need
|
|
426
485
|
// fetchUsageReports). `bulkTargets === null` means "discover via bulk" — used
|
|
@@ -446,10 +505,13 @@ async function gatherReports(
|
|
|
446
505
|
// Step 1: bulk fetchUsageReports — ONLY when there are bulk targets to fetch.
|
|
447
506
|
// A direct-fetcher provider never triggers this. Discovery mode (null) calls
|
|
448
507
|
// bulk unconditionally but excludes providers that have direct fetchers.
|
|
449
|
-
|
|
508
|
+
// The capability check runs BEFORE the fetch promise is created.
|
|
509
|
+
const hasBulkFetch = Boolean(authStorage?.fetchUsageReports ?? authStorage?.usage?.reports);
|
|
510
|
+
if (hasBulkFetch && (bulkTargets === null || bulkTargets.length > 0)) {
|
|
511
|
+
const bulkFetch = bulkFetchUsageReports(authStorage, signal);
|
|
450
512
|
const filterSet = bulkTargets ? new Set(bulkTargets) : null;
|
|
451
513
|
try {
|
|
452
|
-
const fetched = await
|
|
514
|
+
const fetched = await bulkFetch;
|
|
453
515
|
if (Array.isArray(fetched)) {
|
|
454
516
|
for (const r of fetched) {
|
|
455
517
|
// Canonicalize BEFORE filtering so a login-name id merges
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genee/omp-opsx-addon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -38,13 +38,15 @@
|
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@oh-my-pi/pi-agent-core": ">=17.0.7",
|
|
40
40
|
"@oh-my-pi/pi-coding-agent": ">=17.0.7",
|
|
41
|
+
"@oh-my-pi/pi-tui": ">=17.0.7",
|
|
41
42
|
"@oh-my-pi/pi-utils": ">=17.0.7",
|
|
42
43
|
"typebox": ">=0.34.0"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@oh-my-pi/pi-coding-agent": ">=17.0.7",
|
|
47
|
+
"@oh-my-pi/pi-tui": ">=17.0.7",
|
|
46
48
|
"@oh-my-pi/pi-utils": ">=17.0.7",
|
|
47
|
-
"@types/node": "^24.
|
|
49
|
+
"@types/node": "^24.19.1",
|
|
48
50
|
"typebox": ">=0.34.0",
|
|
49
51
|
"typescript": "^5.9.3"
|
|
50
52
|
}
|
|
@@ -45,4 +45,10 @@ declare module '@oh-my-pi/pi-coding-agent/edit/settings' {
|
|
|
45
45
|
override(settings: unknown, value: unknown): void;
|
|
46
46
|
clearOverride?(settings: unknown): void;
|
|
47
47
|
};
|
|
48
|
+
}
|
|
49
|
+
declare module '@oh-my-pi/pi-coding-agent/modes/settings' {
|
|
50
|
+
/** Registered setting handle `symbolPreset` (read-only use). */
|
|
51
|
+
export const cfgSymbolPreset: {
|
|
52
|
+
get(settings: unknown): unknown;
|
|
53
|
+
};
|
|
48
54
|
}
|