@leo000001/opencode-quota-sidebar 1.13.10 → 2.0.1

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
@@ -12,9 +12,9 @@ OpenCode plugin: show token usage and subscription quota in the session sidebar
12
12
  Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to install it automatically on startup:
13
13
 
14
14
  ```json
15
- {
16
- "plugin": ["@leo000001/opencode-quota-sidebar@1.13.2"]
17
- }
15
+ {
16
+ "plugin": ["@leo000001/opencode-quota-sidebar@2.0.0"]
17
+ }
18
18
  ```
19
19
 
20
20
  Note for OpenCode `>=1.2.15`: TUI settings (`theme`/`keybinds`/`tui`) moved to `tui.json`, but plugin loading still stays in `opencode.json` (`plugin: []`).
@@ -53,20 +53,24 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
53
53
 
54
54
  ## Features
55
55
 
56
- - Session title becomes multiline in sidebar:
57
- - line 1: original session title
58
- - line 2: Input/Output tokens
59
- - line 3: Cache Read tokens (only if non-zero)
60
- - line 4: Cache Write tokens (only if non-zero)
61
- - line 5: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
62
- - quota lines: quota text like `OpenAI 5h 80% Rst 16:20`; short windows (`5h`, `1d`, `Daily`) show `HH:MM` on same-day resets and `MM-DD HH:MM` when crossing days, while longer windows continue to show `MM-DD`
56
+ - Session title becomes multiline in sidebar:
57
+ - line 1: original session title
58
+ - line 2: blank separator
59
+ - line 3: Input/Output tokens
60
+ - line 4: Cache Read tokens (only if non-zero)
61
+ - line 5: Cache Write tokens (only if non-zero)
62
+ - next lines: `Cache Coverage` (read/write cache models) and `Cache Read Coverage` (read-only cache models) when enough cache telemetry is available; mixed sessions can show both
63
+ - next line: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
64
+ - quota lines: quota text like `OpenAI 5h 80% Rst 16:20`; short windows (`5h`, `1d`, `Daily`) show `HH:MM` on same-day resets and `MM-DD HH:MM` when crossing days, while longer windows continue to show `MM-DD`
63
65
  - RightCode daily quota shows `$remaining/$dailyTotal` + expiry (e.g. `RC Daily $105/$60 Exp 02-27`, without trailing percent) and also shows balance on the next indented line when available; `Exp` remains date-only
64
66
  - Session-scoped usage/quota can include descendant subagent sessions (enabled by default via `sidebar.includeChildren=true`). Traversal is bounded by `childrenMaxDepth` (default 6), `childrenMaxSessions` (default 128), and `childrenConcurrency` (default 5); truncation is logged when `OPENCODE_QUOTA_DEBUG=1`. Day/week/month ranges never merge children — only session scope does.
65
- - Toast message includes three sections: `Token Usage`, `Cost as API` (per provider), and `Quota`
67
+ - Toast message includes three sections: `Token Usage`, `Cost as API` (per provider), and `Quota`
68
+ - `quota_summary` markdown / toast also include `Cache Coverage` and `Cache Read Coverage` summary lines when available
66
69
  - Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
67
70
  - Custom tools:
68
71
  - `quota_summary` — generate usage report for session/day/week/month (markdown + toast)
69
- - `quota_show` — toggle sidebar title display on/off (state persists across sessions)
72
+ - `quota_show` — toggle sidebar title display on/off (state persists across sessions)
73
+ - After startup, titles refresh on the next relevant session/message event or when `quota_show` is toggled
70
74
  - Quota connectors:
71
75
  - OpenAI Codex OAuth (`/backend-api/wham/usage`)
72
76
  - GitHub Copilot OAuth (`/copilot_internal/user`)
@@ -294,7 +298,7 @@ Other defaults:
294
298
  - When OpenCode exposes a long-context tier like `context_over_200k`, the plugin uses that premium rate for the whole request once `input > 200000`, matching OpenCode's current pricing schema.
295
299
  - `quota.providers` is the extensible per-adapter switch map.
296
300
  - If API Cost is `$0.00`, it usually means the model/provider has no pricing mapping in OpenCode at the moment, so equivalent API cost cannot be estimated.
297
- - Usage chunks cache both measured `cost` and computed `apiCost`. `quota_summary` (`/qday`, `/qweek`, `/qmonth`) usually reads those cached aggregates first, but a billing-cache version bump or missing/legacy API-cost data will trigger a rescan and persist refreshed values.
301
+ - Usage chunks cache both measured `cost` and computed `apiCost`. `quota_summary` (`/qday`, `/qweek`, `/qmonth`) recomputes range totals from session messages so period filtering follows message completion time; refreshed full-session usage may then be persisted back into day chunks when billing-cache refresh is needed.
298
302
 
299
303
  ### Buzz provider example
300
304
 
@@ -317,7 +321,7 @@ The adapter also tolerates `https://buzzai.cc/v1`, but `https://buzzai.cc` is th
317
321
  With that setup, the sidebar/toast quota line will look like:
318
322
 
319
323
  ```text
320
- Buzz Balance CNY 10.17
324
+ Buzz Balance 10.17
321
325
  ```
322
326
 
323
327
  ## Rendering examples
@@ -380,7 +384,7 @@ OpenAI
380
384
  5h 78% Rst 05:05
381
385
  Copilot
382
386
  Monthly 78% Rst 04-01
383
- Buzz Balance CNY 10.2
387
+ Buzz Balance 10.2
384
388
  ```
385
389
 
386
390
  Balance-style quota:
@@ -392,7 +396,7 @@ RC Balance $260
392
396
  Buzz balance quota:
393
397
 
394
398
  ```text
395
- Buzz Balance CNY 10.17
399
+ Buzz Balance 10.17
396
400
  ```
397
401
 
398
402
  Multi-detail quota (window + balance):
@@ -422,7 +426,7 @@ Quota is rendered inline as part of a single-line title:
422
426
  Mixed with Buzz balance:
423
427
 
424
428
  ```text
425
- <base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | Buzz Balance CNY 10.2
429
+ <base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | Buzz Balance 10.2
426
430
  ```
427
431
 
428
432
  `quota_summary` also supports an optional `includeChildren` flag (only effective for `period=session`) to override the config per call. For `day`/`week`/`month` periods, children are never merged — each session is counted independently.
package/dist/cost.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AssistantMessage } from '@opencode-ai/sdk';
2
+ import type { CacheCoverageMode } from './types.js';
2
3
  export declare const SUBSCRIPTION_API_COST_PROVIDERS: Set<string>;
3
4
  export declare function canonicalApiCostProviderID(providerID: string): string;
4
5
  export type ModelCostRates = {
@@ -16,4 +17,5 @@ export type ModelCostRates = {
16
17
  export declare function modelCostKey(providerID: string, modelID: string): string;
17
18
  export declare function parseModelCostRates(value: unknown): ModelCostRates | undefined;
18
19
  export declare function guessModelCostDivisor(rates: ModelCostRates): 1 | 1000000;
20
+ export declare function cacheCoverageModeFromRates(rates: ModelCostRates | undefined): CacheCoverageMode;
19
21
  export declare function calcEquivalentApiCostForMessage(message: AssistantMessage, rates: ModelCostRates): number;
package/dist/cost.js CHANGED
@@ -78,8 +78,18 @@ export function guessModelCostDivisor(rates) {
78
78
  ? MODEL_COST_DIVISOR_PER_MILLION
79
79
  : MODEL_COST_DIVISOR_PER_TOKEN;
80
80
  }
81
+ export function cacheCoverageModeFromRates(rates) {
82
+ if (!rates)
83
+ return 'none';
84
+ if (rates.cacheWrite > 0)
85
+ return 'read-write';
86
+ if (rates.cacheRead > 0)
87
+ return 'read-only';
88
+ return 'none';
89
+ }
81
90
  export function calcEquivalentApiCostForMessage(message, rates) {
82
- const effectiveRates = message.tokens.input > 200_000 && rates.contextOver200k
91
+ const effectiveRates = message.tokens.input + message.tokens.cache.read > 200_000 &&
92
+ rates.contextOver200k
83
93
  ? rates.contextOver200k
84
94
  : rates;
85
95
  // For providers that expose reasoning tokens separately, they are still
package/dist/format.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { QuotaSidebarConfig, QuotaSnapshot } from './types.js';
2
- import type { UsageSummary } from './usage.js';
2
+ import { type UsageSummary } from './usage.js';
3
3
  /**
4
4
  * Render sidebar title with multi-line token breakdown.
5
5
  *
package/dist/format.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { getCacheCoverageMetrics } from './usage.js';
1
2
  import { canonicalProviderID, collapseQuotaSnapshots, displayShortLabel, quotaDisplayLabel, } from './quota_render.js';
2
3
  import { stripAnsi } from './title.js';
3
4
  /** M6 fix: handle negative, NaN, Infinity gracefully. */
@@ -128,10 +129,18 @@ function fitLine(value, width) {
128
129
  return `${head}~`;
129
130
  }
130
131
  function formatCurrency(value, currency) {
131
- const safe = Number.isFinite(value) && value > 0 ? value : 0;
132
+ const safe = Number.isFinite(value) ? value : 0;
132
133
  const prefix = typeof currency === 'string' && currency ? currency : '$';
133
134
  if (safe === 0)
134
135
  return `${prefix}0.00`;
136
+ if (safe < 0) {
137
+ const abs = Math.abs(safe);
138
+ if (abs < 10)
139
+ return `-${prefix}${abs.toFixed(2)}`;
140
+ const one = abs.toFixed(1);
141
+ const trimmed = one.endsWith('.0') ? one.slice(0, -2) : one;
142
+ return `-${prefix}${trimmed}`;
143
+ }
135
144
  if (safe < 10)
136
145
  return `${prefix}${safe.toFixed(2)}`;
137
146
  const one = safe.toFixed(1);
@@ -147,6 +156,11 @@ function formatApiCostValue(value) {
147
156
  function formatApiCostLine(value) {
148
157
  return `${formatApiCostValue(value)} as API cost`;
149
158
  }
159
+ function formatPercent(value, decimals = 1) {
160
+ const safe = Number.isFinite(value) && value >= 0 ? value : 0;
161
+ const pct = (safe * 100).toFixed(decimals);
162
+ return `${pct.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')}%`;
163
+ }
150
164
  function alignPairs(pairs, indent = ' ') {
151
165
  if (pairs.length === 0)
152
166
  return [];
@@ -164,8 +178,11 @@ function alignPairs(pairs, indent = ' ') {
164
178
  }
165
179
  function compactQuotaInline(quota) {
166
180
  const label = sanitizeLine(quotaDisplayLabel(quota));
167
- if (quota.status !== 'ok')
168
- return label;
181
+ if (quota.status !== 'ok') {
182
+ if (quota.status === 'error')
183
+ return `${label} Remaining ?`;
184
+ return `${label} ${sanitizeLine(quota.status)}`;
185
+ }
169
186
  if (quota.windows && quota.windows.length > 0) {
170
187
  const first = quota.windows[0];
171
188
  const showPercent = first.showPercent !== false;
@@ -191,6 +208,7 @@ function compactQuotaInline(quota) {
191
208
  function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
192
209
  const baseBudget = Math.min(16, Math.max(8, Math.floor(width * 0.35)));
193
210
  const base = fitLine(baseTitle, baseBudget);
211
+ const cacheMetrics = getCacheCoverageMetrics(usage);
194
212
  const segments = [
195
213
  `Input ${sidebarNumber(usage.input)} Output ${sidebarNumber(usage.output)}`,
196
214
  ];
@@ -200,6 +218,12 @@ function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
200
218
  if (usage.cacheWrite > 0) {
201
219
  segments.push(`Cache Write ${sidebarNumber(usage.cacheWrite)}`);
202
220
  }
221
+ if (cacheMetrics.cacheCoverage !== undefined) {
222
+ segments.push(`Cache Coverage ${formatPercent(cacheMetrics.cacheCoverage, 0)}`);
223
+ }
224
+ if (cacheMetrics.cacheReadCoverage !== undefined) {
225
+ segments.push(`Cache Read Coverage ${formatPercent(cacheMetrics.cacheReadCoverage, 0)}`);
226
+ }
203
227
  if (config.sidebar.showCost && usage.apiCost > 0) {
204
228
  segments.push(formatApiCostLine(usage.apiCost));
205
229
  }
@@ -225,12 +249,16 @@ function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
225
249
  */
226
250
  export function renderSidebarTitle(baseTitle, usage, quotas, config) {
227
251
  const width = Math.max(8, Math.floor(config.sidebar.width || 36));
228
- const safeBaseTitle = stripAnsi(baseTitle || 'Session').split(/\r?\n/, 1)[0] || 'Session';
252
+ const safeBaseTitle = stripAnsi(baseTitle || 'Session') || 'Session';
229
253
  if (config.sidebar.multilineTitle !== true) {
230
- return renderSingleLineTitle(safeBaseTitle, usage, quotas, config, width);
254
+ const singleLineBase = safeBaseTitle.split(/\r?\n/, 1)[0] || 'Session';
255
+ return renderSingleLineTitle(singleLineBase, usage, quotas, config, width);
231
256
  }
257
+ const cacheMetrics = getCacheCoverageMetrics(usage);
232
258
  const lines = [];
233
- lines.push(fitLine(safeBaseTitle, width));
259
+ for (const line of safeBaseTitle.split(/\r?\n/)) {
260
+ lines.push(fitLine(line || 'Session', width));
261
+ }
234
262
  lines.push('');
235
263
  // Input / Output line
236
264
  const io = `Input ${sidebarNumber(usage.input)} Output ${sidebarNumber(usage.output)}`;
@@ -242,6 +270,12 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
242
270
  if (usage.cacheWrite > 0) {
243
271
  lines.push(fitLine(`Cache Write ${sidebarNumber(usage.cacheWrite)}`, width));
244
272
  }
273
+ if (cacheMetrics.cacheCoverage !== undefined) {
274
+ lines.push(fitLine(`Cache Coverage ${formatPercent(cacheMetrics.cacheCoverage, 0)}`, width));
275
+ }
276
+ if (cacheMetrics.cacheReadCoverage !== undefined) {
277
+ lines.push(fitLine(`Cache Read Coverage ${formatPercent(cacheMetrics.cacheReadCoverage, 0)}`, width));
278
+ }
245
279
  if (config.sidebar.showCost && usage.apiCost > 0) {
246
280
  lines.push(fitLine(formatApiCostLine(usage.apiCost), width));
247
281
  }
@@ -389,9 +423,6 @@ function compactReset(iso, resetLabel, windowLabel) {
389
423
  return hhmm;
390
424
  return `${two(value.getMonth() + 1)}-${two(value.getDate())} ${hhmm}`;
391
425
  }
392
- if (sameDay) {
393
- return `${two(value.getHours())}:${two(value.getMinutes())}`;
394
- }
395
426
  return `${two(value.getMonth() + 1)}-${two(value.getDate())}`;
396
427
  }
397
428
  function dateLine(iso) {
@@ -419,6 +450,7 @@ function periodLabel(period) {
419
450
  }
420
451
  export function renderMarkdownReport(period, usage, quotas, options) {
421
452
  const showCost = options?.showCost !== false;
453
+ const cacheMetrics = getCacheCoverageMetrics(usage);
422
454
  const mdCell = (value) => sanitizeLine(value).replace(/\|/g, '\\|');
423
455
  const rightCodeSubscriptionProviderIDs = new Set(collapseQuotaSnapshots(quotas)
424
456
  .filter((quota) => quota.adapterID === 'rightcode')
@@ -429,6 +461,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
429
461
  const measuredCostCell = (providerID, cost) => {
430
462
  const canonical = canonicalProviderID(providerID);
431
463
  const isSubscription = canonical === 'openai' ||
464
+ canonical === 'anthropic' ||
432
465
  canonical === 'github-copilot' ||
433
466
  rightCodeSubscriptionProviderIDs.has(providerID);
434
467
  if (isSubscription)
@@ -438,6 +471,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
438
471
  const isSubscriptionMeasuredProvider = (providerID) => {
439
472
  const canonical = canonicalProviderID(providerID);
440
473
  return (canonical === 'openai' ||
474
+ canonical === 'anthropic' ||
441
475
  canonical === 'github-copilot' ||
442
476
  rightCodeSubscriptionProviderIDs.has(providerID));
443
477
  };
@@ -474,30 +508,40 @@ export function renderMarkdownReport(period, usage, quotas, options) {
474
508
  : `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`;
475
509
  });
476
510
  const quotaLines = collapseQuotaSnapshots(quotas).flatMap((quota) => {
511
+ const displayLabel = quotaDisplayLabel(quota);
477
512
  // Multi-window detail
478
513
  if (quota.windows && quota.windows.length > 0 && quota.status === 'ok') {
479
- return quota.windows.map((win) => {
514
+ const windowLines = quota.windows.map((win) => {
480
515
  if (win.showPercent === false) {
481
516
  const winLabel = win.label ? ` (${win.label})` : '';
482
- return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
517
+ return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
483
518
  }
484
519
  const remaining = win.remainingPercent === undefined
485
520
  ? '-'
486
521
  : `${win.remainingPercent.toFixed(1)}%`;
487
522
  const winLabel = win.label ? ` (${win.label})` : '';
488
- return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
523
+ return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
489
524
  });
525
+ if (quota.balance) {
526
+ windowLines.push(mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`));
527
+ }
528
+ return windowLines;
490
529
  }
491
530
  if (quota.status === 'ok' && quota.balance) {
492
531
  return [
493
- mdCell(`- ${quota.label}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`),
532
+ mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`),
533
+ ];
534
+ }
535
+ if (quota.status !== 'ok') {
536
+ return [
537
+ mdCell(`- ${displayLabel}: ${quota.status}${quota.note ? ` | ${quota.note}` : ''}`),
494
538
  ];
495
539
  }
496
540
  const remaining = quota.remainingPercent === undefined
497
541
  ? '-'
498
542
  : `${quota.remainingPercent.toFixed(1)}%`;
499
543
  return [
500
- mdCell(`- ${quota.label}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
544
+ mdCell(`- ${displayLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
501
545
  ];
502
546
  });
503
547
  return [
@@ -506,6 +550,14 @@ export function renderMarkdownReport(period, usage, quotas, options) {
506
550
  `- Sessions: ${usage.sessionCount}`,
507
551
  `- Assistant messages: ${usage.assistantMessages}`,
508
552
  `- Tokens: input ${usage.input}, output ${usage.output}, cache_read ${usage.cacheRead}, cache_write ${usage.cacheWrite}, total ${usage.total}`,
553
+ ...(cacheMetrics.cacheCoverage !== undefined
554
+ ? [`- Cache Coverage: ${formatPercent(cacheMetrics.cacheCoverage, 1)}`]
555
+ : []),
556
+ ...(cacheMetrics.cacheReadCoverage !== undefined
557
+ ? [
558
+ `- Cache Read Coverage: ${formatPercent(cacheMetrics.cacheReadCoverage, 1)}`,
559
+ ]
560
+ : []),
509
561
  ...(showCost
510
562
  ? [
511
563
  `- Measured cost: ${measuredCostSummaryValue()}`,
@@ -533,6 +585,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
533
585
  export function renderToastMessage(period, usage, quotas, options) {
534
586
  const width = Math.max(24, Math.floor(options?.width || 56));
535
587
  const showCost = options?.showCost !== false;
588
+ const cacheMetrics = getCacheCoverageMetrics(usage);
536
589
  const lines = [];
537
590
  lines.push(fitLine(`${periodLabel(period)} - Total ${shortNumber(usage.total)}`, width));
538
591
  lines.push('');
@@ -553,13 +606,17 @@ export function renderToastMessage(period, usage, quotas, options) {
553
606
  value: shortNumber(usage.cacheWrite),
554
607
  });
555
608
  }
556
- if (showCost) {
557
- if (usage.apiCost > 0) {
558
- tokenPairs.push({
559
- label: 'API Cost',
560
- value: formatApiCostValue(usage.apiCost),
561
- });
562
- }
609
+ if (cacheMetrics.cacheCoverage !== undefined) {
610
+ tokenPairs.push({
611
+ label: 'Cache Coverage',
612
+ value: formatPercent(cacheMetrics.cacheCoverage, 1),
613
+ });
614
+ }
615
+ if (cacheMetrics.cacheReadCoverage !== undefined) {
616
+ tokenPairs.push({
617
+ label: 'Cache Read Coverage',
618
+ value: formatPercent(cacheMetrics.cacheReadCoverage, 1),
619
+ });
563
620
  }
564
621
  lines.push(...alignPairs(tokenPairs).map((line) => fitLine(line, width)));
565
622
  if (showCost) {
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ export async function QuotaSidebarPlugin(input) {
29
29
  const authPath = authFilePath(dataDir);
30
30
  const state = await loadState(statePath);
31
31
  // M2: evict old sessions on startup
32
- evictOldSessions(state, config.retentionDays);
32
+ const evictedOnStartup = evictOldSessions(state, config.retentionDays);
33
33
  const persistence = createPersistenceScheduler({
34
34
  statePath,
35
35
  state,
@@ -38,6 +38,9 @@ export async function QuotaSidebarPlugin(input) {
38
38
  const markDirty = persistence.markDirty;
39
39
  const scheduleSave = persistence.scheduleSave;
40
40
  const flushSave = persistence.flushSave;
41
+ if (evictedOnStartup > 0) {
42
+ scheduleSave();
43
+ }
41
44
  const RESTORE_TITLE_CONCURRENCY = 5;
42
45
  const quotaService = createQuotaService({
43
46
  quotaRuntime,
@@ -166,6 +169,14 @@ export async function QuotaSidebarPlugin(input) {
166
169
  });
167
170
  scheduleTitleRefresh = titleRefresh.schedule;
168
171
  const restoreAllVisibleTitles = titleApplicator.restoreAllVisibleTitles;
172
+ const refreshAllTouchedTitles = titleApplicator.refreshAllTouchedTitles;
173
+ const refreshAllVisibleTitles = titleApplicator.refreshAllVisibleTitles;
174
+ if (!state.titleEnabled || !config.sidebar.enabled) {
175
+ void restoreAllVisibleTitles().catch(swallow('startup:restoreAllVisibleTitles'));
176
+ }
177
+ else {
178
+ void refreshAllTouchedTitles().catch(swallow('startup:refreshAllTouchedTitles'));
179
+ }
169
180
  const showToast = async (period, message) => {
170
181
  await input.client.tui
171
182
  .showToast({
@@ -217,10 +228,16 @@ export async function QuotaSidebarPlugin(input) {
217
228
  titleRefresh.cancel(session.id);
218
229
  const dateKey = state.sessionDateMap[session.id] ||
219
230
  dateKeyFromTimestamp(session.time.created);
231
+ state.deletedSessionDateMap[session.id] = dateKey;
220
232
  delete state.sessions[session.id];
221
233
  delete state.sessionDateMap[session.id];
234
+ markDirty(dateKey);
222
235
  scheduleSave();
223
- await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
236
+ const deletedFromChunk = await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
237
+ if (deletedFromChunk) {
238
+ delete state.deletedSessionDateMap[session.id];
239
+ scheduleSave();
240
+ }
224
241
  if (config.sidebar.includeChildren && session.parentID) {
225
242
  titleRefresh.schedule(session.parentID, 0);
226
243
  }
@@ -250,8 +267,13 @@ export async function QuotaSidebarPlugin(input) {
250
267
  state.titleEnabled = enabled;
251
268
  },
252
269
  scheduleSave,
270
+ flushSave,
253
271
  refreshSessionTitle: (sessionID, delay) => titleRefresh.schedule(sessionID, delay ?? 250),
272
+ cancelAllTitleRefreshes: () => titleRefresh.cancelAll(),
273
+ waitForTitleRefreshIdle: () => titleRefresh.waitForIdle(),
254
274
  restoreAllVisibleTitles,
275
+ refreshAllTouchedTitles,
276
+ refreshAllVisibleTitles,
255
277
  showToast,
256
278
  summarizeForTool,
257
279
  getQuotaSnapshots,
package/dist/quota.js CHANGED
@@ -6,14 +6,12 @@ function resolveContext(providerID, providerOptions) {
6
6
  return { providerID, providerOptions };
7
7
  }
8
8
  function authCandidates(providerID, normalizedProviderID, adapterID) {
9
- const candidates = new Set([
10
- providerID,
11
- normalizedProviderID,
12
- adapterID,
13
- ]);
9
+ const candidates = new Set([providerID]);
14
10
  if (adapterID === 'github-copilot') {
15
11
  candidates.add('github-copilot-enterprise');
16
12
  }
13
+ candidates.add(normalizedProviderID);
14
+ candidates.add(adapterID);
17
15
  return [...candidates];
18
16
  }
19
17
  function pickAuth(providerID, normalizedProviderID, adapterID, authMap) {
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { TtlValueCache } from './cache.js';
2
3
  import { isRecord, swallow } from './helpers.js';
3
4
  import { listDefaultQuotaProviderIDs, loadAuthMap, quotaSort } from './quota.js';
@@ -10,6 +11,36 @@ export function createQuotaService(deps) {
10
11
  const authCache = new TtlValueCache();
11
12
  const providerOptionsCache = new TtlValueCache();
12
13
  const inFlight = new Map();
14
+ const authFingerprint = (auth) => {
15
+ if (!auth || typeof auth !== 'object')
16
+ return undefined;
17
+ const stable = JSON.stringify(Object.keys(auth)
18
+ .sort()
19
+ .reduce((acc, key) => {
20
+ const value = auth[key];
21
+ if (value !== undefined)
22
+ acc[key] = value;
23
+ return acc;
24
+ }, {}));
25
+ return createHash('sha256').update(stable).digest('hex').slice(0, 12);
26
+ };
27
+ const providerOptionsFingerprint = (providerOptions) => {
28
+ if (!providerOptions)
29
+ return undefined;
30
+ const stable = JSON.stringify(Object.keys(providerOptions)
31
+ .sort()
32
+ .reduce((acc, key) => {
33
+ if (key === 'baseURL')
34
+ return acc;
35
+ const value = providerOptions[key];
36
+ if (value !== undefined)
37
+ acc[key] = value;
38
+ return acc;
39
+ }, {}));
40
+ if (stable === '{}')
41
+ return undefined;
42
+ return createHash('sha256').update(stable).digest('hex').slice(0, 12);
43
+ };
13
44
  const getAuthMap = async () => {
14
45
  const cached = authCache.get();
15
46
  if (cached)
@@ -23,7 +54,7 @@ export function createQuotaService(deps) {
23
54
  return cached;
24
55
  const client = deps.client;
25
56
  if (!client.config?.providers && !client.provider?.list) {
26
- return providerOptionsCache.set({}, 30_000);
57
+ return providerOptionsCache.set({}, 5_000);
27
58
  }
28
59
  // Newer runtimes expose config.providers; older clients may only expose
29
60
  // provider.list with a slightly different response shape.
@@ -63,7 +94,7 @@ export function createQuotaService(deps) {
63
94
  return acc;
64
95
  }, {})
65
96
  : {};
66
- return providerOptionsCache.set(map, 30_000);
97
+ return providerOptionsCache.set(map, 5_000);
67
98
  };
68
99
  const isValidQuotaCache = (snapshot) => {
69
100
  // Guard against stale RightCode cache entries from pre-daily format.
@@ -166,14 +197,6 @@ export function createQuotaService(deps) {
166
197
  ? directCandidates
167
198
  : defaultCandidates;
168
199
  const matchedCandidates = rawCandidates.filter((candidate) => Boolean(deps.quotaRuntime.resolveQuotaAdapter(candidate.providerID, candidate.providerOptions)));
169
- const dedupedCandidates = Array.from(matchedCandidates
170
- .reduce((acc, candidate) => {
171
- const key = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
172
- if (!acc.has(key))
173
- acc.set(key, candidate);
174
- return acc;
175
- }, new Map())
176
- .values());
177
200
  function authScopeFor(providerID, providerOptions) {
178
201
  const adapter = deps.quotaRuntime.resolveQuotaAdapter(providerID, providerOptions);
179
202
  const normalized = deps.quotaRuntime.normalizeProviderID(providerID);
@@ -186,24 +209,48 @@ export function createQuotaService(deps) {
186
209
  candidates.push(value);
187
210
  };
188
211
  push(providerID);
189
- push(normalized);
190
- push(adapterID);
191
212
  if (adapterID === 'github-copilot')
192
213
  push('github-copilot-enterprise');
214
+ push(normalized);
215
+ push(adapterID);
216
+ const optionsFingerprint = providerOptionsFingerprint(providerOptions);
193
217
  for (const key of candidates) {
194
218
  const auth = authMap[key];
195
219
  if (!auth)
196
220
  continue;
197
- if (key === 'openai' &&
198
- auth.type === 'oauth' &&
199
- typeof auth.accountId === 'string' &&
200
- auth.accountId) {
201
- return `${key}@${auth.accountId}`;
221
+ if (auth.type === 'oauth') {
222
+ const authRecord = auth;
223
+ const identity = (typeof auth.accountId === 'string' && auth.accountId) ||
224
+ (typeof authRecord.login === 'string' && authRecord.login) ||
225
+ (typeof authRecord.userId === 'string' && authRecord.userId);
226
+ if (identity) {
227
+ return optionsFingerprint
228
+ ? `${key}@${identity}|options@${optionsFingerprint}`
229
+ : `${key}@${identity}`;
230
+ }
231
+ }
232
+ const fingerprint = authFingerprint(auth);
233
+ if (fingerprint) {
234
+ return optionsFingerprint
235
+ ? `${key}@${fingerprint}|options@${optionsFingerprint}`
236
+ : `${key}@${fingerprint}`;
202
237
  }
203
- return key;
238
+ return optionsFingerprint ? `${key}|options@${optionsFingerprint}` : key;
239
+ }
240
+ if (optionsFingerprint) {
241
+ return `options@${optionsFingerprint}`;
204
242
  }
205
243
  return 'none';
206
244
  }
245
+ const dedupedCandidates = Array.from(matchedCandidates
246
+ .reduce((acc, candidate) => {
247
+ const baseKey = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
248
+ const key = `${baseKey}#${authScopeFor(candidate.providerID, candidate.providerOptions)}`;
249
+ if (!acc.has(key))
250
+ acc.set(key, candidate);
251
+ return acc;
252
+ }, new Map())
253
+ .values());
207
254
  let cacheChanged = false;
208
255
  const fetchSnapshot = (providerID, providerOptions) => {
209
256
  const baseKey = deps.quotaRuntime.quotaCacheKey(providerID, providerOptions);
package/dist/storage.d.ts CHANGED
@@ -30,6 +30,11 @@ export declare function scanSessionsByCreatedRange(statePath: string, startAt: n
30
30
  dateKey: string;
31
31
  state: SessionState;
32
32
  }[]>;
33
+ export declare function scanAllSessions(statePath: string, memoryState?: QuotaSidebarState): Promise<{
34
+ sessionID: string;
35
+ dateKey: string;
36
+ state: SessionState;
37
+ }[]>;
33
38
  /** Best-effort: remove a session entry from its day chunk (if present). */
34
39
  export declare function deleteSessionFromDayChunk(statePath: string, sessionID: string, dateKey: string): Promise<boolean>;
35
40
  /** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */