@leo000001/opencode-quota-sidebar 1.0.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/CONTRIBUTING.md +102 -0
  3. package/LICENSE +21 -0
  4. package/README.md +216 -0
  5. package/SECURITY.md +26 -0
  6. package/dist/cache.d.ts +6 -0
  7. package/dist/cache.js +22 -0
  8. package/dist/cost.d.ts +13 -0
  9. package/dist/cost.js +76 -0
  10. package/dist/format.d.ts +21 -0
  11. package/dist/format.js +426 -0
  12. package/dist/helpers.d.ts +14 -0
  13. package/dist/helpers.js +50 -0
  14. package/dist/index.d.ts +5 -0
  15. package/dist/index.js +699 -0
  16. package/dist/period.d.ts +1 -0
  17. package/dist/period.js +14 -0
  18. package/dist/providers/common.d.ts +24 -0
  19. package/dist/providers/common.js +114 -0
  20. package/dist/providers/core/anthropic.d.ts +2 -0
  21. package/dist/providers/core/anthropic.js +46 -0
  22. package/dist/providers/core/copilot.d.ts +2 -0
  23. package/dist/providers/core/copilot.js +117 -0
  24. package/dist/providers/core/openai.d.ts +2 -0
  25. package/dist/providers/core/openai.js +159 -0
  26. package/dist/providers/index.d.ts +8 -0
  27. package/dist/providers/index.js +14 -0
  28. package/dist/providers/registry.d.ts +9 -0
  29. package/dist/providers/registry.js +38 -0
  30. package/dist/providers/third_party/rightcode.d.ts +2 -0
  31. package/dist/providers/third_party/rightcode.js +230 -0
  32. package/dist/providers/types.d.ts +58 -0
  33. package/dist/providers/types.js +1 -0
  34. package/dist/quota.d.ts +49 -0
  35. package/dist/quota.js +116 -0
  36. package/dist/quota_render.d.ts +5 -0
  37. package/dist/quota_render.js +85 -0
  38. package/dist/storage.d.ts +32 -0
  39. package/dist/storage.js +328 -0
  40. package/dist/storage_chunks.d.ts +9 -0
  41. package/dist/storage_chunks.js +147 -0
  42. package/dist/storage_dates.d.ts +9 -0
  43. package/dist/storage_dates.js +88 -0
  44. package/dist/storage_parse.d.ts +4 -0
  45. package/dist/storage_parse.js +149 -0
  46. package/dist/storage_paths.d.ts +14 -0
  47. package/dist/storage_paths.js +31 -0
  48. package/dist/title.d.ts +8 -0
  49. package/dist/title.js +38 -0
  50. package/dist/types.d.ts +116 -0
  51. package/dist/types.js +1 -0
  52. package/dist/usage.d.ts +51 -0
  53. package/dist/usage.js +243 -0
  54. package/package.json +68 -0
  55. package/quota-sidebar.config.example.json +25 -0
package/dist/index.js ADDED
@@ -0,0 +1,699 @@
1
+ import path from 'node:path';
2
+ import { tool } from '@opencode-ai/plugin/tool';
3
+ import { renderMarkdownReport, renderSidebarTitle, renderToastMessage, } from './format.js';
4
+ import { createQuotaRuntime, listDefaultQuotaProviderIDs, loadAuthMap, quotaSort, } from './quota.js';
5
+ import { authFilePath, dateKeyFromTimestamp, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, resolveOpencodeDataDir, saveState, scanSessionsByCreatedRange, stateFilePath, } from './storage.js';
6
+ import { debug, isRecord, mapConcurrent, swallow } from './helpers.js';
7
+ import { calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
8
+ import { canonicalizeTitle, looksDecorated, normalizeBaseTitle, } from './title.js';
9
+ import { periodStart } from './period.js';
10
+ import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, } from './usage.js';
11
+ import { TtlValueCache } from './cache.js';
12
+ const z = tool.schema;
13
+ function isAssistantMessage(message) {
14
+ return message.role === 'assistant';
15
+ }
16
+ export async function QuotaSidebarPlugin(input) {
17
+ const quotaRuntime = createQuotaRuntime();
18
+ const config = await loadConfig([
19
+ path.join(input.directory, 'quota-sidebar.config.json'),
20
+ path.join(input.worktree, 'quota-sidebar.config.json'),
21
+ ]);
22
+ const dataDir = resolveOpencodeDataDir();
23
+ const statePath = stateFilePath(dataDir);
24
+ const authPath = authFilePath(dataDir);
25
+ const state = await loadState(statePath);
26
+ // M2: evict old sessions on startup
27
+ evictOldSessions(state, config.retentionDays);
28
+ const refreshTimer = new Map();
29
+ const pendingAppliedTitle = new Map();
30
+ const dirtyDateKeys = new Set();
31
+ // Per-session queue for applyTitle
32
+ const applyTitleLocks = new Map();
33
+ // M1: track sessions that have been cleaned up from refreshTimer
34
+ // (we clean up on each scheduleTitleRefresh call)
35
+ // P1: track sessions needing full rescan (after message.removed)
36
+ const forceRescanSessions = new Set();
37
+ const authCache = new TtlValueCache();
38
+ const getAuthMap = async () => {
39
+ const cached = authCache.get();
40
+ if (cached)
41
+ return cached;
42
+ const value = await loadAuthMap(authPath);
43
+ return authCache.set(value, 30_000);
44
+ };
45
+ const providerOptionsCache = new TtlValueCache();
46
+ const getProviderOptionsMap = async () => {
47
+ const cached = providerOptionsCache.get();
48
+ if (cached)
49
+ return cached;
50
+ const configClient = input.client;
51
+ if (!configClient.config?.providers) {
52
+ return providerOptionsCache.set({}, 30_000);
53
+ }
54
+ const response = await configClient.config
55
+ .providers({
56
+ query: { directory: input.directory },
57
+ throwOnError: true,
58
+ })
59
+ .catch(swallow('getProviderOptionsMap'));
60
+ const data = response &&
61
+ typeof response === 'object' &&
62
+ 'data' in response &&
63
+ response.data &&
64
+ typeof response.data === 'object' &&
65
+ 'providers' in response.data
66
+ ? response.data.providers
67
+ : undefined;
68
+ const map = Array.isArray(data)
69
+ ? data.reduce((acc, item) => {
70
+ if (!item || typeof item !== 'object')
71
+ return acc;
72
+ const record = item;
73
+ const id = record.id;
74
+ const options = record.options;
75
+ if (typeof id !== 'string')
76
+ return acc;
77
+ if (!options ||
78
+ typeof options !== 'object' ||
79
+ Array.isArray(options)) {
80
+ acc[id] = {};
81
+ return acc;
82
+ }
83
+ acc[id] = options;
84
+ return acc;
85
+ }, {})
86
+ : {};
87
+ return providerOptionsCache.set(map, 30_000);
88
+ };
89
+ const modelCostCache = new TtlValueCache();
90
+ const missingApiCostRateKeys = new Set();
91
+ const getModelCostMap = async () => {
92
+ const cached = modelCostCache.get();
93
+ if (cached)
94
+ return cached;
95
+ const providerClient = input.client;
96
+ if (!providerClient.provider?.list) {
97
+ return modelCostCache.set({}, 30_000);
98
+ }
99
+ const response = await providerClient.provider
100
+ .list({
101
+ query: { directory: input.directory },
102
+ throwOnError: true,
103
+ })
104
+ .catch(swallow('getModelCostMap'));
105
+ const all = response &&
106
+ typeof response === 'object' &&
107
+ 'data' in response &&
108
+ isRecord(response.data) &&
109
+ Array.isArray(response.data.all)
110
+ ? response.data.all
111
+ : [];
112
+ const map = all.reduce((acc, provider) => {
113
+ if (!isRecord(provider))
114
+ return acc;
115
+ const providerID = typeof provider.id === 'string'
116
+ ? canonicalApiCostProviderID(provider.id)
117
+ : undefined;
118
+ if (!providerID)
119
+ return acc;
120
+ if (!SUBSCRIPTION_API_COST_PROVIDERS.has(providerID))
121
+ return acc;
122
+ const models = isRecord(provider.models) ? provider.models : undefined;
123
+ if (!models)
124
+ return acc;
125
+ for (const [modelKey, modelValue] of Object.entries(models)) {
126
+ if (!isRecord(modelValue))
127
+ continue;
128
+ const rates = parseModelCostRates(modelValue.cost);
129
+ if (!rates)
130
+ continue;
131
+ const modelID = typeof modelValue.id === 'string' ? modelValue.id : modelKey;
132
+ acc[modelCostKey(providerID, modelID)] = rates;
133
+ if (modelKey !== modelID) {
134
+ acc[modelCostKey(providerID, modelKey)] = rates;
135
+ }
136
+ }
137
+ return acc;
138
+ }, {});
139
+ return modelCostCache.set(map, Math.max(30_000, config.quota.refreshMs));
140
+ };
141
+ const calcEquivalentApiCost = (message, modelCostMap) => {
142
+ const providerID = canonicalApiCostProviderID(message.providerID);
143
+ if (!SUBSCRIPTION_API_COST_PROVIDERS.has(providerID))
144
+ return 0;
145
+ const rates = modelCostMap[modelCostKey(providerID, message.modelID)];
146
+ if (!rates) {
147
+ const key = modelCostKey(providerID, message.modelID);
148
+ if (!missingApiCostRateKeys.has(key)) {
149
+ missingApiCostRateKeys.add(key);
150
+ debug(`apiCost skipped: no model price for ${key}`);
151
+ }
152
+ return 0;
153
+ }
154
+ return calcEquivalentApiCostForMessage(message, rates);
155
+ };
156
+ let saveTimer;
157
+ let saveInFlight = Promise.resolve();
158
+ /**
159
+ * H2 fix: capture and delete specific dirty keys instead of clearing the whole set.
160
+ * Keys added between capture and write completion are preserved.
161
+ */
162
+ const persistState = () => {
163
+ const dirty = Array.from(dirtyDateKeys);
164
+ if (dirty.length === 0)
165
+ return saveInFlight;
166
+ // H2: delete only the captured keys, not clear()
167
+ for (const key of dirty) {
168
+ dirtyDateKeys.delete(key);
169
+ }
170
+ const write = saveInFlight
171
+ .catch(swallow('persistState:wait'))
172
+ .then(() => saveState(statePath, state, { dirtyDateKeys: dirty }))
173
+ .catch((error) => {
174
+ // Re-add captured keys so they are not lost on failed persistence.
175
+ for (const key of dirty) {
176
+ dirtyDateKeys.add(key);
177
+ }
178
+ throw error;
179
+ })
180
+ .catch(swallow('persistState:save'));
181
+ saveInFlight = write;
182
+ return write;
183
+ };
184
+ const scheduleSave = () => {
185
+ if (saveTimer)
186
+ clearTimeout(saveTimer);
187
+ saveTimer = setTimeout(() => {
188
+ saveTimer = undefined;
189
+ void persistState();
190
+ }, 200);
191
+ };
192
+ /**
193
+ * M5 fix: always flush current dirty keys, even when no timer is pending.
194
+ */
195
+ const flushSave = async () => {
196
+ if (saveTimer) {
197
+ clearTimeout(saveTimer);
198
+ saveTimer = undefined;
199
+ }
200
+ // M5: always persist if there are dirty keys, regardless of timer state
201
+ if (dirtyDateKeys.size > 0) {
202
+ await persistState();
203
+ return;
204
+ }
205
+ await saveInFlight;
206
+ };
207
+ const ensureSessionState = (sessionID, title, createdAt = Date.now()) => {
208
+ const existing = state.sessions[sessionID];
209
+ if (existing) {
210
+ if (!state.sessionDateMap[sessionID]) {
211
+ state.sessionDateMap[sessionID] = dateKeyFromTimestamp(existing.createdAt);
212
+ }
213
+ return existing;
214
+ }
215
+ const normalizedCreatedAt = normalizeTimestampMs(createdAt);
216
+ const created = {
217
+ createdAt: normalizedCreatedAt,
218
+ baseTitle: normalizeBaseTitle(title),
219
+ lastAppliedTitle: undefined,
220
+ usage: undefined,
221
+ cursor: undefined,
222
+ };
223
+ state.sessions[sessionID] = created;
224
+ state.sessionDateMap[sessionID] = dateKeyFromTimestamp(normalizedCreatedAt);
225
+ dirtyDateKeys.add(state.sessionDateMap[sessionID]);
226
+ return created;
227
+ };
228
+ const loadSessionEntries = async (sessionID) => {
229
+ const response = await input.client.session
230
+ .messages({
231
+ path: { id: sessionID },
232
+ query: { directory: input.directory },
233
+ throwOnError: true,
234
+ })
235
+ .catch(swallow('loadSessionEntries'));
236
+ return response?.data ?? [];
237
+ };
238
+ /**
239
+ * P1: Incremental usage aggregation for current session.
240
+ */
241
+ const summarizeSessionUsage = async (sessionID) => {
242
+ const entries = await loadSessionEntries(sessionID);
243
+ const modelCostMap = await getModelCostMap();
244
+ const sessionState = state.sessions[sessionID];
245
+ const forceRescan = forceRescanSessions.has(sessionID);
246
+ if (forceRescan)
247
+ forceRescanSessions.delete(sessionID);
248
+ const { usage, cursor } = summarizeMessagesIncremental(entries, sessionState?.usage, sessionState?.cursor, forceRescan, {
249
+ calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
250
+ });
251
+ usage.sessionCount = 1;
252
+ // Update cursor in state
253
+ if (sessionState) {
254
+ sessionState.cursor = cursor;
255
+ }
256
+ return usage;
257
+ };
258
+ /**
259
+ * M10 fix: parallelize API calls for range usage with concurrency limit.
260
+ */
261
+ const summarizeRangeUsage = async (period) => {
262
+ const startAt = periodStart(period);
263
+ await flushSave();
264
+ // M9: pass memoryState so we prefer in-memory data
265
+ const sessions = await scanSessionsByCreatedRange(statePath, startAt, Date.now(), state);
266
+ const usage = emptyUsageSummary();
267
+ usage.sessionCount = sessions.length;
268
+ const modelCostMap = await getModelCostMap();
269
+ const shouldRecomputeApiCost = (cached) => {
270
+ if (cached.assistantMessages <= 0)
271
+ return false;
272
+ if (cached.apiCost > 0)
273
+ return false;
274
+ if (cached.total <= 0)
275
+ return false;
276
+ return true;
277
+ };
278
+ // Separate sessions with cached usage from those needing API calls
279
+ const needsFetch = [];
280
+ for (const session of sessions) {
281
+ if (session.state.usage) {
282
+ if (shouldRecomputeApiCost(session.state.usage)) {
283
+ needsFetch.push(session);
284
+ }
285
+ else {
286
+ mergeUsage(usage, fromCachedSessionUsage(session.state.usage, 0));
287
+ }
288
+ }
289
+ else {
290
+ needsFetch.push(session);
291
+ }
292
+ }
293
+ // M10: fetch in parallel with concurrency limit
294
+ if (needsFetch.length > 0) {
295
+ const fetched = await mapConcurrent(needsFetch, 5, async (session) => {
296
+ const entries = await loadSessionEntries(session.sessionID);
297
+ const { usage: computed } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
298
+ calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
299
+ });
300
+ return { sessionID: session.sessionID, computed };
301
+ });
302
+ let dirty = false;
303
+ for (const { sessionID, computed } of fetched) {
304
+ // Range stats already know the session count (sessions.length).
305
+ // Do not double-count sessionCount when merging per-session summaries.
306
+ mergeUsage(usage, { ...computed, sessionCount: 0 });
307
+ const memoryState = state.sessions[sessionID];
308
+ if (memoryState) {
309
+ memoryState.usage = toCachedSessionUsage(computed);
310
+ const dateKey = state.sessionDateMap[sessionID] ||
311
+ dateKeyFromTimestamp(memoryState.createdAt);
312
+ state.sessionDateMap[sessionID] = dateKey;
313
+ dirtyDateKeys.add(dateKey);
314
+ dirty = true;
315
+ }
316
+ }
317
+ if (dirty)
318
+ scheduleSave();
319
+ }
320
+ return usage;
321
+ };
322
+ const getQuotaSnapshots = async (providerIDs, options) => {
323
+ const isValidQuotaCache = (snapshot) => {
324
+ // Guard against stale RightCode cache entries from pre-daily format.
325
+ if (snapshot.adapterID !== 'rightcode' || snapshot.status !== 'ok') {
326
+ return true;
327
+ }
328
+ if (!snapshot.windows || snapshot.windows.length === 0)
329
+ return true;
330
+ const primary = snapshot.windows[0];
331
+ if (!primary.label.startsWith('Daily $'))
332
+ return false;
333
+ if (primary.showPercent !== false)
334
+ return false;
335
+ return true;
336
+ };
337
+ const [authMap, providerOptionsMap] = await Promise.all([
338
+ getAuthMap(),
339
+ getProviderOptionsMap(),
340
+ ]);
341
+ const optionsForProvider = (providerID) => {
342
+ return (providerOptionsMap[providerID] ||
343
+ providerOptionsMap[quotaRuntime.normalizeProviderID(providerID)]);
344
+ };
345
+ const directCandidates = providerIDs.map((providerID) => ({
346
+ providerID,
347
+ providerOptions: optionsForProvider(providerID),
348
+ }));
349
+ const defaultCandidates = options?.allowDefault
350
+ ? [
351
+ ...Object.keys(providerOptionsMap).map((providerID) => ({
352
+ providerID,
353
+ providerOptions: providerOptionsMap[providerID],
354
+ })),
355
+ ...listDefaultQuotaProviderIDs().map((providerID) => ({
356
+ providerID,
357
+ providerOptions: optionsForProvider(providerID),
358
+ })),
359
+ ]
360
+ : [];
361
+ const rawCandidates = directCandidates.length
362
+ ? directCandidates
363
+ : defaultCandidates;
364
+ const matchedCandidates = rawCandidates.filter((candidate) => Boolean(quotaRuntime.resolveQuotaAdapter(candidate.providerID, candidate.providerOptions)));
365
+ const dedupedCandidates = Array.from(matchedCandidates
366
+ .reduce((acc, candidate) => {
367
+ const key = quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
368
+ if (!acc.has(key))
369
+ acc.set(key, candidate);
370
+ return acc;
371
+ }, new Map())
372
+ .values());
373
+ const fetched = await Promise.all(dedupedCandidates.map(async ({ providerID, providerOptions }) => {
374
+ const cacheKey = quotaRuntime.quotaCacheKey(providerID, providerOptions);
375
+ const cached = state.quotaCache[cacheKey];
376
+ if (cached && Date.now() - cached.checkedAt <= config.quota.refreshMs) {
377
+ if (isValidQuotaCache(cached)) {
378
+ return cached;
379
+ }
380
+ delete state.quotaCache[cacheKey];
381
+ }
382
+ const latest = await quotaRuntime.fetchQuotaSnapshot(providerID, authMap, config, async (id, auth) => {
383
+ await input.client.auth
384
+ .set({
385
+ path: { id },
386
+ query: { directory: input.directory },
387
+ body: {
388
+ type: auth.type,
389
+ access: auth.access,
390
+ refresh: auth.refresh,
391
+ expires: auth.expires,
392
+ enterpriseUrl: auth.enterpriseUrl,
393
+ },
394
+ throwOnError: true,
395
+ })
396
+ .catch(swallow('getQuotaSnapshots:authSet'));
397
+ }, providerOptions);
398
+ if (!latest)
399
+ return undefined;
400
+ state.quotaCache[cacheKey] = latest;
401
+ return latest;
402
+ }));
403
+ const snapshots = fetched.filter((value) => Boolean(value));
404
+ snapshots.sort(quotaSort);
405
+ scheduleSave();
406
+ return snapshots;
407
+ };
408
+ /**
409
+ * Per-session apply queue.
410
+ * New updates chain behind the previous one to preserve ordering.
411
+ */
412
+ const applyTitle = async (sessionID) => {
413
+ const previous = applyTitleLocks.get(sessionID) ?? Promise.resolve();
414
+ const promise = previous
415
+ .catch(() => undefined)
416
+ .then(() => applyTitleInner(sessionID));
417
+ applyTitleLocks.set(sessionID, promise);
418
+ try {
419
+ await promise;
420
+ }
421
+ finally {
422
+ if (applyTitleLocks.get(sessionID) === promise) {
423
+ applyTitleLocks.delete(sessionID);
424
+ }
425
+ }
426
+ };
427
+ const applyTitleInner = async (sessionID) => {
428
+ if (!config.sidebar.enabled || !state.titleEnabled)
429
+ return;
430
+ const session = await input.client.session
431
+ .get({
432
+ path: { id: sessionID },
433
+ query: { directory: input.directory },
434
+ throwOnError: true,
435
+ })
436
+ .catch(swallow('applyTitle:getSession'));
437
+ if (!session)
438
+ return;
439
+ const sessionState = ensureSessionState(sessionID, session.data.title, session.data.time.created);
440
+ // Detect whether the current title is our own decorated form.
441
+ const currentTitle = session.data.title;
442
+ if (canonicalizeTitle(currentTitle) !==
443
+ canonicalizeTitle(sessionState.lastAppliedTitle || '')) {
444
+ if (looksDecorated(currentTitle)) {
445
+ // Ignore decorated echoes as base-title source.
446
+ debug(`ignoring decorated current title for session ${sessionID}`);
447
+ }
448
+ else {
449
+ sessionState.baseTitle = normalizeBaseTitle(currentTitle);
450
+ }
451
+ sessionState.lastAppliedTitle = undefined;
452
+ }
453
+ const usage = await summarizeSessionUsage(sessionID);
454
+ const quotaProviders = Array.from(new Set(Object.keys(usage.providers).map((id) => quotaRuntime.normalizeProviderID(id))));
455
+ const quotas = config.sidebar.showQuota && quotaProviders.length > 0
456
+ ? await getQuotaSnapshots(quotaProviders)
457
+ : [];
458
+ const nextTitle = renderSidebarTitle(sessionState.baseTitle, usage, quotas, config);
459
+ sessionState.usage = toCachedSessionUsage(usage);
460
+ dirtyDateKeys.add(state.sessionDateMap[sessionID]);
461
+ if (canonicalizeTitle(nextTitle) === canonicalizeTitle(session.data.title)) {
462
+ scheduleSave();
463
+ return;
464
+ }
465
+ // Mark pending title to ignore the immediate echo `session.updated` event.
466
+ // H3 fix: use longer TTL (15s) and add decoration detection as backup.
467
+ pendingAppliedTitle.set(sessionID, {
468
+ title: nextTitle,
469
+ expiresAt: Date.now() + 15_000,
470
+ });
471
+ const previousApplied = sessionState.lastAppliedTitle;
472
+ sessionState.lastAppliedTitle = nextTitle;
473
+ dirtyDateKeys.add(state.sessionDateMap[sessionID]);
474
+ const updated = await input.client.session
475
+ .update({
476
+ path: { id: sessionID },
477
+ query: { directory: input.directory },
478
+ body: { title: nextTitle },
479
+ throwOnError: true,
480
+ })
481
+ .catch(swallow('applyTitle:update'));
482
+ if (!updated) {
483
+ pendingAppliedTitle.delete(sessionID);
484
+ sessionState.lastAppliedTitle = previousApplied;
485
+ scheduleSave();
486
+ return;
487
+ }
488
+ pendingAppliedTitle.delete(sessionID);
489
+ scheduleSave();
490
+ };
491
+ const scheduleTitleRefresh = (sessionID, delay = 250) => {
492
+ // M1: clean up completed timer entry before setting new one
493
+ const previous = refreshTimer.get(sessionID);
494
+ if (previous)
495
+ clearTimeout(previous);
496
+ const timer = setTimeout(() => {
497
+ refreshTimer.delete(sessionID);
498
+ void applyTitle(sessionID).catch(swallow('scheduleTitleRefresh'));
499
+ }, delay);
500
+ refreshTimer.set(sessionID, timer);
501
+ };
502
+ const restoreSessionTitle = async (sessionID) => {
503
+ const session = await input.client.session
504
+ .get({
505
+ path: { id: sessionID },
506
+ query: { directory: input.directory },
507
+ throwOnError: true,
508
+ })
509
+ .catch(swallow('restoreSessionTitle:get'));
510
+ if (!session)
511
+ return;
512
+ const sessionState = ensureSessionState(sessionID, session.data.title, session.data.time.created);
513
+ const baseTitle = normalizeBaseTitle(sessionState.baseTitle);
514
+ if (session.data.title === baseTitle)
515
+ return;
516
+ await input.client.session
517
+ .update({
518
+ path: { id: sessionID },
519
+ query: { directory: input.directory },
520
+ body: { title: baseTitle },
521
+ throwOnError: true,
522
+ })
523
+ .catch(swallow('restoreSessionTitle:update'));
524
+ sessionState.lastAppliedTitle = undefined;
525
+ dirtyDateKeys.add(state.sessionDateMap[sessionID]);
526
+ scheduleSave();
527
+ };
528
+ /**
529
+ * P3 fix: concurrency-limited title restoration.
530
+ */
531
+ const restoreAllVisibleTitles = async () => {
532
+ const list = await input.client.session
533
+ .list({
534
+ query: { directory: input.directory },
535
+ throwOnError: true,
536
+ })
537
+ .catch(swallow('restoreAllVisibleTitles:list'));
538
+ if (!list?.data)
539
+ return;
540
+ // Only restore sessions we've touched (have lastAppliedTitle)
541
+ const touched = list.data.filter((s) => state.sessions[s.id]?.lastAppliedTitle);
542
+ // P3: limit concurrency to 5
543
+ await mapConcurrent(touched, 5, async (s) => {
544
+ await restoreSessionTitle(s.id);
545
+ });
546
+ };
547
+ const summarizeForTool = async (period, sessionID) => {
548
+ if (period === 'session')
549
+ return summarizeSessionUsage(sessionID);
550
+ return summarizeRangeUsage(period);
551
+ };
552
+ const showToast = async (period, message) => {
553
+ await input.client.tui
554
+ .showToast({
555
+ query: { directory: input.directory },
556
+ body: {
557
+ title: `Quota ${period}`,
558
+ message,
559
+ variant: 'info',
560
+ duration: config.toast.durationMs,
561
+ },
562
+ throwOnError: true,
563
+ })
564
+ .catch(swallow('showToast'));
565
+ };
566
+ const onEvent = async (event) => {
567
+ if (event.type === 'session.created') {
568
+ ensureSessionState(event.properties.info.id, event.properties.info.title, event.properties.info.time.created);
569
+ scheduleSave();
570
+ return;
571
+ }
572
+ if (event.type === 'session.updated') {
573
+ const sessionState = ensureSessionState(event.properties.info.id, event.properties.info.title, event.properties.info.time.created);
574
+ const pending = pendingAppliedTitle.get(event.properties.info.id);
575
+ if (pending) {
576
+ if (pending.expiresAt > Date.now()) {
577
+ if (canonicalizeTitle(event.properties.info.title) ===
578
+ canonicalizeTitle(pending.title)) {
579
+ pendingAppliedTitle.delete(event.properties.info.id);
580
+ sessionState.lastAppliedTitle = pending.title;
581
+ dirtyDateKeys.add(state.sessionDateMap[event.properties.info.id]);
582
+ scheduleSave();
583
+ return;
584
+ }
585
+ }
586
+ else {
587
+ pendingAppliedTitle.delete(event.properties.info.id);
588
+ }
589
+ }
590
+ // H3 fix: if the incoming title looks decorated, it's likely a late echo
591
+ // of our own update. Extract the base title from line 1 instead of
592
+ // treating the whole decorated string as the new base title.
593
+ const incomingTitle = event.properties.info.title;
594
+ if (canonicalizeTitle(incomingTitle) ===
595
+ canonicalizeTitle(sessionState.lastAppliedTitle || '')) {
596
+ return;
597
+ }
598
+ if (looksDecorated(incomingTitle)) {
599
+ // Late echo — ignore as base-title source.
600
+ debug(`ignoring late decorated echo for session ${event.properties.info.id}`);
601
+ return;
602
+ }
603
+ else {
604
+ sessionState.baseTitle = normalizeBaseTitle(incomingTitle);
605
+ }
606
+ sessionState.lastAppliedTitle = undefined;
607
+ dirtyDateKeys.add(state.sessionDateMap[event.properties.info.id]);
608
+ scheduleSave();
609
+ // External rename detected — re-render sidebar with new base title
610
+ scheduleTitleRefresh(event.properties.info.id);
611
+ return;
612
+ }
613
+ if (event.type === 'message.removed') {
614
+ // P1: mark session for full rescan since message order changed
615
+ forceRescanSessions.add(event.properties.sessionID);
616
+ // Also invalidate cached usage
617
+ const sessionState = state.sessions[event.properties.sessionID];
618
+ if (sessionState) {
619
+ sessionState.usage = undefined;
620
+ sessionState.cursor = undefined;
621
+ const dateKey = state.sessionDateMap[event.properties.sessionID] ||
622
+ dateKeyFromTimestamp(sessionState.createdAt);
623
+ state.sessionDateMap[event.properties.sessionID] = dateKey;
624
+ dirtyDateKeys.add(dateKey);
625
+ scheduleSave();
626
+ }
627
+ scheduleTitleRefresh(event.properties.sessionID);
628
+ return;
629
+ }
630
+ if (event.type !== 'message.updated')
631
+ return;
632
+ if (!isAssistantMessage(event.properties.info))
633
+ return;
634
+ if (!event.properties.info.time.completed)
635
+ return;
636
+ scheduleTitleRefresh(event.properties.info.sessionID);
637
+ };
638
+ return {
639
+ event: async ({ event }) => {
640
+ try {
641
+ await onEvent(event);
642
+ }
643
+ catch (error) {
644
+ debug(`event handler failed: ${String(error)}`);
645
+ }
646
+ },
647
+ tool: {
648
+ quota_summary: tool({
649
+ description: 'Show usage and quota summary for session/day/week/month.',
650
+ args: {
651
+ period: z.enum(['session', 'day', 'week', 'month']).optional(),
652
+ toast: z.boolean().optional(),
653
+ },
654
+ execute: async (args, context) => {
655
+ const period = args.period || 'session';
656
+ const usage = await summarizeForTool(period, context.sessionID);
657
+ // For quota_summary, always show all subscription quota balances,
658
+ // regardless of which providers were used in the session.
659
+ const quotas = await getQuotaSnapshots([], { allowDefault: true });
660
+ const markdown = renderMarkdownReport(period, usage, quotas, {
661
+ showCost: config.sidebar.showCost,
662
+ });
663
+ if (args.toast !== false) {
664
+ await showToast(period, renderToastMessage(period, usage, quotas, {
665
+ showCost: config.sidebar.showCost,
666
+ width: Math.max(44, config.sidebar.width + 18),
667
+ }));
668
+ }
669
+ return markdown;
670
+ },
671
+ }),
672
+ quota_show: tool({
673
+ description: 'Toggle sidebar title display mode. When on, titles show token usage and quota; when off, titles revert to original.',
674
+ args: {
675
+ enabled: z
676
+ .boolean()
677
+ .optional()
678
+ .describe('Explicit on/off. Omit to toggle current state.'),
679
+ },
680
+ execute: async (args, context) => {
681
+ const next = args.enabled !== undefined ? args.enabled : !state.titleEnabled;
682
+ state.titleEnabled = next;
683
+ scheduleSave();
684
+ if (next) {
685
+ // Turning on — re-render current session immediately
686
+ scheduleTitleRefresh(context.sessionID, 0);
687
+ await showToast('toggle', 'Sidebar usage display: ON');
688
+ return 'Sidebar usage display is now ON. Session titles will show token usage and quota.';
689
+ }
690
+ // Turning off — restore all touched sessions to base titles
691
+ await restoreAllVisibleTitles();
692
+ await showToast('toggle', 'Sidebar usage display: OFF');
693
+ return 'Sidebar usage display is now OFF. Session titles restored to original.';
694
+ },
695
+ }),
696
+ },
697
+ };
698
+ }
699
+ export default QuotaSidebarPlugin;