@leo000001/opencode-quota-sidebar 1.0.2 → 1.0.3

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.
@@ -0,0 +1,13 @@
1
+ export declare function createPersistenceScheduler<TState>(deps: {
2
+ statePath: string;
3
+ state: TState;
4
+ saveState: (statePath: string, state: TState, options: {
5
+ dirtyDateKeys: string[];
6
+ }) => Promise<void>;
7
+ }): {
8
+ markDirty: (dateKey: string | undefined) => void;
9
+ scheduleSave: () => void;
10
+ flushSave: () => Promise<void>;
11
+ persist: () => Promise<void>;
12
+ getDirtyCount: () => number;
13
+ };
@@ -0,0 +1,63 @@
1
+ import { debug, swallow } from './helpers.js';
2
+ export function createPersistenceScheduler(deps) {
3
+ const dirtyDateKeys = new Set();
4
+ let stateDirty = false;
5
+ let saveTimer;
6
+ let saveInFlight = Promise.resolve();
7
+ /**
8
+ * Capture and delete specific dirty keys instead of clearing the whole set.
9
+ * Keys added between capture and write completion are preserved.
10
+ */
11
+ const persist = () => {
12
+ const dirty = Array.from(dirtyDateKeys);
13
+ if (dirty.length === 0 && !stateDirty)
14
+ return saveInFlight;
15
+ for (const key of dirty)
16
+ dirtyDateKeys.delete(key);
17
+ stateDirty = false;
18
+ const write = saveInFlight
19
+ .catch(swallow('persistState:wait'))
20
+ .then(() => deps.saveState(deps.statePath, deps.state, { dirtyDateKeys: dirty }))
21
+ .catch((error) => {
22
+ for (const key of dirty)
23
+ dirtyDateKeys.add(key);
24
+ stateDirty = true;
25
+ debug(`persistState failed: ${String(error)}`);
26
+ throw error;
27
+ });
28
+ saveInFlight = write;
29
+ return write;
30
+ };
31
+ const scheduleSave = () => {
32
+ stateDirty = true;
33
+ if (saveTimer)
34
+ clearTimeout(saveTimer);
35
+ saveTimer = setTimeout(() => {
36
+ saveTimer = undefined;
37
+ void persist().catch(swallow('persistState:save'));
38
+ }, 200);
39
+ };
40
+ const flushSave = async () => {
41
+ if (saveTimer) {
42
+ clearTimeout(saveTimer);
43
+ saveTimer = undefined;
44
+ }
45
+ if (dirtyDateKeys.size > 0 || stateDirty) {
46
+ await persist();
47
+ return;
48
+ }
49
+ await saveInFlight;
50
+ };
51
+ const markDirty = (dateKey) => {
52
+ if (!dateKey)
53
+ return;
54
+ dirtyDateKeys.add(dateKey);
55
+ };
56
+ return {
57
+ markDirty,
58
+ scheduleSave,
59
+ flushSave,
60
+ persist,
61
+ getDirtyCount: () => dirtyDateKeys.size,
62
+ };
63
+ }
@@ -66,12 +66,13 @@ async function fetchOpenAIQuota(ctx) {
66
66
  1000;
67
67
  if (ctx.updateAuth && ctx.auth.refresh && ctx.auth.expires) {
68
68
  try {
69
- await ctx.updateAuth('openai', {
69
+ await ctx.updateAuth(ctx.providerID, {
70
70
  type: 'oauth',
71
71
  access: ctx.auth.access,
72
72
  refresh: ctx.auth.refresh,
73
73
  expires: ctx.auth.expires,
74
74
  accountId: ctx.auth.accountId,
75
+ enterpriseUrl: ctx.auth.enterpriseUrl,
75
76
  });
76
77
  debug('openai oauth token refreshed and persisted');
77
78
  }
package/dist/quota.js CHANGED
@@ -47,7 +47,19 @@ export function createQuotaRuntime() {
47
47
  const adapter = resolveQuotaAdapter(providerID, providerOptions);
48
48
  const normalizedProviderID = normalizeProviderID(providerID);
49
49
  const baseURL = sanitizeBaseURL(providerOptions?.baseURL);
50
- const keyBase = adapter?.id || normalizedProviderID;
50
+ let keyBase = adapter?.id || normalizedProviderID;
51
+ // Some adapters normalize multiple provider IDs into one canonical ID.
52
+ // Preserve the original providerID in the cache key to avoid collisions
53
+ // when different auth entries are used (e.g. Copilot enterprise variants).
54
+ if (adapter?.id && normalizedProviderID !== providerID) {
55
+ keyBase = `${adapter.id}:${providerID}`;
56
+ }
57
+ // RightCode variants intentionally keep provider-specific labels (RC-openai,
58
+ // RC-foo). Preserve that identity in cache keys so snapshots don't collide.
59
+ if (adapter?.id === 'rightcode' &&
60
+ normalizedProviderID.startsWith('rightcode-')) {
61
+ keyBase = normalizedProviderID;
62
+ }
51
63
  return baseURL ? `${keyBase}@${baseURL}` : keyBase;
52
64
  };
53
65
  const fetchQuotaSnapshot = async (providerID, authMap, config, updateAuth, providerOptions) => {
@@ -82,17 +94,15 @@ export function createQuotaRuntime() {
82
94
  fetchQuotaSnapshot,
83
95
  };
84
96
  }
85
- function withRuntime(fn) {
86
- return fn(createQuotaRuntime());
87
- }
97
+ const defaultRuntime = createQuotaRuntime();
88
98
  export function normalizeProviderID(providerID) {
89
- return withRuntime((runtime) => runtime.normalizeProviderID(providerID));
99
+ return defaultRuntime.normalizeProviderID(providerID);
90
100
  }
91
101
  export function resolveQuotaAdapter(providerID, providerOptions) {
92
- return withRuntime((runtime) => runtime.resolveQuotaAdapter(providerID, providerOptions));
102
+ return defaultRuntime.resolveQuotaAdapter(providerID, providerOptions);
93
103
  }
94
104
  export function quotaCacheKey(providerID, providerOptions) {
95
- return withRuntime((runtime) => runtime.quotaCacheKey(providerID, providerOptions));
105
+ return defaultRuntime.quotaCacheKey(providerID, providerOptions);
96
106
  }
97
107
  export async function loadAuthMap(authPath) {
98
108
  const parsed = await fs
@@ -112,5 +122,5 @@ export async function loadAuthMap(authPath) {
112
122
  }, {});
113
123
  }
114
124
  export async function fetchQuotaSnapshot(providerID, authMap, config, updateAuth, providerOptions) {
115
- return withRuntime((runtime) => runtime.fetchQuotaSnapshot(providerID, authMap, config, updateAuth, providerOptions));
125
+ return defaultRuntime.fetchQuotaSnapshot(providerID, authMap, config, updateAuth, providerOptions);
116
126
  }
@@ -0,0 +1,23 @@
1
+ import type { PluginInput } from '@opencode-ai/plugin';
2
+ import type { QuotaSidebarConfig, QuotaSidebarState, QuotaSnapshot } from './types.js';
3
+ import type { AuthValue } from './providers/types.js';
4
+ export declare function createQuotaService(deps: {
5
+ quotaRuntime: {
6
+ normalizeProviderID: (providerID: string) => string;
7
+ resolveQuotaAdapter: (providerID: string, providerOptions?: Record<string, unknown>) => {
8
+ id: string;
9
+ } | undefined;
10
+ quotaCacheKey: (providerID: string, providerOptions?: Record<string, unknown>) => string;
11
+ fetchQuotaSnapshot: (providerID: string, authMap: Record<string, AuthValue>, config: QuotaSidebarConfig, updateAuth?: (providerID: string, next: unknown) => Promise<void>, providerOptions?: Record<string, unknown>) => Promise<QuotaSnapshot | undefined>;
12
+ };
13
+ config: QuotaSidebarConfig;
14
+ state: QuotaSidebarState;
15
+ authPath: string;
16
+ client: PluginInput['client'];
17
+ directory: string;
18
+ scheduleSave: () => void;
19
+ }): {
20
+ getQuotaSnapshots: (providerIDs: string[], options?: {
21
+ allowDefault?: boolean;
22
+ }) => Promise<QuotaSnapshot[]>;
23
+ };
@@ -0,0 +1,188 @@
1
+ import { TtlValueCache } from './cache.js';
2
+ import { swallow } from './helpers.js';
3
+ import { listDefaultQuotaProviderIDs, loadAuthMap, quotaSort } from './quota.js';
4
+ export function createQuotaService(deps) {
5
+ const authCache = new TtlValueCache();
6
+ const providerOptionsCache = new TtlValueCache();
7
+ const inFlight = new Map();
8
+ const getAuthMap = async () => {
9
+ const cached = authCache.get();
10
+ if (cached)
11
+ return cached;
12
+ const value = await loadAuthMap(deps.authPath);
13
+ return authCache.set(value, 30_000);
14
+ };
15
+ const getProviderOptionsMap = async () => {
16
+ const cached = providerOptionsCache.get();
17
+ if (cached)
18
+ return cached;
19
+ const configClient = deps.client;
20
+ if (!configClient.config?.providers) {
21
+ return providerOptionsCache.set({}, 30_000);
22
+ }
23
+ const response = await configClient.config
24
+ .providers({
25
+ query: { directory: deps.directory },
26
+ throwOnError: true,
27
+ })
28
+ .catch(swallow('getProviderOptionsMap'));
29
+ const data = response &&
30
+ typeof response === 'object' &&
31
+ 'data' in response &&
32
+ response.data &&
33
+ typeof response.data === 'object' &&
34
+ 'providers' in response.data
35
+ ? response.data.providers
36
+ : undefined;
37
+ const map = Array.isArray(data)
38
+ ? data.reduce((acc, item) => {
39
+ if (!item || typeof item !== 'object')
40
+ return acc;
41
+ const record = item;
42
+ const id = record.id;
43
+ const options = record.options;
44
+ if (typeof id !== 'string')
45
+ return acc;
46
+ if (!options ||
47
+ typeof options !== 'object' ||
48
+ Array.isArray(options)) {
49
+ acc[id] = {};
50
+ return acc;
51
+ }
52
+ acc[id] = options;
53
+ return acc;
54
+ }, {})
55
+ : {};
56
+ return providerOptionsCache.set(map, 30_000);
57
+ };
58
+ const isValidQuotaCache = (snapshot) => {
59
+ // Guard against stale RightCode cache entries from pre-daily format.
60
+ if (snapshot.adapterID !== 'rightcode' || snapshot.status !== 'ok')
61
+ return true;
62
+ if (!snapshot.windows || snapshot.windows.length === 0)
63
+ return true;
64
+ const primary = snapshot.windows[0];
65
+ if (!primary.label.startsWith('Daily $'))
66
+ return false;
67
+ if (primary.showPercent !== false)
68
+ return false;
69
+ return true;
70
+ };
71
+ const getQuotaSnapshots = async (providerIDs, options) => {
72
+ const allowDefault = options?.allowDefault === true;
73
+ const [authMap, providerOptionsMap] = await Promise.all([
74
+ getAuthMap(),
75
+ getProviderOptionsMap(),
76
+ ]);
77
+ const optionsForProvider = (providerID) => {
78
+ return (providerOptionsMap[providerID] ||
79
+ providerOptionsMap[deps.quotaRuntime.normalizeProviderID(providerID)]);
80
+ };
81
+ const directCandidates = providerIDs.map((providerID) => ({
82
+ providerID,
83
+ providerOptions: optionsForProvider(providerID),
84
+ }));
85
+ const defaultCandidates = allowDefault
86
+ ? [
87
+ ...Object.keys(providerOptionsMap).map((providerID) => ({
88
+ providerID,
89
+ providerOptions: providerOptionsMap[providerID],
90
+ })),
91
+ ...listDefaultQuotaProviderIDs().map((providerID) => ({
92
+ providerID,
93
+ providerOptions: optionsForProvider(providerID),
94
+ })),
95
+ ]
96
+ : [];
97
+ const rawCandidates = directCandidates.length
98
+ ? directCandidates
99
+ : defaultCandidates;
100
+ const matchedCandidates = rawCandidates.filter((candidate) => Boolean(deps.quotaRuntime.resolveQuotaAdapter(candidate.providerID, candidate.providerOptions)));
101
+ const dedupedCandidates = Array.from(matchedCandidates
102
+ .reduce((acc, candidate) => {
103
+ const key = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
104
+ if (!acc.has(key))
105
+ acc.set(key, candidate);
106
+ return acc;
107
+ }, new Map())
108
+ .values());
109
+ function authScopeFor(providerID, providerOptions) {
110
+ const adapter = deps.quotaRuntime.resolveQuotaAdapter(providerID, providerOptions);
111
+ const normalized = deps.quotaRuntime.normalizeProviderID(providerID);
112
+ const adapterID = adapter?.id;
113
+ const candidates = [];
114
+ const push = (value) => {
115
+ if (!value)
116
+ return;
117
+ if (!candidates.includes(value))
118
+ candidates.push(value);
119
+ };
120
+ push(providerID);
121
+ push(normalized);
122
+ push(adapterID);
123
+ if (adapterID === 'github-copilot')
124
+ push('github-copilot-enterprise');
125
+ for (const key of candidates) {
126
+ const auth = authMap[key];
127
+ if (!auth)
128
+ continue;
129
+ if (key === 'openai' &&
130
+ auth.type === 'oauth' &&
131
+ typeof auth.accountId === 'string' &&
132
+ auth.accountId) {
133
+ return `${key}@${auth.accountId}`;
134
+ }
135
+ return key;
136
+ }
137
+ return 'none';
138
+ }
139
+ let cacheChanged = false;
140
+ const fetchSnapshot = (providerID, providerOptions) => {
141
+ const baseKey = deps.quotaRuntime.quotaCacheKey(providerID, providerOptions);
142
+ const cacheKey = `${baseKey}#${authScopeFor(providerID, providerOptions)}`;
143
+ const cached = deps.state.quotaCache[cacheKey];
144
+ if (cached &&
145
+ Date.now() - cached.checkedAt <= deps.config.quota.refreshMs) {
146
+ if (isValidQuotaCache(cached))
147
+ return Promise.resolve(cached);
148
+ delete deps.state.quotaCache[cacheKey];
149
+ cacheChanged = true;
150
+ }
151
+ const existing = inFlight.get(cacheKey);
152
+ if (existing)
153
+ return existing;
154
+ const promise = deps.quotaRuntime
155
+ .fetchQuotaSnapshot(providerID, authMap, deps.config, async (id, next) => {
156
+ await deps.client.auth
157
+ .set({
158
+ path: { id },
159
+ query: { directory: deps.directory },
160
+ body: next,
161
+ throwOnError: true,
162
+ })
163
+ .catch(swallow('getQuotaSnapshots:authSet'));
164
+ }, providerOptions)
165
+ .then((latest) => {
166
+ if (!latest)
167
+ return undefined;
168
+ deps.state.quotaCache[cacheKey] = latest;
169
+ cacheChanged = true;
170
+ return latest;
171
+ })
172
+ .finally(() => {
173
+ if (inFlight.get(cacheKey) === promise) {
174
+ inFlight.delete(cacheKey);
175
+ }
176
+ });
177
+ inFlight.set(cacheKey, promise);
178
+ return promise;
179
+ };
180
+ const fetched = await Promise.all(dedupedCandidates.map(({ providerID, providerOptions }) => fetchSnapshot(providerID, providerOptions)));
181
+ const snapshots = fetched.filter((value) => Boolean(value));
182
+ snapshots.sort(quotaSort);
183
+ if (cacheChanged)
184
+ deps.scheduleSave();
185
+ return snapshots;
186
+ };
187
+ return { getQuotaSnapshots };
188
+ }
package/dist/storage.d.ts CHANGED
@@ -30,3 +30,5 @@ export declare function scanSessionsByCreatedRange(statePath: string, startAt: n
30
30
  dateKey: string;
31
31
  state: SessionState;
32
32
  }[]>;
33
+ /** Best-effort: remove a session entry from its day chunk (if present). */
34
+ export declare function deleteSessionFromDayChunk(statePath: string, sessionID: string, dateKey: string): Promise<boolean>;
package/dist/storage.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { asBoolean, asNumber, debug, isRecord, swallow } from './helpers.js';
3
+ import { asBoolean, asNumber, debug, isRecord, mapConcurrent, swallow, } from './helpers.js';
4
4
  import { discoverChunks, readDayChunk, safeWriteFile, writeDayChunk, } from './storage_chunks.js';
5
5
  import { dateKeyFromTimestamp, dateKeysInRange, dateStartFromKey, isDateKey, normalizeTimestampMs, } from './storage_dates.js';
6
6
  import { parseQuotaCache } from './storage_parse.js';
@@ -13,6 +13,10 @@ export const defaultConfig = {
13
13
  width: 36,
14
14
  showCost: true,
15
15
  showQuota: true,
16
+ includeChildren: true,
17
+ childrenMaxDepth: 6,
18
+ childrenMaxSessions: 128,
19
+ childrenConcurrency: 5,
16
20
  },
17
21
  quota: {
18
22
  refreshMs: 5 * 60 * 1000,
@@ -64,6 +68,10 @@ export async function loadConfig(paths) {
64
68
  width: Math.max(20, Math.min(60, asNumber(sidebar.width, defaultConfig.sidebar.width))),
65
69
  showCost: asBoolean(sidebar.showCost, defaultConfig.sidebar.showCost),
66
70
  showQuota: asBoolean(sidebar.showQuota, defaultConfig.sidebar.showQuota),
71
+ includeChildren: asBoolean(sidebar.includeChildren, defaultConfig.sidebar.includeChildren),
72
+ childrenMaxDepth: Math.max(1, Math.min(32, Math.floor(asNumber(sidebar.childrenMaxDepth, defaultConfig.sidebar.childrenMaxDepth)))),
73
+ childrenMaxSessions: Math.max(0, Math.min(2000, Math.floor(asNumber(sidebar.childrenMaxSessions, defaultConfig.sidebar.childrenMaxSessions)))),
74
+ childrenConcurrency: Math.max(1, Math.min(10, Math.floor(asNumber(sidebar.childrenConcurrency, defaultConfig.sidebar.childrenConcurrency)))),
67
75
  },
68
76
  quota: {
69
77
  refreshMs: Math.max(30_000, asNumber(quota.refreshMs, defaultConfig.quota.refreshMs)),
@@ -93,6 +101,7 @@ async function loadVersion2State(raw, statePath) {
93
101
  const titleEnabled = asBoolean(raw.titleEnabled, true);
94
102
  const quotaCache = parseQuotaCache(raw.quotaCache);
95
103
  const rootPath = chunkRootPathFromStateFile(statePath);
104
+ const hasSessionDateMap = Object.prototype.hasOwnProperty.call(raw, 'sessionDateMap');
96
105
  const sessionDateMapRaw = isRecord(raw.sessionDateMap)
97
106
  ? raw.sessionDateMap
98
107
  : {};
@@ -104,17 +113,25 @@ async function loadVersion2State(raw, statePath) {
104
113
  acc[sessionID] = value;
105
114
  return acc;
106
115
  }, {});
116
+ const hadRawSessionDateMapEntries = isRecord(raw.sessionDateMap) && Object.keys(raw.sessionDateMap).length > 0;
107
117
  const explicitDateKeys = Array.from(new Set(Object.values(sessionDateMap)));
108
- const discoveredDateKeys = explicitDateKeys.length
109
- ? []
110
- : await discoverChunks(rootPath);
118
+ // Only discover chunks when sessionDateMap is missing from state.
119
+ // If sessionDateMap exists (even empty), treat it as authoritative so we
120
+ // don't repeatedly load and evict historical sessions from disk.
121
+ const discoveredDateKeys = (!hasSessionDateMap && explicitDateKeys.length === 0) ||
122
+ (hasSessionDateMap &&
123
+ hadRawSessionDateMapEntries &&
124
+ explicitDateKeys.length === 0)
125
+ ? await discoverChunks(rootPath)
126
+ : [];
111
127
  const dateKeys = explicitDateKeys.length
112
128
  ? explicitDateKeys
113
129
  : discoveredDateKeys;
114
- const chunks = await Promise.all(dateKeys.map(async (dateKey) => {
130
+ const LOAD_CHUNKS_CONCURRENCY = 5;
131
+ const chunks = await mapConcurrent(dateKeys, LOAD_CHUNKS_CONCURRENCY, async (dateKey) => {
115
132
  const sessions = await readDayChunk(rootPath, dateKey);
116
133
  return [dateKey, sessions];
117
- }));
134
+ });
118
135
  const sessions = {};
119
136
  for (const [dateKey, chunkSessions] of chunks) {
120
137
  for (const [sessionID, session] of Object.entries(chunkSessions)) {
@@ -175,8 +192,9 @@ export async function saveState(statePath, state, options) {
175
192
  ? session.createdAt
176
193
  : Date.now();
177
194
  session.createdAt = normalizedCreatedAt;
178
- const dateKey = state.sessionDateMap[sessionID] ||
179
- dateKeyFromTimestamp(normalizedCreatedAt);
195
+ const dateKey = isDateKey(state.sessionDateMap[sessionID])
196
+ ? state.sessionDateMap[sessionID]
197
+ : dateKeyFromTimestamp(normalizedCreatedAt);
180
198
  state.sessionDateMap[sessionID] = dateKey;
181
199
  // M11: skip sessions not in dirty set
182
200
  if (!writeAll && dirtySet && !dirtySet.has(dateKey))
@@ -187,7 +205,28 @@ export async function saveState(statePath, state, options) {
187
205
  }
188
206
  }
189
207
  await fs.mkdir(path.dirname(statePath), { recursive: true });
190
- await fs.mkdir(rootPath, { recursive: true });
208
+ if (!skipChunks) {
209
+ const keysToWrite = writeAll
210
+ ? Object.keys(sessionsByDate)
211
+ : Array.from(dirtySet ?? []);
212
+ await Promise.all(keysToWrite
213
+ .map((dateKey) => {
214
+ if (!Object.prototype.hasOwnProperty.call(sessionsByDate, dateKey)) {
215
+ return undefined;
216
+ }
217
+ return (async () => {
218
+ const memorySessions = sessionsByDate[dateKey] || {};
219
+ const next = writeAll
220
+ ? memorySessions
221
+ : {
222
+ ...(await readDayChunk(rootPath, dateKey)),
223
+ ...memorySessions,
224
+ };
225
+ await writeDayChunk(rootPath, dateKey, next);
226
+ })();
227
+ })
228
+ .filter((promise) => Boolean(promise)));
229
+ }
191
230
  // M4: atomic state file write
192
231
  await safeWriteFile(statePath, `${JSON.stringify({
193
232
  version: 2,
@@ -195,19 +234,6 @@ export async function saveState(statePath, state, options) {
195
234
  sessionDateMap: state.sessionDateMap,
196
235
  quotaCache: state.quotaCache,
197
236
  }, null, 2)}\n`);
198
- if (skipChunks)
199
- return;
200
- const keysToWrite = writeAll
201
- ? Object.keys(sessionsByDate)
202
- : Array.from(dirtySet ?? []);
203
- await Promise.all(keysToWrite
204
- .map((dateKey) => {
205
- const sessions = sessionsByDate[dateKey];
206
- if (!sessions)
207
- return undefined;
208
- return writeDayChunk(rootPath, dateKey, sessions);
209
- })
210
- .filter((promise) => Boolean(promise)));
211
237
  }
212
238
  // ─── Eviction (M2) ──────────────────────────────────────────────────────────
213
239
  /**
@@ -264,14 +290,15 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
264
290
  : new Set();
265
291
  const diskDateKeys = dateKeys.filter((dk) => !memoryDateKeys.has(dk));
266
292
  if (diskDateKeys.length > 0) {
267
- const chunkEntries = await Promise.all(diskDateKeys.map(async (dateKey) => {
293
+ const RANGE_SCAN_CONCURRENCY = 5;
294
+ const chunkEntries = await mapConcurrent(diskDateKeys, RANGE_SCAN_CONCURRENCY, async (dateKey) => {
268
295
  const sessions = await readDayChunk(rootPath, dateKey);
269
296
  return Object.entries(sessions).map(([sessionID, state]) => ({
270
297
  sessionID,
271
298
  dateKey,
272
299
  state,
273
300
  }));
274
- }));
301
+ });
275
302
  for (const entry of chunkEntries.flat()) {
276
303
  if (seenSessionIDs.has(entry.sessionID))
277
304
  continue;
@@ -286,3 +313,14 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
286
313
  }
287
314
  return results;
288
315
  }
316
+ /** Best-effort: remove a session entry from its day chunk (if present). */
317
+ export async function deleteSessionFromDayChunk(statePath, sessionID, dateKey) {
318
+ 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;
326
+ }
@@ -5,6 +5,39 @@ import { debug, isRecord, swallow } from './helpers.js';
5
5
  import { isDateKey } from './storage_dates.js';
6
6
  import { parseSessionState } from './storage_parse.js';
7
7
  import { chunkFilePath } from './storage_paths.js';
8
+ async function mkdirpNoSymlink(rootPath, dirPath) {
9
+ const rel = path.relative(rootPath, dirPath);
10
+ if (!rel || rel === '.')
11
+ return;
12
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
13
+ throw new Error(`refusing to mkdir outside root: ${dirPath}`);
14
+ }
15
+ let current = rootPath;
16
+ const parts = rel.split(path.sep).filter(Boolean);
17
+ for (const part of parts) {
18
+ current = path.join(current, part);
19
+ const stat = await fs.lstat(current).catch(() => undefined);
20
+ if (stat) {
21
+ if (stat.isSymbolicLink()) {
22
+ throw new Error(`refusing to write through symlink dir: ${current}`);
23
+ }
24
+ if (!stat.isDirectory()) {
25
+ throw new Error(`expected directory at ${current}`);
26
+ }
27
+ continue;
28
+ }
29
+ await fs.mkdir(current).catch((error) => {
30
+ const code = error.code;
31
+ if (code === 'EEXIST')
32
+ return;
33
+ throw error;
34
+ });
35
+ const created = await fs.lstat(current).catch(() => undefined);
36
+ if (!created || created.isSymbolicLink() || !created.isDirectory()) {
37
+ throw new Error(`unsafe directory created at ${current}`);
38
+ }
39
+ }
40
+ }
8
41
  /** P2: Simple LRU cache for loaded chunks. */
9
42
  class ChunkCache {
10
43
  cache = new Map();
@@ -41,10 +74,17 @@ class ChunkCache {
41
74
  }
42
75
  const chunkCache = new ChunkCache();
43
76
  export async function readDayChunk(rootPath, dateKey) {
77
+ if (!isDateKey(dateKey))
78
+ return {};
44
79
  const cached = chunkCache.get(dateKey);
45
80
  if (cached)
46
81
  return cached;
47
82
  const filePath = chunkFilePath(rootPath, dateKey);
83
+ const stat = await fs.lstat(filePath).catch(() => undefined);
84
+ if (stat?.isSymbolicLink()) {
85
+ debug(`refusing to read symlink chunk: ${filePath}`);
86
+ return {};
87
+ }
48
88
  const parsed = await fs
49
89
  .readFile(filePath, 'utf8')
50
90
  .then((value) => JSON.parse(value))
@@ -76,6 +116,12 @@ export async function safeWriteFile(filePath, content) {
76
116
  debug(message);
77
117
  throw new Error(message);
78
118
  }
119
+ const dirStat = await fs.lstat(path.dirname(filePath)).catch(() => undefined);
120
+ if (dirStat?.isSymbolicLink()) {
121
+ const message = `refusing to write through symlink dir: ${path.dirname(filePath)}`;
122
+ debug(message);
123
+ throw new Error(message);
124
+ }
79
125
  // M4: atomic write via temp + rename
80
126
  const dir = path.dirname(filePath);
81
127
  const name = path.basename(filePath);
@@ -109,8 +155,25 @@ export async function safeWriteFile(filePath, content) {
109
155
  : new Error(`safeWriteFile failed for ${filePath}`);
110
156
  }
111
157
  export async function writeDayChunk(rootPath, dateKey, sessions) {
158
+ if (!isDateKey(dateKey)) {
159
+ throw new Error(`invalid dateKey: ${dateKey}`);
160
+ }
112
161
  const filePath = chunkFilePath(rootPath, dateKey);
113
- await fs.mkdir(path.dirname(filePath), { recursive: true });
162
+ const rootStat = await fs.lstat(rootPath).catch(() => undefined);
163
+ if (rootStat?.isSymbolicLink()) {
164
+ throw new Error(`refusing to write through symlink dir: ${rootPath}`);
165
+ }
166
+ if (rootStat && !rootStat.isDirectory()) {
167
+ throw new Error(`expected directory at ${rootPath}`);
168
+ }
169
+ await fs.mkdir(rootPath, { recursive: true });
170
+ const createdRoot = await fs.lstat(rootPath).catch(() => undefined);
171
+ if (!createdRoot ||
172
+ createdRoot.isSymbolicLink() ||
173
+ !createdRoot.isDirectory()) {
174
+ throw new Error(`unsafe chunk root at ${rootPath}`);
175
+ }
176
+ await mkdirpNoSymlink(rootPath, path.dirname(filePath));
114
177
  const chunk = {
115
178
  version: 1,
116
179
  dateKey,
@@ -126,11 +189,21 @@ export async function discoverChunks(rootPath) {
126
189
  if (!/^\d{4}$/.test(year))
127
190
  continue;
128
191
  const yearPath = path.join(rootPath, year);
192
+ const yearStat = await fs.lstat(yearPath).catch(() => undefined);
193
+ if (!yearStat || yearStat.isSymbolicLink() || !yearStat.isDirectory()) {
194
+ continue;
195
+ }
129
196
  const months = await fs.readdir(yearPath).catch(() => []);
130
197
  for (const month of months) {
131
198
  if (!/^\d{2}$/.test(month))
132
199
  continue;
133
200
  const monthPath = path.join(yearPath, month);
201
+ const monthStat = await fs.lstat(monthPath).catch(() => undefined);
202
+ if (!monthStat ||
203
+ monthStat.isSymbolicLink() ||
204
+ !monthStat.isDirectory()) {
205
+ continue;
206
+ }
134
207
  const days = await fs.readdir(monthPath).catch(() => []);
135
208
  for (const dayFile of days) {
136
209
  const match = dayFile.match(/^(\d{2})\.json$/);
@@ -55,9 +55,16 @@ function parseCachedUsage(value) {
55
55
  function parseCursor(value) {
56
56
  if (!isRecord(value))
57
57
  return undefined;
58
+ const idsRaw = value.lastMessageIdsAtTime;
59
+ const lastMessageIdsAtTime = Array.isArray(idsRaw)
60
+ ? idsRaw.filter((item) => typeof item === 'string' && !!item)
61
+ : undefined;
58
62
  return {
59
63
  lastMessageId: typeof value.lastMessageId === 'string' ? value.lastMessageId : undefined,
60
64
  lastMessageTime: asNumber(value.lastMessageTime),
65
+ lastMessageIdsAtTime: lastMessageIdsAtTime && lastMessageIdsAtTime.length
66
+ ? Array.from(new Set(lastMessageIdsAtTime)).sort()
67
+ : undefined,
61
68
  };
62
69
  }
63
70
  export function parseSessionState(value) {
@@ -72,6 +79,7 @@ export function parseSessionState(value) {
72
79
  return {
73
80
  ...title,
74
81
  createdAt,
82
+ parentID: typeof value.parentID === 'string' ? value.parentID : undefined,
75
83
  usage: parseCachedUsage(value.usage),
76
84
  cursor: parseCursor(value.cursor),
77
85
  };
@@ -6,6 +6,7 @@
6
6
  * This applies on all platforms including Windows and macOS.
7
7
  *
8
8
  * S4 fix: renamed env var from OPENCODE_TEST_HOME to OPENCODE_QUOTA_DATA_HOME.
9
+ * OPENCODE_QUOTA_DATA_HOME overrides the full data directory path.
9
10
  */
10
11
  export declare function resolveOpencodeDataDir(): string;
11
12
  export declare function stateFilePath(dataDir: string): string;