@leo000001/opencode-quota-sidebar 2.0.1 → 2.0.2

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
@@ -13,7 +13,7 @@ Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to i
13
13
 
14
14
  ```json
15
15
  {
16
- "plugin": ["@leo000001/opencode-quota-sidebar@2.0.0"]
16
+ "plugin": ["@leo000001/opencode-quota-sidebar@2.0.1"]
17
17
  }
18
18
  ```
19
19
 
@@ -64,13 +64,13 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
64
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`
65
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
66
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.
67
- - Toast message includes three sections: `Token Usage`, `Cost as API` (per provider), and `Quota`
67
+ - Toast message can include four sections: `Token Usage`, `Cost as API` (per provider), `Provider Cache` (when provider-level cache coverage is available), and `Quota`
68
68
  - `quota_summary` markdown / toast also include `Cache Coverage` and `Cache Read Coverage` summary lines when available
69
69
  - Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
70
70
  - Custom tools:
71
71
  - `quota_summary` — generate usage report for session/day/week/month (markdown + toast)
72
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
73
+ - After startup, titles are restored immediately when persisted display mode is OFF; when persisted display mode is ON, touched titles refresh on startup and the rest update on the next relevant session/message event or when `quota_show` is toggled
74
74
  - Quota connectors:
75
75
  - OpenAI Codex OAuth (`/backend-api/wham/usage`)
76
76
  - GitHub Copilot OAuth (`/copilot_internal/user`)
@@ -90,12 +90,17 @@ The plugin stores lightweight global state and date-partitioned session chunks.
90
90
  - `titleEnabled`
91
91
  - `sessionDateMap` (sessionID -> `YYYY-MM-DD`)
92
92
  - `quotaCache`
93
- - Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
94
- - per-session title state (`baseTitle`, `lastAppliedTitle`)
95
- - `createdAt`
96
- - `parentID` (when the session is a subagent child session)
97
- - cached usage summary used by `quota_summary`
98
- - incremental aggregation cursor
93
+ - Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
94
+ - per-session title state (`baseTitle`, `lastAppliedTitle`)
95
+ - `createdAt`
96
+ - `parentID` (when the session is a subagent child session)
97
+ - cached usage summary used by `quota_summary`, including session-level and provider-level `cacheBuckets` for cache coverage reporting
98
+ - incremental aggregation cursor
99
+
100
+ Notes on cache coverage persistence:
101
+
102
+ - Older cached usage written before `cacheBuckets` existed can only be approximated from top-level `cache_read` / `cache_write` totals.
103
+ - In those legacy cases, mixed read-only + read-write cache traffic may be attributed to a single fallback bucket until the session is recomputed from messages.
99
104
 
100
105
  Example tree:
101
106
 
package/dist/format.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getCacheCoverageMetrics } from './usage.js';
1
+ import { getCacheCoverageMetrics, getProviderCacheCoverageMetrics, } from './usage.js';
2
2
  import { canonicalProviderID, collapseQuotaSnapshots, displayShortLabel, quotaDisplayLabel, } from './quota_render.js';
3
3
  import { stripAnsi } from './title.js';
4
4
  /** M6 fix: handle negative, NaN, Infinity gracefully. */
@@ -161,6 +161,16 @@ function formatPercent(value, decimals = 1) {
161
161
  const pct = (safe * 100).toFixed(decimals);
162
162
  return `${pct.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')}%`;
163
163
  }
164
+ function formatQuotaPercent(value, options) {
165
+ const missing = options?.missing ?? '-';
166
+ if (value === undefined)
167
+ return missing;
168
+ if (!Number.isFinite(value) || value < 0)
169
+ return missing;
170
+ if (options?.rounded)
171
+ return `${Math.round(value)}%`;
172
+ return `${value.toFixed(options?.decimals ?? 1)}%`;
173
+ }
164
174
  function alignPairs(pairs, indent = ' ') {
165
175
  if (pairs.length === 0)
166
176
  return [];
@@ -187,9 +197,10 @@ function compactQuotaInline(quota) {
187
197
  const first = quota.windows[0];
188
198
  const showPercent = first.showPercent !== false;
189
199
  const firstLabel = sanitizeLine(first.label || '');
190
- const pct = first.remainingPercent === undefined
191
- ? undefined
192
- : `${Math.round(first.remainingPercent)}%`;
200
+ const pct = formatQuotaPercent(first.remainingPercent, {
201
+ rounded: true,
202
+ missing: '',
203
+ });
193
204
  const summary = showPercent
194
205
  ? [firstLabel, pct].filter(Boolean).join(' ')
195
206
  : firstLabel.replace(/^Daily\s+/i, '') || firstLabel;
@@ -200,8 +211,12 @@ function compactQuotaInline(quota) {
200
211
  if (quota.balance) {
201
212
  return `${label} Balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`;
202
213
  }
203
- if (quota.remainingPercent !== undefined) {
204
- return `${label} ${Math.round(quota.remainingPercent)}%`;
214
+ const singlePercent = formatQuotaPercent(quota.remainingPercent, {
215
+ rounded: true,
216
+ missing: '',
217
+ });
218
+ if (singlePercent) {
219
+ return `${label} ${singlePercent}`;
205
220
  }
206
221
  return label;
207
222
  }
@@ -351,9 +366,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
351
366
  : undefined;
352
367
  const renderWindow = (win) => {
353
368
  const showPercent = win.showPercent !== false;
354
- const pct = win.remainingPercent === undefined
355
- ? '?'
356
- : `${Math.round(win.remainingPercent)}%`;
369
+ const pct = formatQuotaPercent(win.remainingPercent, { rounded: true });
357
370
  const parts = win.label
358
371
  ? showPercent
359
372
  ? [sanitizeLine(win.label), pct]
@@ -387,9 +400,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
387
400
  return maybeBreak(balanceText, [balanceText]);
388
401
  }
389
402
  // Fallback: single value from top-level remainingPercent
390
- const percent = quota.remainingPercent === undefined
391
- ? '?'
392
- : `${Math.round(quota.remainingPercent)}%`;
403
+ const percent = formatQuotaPercent(quota.remainingPercent, { rounded: true });
393
404
  const reset = compactReset(quota.resetAt, 'Rst');
394
405
  const fallbackText = `Remaining ${percent}${reset ? ` Rst ${reset}` : ''}`;
395
406
  return maybeBreak(fallbackText, [fallbackText]);
@@ -499,12 +510,71 @@ export function renderMarkdownReport(period, usage, quotas, options) {
499
510
  return '-';
500
511
  return formatApiCostValue(usage.apiCost);
501
512
  };
502
- const providerRows = Object.values(usage.providers)
503
- .sort((a, b) => b.total - a.total)
513
+ const cacheCoverageCell = (provider) => {
514
+ const metrics = getProviderCacheCoverageMetrics(provider);
515
+ return metrics.cacheCoverage !== undefined
516
+ ? formatPercent(metrics.cacheCoverage, 1)
517
+ : '-';
518
+ };
519
+ const cacheReadCoverageCell = (provider) => {
520
+ const metrics = getProviderCacheCoverageMetrics(provider);
521
+ return metrics.cacheReadCoverage !== undefined
522
+ ? formatPercent(metrics.cacheReadCoverage, 1)
523
+ : '-';
524
+ };
525
+ const providerEntries = Object.values(usage.providers).sort((a, b) => b.total - a.total);
526
+ const highlightLines = () => {
527
+ const lines = [];
528
+ const providerLabel = (providerID) => quotaDisplayLabel({
529
+ providerID,
530
+ label: providerID,
531
+ status: 'ok',
532
+ checkedAt: 0,
533
+ });
534
+ const topApiCost = providerEntries
535
+ .filter((provider) => provider.apiCost > 0)
536
+ .sort((a, b) => b.apiCost - a.apiCost)[0];
537
+ if (topApiCost) {
538
+ lines.push(`- Top API cost: ${quotaDisplayLabel({
539
+ providerID: topApiCost.providerID,
540
+ label: topApiCost.providerID,
541
+ status: 'ok',
542
+ checkedAt: 0,
543
+ })} (${formatUsd(topApiCost.apiCost)})`);
544
+ }
545
+ const bestCacheCoverage = providerEntries
546
+ .map((provider) => ({
547
+ provider,
548
+ value: getProviderCacheCoverageMetrics(provider).cacheCoverage,
549
+ }))
550
+ .filter((entry) => entry.value !== undefined)
551
+ .sort((a, b) => b.value - a.value)[0];
552
+ if (bestCacheCoverage) {
553
+ lines.push(`- Best Cache Coverage: ${providerLabel(bestCacheCoverage.provider.providerID)} (${formatPercent(bestCacheCoverage.value, 1)})`);
554
+ }
555
+ const bestCacheReadCoverage = providerEntries
556
+ .map((provider) => ({
557
+ provider,
558
+ value: getProviderCacheCoverageMetrics(provider).cacheReadCoverage,
559
+ }))
560
+ .filter((entry) => entry.value !== undefined)
561
+ .sort((a, b) => b.value - a.value)[0];
562
+ if (bestCacheReadCoverage) {
563
+ lines.push(`- Best Cache Read Coverage: ${providerLabel(bestCacheReadCoverage.provider.providerID)} (${formatPercent(bestCacheReadCoverage.value, 1)})`);
564
+ }
565
+ const highestMeasured = providerEntries
566
+ .filter((provider) => measuredCostCell(provider.providerID, provider.cost) !== '-')
567
+ .sort((a, b) => b.cost - a.cost)[0];
568
+ if (highestMeasured && highestMeasured.cost > 0) {
569
+ lines.push(`- Highest measured cost: ${providerLabel(highestMeasured.providerID)} (${formatUsd(highestMeasured.cost)})`);
570
+ }
571
+ return lines;
572
+ };
573
+ const providerRows = providerEntries
504
574
  .map((provider) => {
505
575
  const providerID = mdCell(provider.providerID);
506
576
  return showCost
507
- ? `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} | ${measuredCostCell(provider.providerID, provider.cost)} | ${apiCostCell(provider.providerID, provider.apiCost)} |`
577
+ ? `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} | ${cacheCoverageCell(provider)} | ${cacheReadCoverageCell(provider)} | ${measuredCostCell(provider.providerID, provider.cost)} | ${apiCostCell(provider.providerID, provider.apiCost)} |`
508
578
  : `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`;
509
579
  });
510
580
  const quotaLines = collapseQuotaSnapshots(quotas).flatMap((quota) => {
@@ -516,9 +586,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
516
586
  const winLabel = win.label ? ` (${win.label})` : '';
517
587
  return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
518
588
  }
519
- const remaining = win.remainingPercent === undefined
520
- ? '-'
521
- : `${win.remainingPercent.toFixed(1)}%`;
589
+ const remaining = formatQuotaPercent(win.remainingPercent);
522
590
  const winLabel = win.label ? ` (${win.label})` : '';
523
591
  return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
524
592
  });
@@ -537,9 +605,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
537
605
  mdCell(`- ${displayLabel}: ${quota.status}${quota.note ? ` | ${quota.note}` : ''}`),
538
606
  ];
539
607
  }
540
- const remaining = quota.remainingPercent === undefined
541
- ? '-'
542
- : `${quota.remainingPercent.toFixed(1)}%`;
608
+ const remaining = formatQuotaPercent(quota.remainingPercent);
543
609
  return [
544
610
  mdCell(`- ${displayLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
545
611
  ];
@@ -564,17 +630,20 @@ export function renderMarkdownReport(period, usage, quotas, options) {
564
630
  `- API cost: ${apiCostSummaryValue()}`,
565
631
  ]
566
632
  : []),
633
+ ...(highlightLines().length > 0
634
+ ? ['', '### Highlights', ...highlightLines()]
635
+ : []),
567
636
  '',
568
637
  '### Usage by Provider',
569
638
  showCost
570
- ? '| Provider | Input | Output | Cache | Total | Measured Cost | API Cost |'
639
+ ? '| Provider | Input | Output | Cache | Total | Cache Coverage | Cache Read Coverage | Measured Cost | API Cost |'
571
640
  : '| Provider | Input | Output | Cache | Total |',
572
641
  showCost
573
- ? '|---|---:|---:|---:|---:|---:|---:|'
642
+ ? '|---|---:|---:|---:|---:|---:|---:|---:|---:|'
574
643
  : '|---|---:|---:|---:|---:|',
575
644
  ...(providerRows.length
576
645
  ? providerRows
577
- : [showCost ? '| - | - | - | - | - | - | - |' : '| - | - | - | - | - |']),
646
+ : [showCost ? '| - | - | - | - | - | - | - | - | - |' : '| - | - | - | - | - |']),
578
647
  '',
579
648
  '### Subscription Quota',
580
649
  ...(quotaLines.length
@@ -638,14 +707,35 @@ export function renderToastMessage(period, usage, quotas, options) {
638
707
  lines.push(fitLine(hasAnyUsage ? ' N/A (Copilot)' : ' -', width));
639
708
  }
640
709
  }
710
+ const providerCachePairs = Object.values(usage.providers)
711
+ .map((provider) => {
712
+ const metrics = getProviderCacheCoverageMetrics(provider);
713
+ const parts = [];
714
+ if (metrics.cacheCoverage !== undefined) {
715
+ parts.push(`Cov ${formatPercent(metrics.cacheCoverage, 1)}`);
716
+ }
717
+ if (metrics.cacheReadCoverage !== undefined) {
718
+ parts.push(`Read ${formatPercent(metrics.cacheReadCoverage, 1)}`);
719
+ }
720
+ if (parts.length === 0)
721
+ return undefined;
722
+ return {
723
+ label: displayShortLabel(provider.providerID),
724
+ value: parts.join(' '),
725
+ };
726
+ })
727
+ .filter((item) => Boolean(item));
728
+ if (providerCachePairs.length > 0) {
729
+ lines.push('');
730
+ lines.push(fitLine('Provider Cache', width));
731
+ lines.push(...alignPairs(providerCachePairs).map((line) => fitLine(line, width)));
732
+ }
641
733
  const quotaPairs = collapseQuotaSnapshots(quotas).flatMap((item) => {
642
734
  if (item.status === 'ok') {
643
735
  if (item.windows && item.windows.length > 0) {
644
736
  const pairs = item.windows.map((win, idx) => {
645
737
  const showPercent = win.showPercent !== false;
646
- const pct = win.remainingPercent === undefined
647
- ? '-'
648
- : `${win.remainingPercent.toFixed(1)}%`;
738
+ const pct = formatQuotaPercent(win.remainingPercent);
649
739
  const reset = compactReset(win.resetAt, win.resetLabel, win.label);
650
740
  const parts = [win.label];
651
741
  if (showPercent)
@@ -673,9 +763,7 @@ export function renderToastMessage(period, usage, quotas, options) {
673
763
  },
674
764
  ];
675
765
  }
676
- const percent = item.remainingPercent === undefined
677
- ? '-'
678
- : `${item.remainingPercent.toFixed(1)}%`;
766
+ const percent = formatQuotaPercent(item.remainingPercent);
679
767
  const reset = compactReset(item.resetAt, 'Rst');
680
768
  return [
681
769
  {
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ import { createPersistenceScheduler } from './persistence.js';
12
12
  import { createQuotaService } from './quota_service.js';
13
13
  import { createUsageService } from './usage_service.js';
14
14
  import { createTitleApplicator } from './title_apply.js';
15
+ const SHUTDOWN_HOOK_KEY = Symbol.for('opencode-quota-sidebar.shutdown-hook');
16
+ const SHUTDOWN_CALLBACKS_KEY = Symbol.for('opencode-quota-sidebar.shutdown-callbacks');
15
17
  export async function QuotaSidebarPlugin(input) {
16
18
  const quotaRuntime = createQuotaRuntime();
17
19
  const configDir = resolveOpencodeConfigDir();
@@ -164,18 +166,61 @@ export async function QuotaSidebarPlugin(input) {
164
166
  restoreConcurrency: RESTORE_TITLE_CONCURRENCY,
165
167
  });
166
168
  const titleRefresh = createTitleRefreshScheduler({
167
- apply: titleApplicator.applyTitle,
169
+ apply: async (sessionID) => {
170
+ await titleApplicator.applyTitle(sessionID);
171
+ },
168
172
  onError: swallow('titleRefresh'),
169
173
  });
170
174
  scheduleTitleRefresh = titleRefresh.schedule;
171
175
  const restoreAllVisibleTitles = titleApplicator.restoreAllVisibleTitles;
172
176
  const refreshAllTouchedTitles = titleApplicator.refreshAllTouchedTitles;
173
177
  const refreshAllVisibleTitles = titleApplicator.refreshAllVisibleTitles;
178
+ let startupTitleWork = Promise.resolve();
179
+ const runStartupRestore = async (attempt = 0) => {
180
+ const result = await restoreAllVisibleTitles({
181
+ abortIfEnabled: config.sidebar.enabled,
182
+ });
183
+ if (result.restored === result.attempted)
184
+ return;
185
+ debug(`startup restore incomplete: restored ${result.restored}/${result.attempted} touched titles while display mode remains OFF`);
186
+ if (state.titleEnabled || config.sidebar.enabled === false)
187
+ return;
188
+ if (attempt >= 2)
189
+ return;
190
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
191
+ await runStartupRestore(attempt + 1);
192
+ };
174
193
  if (!state.titleEnabled || !config.sidebar.enabled) {
175
- void restoreAllVisibleTitles().catch(swallow('startup:restoreAllVisibleTitles'));
194
+ startupTitleWork = runStartupRestore().catch(swallow('startup:restoreAllVisibleTitles'));
176
195
  }
177
196
  else {
178
- void refreshAllTouchedTitles().catch(swallow('startup:refreshAllTouchedTitles'));
197
+ startupTitleWork = refreshAllTouchedTitles()
198
+ .then(() => undefined)
199
+ .catch(swallow('startup:refreshAllTouchedTitles'));
200
+ }
201
+ const shutdown = async () => {
202
+ await Promise.race([
203
+ startupTitleWork,
204
+ new Promise((resolve) => setTimeout(resolve, 5_000)),
205
+ ]).catch(swallow('shutdown:startupTitleWork'));
206
+ await titleRefresh.waitForQuiescence().catch(swallow('shutdown:titleQuiescence'));
207
+ await flushSave().catch(swallow('shutdown:flushSave'));
208
+ };
209
+ const processWithHook = process;
210
+ const shutdownCallbacks = (processWithHook[SHUTDOWN_CALLBACKS_KEY] ||= new Set());
211
+ shutdownCallbacks.add(shutdown);
212
+ if (!processWithHook[SHUTDOWN_HOOK_KEY]) {
213
+ processWithHook[SHUTDOWN_HOOK_KEY] = true;
214
+ process.once('beforeExit', () => {
215
+ void Promise.allSettled(Array.from(shutdownCallbacks).map((callback) => callback()));
216
+ });
217
+ for (const signal of ['SIGINT', 'SIGTERM']) {
218
+ process.once(signal, () => {
219
+ void Promise.allSettled(Array.from(shutdownCallbacks).map((callback) => callback())).finally(() => {
220
+ process.kill(process.pid, signal);
221
+ });
222
+ });
223
+ }
179
224
  }
180
225
  const showToast = async (period, message) => {
181
226
  await input.client.tui
@@ -268,9 +313,12 @@ export async function QuotaSidebarPlugin(input) {
268
313
  },
269
314
  scheduleSave,
270
315
  flushSave,
316
+ waitForStartupTitleWork: () => startupTitleWork,
271
317
  refreshSessionTitle: (sessionID, delay) => titleRefresh.schedule(sessionID, delay ?? 250),
272
318
  cancelAllTitleRefreshes: () => titleRefresh.cancelAll(),
319
+ flushScheduledTitleRefreshes: () => titleRefresh.flushScheduled(),
273
320
  waitForTitleRefreshIdle: () => titleRefresh.waitForIdle(),
321
+ waitForTitleRefreshQuiescence: () => titleRefresh.waitForQuiescence(),
274
322
  restoreAllVisibleTitles,
275
323
  refreshAllTouchedTitles,
276
324
  refreshAllVisibleTitles,
@@ -279,7 +327,10 @@ export async function QuotaSidebarPlugin(input) {
279
327
  getQuotaSnapshots,
280
328
  renderMarkdownReport,
281
329
  renderToastMessage,
282
- config,
330
+ config: {
331
+ sidebar: config.sidebar,
332
+ sidebarEnabled: config.sidebar.enabled,
333
+ },
283
334
  }),
284
335
  };
285
336
  }
@@ -4,6 +4,17 @@ export function createPersistenceScheduler(deps) {
4
4
  let stateDirty = false;
5
5
  let saveTimer;
6
6
  let saveInFlight = Promise.resolve();
7
+ const scheduleRetry = (delayMs = 1_000) => {
8
+ if (saveTimer)
9
+ return;
10
+ saveTimer = setTimeout(() => {
11
+ saveTimer = undefined;
12
+ void persist().catch((error) => {
13
+ debug(`persistState retry failed: ${String(error)}`);
14
+ scheduleRetry(Math.min(delayMs * 2, 10_000));
15
+ });
16
+ }, delayMs);
17
+ };
7
18
  /**
8
19
  * Capture and delete specific dirty keys instead of clearing the whole set.
9
20
  * Keys added between capture and write completion are preserved.
@@ -34,7 +45,10 @@ export function createPersistenceScheduler(deps) {
34
45
  clearTimeout(saveTimer);
35
46
  saveTimer = setTimeout(() => {
36
47
  saveTimer = undefined;
37
- void persist().catch(swallow('persistState:save'));
48
+ void persist().catch((error) => {
49
+ swallow('persistState:save')(error);
50
+ scheduleRetry();
51
+ });
38
52
  }, 200);
39
53
  };
40
54
  const flushSave = async () => {
@@ -11,6 +11,7 @@ export function createQuotaService(deps) {
11
11
  const authCache = new TtlValueCache();
12
12
  const providerOptionsCache = new TtlValueCache();
13
13
  const inFlight = new Map();
14
+ let lastSuccessfulProviderOptionsMap = {};
14
15
  const authFingerprint = (auth) => {
15
16
  if (!auth || typeof auth !== 'object')
16
17
  return undefined;
@@ -46,7 +47,7 @@ export function createQuotaService(deps) {
46
47
  if (cached)
47
48
  return cached;
48
49
  const value = await loadAuthMap(deps.authPath);
49
- return authCache.set(value, 30_000);
50
+ return authCache.set(value, 5_000);
50
51
  };
51
52
  const getProviderOptionsMap = async () => {
52
53
  const cached = providerOptionsCache.get();
@@ -58,23 +59,127 @@ export function createQuotaService(deps) {
58
59
  }
59
60
  // Newer runtimes expose config.providers; older clients may only expose
60
61
  // provider.list with a slightly different response shape.
61
- const response = await (client.config?.providers
62
- ? client.config.providers({
62
+ let response;
63
+ let fromConfigProviders = false;
64
+ if (client.config?.providers) {
65
+ fromConfigProviders = true;
66
+ response = await client.config
67
+ .providers({
63
68
  query: { directory: deps.directory },
64
69
  throwOnError: true,
65
70
  })
66
- : client.provider.list({
71
+ .catch(swallow('getProviderOptionsMap:configProviders'));
72
+ }
73
+ if (!response && client.provider?.list) {
74
+ response = await client.provider
75
+ .list({
67
76
  query: { directory: deps.directory },
68
77
  throwOnError: true,
69
- })).catch(swallow('getProviderOptionsMap'));
70
- const data = isRecord(response) && isRecord(response.data) ? response.data : undefined;
71
- const list = Array.isArray(data?.providers)
72
- ? data.providers
73
- : Array.isArray(data?.all)
74
- ? data.all
78
+ })
79
+ .catch(swallow('getProviderOptionsMap:providerList'));
80
+ }
81
+ const data = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
82
+ ? response.data
83
+ : undefined;
84
+ if (!response || data === undefined) {
85
+ if (client.provider?.list && fromConfigProviders) {
86
+ response = await client.provider
87
+ .list({
88
+ query: { directory: deps.directory },
89
+ throwOnError: true,
90
+ })
91
+ .catch(swallow('getProviderOptionsMap:providerListNoDataFallback'));
92
+ const fallbackData = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
93
+ ? response.data
94
+ : undefined;
95
+ const fallbackRecord = isRecord(fallbackData) ? fallbackData : undefined;
96
+ const fallbackList = Array.isArray(fallbackRecord?.providers)
97
+ ? fallbackRecord.providers
98
+ : Array.isArray(fallbackRecord?.all)
99
+ ? fallbackRecord.all
100
+ : Array.isArray(fallbackData)
101
+ ? fallbackData
102
+ : undefined;
103
+ const map = Array.isArray(fallbackList)
104
+ ? fallbackList.reduce((acc, item) => {
105
+ if (!item || typeof item !== 'object')
106
+ return acc;
107
+ const record = item;
108
+ const id = record.id;
109
+ const options = record.options;
110
+ if (typeof id !== 'string')
111
+ return acc;
112
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
113
+ acc[id] = {};
114
+ return acc;
115
+ }
116
+ acc[id] = options;
117
+ return acc;
118
+ }, {})
119
+ : {};
120
+ if (Object.keys(map).length > 0) {
121
+ lastSuccessfulProviderOptionsMap = map;
122
+ return providerOptionsCache.set(map, 5_000);
123
+ }
124
+ }
125
+ return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
126
+ ? lastSuccessfulProviderOptionsMap
127
+ : {};
128
+ }
129
+ const dataRecord = isRecord(data) ? data : undefined;
130
+ const list = Array.isArray(dataRecord?.providers)
131
+ ? dataRecord.providers
132
+ : Array.isArray(dataRecord?.all)
133
+ ? dataRecord.all
75
134
  : Array.isArray(data)
76
135
  ? data
77
136
  : undefined;
137
+ if (!list && fromConfigProviders && client.provider?.list) {
138
+ response = await client.provider
139
+ .list({
140
+ query: { directory: deps.directory },
141
+ throwOnError: true,
142
+ })
143
+ .catch(swallow('getProviderOptionsMap:providerListFallback'));
144
+ const fallbackData = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
145
+ ? response.data
146
+ : undefined;
147
+ const fallbackRecord = isRecord(fallbackData) ? fallbackData : undefined;
148
+ const fallbackList = Array.isArray(fallbackRecord?.providers)
149
+ ? fallbackRecord.providers
150
+ : Array.isArray(fallbackRecord?.all)
151
+ ? fallbackRecord.all
152
+ : Array.isArray(fallbackData)
153
+ ? fallbackData
154
+ : undefined;
155
+ const map = Array.isArray(fallbackList)
156
+ ? fallbackList.reduce((acc, item) => {
157
+ if (!item || typeof item !== 'object')
158
+ return acc;
159
+ const record = item;
160
+ const id = record.id;
161
+ const options = record.options;
162
+ if (typeof id !== 'string')
163
+ return acc;
164
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
165
+ acc[id] = {};
166
+ return acc;
167
+ }
168
+ acc[id] = options;
169
+ return acc;
170
+ }, {})
171
+ : {};
172
+ if (Object.keys(map).length > 0) {
173
+ lastSuccessfulProviderOptionsMap = map;
174
+ return providerOptionsCache.set(map, 5_000);
175
+ }
176
+ if (!Array.isArray(fallbackList)) {
177
+ return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
178
+ ? lastSuccessfulProviderOptionsMap
179
+ : {};
180
+ }
181
+ return providerOptionsCache.set(map, 5_000);
182
+ }
78
183
  const map = Array.isArray(list)
79
184
  ? list.reduce((acc, item) => {
80
185
  if (!item || typeof item !== 'object')
@@ -94,6 +199,15 @@ export function createQuotaService(deps) {
94
199
  return acc;
95
200
  }, {})
96
201
  : {};
202
+ if (Object.keys(map).length > 0) {
203
+ lastSuccessfulProviderOptionsMap = map;
204
+ return providerOptionsCache.set(map, 5_000);
205
+ }
206
+ if (!Array.isArray(list)) {
207
+ return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
208
+ ? lastSuccessfulProviderOptionsMap
209
+ : providerOptionsCache.set(map, 5_000);
210
+ }
97
211
  return providerOptionsCache.set(map, 5_000);
98
212
  };
99
213
  const isValidQuotaCache = (snapshot) => {
@@ -276,7 +390,11 @@ export function createQuotaService(deps) {
276
390
  body: next,
277
391
  throwOnError: true,
278
392
  })
279
- .catch(swallow('getQuotaSnapshots:authSet'));
393
+ .catch((error) => {
394
+ swallow('getQuotaSnapshots:authSet')(error);
395
+ throw error;
396
+ });
397
+ authCache.clear();
280
398
  }, providerOptions)
281
399
  .then((latest) => {
282
400
  if (!latest)
@@ -27,6 +27,7 @@ function parseProviderUsage(value) {
27
27
  cost: asNumber(value.cost, 0),
28
28
  apiCost: asNumber(value.apiCost, 0),
29
29
  assistantMessages: asNumber(value.assistantMessages, 0),
30
+ cacheBuckets: parseCacheUsageBuckets(value.cacheBuckets),
30
31
  };
31
32
  }
32
33
  function parseCacheUsageBucket(value) {
@@ -17,16 +17,32 @@ export declare function createTitleApplicator(deps: {
17
17
  scheduleParentRefreshIfSafe: (sessionID: string, parentID?: string) => void;
18
18
  restoreConcurrency: number;
19
19
  }): {
20
- applyTitle: (sessionID: string) => Promise<void>;
20
+ applyTitle: (sessionID: string) => Promise<boolean>;
21
21
  handleSessionUpdatedTitle: (args: {
22
22
  sessionID: string;
23
23
  incomingTitle: string;
24
24
  sessionState: SessionState;
25
25
  scheduleRefresh: (sessionID: string, delay?: number) => void;
26
26
  }) => Promise<void>;
27
- restoreSessionTitle: (sessionID: string) => Promise<void>;
28
- restoreAllVisibleTitles: () => Promise<void>;
29
- refreshAllTouchedTitles: () => Promise<void>;
30
- refreshAllVisibleTitles: () => Promise<void>;
27
+ restoreSessionTitle: (sessionID: string, options?: {
28
+ abortIfEnabled?: boolean;
29
+ }) => Promise<boolean>;
30
+ restoreAllVisibleTitles: (options?: {
31
+ abortIfEnabled?: boolean;
32
+ }) => Promise<{
33
+ attempted: number;
34
+ restored: number;
35
+ listFailed: boolean;
36
+ }>;
37
+ refreshAllTouchedTitles: () => Promise<{
38
+ attempted: number;
39
+ refreshed: number;
40
+ listFailed: boolean;
41
+ }>;
42
+ refreshAllVisibleTitles: () => Promise<{
43
+ attempted: number;
44
+ refreshed: number;
45
+ listFailed: boolean;
46
+ }>;
31
47
  forgetSession: (sessionID: string) => void;
32
48
  };
@@ -2,12 +2,14 @@ import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, } from
2
2
  import { swallow, debug, mapConcurrent } from './helpers.js';
3
3
  export function createTitleApplicator(deps) {
4
4
  const pendingAppliedTitle = new Map();
5
+ const recentRestore = new Map();
5
6
  const forgetSession = (sessionID) => {
6
7
  pendingAppliedTitle.delete(sessionID);
8
+ recentRestore.delete(sessionID);
7
9
  };
8
10
  const applyTitle = async (sessionID) => {
9
11
  if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
10
- return;
12
+ return false;
11
13
  let stateMutated = false;
12
14
  const session = await deps.client.session
13
15
  .get({
@@ -17,7 +19,14 @@ export function createTitleApplicator(deps) {
17
19
  })
18
20
  .catch(swallow('applyTitle:getSession'));
19
21
  if (!session)
20
- return;
22
+ return false;
23
+ if (!session.data ||
24
+ typeof session.data.title !== 'string' ||
25
+ !session.data.time ||
26
+ typeof session.data.time.created !== 'number') {
27
+ debug(`applyTitle skipped malformed session payload for ${sessionID}`);
28
+ return false;
29
+ }
21
30
  const sessionState = deps.ensureSessionState(sessionID, session.data.title, session.data.time.created, session.data.parentID ?? null);
22
31
  // Detect whether the current title is our own decorated form.
23
32
  const currentTitle = session.data.title;
@@ -62,7 +71,7 @@ export function createTitleApplicator(deps) {
62
71
  : [];
63
72
  const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config);
64
73
  if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
65
- return;
74
+ return false;
66
75
  if (canonicalizeTitleForCompare(nextTitle) ===
67
76
  canonicalizeTitleForCompare(session.data.title)) {
68
77
  if (looksDecorated(session.data.title)) {
@@ -76,7 +85,7 @@ export function createTitleApplicator(deps) {
76
85
  }
77
86
  deps.scheduleSave();
78
87
  deps.scheduleParentRefreshIfSafe(sessionID, sessionState.parentID);
79
- return;
88
+ return true;
80
89
  }
81
90
  // Mark pending title to ignore the immediate echo `session.updated` event.
82
91
  // H3 fix: use longer TTL (15s) and add decoration detection as backup.
@@ -100,11 +109,12 @@ export function createTitleApplicator(deps) {
100
109
  sessionState.lastAppliedTitle = previousApplied;
101
110
  deps.scheduleSave();
102
111
  deps.scheduleParentRefreshIfSafe(sessionID, sessionState.parentID);
103
- return;
112
+ return false;
104
113
  }
105
114
  pendingAppliedTitle.delete(sessionID);
106
115
  deps.scheduleSave();
107
116
  deps.scheduleParentRefreshIfSafe(sessionID, sessionState.parentID);
117
+ return true;
108
118
  };
109
119
  const handleSessionUpdatedTitle = async (args) => {
110
120
  const pending = pendingAppliedTitle.get(args.sessionID);
@@ -138,13 +148,32 @@ export function createTitleApplicator(deps) {
138
148
  return;
139
149
  }
140
150
  }
151
+ if (looksDecorated(args.incomingTitle) && !args.sessionState.lastAppliedTitle) {
152
+ debug(`ignoring untracked decorated title for session ${args.sessionID}`);
153
+ return;
154
+ }
155
+ const restored = recentRestore.get(args.sessionID);
156
+ if (restored) {
157
+ if (restored.expiresAt <= Date.now()) {
158
+ recentRestore.delete(args.sessionID);
159
+ }
160
+ else if (looksDecorated(args.incomingTitle) &&
161
+ (!restored.decoratedTitle ||
162
+ canonicalizeTitleForCompare(args.incomingTitle) ===
163
+ canonicalizeTitleForCompare(restored.decoratedTitle))) {
164
+ debug(`ignoring decorated echo after restore for session ${args.sessionID}`);
165
+ return;
166
+ }
167
+ }
141
168
  args.sessionState.baseTitle = canonicalizeTitle(args.incomingTitle) || 'Session';
142
169
  args.sessionState.lastAppliedTitle = undefined;
143
170
  deps.markDirty(deps.state.sessionDateMap[args.sessionID]);
144
171
  deps.scheduleSave();
145
172
  args.scheduleRefresh(args.sessionID);
146
173
  };
147
- const restoreSessionTitle = async (sessionID) => {
174
+ const restoreSessionTitle = async (sessionID, options) => {
175
+ if (options?.abortIfEnabled && deps.state.titleEnabled)
176
+ return false;
148
177
  const session = await deps.client.session
149
178
  .get({
150
179
  path: { id: sessionID },
@@ -153,7 +182,14 @@ export function createTitleApplicator(deps) {
153
182
  })
154
183
  .catch(swallow('restoreSessionTitle:get'));
155
184
  if (!session)
156
- return;
185
+ return false;
186
+ if (!session.data ||
187
+ typeof session.data.title !== 'string' ||
188
+ !session.data.time ||
189
+ typeof session.data.time.created !== 'number') {
190
+ debug(`restoreSessionTitle skipped malformed session payload for ${sessionID}`);
191
+ return false;
192
+ }
157
193
  const sessionState = deps.ensureSessionState(sessionID, session.data.title, session.data.time.created, session.data.parentID ?? null);
158
194
  const baseTitle = canonicalizeTitle(sessionState.baseTitle) || 'Session';
159
195
  if (session.data.title === baseTitle) {
@@ -162,8 +198,10 @@ export function createTitleApplicator(deps) {
162
198
  deps.markDirty(deps.state.sessionDateMap[sessionID]);
163
199
  deps.scheduleSave();
164
200
  }
165
- return;
201
+ return true;
166
202
  }
203
+ if (options?.abortIfEnabled && deps.state.titleEnabled)
204
+ return false;
167
205
  const updated = await deps.client.session
168
206
  .update({
169
207
  path: { id: sessionID },
@@ -173,26 +211,39 @@ export function createTitleApplicator(deps) {
173
211
  })
174
212
  .catch(swallow('restoreSessionTitle:update'));
175
213
  if (!updated)
176
- return;
214
+ return false;
215
+ pendingAppliedTitle.delete(sessionID);
216
+ recentRestore.set(sessionID, {
217
+ baseTitle,
218
+ decoratedTitle: sessionState.lastAppliedTitle,
219
+ expiresAt: Date.now() + 15_000,
220
+ });
177
221
  sessionState.lastAppliedTitle = undefined;
178
222
  deps.markDirty(deps.state.sessionDateMap[sessionID]);
179
223
  deps.scheduleSave();
224
+ return true;
180
225
  };
181
- const restoreAllVisibleTitles = async () => {
226
+ const restoreAllVisibleTitles = async (options) => {
182
227
  const touched = Object.entries(deps.state.sessions)
183
228
  .filter(([, sessionState]) => Boolean(sessionState.lastAppliedTitle))
184
229
  .map(([sessionID]) => sessionID);
185
- await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => {
186
- await restoreSessionTitle(sessionID);
187
- });
230
+ const results = await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => restoreSessionTitle(sessionID, options));
231
+ return {
232
+ attempted: touched.length,
233
+ restored: results.filter(Boolean).length,
234
+ listFailed: false,
235
+ };
188
236
  };
189
237
  const refreshAllTouchedTitles = async () => {
190
238
  const touched = Object.entries(deps.state.sessions)
191
239
  .filter(([, sessionState]) => Boolean(sessionState.lastAppliedTitle))
192
240
  .map(([sessionID]) => sessionID);
193
- await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => {
194
- await applyTitle(sessionID);
195
- });
241
+ const results = await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => applyTitle(sessionID));
242
+ return {
243
+ attempted: touched.length,
244
+ refreshed: results.filter(Boolean).length,
245
+ listFailed: false,
246
+ };
196
247
  };
197
248
  const refreshAllVisibleTitles = async () => {
198
249
  const list = await deps.client.session
@@ -201,11 +252,16 @@ export function createTitleApplicator(deps) {
201
252
  throwOnError: true,
202
253
  })
203
254
  .catch(swallow('refreshAllVisibleTitles:list'));
204
- if (!list?.data)
205
- return;
206
- await mapConcurrent(list.data, deps.restoreConcurrency, async (session) => {
207
- await applyTitle(session.id);
208
- });
255
+ if (!list?.data || !Array.isArray(list.data)) {
256
+ return { attempted: 0, refreshed: 0, listFailed: true };
257
+ }
258
+ const sessions = list.data.filter((session) => Boolean(session && typeof session.id === 'string'));
259
+ const results = await mapConcurrent(sessions, deps.restoreConcurrency, async (session) => applyTitle(session.id));
260
+ return {
261
+ attempted: sessions.length,
262
+ refreshed: results.filter(Boolean).length,
263
+ listFailed: false,
264
+ };
209
265
  };
210
266
  return {
211
267
  applyTitle,
@@ -6,6 +6,8 @@ export declare function createTitleRefreshScheduler(options: {
6
6
  apply: (sessionID: string) => Promise<void>;
7
7
  cancel: (sessionID: string) => void;
8
8
  cancelAll: () => void;
9
- waitForIdle: () => Promise<void>;
9
+ flushScheduled: () => Promise<void>;
10
+ waitForIdle: (timeoutMs?: number) => Promise<void>;
11
+ waitForQuiescence: (budgetMs?: number) => Promise<void>;
10
12
  dispose: () => void;
11
13
  };
@@ -36,11 +36,32 @@ export function createTitleRefreshScheduler(options) {
36
36
  clearTimeout(timer);
37
37
  refreshTimer.clear();
38
38
  };
39
- const waitForIdle = async () => {
39
+ const flushScheduled = async () => {
40
+ const pending = Array.from(refreshTimer.keys());
41
+ cancelAll();
42
+ await Promise.allSettled(pending.map((sessionID) => applyLocked(sessionID)));
43
+ };
44
+ const waitForIdle = async (timeoutMs) => {
40
45
  const inflight = Array.from(applyLocks.values());
41
46
  if (inflight.length === 0)
42
47
  return;
43
- await Promise.allSettled(inflight);
48
+ if (timeoutMs === undefined) {
49
+ await Promise.allSettled(inflight);
50
+ return;
51
+ }
52
+ await Promise.race([
53
+ Promise.allSettled(inflight),
54
+ new Promise((resolve) => setTimeout(resolve, timeoutMs)),
55
+ ]);
56
+ };
57
+ const waitForQuiescence = async (budgetMs = 10_000) => {
58
+ const deadline = Date.now() + budgetMs;
59
+ while (Date.now() < deadline) {
60
+ await flushScheduled();
61
+ await waitForIdle(Math.max(0, deadline - Date.now()));
62
+ if (refreshTimer.size === 0 && applyLocks.size === 0)
63
+ return;
64
+ }
44
65
  };
45
66
  const dispose = () => {
46
67
  cancelAll();
@@ -51,7 +72,9 @@ export function createTitleRefreshScheduler(options) {
51
72
  apply: applyLocked,
52
73
  cancel,
53
74
  cancelAll,
75
+ flushScheduled,
54
76
  waitForIdle,
77
+ waitForQuiescence,
55
78
  dispose,
56
79
  };
57
80
  }
package/dist/tools.d.ts CHANGED
@@ -5,12 +5,27 @@ export declare function createQuotaSidebarTools(deps: {
5
5
  setTitleEnabled: (enabled: boolean) => void;
6
6
  scheduleSave: () => void;
7
7
  flushSave: () => Promise<void>;
8
+ waitForStartupTitleWork: () => Promise<void>;
8
9
  refreshSessionTitle: (sessionID: string, delay?: number) => void;
9
10
  cancelAllTitleRefreshes: () => void;
11
+ flushScheduledTitleRefreshes: () => Promise<void>;
10
12
  waitForTitleRefreshIdle: () => Promise<void>;
11
- restoreAllVisibleTitles: () => Promise<void>;
12
- refreshAllTouchedTitles: () => Promise<void>;
13
- refreshAllVisibleTitles: () => Promise<void>;
13
+ waitForTitleRefreshQuiescence: () => Promise<void>;
14
+ restoreAllVisibleTitles: () => Promise<{
15
+ attempted: number;
16
+ restored: number;
17
+ listFailed: boolean;
18
+ }>;
19
+ refreshAllTouchedTitles: () => Promise<{
20
+ attempted: number;
21
+ refreshed: number;
22
+ listFailed: boolean;
23
+ }>;
24
+ refreshAllVisibleTitles: () => Promise<{
25
+ attempted: number;
26
+ refreshed: number;
27
+ listFailed: boolean;
28
+ }>;
14
29
  showToast: (period: 'session' | 'day' | 'week' | 'month' | 'toggle', message: string) => Promise<void>;
15
30
  summarizeForTool: (period: 'session' | 'day' | 'week' | 'month', sessionID: string, includeChildren: boolean) => Promise<UsageSummary>;
16
31
  getQuotaSnapshots: (providerIDs: string[], options?: {
@@ -29,6 +44,7 @@ export declare function createQuotaSidebarTools(deps: {
29
44
  width: number;
30
45
  includeChildren: boolean;
31
46
  };
47
+ sidebarEnabled: boolean;
32
48
  };
33
49
  }): {
34
50
  quota_summary: {
package/dist/tools.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import { tool } from '@opencode-ai/plugin/tool';
2
2
  const z = tool.schema;
3
3
  export function createQuotaSidebarTools(deps) {
4
+ let toggleLock = Promise.resolve();
5
+ const waitForStartupTitleWork = async () => {
6
+ const timedOut = await Promise.race([
7
+ deps.waitForStartupTitleWork(),
8
+ new Promise((resolve) => setTimeout(() => resolve('timeout'), 3_000)),
9
+ ]);
10
+ return timedOut === 'timeout';
11
+ };
4
12
  return {
5
13
  quota_summary: tool({
6
14
  description: 'Show usage and quota summary for session/day/week/month.',
@@ -42,25 +50,59 @@ export function createQuotaSidebarTools(deps) {
42
50
  .describe('Explicit on/off. Omit to toggle current state.'),
43
51
  },
44
52
  execute: async (args, context) => {
45
- const current = deps.getTitleEnabled();
46
- const next = args.enabled !== undefined ? args.enabled : !current;
47
- deps.setTitleEnabled(next);
48
- deps.scheduleSave();
49
- await deps.flushSave();
50
- if (next) {
51
- // Turning on — refresh visible sessions, plus touched sessions as backup.
53
+ const run = async () => {
54
+ const current = deps.getTitleEnabled();
55
+ const next = args.enabled !== undefined ? args.enabled : !current;
56
+ if (next) {
57
+ if (!deps.config.sidebarEnabled) {
58
+ return 'Sidebar usage display cannot be enabled because `sidebar.enabled=false` in config. Re-enable the sidebar feature first.';
59
+ }
60
+ const startupTimedOut = await waitForStartupTitleWork();
61
+ deps.setTitleEnabled(true);
62
+ deps.scheduleSave();
63
+ await deps.flushSave();
64
+ const visible = await deps.refreshAllVisibleTitles();
65
+ const touched = await deps.refreshAllTouchedTitles();
66
+ deps.refreshSessionTitle(context.sessionID, 0);
67
+ if (startupTimedOut) {
68
+ void deps.waitForStartupTitleWork().then(() => {
69
+ if (!deps.getTitleEnabled())
70
+ return;
71
+ void deps.refreshAllVisibleTitles();
72
+ void deps.refreshAllTouchedTitles();
73
+ deps.refreshSessionTitle(context.sessionID, 0);
74
+ });
75
+ }
76
+ await deps.showToast('toggle', 'Sidebar usage display: ON');
77
+ if (visible.listFailed ||
78
+ visible.refreshed < visible.attempted ||
79
+ touched.refreshed < touched.attempted) {
80
+ return 'Sidebar usage display is now ON. Visible-session refresh failed, so only touched/current session titles are guaranteed to refresh immediately.';
81
+ }
82
+ return 'Sidebar usage display is now ON. Visible session titles are refreshing to show token usage and quota.';
83
+ }
84
+ deps.setTitleEnabled(false);
85
+ deps.scheduleSave();
86
+ await deps.flushSave();
87
+ deps.cancelAllTitleRefreshes();
88
+ await deps.waitForTitleRefreshQuiescence();
89
+ const restore = await deps.restoreAllVisibleTitles();
90
+ if (restore.restored === restore.attempted) {
91
+ await deps.showToast('toggle', 'Sidebar usage display: OFF');
92
+ return 'Sidebar usage display is now OFF. Touched session titles were restored to base titles.';
93
+ }
94
+ deps.setTitleEnabled(true);
95
+ deps.scheduleSave();
96
+ await deps.flushSave();
52
97
  await deps.refreshAllVisibleTitles();
53
98
  await deps.refreshAllTouchedTitles();
54
99
  deps.refreshSessionTitle(context.sessionID, 0);
55
- await deps.showToast('toggle', 'Sidebar usage display: ON');
56
- return 'Sidebar usage display is now ON. Visible session titles are refreshing to show token usage and quota.';
57
- }
58
- // Turning off restore all touched sessions to base titles
59
- deps.cancelAllTitleRefreshes();
60
- await deps.waitForTitleRefreshIdle();
61
- await deps.restoreAllVisibleTitles();
62
- await deps.showToast('toggle', 'Sidebar usage display: OFF');
63
- return 'Sidebar usage display is now OFF. Restore was attempted for touched session titles.';
100
+ await deps.showToast('toggle', 'Sidebar usage display: OFF failed');
101
+ return 'Sidebar usage display remains ON because some touched session titles could not be restored. Try again after the session service recovers.';
102
+ };
103
+ const pending = toggleLock.then(run, run);
104
+ toggleLock = pending.then(() => undefined, () => undefined);
105
+ return pending;
64
106
  },
65
107
  }),
66
108
  };
package/dist/types.d.ts CHANGED
@@ -72,6 +72,8 @@ export type CachedProviderUsage = {
72
72
  /** Equivalent API billing cost (USD) computed from model pricing. */
73
73
  apiCost: number;
74
74
  assistantMessages: number;
75
+ /** Provider-level cache coverage buckets grouped by model cache behavior. */
76
+ cacheBuckets?: CacheUsageBuckets;
75
77
  };
76
78
  export type CachedSessionUsage = {
77
79
  /** Billing aggregation cache version for cost/apiCost refresh migrations. */
package/dist/usage.d.ts CHANGED
@@ -19,6 +19,7 @@ export type ProviderUsage = {
19
19
  cost: number;
20
20
  apiCost: number;
21
21
  assistantMessages: number;
22
+ cacheBuckets?: CacheUsageBuckets;
22
23
  };
23
24
  export type UsageSummary = {
24
25
  input: number;
@@ -42,6 +43,7 @@ export type UsageOptions = {
42
43
  classifyCacheMode?: (message: AssistantMessage) => CacheCoverageMode;
43
44
  };
44
45
  export declare function getCacheCoverageMetrics(usage: Pick<UsageSummary, 'input' | 'cacheRead' | 'cacheWrite' | 'assistantMessages' | 'cacheBuckets'>): CacheCoverageMetrics;
46
+ export declare function getProviderCacheCoverageMetrics(usage: Pick<ProviderUsage, 'input' | 'cacheRead' | 'cacheWrite' | 'assistantMessages' | 'cacheBuckets'>): CacheCoverageMetrics;
45
47
  export declare function emptyUsageSummary(): UsageSummary;
46
48
  export declare function summarizeMessages(entries: Array<{
47
49
  info: Message;
package/dist/usage.js CHANGED
@@ -120,6 +120,9 @@ export function getCacheCoverageMetrics(usage) {
120
120
  : undefined,
121
121
  };
122
122
  }
123
+ export function getProviderCacheCoverageMetrics(usage) {
124
+ return getCacheCoverageMetrics(usage);
125
+ }
123
126
  export function emptyUsageSummary() {
124
127
  return {
125
128
  input: 0,
@@ -147,6 +150,7 @@ function emptyProviderUsage(providerID) {
147
150
  cost: 0,
148
151
  apiCost: 0,
149
152
  assistantMessages: 0,
153
+ cacheBuckets: undefined,
150
154
  };
151
155
  }
152
156
  function isAssistant(message) {
@@ -194,10 +198,14 @@ function addMessageUsage(target, message, options) {
194
198
  if (cacheMode === 'read-only') {
195
199
  const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
196
200
  addMessageCacheUsage(buckets.readOnly, message);
201
+ const providerBuckets = (provider.cacheBuckets ||= emptyCacheUsageBuckets());
202
+ addMessageCacheUsage(providerBuckets.readOnly, message);
197
203
  }
198
204
  else if (cacheMode === 'read-write') {
199
205
  const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
200
206
  addMessageCacheUsage(buckets.readWrite, message);
207
+ const providerBuckets = (provider.cacheBuckets ||= emptyCacheUsageBuckets());
208
+ addMessageCacheUsage(providerBuckets.readWrite, message);
201
209
  }
202
210
  }
203
211
  function completedTimeOf(message) {
@@ -445,6 +453,11 @@ export function mergeUsage(target, source, options) {
445
453
  }
446
454
  existing.apiCost += provider.apiCost;
447
455
  existing.assistantMessages += provider.assistantMessages;
456
+ if (provider.cacheBuckets) {
457
+ const providerBuckets = (existing.cacheBuckets ||= emptyCacheUsageBuckets());
458
+ mergeCacheUsageBucket(providerBuckets.readOnly, provider.cacheBuckets.readOnly);
459
+ mergeCacheUsageBucket(providerBuckets.readWrite, provider.cacheBuckets.readWrite);
460
+ }
448
461
  target.providers[provider.providerID] = existing;
449
462
  }
450
463
  return target;
@@ -462,6 +475,7 @@ export function toCachedSessionUsage(summary) {
462
475
  cost: provider.cost,
463
476
  apiCost: provider.apiCost,
464
477
  assistantMessages: provider.assistantMessages,
478
+ cacheBuckets: cloneCacheUsageBuckets(provider.cacheBuckets),
465
479
  };
466
480
  return acc;
467
481
  }, {});
@@ -509,6 +523,7 @@ export function fromCachedSessionUsage(cached, sessionCount = 1) {
509
523
  cost: provider.cost,
510
524
  apiCost: provider.apiCost || 0,
511
525
  assistantMessages: provider.assistantMessages,
526
+ cacheBuckets: cloneCacheUsageBuckets(provider.cacheBuckets),
512
527
  };
513
528
  return acc;
514
529
  }, {}),
@@ -210,6 +210,7 @@ export function createUsageService(deps) {
210
210
  .filter((item) => Boolean(item));
211
211
  if (decoded.length > 0 && decoded.length < value.length) {
212
212
  debug(`message entries partially decoded: kept ${decoded.length}/${value.length}`);
213
+ return undefined;
213
214
  }
214
215
  // If the API returned entries but none match the expected shape,
215
216
  // treat it as a load failure so we don't silently undercount.
@@ -260,7 +261,7 @@ export function createUsageService(deps) {
260
261
  key.startsWith(`${canonicalProviderID}:`));
261
262
  });
262
263
  };
263
- const summarizeSessionUsage = async (sessionID, generationAtStart) => {
264
+ const summarizeSessionUsage = async (sessionID, generationAtStart, options) => {
264
265
  const entries = await loadSessionEntries(sessionID);
265
266
  const sessionState = deps.state.sessions[sessionID];
266
267
  // If we can't load messages (transient API failure), fall back to cached
@@ -272,6 +273,9 @@ export function createUsageService(deps) {
272
273
  persist: false,
273
274
  };
274
275
  }
276
+ if (options?.requireEntries) {
277
+ throw new Error(`session usage unavailable: failed to load messages for ${sessionID}`);
278
+ }
275
279
  const empty = emptyUsageSummary();
276
280
  empty.sessionCount = 1;
277
281
  return { usage: empty, persist: false };
@@ -301,7 +305,7 @@ export function createUsageService(deps) {
301
305
  }
302
306
  return { usage, persist: true };
303
307
  };
304
- const summarizeSessionUsageLocked = async (sessionID) => {
308
+ const summarizeSessionUsageLocked = async (sessionID, options) => {
305
309
  for (let attempt = 0; attempt < 2; attempt++) {
306
310
  const generationAtStart = dirtyGeneration.get(sessionID) || 0;
307
311
  const existing = usageInFlight.get(sessionID);
@@ -311,13 +315,15 @@ export function createUsageService(deps) {
311
315
  continue;
312
316
  return result;
313
317
  }
314
- const promise = summarizeSessionUsage(sessionID, generationAtStart);
318
+ const promise = summarizeSessionUsage(sessionID, generationAtStart, options);
315
319
  const entry = { generation: generationAtStart, promise };
316
- promise.finally(() => {
320
+ void promise
321
+ .finally(() => {
317
322
  const current = usageInFlight.get(sessionID);
318
323
  if (current?.promise === promise)
319
324
  usageInFlight.delete(sessionID);
320
- });
325
+ })
326
+ .catch(() => undefined);
321
327
  usageInFlight.set(sessionID, entry);
322
328
  const result = await promise;
323
329
  if ((dirtyGeneration.get(sessionID) || 0) !== generationAtStart)
@@ -345,8 +351,11 @@ export function createUsageService(deps) {
345
351
  maxSessions: deps.config.sidebar.childrenMaxSessions,
346
352
  concurrency: deps.config.sidebar.childrenConcurrency,
347
353
  });
348
- if (descendantIDs.length === 0)
354
+ if (descendantIDs.length === 0) {
355
+ if (dirty)
356
+ deps.persistence.scheduleSave();
349
357
  return usage;
358
+ }
350
359
  const merged = emptyUsageSummary();
351
360
  mergeUsage(merged, usage);
352
361
  const needsFetch = [];
@@ -424,6 +433,7 @@ export function createUsageService(deps) {
424
433
  dateKey: session.dateKey,
425
434
  createdAt: session.state.createdAt,
426
435
  lastMessageTime: session.state.cursor?.lastMessageTime,
436
+ dirty: session.state.dirty === true,
427
437
  computed: emptyUsageSummary(),
428
438
  fullUsage: undefined,
429
439
  loadFailed: true,
@@ -442,6 +452,7 @@ export function createUsageService(deps) {
442
452
  dateKey: session.dateKey,
443
453
  createdAt: session.state.createdAt,
444
454
  lastMessageTime: session.state.cursor?.lastMessageTime,
455
+ dirty: session.state.dirty === true,
445
456
  computed,
446
457
  fullUsage: undefined,
447
458
  loadFailed: false,
@@ -458,6 +469,7 @@ export function createUsageService(deps) {
458
469
  dateKey: session.dateKey,
459
470
  createdAt: session.state.createdAt,
460
471
  lastMessageTime: cursor.lastMessageTime,
472
+ dirty: false,
461
473
  computed,
462
474
  fullUsage,
463
475
  loadFailed: false,
@@ -468,6 +480,8 @@ export function createUsageService(deps) {
468
480
  const failedLoads = fetched.filter((item) => {
469
481
  if (!item.loadFailed)
470
482
  return false;
483
+ if (item.dirty)
484
+ return true;
471
485
  const lastMessageTime = item.lastMessageTime;
472
486
  if (typeof lastMessageTime === 'number' && lastMessageTime < startAt) {
473
487
  return false;
@@ -492,6 +506,7 @@ export function createUsageService(deps) {
492
506
  dateKeyFromTimestamp(memoryState.createdAt);
493
507
  deps.state.sessionDateMap[sessionID] = resolvedDateKey;
494
508
  deps.persistence.markDirty(resolvedDateKey);
509
+ memoryState.dirty = false;
495
510
  dirty = true;
496
511
  }
497
512
  else if (persist && fullUsage) {
@@ -504,7 +519,13 @@ export function createUsageService(deps) {
504
519
  }
505
520
  }
506
521
  if (diskOnlyUpdates.length > 0) {
507
- await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch(swallow('updateSessionsInDayChunks'));
522
+ const persisted = await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch((error) => {
523
+ swallow('updateSessionsInDayChunks')(error);
524
+ return false;
525
+ });
526
+ if (!persisted) {
527
+ throw new Error(`range usage unavailable: failed to persist ${diskOnlyUpdates.length} disk-only session(s)`);
528
+ }
508
529
  }
509
530
  if (dirty)
510
531
  deps.persistence.scheduleSave();
@@ -513,6 +534,16 @@ export function createUsageService(deps) {
513
534
  };
514
535
  const summarizeForTool = async (period, sessionID, includeChildren) => {
515
536
  if (period === 'session') {
537
+ if (!includeChildren) {
538
+ const session = await summarizeSessionUsageLocked(sessionID, {
539
+ requireEntries: true,
540
+ });
541
+ if (session.persist) {
542
+ persistSessionUsage(sessionID, toCachedSessionUsage(session.usage));
543
+ deps.persistence.scheduleSave();
544
+ }
545
+ return session.usage;
546
+ }
516
547
  return summarizeSessionUsageForDisplay(sessionID, includeChildren);
517
548
  }
518
549
  return summarizeRangeUsage(period);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",