@leo000001/opencode-quota-sidebar 1.2.0 → 1.4.0

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
@@ -180,8 +180,8 @@ Notes:
180
180
  - `sidebar.childrenMaxDepth` limits how many levels of nested subagents are traversed (default: `6`, clamped 1–32).
181
181
  - `sidebar.childrenMaxSessions` caps the total number of descendant sessions aggregated (default: `128`, clamped 0–2000).
182
182
  - `sidebar.childrenConcurrency` controls parallel fetches for descendant session messages (default: `5`, clamped 1–10).
183
- - `output` now includes reasoning tokens. Reasoning is no longer rendered as a separate line.
184
- - API cost excludes reasoning tokens from output billing (uses `tokens.output` only for output-price multiplication).
183
+ - `output` includes reasoning tokens (`output = tokens.output + tokens.reasoning`). Reasoning is not rendered as a separate line.
184
+ - API cost bills reasoning tokens at the output rate (same as completion tokens).
185
185
  - `quota.providers` is the extensible per-adapter switch map.
186
186
  - 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.
187
187
 
package/dist/format.js CHANGED
@@ -187,7 +187,10 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
187
187
  return Math.max(max, stringCellWidth(label));
188
188
  }, 0);
189
189
  const quotaItems = visibleQuotas
190
- .flatMap((item) => compactQuotaWide(item, labelWidth))
190
+ .flatMap((item) => compactQuotaWide(item, labelWidth, {
191
+ width,
192
+ wrapLines: config.sidebar.wrapQuotaLines,
193
+ }))
191
194
  .filter((s) => Boolean(s));
192
195
  if (quotaItems.length > 0) {
193
196
  lines.push('');
@@ -200,14 +203,31 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
200
203
  }
201
204
  /**
202
205
  * Multi-window quota format for sidebar.
203
- * Single window: "OpenAI 5h 80% Rst 16:20"
204
- * Multi window: "OpenAI 5h 80% Rst 16:20" + indented next line
205
- * Copilot: "Copilot Monthly 70% Rst 03-01"
206
+ *
207
+ * When wrapLines=false (or content fits):
208
+ * "OpenAI 5h 80% Rst 16:20"
209
+ * " Weekly 70% Rst 03-01"
210
+ *
211
+ * When wrapLines=true and label+content overflows width:
212
+ * "RC-openai"
213
+ * " Daily $349.66/$180 Exp+ 02-27"
214
+ * " Balance $108.88"
206
215
  */
207
- function compactQuotaWide(quota, labelWidth = 0) {
216
+ function compactQuotaWide(quota, labelWidth = 0, options) {
208
217
  const label = sanitizeLine(quotaDisplayLabel(quota));
209
218
  const labelPadded = padEndCells(label, labelWidth);
219
+ const indent = ' '.repeat(labelWidth + 1);
220
+ const detailIndent = ' ';
210
221
  const withLabel = (content) => `${labelPadded} ${content}`;
222
+ const wrap = options?.wrapLines === true && (options?.width || 0) > 0;
223
+ const width = options?.width || 0;
224
+ /** If inline version overflows, break into label-line + indented detail lines. */
225
+ const maybeBreak = (inlineText, detailLines) => {
226
+ const inline = withLabel(inlineText);
227
+ if (!wrap || stringCellWidth(inline) <= width)
228
+ return [inline];
229
+ return [label, ...detailLines.map((d) => `${detailIndent}${d}`)];
230
+ };
211
231
  if (quota.status === 'error')
212
232
  return [withLabel('Remaining ?')];
213
233
  if (quota.status === 'unsupported')
@@ -229,7 +249,7 @@ function compactQuotaWide(quota, labelWidth = 0) {
229
249
  ? [sanitizeLine(win.label), pct]
230
250
  : [sanitizeLine(win.label)]
231
251
  : [pct];
232
- const reset = compactReset(win.resetAt);
252
+ const reset = compactReset(win.resetAt, win.resetLabel);
233
253
  if (reset) {
234
254
  parts.push(`${sanitizeLine(win.resetLabel || 'Rst')} ${reset}`);
235
255
  }
@@ -238,42 +258,64 @@ function compactQuotaWide(quota, labelWidth = 0) {
238
258
  // Multi-window rendering
239
259
  if (quota.windows && quota.windows.length > 0) {
240
260
  const parts = quota.windows.map(renderWindow);
261
+ // Build the detail lines (window texts + optional balance)
262
+ const details = [...parts];
263
+ if (balanceText && !parts.some((p) => p.includes('Balance '))) {
264
+ details.push(balanceText);
265
+ }
266
+ // Try inline first (single window, fits in one line)
241
267
  if (parts.length === 1) {
242
- const first = withLabel(parts[0]);
243
- if (balanceText && !parts[0].includes('Balance ')) {
244
- const indent = ' '.repeat(labelWidth + 1);
245
- return [first, `${indent}${balanceText}`];
268
+ const firstInline = withLabel(parts[0]);
269
+ if (!wrap || stringCellWidth(firstInline) <= width) {
270
+ // Inline fits use classic layout
271
+ const lines = [firstInline];
272
+ if (balanceText && !parts[0].includes('Balance ')) {
273
+ lines.push(`${indent}${balanceText}`);
274
+ }
275
+ return lines;
246
276
  }
247
- return [first];
277
+ // Overflow — break: label on its own line, details indented
278
+ return [label, ...details.map((d) => `${detailIndent}${d}`)];
248
279
  }
249
- const indent = ' '.repeat(labelWidth + 1);
250
- const lines = [
251
- withLabel(parts[0]),
252
- ...parts.slice(1).map((part) => `${indent}${part}`),
253
- ];
254
- const alreadyHasBalance = parts.some((part) => part.includes('Balance '));
255
- if (balanceText && !alreadyHasBalance) {
256
- lines.push(`${indent}${balanceText}`);
280
+ // Multiple windows: try classic inline layout first
281
+ const firstInline = withLabel(parts[0]);
282
+ if (!wrap || stringCellWidth(firstInline) <= width) {
283
+ const lines = [
284
+ firstInline,
285
+ ...parts.slice(1).map((part) => `${indent}${part}`),
286
+ ];
287
+ if (balanceText && !parts.some((p) => p.includes('Balance '))) {
288
+ lines.push(`${indent}${balanceText}`);
289
+ }
290
+ return lines;
257
291
  }
258
- return lines;
292
+ // Overflow — break all
293
+ return [label, ...details.map((d) => `${detailIndent}${d}`)];
259
294
  }
260
295
  if (balanceText) {
261
- return [withLabel(balanceText)];
296
+ return maybeBreak(balanceText, [balanceText]);
262
297
  }
263
298
  // Fallback: single value from top-level remainingPercent
264
299
  const percent = quota.remainingPercent === undefined
265
300
  ? '?'
266
301
  : `${Math.round(quota.remainingPercent)}%`;
267
- const reset = compactReset(quota.resetAt);
268
- return [withLabel(`Remaining ${percent}${reset ? ` Rst ${reset}` : ''}`)];
302
+ const reset = compactReset(quota.resetAt, 'Rst');
303
+ const fallbackText = `Remaining ${percent}${reset ? ` Rst ${reset}` : ''}`;
304
+ return maybeBreak(fallbackText, [fallbackText]);
269
305
  }
270
- function compactReset(iso) {
306
+ function compactReset(iso, resetLabel) {
271
307
  if (!iso)
272
308
  return undefined;
273
309
  const timestamp = Date.parse(iso);
274
310
  if (Number.isNaN(timestamp))
275
311
  return undefined;
276
312
  const value = new Date(timestamp);
313
+ // RightCode subscriptions are displayed as an expiry date (MM-DD), not a time.
314
+ // Using UTC here makes the output stable across time zones for ISO `...Z` input.
315
+ if (typeof resetLabel === 'string' && resetLabel.startsWith('Exp')) {
316
+ const two = (num) => `${num}`.padStart(2, '0');
317
+ return `${two(value.getUTCMonth() + 1)}-${two(value.getUTCDate())}`;
318
+ }
277
319
  const now = new Date();
278
320
  const sameDay = value.getFullYear() === now.getFullYear() &&
279
321
  value.getMonth() === now.getMonth() &&
@@ -475,7 +517,7 @@ export function renderToastMessage(period, usage, quotas, options) {
475
517
  const pct = win.remainingPercent === undefined
476
518
  ? '-'
477
519
  : `${win.remainingPercent.toFixed(1)}%`;
478
- const reset = compactReset(win.resetAt);
520
+ const reset = compactReset(win.resetAt, win.resetLabel);
479
521
  const parts = [win.label];
480
522
  if (showPercent)
481
523
  parts.push(pct);
@@ -505,7 +547,7 @@ export function renderToastMessage(period, usage, quotas, options) {
505
547
  const percent = item.remainingPercent === undefined
506
548
  ? '-'
507
549
  : `${item.remainingPercent.toFixed(1)}%`;
508
- const reset = compactReset(item.resetAt);
550
+ const reset = compactReset(item.resetAt, 'Rst');
509
551
  return [
510
552
  {
511
553
  label: quotaDisplayLabel(item),
package/dist/storage.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { dateKeyFromTimestamp, normalizeTimestampMs } from './storage_dates.js';
2
2
  import { authFilePath, resolveOpencodeDataDir, stateFilePath } from './storage_paths.js';
3
- import type { QuotaSidebarConfig, QuotaSidebarState, SessionState } from './types.js';
3
+ import type { CachedSessionUsage, IncrementalCursor, QuotaSidebarConfig, QuotaSidebarState, SessionState } from './types.js';
4
4
  export { authFilePath, dateKeyFromTimestamp, normalizeTimestampMs, resolveOpencodeDataDir, stateFilePath, };
5
5
  export declare const defaultConfig: QuotaSidebarConfig;
6
6
  export declare function defaultState(): QuotaSidebarState;
@@ -32,3 +32,10 @@ export declare function scanSessionsByCreatedRange(statePath: string, startAt: n
32
32
  }[]>;
33
33
  /** Best-effort: remove a session entry from its day chunk (if present). */
34
34
  export declare function deleteSessionFromDayChunk(statePath: string, sessionID: string, dateKey: string): Promise<boolean>;
35
+ /** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */
36
+ export declare function updateSessionsInDayChunks(statePath: string, updates: Array<{
37
+ sessionID: string;
38
+ dateKey: string;
39
+ usage: CachedSessionUsage;
40
+ cursor: IncrementalCursor | undefined;
41
+ }>): Promise<number>;
package/dist/storage.js CHANGED
@@ -13,6 +13,7 @@ export const defaultConfig = {
13
13
  width: 36,
14
14
  showCost: true,
15
15
  showQuota: true,
16
+ wrapQuotaLines: true,
16
17
  includeChildren: true,
17
18
  childrenMaxDepth: 6,
18
19
  childrenMaxSessions: 128,
@@ -68,6 +69,7 @@ export async function loadConfig(paths) {
68
69
  width: Math.max(20, Math.min(60, asNumber(sidebar.width, defaultConfig.sidebar.width))),
69
70
  showCost: asBoolean(sidebar.showCost, defaultConfig.sidebar.showCost),
70
71
  showQuota: asBoolean(sidebar.showQuota, defaultConfig.sidebar.showQuota),
72
+ wrapQuotaLines: asBoolean(sidebar.wrapQuotaLines, defaultConfig.sidebar.wrapQuotaLines),
71
73
  includeChildren: asBoolean(sidebar.includeChildren, defaultConfig.sidebar.includeChildren),
72
74
  childrenMaxDepth: Math.max(1, Math.min(32, Math.floor(asNumber(sidebar.childrenMaxDepth, defaultConfig.sidebar.childrenMaxDepth)))),
73
75
  childrenMaxSessions: Math.max(0, Math.min(2000, Math.floor(asNumber(sidebar.childrenMaxSessions, defaultConfig.sidebar.childrenMaxSessions)))),
@@ -161,6 +163,21 @@ export async function loadState(statePath) {
161
163
  }
162
164
  // ─── State saving ────────────────────────────────────────────────────────────
163
165
  const MAX_QUOTA_CACHE_AGE_MS = 24 * 60 * 60 * 1000;
166
+ const dayChunkWriteLocks = new Map();
167
+ async function withDayChunkWriteLock(dateKey, fn) {
168
+ const previous = dayChunkWriteLocks.get(dateKey) || Promise.resolve();
169
+ const run = previous.then(() => fn());
170
+ const release = run.then(() => undefined, () => undefined);
171
+ dayChunkWriteLocks.set(dateKey, release);
172
+ try {
173
+ return await run;
174
+ }
175
+ finally {
176
+ if (dayChunkWriteLocks.get(dateKey) === release) {
177
+ dayChunkWriteLocks.delete(dateKey);
178
+ }
179
+ }
180
+ }
164
181
  function pruneState(state) {
165
182
  const now = Date.now();
166
183
  for (const [key, value] of Object.entries(state.quotaCache)) {
@@ -214,7 +231,7 @@ export async function saveState(statePath, state, options) {
214
231
  if (!Object.prototype.hasOwnProperty.call(sessionsByDate, dateKey)) {
215
232
  return undefined;
216
233
  }
217
- return (async () => {
234
+ return withDayChunkWriteLock(dateKey, async () => {
218
235
  const memorySessions = sessionsByDate[dateKey] || {};
219
236
  const next = writeAll
220
237
  ? memorySessions
@@ -223,7 +240,7 @@ export async function saveState(statePath, state, options) {
223
240
  ...memorySessions,
224
241
  };
225
242
  await writeDayChunk(rootPath, dateKey, next);
226
- })();
243
+ });
227
244
  })
228
245
  .filter((promise) => Boolean(promise)));
229
246
  }
@@ -316,11 +333,50 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
316
333
  /** Best-effort: remove a session entry from its day chunk (if present). */
317
334
  export async function deleteSessionFromDayChunk(statePath, sessionID, dateKey) {
318
335
  const rootPath = chunkRootPathFromStateFile(statePath);
319
- const sessions = await readDayChunk(rootPath, dateKey);
320
- if (!Object.prototype.hasOwnProperty.call(sessions, sessionID))
321
- return false;
322
- const next = { ...sessions };
323
- delete next[sessionID];
324
- await writeDayChunk(rootPath, dateKey, next);
325
- return true;
336
+ return withDayChunkWriteLock(dateKey, async () => {
337
+ const sessions = await readDayChunk(rootPath, dateKey);
338
+ if (!Object.prototype.hasOwnProperty.call(sessions, sessionID))
339
+ return false;
340
+ const next = { ...sessions };
341
+ delete next[sessionID];
342
+ await writeDayChunk(rootPath, dateKey, next);
343
+ return true;
344
+ });
345
+ }
346
+ /** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */
347
+ export async function updateSessionsInDayChunks(statePath, updates) {
348
+ if (updates.length === 0)
349
+ return 0;
350
+ const rootPath = chunkRootPathFromStateFile(statePath);
351
+ const byDate = updates.reduce((acc, item) => {
352
+ const bucket = acc[item.dateKey] || [];
353
+ bucket.push(item);
354
+ acc[item.dateKey] = bucket;
355
+ return acc;
356
+ }, {});
357
+ let written = 0;
358
+ const WRITE_CONCURRENCY = 5;
359
+ await mapConcurrent(Object.entries(byDate), WRITE_CONCURRENCY, async ([dateKey, dateUpdates]) => {
360
+ await withDayChunkWriteLock(dateKey, async () => {
361
+ const sessions = await readDayChunk(rootPath, dateKey);
362
+ const next = { ...sessions };
363
+ let changed = false;
364
+ for (const item of dateUpdates) {
365
+ const existing = next[item.sessionID];
366
+ if (!existing)
367
+ continue;
368
+ next[item.sessionID] = {
369
+ ...existing,
370
+ usage: item.usage,
371
+ cursor: item.cursor,
372
+ };
373
+ changed = true;
374
+ }
375
+ if (!changed)
376
+ return;
377
+ await writeDayChunk(rootPath, dateKey, next);
378
+ written++;
379
+ });
380
+ });
381
+ return written;
326
382
  }
@@ -40,6 +40,7 @@ function parseCachedUsage(value) {
40
40
  return acc;
41
41
  }, {});
42
42
  return {
43
+ billingVersion: asNumber(value.billingVersion),
43
44
  input: asNumber(value.input, 0),
44
45
  output: asNumber(value.output, 0),
45
46
  reasoning: asNumber(value.reasoning, 0),
package/dist/types.d.ts CHANGED
@@ -49,6 +49,8 @@ export type CachedProviderUsage = {
49
49
  assistantMessages: number;
50
50
  };
51
51
  export type CachedSessionUsage = {
52
+ /** Billing aggregation cache version for cost/apiCost refresh migrations. */
53
+ billingVersion?: number;
52
54
  input: number;
53
55
  output: number;
54
56
  reasoning: number;
@@ -97,6 +99,8 @@ export type QuotaSidebarConfig = {
97
99
  width: number;
98
100
  showCost: boolean;
99
101
  showQuota: boolean;
102
+ /** When true, wrap long quota lines and indent continuations. */
103
+ wrapQuotaLines: boolean;
100
104
  /** Include descendant subagent sessions in session-scoped usage/quota. */
101
105
  includeChildren: boolean;
102
106
  /** Max descendant traversal depth when includeChildren is enabled. */
package/dist/usage.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AssistantMessage, Message } from '@opencode-ai/sdk';
2
2
  import type { CachedSessionUsage, IncrementalCursor } from './types.js';
3
+ export declare const USAGE_BILLING_CACHE_VERSION = 1;
3
4
  export type ProviderUsage = {
4
5
  providerID: string;
5
6
  input: number;
package/dist/usage.js CHANGED
@@ -1,3 +1,4 @@
1
+ export const USAGE_BILLING_CACHE_VERSION = 1;
1
2
  export function emptyUsageSummary() {
2
3
  return {
3
4
  input: 0,
@@ -189,10 +190,16 @@ export function summarizeMessagesIncremental(entries, existingUsage, cursor, for
189
190
  time: nextCursor.lastMessageTime ?? cursorTime,
190
191
  };
191
192
  if (newerThan(candidate, current)) {
193
+ const idsAtCursorTime = new Set(nextCursor.lastMessageIdsAtTime ||
194
+ cursor.lastMessageIdsAtTime ||
195
+ (current.id ? [current.id] : []));
196
+ idsAtCursorTime.add(msg.id);
192
197
  nextCursor = {
193
198
  lastMessageId: msg.id,
194
199
  lastMessageTime: msg.time.completed,
195
- lastMessageIdsAtTime: [msg.id],
200
+ lastMessageIdsAtTime: candidate.time > current.time
201
+ ? [msg.id]
202
+ : Array.from(idsAtCursorTime).sort(),
196
203
  };
197
204
  }
198
205
  else if (nextCursor.lastMessageTime === msg.time.completed) {
@@ -298,6 +305,7 @@ export function toCachedSessionUsage(summary) {
298
305
  return acc;
299
306
  }, {});
300
307
  return {
308
+ billingVersion: USAGE_BILLING_CACHE_VERSION,
301
309
  input: summary.input,
302
310
  output: summary.output,
303
311
  // Always 0 after merge into output; kept for serialization shape.
@@ -1,9 +1,9 @@
1
1
  import { TtlValueCache } from './cache.js';
2
2
  import { calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
3
- import { dateKeyFromTimestamp, scanSessionsByCreatedRange } from './storage.js';
3
+ import { dateKeyFromTimestamp, scanSessionsByCreatedRange, updateSessionsInDayChunks, } from './storage.js';
4
4
  import { periodStart } from './period.js';
5
5
  import { debug, isRecord, mapConcurrent, swallow } from './helpers.js';
6
- import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, } from './usage.js';
6
+ import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
7
7
  export function createUsageService(deps) {
8
8
  const forceRescanSessions = new Set();
9
9
  const dirtyGeneration = new Map();
@@ -189,6 +189,11 @@ export function createUsageService(deps) {
189
189
  deps.state.sessionDateMap[sessionID] = dateKey;
190
190
  deps.persistence.markDirty(dateKey);
191
191
  };
192
+ const isUsageBillingCurrent = (cached) => {
193
+ if (!cached)
194
+ return false;
195
+ return cached.billingVersion === USAGE_BILLING_CACHE_VERSION;
196
+ };
192
197
  const summarizeSessionUsage = async (sessionID, generationAtStart) => {
193
198
  const entries = await loadSessionEntries(sessionID);
194
199
  const sessionState = deps.state.sessions[sessionID];
@@ -206,9 +211,14 @@ export function createUsageService(deps) {
206
211
  return { usage: empty, persist: false };
207
212
  }
208
213
  const modelCostMap = await getModelCostMap();
209
- const forceRescan = forceRescanSessions.has(sessionID);
214
+ const staleBillingCache = Boolean(sessionState?.usage) &&
215
+ !isUsageBillingCurrent(sessionState?.usage);
216
+ const forceRescan = forceRescanSessions.has(sessionID) || staleBillingCache;
210
217
  if (forceRescan)
211
218
  forceRescanSessions.delete(sessionID);
219
+ if (staleBillingCache) {
220
+ debug(`usage cache billing refresh for session ${sessionID}`);
221
+ }
212
222
  const { usage, cursor } = summarizeMessagesIncremental(entries, sessionState?.usage, sessionState?.cursor, forceRescan, {
213
223
  calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
214
224
  });
@@ -268,7 +278,7 @@ export function createUsageService(deps) {
268
278
  const needsFetch = [];
269
279
  for (const childID of descendantIDs) {
270
280
  const cached = deps.state.sessions[childID]?.usage;
271
- if (cached && !isDirty(childID)) {
281
+ if (cached && !isDirty(childID) && isUsageBillingCurrent(cached)) {
272
282
  mergeUsage(merged, fromCachedSessionUsage(cached, 1));
273
283
  }
274
284
  else {
@@ -309,7 +319,9 @@ export function createUsageService(deps) {
309
319
  return SUBSCRIPTION_API_COST_PROVIDERS.has(canonical);
310
320
  });
311
321
  };
312
- const shouldRecomputeApiCost = (cached) => {
322
+ const shouldRecomputeUsageCache = (cached) => {
323
+ if (!isUsageBillingCurrent(cached))
324
+ return true;
313
325
  if (!hasPricing)
314
326
  return false;
315
327
  if (cached.assistantMessages <= 0)
@@ -325,7 +337,7 @@ export function createUsageService(deps) {
325
337
  const needsFetch = [];
326
338
  for (const session of sessions) {
327
339
  if (session.state.usage) {
328
- if (shouldRecomputeApiCost(session.state.usage)) {
340
+ if (shouldRecomputeUsageCache(session.state.usage)) {
329
341
  needsFetch.push(session);
330
342
  }
331
343
  else {
@@ -343,35 +355,58 @@ export function createUsageService(deps) {
343
355
  if (session.state.usage) {
344
356
  return {
345
357
  sessionID: session.sessionID,
358
+ dateKey: session.dateKey,
346
359
  computed: fromCachedSessionUsage(session.state.usage, 1),
347
360
  persist: false,
361
+ cursor: session.state.cursor,
348
362
  };
349
363
  }
350
364
  const empty = emptyUsageSummary();
351
365
  empty.sessionCount = 1;
352
366
  return {
353
367
  sessionID: session.sessionID,
368
+ dateKey: session.dateKey,
354
369
  computed: empty,
355
370
  persist: false,
371
+ cursor: undefined,
356
372
  };
357
373
  }
358
- const { usage: computed } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
374
+ const { usage: computed, cursor } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
359
375
  calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
360
376
  });
361
- return { sessionID: session.sessionID, computed, persist: true };
377
+ return {
378
+ sessionID: session.sessionID,
379
+ dateKey: session.dateKey,
380
+ computed,
381
+ persist: true,
382
+ cursor,
383
+ };
362
384
  });
363
385
  let dirty = false;
364
- for (const { sessionID, computed, persist } of fetched) {
386
+ const diskOnlyUpdates = [];
387
+ for (const { sessionID, dateKey, computed, persist, cursor } of fetched) {
365
388
  mergeUsage(usage, { ...computed, sessionCount: 0 });
366
389
  const memoryState = deps.state.sessions[sessionID];
367
390
  if (persist && memoryState) {
368
391
  memoryState.usage = toCachedSessionUsage(computed);
369
- const dateKey = deps.state.sessionDateMap[sessionID] ||
392
+ memoryState.cursor = cursor;
393
+ const resolvedDateKey = deps.state.sessionDateMap[sessionID] ||
370
394
  dateKeyFromTimestamp(memoryState.createdAt);
371
- deps.state.sessionDateMap[sessionID] = dateKey;
372
- deps.persistence.markDirty(dateKey);
395
+ deps.state.sessionDateMap[sessionID] = resolvedDateKey;
396
+ deps.persistence.markDirty(resolvedDateKey);
373
397
  dirty = true;
374
398
  }
399
+ else if (persist) {
400
+ diskOnlyUpdates.push({
401
+ sessionID,
402
+ dateKey,
403
+ usage: toCachedSessionUsage(computed),
404
+ cursor,
405
+ });
406
+ }
407
+ }
408
+ if (diskOnlyUpdates.length > 0) {
409
+ await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch(swallow('updateSessionsInDayChunks'));
375
410
  }
376
411
  if (dirty)
377
412
  deps.persistence.scheduleSave();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",