@leo000001/opencode-quota-sidebar 2.0.1 → 2.0.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Add built-in `kimi-for-coding` subscription quota support via `GET https://api.kimi.com/coding/v1/usages`.
6
+ - Parse Kimi's `5h` and `Weekly` windows, including reset timestamps, and render them like other subscription providers.
7
+ - Accept OpenCode provider discovery responses that expose Kimi API keys through provider `key` fields.
5
8
  - Add Buzz API balance support for OpenAI-compatible providers that use a Buzz `baseURL`.
6
9
  - Document Buzz configuration, rendering, and outbound billing endpoints.
7
10
  - Keep session measured cost aligned with OpenCode root-session `message.cost` while still including descendant subagent usage in API-equivalent cost.
package/CONTRIBUTING.md CHANGED
@@ -19,6 +19,7 @@ The plugin now uses a provider adapter registry, so adding a new provider does n
19
19
  - `baseURL` match: best for OpenAI-compatible relays such as RightCode or Buzz
20
20
  - Prefix/variant normalization: best when one provider has multiple runtime IDs
21
21
  - Balance-only providers should prefer `balance` over inventing fake percent windows
22
+ - Built-in API-key providers such as `kimi-for-coding` may need both: direct ID matching for the canonical provider and support for OpenCode's discovered `key -> options.apiKey` bridge
22
23
 
23
24
  ## Add a new provider
24
25
 
@@ -70,6 +71,10 @@ If your provider is an OpenAI-compatible relay, prefer matching on
70
71
  `providerOptions.baseURL` instead of the runtime `providerID`; that keeps custom
71
72
  aliases working without extra user config.
72
73
 
74
+ If your provider is built into OpenCode and already has a stable runtime ID
75
+ (for example `kimi-for-coding`), prefer a direct provider-ID match first, then
76
+ add a `baseURL` fallback only when it helps older/custom runtime shapes.
77
+
73
78
  If the new provider should appear in default `quota_summary` reports even when
74
79
  it has not yet been used in the current session, also update
75
80
  `listDefaultQuotaProviderIDs()` in `src/quota.ts`.
@@ -98,7 +103,7 @@ At minimum:
98
103
  - format output if using special fields (e.g. `balance`)
99
104
  - cache compatibility if the change replaces an older snapshot shape
100
105
  - mixed-provider rendering if the new provider will commonly appear next to
101
- OpenAI/Copilot/RightCode in sidebar or toast output
106
+ OpenAI/Copilot/Kimi/RightCode in sidebar or toast output
102
107
 
103
108
  If the provider introduces new rendering rules or multi-window behavior, add
104
109
  coverage in both `src/__tests__/quota.test.ts` and `src/__tests__/format.test.ts`.
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
 
@@ -41,13 +41,14 @@ On Windows, use forward slashes: `"file:///D:/Lab/opencode-quota-sidebar/dist/in
41
41
 
42
42
  ## Supported quota providers
43
43
 
44
- | Provider | Endpoint | Auth | Status |
45
- | -------------- | -------------------------------------- | --------------- | --------------------------------------- |
46
- | OpenAI Codex | `chatgpt.com/backend-api/wham/usage` | OAuth (ChatGPT) | Multi-window (short-term + weekly) |
47
- | GitHub Copilot | `api.github.com/copilot_internal/user` | OAuth | Monthly quota |
48
- | RightCode | `www.right.codes/account/summary` | API key | Subscription or balance (by prefix) |
49
- | Buzz | `buzzai.cc/v1/dashboard/billing/*` | API key | Balance only (computed from total-used) |
50
- | Anthropic | `api.anthropic.com/api/oauth/usage` | OAuth | Multi-window (5h + weekly / plan-based) |
44
+ | Provider | Endpoint | Auth | Status |
45
+ | -------------- | -------------------------------------- | --------------- | --------------------------------------- |
46
+ | OpenAI Codex | `chatgpt.com/backend-api/wham/usage` | OAuth (ChatGPT) | Multi-window (short-term + weekly) |
47
+ | GitHub Copilot | `api.github.com/copilot_internal/user` | OAuth | Monthly quota |
48
+ | Kimi For Coding | `api.kimi.com/coding/v1/usages` | API key | Multi-window subscription (5h + weekly) |
49
+ | RightCode | `www.right.codes/account/summary` | API key | Subscription or balance (by prefix) |
50
+ | Buzz | `buzzai.cc/v1/dashboard/billing/*` | API key | Balance only (computed from total-used) |
51
+ | Anthropic | `api.anthropic.com/api/oauth/usage` | OAuth | Multi-window (5h + weekly / plan-based) |
51
52
 
52
53
  Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware AI, etc.)? See [CONTRIBUTING.md](CONTRIBUTING.md).
53
54
 
@@ -64,23 +65,32 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
64
65
  - 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
66
  - 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
67
  - 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`
68
+ - 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
69
  - `quota_summary` markdown / toast also include `Cache Coverage` and `Cache Read Coverage` summary lines when available
69
70
  - Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
70
71
  - Custom tools:
71
72
  - `quota_summary` — generate usage report for session/day/week/month (markdown + toast)
72
73
  - `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
74
- - Quota connectors:
75
- - OpenAI Codex OAuth (`/backend-api/wham/usage`)
76
- - GitHub Copilot OAuth (`/copilot_internal/user`)
77
- - RightCode API key (`/account/summary`)
78
- - Buzz API key (`/v1/dashboard/billing/subscription` + `/v1/dashboard/billing/usage`)
79
- - Anthropic Claude OAuth (`/api/oauth/usage`, with beta header)
74
+ - 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
75
+ - Quota connectors:
76
+ - OpenAI Codex OAuth (`/backend-api/wham/usage`)
77
+ - GitHub Copilot OAuth (`/copilot_internal/user`)
78
+ - Kimi For Coding API key (`/usages`, built-in `kimi-for-coding` provider)
79
+ - RightCode API key (`/account/summary`)
80
+ - Buzz API key (`/v1/dashboard/billing/subscription` + `/v1/dashboard/billing/usage`)
81
+ - Anthropic Claude OAuth (`/api/oauth/usage`, with beta header)
80
82
  - OpenAI OAuth quota checks auto-refresh expired access token (using refresh token)
81
- - API key providers still show usage aggregation (quota only applies to subscription providers)
82
- - Incremental usage aggregation — only processes new messages since last cursor
83
- - Sidebar token units are adaptive (`k`/`m` with one decimal where applicable)
83
+ - API key providers still show usage aggregation (quota only applies to subscription providers)
84
+ - Incremental usage aggregation — only processes new messages since last cursor
85
+ - Sidebar token units are adaptive (`k`/`m` with one decimal where applicable)
86
+
87
+ ### Kimi For Coding notes
88
+
89
+ - OpenCode's built-in provider ID is `kimi-for-coding` and its runtime base URL is `https://api.kimi.com/coding/v1`.
90
+ - The plugin treats Kimi as a subscription quota source, not a balance source.
91
+ - Quota data is read from `GET https://api.kimi.com/coding/v1/usages`.
92
+ - The current implementation maps the short rolling window in `limits[]` to `5h` and the top-level `usage` block to `Weekly`.
93
+ - Rendering follows the same compact reset formatting as OpenAI: short windows show `Rst MM-DD HH:MM` when they cross days, and longer windows show `Rst MM-DD`.
84
94
 
85
95
  ## Storage layout
86
96
 
@@ -90,12 +100,17 @@ The plugin stores lightweight global state and date-partitioned session chunks.
90
100
  - `titleEnabled`
91
101
  - `sessionDateMap` (sessionID -> `YYYY-MM-DD`)
92
102
  - `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
103
+ - Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
104
+ - per-session title state (`baseTitle`, `lastAppliedTitle`)
105
+ - `createdAt`
106
+ - `parentID` (when the session is a subagent child session)
107
+ - cached usage summary used by `quota_summary`, including session-level and provider-level `cacheBuckets` for cache coverage reporting
108
+ - incremental aggregation cursor
109
+
110
+ Notes on cache coverage persistence:
111
+
112
+ - Older cached usage written before `cacheBuckets` existed can only be approximated from top-level `cache_read` / `cache_write` totals.
113
+ - 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
114
 
100
115
  Example tree:
101
116
 
@@ -450,12 +465,13 @@ Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
450
465
  ## Security & privacy notes
451
466
 
452
467
  - The plugin reads OpenCode credentials from `<opencode-data>/auth.json`.
453
- - If enabled, quota checks call external endpoints:
454
- - OpenAI Codex: `https://chatgpt.com/backend-api/wham/usage`
455
- - GitHub Copilot: `https://api.github.com/copilot_internal/user`
456
- - RightCode: `https://www.right.codes/account/summary`
457
- - Buzz: `https://buzzai.cc/v1/dashboard/billing/subscription` and `https://buzzai.cc/v1/dashboard/billing/usage`
458
- - Anthropic: `https://api.anthropic.com/api/oauth/usage`
468
+ - If enabled, quota checks call external endpoints:
469
+ - OpenAI Codex: `https://chatgpt.com/backend-api/wham/usage`
470
+ - GitHub Copilot: `https://api.github.com/copilot_internal/user`
471
+ - Kimi For Coding: `https://api.kimi.com/coding/v1/usages`
472
+ - RightCode: `https://www.right.codes/account/summary`
473
+ - Buzz: `https://buzzai.cc/v1/dashboard/billing/subscription` and `https://buzzai.cc/v1/dashboard/billing/usage`
474
+ - Anthropic: `https://api.anthropic.com/api/oauth/usage`
459
475
  - **Screen-sharing warning**: Session titles and toasts surface usage/quota
460
476
  information. If you are screen-sharing or recording, consider toggling the
461
477
  sidebar display off (`/qtoggle` or `quota_show` tool) to avoid leaking
@@ -465,8 +481,9 @@ Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
465
481
  - OpenAI OAuth token refresh is disabled by default; set
466
482
  `quota.refreshAccessToken=true` if you want the plugin to refresh access
467
483
  tokens when expired.
468
- - Anthropic quota currently uses a beta/internal-style OAuth usage endpoint and
469
- request header; response fields may change without notice.
484
+ - Anthropic quota currently uses a beta/internal-style OAuth usage endpoint and
485
+ request header; response fields may change without notice.
486
+ - Kimi For Coding quota uses the current `/usages` response shape exposed by the Kimi coding service; if Kimi changes that payload, window parsing may need to be updated.
470
487
  - State/chunk file writes refuse to write through symlinked targets (best-effort defense-in-depth).
471
488
  - The `OPENCODE_QUOTA_DATA_HOME` env var overrides the OpenCode data directory
472
489
  path (for testing); do not set this in production.
package/SECURITY.md CHANGED
@@ -25,7 +25,7 @@ We will acknowledge reports as quickly as possible and provide a remediation tim
25
25
  - Keep debug logs free of secrets.
26
26
  - Prefer fail-closed behavior for writes (already enforced via symlink checks and atomic writes).
27
27
  - Quota fetching may contact provider-operated endpoints such as OpenAI, GitHub,
28
- RightCode, Buzz, and Anthropic; review any new provider integration for
28
+ Kimi, RightCode, Buzz, and Anthropic; review any new provider integration for
29
29
  outbound data exposure and header/token handling.
30
30
  - Some quota integrations rely on beta or internal-style endpoints; document
31
31
  instability risks clearly and avoid assuming long-term API compatibility.
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 () => {
@@ -0,0 +1,2 @@
1
+ import type { QuotaProviderAdapter } from '../types.js';
2
+ export declare const kimiForCodingAdapter: QuotaProviderAdapter;