@vibe-cafe/vibe-usage 0.10.19 → 0.10.20

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/src/index.js CHANGED
@@ -2,6 +2,12 @@ import { loadConfig, saveConfig, getConfigPath } from './config.js';
2
2
  import { detectInstalledTools, TOOLS } from './tools.js';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { validateExtraCodexHome } from './codex-roots.js';
5
+ import {
6
+ EXTRA_ROOT_SOURCES,
7
+ extraRootList,
8
+ normalizeExtraRoot,
9
+ validateExtraRoot,
10
+ } from './extra-roots.js';
5
11
  import { failure, smallHeader } from './output.js';
6
12
 
7
13
  function printSmallHeader() {
@@ -24,10 +30,18 @@ async function showStatus() {
24
30
  if (config.codexExtraHome) {
25
31
  console.log(` Extra Codex Home: ${config.codexExtraHome}`);
26
32
  }
33
+ for (const source of EXTRA_ROOT_SOURCES) {
34
+ for (const root of extraRootList(config.extraRoots?.[source])) {
35
+ console.log(` Extra ${source} Root: ${root}`);
36
+ }
37
+ }
27
38
  }
28
39
 
29
40
  console.log('\n Detected tools:');
30
- const toolOptions = { codexExtraHome: config?.codexExtraHome };
41
+ const toolOptions = {
42
+ codexExtraHome: config?.codexExtraHome,
43
+ extraRoots: config?.extraRoots,
44
+ };
31
45
  const detected = detectInstalledTools(toolOptions);
32
46
  if (detected.length === 0) {
33
47
  console.log(' (none)\n');
@@ -103,9 +117,59 @@ function handleConfig(args) {
103
117
  }
104
118
  break;
105
119
  }
120
+ case 'add-root': {
121
+ const source = args[1];
122
+ const value = args[2];
123
+ if (!source || value === undefined) {
124
+ console.error('Usage: vibe-usage config add-root <codex|grok|antigravity> <path>');
125
+ process.exit(1);
126
+ }
127
+ const validation = validateExtraRoot(source, value);
128
+ if (!validation.ok) {
129
+ console.error(failure(`额外 ${source} 根目录无效(${validation.reason}): ${validation.path}`));
130
+ process.exit(1);
131
+ }
132
+ const config = loadConfig() || {};
133
+ if (!config.extraRoots || typeof config.extraRoots !== 'object' || Array.isArray(config.extraRoots)) {
134
+ config.extraRoots = {};
135
+ }
136
+ const roots = extraRootList(config.extraRoots[source]);
137
+ config.extraRoots[source] = [...new Set([...roots, validation.path])];
138
+ saveConfig(config);
139
+ break;
140
+ }
141
+ case 'remove-root': {
142
+ const source = args[1];
143
+ const value = args[2];
144
+ if (!EXTRA_ROOT_SOURCES.includes(source) || value === undefined) {
145
+ console.error('Usage: vibe-usage config remove-root <codex|grok|antigravity> <path>');
146
+ process.exit(1);
147
+ }
148
+ const config = loadConfig() || {};
149
+ const path = normalizeExtraRoot(value);
150
+ const roots = extraRootList(config.extraRoots?.[source])
151
+ .filter(root => normalizeExtraRoot(root) !== path);
152
+ if (config.extraRoots && typeof config.extraRoots === 'object' && !Array.isArray(config.extraRoots)) {
153
+ if (roots.length > 0) config.extraRoots[source] = roots;
154
+ else delete config.extraRoots[source];
155
+ if (Object.keys(config.extraRoots).length === 0) delete config.extraRoots;
156
+ }
157
+ saveConfig(config);
158
+ break;
159
+ }
160
+ case 'roots': {
161
+ const config = loadConfig();
162
+ const roots = config?.extraRoots;
163
+ console.log(JSON.stringify(
164
+ roots && typeof roots === 'object' && !Array.isArray(roots) ? roots : {},
165
+ null,
166
+ 2,
167
+ ));
168
+ break;
169
+ }
106
170
  default:
107
171
  console.error(`Unknown config subcommand: ${sub || '(none)'}`);
108
- console.error('Usage: vibe-usage config <get|set|show>');
172
+ console.error('Usage: vibe-usage config <get|set|show|add-root|remove-root|roots>');
109
173
  process.exit(1);
110
174
  }
111
175
  }
@@ -219,7 +283,7 @@ export async function run(rawArgs) {
219
283
  npx @vibe-cafe/vibe-usage summary Print last 7 days as markdown (cost/tokens/model/project)
220
284
  npx @vibe-cafe/vibe-usage summary --days N Same, but over the last N days (1-90)
221
285
  npx @vibe-cafe/vibe-usage daemon Continuous sync (every 30m, foreground)
222
- npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd)
286
+ npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd/Task Scheduler)
223
287
  npx @vibe-cafe/vibe-usage daemon uninstall Remove background service
224
288
  npx @vibe-cafe/vibe-usage daemon status Show background service status
225
289
  npx @vibe-cafe/vibe-usage daemon stop Stop background service
@@ -233,6 +297,9 @@ export async function run(rawArgs) {
233
297
  npx @vibe-cafe/vibe-usage config get <key> Get a config value
234
298
  npx @vibe-cafe/vibe-usage config set <key> <value> Set a config value
235
299
  npx @vibe-cafe/vibe-usage config set codexExtraHome <path> Persist another Codex Home
300
+ npx @vibe-cafe/vibe-usage config add-root <tool> <path> Add a Codex, Grok, or Antigravity data root
301
+ npx @vibe-cafe/vibe-usage config remove-root <tool> <path> Remove an added data root
302
+ npx @vibe-cafe/vibe-usage config roots Show added data roots as JSON
236
303
  npx @vibe-cafe/vibe-usage help Show this help
237
304
  `);
238
305
  break;
package/src/init.js CHANGED
@@ -27,7 +27,7 @@ function openBrowser(url) {
27
27
  }
28
28
 
29
29
  function isDaemonPlatform() {
30
- return process.platform === 'linux' || process.platform === 'darwin';
30
+ return process.platform === 'linux' || process.platform === 'darwin' || process.platform === 'win32';
31
31
  }
32
32
 
33
33
  export async function runInit(options = {}) {
@@ -81,10 +81,14 @@ export async function runInit(options = {}) {
81
81
  apiUrl,
82
82
  hostname: host,
83
83
  ...(existing?.codexExtraHome ? { codexExtraHome: existing.codexExtraHome } : {}),
84
+ ...(existing?.extraRoots ? { extraRoots: existing.extraRoots } : {}),
84
85
  };
85
86
  saveConfig(config);
86
87
 
87
- const tools = detectInstalledTools({ codexExtraHome: config.codexExtraHome });
88
+ const tools = detectInstalledTools({
89
+ codexExtraHome: config.codexExtraHome,
90
+ extraRoots: config.extraRoots,
91
+ });
88
92
  if (tools.length > 0) {
89
93
  console.log(success(`检测到 ${tools.length} 款工具: ${dim(tools.map(t => t.name).join(' · '))}`));
90
94
  } else {
@@ -200,14 +200,15 @@ function queryCascadeDb(conversationsDir, cascadeId, sql) {
200
200
  }
201
201
 
202
202
  /** List cascade IDs backed by a `.db` file in a conversations directory. */
203
- export function listDbCascades(conversationsDir) {
203
+ export function listDbCascades(conversationsDir, { strict = false } = {}) {
204
204
  try {
205
205
  const out = [];
206
206
  for (const f of readdirSync(conversationsDir)) {
207
207
  if (f.endsWith('.db') && f !== 'db.sqlite') out.push(f.slice(0, -3));
208
208
  }
209
209
  return out;
210
- } catch {
210
+ } catch (err) {
211
+ if (strict) throw err;
211
212
  return [];
212
213
  }
213
214
  }
@@ -217,12 +218,12 @@ export function listDbCascades(conversationsDir) {
217
218
  * records. blob is fetched as hex text so it round-trips through both the
218
219
  * node:sqlite and sqlite3-CLI backends uniformly.
219
220
  */
220
- export function readDbUsageRecords(conversationsDir, cascadeId) {
221
+ export function readDbUsageRecords(conversationsDir, cascadeId, { strict = false } = {}) {
221
222
  let rows;
222
223
  try {
223
224
  rows = queryCascadeDb(conversationsDir, cascadeId, 'SELECT idx, hex(data) AS h FROM gen_metadata ORDER BY idx');
224
225
  } catch (err) {
225
- if (isSqliteUnavailableError(err)) throw err;
226
+ if (isSqliteUnavailableError(err) || strict) throw err;
226
227
  return [];
227
228
  }
228
229
  const records = [];
@@ -247,7 +248,7 @@ export function readDbUsageRecords(conversationsDir, cascadeId) {
247
248
  * system/tool steps that parseStepMetadata skips. Used to timestamp 3.7
248
249
  * gen_metadata rows that no longer embed chatStartMetadata.
249
250
  */
250
- export function readDbStepTimestamps(conversationsDir, cascadeId) {
251
+ export function readDbStepTimestamps(conversationsDir, cascadeId, { strict = false } = {}) {
251
252
  let rows;
252
253
  try {
253
254
  rows = queryCascadeDb(
@@ -256,7 +257,7 @@ export function readDbStepTimestamps(conversationsDir, cascadeId) {
256
257
  'SELECT idx, hex(metadata) AS h FROM steps WHERE metadata IS NOT NULL ORDER BY idx',
257
258
  );
258
259
  } catch (err) {
259
- if (isSqliteUnavailableError(err)) throw err;
260
+ if (isSqliteUnavailableError(err) || strict) throw err;
260
261
  return new Map();
261
262
  }
262
263
  const byIdx = new Map();
@@ -331,7 +332,7 @@ export function parseStepMetadata(buf) {
331
332
  * Read session timing events (user/assistant turns) for a cascade from the
332
333
  * steps table, chronological by idx.
333
334
  */
334
- export function readDbSessionEvents(conversationsDir, cascadeId) {
335
+ export function readDbSessionEvents(conversationsDir, cascadeId, { strict = false } = {}) {
335
336
  let rows;
336
337
  try {
337
338
  rows = queryCascadeDb(
@@ -340,7 +341,7 @@ export function readDbSessionEvents(conversationsDir, cascadeId) {
340
341
  'SELECT hex(metadata) AS h FROM steps WHERE metadata IS NOT NULL ORDER BY idx',
341
342
  );
342
343
  } catch (err) {
343
- if (isSqliteUnavailableError(err)) throw err;
344
+ if (isSqliteUnavailableError(err) || strict) throw err;
344
345
  return [];
345
346
  }
346
347
  const events = [];
@@ -1,7 +1,8 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { readdirSync } from 'node:fs';
3
- import { join } from 'node:path';
2
+ import { readdirSync, statSync } from 'node:fs';
3
+ import { delimiter, join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
+ import { antigravityConversationDirs, normalizeExtraRoot } from '../extra-roots.js';
5
6
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
6
7
  import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEvents, readDbStepTimestamps, resolveUsageTimestamp } from './antigravity-db.js';
7
8
 
@@ -295,10 +296,10 @@ function projectFromUri(uri) {
295
296
  * List cascade IDs backed by a legacy `.pb` file (App history). `.db` cascades
296
297
  * are handled separately via offline parsing.
297
298
  */
298
- function listPbCascades() {
299
+ function listPbCascades(conversationsDir = CONVERSATIONS_DIR) {
299
300
  try {
300
301
  const out = [];
301
- for (const f of readdirSync(CONVERSATIONS_DIR)) {
302
+ for (const f of readdirSync(conversationsDir)) {
302
303
  if (f.endsWith('.pb')) out.push(f.slice(0, -3));
303
304
  }
304
305
  return out;
@@ -316,19 +317,88 @@ function modelFromRecord(rec) {
316
317
  return 'unknown';
317
318
  }
318
319
 
319
- export async function parse() {
320
+ export async function parse({ extraRoots = [] } = {}) {
320
321
  const entries = [];
321
322
  const sessionEvents = [];
322
323
  const seenResponseIds = new Set();
323
324
 
325
+ const extraDirs = [];
326
+ for (const root of extraRoots) {
327
+ const dirs = antigravityConversationDirs(root);
328
+ let found = false;
329
+ for (const dir of dirs) {
330
+ try {
331
+ readdirSync(dir);
332
+ extraDirs.push(dir);
333
+ found = true;
334
+ } catch (err) {
335
+ if (err?.code === 'ENOENT') continue;
336
+ return {
337
+ buckets: [],
338
+ sessions: [],
339
+ skipped: true,
340
+ warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${normalizeExtraRoot(root)}`],
341
+ };
342
+ }
343
+ }
344
+ if (!found) {
345
+ return {
346
+ buckets: [],
347
+ sessions: [],
348
+ skipped: true,
349
+ warnings: [`antigravity: 额外根目录不可用,已跳过本次 Antigravity 同步: ${normalizeExtraRoot(root)}`],
350
+ };
351
+ }
352
+ }
353
+
324
354
  // ── Path 1: offline .db parsing (App 2.0 + agy CLI, no process needed) ──
325
355
  const dbHandled = new Set();
326
- for (const dir of [CONVERSATIONS_DIR, CLI_CONVERSATIONS_DIR]) {
327
- for (const cascadeId of listDbCascades(dir)) {
328
- const records = readDbUsageRecords(dir, cascadeId);
356
+ const fixtureDirs = process.env.VIBE_USAGE_ANTIGRAVITY_DIRS?.trim();
357
+ const defaultDirs = fixtureDirs
358
+ ? fixtureDirs.split(delimiter).filter(Boolean)
359
+ : [CONVERSATIONS_DIR, CLI_CONVERSATIONS_DIR];
360
+ const strictDirs = new Set(extraDirs);
361
+ const conversationDirs = [...new Set([...defaultDirs, ...extraDirs])];
362
+ const candidates = [];
363
+ for (const dir of conversationDirs) {
364
+ const strict = strictDirs.has(dir);
365
+ try {
366
+ for (const cascadeId of listDbCascades(dir, { strict })) {
367
+ candidates.push({ dir, cascadeId, strict });
368
+ }
369
+ } catch {
370
+ return {
371
+ buckets: [], sessions: [], skipped: true,
372
+ warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${dir}`],
373
+ };
374
+ }
375
+ }
376
+ const configuredCascadeIds = new Set(
377
+ candidates.filter(candidate => candidate.strict).map(candidate => candidate.cascadeId),
378
+ );
379
+ const selectedConfiguredCopies = new Map();
380
+ for (const candidate of candidates) {
381
+ if (!configuredCascadeIds.has(candidate.cascadeId)) continue;
382
+ let size = 0;
383
+ try {
384
+ size = statSync(join(candidate.dir, `${candidate.cascadeId}.db`)).size;
385
+ } catch {
386
+ // The DB may move between discovery and stat; the read below will fail
387
+ // open in the existing offline reader.
388
+ }
389
+ const previous = selectedConfiguredCopies.get(candidate.cascadeId);
390
+ if (!previous || size > previous.size) selectedConfiguredCopies.set(candidate.cascadeId, { ...candidate, size });
391
+ }
392
+
393
+ for (const { dir, cascadeId, strict } of candidates) {
394
+ const selected = selectedConfiguredCopies.get(cascadeId);
395
+ if (selected && selected.dir !== dir) continue;
396
+ try {
397
+ const options = { strict };
398
+ const records = readDbUsageRecords(dir, cascadeId, options);
329
399
  const project = projectFromUri(readDbWorkspaceUri(dir, cascadeId)) || 'unknown';
330
400
  const stepTimestampsByIdx = records.some((rec) => !rec.timestamp || isNaN(rec.timestamp.getTime()))
331
- ? readDbStepTimestamps(dir, cascadeId)
401
+ ? readDbStepTimestamps(dir, cascadeId, options)
332
402
  : new Map();
333
403
 
334
404
  if (records.length > 0) {
@@ -355,7 +425,7 @@ export async function parse() {
355
425
  }
356
426
 
357
427
  // Session timing from steps (independent of token usage presence).
358
- for (const ev of readDbSessionEvents(dir, cascadeId)) {
428
+ for (const ev of readDbSessionEvents(dir, cascadeId, options)) {
359
429
  sessionEvents.push({
360
430
  sessionId: cascadeId,
361
431
  source: SOURCE,
@@ -364,11 +434,18 @@ export async function parse() {
364
434
  role: ev.role,
365
435
  });
366
436
  }
437
+ } catch (err) {
438
+ if (!strict) throw err;
439
+ return {
440
+ buckets: [], sessions: [], skipped: true,
441
+ warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${dir}`],
442
+ };
367
443
  }
368
444
  }
369
445
 
370
446
  // ── Path 2: RPC fallback, only for legacy .pb cascades not already parsed ──
371
- const pbCascades = listPbCascades().filter((id) => !dbHandled.has(id));
447
+ const pbDir = defaultDirs[0] || CONVERSATIONS_DIR;
448
+ const pbCascades = listPbCascades(pbDir).filter((id) => !dbHandled.has(id));
372
449
  if (pbCascades.length > 0) {
373
450
  const server = findLanguageServer();
374
451
  const ports = server ? findListeningPorts(server.pid) : [];
@@ -17,6 +17,7 @@ import {
17
17
  resolveCodexHomes,
18
18
  validateExtraCodexHome,
19
19
  } from '../codex-roots.js';
20
+ import { discoverCodexHomes } from '../extra-roots.js';
20
21
  import {
21
22
  codexCacheEnabled,
22
23
  fileSignature,
@@ -62,20 +63,22 @@ function decorateCodexModel(model, serviceTier, timestampMs) {
62
63
  * Recursively find all .jsonl files under a directory.
63
64
  * Codex CLI stores sessions as: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
64
65
  */
65
- function findJsonlFiles(dir) {
66
+ function findJsonlFiles(dir, strict = false) {
66
67
  const results = [];
67
68
  if (!existsSync(dir)) return results;
68
69
  try {
69
70
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
70
71
  const fullPath = join(dir, entry.name);
71
72
  if (entry.isDirectory()) {
72
- for (const nested of findJsonlFiles(fullPath)) results.push(nested);
73
+ for (const nested of findJsonlFiles(fullPath, strict)) results.push(nested);
73
74
  } else if (entry.name.endsWith('.jsonl')) {
74
75
  results.push(fullPath);
75
76
  }
76
77
  }
77
- } catch {
78
- // ignore unreadable directories
78
+ } catch (err) {
79
+ if (strict && err?.code !== 'ENOENT') throw err;
80
+ // Default roots are best-effort; configured roots must never look empty
81
+ // merely because a directory became unreadable between syncs.
79
82
  }
80
83
  return results;
81
84
  }
@@ -837,7 +840,8 @@ function mergeFileResults(results) {
837
840
  return { buckets: aggregateToBuckets(entries), sessions };
838
841
  }
839
842
 
840
- async function parseNativeCodex({ codexExtraHome } = {}) {
843
+ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
844
+ let extraCodexHomePath = null;
841
845
  if (codexExtraHome?.trim()) {
842
846
  const validation = validateExtraCodexHome(codexExtraHome);
843
847
  if (!validation.ok) {
@@ -848,11 +852,31 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
848
852
  warnings: [`codex: 额外 Codex Home 不可用,已跳过本次 Codex 同步: ${validation.path}`],
849
853
  };
850
854
  }
855
+ extraCodexHomePath = validation.path;
851
856
  }
852
857
 
853
- const codexHomes = resolveCodexHomes(codexExtraHome);
858
+ const configuredHomes = [];
859
+ for (const root of extraRoots) {
860
+ const discovered = discoverCodexHomes(root);
861
+ if (!discovered.readable || discovered.homes.length === 0) {
862
+ return {
863
+ buckets: [],
864
+ sessions: [],
865
+ skipped: true,
866
+ warnings: [`codex: 额外根目录不可用,已跳过本次 Codex 同步: ${discovered.root}`],
867
+ };
868
+ }
869
+ configuredHomes.push(...discovered.homes);
870
+ }
871
+
872
+ const strictHomes = new Set(configuredHomes);
873
+ if (extraCodexHomePath) strictHomes.add(extraCodexHomePath);
874
+ const codexHomes = [...new Set([
875
+ ...resolveCodexHomes(codexExtraHome),
876
+ ...configuredHomes,
877
+ ])];
854
878
  const dirs = codexHomes.flatMap(codexHome => (
855
- codexSessionDirs(codexHome).map(dir => ({ codexHome, dir }))
879
+ codexSessionDirs(codexHome).map(dir => ({ codexHome, dir, strict: strictHomes.has(codexHome) }))
856
880
  ));
857
881
  if (!dirs.some(({ dir }) => existsSync(dir))) return { buckets: [], sessions: [] };
858
882
 
@@ -868,8 +892,17 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
868
892
  audited: 0,
869
893
  };
870
894
  const files = [];
871
- for (const { codexHome, dir } of dirs) {
872
- for (const filePath of findJsonlFiles(dir)) {
895
+ for (const { codexHome, dir, strict } of dirs) {
896
+ let filePaths;
897
+ try {
898
+ filePaths = findJsonlFiles(dir, strict);
899
+ } catch {
900
+ return {
901
+ buckets: [], sessions: [], skipped: true,
902
+ warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${codexHome}`],
903
+ };
904
+ }
905
+ for (const filePath of filePaths) {
873
906
  try {
874
907
  const stat = statSync(filePath);
875
908
  if (stat.size <= 0) continue;
@@ -880,6 +913,7 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
880
913
  const file = {
881
914
  codexHome,
882
915
  filePath,
916
+ strict,
883
917
  snapshotSize: stat.size,
884
918
  signature,
885
919
  cache,
@@ -890,9 +924,14 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
890
924
  };
891
925
  if (!cache && priorCache) file.appendTail = tailStateFor(file);
892
926
  files.push(file);
893
- } catch {
927
+ } catch (err) {
894
928
  // The file may move to archived_sessions between discovery and stat.
895
- // Its archived copy will be picked up on the next sync.
929
+ if (strict && err?.code !== 'ENOENT') {
930
+ return {
931
+ buckets: [], sessions: [], skipped: true,
932
+ warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${codexHome}`],
933
+ };
934
+ }
896
935
  }
897
936
  }
898
937
  }
@@ -928,6 +967,12 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
928
967
  cacheStats.filesRead++;
929
968
  updateFileCache(file, { header: file.header });
930
969
  } catch {
970
+ if (file.strict) {
971
+ return {
972
+ buckets: [], sessions: [], skipped: true,
973
+ warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${file.codexHome}`],
974
+ };
975
+ }
931
976
  continue;
932
977
  }
933
978
  }
@@ -981,6 +1026,12 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
981
1026
  cacheStats.filesRead++;
982
1027
  updateFileCache(file, { index: meta });
983
1028
  } catch {
1029
+ if (file.strict) {
1030
+ return {
1031
+ buckets: [], sessions: [], skipped: true,
1032
+ warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${file.codexHome}`],
1033
+ };
1034
+ }
984
1035
  continue;
985
1036
  }
986
1037
  }
@@ -68,7 +68,19 @@ function decodeJwtSub(token) {
68
68
  }
69
69
  }
70
70
 
71
- const FETCH_TIMEOUT_MS = 10_000;
71
+ // Under full sync many parsers hammer disk concurrently; cursor.com's CSV
72
+ // export can still succeed but take >10s. A short timeout caused silent skips.
73
+ const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
74
+ const MAX_FETCH_TIMEOUT_MS = 2_147_483_647;
75
+
76
+ export function resolveCursorFetchTimeout(value) {
77
+ const timeout = Number(value);
78
+ return Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_FETCH_TIMEOUT_MS
79
+ ? timeout
80
+ : DEFAULT_FETCH_TIMEOUT_MS;
81
+ }
82
+
83
+ const FETCH_TIMEOUT_MS = resolveCursorFetchTimeout(process.env.VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS);
72
84
 
73
85
  async function fetchUsageCsv(token) {
74
86
  const url = `${(process.env.CURSOR_WEB_BASE_URL?.trim() || 'https://cursor.com').replace(/\/+$/, '')}/api/dashboard/export-usage-events-csv?strategy=tokens`;
@@ -190,7 +202,14 @@ export async function parse() {
190
202
  // Auth failure → bubble up so user sees they need to re-login in Cursor.
191
203
  // Tell sync.js this was not a successful empty snapshot so it preserves
192
204
  // Cursor's incremental state instead of pruning it as dead history.
193
- if (err && err.skip) return { buckets: [], sessions: [], skipped: true };
205
+ if (err && err.skip) {
206
+ return {
207
+ buckets: [],
208
+ sessions: [],
209
+ skipped: true,
210
+ warnings: [`cursor: ${err.message}`],
211
+ };
212
+ }
194
213
  throw err;
195
214
  }
196
215
  const rows = parseCsv(csv);