@leo000001/opencode-quota-sidebar 1.13.10 → 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/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);
@@ -45,14 +45,17 @@ class ChunkCache {
45
45
  constructor(maxSize = 64) {
46
46
  this.maxSize = maxSize;
47
47
  }
48
- get(dateKey) {
49
- const entry = this.cache.get(dateKey);
48
+ key(rootPath, dateKey) {
49
+ return `${path.resolve(rootPath)}::${dateKey}`;
50
+ }
51
+ get(rootPath, dateKey) {
52
+ const entry = this.cache.get(this.key(rootPath, dateKey));
50
53
  if (!entry)
51
54
  return undefined;
52
55
  entry.accessedAt = Date.now();
53
56
  return entry.sessions;
54
57
  }
55
- set(dateKey, sessions) {
58
+ set(rootPath, dateKey, sessions) {
56
59
  if (this.cache.size >= this.maxSize) {
57
60
  // Evict least recently accessed
58
61
  let oldestKey;
@@ -66,17 +69,20 @@ class ChunkCache {
66
69
  if (oldestKey)
67
70
  this.cache.delete(oldestKey);
68
71
  }
69
- this.cache.set(dateKey, { sessions, accessedAt: Date.now() });
72
+ this.cache.set(this.key(rootPath, dateKey), {
73
+ sessions,
74
+ accessedAt: Date.now(),
75
+ });
70
76
  }
71
- invalidate(dateKey) {
72
- this.cache.delete(dateKey);
77
+ invalidate(rootPath, dateKey) {
78
+ this.cache.delete(this.key(rootPath, dateKey));
73
79
  }
74
80
  }
75
81
  const chunkCache = new ChunkCache();
76
82
  export async function readDayChunk(rootPath, dateKey) {
77
83
  if (!isDateKey(dateKey))
78
84
  return {};
79
- const cached = chunkCache.get(dateKey);
85
+ const cached = chunkCache.get(rootPath, dateKey);
80
86
  if (cached)
81
87
  return cached;
82
88
  const filePath = chunkFilePath(rootPath, dateKey);
@@ -101,7 +107,7 @@ export async function readDayChunk(rootPath, dateKey) {
101
107
  acc[sessionID] = parsedSession;
102
108
  return acc;
103
109
  }, {});
104
- chunkCache.set(dateKey, sessions);
110
+ chunkCache.set(rootPath, dateKey, sessions);
105
111
  return sessions;
106
112
  }
107
113
  /**
@@ -174,13 +180,18 @@ export async function writeDayChunk(rootPath, dateKey, sessions) {
174
180
  throw new Error(`unsafe chunk root at ${rootPath}`);
175
181
  }
176
182
  await mkdirpNoSymlink(rootPath, path.dirname(filePath));
183
+ if (Object.keys(sessions).length === 0) {
184
+ await fs.rm(filePath, { force: true }).catch(() => undefined);
185
+ chunkCache.invalidate(rootPath, dateKey);
186
+ return;
187
+ }
177
188
  const chunk = {
178
189
  version: 1,
179
190
  dateKey,
180
191
  sessions,
181
192
  };
182
193
  await safeWriteFile(filePath, `${JSON.stringify(chunk, null, 2)}\n`);
183
- chunkCache.invalidate(dateKey);
194
+ chunkCache.invalidate(rootPath, dateKey);
184
195
  }
185
196
  export async function discoverChunks(rootPath) {
186
197
  const years = await fs.readdir(rootPath).catch(() => []);
@@ -1,4 +1,5 @@
1
1
  import { asNumber, isRecord } from './helpers.js';
2
+ import { normalizeTimestampMs } from './storage_dates.js';
2
3
  function parseSessionTitleState(value) {
3
4
  if (!isRecord(value))
4
5
  return undefined;
@@ -28,6 +29,38 @@ function parseProviderUsage(value) {
28
29
  assistantMessages: asNumber(value.assistantMessages, 0),
29
30
  };
30
31
  }
32
+ function parseCacheUsageBucket(value) {
33
+ if (!isRecord(value))
34
+ return undefined;
35
+ return {
36
+ input: asNumber(value.input, 0),
37
+ cacheRead: asNumber(value.cacheRead, 0),
38
+ cacheWrite: asNumber(value.cacheWrite, 0),
39
+ assistantMessages: asNumber(value.assistantMessages, 0),
40
+ };
41
+ }
42
+ function parseCacheUsageBuckets(value) {
43
+ if (!isRecord(value))
44
+ return undefined;
45
+ const readOnly = parseCacheUsageBucket(value.readOnly);
46
+ const readWrite = parseCacheUsageBucket(value.readWrite);
47
+ if (!readOnly && !readWrite)
48
+ return undefined;
49
+ return {
50
+ readOnly: readOnly || {
51
+ input: 0,
52
+ cacheRead: 0,
53
+ cacheWrite: 0,
54
+ assistantMessages: 0,
55
+ },
56
+ readWrite: readWrite || {
57
+ input: 0,
58
+ cacheRead: 0,
59
+ cacheWrite: 0,
60
+ assistantMessages: 0,
61
+ },
62
+ };
63
+ }
31
64
  function parseCachedUsage(value) {
32
65
  if (!isRecord(value))
33
66
  return undefined;
@@ -50,6 +83,7 @@ function parseCachedUsage(value) {
50
83
  cost: asNumber(value.cost, 0),
51
84
  apiCost: asNumber(value.apiCost, 0),
52
85
  assistantMessages: asNumber(value.assistantMessages, 0),
86
+ cacheBuckets: parseCacheUsageBuckets(value.cacheBuckets),
53
87
  providers,
54
88
  };
55
89
  }
@@ -74,7 +108,7 @@ export function parseSessionState(value) {
74
108
  const title = parseSessionTitleState(value);
75
109
  if (!title)
76
110
  return undefined;
77
- const createdAt = asNumber(value.createdAt, 0);
111
+ const createdAt = normalizeTimestampMs(value.createdAt, 0);
78
112
  if (!createdAt)
79
113
  return undefined;
80
114
  return {
@@ -82,6 +116,7 @@ export function parseSessionState(value) {
82
116
  createdAt,
83
117
  parentID: typeof value.parentID === 'string' ? value.parentID : undefined,
84
118
  usage: parseCachedUsage(value.usage),
119
+ dirty: value.dirty === true,
85
120
  cursor: parseCursor(value.cursor),
86
121
  };
87
122
  }
package/dist/title.js CHANGED
@@ -3,14 +3,15 @@ function sanitizeTitleFragment(value) {
3
3
  .replace(/[\x00-\x1F\x7F-\x9F]/g, ' ')
4
4
  .trimEnd();
5
5
  }
6
- function isStrongDecoratedDetail(line) {
6
+ function isCoreDecoratedDetail(line) {
7
7
  if (!line)
8
8
  return false;
9
- if (/^Input\s+\S+\s+Output(?:\s+\S+)?/.test(line))
9
+ if (/^Input\s+\$?[\d.,]+[kKmM]?(?:\s+Output(?:\s+\$?[\d.,]+[kKmM]?)?)?~?$/.test(line)) {
10
10
  return true;
11
- if (/^Cache\s+(Read|Write)\s+\S+/.test(line))
11
+ }
12
+ if (/^Cache\s+(Read|Write)\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
12
13
  return true;
13
- if (/^\$\S+\s+as API cost\b/.test(line))
14
+ if (/^\$\S+\s+as API cost$/.test(line))
14
15
  return true;
15
16
  // Single-line compact mode compatibility.
16
17
  if (/^I(?:nput)?\s+\$?\d[\d.,]*[kKmM]?\s+O(?:utput)?\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
@@ -21,12 +22,26 @@ function isStrongDecoratedDetail(line) {
21
22
  return true;
22
23
  return false;
23
24
  }
24
- function isQuotaLikeProviderDetail(line) {
25
+ function isSingleLineDecoratedPrefix(line) {
25
26
  if (!line)
26
27
  return false;
27
- if (!/^(OpenAI|Copilot|Anthropic|RightCode|RC)\b/.test(line))
28
- return false;
29
- return /\b(Rst|Exp\+?|Balance|Remaining)\b|\d{1,3}%/.test(line);
28
+ if (/^Input\s+\$?[\d.,]+[kKmM]?~?$/.test(line))
29
+ return true;
30
+ if (/^Input\s+\$?[\d.,]+[kKmM]?\s+Output(?:\s+\$?[\d.,]+[kKmM]?~?)?$/.test(line)) {
31
+ return true;
32
+ }
33
+ if (/^Cache\s+(Read|Write)\s+\$?\d[\d.,]*[kKmM]?(?:~|$)/.test(line)) {
34
+ return true;
35
+ }
36
+ if (/^Cache(?:\s+Read)?\s+Coverage\s+\d[\d.,]*(?:%|~)$/.test(line)) {
37
+ return true;
38
+ }
39
+ if (/^\$\S+\s+as API cost(?:~|$)/.test(line))
40
+ return true;
41
+ return false;
42
+ }
43
+ function isSingleLineDetailPrefix(line) {
44
+ return isCoreDecoratedDetail(line) || isSingleLineDecoratedPrefix(line);
30
45
  }
31
46
  function decoratedSingleLineBase(line) {
32
47
  const parts = sanitizeTitleFragment(line)
@@ -34,18 +49,28 @@ function decoratedSingleLineBase(line) {
34
49
  .map((part) => part.trim());
35
50
  if (parts.length < 2)
36
51
  return undefined;
52
+ if (isSingleLineDetailPrefix(parts[0] || ''))
53
+ return undefined;
37
54
  const details = parts.slice(1);
38
- if (!details.some((detail) => isStrongDecoratedDetail(detail))) {
55
+ if (!details.some((detail) => isSingleLineDetailPrefix(detail))) {
39
56
  return undefined;
40
57
  }
41
58
  return parts[0] || 'Session';
42
59
  }
43
60
  export function normalizeBaseTitle(title) {
44
- const firstLine = stripAnsi(title).split(/\r?\n/, 1)[0] || 'Session';
61
+ const safeTitle = canonicalizeTitle(title) || 'Session';
62
+ const firstLine = stripAnsi(safeTitle).split(/\r?\n/, 1)[0] || 'Session';
45
63
  const decoratedBase = decoratedSingleLineBase(firstLine);
46
64
  if (decoratedBase)
47
65
  return decoratedBase;
48
- return sanitizeTitleFragment(firstLine) || 'Session';
66
+ const lines = stripAnsi(safeTitle).split(/\r?\n/);
67
+ if (lines.length > 1) {
68
+ const detail = lines.slice(1).map((line) => sanitizeTitleFragment(line).trim());
69
+ if (detail.some((line) => isCoreDecoratedDetail(line))) {
70
+ return sanitizeTitleFragment(firstLine) || 'Session';
71
+ }
72
+ }
73
+ return safeTitle;
49
74
  }
50
75
  export function stripAnsi(value) {
51
76
  // Remove terminal escape sequences. Sidebar titles must be plain text.
@@ -91,6 +116,5 @@ export function looksDecorated(title) {
91
116
  const detail = lines
92
117
  .slice(1)
93
118
  .map((line) => sanitizeTitleFragment(line).trim());
94
- return (detail.some((line) => isStrongDecoratedDetail(line)) ||
95
- detail.some((line) => isQuotaLikeProviderDetail(line)));
119
+ return detail.some((line) => isCoreDecoratedDetail(line));
96
120
  }
@@ -26,5 +26,7 @@ export declare function createTitleApplicator(deps: {
26
26
  }) => Promise<void>;
27
27
  restoreSessionTitle: (sessionID: string) => Promise<void>;
28
28
  restoreAllVisibleTitles: () => Promise<void>;
29
+ refreshAllTouchedTitles: () => Promise<void>;
30
+ refreshAllVisibleTitles: () => Promise<void>;
29
31
  forgetSession: (sessionID: string) => void;
30
32
  };
@@ -1,4 +1,4 @@
1
- import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, normalizeBaseTitle, } from './title.js';
1
+ import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, } from './title.js';
2
2
  import { swallow, debug, mapConcurrent } from './helpers.js';
3
3
  export function createTitleApplicator(deps) {
4
4
  const pendingAppliedTitle = new Map();
@@ -44,7 +44,7 @@ export function createTitleApplicator(deps) {
44
44
  }
45
45
  }
46
46
  else {
47
- const nextBase = normalizeBaseTitle(currentTitle);
47
+ const nextBase = canonicalizeTitle(currentTitle) || 'Session';
48
48
  if (sessionState.baseTitle !== nextBase) {
49
49
  sessionState.baseTitle = nextBase;
50
50
  stateMutated = true;
@@ -61,6 +61,8 @@ export function createTitleApplicator(deps) {
61
61
  ? await deps.getQuotaSnapshots(quotaProviders)
62
62
  : [];
63
63
  const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config);
64
+ if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
65
+ return;
64
66
  if (canonicalizeTitleForCompare(nextTitle) ===
65
67
  canonicalizeTitleForCompare(session.data.title)) {
66
68
  if (looksDecorated(session.data.title)) {
@@ -129,11 +131,14 @@ export function createTitleApplicator(deps) {
129
131
  canonicalizeTitleForCompare(args.sessionState.lastAppliedTitle || '')) {
130
132
  return;
131
133
  }
132
- if (looksDecorated(args.incomingTitle)) {
133
- debug(`ignoring late decorated echo for session ${args.sessionID}`);
134
- return;
134
+ if (looksDecorated(args.incomingTitle) && args.sessionState.lastAppliedTitle) {
135
+ if (canonicalizeTitleForCompare(args.incomingTitle) ===
136
+ canonicalizeTitleForCompare(args.sessionState.lastAppliedTitle)) {
137
+ debug(`ignoring late decorated echo for session ${args.sessionID}`);
138
+ return;
139
+ }
135
140
  }
136
- args.sessionState.baseTitle = normalizeBaseTitle(args.incomingTitle);
141
+ args.sessionState.baseTitle = canonicalizeTitle(args.incomingTitle) || 'Session';
137
142
  args.sessionState.lastAppliedTitle = undefined;
138
143
  deps.markDirty(deps.state.sessionDateMap[args.sessionID]);
139
144
  deps.scheduleSave();
@@ -150,10 +155,16 @@ export function createTitleApplicator(deps) {
150
155
  if (!session)
151
156
  return;
152
157
  const sessionState = deps.ensureSessionState(sessionID, session.data.title, session.data.time.created, session.data.parentID ?? null);
153
- const baseTitle = normalizeBaseTitle(sessionState.baseTitle);
154
- if (session.data.title === baseTitle)
158
+ const baseTitle = canonicalizeTitle(sessionState.baseTitle) || 'Session';
159
+ if (session.data.title === baseTitle) {
160
+ if (sessionState.lastAppliedTitle !== undefined) {
161
+ sessionState.lastAppliedTitle = undefined;
162
+ deps.markDirty(deps.state.sessionDateMap[sessionID]);
163
+ deps.scheduleSave();
164
+ }
155
165
  return;
156
- await deps.client.session
166
+ }
167
+ const updated = await deps.client.session
157
168
  .update({
158
169
  path: { id: sessionID },
159
170
  query: { directory: deps.directory },
@@ -161,22 +172,39 @@ export function createTitleApplicator(deps) {
161
172
  throwOnError: true,
162
173
  })
163
174
  .catch(swallow('restoreSessionTitle:update'));
175
+ if (!updated)
176
+ return;
164
177
  sessionState.lastAppliedTitle = undefined;
165
178
  deps.markDirty(deps.state.sessionDateMap[sessionID]);
166
179
  deps.scheduleSave();
167
180
  };
168
181
  const restoreAllVisibleTitles = async () => {
182
+ const touched = Object.entries(deps.state.sessions)
183
+ .filter(([, sessionState]) => Boolean(sessionState.lastAppliedTitle))
184
+ .map(([sessionID]) => sessionID);
185
+ await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => {
186
+ await restoreSessionTitle(sessionID);
187
+ });
188
+ };
189
+ const refreshAllTouchedTitles = async () => {
190
+ const touched = Object.entries(deps.state.sessions)
191
+ .filter(([, sessionState]) => Boolean(sessionState.lastAppliedTitle))
192
+ .map(([sessionID]) => sessionID);
193
+ await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => {
194
+ await applyTitle(sessionID);
195
+ });
196
+ };
197
+ const refreshAllVisibleTitles = async () => {
169
198
  const list = await deps.client.session
170
199
  .list({
171
200
  query: { directory: deps.directory },
172
201
  throwOnError: true,
173
202
  })
174
- .catch(swallow('restoreAllVisibleTitles:list'));
203
+ .catch(swallow('refreshAllVisibleTitles:list'));
175
204
  if (!list?.data)
176
205
  return;
177
- const touched = list.data.filter((s) => deps.state.sessions[s.id]?.lastAppliedTitle);
178
- await mapConcurrent(touched, deps.restoreConcurrency, async (s) => {
179
- await restoreSessionTitle(s.id);
206
+ await mapConcurrent(list.data, deps.restoreConcurrency, async (session) => {
207
+ await applyTitle(session.id);
180
208
  });
181
209
  };
182
210
  return {
@@ -184,6 +212,8 @@ export function createTitleApplicator(deps) {
184
212
  handleSessionUpdatedTitle,
185
213
  restoreSessionTitle,
186
214
  restoreAllVisibleTitles,
215
+ refreshAllTouchedTitles,
216
+ refreshAllVisibleTitles,
187
217
  forgetSession,
188
218
  };
189
219
  }
@@ -5,5 +5,7 @@ export declare function createTitleRefreshScheduler(options: {
5
5
  schedule: (sessionID: string, delay?: number) => void;
6
6
  apply: (sessionID: string) => Promise<void>;
7
7
  cancel: (sessionID: string) => void;
8
+ cancelAll: () => void;
9
+ waitForIdle: () => Promise<void>;
8
10
  dispose: () => void;
9
11
  };
@@ -31,16 +31,27 @@ export function createTitleRefreshScheduler(options) {
31
31
  clearTimeout(timer);
32
32
  refreshTimer.delete(sessionID);
33
33
  };
34
- const dispose = () => {
34
+ const cancelAll = () => {
35
35
  for (const timer of refreshTimer.values())
36
36
  clearTimeout(timer);
37
37
  refreshTimer.clear();
38
+ };
39
+ const waitForIdle = async () => {
40
+ const inflight = Array.from(applyLocks.values());
41
+ if (inflight.length === 0)
42
+ return;
43
+ await Promise.allSettled(inflight);
44
+ };
45
+ const dispose = () => {
46
+ cancelAll();
38
47
  applyLocks.clear();
39
48
  };
40
49
  return {
41
50
  schedule,
42
51
  apply: applyLocked,
43
52
  cancel,
53
+ cancelAll,
54
+ waitForIdle,
44
55
  dispose,
45
56
  };
46
57
  }
package/dist/tools.d.ts CHANGED
@@ -4,8 +4,13 @@ export declare function createQuotaSidebarTools(deps: {
4
4
  getTitleEnabled: () => boolean;
5
5
  setTitleEnabled: (enabled: boolean) => void;
6
6
  scheduleSave: () => void;
7
+ flushSave: () => Promise<void>;
7
8
  refreshSessionTitle: (sessionID: string, delay?: number) => void;
9
+ cancelAllTitleRefreshes: () => void;
10
+ waitForTitleRefreshIdle: () => Promise<void>;
8
11
  restoreAllVisibleTitles: () => Promise<void>;
12
+ refreshAllTouchedTitles: () => Promise<void>;
13
+ refreshAllVisibleTitles: () => Promise<void>;
9
14
  showToast: (period: 'session' | 'day' | 'week' | 'month' | 'toggle', message: string) => Promise<void>;
10
15
  summarizeForTool: (period: 'session' | 'day' | 'week' | 'month', sessionID: string, includeChildren: boolean) => Promise<UsageSummary>;
11
16
  getQuotaSnapshots: (providerIDs: string[], options?: {
package/dist/tools.js CHANGED
@@ -18,7 +18,6 @@ export function createQuotaSidebarTools(deps) {
18
18
  ? (args.includeChildren ?? deps.config.sidebar.includeChildren)
19
19
  : false;
20
20
  const usage = await deps.summarizeForTool(period, context.sessionID, includeChildren);
21
- deps.scheduleSave();
22
21
  // For quota_summary, always show all subscription quota balances,
23
22
  // regardless of which providers were used in the session.
24
23
  const quotas = await deps.getQuotaSnapshots([], { allowDefault: true });
@@ -47,16 +46,21 @@ export function createQuotaSidebarTools(deps) {
47
46
  const next = args.enabled !== undefined ? args.enabled : !current;
48
47
  deps.setTitleEnabled(next);
49
48
  deps.scheduleSave();
49
+ await deps.flushSave();
50
50
  if (next) {
51
- // Turning on — re-render current session immediately
51
+ // Turning on — refresh visible sessions, plus touched sessions as backup.
52
+ await deps.refreshAllVisibleTitles();
53
+ await deps.refreshAllTouchedTitles();
52
54
  deps.refreshSessionTitle(context.sessionID, 0);
53
55
  await deps.showToast('toggle', 'Sidebar usage display: ON');
54
- return 'Sidebar usage display is now ON. Session titles will show token usage and quota.';
56
+ return 'Sidebar usage display is now ON. Visible session titles are refreshing to show token usage and quota.';
55
57
  }
56
58
  // Turning off — restore all touched sessions to base titles
59
+ deps.cancelAllTitleRefreshes();
60
+ await deps.waitForTitleRefreshIdle();
57
61
  await deps.restoreAllVisibleTitles();
58
62
  await deps.showToast('toggle', 'Sidebar usage display: OFF');
59
- return 'Sidebar usage display is now OFF. Session titles restored to original.';
63
+ return 'Sidebar usage display is now OFF. Restore was attempted for touched session titles.';
60
64
  },
61
65
  }),
62
66
  };
package/dist/types.d.ts CHANGED
@@ -36,6 +36,31 @@ export type SessionTitleState = {
36
36
  baseTitle: string;
37
37
  lastAppliedTitle?: string;
38
38
  };
39
+ export type CacheCoverageMode = 'none' | 'read-only' | 'read-write';
40
+ export type CacheUsageBucket = {
41
+ input: number;
42
+ cacheRead: number;
43
+ cacheWrite: number;
44
+ assistantMessages: number;
45
+ };
46
+ export type CacheUsageBuckets = {
47
+ readOnly: CacheUsageBucket;
48
+ readWrite: CacheUsageBucket;
49
+ };
50
+ /**
51
+ * Derived cache coverage metrics.
52
+ *
53
+ * - `cacheCoverage`: fraction of prompt surface covered by read-write cache
54
+ * (`(cacheRead + cacheWrite) / (input + cacheRead + cacheWrite)`).
55
+ * Only defined when the read-write bucket has traffic.
56
+ * - `cacheReadCoverage`: fraction of prompt surface served from read-only cache
57
+ * (`cacheRead / (input + cacheRead)`).
58
+ * Only defined when the read-only bucket has traffic.
59
+ */
60
+ export type CacheCoverageMetrics = {
61
+ cacheCoverage: number | undefined;
62
+ cacheReadCoverage: number | undefined;
63
+ };
39
64
  export type CachedProviderUsage = {
40
65
  input: number;
41
66
  output: number;
@@ -61,6 +86,14 @@ export type CachedSessionUsage = {
61
86
  /** Equivalent API billing cost (USD) computed from model pricing. */
62
87
  apiCost: number;
63
88
  assistantMessages: number;
89
+ /**
90
+ * Cache coverage buckets grouped by model cache behavior.
91
+ *
92
+ * `undefined` when no cache-capable models were used or data predates
93
+ * billingVersion 3. The fallback in `resolvedCacheUsageBuckets()` derives
94
+ * approximate buckets from top-level `cacheRead`/`cacheWrite` when missing.
95
+ */
96
+ cacheBuckets?: CacheUsageBuckets;
64
97
  providers: Record<string, CachedProviderUsage>;
65
98
  };
66
99
  /** Tracks incremental aggregation cursor for a session (P1). */
@@ -77,6 +110,8 @@ export type SessionState = SessionTitleState & {
77
110
  /** Parent session ID for subagent child sessions. */
78
111
  parentID?: string;
79
112
  usage?: CachedSessionUsage;
113
+ /** Persisted dirtiness flag so descendant aggregation survives restart. */
114
+ dirty?: boolean;
80
115
  /** Incremental aggregation cursor (P1). */
81
116
  cursor?: IncrementalCursor;
82
117
  };
@@ -91,6 +126,8 @@ export type QuotaSidebarState = {
91
126
  titleEnabled: boolean;
92
127
  sessionDateMap: Record<string, string>;
93
128
  sessions: Record<string, SessionState>;
129
+ /** Tombstones for sessions deleted from memory but not yet purged from day chunks. */
130
+ deletedSessionDateMap: Record<string, string>;
94
131
  quotaCache: Record<string, QuotaSnapshot>;
95
132
  };
96
133
  export type QuotaSidebarConfig = {