|
5 | 5 | */ |
6 | 6 |
|
7 | 7 | import crypto from 'node:crypto' |
8 | | -import { |
9 | | - existsSync, |
10 | | - readFileSync, |
11 | | - writeFileSync, |
12 | | - promises as fs, |
13 | | -} from 'node:fs' |
| 8 | +import { existsSync, promises as fs } from 'node:fs' |
14 | 9 | import os from 'node:os' |
15 | 10 | import path from 'node:path' |
16 | 11 | import process from 'node:process' |
@@ -325,37 +320,59 @@ class CostTracker { |
325 | 320 | constructor(model: string = 'claude-sonnet-4-5') { |
326 | 321 | this.model = model |
327 | 322 | this.session = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 } |
328 | | - this.monthly = this.loadMonthlyStats() |
329 | | - this.startTime = Date.now() |
330 | | - } |
331 | | - |
332 | | - loadMonthlyStats(): MonthlyStats { |
333 | | - try { |
334 | | - if (existsSync(STORAGE_PATHS.stats)) { |
335 | | - const data = JSON.parse(readFileSync(STORAGE_PATHS.stats, 'utf8')) |
336 | | - // YYYY-MM |
337 | | - const currentMonth = new Date().toISOString().slice(0, 7) |
338 | | - if (data.month === currentMonth) { |
339 | | - return data |
340 | | - } |
341 | | - } |
342 | | - } catch { |
343 | | - // Ignore errors, start fresh. |
344 | | - } |
345 | | - return { |
| 323 | + // Start with a fresh-month default. The on-disk monthly stats (if |
| 324 | + // any) load asynchronously below and replace this.monthly when |
| 325 | + // they resolve. Known race: if track() fires before the load |
| 326 | + // resolves, it mutates the default baseline and the first save |
| 327 | + // clobbers the prior month's accumulated value. Acceptable here |
| 328 | + // because (a) the Claude CLI session is single-user and typically |
| 329 | + // has a human-paced delay between start and first tracked event, |
| 330 | + // and (b) the persisted quantity is per-session cost accounting — |
| 331 | + // losing at most one update window costs fractions of a cent. If |
| 332 | + // the race ever becomes load-bearing, promote this to a proper |
| 333 | + // read-modify-write on save() with a mutex. |
| 334 | + this.monthly = { |
346 | 335 | month: new Date().toISOString().slice(0, 7), |
347 | 336 | cost: 0, |
348 | 337 | fixes: 0, |
349 | 338 | sessions: 0, |
350 | 339 | } |
| 340 | + this.startTime = Date.now() |
| 341 | + void this.loadMonthlyStats().then(loaded => { |
| 342 | + if (loaded) { |
| 343 | + this.monthly = loaded |
| 344 | + } |
| 345 | + }) |
351 | 346 | } |
352 | 347 |
|
353 | | - saveMonthlyStats(): void { |
| 348 | + async loadMonthlyStats(): Promise<MonthlyStats | undefined> { |
| 349 | + if (!existsSync(STORAGE_PATHS.stats)) { |
| 350 | + return undefined |
| 351 | + } |
354 | 352 | try { |
355 | | - writeFileSync(STORAGE_PATHS.stats, JSON.stringify(this.monthly, null, 2)) |
| 353 | + const text = await fs.readFile(STORAGE_PATHS.stats, 'utf8') |
| 354 | + const data = JSON.parse(text) as MonthlyStats |
| 355 | + // YYYY-MM — only honor disk state if the persisted month matches |
| 356 | + // the current one; otherwise the caller keeps the fresh default. |
| 357 | + const currentMonth = new Date().toISOString().slice(0, 7) |
| 358 | + if (data.month === currentMonth) { |
| 359 | + return data |
| 360 | + } |
356 | 361 | } catch { |
357 | | - // Ignore errors. |
| 362 | + // Unreadable, invalid JSON, etc. — keep the fresh default. |
358 | 363 | } |
| 364 | + return undefined |
| 365 | + } |
| 366 | + |
| 367 | + saveMonthlyStats(): void { |
| 368 | + // Fire-and-forget async write. Best-effort persistence — existing |
| 369 | + // try/catch pattern already swallowed failures, so the semantic |
| 370 | + // doesn't change. Keeps the calling track() path synchronous |
| 371 | + // without pinning fs to a sync primitive. |
| 372 | + fs.writeFile( |
| 373 | + STORAGE_PATHS.stats, |
| 374 | + JSON.stringify(this.monthly, null, 2), |
| 375 | + ).catch(() => {}) |
359 | 376 | } |
360 | 377 |
|
361 | 378 | track(usage: UsageData): void { |
@@ -437,38 +454,54 @@ class ProgressTracker { |
437 | 454 | this.phases = [] |
438 | 455 | this.currentPhase = null |
439 | 456 | this.startTime = Date.now() |
440 | | - this.history = this.loadHistory() |
| 457 | + // Empty history until the on-disk state loads. Same race as in |
| 458 | + // CostTracker: a brief window where ETA estimation sees no prior |
| 459 | + // sessions. In practice history reads faster than the first phase |
| 460 | + // starts, and worst case we emit a "no ETA yet" message for one |
| 461 | + // phase. |
| 462 | + this.history = [] |
| 463 | + void this.loadHistory().then(loaded => { |
| 464 | + if (loaded) { |
| 465 | + this.history = loaded |
| 466 | + } |
| 467 | + }) |
441 | 468 | } |
442 | 469 |
|
443 | | - loadHistory(): Array<{ phases: PhaseRecord[]; timestamp: number }> { |
| 470 | + async loadHistory(): Promise< |
| 471 | + Array<{ phases: PhaseRecord[]; timestamp: number }> | undefined |
| 472 | + > { |
| 473 | + if (!existsSync(STORAGE_PATHS.history)) { |
| 474 | + return undefined |
| 475 | + } |
444 | 476 | try { |
445 | | - if (existsSync(STORAGE_PATHS.history)) { |
446 | | - const data = JSON.parse(readFileSync(STORAGE_PATHS.history, 'utf8')) |
447 | | - // Keep only last 50 sessions. |
448 | | - return data.sessions.slice(-50) |
| 477 | + const text = await fs.readFile(STORAGE_PATHS.history, 'utf8') |
| 478 | + const data = JSON.parse(text) as { |
| 479 | + sessions: Array<{ phases: PhaseRecord[]; timestamp: number }> |
449 | 480 | } |
| 481 | + // Keep only the last 50 sessions — old ones aren't useful for ETA. |
| 482 | + return data.sessions.slice(-50) |
450 | 483 | } catch { |
451 | | - // Ignore errors. |
| 484 | + // Unreadable / invalid JSON — keep empty history. |
452 | 485 | } |
453 | | - return [] |
| 486 | + return undefined |
454 | 487 | } |
455 | 488 |
|
456 | 489 | saveHistory(): void { |
457 | | - try { |
458 | | - const data = { |
459 | | - sessions: [ |
460 | | - ...this.history, |
461 | | - { phases: this.phases, timestamp: Date.now() }, |
462 | | - ], |
463 | | - } |
464 | | - // Keep only last 50 sessions. |
465 | | - if (data.sessions.length > 50) { |
466 | | - data.sessions = data.sessions.slice(-50) |
467 | | - } |
468 | | - writeFileSync(STORAGE_PATHS.history, JSON.stringify(data, null, 2)) |
469 | | - } catch { |
470 | | - // Ignore errors. |
| 490 | + // Fire-and-forget async write. Existing try/catch already ignored |
| 491 | + // failures, so the best-effort semantic is unchanged. |
| 492 | + const data = { |
| 493 | + sessions: [ |
| 494 | + ...this.history, |
| 495 | + { phases: this.phases, timestamp: Date.now() }, |
| 496 | + ], |
| 497 | + } |
| 498 | + // Keep only last 50 sessions. |
| 499 | + if (data.sessions.length > 50) { |
| 500 | + data.sessions = data.sessions.slice(-50) |
471 | 501 | } |
| 502 | + fs.writeFile(STORAGE_PATHS.history, JSON.stringify(data, null, 2)).catch( |
| 503 | + () => {}, |
| 504 | + ) |
472 | 505 | } |
473 | 506 |
|
474 | 507 | startPhase(name: string): void { |
|
0 commit comments