@eiei114/pi-sub-core 1.5.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/CHANGELOG.md +190 -0
- package/README.md +178 -0
- package/index.ts +540 -0
- package/package.json +35 -0
- package/src/cache.ts +546 -0
- package/src/config.ts +35 -0
- package/src/dependencies.ts +37 -0
- package/src/errors.ts +71 -0
- package/src/paths.ts +55 -0
- package/src/provider.ts +66 -0
- package/src/providers/detection.ts +51 -0
- package/src/providers/impl/anthropic.ts +174 -0
- package/src/providers/impl/antigravity.ts +226 -0
- package/src/providers/impl/codex.ts +186 -0
- package/src/providers/impl/copilot.ts +176 -0
- package/src/providers/impl/gemini.ts +130 -0
- package/src/providers/impl/kiro.ts +92 -0
- package/src/providers/impl/zai.ts +120 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/metadata.ts +16 -0
- package/src/providers/registry.ts +54 -0
- package/src/providers/settings.ts +109 -0
- package/src/providers/status.ts +25 -0
- package/src/settings/behavior.ts +58 -0
- package/src/settings/menu.ts +83 -0
- package/src/settings/tools.ts +38 -0
- package/src/settings/ui.ts +450 -0
- package/src/settings-types.ts +95 -0
- package/src/settings-ui.ts +1 -0
- package/src/settings.ts +137 -0
- package/src/status.ts +245 -0
- package/src/storage/lock.ts +150 -0
- package/src/storage.ts +61 -0
- package/src/types.ts +33 -0
- package/src/ui/keybindings.ts +92 -0
- package/src/ui/settings-list.ts +290 -0
- package/src/usage/controller.ts +250 -0
- package/src/usage/fetch.ts +215 -0
- package/src/usage/types.ts +5 -0
- package/src/utils.ts +158 -0
- package/test/all.test.ts +9 -0
- package/test/cache.test.ts +157 -0
- package/test/controller.test.ts +101 -0
- package/test/detection.test.ts +24 -0
- package/test/extension.test.ts +233 -0
- package/test/helpers.ts +48 -0
- package/test/keybindings.test.ts +59 -0
- package/test/lock.test.ts +49 -0
- package/test/prioritize.test.ts +81 -0
- package/test/providers.test.ts +385 -0
- package/test/status.test.ts +70 -0
- package/tsconfig.json +5 -0
package/src/cache.ts
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache management for sub-bar
|
|
3
|
+
* Shared cache across all pi instances to avoid redundant API calls
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import type { ProviderName, ProviderStatus, UsageSnapshot } from "./types.js";
|
|
9
|
+
import { isExpectedMissingData } from "./errors.js";
|
|
10
|
+
import { getStorage } from "./storage.js";
|
|
11
|
+
import {
|
|
12
|
+
getCachePath,
|
|
13
|
+
getCacheLockPath,
|
|
14
|
+
getLegacyAgentCacheLockPath,
|
|
15
|
+
getLegacyAgentCachePath,
|
|
16
|
+
getLegacyCacheLockPath,
|
|
17
|
+
getLegacyCachePath,
|
|
18
|
+
} from "./paths.js";
|
|
19
|
+
import { tryAcquireFileLock, releaseFileLock, waitForLockRelease } from "./storage/lock.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Cache entry for a provider
|
|
23
|
+
*/
|
|
24
|
+
export interface CacheEntry {
|
|
25
|
+
fetchedAt: number;
|
|
26
|
+
statusFetchedAt?: number;
|
|
27
|
+
usage?: UsageSnapshot;
|
|
28
|
+
status?: ProviderStatus;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cache structure
|
|
33
|
+
*/
|
|
34
|
+
export interface Cache {
|
|
35
|
+
[provider: string]: CacheEntry;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type CacheUpdateListener = (provider: ProviderName, entry?: CacheEntry) => void;
|
|
39
|
+
export type CacheSnapshotListener = (cache: Cache) => void;
|
|
40
|
+
|
|
41
|
+
const cacheUpdateListeners = new Set<CacheUpdateListener>();
|
|
42
|
+
const cacheSnapshotListeners = new Set<CacheSnapshotListener>();
|
|
43
|
+
|
|
44
|
+
let lastCacheSnapshot: Cache | null = null;
|
|
45
|
+
let lastCacheContent = "";
|
|
46
|
+
let lastCacheMtimeMs = 0;
|
|
47
|
+
let legacyCacheMigrated = false;
|
|
48
|
+
|
|
49
|
+
function updateCacheSnapshot(cache: Cache, content: string, mtimeMs: number): void {
|
|
50
|
+
lastCacheSnapshot = cache;
|
|
51
|
+
lastCacheContent = content;
|
|
52
|
+
lastCacheMtimeMs = mtimeMs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resetCacheSnapshot(): void {
|
|
56
|
+
lastCacheSnapshot = {};
|
|
57
|
+
lastCacheContent = "";
|
|
58
|
+
lastCacheMtimeMs = 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function migrateLegacyCache(): void {
|
|
62
|
+
if (legacyCacheMigrated) return;
|
|
63
|
+
legacyCacheMigrated = true;
|
|
64
|
+
const storage = getStorage();
|
|
65
|
+
try {
|
|
66
|
+
const legacyCachePaths = [LEGACY_AGENT_CACHE_PATH, LEGACY_CACHE_PATH];
|
|
67
|
+
if (!storage.exists(CACHE_PATH)) {
|
|
68
|
+
const legacyPath = legacyCachePaths.find((path) => storage.exists(path));
|
|
69
|
+
if (legacyPath) {
|
|
70
|
+
const content = storage.readFile(legacyPath);
|
|
71
|
+
if (content) {
|
|
72
|
+
ensureCacheDir();
|
|
73
|
+
storage.writeFile(CACHE_PATH, content);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const legacyPath of legacyCachePaths) {
|
|
78
|
+
if (storage.exists(legacyPath)) {
|
|
79
|
+
storage.removeFile(legacyPath);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const legacyLockPath of [LEGACY_AGENT_LOCK_PATH, LEGACY_LOCK_PATH]) {
|
|
83
|
+
if (storage.exists(legacyLockPath)) {
|
|
84
|
+
storage.removeFile(legacyLockPath);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error("Failed to migrate cache:", error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function onCacheUpdate(listener: CacheUpdateListener): () => void {
|
|
93
|
+
cacheUpdateListeners.add(listener);
|
|
94
|
+
return () => {
|
|
95
|
+
cacheUpdateListeners.delete(listener);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function onCacheSnapshot(listener: CacheSnapshotListener): () => void {
|
|
100
|
+
cacheSnapshotListeners.add(listener);
|
|
101
|
+
return () => {
|
|
102
|
+
cacheSnapshotListeners.delete(listener);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function emitCacheUpdate(provider: ProviderName, entry?: CacheEntry): void {
|
|
107
|
+
for (const listener of cacheUpdateListeners) {
|
|
108
|
+
try {
|
|
109
|
+
listener(provider, entry);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error("Failed to notify cache update:", error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function emitCacheSnapshot(cache: Cache): void {
|
|
117
|
+
for (const listener of cacheSnapshotListeners) {
|
|
118
|
+
try {
|
|
119
|
+
listener(cache);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error("Failed to notify cache snapshot:", error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Cache file path
|
|
128
|
+
*/
|
|
129
|
+
export const CACHE_PATH = getCachePath();
|
|
130
|
+
const LEGACY_CACHE_PATH = getLegacyCachePath();
|
|
131
|
+
const LEGACY_AGENT_CACHE_PATH = getLegacyAgentCachePath();
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Lock file path
|
|
135
|
+
*/
|
|
136
|
+
const LOCK_PATH = getCacheLockPath();
|
|
137
|
+
const LEGACY_LOCK_PATH = getLegacyCacheLockPath();
|
|
138
|
+
const LEGACY_AGENT_LOCK_PATH = getLegacyAgentCacheLockPath();
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Lock timeout in milliseconds
|
|
142
|
+
*/
|
|
143
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
144
|
+
const CACHE_WRITE_RETRY_ATTEMPTS = 8;
|
|
145
|
+
const CACHE_WRITE_RETRY_DELAY_MS = 25;
|
|
146
|
+
const RETRYABLE_RENAME_ERROR_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
|
|
147
|
+
const SLEEP_ARRAY = new Int32Array(new SharedArrayBuffer(4));
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Ensure cache directory exists
|
|
151
|
+
*/
|
|
152
|
+
function ensureCacheDir(): void {
|
|
153
|
+
const storage = getStorage();
|
|
154
|
+
const dir = path.dirname(CACHE_PATH);
|
|
155
|
+
storage.ensureDir(dir);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Read cache from disk
|
|
160
|
+
*/
|
|
161
|
+
export function readCache(): Cache {
|
|
162
|
+
migrateLegacyCache();
|
|
163
|
+
const storage = getStorage();
|
|
164
|
+
try {
|
|
165
|
+
const cacheExists = storage.exists(CACHE_PATH);
|
|
166
|
+
if (!cacheExists) {
|
|
167
|
+
if (lastCacheMtimeMs !== 0 || lastCacheContent) {
|
|
168
|
+
resetCacheSnapshot();
|
|
169
|
+
}
|
|
170
|
+
return lastCacheSnapshot ?? {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
|
|
174
|
+
if (stat && stat.mtimeMs === lastCacheMtimeMs && lastCacheSnapshot) {
|
|
175
|
+
return lastCacheSnapshot;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const content = storage.readFile(CACHE_PATH);
|
|
179
|
+
if (!content) {
|
|
180
|
+
updateCacheSnapshot({}, "", stat?.mtimeMs ?? 0);
|
|
181
|
+
return {};
|
|
182
|
+
}
|
|
183
|
+
if (!stat && content === lastCacheContent && lastCacheSnapshot) {
|
|
184
|
+
return lastCacheSnapshot;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(content) as Cache;
|
|
189
|
+
updateCacheSnapshot(parsed, content, stat?.mtimeMs ?? Date.now());
|
|
190
|
+
return parsed;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const lastBrace = content.lastIndexOf("}");
|
|
193
|
+
if (lastBrace > 0) {
|
|
194
|
+
const trimmed = content.slice(0, lastBrace + 1);
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(trimmed) as Cache;
|
|
197
|
+
if (stat) {
|
|
198
|
+
writeCache(parsed);
|
|
199
|
+
} else {
|
|
200
|
+
updateCacheSnapshot(parsed, trimmed, Date.now());
|
|
201
|
+
}
|
|
202
|
+
return parsed;
|
|
203
|
+
} catch {
|
|
204
|
+
// fall through to log below
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
console.error("Failed to read cache:", error);
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error("Failed to read cache:", error);
|
|
211
|
+
}
|
|
212
|
+
return {};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Write cache to disk
|
|
217
|
+
*/
|
|
218
|
+
function writeCache(cache: Cache): void {
|
|
219
|
+
migrateLegacyCache();
|
|
220
|
+
const storage = getStorage();
|
|
221
|
+
try {
|
|
222
|
+
ensureCacheDir();
|
|
223
|
+
const content = JSON.stringify(cache, null, 2);
|
|
224
|
+
const cacheExists = storage.exists(CACHE_PATH);
|
|
225
|
+
if (cacheExists && content === lastCacheContent) {
|
|
226
|
+
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
|
|
227
|
+
updateCacheSnapshot(cache, content, stat?.mtimeMs ?? lastCacheMtimeMs);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const tempPath = `${CACHE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
231
|
+
fs.writeFileSync(tempPath, content, "utf-8");
|
|
232
|
+
try {
|
|
233
|
+
renameCacheFileWithRetry(tempPath, CACHE_PATH);
|
|
234
|
+
} finally {
|
|
235
|
+
removeTempCacheFile(tempPath);
|
|
236
|
+
}
|
|
237
|
+
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
|
|
238
|
+
updateCacheSnapshot(cache, content, stat?.mtimeMs ?? Date.now());
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error("Failed to write cache:", error);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function renameCacheFileWithRetry(fromPath: string, toPath: string): void {
|
|
245
|
+
let lastError: unknown;
|
|
246
|
+
for (let attempt = 0; attempt < CACHE_WRITE_RETRY_ATTEMPTS; attempt++) {
|
|
247
|
+
try {
|
|
248
|
+
fs.renameSync(fromPath, toPath);
|
|
249
|
+
return;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (!isRetryableRenameError(error)) {
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
lastError = error;
|
|
255
|
+
if (attempt >= CACHE_WRITE_RETRY_ATTEMPTS - 1) {
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
sleepSync(CACHE_WRITE_RETRY_DELAY_MS * (attempt + 1));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (lastError) {
|
|
262
|
+
throw lastError;
|
|
263
|
+
}
|
|
264
|
+
throw new Error("Failed to rename cache file");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function removeTempCacheFile(tempPath: string): void {
|
|
268
|
+
try {
|
|
269
|
+
if (fs.existsSync(tempPath)) {
|
|
270
|
+
fs.unlinkSync(tempPath);
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// Ignore cleanup errors
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isRetryableRenameError(error: unknown): boolean {
|
|
278
|
+
if (!error || typeof error !== "object") return false;
|
|
279
|
+
const code = "code" in error ? (error as { code?: unknown }).code : undefined;
|
|
280
|
+
return typeof code === "string" && RETRYABLE_RENAME_ERROR_CODES.has(code);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function sleepSync(ms: number): void {
|
|
284
|
+
if (ms <= 0) return;
|
|
285
|
+
Atomics.wait(SLEEP_ARRAY, 0, 0, ms);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface CacheWatchOptions {
|
|
289
|
+
debounceMs?: number;
|
|
290
|
+
pollIntervalMs?: number;
|
|
291
|
+
lockRetryMs?: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function watchCacheUpdates(options?: CacheWatchOptions): () => void {
|
|
295
|
+
migrateLegacyCache();
|
|
296
|
+
const debounceMs = options?.debounceMs ?? 250;
|
|
297
|
+
const pollIntervalMs = options?.pollIntervalMs ?? 5000;
|
|
298
|
+
const lockRetryMs = options?.lockRetryMs ?? 1000;
|
|
299
|
+
let debounceTimer: NodeJS.Timeout | undefined;
|
|
300
|
+
let pollTimer: NodeJS.Timeout | undefined;
|
|
301
|
+
let lockRetryPending = false;
|
|
302
|
+
let lastSnapshot = "";
|
|
303
|
+
let lastMtimeMs = 0;
|
|
304
|
+
let stopped = false;
|
|
305
|
+
|
|
306
|
+
const scheduleLockRetry = () => {
|
|
307
|
+
if (lockRetryPending || stopped) return;
|
|
308
|
+
lockRetryPending = true;
|
|
309
|
+
void waitForLockRelease(LOCK_PATH, lockRetryMs).then((released) => {
|
|
310
|
+
lockRetryPending = false;
|
|
311
|
+
if (released) {
|
|
312
|
+
emitFromCache();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const emitFromCache = () => {
|
|
318
|
+
try {
|
|
319
|
+
if (fs.existsSync(LOCK_PATH)) {
|
|
320
|
+
scheduleLockRetry();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
|
|
324
|
+
if (!stat || !stat.mtimeMs) return;
|
|
325
|
+
if (stat.mtimeMs === lastMtimeMs) return;
|
|
326
|
+
lastMtimeMs = stat.mtimeMs;
|
|
327
|
+
const content = fs.readFileSync(CACHE_PATH, "utf-8");
|
|
328
|
+
if (content === lastSnapshot) return;
|
|
329
|
+
lastSnapshot = content;
|
|
330
|
+
const cache = JSON.parse(content) as Cache;
|
|
331
|
+
updateCacheSnapshot(cache, content, stat.mtimeMs);
|
|
332
|
+
emitCacheSnapshot(cache);
|
|
333
|
+
for (const [provider, entry] of Object.entries(cache)) {
|
|
334
|
+
emitCacheUpdate(provider as ProviderName, entry);
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
// Ignore parse or read errors (likely mid-write)
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const scheduleEmit = () => {
|
|
342
|
+
if (stopped) return;
|
|
343
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
344
|
+
debounceTimer = setTimeout(() => emitFromCache(), debounceMs);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
let watcher: fs.FSWatcher | undefined;
|
|
348
|
+
try {
|
|
349
|
+
watcher = fs.watch(CACHE_PATH, scheduleEmit);
|
|
350
|
+
watcher.unref?.();
|
|
351
|
+
} catch {
|
|
352
|
+
watcher = undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
pollTimer = setInterval(() => emitFromCache(), pollIntervalMs);
|
|
356
|
+
pollTimer.unref?.();
|
|
357
|
+
|
|
358
|
+
return () => {
|
|
359
|
+
stopped = true;
|
|
360
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
361
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
362
|
+
watcher?.close();
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Wait for lock to be released and re-check cache
|
|
368
|
+
* Returns the cache entry if it became fresh while waiting
|
|
369
|
+
*/
|
|
370
|
+
async function waitForLockAndRecheck(
|
|
371
|
+
provider: ProviderName,
|
|
372
|
+
ttlMs: number,
|
|
373
|
+
maxWaitMs: number = 3000
|
|
374
|
+
): Promise<CacheEntry | null> {
|
|
375
|
+
const released = await waitForLockRelease(LOCK_PATH, maxWaitMs);
|
|
376
|
+
if (!released) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const cache = readCache();
|
|
381
|
+
const entry = cache[provider];
|
|
382
|
+
if (entry && entry.usage?.error && !isExpectedMissingData(entry.usage.error)) {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
if (entry && Date.now() - entry.fetchedAt < ttlMs) {
|
|
386
|
+
return entry;
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Get cached data for a provider if fresh, or null if stale/missing
|
|
393
|
+
*/
|
|
394
|
+
export async function getCachedData(
|
|
395
|
+
provider: ProviderName,
|
|
396
|
+
ttlMs: number,
|
|
397
|
+
cacheSnapshot?: Cache
|
|
398
|
+
): Promise<CacheEntry | null> {
|
|
399
|
+
const cache = cacheSnapshot ?? readCache();
|
|
400
|
+
const entry = cache[provider];
|
|
401
|
+
|
|
402
|
+
if (!entry) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (entry.usage?.error && !isExpectedMissingData(entry.usage.error)) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const age = Date.now() - entry.fetchedAt;
|
|
411
|
+
if (age < ttlMs) {
|
|
412
|
+
return entry;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Fetch data with lock coordination
|
|
420
|
+
* Returns cached data if fresh, or executes fetchFn if cache is stale
|
|
421
|
+
*/
|
|
422
|
+
export async function fetchWithCache<T extends { usage?: UsageSnapshot; status?: ProviderStatus; statusFetchedAt?: number }>(
|
|
423
|
+
provider: ProviderName,
|
|
424
|
+
ttlMs: number,
|
|
425
|
+
fetchFn: () => Promise<T>,
|
|
426
|
+
options?: { force?: boolean }
|
|
427
|
+
): Promise<T> {
|
|
428
|
+
const forceRefresh = options?.force === true;
|
|
429
|
+
|
|
430
|
+
if (!forceRefresh) {
|
|
431
|
+
// Check cache first
|
|
432
|
+
const cached = await getCachedData(provider, ttlMs);
|
|
433
|
+
if (cached) {
|
|
434
|
+
return { usage: cached.usage, status: cached.status } as T;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Cache is stale or forced refresh, try to acquire lock
|
|
439
|
+
const lockToken = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS);
|
|
440
|
+
|
|
441
|
+
if (!lockToken) {
|
|
442
|
+
// Another process is fetching. Re-check once, then skip duplicate fetch work.
|
|
443
|
+
const freshEntry = await waitForLockAndRecheck(provider, ttlMs);
|
|
444
|
+
if (freshEntry) {
|
|
445
|
+
return { usage: freshEntry.usage, status: freshEntry.status } as T;
|
|
446
|
+
}
|
|
447
|
+
const cache = readCache();
|
|
448
|
+
const staleEntry = cache[provider];
|
|
449
|
+
if (staleEntry) {
|
|
450
|
+
return { usage: staleEntry.usage, status: staleEntry.status } as T;
|
|
451
|
+
}
|
|
452
|
+
return {} as T;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
// Fetch fresh data
|
|
457
|
+
const result = await fetchFn();
|
|
458
|
+
|
|
459
|
+
// Only cache if we got valid usage data (not just no-credentials/errors)
|
|
460
|
+
const hasCredentialError = result.usage?.error && isExpectedMissingData(result.usage.error);
|
|
461
|
+
const hasError = Boolean(result.usage?.error);
|
|
462
|
+
const shouldCache = result.usage && !hasCredentialError && !hasError;
|
|
463
|
+
|
|
464
|
+
const cache = readCache();
|
|
465
|
+
|
|
466
|
+
if (shouldCache) {
|
|
467
|
+
// Update cache with valid data
|
|
468
|
+
const fetchedAt = Date.now();
|
|
469
|
+
const previous = cache[provider];
|
|
470
|
+
const statusFetchedAt = result.statusFetchedAt ?? (result.status ? fetchedAt : previous?.statusFetchedAt);
|
|
471
|
+
cache[provider] = {
|
|
472
|
+
fetchedAt,
|
|
473
|
+
statusFetchedAt,
|
|
474
|
+
usage: result.usage,
|
|
475
|
+
status: result.status,
|
|
476
|
+
};
|
|
477
|
+
writeCache(cache);
|
|
478
|
+
emitCacheUpdate(provider, cache[provider]);
|
|
479
|
+
emitCacheSnapshot(cache);
|
|
480
|
+
} else if (hasCredentialError) {
|
|
481
|
+
// Remove from cache if no credentials
|
|
482
|
+
if (cache[provider]) {
|
|
483
|
+
delete cache[provider];
|
|
484
|
+
writeCache(cache);
|
|
485
|
+
emitCacheUpdate(provider, undefined);
|
|
486
|
+
emitCacheSnapshot(cache);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return result;
|
|
491
|
+
} finally {
|
|
492
|
+
if (lockToken) {
|
|
493
|
+
releaseFileLock(LOCK_PATH, lockToken);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function updateCacheStatus(
|
|
499
|
+
provider: ProviderName,
|
|
500
|
+
status: ProviderStatus,
|
|
501
|
+
options?: { statusFetchedAt?: number }
|
|
502
|
+
): Promise<void> {
|
|
503
|
+
const lockToken = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS);
|
|
504
|
+
if (!lockToken) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
const cache = readCache();
|
|
509
|
+
const entry = cache[provider];
|
|
510
|
+
const statusFetchedAt = options?.statusFetchedAt ?? Date.now();
|
|
511
|
+
cache[provider] = {
|
|
512
|
+
fetchedAt: entry?.fetchedAt ?? 0,
|
|
513
|
+
statusFetchedAt,
|
|
514
|
+
usage: entry?.usage,
|
|
515
|
+
status,
|
|
516
|
+
};
|
|
517
|
+
writeCache(cache);
|
|
518
|
+
emitCacheUpdate(provider, cache[provider]);
|
|
519
|
+
emitCacheSnapshot(cache);
|
|
520
|
+
} finally {
|
|
521
|
+
if (lockToken) {
|
|
522
|
+
releaseFileLock(LOCK_PATH, lockToken);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Clear cache for a specific provider or all providers
|
|
529
|
+
*/
|
|
530
|
+
export function clearCache(provider?: ProviderName): void {
|
|
531
|
+
const storage = getStorage();
|
|
532
|
+
if (provider) {
|
|
533
|
+
const cache = readCache();
|
|
534
|
+
delete cache[provider];
|
|
535
|
+
writeCache(cache);
|
|
536
|
+
} else {
|
|
537
|
+
try {
|
|
538
|
+
if (storage.exists(CACHE_PATH)) {
|
|
539
|
+
storage.removeFile(CACHE_PATH);
|
|
540
|
+
}
|
|
541
|
+
resetCacheSnapshot();
|
|
542
|
+
} catch (error) {
|
|
543
|
+
console.error("Failed to clear cache:", error);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration constants for the sub-bar extension
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Google Workspace status API endpoint
|
|
7
|
+
*/
|
|
8
|
+
export const GOOGLE_STATUS_URL = "https://www.google.com/appsstatus/dashboard/incidents.json";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Google product ID for Gemini in the status API
|
|
12
|
+
*/
|
|
13
|
+
export const GEMINI_PRODUCT_ID = "npdyhgECDJ6tB66MxXyo";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Model multipliers for Copilot request counting
|
|
17
|
+
* Maps model display names to their request multiplier
|
|
18
|
+
*/
|
|
19
|
+
export { MODEL_MULTIPLIERS } from "@eiei114/pi-sub-shared";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Timeout for API requests in milliseconds
|
|
23
|
+
*/
|
|
24
|
+
export const API_TIMEOUT_MS = 5000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Timeout for CLI commands in milliseconds
|
|
28
|
+
*/
|
|
29
|
+
export const CLI_TIMEOUT_MS = 10000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Interval for automatic usage refresh in milliseconds
|
|
33
|
+
*/
|
|
34
|
+
export const REFRESH_INTERVAL_MS = 60_000;
|
|
35
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default dependencies using real implementations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
|
|
9
|
+
import type { Dependencies } from "./types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create default dependencies using Node.js APIs
|
|
13
|
+
*/
|
|
14
|
+
export function createDefaultDependencies(): Dependencies {
|
|
15
|
+
return {
|
|
16
|
+
fetch: globalThis.fetch,
|
|
17
|
+
readFile: (path: string) => {
|
|
18
|
+
try {
|
|
19
|
+
return fs.readFileSync(path, "utf-8");
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
fileExists: (path: string) => {
|
|
25
|
+
try {
|
|
26
|
+
return fs.existsSync(path);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
execFileSync: (file: string, args: string[], options?: ExecFileSyncOptionsWithStringEncoding) => {
|
|
32
|
+
return execFileSync(file, args, options) as string;
|
|
33
|
+
},
|
|
34
|
+
homedir: () => os.homedir(),
|
|
35
|
+
env: process.env,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error utilities for the sub-bar extension
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { UsageError, UsageErrorCode } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export function createError(code: UsageErrorCode, message: string, httpStatus?: number): UsageError {
|
|
8
|
+
return { code, message, httpStatus };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function noCredentials(): UsageError {
|
|
12
|
+
return createError("NO_CREDENTIALS", "No credentials found");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function noCli(cliName: string): UsageError {
|
|
16
|
+
return createError("NO_CLI", `${cliName} CLI not found`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function notLoggedIn(): UsageError {
|
|
20
|
+
return createError("NOT_LOGGED_IN", "Not logged in");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function fetchFailed(reason?: string): UsageError {
|
|
24
|
+
return createError("FETCH_FAILED", reason ?? "Fetch failed");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function httpError(status: number): UsageError {
|
|
28
|
+
return createError("HTTP_ERROR", `HTTP ${status}`, status);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function apiError(message: string): UsageError {
|
|
32
|
+
return createError("API_ERROR", message);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function timeout(): UsageError {
|
|
36
|
+
return createError("TIMEOUT", "Request timed out");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if an error should be considered "no data available" vs actual error
|
|
41
|
+
* These are expected states when provider isn't configured
|
|
42
|
+
*/
|
|
43
|
+
export function isExpectedMissingData(error: UsageError): boolean {
|
|
44
|
+
const ignoreCodes = new Set<UsageErrorCode>(["NO_CREDENTIALS", "NO_CLI", "NOT_LOGGED_IN"]);
|
|
45
|
+
return ignoreCodes.has(error.code);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Format error for display in the usage widget
|
|
50
|
+
*/
|
|
51
|
+
export function formatErrorForDisplay(error: UsageError): string {
|
|
52
|
+
switch (error.code) {
|
|
53
|
+
case "NO_CREDENTIALS":
|
|
54
|
+
return "No creds";
|
|
55
|
+
case "NO_CLI":
|
|
56
|
+
return "No CLI";
|
|
57
|
+
case "NOT_LOGGED_IN":
|
|
58
|
+
return "Not logged in";
|
|
59
|
+
case "HTTP_ERROR":
|
|
60
|
+
if (error.httpStatus === 401) {
|
|
61
|
+
return "token no longer valid – please /login again";
|
|
62
|
+
}
|
|
63
|
+
return `${error.httpStatus}`;
|
|
64
|
+
case "FETCH_FAILED":
|
|
65
|
+
case "API_ERROR":
|
|
66
|
+
case "TIMEOUT":
|
|
67
|
+
case "UNKNOWN":
|
|
68
|
+
default:
|
|
69
|
+
return "Fetch failed";
|
|
70
|
+
}
|
|
71
|
+
}
|