@leo000001/opencode-quota-sidebar 1.0.3 → 1.3.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
@@ -17,6 +17,8 @@ Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to i
17
17
  }
18
18
  ```
19
19
 
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
+
20
22
  ## Development (build from source)
21
23
 
22
24
  ```bash
@@ -105,6 +107,7 @@ memory on startup. Chunk files remain on disk for historical range scans.
105
107
 
106
108
  - Node.js: >= 18 (for `fetch` + `AbortController`)
107
109
  - OpenCode: plugin SDK `@opencode-ai/plugin` ^1.2.10
110
+ - OpenCode config split: if you are on `>=1.2.15`, keep this plugin in `opencode.json` and keep TUI-only keys in `tui.json`.
108
111
 
109
112
  ## Optional commands
110
113
 
@@ -177,8 +180,8 @@ Notes:
177
180
  - `sidebar.childrenMaxDepth` limits how many levels of nested subagents are traversed (default: `6`, clamped 1–32).
178
181
  - `sidebar.childrenMaxSessions` caps the total number of descendant sessions aggregated (default: `128`, clamped 0–2000).
179
182
  - `sidebar.childrenConcurrency` controls parallel fetches for descendant session messages (default: `5`, clamped 1–10).
180
- - `output` now includes reasoning tokens. Reasoning is no longer rendered as a separate line.
181
- - 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).
182
185
  - `quota.providers` is the extensible per-adapter switch map.
183
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.
184
187
 
package/dist/cost.js CHANGED
@@ -65,9 +65,12 @@ export function guessModelCostDivisor(rates) {
65
65
  : MODEL_COST_DIVISOR_PER_TOKEN;
66
66
  }
67
67
  export function calcEquivalentApiCostForMessage(message, rates) {
68
+ // For providers that expose reasoning tokens separately, they are still
69
+ // billed as output/completion tokens (same unit price). Our UI also merges
70
+ // reasoning into the single Output statistic, so API cost should match that.
71
+ const billedOutput = message.tokens.output + message.tokens.reasoning;
68
72
  const rawCost = message.tokens.input * rates.input +
69
- // API cost intentionally excludes reasoning tokens.
70
- message.tokens.output * rates.output +
73
+ billedOutput * rates.output +
71
74
  message.tokens.cache.read * rates.cacheRead +
72
75
  message.tokens.cache.write * rates.cacheWrite;
73
76
  const divisor = guessModelCostDivisor(rates);
@@ -159,18 +159,19 @@ async function fetchRightCodeQuota(ctx) {
159
159
  const dailyTotal = matched.reduce((sum, subscription) => sum + subscription.dailyTotal, 0);
160
160
  const dailyRemaining = matched.reduce((sum, subscription) => sum + subscription.dailyRemaining, 0);
161
161
  const dailyPercent = dailyTotal > 0 ? (dailyRemaining / dailyTotal) * 100 : undefined;
162
- const expiry = matched.reduce((acc, subscription) => {
163
- if (!subscription.expiresAt)
164
- return acc;
165
- const current = Date.parse(subscription.expiresAt);
166
- if (Number.isNaN(current))
167
- return acc;
162
+ const parsedExpiries = matched
163
+ .map((subscription) => subscription.expiresAt)
164
+ .filter((iso) => typeof iso === 'string' && !!iso)
165
+ .map((iso) => ({ iso, ts: Date.parse(iso) }))
166
+ .filter((item) => !Number.isNaN(item.ts));
167
+ const uniqueExpiryTimestamps = new Set(parsedExpiries.map((e) => e.ts));
168
+ const hasMultipleExpiries = uniqueExpiryTimestamps.size > 1;
169
+ const expiry = parsedExpiries.reduce((acc, item) => {
168
170
  if (!acc)
169
- return subscription.expiresAt;
171
+ return item.iso;
170
172
  const existing = Date.parse(acc);
171
- if (Number.isNaN(existing) || current < existing) {
172
- return subscription.expiresAt;
173
- }
173
+ if (Number.isNaN(existing) || item.ts < existing)
174
+ return item.iso;
174
175
  return acc;
175
176
  }, undefined);
176
177
  const windows = [
@@ -179,7 +180,7 @@ async function fetchRightCodeQuota(ctx) {
179
180
  showPercent: false,
180
181
  remainingPercent: dailyPercent,
181
182
  resetAt: expiry,
182
- resetLabel: 'Exp',
183
+ resetLabel: hasMultipleExpiries ? 'Exp+' : 'Exp',
183
184
  },
184
185
  ];
185
186
  const names = matched.map((subscription) => subscription.name).join(', ');
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
@@ -161,6 +161,21 @@ export async function loadState(statePath) {
161
161
  }
162
162
  // ─── State saving ────────────────────────────────────────────────────────────
163
163
  const MAX_QUOTA_CACHE_AGE_MS = 24 * 60 * 60 * 1000;
164
+ const dayChunkWriteLocks = new Map();
165
+ async function withDayChunkWriteLock(dateKey, fn) {
166
+ const previous = dayChunkWriteLocks.get(dateKey) || Promise.resolve();
167
+ const run = previous.then(() => fn());
168
+ const release = run.then(() => undefined, () => undefined);
169
+ dayChunkWriteLocks.set(dateKey, release);
170
+ try {
171
+ return await run;
172
+ }
173
+ finally {
174
+ if (dayChunkWriteLocks.get(dateKey) === release) {
175
+ dayChunkWriteLocks.delete(dateKey);
176
+ }
177
+ }
178
+ }
164
179
  function pruneState(state) {
165
180
  const now = Date.now();
166
181
  for (const [key, value] of Object.entries(state.quotaCache)) {
@@ -214,7 +229,7 @@ export async function saveState(statePath, state, options) {
214
229
  if (!Object.prototype.hasOwnProperty.call(sessionsByDate, dateKey)) {
215
230
  return undefined;
216
231
  }
217
- return (async () => {
232
+ return withDayChunkWriteLock(dateKey, async () => {
218
233
  const memorySessions = sessionsByDate[dateKey] || {};
219
234
  const next = writeAll
220
235
  ? memorySessions
@@ -223,7 +238,7 @@ export async function saveState(statePath, state, options) {
223
238
  ...memorySessions,
224
239
  };
225
240
  await writeDayChunk(rootPath, dateKey, next);
226
- })();
241
+ });
227
242
  })
228
243
  .filter((promise) => Boolean(promise)));
229
244
  }
@@ -316,11 +331,50 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
316
331
  /** Best-effort: remove a session entry from its day chunk (if present). */
317
332
  export async function deleteSessionFromDayChunk(statePath, sessionID, dateKey) {
318
333
  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;
334
+ return withDayChunkWriteLock(dateKey, async () => {
335
+ const sessions = await readDayChunk(rootPath, dateKey);
336
+ if (!Object.prototype.hasOwnProperty.call(sessions, sessionID))
337
+ return false;
338
+ const next = { ...sessions };
339
+ delete next[sessionID];
340
+ await writeDayChunk(rootPath, dateKey, next);
341
+ return true;
342
+ });
343
+ }
344
+ /** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */
345
+ export async function updateSessionsInDayChunks(statePath, updates) {
346
+ if (updates.length === 0)
347
+ return 0;
348
+ const rootPath = chunkRootPathFromStateFile(statePath);
349
+ const byDate = updates.reduce((acc, item) => {
350
+ const bucket = acc[item.dateKey] || [];
351
+ bucket.push(item);
352
+ acc[item.dateKey] = bucket;
353
+ return acc;
354
+ }, {});
355
+ let written = 0;
356
+ const WRITE_CONCURRENCY = 5;
357
+ await mapConcurrent(Object.entries(byDate), WRITE_CONCURRENCY, async ([dateKey, dateUpdates]) => {
358
+ await withDayChunkWriteLock(dateKey, async () => {
359
+ const sessions = await readDayChunk(rootPath, dateKey);
360
+ const next = { ...sessions };
361
+ let changed = false;
362
+ for (const item of dateUpdates) {
363
+ const existing = next[item.sessionID];
364
+ if (!existing)
365
+ continue;
366
+ next[item.sessionID] = {
367
+ ...existing,
368
+ usage: item.usage,
369
+ cursor: item.cursor,
370
+ };
371
+ changed = true;
372
+ }
373
+ if (!changed)
374
+ return;
375
+ await writeDayChunk(rootPath, dateKey, next);
376
+ written++;
377
+ });
378
+ });
379
+ return written;
326
380
  }
@@ -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;
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.0.3",
3
+ "version": "1.3.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",