@leo000001/opencode-quota-sidebar 1.12.0 → 1.13.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
@@ -18,6 +18,7 @@ Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to i
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: []`).
21
+ This plugin also accepts both `config.providers` and older `provider.list` runtime shapes when discovering provider options.
21
22
 
22
23
  ## Development (build from source)
23
24
 
@@ -38,12 +39,12 @@ On Windows, use forward slashes: `"file:///D:/Lab/opencode-quota-sidebar/dist/in
38
39
 
39
40
  ## Supported quota providers
40
41
 
41
- | Provider | Endpoint | Auth | Status |
42
- | -------------- | -------------------------------------- | --------------- | -------------------------------------- |
43
- | OpenAI Codex | `chatgpt.com/backend-api/wham/usage` | OAuth (ChatGPT) | Multi-window (short-term + weekly) |
44
- | GitHub Copilot | `api.github.com/copilot_internal/user` | OAuth | Monthly quota |
45
- | RightCode | `www.right.codes/account/summary` | API key | Subscription or balance (by prefix) |
46
- | Anthropic | | | Unsupported (no public quota endpoint) |
42
+ | Provider | Endpoint | Auth | Status |
43
+ | -------------- | -------------------------------------- | --------------- | --------------------------------------- |
44
+ | OpenAI Codex | `chatgpt.com/backend-api/wham/usage` | OAuth (ChatGPT) | Multi-window (short-term + weekly) |
45
+ | GitHub Copilot | `api.github.com/copilot_internal/user` | OAuth | Monthly quota |
46
+ | RightCode | `www.right.codes/account/summary` | API key | Subscription or balance (by prefix) |
47
+ | Anthropic | `api.anthropic.com/api/oauth/usage` | OAuth | Multi-window (5h + weekly / plan-based) |
47
48
 
48
49
  Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware AI, etc.)? See [CONTRIBUTING.md](CONTRIBUTING.md).
49
50
 
@@ -55,8 +56,8 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
55
56
  - line 3: Cache Read tokens (only if non-zero)
56
57
  - line 4: Cache Write tokens (only if non-zero)
57
58
  - line 5: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
58
- - quota lines: quota text like `OpenAI 5h 80% Rst 16:20`, with multi-window continuation lines indented (e.g. ` Weekly 70% Rst 03-01`)
59
- - 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
59
+ - 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`
60
+ - 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
60
61
  - 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.
61
62
  - Toast message includes three sections: `Token Usage`, `Cost as API` (per provider), and `Quota`
62
63
  - Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
@@ -67,7 +68,7 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
67
68
  - OpenAI Codex OAuth (`/backend-api/wham/usage`)
68
69
  - GitHub Copilot OAuth (`/copilot_internal/user`)
69
70
  - RightCode API key (`/account/summary`)
70
- - Anthropic: currently marked unsupported (no public quota endpoint)
71
+ - Anthropic Claude OAuth (`/api/oauth/usage`, with beta header)
71
72
  - OpenAI OAuth quota checks auto-refresh expired access token (using refresh token)
72
73
  - API key providers still show usage aggregation (quota only applies to subscription providers)
73
74
  - Incremental usage aggregation — only processes new messages since last cursor
@@ -145,7 +146,9 @@ Recommended global config:
145
146
  Optional project overrides:
146
147
 
147
148
  - `<worktree>/quota-sidebar.config.json`
149
+ - `<directory>/quota-sidebar.config.json` (when different from `worktree`)
148
150
  - `<worktree>/.opencode/quota-sidebar.config.json`
151
+ - `<directory>/.opencode/quota-sidebar.config.json` (when different from `worktree`)
149
152
 
150
153
  Optional explicit override:
151
154
 
@@ -158,9 +161,11 @@ Optional config-home override:
158
161
  Resolution order (low -> high):
159
162
 
160
163
  1. Global config (`~/.config/opencode/...`)
161
- 2. Project root config
162
- 3. `.opencode` project config
163
- 4. `OPENCODE_QUOTA_CONFIG`
164
+ 2. `<worktree>/quota-sidebar.config.json`
165
+ 3. `<directory>/quota-sidebar.config.json`
166
+ 4. `<worktree>/.opencode/quota-sidebar.config.json`
167
+ 5. `<directory>/.opencode/quota-sidebar.config.json`
168
+ 6. `OPENCODE_QUOTA_CONFIG`
164
169
 
165
170
  Values are layered; later sources override earlier ones.
166
171
 
@@ -235,6 +240,7 @@ Example config:
235
240
  Notes:
236
241
 
237
242
  - `sidebar.showCost` controls API-cost visibility in sidebar title, `quota_summary` markdown report, and toast message.
243
+ - `quota_summary` follows the same reset compaction rules for short windows in its subscription section (`5h` / `1d` / `Daily` show time, long windows show date, RightCode `Exp` stays date-only).
238
244
  - `sidebar.width` is measured in terminal cells. CJK/emoji truncation is best-effort to avoid sidebar overflow.
239
245
  - `sidebar.multilineTitle` controls multi-line sidebar layout (default: `true`). Set `false` for compact single-line title.
240
246
  - `sidebar.wrapQuotaLines` controls quota line wrapping and continuation indentation (default: `true`).
@@ -273,6 +279,14 @@ OpenAI
273
279
  Weekly 73% Rst 03-12
274
280
  ```
275
281
 
282
+ 1 provider, short window crossing into the next day:
283
+
284
+ ```text
285
+ Anthropic
286
+ 5h 0% Rst 03-10 01:00
287
+ Weekly 46% Rst 03-15
288
+ ```
289
+
276
290
  2+ providers (even if each provider is single-window):
277
291
 
278
292
  ```text
@@ -306,10 +320,10 @@ RC
306
320
  Balance $260
307
321
  ```
308
322
 
309
- Provider status (examples):
323
+ Provider status / quota (examples):
310
324
 
311
325
  ```text
312
- Anthropic unsupported
326
+ Anthropic 5h 80%+
313
327
  Copilot unavailable
314
328
  OpenAI Remaining ?
315
329
  ```
package/dist/events.d.ts CHANGED
@@ -3,6 +3,9 @@ export declare function createEventDispatcher(handlers: {
3
3
  onSessionCreated: (session: Session) => Promise<void>;
4
4
  onSessionUpdated: (session: Session) => Promise<void>;
5
5
  onSessionDeleted: (session: Session) => Promise<void>;
6
- onMessageRemoved: (sessionID: string) => Promise<void>;
6
+ onMessageRemoved: (info: {
7
+ sessionID: string;
8
+ messageID?: string;
9
+ }) => Promise<void>;
7
10
  onAssistantMessageCompleted: (message: AssistantMessage) => Promise<void>;
8
11
  }): (event: Event) => Promise<void>;
package/dist/events.js CHANGED
@@ -16,7 +16,11 @@ export function createEventDispatcher(handlers) {
16
16
  return;
17
17
  }
18
18
  if (event.type === 'message.removed') {
19
- await handlers.onMessageRemoved(event.properties.sessionID);
19
+ const props = event.properties;
20
+ await handlers.onMessageRemoved({
21
+ sessionID: props.sessionID,
22
+ messageID: typeof props.messageID === 'string' ? props.messageID : undefined,
23
+ });
20
24
  return;
21
25
  }
22
26
  if (event.type !== 'message.updated')
package/dist/format.js CHANGED
@@ -325,7 +325,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
325
325
  ? [sanitizeLine(win.label), pct]
326
326
  : [sanitizeLine(win.label)]
327
327
  : [pct];
328
- const reset = compactReset(win.resetAt, win.resetLabel);
328
+ const reset = compactReset(win.resetAt, win.resetLabel, win.label);
329
329
  if (reset) {
330
330
  parts.push(`${sanitizeLine(win.resetLabel || 'Rst')} ${reset}`);
331
331
  }
@@ -360,7 +360,12 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
360
360
  const fallbackText = `Remaining ${percent}${reset ? ` Rst ${reset}` : ''}`;
361
361
  return maybeBreak(fallbackText, [fallbackText]);
362
362
  }
363
- function compactReset(iso, resetLabel) {
363
+ function isShortResetWindow(label) {
364
+ if (typeof label !== 'string')
365
+ return false;
366
+ return /^\s*(?:\d+\s*[hd]|daily)\b/i.test(label);
367
+ }
368
+ function compactReset(iso, resetLabel, windowLabel) {
364
369
  if (!iso)
365
370
  return undefined;
366
371
  const timestamp = Date.parse(iso);
@@ -378,6 +383,12 @@ function compactReset(iso, resetLabel) {
378
383
  value.getMonth() === now.getMonth() &&
379
384
  value.getDate() === now.getDate();
380
385
  const two = (num) => `${num}`.padStart(2, '0');
386
+ if (isShortResetWindow(windowLabel)) {
387
+ const hhmm = `${two(value.getHours())}:${two(value.getMinutes())}`;
388
+ if (sameDay)
389
+ return hhmm;
390
+ return `${two(value.getMonth() + 1)}-${two(value.getDate())} ${hhmm}`;
391
+ }
381
392
  if (sameDay) {
382
393
  return `${two(value.getHours())}:${two(value.getMinutes())}`;
383
394
  }
@@ -391,6 +402,12 @@ function dateLine(iso) {
391
402
  return iso;
392
403
  return new Date(time).toLocaleString();
393
404
  }
405
+ function reportResetLine(iso, resetLabel, windowLabel) {
406
+ const compact = compactReset(iso, resetLabel, windowLabel);
407
+ if (compact)
408
+ return compact;
409
+ return dateLine(iso);
410
+ }
394
411
  function periodLabel(period) {
395
412
  if (period === 'day')
396
413
  return 'Today';
@@ -462,13 +479,13 @@ export function renderMarkdownReport(period, usage, quotas, options) {
462
479
  return quota.windows.map((win) => {
463
480
  if (win.showPercent === false) {
464
481
  const winLabel = win.label ? ` (${win.label})` : '';
465
- return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | reset ${dateLine(win.resetAt)}`);
482
+ return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
466
483
  }
467
484
  const remaining = win.remainingPercent === undefined
468
485
  ? '-'
469
486
  : `${win.remainingPercent.toFixed(1)}%`;
470
487
  const winLabel = win.label ? ` (${win.label})` : '';
471
- return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${dateLine(win.resetAt)}`);
488
+ return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
472
489
  });
473
490
  }
474
491
  if (quota.status === 'ok' && quota.balance) {
@@ -480,7 +497,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
480
497
  ? '-'
481
498
  : `${quota.remainingPercent.toFixed(1)}%`;
482
499
  return [
483
- mdCell(`- ${quota.label}: ${quota.status} | remaining ${remaining} | reset ${dateLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
500
+ mdCell(`- ${quota.label}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
484
501
  ];
485
502
  });
486
503
  return [
@@ -572,7 +589,7 @@ export function renderToastMessage(period, usage, quotas, options) {
572
589
  const pct = win.remainingPercent === undefined
573
590
  ? '-'
574
591
  : `${win.remainingPercent.toFixed(1)}%`;
575
- const reset = compactReset(win.resetAt, win.resetLabel);
592
+ const reset = compactReset(win.resetAt, win.resetLabel, win.label);
576
593
  const parts = [win.label];
577
594
  if (showPercent)
578
595
  parts.push(pct);
package/dist/index.js CHANGED
@@ -225,9 +225,10 @@ export async function QuotaSidebarPlugin(input) {
225
225
  titleRefresh.schedule(session.parentID, 0);
226
226
  }
227
227
  },
228
- onMessageRemoved: async (sessionID) => {
229
- usageService.markForceRescan(sessionID);
230
- titleRefresh.schedule(sessionID);
228
+ onMessageRemoved: async (info) => {
229
+ usageService.markForceRescan(info.sessionID);
230
+ titleRefresh.schedule(info.sessionID, 0);
231
+ scheduleParentRefreshIfSafe(info.sessionID, state.sessions[info.sessionID]?.parentID);
231
232
  },
232
233
  onAssistantMessageCompleted: async (message) => {
233
234
  usageService.markSessionDirty(message.sessionID);
@@ -1,4 +1,109 @@
1
- import { configuredProviderEnabled } from '../common.js';
1
+ import { swallow } from '../../helpers.js';
2
+ import { asRecord, configuredProviderEnabled, fetchWithTimeout, normalizePercent, toIso, } from '../common.js';
3
+ const ANTHROPIC_OAUTH_USAGE_BETA = 'oauth-2025-04-20';
4
+ function parseAnthropicWindow(value, label) {
5
+ const win = asRecord(value);
6
+ if (!win)
7
+ return undefined;
8
+ const usedPercent = normalizePercent(win.utilization);
9
+ if (usedPercent === undefined)
10
+ return undefined;
11
+ const parsed = {
12
+ label,
13
+ usedPercent,
14
+ remainingPercent: Math.max(0, 100 - usedPercent),
15
+ resetAt: toIso(win.resets_at),
16
+ };
17
+ return parsed;
18
+ }
19
+ async function fetchAnthropicQuota({ providerID, auth, config, }) {
20
+ const checkedAt = Date.now();
21
+ const base = {
22
+ providerID,
23
+ adapterID: 'anthropic',
24
+ label: 'Anthropic',
25
+ shortLabel: 'Anthropic',
26
+ sortOrder: 30,
27
+ };
28
+ if (!auth) {
29
+ return {
30
+ ...base,
31
+ status: 'unavailable',
32
+ checkedAt,
33
+ note: 'auth not found',
34
+ };
35
+ }
36
+ if (auth.type !== 'oauth') {
37
+ return {
38
+ ...base,
39
+ status: 'unsupported',
40
+ checkedAt,
41
+ note: 'api key auth has no quota endpoint',
42
+ };
43
+ }
44
+ if (typeof auth.access !== 'string' || !auth.access) {
45
+ return {
46
+ ...base,
47
+ status: 'unavailable',
48
+ checkedAt,
49
+ note: 'missing oauth access token',
50
+ };
51
+ }
52
+ const response = await fetchWithTimeout('https://api.anthropic.com/api/oauth/usage', {
53
+ method: 'GET',
54
+ headers: {
55
+ Accept: 'application/json',
56
+ Authorization: `Bearer ${auth.access}`,
57
+ 'Content-Type': 'application/json',
58
+ 'User-Agent': 'opencode-quota-sidebar',
59
+ 'anthropic-beta': ANTHROPIC_OAUTH_USAGE_BETA,
60
+ },
61
+ }, config.quota.requestTimeoutMs).catch(swallow('fetchAnthropicQuota:usage'));
62
+ if (!response) {
63
+ return {
64
+ ...base,
65
+ status: 'error',
66
+ checkedAt,
67
+ note: 'network request failed',
68
+ };
69
+ }
70
+ if (!response.ok) {
71
+ return {
72
+ ...base,
73
+ status: 'error',
74
+ checkedAt,
75
+ note: `http ${response.status}`,
76
+ };
77
+ }
78
+ const payload = await response
79
+ .json()
80
+ .catch(swallow('fetchAnthropicQuota:json'));
81
+ const usage = asRecord(payload);
82
+ if (!usage) {
83
+ return {
84
+ ...base,
85
+ status: 'error',
86
+ checkedAt,
87
+ note: 'invalid response',
88
+ };
89
+ }
90
+ const windows = [
91
+ parseAnthropicWindow(usage.five_hour, '5h'),
92
+ parseAnthropicWindow(usage.seven_day, 'Weekly'),
93
+ parseAnthropicWindow(usage.seven_day_sonnet, 'Sonnet 7d'),
94
+ ].filter((window) => Boolean(window));
95
+ const primary = windows[0];
96
+ return {
97
+ ...base,
98
+ status: primary ? 'ok' : 'error',
99
+ checkedAt,
100
+ usedPercent: primary?.usedPercent,
101
+ remainingPercent: primary?.remainingPercent,
102
+ resetAt: primary?.resetAt,
103
+ note: primary ? undefined : 'missing quota fields',
104
+ windows: windows.length > 0 ? windows : undefined,
105
+ };
106
+ }
2
107
  export const anthropicAdapter = {
3
108
  id: 'anthropic',
4
109
  label: 'Anthropic',
@@ -6,41 +111,5 @@ export const anthropicAdapter = {
6
111
  sortOrder: 30,
7
112
  matchScore: ({ providerID }) => (providerID === 'anthropic' ? 80 : 0),
8
113
  isEnabled: (config) => configuredProviderEnabled(config.quota, 'anthropic', config.quota.includeAnthropic),
9
- fetch: async ({ providerID, auth }) => {
10
- const checkedAt = Date.now();
11
- if (!auth) {
12
- return {
13
- providerID,
14
- adapterID: 'anthropic',
15
- label: 'Anthropic',
16
- shortLabel: 'Anthropic',
17
- sortOrder: 30,
18
- status: 'unavailable',
19
- checkedAt,
20
- note: 'auth not found',
21
- };
22
- }
23
- if (auth.type === 'api') {
24
- return {
25
- providerID,
26
- adapterID: 'anthropic',
27
- label: 'Anthropic',
28
- shortLabel: 'Anthropic',
29
- sortOrder: 30,
30
- status: 'unsupported',
31
- checkedAt,
32
- note: 'api key has no public quota endpoint',
33
- };
34
- }
35
- return {
36
- providerID,
37
- adapterID: 'anthropic',
38
- label: 'Anthropic',
39
- shortLabel: 'Anthropic',
40
- sortOrder: 30,
41
- status: 'unsupported',
42
- checkedAt,
43
- note: 'oauth quota endpoint is not publicly documented',
44
- };
45
- },
114
+ fetch: fetchAnthropicQuota,
46
115
  };
@@ -1,5 +1,5 @@
1
1
  import { TtlValueCache } from './cache.js';
2
- import { swallow } from './helpers.js';
2
+ import { isRecord, swallow } from './helpers.js';
3
3
  import { listDefaultQuotaProviderIDs, loadAuthMap, quotaSort } from './quota.js';
4
4
  export function createQuotaService(deps) {
5
5
  const authCache = new TtlValueCache();
@@ -16,26 +16,31 @@ export function createQuotaService(deps) {
16
16
  const cached = providerOptionsCache.get();
17
17
  if (cached)
18
18
  return cached;
19
- const configClient = deps.client;
20
- if (!configClient.config?.providers) {
19
+ const client = deps.client;
20
+ if (!client.config?.providers && !client.provider?.list) {
21
21
  return providerOptionsCache.set({}, 30_000);
22
22
  }
23
- const response = await configClient.config
24
- .providers({
25
- query: { directory: deps.directory },
26
- throwOnError: true,
27
- })
28
- .catch(swallow('getProviderOptionsMap'));
29
- const data = response &&
30
- typeof response === 'object' &&
31
- 'data' in response &&
32
- response.data &&
33
- typeof response.data === 'object' &&
34
- 'providers' in response.data
35
- ? response.data.providers
36
- : undefined;
37
- const map = Array.isArray(data)
38
- ? data.reduce((acc, item) => {
23
+ // Newer runtimes expose config.providers; older clients may only expose
24
+ // provider.list with a slightly different response shape.
25
+ const response = await (client.config?.providers
26
+ ? client.config.providers({
27
+ query: { directory: deps.directory },
28
+ throwOnError: true,
29
+ })
30
+ : client.provider.list({
31
+ query: { directory: deps.directory },
32
+ throwOnError: true,
33
+ })).catch(swallow('getProviderOptionsMap'));
34
+ const data = isRecord(response) && isRecord(response.data) ? response.data : undefined;
35
+ const list = Array.isArray(data?.providers)
36
+ ? data.providers
37
+ : Array.isArray(data?.all)
38
+ ? data.all
39
+ : Array.isArray(data)
40
+ ? data
41
+ : undefined;
42
+ const map = Array.isArray(list)
43
+ ? list.reduce((acc, item) => {
39
44
  if (!item || typeof item !== 'object')
40
45
  return acc;
41
46
  const record = item;
@@ -58,7 +63,9 @@ export function createQuotaService(deps) {
58
63
  const isValidQuotaCache = (snapshot) => {
59
64
  // Guard against stale RightCode cache entries from pre-daily format.
60
65
  if (snapshot.adapterID !== 'rightcode' || snapshot.status !== 'ok')
61
- return true;
66
+ return !(snapshot.adapterID === 'anthropic' &&
67
+ snapshot.status === 'unsupported' &&
68
+ snapshot.note === 'oauth quota endpoint is not publicly documented');
62
69
  if (!snapshot.windows || snapshot.windows.length === 0)
63
70
  return true;
64
71
  const primary = snapshot.windows[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",