@leo000001/opencode-quota-sidebar 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,9 +12,9 @@ OpenCode plugin: show token usage and subscription quota in the session sidebar
12
12
  Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to install it automatically on startup:
13
13
 
14
14
  ```json
15
- {
16
- "plugin": ["@leo000001/opencode-quota-sidebar@1.13.2"]
17
- }
15
+ {
16
+ "plugin": ["@leo000001/opencode-quota-sidebar@2.0.0"]
17
+ }
18
18
  ```
19
19
 
20
20
  Note for OpenCode `>=1.2.15`: TUI settings (`theme`/`keybinds`/`tui`) moved to `tui.json`, but plugin loading still stays in `opencode.json` (`plugin: []`).
@@ -55,9 +55,10 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
55
55
 
56
56
  - Session title becomes multiline in sidebar:
57
57
  - line 1: original session title
58
- - line 2: Input/Output tokens
59
- - line 3: Cache Read tokens (only if non-zero)
60
- - line 4: Cache Write tokens (only if non-zero)
58
+ - line 2: blank separator
59
+ - line 3: Input/Output tokens
60
+ - line 4: Cache Read tokens (only if non-zero)
61
+ - line 5: Cache Write tokens (only if non-zero)
61
62
  - next lines: `Cache Coverage` (read/write cache models) and `Cache Read Coverage` (read-only cache models) when enough cache telemetry is available; mixed sessions can show both
62
63
  - next line: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
63
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`
@@ -68,7 +69,8 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
68
69
  - Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
69
70
  - Custom tools:
70
71
  - `quota_summary` — generate usage report for session/day/week/month (markdown + toast)
71
- - `quota_show` — toggle sidebar title display on/off (state persists across sessions)
72
+ - `quota_show` — toggle sidebar title display on/off (state persists across sessions)
73
+ - After startup, titles refresh on the next relevant session/message event or when `quota_show` is toggled
72
74
  - Quota connectors:
73
75
  - OpenAI Codex OAuth (`/backend-api/wham/usage`)
74
76
  - GitHub Copilot OAuth (`/copilot_internal/user`)
@@ -296,7 +298,7 @@ Other defaults:
296
298
  - When OpenCode exposes a long-context tier like `context_over_200k`, the plugin uses that premium rate for the whole request once `input > 200000`, matching OpenCode's current pricing schema.
297
299
  - `quota.providers` is the extensible per-adapter switch map.
298
300
  - If API Cost is `$0.00`, it usually means the model/provider has no pricing mapping in OpenCode at the moment, so equivalent API cost cannot be estimated.
299
- - Usage chunks cache both measured `cost` and computed `apiCost`. `quota_summary` (`/qday`, `/qweek`, `/qmonth`) usually reads those cached aggregates first, but a billing-cache version bump or missing/legacy API-cost data will trigger a rescan and persist refreshed values.
301
+ - Usage chunks cache both measured `cost` and computed `apiCost`. `quota_summary` (`/qday`, `/qweek`, `/qmonth`) recomputes range totals from session messages so period filtering follows message completion time; refreshed full-session usage may then be persisted back into day chunks when billing-cache refresh is needed.
300
302
 
301
303
  ### Buzz provider example
302
304
 
@@ -319,7 +321,7 @@ The adapter also tolerates `https://buzzai.cc/v1`, but `https://buzzai.cc` is th
319
321
  With that setup, the sidebar/toast quota line will look like:
320
322
 
321
323
  ```text
322
- Buzz Balance CNY 10.17
324
+ Buzz Balance 10.17
323
325
  ```
324
326
 
325
327
  ## Rendering examples
@@ -382,7 +384,7 @@ OpenAI
382
384
  5h 78% Rst 05:05
383
385
  Copilot
384
386
  Monthly 78% Rst 04-01
385
- Buzz Balance CNY 10.2
387
+ Buzz Balance 10.2
386
388
  ```
387
389
 
388
390
  Balance-style quota:
@@ -394,7 +396,7 @@ RC Balance $260
394
396
  Buzz balance quota:
395
397
 
396
398
  ```text
397
- Buzz Balance CNY 10.17
399
+ Buzz Balance 10.17
398
400
  ```
399
401
 
400
402
  Multi-detail quota (window + balance):
@@ -424,7 +426,7 @@ Quota is rendered inline as part of a single-line title:
424
426
  Mixed with Buzz balance:
425
427
 
426
428
  ```text
427
- <base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | Buzz Balance CNY 10.2
429
+ <base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | Buzz Balance 10.2
428
430
  ```
429
431
 
430
432
  `quota_summary` also supports an optional `includeChildren` flag (only effective for `period=session`) to override the config per call. For `day`/`week`/`month` periods, children are never merged — each session is counted independently.
package/dist/cost.js CHANGED
@@ -88,7 +88,8 @@ export function cacheCoverageModeFromRates(rates) {
88
88
  return 'none';
89
89
  }
90
90
  export function calcEquivalentApiCostForMessage(message, rates) {
91
- const effectiveRates = message.tokens.input > 200_000 && rates.contextOver200k
91
+ const effectiveRates = message.tokens.input + message.tokens.cache.read > 200_000 &&
92
+ rates.contextOver200k
92
93
  ? rates.contextOver200k
93
94
  : rates;
94
95
  // For providers that expose reasoning tokens separately, they are still
package/dist/format.js CHANGED
@@ -129,10 +129,18 @@ function fitLine(value, width) {
129
129
  return `${head}~`;
130
130
  }
131
131
  function formatCurrency(value, currency) {
132
- const safe = Number.isFinite(value) && value > 0 ? value : 0;
132
+ const safe = Number.isFinite(value) ? value : 0;
133
133
  const prefix = typeof currency === 'string' && currency ? currency : '$';
134
134
  if (safe === 0)
135
135
  return `${prefix}0.00`;
136
+ if (safe < 0) {
137
+ const abs = Math.abs(safe);
138
+ if (abs < 10)
139
+ return `-${prefix}${abs.toFixed(2)}`;
140
+ const one = abs.toFixed(1);
141
+ const trimmed = one.endsWith('.0') ? one.slice(0, -2) : one;
142
+ return `-${prefix}${trimmed}`;
143
+ }
136
144
  if (safe < 10)
137
145
  return `${prefix}${safe.toFixed(2)}`;
138
146
  const one = safe.toFixed(1);
@@ -170,8 +178,11 @@ function alignPairs(pairs, indent = ' ') {
170
178
  }
171
179
  function compactQuotaInline(quota) {
172
180
  const label = sanitizeLine(quotaDisplayLabel(quota));
173
- if (quota.status !== 'ok')
174
- return label;
181
+ if (quota.status !== 'ok') {
182
+ if (quota.status === 'error')
183
+ return `${label} Remaining ?`;
184
+ return `${label} ${sanitizeLine(quota.status)}`;
185
+ }
175
186
  if (quota.windows && quota.windows.length > 0) {
176
187
  const first = quota.windows[0];
177
188
  const showPercent = first.showPercent !== false;
@@ -238,13 +249,16 @@ function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
238
249
  */
239
250
  export function renderSidebarTitle(baseTitle, usage, quotas, config) {
240
251
  const width = Math.max(8, Math.floor(config.sidebar.width || 36));
241
- const safeBaseTitle = stripAnsi(baseTitle || 'Session').split(/\r?\n/, 1)[0] || 'Session';
252
+ const safeBaseTitle = stripAnsi(baseTitle || 'Session') || 'Session';
242
253
  if (config.sidebar.multilineTitle !== true) {
243
- return renderSingleLineTitle(safeBaseTitle, usage, quotas, config, width);
254
+ const singleLineBase = safeBaseTitle.split(/\r?\n/, 1)[0] || 'Session';
255
+ return renderSingleLineTitle(singleLineBase, usage, quotas, config, width);
244
256
  }
245
257
  const cacheMetrics = getCacheCoverageMetrics(usage);
246
258
  const lines = [];
247
- lines.push(fitLine(safeBaseTitle, width));
259
+ for (const line of safeBaseTitle.split(/\r?\n/)) {
260
+ lines.push(fitLine(line || 'Session', width));
261
+ }
248
262
  lines.push('');
249
263
  // Input / Output line
250
264
  const io = `Input ${sidebarNumber(usage.input)} Output ${sidebarNumber(usage.output)}`;
@@ -409,9 +423,6 @@ function compactReset(iso, resetLabel, windowLabel) {
409
423
  return hhmm;
410
424
  return `${two(value.getMonth() + 1)}-${two(value.getDate())} ${hhmm}`;
411
425
  }
412
- if (sameDay) {
413
- return `${two(value.getHours())}:${two(value.getMinutes())}`;
414
- }
415
426
  return `${two(value.getMonth() + 1)}-${two(value.getDate())}`;
416
427
  }
417
428
  function dateLine(iso) {
@@ -450,6 +461,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
450
461
  const measuredCostCell = (providerID, cost) => {
451
462
  const canonical = canonicalProviderID(providerID);
452
463
  const isSubscription = canonical === 'openai' ||
464
+ canonical === 'anthropic' ||
453
465
  canonical === 'github-copilot' ||
454
466
  rightCodeSubscriptionProviderIDs.has(providerID);
455
467
  if (isSubscription)
@@ -459,6 +471,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
459
471
  const isSubscriptionMeasuredProvider = (providerID) => {
460
472
  const canonical = canonicalProviderID(providerID);
461
473
  return (canonical === 'openai' ||
474
+ canonical === 'anthropic' ||
462
475
  canonical === 'github-copilot' ||
463
476
  rightCodeSubscriptionProviderIDs.has(providerID));
464
477
  };
@@ -495,30 +508,40 @@ export function renderMarkdownReport(period, usage, quotas, options) {
495
508
  : `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`;
496
509
  });
497
510
  const quotaLines = collapseQuotaSnapshots(quotas).flatMap((quota) => {
511
+ const displayLabel = quotaDisplayLabel(quota);
498
512
  // Multi-window detail
499
513
  if (quota.windows && quota.windows.length > 0 && quota.status === 'ok') {
500
- return quota.windows.map((win) => {
514
+ const windowLines = quota.windows.map((win) => {
501
515
  if (win.showPercent === false) {
502
516
  const winLabel = win.label ? ` (${win.label})` : '';
503
- return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
517
+ return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
504
518
  }
505
519
  const remaining = win.remainingPercent === undefined
506
520
  ? '-'
507
521
  : `${win.remainingPercent.toFixed(1)}%`;
508
522
  const winLabel = win.label ? ` (${win.label})` : '';
509
- return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
523
+ return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
510
524
  });
525
+ if (quota.balance) {
526
+ windowLines.push(mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`));
527
+ }
528
+ return windowLines;
511
529
  }
512
530
  if (quota.status === 'ok' && quota.balance) {
513
531
  return [
514
- mdCell(`- ${quota.label}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`),
532
+ mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`),
533
+ ];
534
+ }
535
+ if (quota.status !== 'ok') {
536
+ return [
537
+ mdCell(`- ${displayLabel}: ${quota.status}${quota.note ? ` | ${quota.note}` : ''}`),
515
538
  ];
516
539
  }
517
540
  const remaining = quota.remainingPercent === undefined
518
541
  ? '-'
519
542
  : `${quota.remainingPercent.toFixed(1)}%`;
520
543
  return [
521
- mdCell(`- ${quota.label}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
544
+ mdCell(`- ${displayLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
522
545
  ];
523
546
  });
524
547
  return [
@@ -595,14 +618,6 @@ export function renderToastMessage(period, usage, quotas, options) {
595
618
  value: formatPercent(cacheMetrics.cacheReadCoverage, 1),
596
619
  });
597
620
  }
598
- if (showCost) {
599
- if (usage.apiCost > 0) {
600
- tokenPairs.push({
601
- label: 'API Cost',
602
- value: formatApiCostValue(usage.apiCost),
603
- });
604
- }
605
- }
606
621
  lines.push(...alignPairs(tokenPairs).map((line) => fitLine(line, width)));
607
622
  if (showCost) {
608
623
  const costPairs = Object.values(usage.providers)
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ export async function QuotaSidebarPlugin(input) {
29
29
  const authPath = authFilePath(dataDir);
30
30
  const state = await loadState(statePath);
31
31
  // M2: evict old sessions on startup
32
- evictOldSessions(state, config.retentionDays);
32
+ const evictedOnStartup = evictOldSessions(state, config.retentionDays);
33
33
  const persistence = createPersistenceScheduler({
34
34
  statePath,
35
35
  state,
@@ -38,6 +38,9 @@ export async function QuotaSidebarPlugin(input) {
38
38
  const markDirty = persistence.markDirty;
39
39
  const scheduleSave = persistence.scheduleSave;
40
40
  const flushSave = persistence.flushSave;
41
+ if (evictedOnStartup > 0) {
42
+ scheduleSave();
43
+ }
41
44
  const RESTORE_TITLE_CONCURRENCY = 5;
42
45
  const quotaService = createQuotaService({
43
46
  quotaRuntime,
@@ -166,6 +169,14 @@ export async function QuotaSidebarPlugin(input) {
166
169
  });
167
170
  scheduleTitleRefresh = titleRefresh.schedule;
168
171
  const restoreAllVisibleTitles = titleApplicator.restoreAllVisibleTitles;
172
+ const refreshAllTouchedTitles = titleApplicator.refreshAllTouchedTitles;
173
+ const refreshAllVisibleTitles = titleApplicator.refreshAllVisibleTitles;
174
+ if (!state.titleEnabled || !config.sidebar.enabled) {
175
+ void restoreAllVisibleTitles().catch(swallow('startup:restoreAllVisibleTitles'));
176
+ }
177
+ else {
178
+ void refreshAllTouchedTitles().catch(swallow('startup:refreshAllTouchedTitles'));
179
+ }
169
180
  const showToast = async (period, message) => {
170
181
  await input.client.tui
171
182
  .showToast({
@@ -217,10 +228,16 @@ export async function QuotaSidebarPlugin(input) {
217
228
  titleRefresh.cancel(session.id);
218
229
  const dateKey = state.sessionDateMap[session.id] ||
219
230
  dateKeyFromTimestamp(session.time.created);
231
+ state.deletedSessionDateMap[session.id] = dateKey;
220
232
  delete state.sessions[session.id];
221
233
  delete state.sessionDateMap[session.id];
234
+ markDirty(dateKey);
222
235
  scheduleSave();
223
- await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
236
+ const deletedFromChunk = await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
237
+ if (deletedFromChunk) {
238
+ delete state.deletedSessionDateMap[session.id];
239
+ scheduleSave();
240
+ }
224
241
  if (config.sidebar.includeChildren && session.parentID) {
225
242
  titleRefresh.schedule(session.parentID, 0);
226
243
  }
@@ -250,8 +267,13 @@ export async function QuotaSidebarPlugin(input) {
250
267
  state.titleEnabled = enabled;
251
268
  },
252
269
  scheduleSave,
270
+ flushSave,
253
271
  refreshSessionTitle: (sessionID, delay) => titleRefresh.schedule(sessionID, delay ?? 250),
272
+ cancelAllTitleRefreshes: () => titleRefresh.cancelAll(),
273
+ waitForTitleRefreshIdle: () => titleRefresh.waitForIdle(),
254
274
  restoreAllVisibleTitles,
275
+ refreshAllTouchedTitles,
276
+ refreshAllVisibleTitles,
255
277
  showToast,
256
278
  summarizeForTool,
257
279
  getQuotaSnapshots,
package/dist/quota.js CHANGED
@@ -6,14 +6,12 @@ function resolveContext(providerID, providerOptions) {
6
6
  return { providerID, providerOptions };
7
7
  }
8
8
  function authCandidates(providerID, normalizedProviderID, adapterID) {
9
- const candidates = new Set([
10
- providerID,
11
- normalizedProviderID,
12
- adapterID,
13
- ]);
9
+ const candidates = new Set([providerID]);
14
10
  if (adapterID === 'github-copilot') {
15
11
  candidates.add('github-copilot-enterprise');
16
12
  }
13
+ candidates.add(normalizedProviderID);
14
+ candidates.add(adapterID);
17
15
  return [...candidates];
18
16
  }
19
17
  function pickAuth(providerID, normalizedProviderID, adapterID, authMap) {
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { TtlValueCache } from './cache.js';
2
3
  import { isRecord, swallow } from './helpers.js';
3
4
  import { listDefaultQuotaProviderIDs, loadAuthMap, quotaSort } from './quota.js';
@@ -10,6 +11,36 @@ export function createQuotaService(deps) {
10
11
  const authCache = new TtlValueCache();
11
12
  const providerOptionsCache = new TtlValueCache();
12
13
  const inFlight = new Map();
14
+ const authFingerprint = (auth) => {
15
+ if (!auth || typeof auth !== 'object')
16
+ return undefined;
17
+ const stable = JSON.stringify(Object.keys(auth)
18
+ .sort()
19
+ .reduce((acc, key) => {
20
+ const value = auth[key];
21
+ if (value !== undefined)
22
+ acc[key] = value;
23
+ return acc;
24
+ }, {}));
25
+ return createHash('sha256').update(stable).digest('hex').slice(0, 12);
26
+ };
27
+ const providerOptionsFingerprint = (providerOptions) => {
28
+ if (!providerOptions)
29
+ return undefined;
30
+ const stable = JSON.stringify(Object.keys(providerOptions)
31
+ .sort()
32
+ .reduce((acc, key) => {
33
+ if (key === 'baseURL')
34
+ return acc;
35
+ const value = providerOptions[key];
36
+ if (value !== undefined)
37
+ acc[key] = value;
38
+ return acc;
39
+ }, {}));
40
+ if (stable === '{}')
41
+ return undefined;
42
+ return createHash('sha256').update(stable).digest('hex').slice(0, 12);
43
+ };
13
44
  const getAuthMap = async () => {
14
45
  const cached = authCache.get();
15
46
  if (cached)
@@ -23,7 +54,7 @@ export function createQuotaService(deps) {
23
54
  return cached;
24
55
  const client = deps.client;
25
56
  if (!client.config?.providers && !client.provider?.list) {
26
- return providerOptionsCache.set({}, 30_000);
57
+ return providerOptionsCache.set({}, 5_000);
27
58
  }
28
59
  // Newer runtimes expose config.providers; older clients may only expose
29
60
  // provider.list with a slightly different response shape.
@@ -63,7 +94,7 @@ export function createQuotaService(deps) {
63
94
  return acc;
64
95
  }, {})
65
96
  : {};
66
- return providerOptionsCache.set(map, 30_000);
97
+ return providerOptionsCache.set(map, 5_000);
67
98
  };
68
99
  const isValidQuotaCache = (snapshot) => {
69
100
  // Guard against stale RightCode cache entries from pre-daily format.
@@ -166,14 +197,6 @@ export function createQuotaService(deps) {
166
197
  ? directCandidates
167
198
  : defaultCandidates;
168
199
  const matchedCandidates = rawCandidates.filter((candidate) => Boolean(deps.quotaRuntime.resolveQuotaAdapter(candidate.providerID, candidate.providerOptions)));
169
- const dedupedCandidates = Array.from(matchedCandidates
170
- .reduce((acc, candidate) => {
171
- const key = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
172
- if (!acc.has(key))
173
- acc.set(key, candidate);
174
- return acc;
175
- }, new Map())
176
- .values());
177
200
  function authScopeFor(providerID, providerOptions) {
178
201
  const adapter = deps.quotaRuntime.resolveQuotaAdapter(providerID, providerOptions);
179
202
  const normalized = deps.quotaRuntime.normalizeProviderID(providerID);
@@ -186,24 +209,48 @@ export function createQuotaService(deps) {
186
209
  candidates.push(value);
187
210
  };
188
211
  push(providerID);
189
- push(normalized);
190
- push(adapterID);
191
212
  if (adapterID === 'github-copilot')
192
213
  push('github-copilot-enterprise');
214
+ push(normalized);
215
+ push(adapterID);
216
+ const optionsFingerprint = providerOptionsFingerprint(providerOptions);
193
217
  for (const key of candidates) {
194
218
  const auth = authMap[key];
195
219
  if (!auth)
196
220
  continue;
197
- if (key === 'openai' &&
198
- auth.type === 'oauth' &&
199
- typeof auth.accountId === 'string' &&
200
- auth.accountId) {
201
- return `${key}@${auth.accountId}`;
221
+ if (auth.type === 'oauth') {
222
+ const authRecord = auth;
223
+ const identity = (typeof auth.accountId === 'string' && auth.accountId) ||
224
+ (typeof authRecord.login === 'string' && authRecord.login) ||
225
+ (typeof authRecord.userId === 'string' && authRecord.userId);
226
+ if (identity) {
227
+ return optionsFingerprint
228
+ ? `${key}@${identity}|options@${optionsFingerprint}`
229
+ : `${key}@${identity}`;
230
+ }
231
+ }
232
+ const fingerprint = authFingerprint(auth);
233
+ if (fingerprint) {
234
+ return optionsFingerprint
235
+ ? `${key}@${fingerprint}|options@${optionsFingerprint}`
236
+ : `${key}@${fingerprint}`;
202
237
  }
203
- return key;
238
+ return optionsFingerprint ? `${key}|options@${optionsFingerprint}` : key;
239
+ }
240
+ if (optionsFingerprint) {
241
+ return `options@${optionsFingerprint}`;
204
242
  }
205
243
  return 'none';
206
244
  }
245
+ const dedupedCandidates = Array.from(matchedCandidates
246
+ .reduce((acc, candidate) => {
247
+ const baseKey = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
248
+ const key = `${baseKey}#${authScopeFor(candidate.providerID, candidate.providerOptions)}`;
249
+ if (!acc.has(key))
250
+ acc.set(key, candidate);
251
+ return acc;
252
+ }, new Map())
253
+ .values());
207
254
  let cacheChanged = false;
208
255
  const fetchSnapshot = (providerID, providerOptions) => {
209
256
  const baseKey = deps.quotaRuntime.quotaCacheKey(providerID, providerOptions);
package/dist/storage.d.ts CHANGED
@@ -30,6 +30,11 @@ export declare function scanSessionsByCreatedRange(statePath: string, startAt: n
30
30
  dateKey: string;
31
31
  state: SessionState;
32
32
  }[]>;
33
+ export declare function scanAllSessions(statePath: string, memoryState?: QuotaSidebarState): Promise<{
34
+ sessionID: string;
35
+ dateKey: string;
36
+ state: SessionState;
37
+ }[]>;
33
38
  /** Best-effort: remove a session entry from its day chunk (if present). */
34
39
  export declare function deleteSessionFromDayChunk(statePath: string, sessionID: string, dateKey: string): Promise<boolean>;
35
40
  /** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */
package/dist/storage.js CHANGED
@@ -40,6 +40,7 @@ export function defaultState() {
40
40
  titleEnabled: true,
41
41
  sessionDateMap: {},
42
42
  sessions: {},
43
+ deletedSessionDateMap: {},
43
44
  quotaCache: {},
44
45
  };
45
46
  }
@@ -128,6 +129,17 @@ async function loadVersion2State(raw, statePath) {
128
129
  acc[sessionID] = value;
129
130
  return acc;
130
131
  }, {});
132
+ const deletedSessionDateMapRaw = isRecord(raw.deletedSessionDateMap)
133
+ ? raw.deletedSessionDateMap
134
+ : {};
135
+ const deletedSessionDateMap = Object.entries(deletedSessionDateMapRaw).reduce((acc, [sessionID, value]) => {
136
+ if (typeof value !== 'string')
137
+ return acc;
138
+ if (!isDateKey(value))
139
+ return acc;
140
+ acc[sessionID] = value;
141
+ return acc;
142
+ }, {});
131
143
  const hadRawSessionDateMapEntries = isRecord(raw.sessionDateMap) && Object.keys(raw.sessionDateMap).length > 0;
132
144
  const explicitDateKeys = Array.from(new Set(Object.values(sessionDateMap)));
133
145
  // Only discover chunks when sessionDateMap is missing from state.
@@ -150,6 +162,8 @@ async function loadVersion2State(raw, statePath) {
150
162
  const sessions = {};
151
163
  for (const [dateKey, chunkSessions] of chunks) {
152
164
  for (const [sessionID, session] of Object.entries(chunkSessions)) {
165
+ if (deletedSessionDateMap[sessionID])
166
+ continue;
153
167
  sessions[sessionID] = session;
154
168
  if (!sessionDateMap[sessionID])
155
169
  sessionDateMap[sessionID] = dateKey;
@@ -160,6 +174,7 @@ async function loadVersion2State(raw, statePath) {
160
174
  titleEnabled,
161
175
  sessionDateMap,
162
176
  sessions,
177
+ deletedSessionDateMap,
163
178
  quotaCache,
164
179
  };
165
180
  }
@@ -237,22 +252,31 @@ export async function saveState(statePath, state, options) {
237
252
  await fs.mkdir(path.dirname(statePath), { recursive: true });
238
253
  if (!skipChunks) {
239
254
  const keysToWrite = writeAll
240
- ? Object.keys(sessionsByDate)
255
+ ? Array.from(new Set([
256
+ ...Object.keys(sessionsByDate),
257
+ ...Object.values(state.deletedSessionDateMap),
258
+ ]))
241
259
  : Array.from(dirtySet ?? []);
242
260
  await Promise.all(keysToWrite
243
261
  .map((dateKey) => {
244
- if (!Object.prototype.hasOwnProperty.call(sessionsByDate, dateKey)) {
245
- return undefined;
246
- }
247
262
  return withDayChunkWriteLock(dateKey, async () => {
248
263
  const memorySessions = sessionsByDate[dateKey] || {};
264
+ const tombstonedIDs = Object.entries(state.deletedSessionDateMap)
265
+ .filter(([, tombstoneDateKey]) => tombstoneDateKey === dateKey)
266
+ .map(([sessionID]) => sessionID);
249
267
  const next = writeAll
250
268
  ? memorySessions
251
269
  : {
252
270
  ...(await readDayChunk(rootPath, dateKey)),
253
271
  ...memorySessions,
254
272
  };
273
+ for (const sessionID of tombstonedIDs) {
274
+ delete next[sessionID];
275
+ }
255
276
  await writeDayChunk(rootPath, dateKey, next);
277
+ for (const sessionID of tombstonedIDs) {
278
+ delete state.deletedSessionDateMap[sessionID];
279
+ }
256
280
  });
257
281
  })
258
282
  .filter((promise) => Boolean(promise)));
@@ -262,6 +286,7 @@ export async function saveState(statePath, state, options) {
262
286
  version: 2,
263
287
  titleEnabled: state.titleEnabled,
264
288
  sessionDateMap: state.sessionDateMap,
289
+ deletedSessionDateMap: state.deletedSessionDateMap,
265
290
  quotaCache: state.quotaCache,
266
291
  }, null, 2)}\n`);
267
292
  }
@@ -292,6 +317,7 @@ export function evictOldSessions(state, retentionDays) {
292
317
  */
293
318
  export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Date.now(), memoryState) {
294
319
  const rootPath = chunkRootPathFromStateFile(statePath);
320
+ const deletedSessionIDs = new Set(Object.keys(memoryState?.deletedSessionDateMap || {}));
295
321
  const dateKeys = dateKeysInRange(startAt, endAt);
296
322
  if (!dateKeys.length) {
297
323
  return [];
@@ -302,6 +328,8 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
302
328
  if (memoryState) {
303
329
  const dateKeySet = new Set(dateKeys);
304
330
  for (const [sessionID, session] of Object.entries(memoryState.sessions)) {
331
+ if (deletedSessionIDs.has(sessionID))
332
+ continue;
305
333
  const dk = memoryState.sessionDateMap[sessionID];
306
334
  if (!dk || !dateKeySet.has(dk))
307
335
  continue;
@@ -314,11 +342,9 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
314
342
  }
315
343
  }
316
344
  }
317
- // Second pass: read disk chunks for date keys that may have sessions not in memory
318
- const memoryDateKeys = memoryState
319
- ? new Set(Object.values(memoryState.sessionDateMap))
320
- : new Set();
321
- const diskDateKeys = dateKeys.filter((dk) => !memoryDateKeys.has(dk));
345
+ // Second pass: read disk chunks for all date keys in range, then de-dupe by sessionID.
346
+ // A date can have a mix of in-memory and disk-only sessions.
347
+ const diskDateKeys = [...dateKeys];
322
348
  if (diskDateKeys.length > 0) {
323
349
  const RANGE_SCAN_CONCURRENCY = 5;
324
350
  const chunkEntries = await mapConcurrent(diskDateKeys, RANGE_SCAN_CONCURRENCY, async (dateKey) => {
@@ -332,6 +358,8 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
332
358
  for (const entry of chunkEntries.flat()) {
333
359
  if (seenSessionIDs.has(entry.sessionID))
334
360
  continue;
361
+ if (deletedSessionIDs.has(entry.sessionID))
362
+ continue;
335
363
  const createdAt = Number.isFinite(entry.state.createdAt) && entry.state.createdAt > 0
336
364
  ? entry.state.createdAt
337
365
  : dateStartFromKey(entry.dateKey);
@@ -343,6 +371,43 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
343
371
  }
344
372
  return results;
345
373
  }
374
+ export async function scanAllSessions(statePath, memoryState) {
375
+ const rootPath = chunkRootPathFromStateFile(statePath);
376
+ const deletedSessionIDs = new Set(Object.keys(memoryState?.deletedSessionDateMap || {}));
377
+ const results = [];
378
+ const seenSessionIDs = new Set();
379
+ if (memoryState) {
380
+ for (const [sessionID, sessionState] of Object.entries(memoryState.sessions)) {
381
+ if (deletedSessionIDs.has(sessionID))
382
+ continue;
383
+ const dateKey = memoryState.sessionDateMap[sessionID] ||
384
+ dateKeyFromTimestamp(sessionState.createdAt);
385
+ results.push({ sessionID, dateKey, state: sessionState });
386
+ seenSessionIDs.add(sessionID);
387
+ }
388
+ }
389
+ const dateKeys = await discoverChunks(rootPath);
390
+ if (dateKeys.length === 0)
391
+ return results;
392
+ const RANGE_SCAN_CONCURRENCY = 5;
393
+ const chunkEntries = await mapConcurrent(dateKeys, RANGE_SCAN_CONCURRENCY, async (dateKey) => {
394
+ const sessions = await readDayChunk(rootPath, dateKey);
395
+ return Object.entries(sessions).map(([sessionID, state]) => ({
396
+ sessionID,
397
+ dateKey,
398
+ state,
399
+ }));
400
+ });
401
+ for (const entry of chunkEntries.flat()) {
402
+ if (seenSessionIDs.has(entry.sessionID))
403
+ continue;
404
+ if (deletedSessionIDs.has(entry.sessionID))
405
+ continue;
406
+ results.push(entry);
407
+ seenSessionIDs.add(entry.sessionID);
408
+ }
409
+ return results;
410
+ }
346
411
  /** Best-effort: remove a session entry from its day chunk (if present). */
347
412
  export async function deleteSessionFromDayChunk(statePath, sessionID, dateKey) {
348
413
  const rootPath = chunkRootPathFromStateFile(statePath);