@genee/omp-opsx-addon 0.12.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 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
- if (auth?.list) {
1312
+ const loggedInIds = loggedInProviderIds(auth);
1313
+ if (loggedInIds) {
1309
1314
  authAvailable = true;
1310
- const list = auth.list();
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
 
@@ -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;
@@ -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: designer via default inheritance,
17
- * commit/tiny via enumeration fallbacks terminating at smol, task via
18
- * primary-session inheritance, plan as a graceful no-op, advisor via the
19
- * static slow chain, custom roles via the config layer. The landing sequence
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
  /**
@@ -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-coding-agent/modes/theme/symbols';
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
- /** Minimal settings surface for the `symbolPreset` read (host `pi.pi.settings`). */
28
- export interface SymbolSettings {
29
- get(key: string): unknown;
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
- let raw: unknown;
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` → inherit the primary session
136
- * model; `@smol`/`@slow` follow the written roles; `@designer` follows via
137
- * default inheritance; `@tiny`/`@advisor` resolve OMP static priority chains
138
- * (unless config-pinned, which always wins over the label); literal patterns
139
- * are pins; other `@role` values stay on the config layer.
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 === '@designer') {
150
- return { label: 'role 链 @designer(经 default 继承)', follows: true };
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 === '@tiny' || first === '@advisor') {
153
- return { label: `OMP 自动链 ${first}(静态 priority 模式)`, follows: false };
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 };
@@ -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: `default`, `smol`, `slow`, `vision`, `plan`,
13
- * `designer`, `commit`, `tiny`, `task`, `advisor` — expanded by the
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
- * designer/commit/tiny/task/advisor + custom roles) follows after
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
- /** OMP-documented role aliases. Plugins MAY pass these to `task(..., model: '@alias')`. */
56
- export const OMP_ROLE_ALIASES = new Set<string>([
57
- 'default',
58
- 'smol',
59
- 'slow',
60
- 'vision',
61
- 'plan',
62
- 'designer',
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.has(trimmed.toLowerCase())) {
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);
@@ -55,7 +55,7 @@ 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
59
  import { fetchCursorLimitStatus, enhanceCursorReport } from './cursor-limit-status.js';
60
60
  import { RedisRelay } from './usage-redis-probe.js';
61
61
  import type { RedisTransport } from './usage-redis-client.js';
@@ -935,8 +935,9 @@ function getLoggedInProviders(
935
935
  directFetchers: DirectFetcher[],
936
936
  ): string[] {
937
937
  const detectable = listDetectableProviders(directFetchers);
938
- if (authStorage?.list) {
939
- return [...new Set([...authStorage.list().map(canonicalizeProvider), ...detectable])];
938
+ const loggedIn = loggedInProviderIds(authStorage);
939
+ if (loggedIn) {
940
+ return [...new Set([...loggedIn.map(canonicalizeProvider), ...detectable])];
940
941
  }
941
942
  // No authStorage (tests / offline): direct fetchers' providers are the universe,
942
943
  // with detectable probes still honored when present.
@@ -355,26 +355,26 @@ export const buildColumn = (r: UsageReport, painter: Painter = ansiPainter, load
355
355
  return isCursor ? [header, painter.balance('—')] : undefined;
356
356
  }
357
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.
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
361
  if (isCursor && health.cursorLimitStatus) {
362
362
  const cls = health.cursorLimitStatus;
363
+ if (cls.isInSlowPool) parts.push(painter.warning('⚠降速'));
363
364
  const status: string[] = [];
364
- if (cls.isInSlowPool) status.push(painter.warning('⚠降速'));
365
365
  if (cls.grantsRemainingCents > 0) {
366
- status.push(painter.balance(`Grants $${(cls.grantsRemainingCents / 100).toFixed(2)}`));
366
+ status.push(painter.balance(`赠金 $${(cls.grantsRemainingCents / 100).toFixed(2)}`));
367
367
  }
368
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.
369
+ // reset so a slow-pooled user can see when quota revives. Bare
370
+ // countdown, error-colored; hidden when no resetsAt.
371
371
  let soonest: number | undefined;
372
372
  for (const l of r.limits) {
373
373
  const at = l.window?.resetsAt;
374
374
  if (typeof at !== 'number') continue;
375
375
  soonest = soonest === undefined ? at : Math.min(soonest, at);
376
376
  }
377
- if (soonest !== undefined) status.push(`${resetIn(soonest)}重置`);
377
+ if (soonest !== undefined) status.push(painter.exhausted(resetIn(soonest)));
378
378
  if (status.length === 0) return [header, parts.join(' · ')];
379
379
  return [header, parts.join(' · '), status.join(' · ')];
380
380
  }
@@ -508,6 +508,19 @@ export function renderUsageReports(
508
508
  const valid = cols.filter((c) => c.col.length > 0);
509
509
  if (valid.length === 0) return [];
510
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
+
511
524
  for (const c of valid) {
512
525
  // Per-provider consumption waveform at the FIXED WAVEFORM_COLS width:
513
526
  // every chart renders 20 Braille columns regardless of the block's
@@ -366,14 +366,51 @@ export function buildHealthMap(reports: UsageReport[]): Map<string, ProviderHeal
366
366
  return map;
367
367
  }
368
368
 
369
- /** Minimal interface for the auth-storage call site we depend on. */
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
+ */
370
377
 
371
378
  export interface AuthStorageLike {
379
+ /** omp <= 18.4: top-level bulk fetch. */
372
380
  fetchUsageReports?: (params?: { signal?: AbortSignal }) => Promise<UsageReport[] | undefined | null>;
373
- /** List all providers with credentials configured. */
381
+ /** omp 18.5+: namespaced bulk fetch. */
382
+ usage?: { reports?: (options?: { signal?: AbortSignal }) => Promise<UsageReport[] | null> };
383
+ /** omp <= 18.4: logged-in provider ids. */
374
384
  list?: () => string[];
375
- /** Check if a specific provider has any auth configured. */
376
- hasAuth?: (provider: string) => boolean;
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;
377
414
  }
378
415
 
379
416
  /**
@@ -441,7 +478,8 @@ async function gatherReports(
441
478
  // `zai-coding-cn` a fetcher returns, so dedup/override keys are stable).
442
479
  const directFetcherByProvider = new Map(directFetchers.map((f) => [canonicalizeProvider(f.provider), f]));
443
480
  const directIds = new Set(directFetcherByProvider.keys());
444
- const loggedInSet = authStorage?.list ? new Set(authStorage.list().map(canonicalizeProvider)) : null;
481
+ const loggedInIds = loggedInProviderIds(authStorage);
482
+ const loggedInSet = loggedInIds ? new Set(loggedInIds.map(canonicalizeProvider)) : null;
445
483
 
446
484
  // Classify target providers into direct (have a fetcher) vs bulk (need
447
485
  // fetchUsageReports). `bulkTargets === null` means "discover via bulk" — used
@@ -467,10 +505,13 @@ async function gatherReports(
467
505
  // Step 1: bulk fetchUsageReports — ONLY when there are bulk targets to fetch.
468
506
  // A direct-fetcher provider never triggers this. Discovery mode (null) calls
469
507
  // bulk unconditionally but excludes providers that have direct fetchers.
470
- if (authStorage?.fetchUsageReports && (bulkTargets === null || bulkTargets.length > 0)) {
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);
471
512
  const filterSet = bulkTargets ? new Set(bulkTargets) : null;
472
513
  try {
473
- const fetched = await authStorage.fetchUsageReports({ signal });
514
+ const fetched = await bulkFetch;
474
515
  if (Array.isArray(fetched)) {
475
516
  for (const r of fetched) {
476
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.12.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.13.3",
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
  }