diff --git a/README.md b/README.md index a3589c6c..5d70152a 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,12 @@ Devflow: IMPLEMENT/ORCHESTRATED **Everything is composable.** 17 plugins (8 core + 9 language/ecosystem). Install only what you need. Six commands cover the entire development lifecycle. -**HUD.** A persistent status line updates on every prompt — project, branch, diff stats, context usage, model, session duration, cost, and configuration counts at a glance. +**HUD.** A persistent status line updates on every prompt — project, branch, diff stats, context usage, model, cost with weekly/monthly totals, quota reset timers, and configuration counts at a glance. ``` -devflow · feat/auth-middleware* · 3↑ · v1.8.3 +5 · 12 files · +234 -56 -Current Session ████░░░░ 42% · Session 5h ██░░░░░░ 18% · 7d █░░░░░░░ 8% -Opus 4.6 [1m] · 23m · $1.24 · 2 CLAUDE.md · 4 MCPs · 8 hooks · 41 skills +~/devflow · main · +2 -1 · v2.0.0+3 +Context ████░░░░ 42% · 5h ████░░░░ 45% (2h 15m) · 7d ████████ 70% (3d 12h) +Opus 4.6 (1M) · 3 MCPs 2 rules · $1.42 · $18.50/wk · $62.30/mo ``` **Security.** Deny lists block dangerous tool patterns out of the box — configurable during init. diff --git a/src/cli/hud/components/context-usage.ts b/src/cli/hud/components/context-usage.ts index 479e7de5..c860f701 100644 --- a/src/cli/hud/components/context-usage.ts +++ b/src/cli/hud/components/context-usage.ts @@ -44,9 +44,9 @@ export default async function contextUsage( suffix = ` (in: ${inK}k)`; } - const raw = `Current Session ${filledBar}${emptyBar} ${pct}%${suffix}`; + const raw = `Context ${filledBar}${emptyBar} ${pct}%${suffix}`; const text = - dim('Current Session ') + + dim('Context ') + colorFn(filledBar) + dim(emptyBar) + ' ' + diff --git a/src/cli/hud/components/session-cost.ts b/src/cli/hud/components/session-cost.ts index 16cff050..8c0f7e97 100644 --- a/src/cli/hud/components/session-cost.ts +++ b/src/cli/hud/components/session-cost.ts @@ -1,11 +1,23 @@ import type { ComponentResult, GatherContext } from '../types.js'; import { dim } from '../colors.js'; +/** Session cost with optional rolling 7-day (/wk) and 30-day (/mo) totals. */ export default async function sessionCost( ctx: GatherContext, ): Promise { const cost = ctx.stdin.cost?.total_cost_usd; if (cost == null) return null; - const formatted = `$${cost.toFixed(2)}`; - return { text: dim(formatted), raw: formatted }; + + const parts: string[] = [`$${cost.toFixed(2)}`]; + + if (ctx.costHistory?.weeklyCost != null && ctx.costHistory.weeklyCost > 0) { + parts.push(`$${ctx.costHistory.weeklyCost.toFixed(2)}/wk`); + } + + if (ctx.costHistory?.monthlyCost != null && ctx.costHistory.monthlyCost > 0) { + parts.push(`$${ctx.costHistory.monthlyCost.toFixed(2)}/mo`); + } + + const raw = parts.join(' \u00B7 '); + return { text: dim(raw), raw }; } diff --git a/src/cli/hud/components/usage-quota.ts b/src/cli/hud/components/usage-quota.ts index 8c634ed9..5fcef9df 100644 --- a/src/cli/hud/components/usage-quota.ts +++ b/src/cli/hud/components/usage-quota.ts @@ -27,27 +27,72 @@ function renderBar(percent: number): { text: string; raw: string } { return { text, raw }; } +/** + * Format seconds remaining until a reset timestamp into compact form. + * Returns '' if the timestamp is in the past or not provided. + * Format: '2h 15m', '3d 12h', '45m' + */ +export function formatCountdown(resetsAtEpoch: number): string { + const nowMs = Date.now(); + const resetsAtMs = resetsAtEpoch * 1000; + const remainingMs = resetsAtMs - nowMs; + + if (remainingMs <= 0) return ''; + + const totalSeconds = Math.floor(remainingMs / 1000); + const totalMinutes = Math.floor(totalSeconds / 60); + const totalHours = Math.floor(totalMinutes / 60); + const days = Math.floor(totalHours / 24); + + if (days > 0) { + const hours = totalHours % 24; + return hours > 0 ? `${days}d ${hours}h` : `${days}d`; + } + + if (totalHours > 0) { + const minutes = totalMinutes % 60; + return minutes > 0 ? `${totalHours}h ${minutes}m` : `${totalHours}h`; + } + + return `${totalMinutes}m`; +} + +/** Render a single quota window: "5h ████░░░░ 45% (2h 15m)" */ +function renderQuotaWindow( + label: string, + percent: number, + resetsAt: number | null, +): { text: string; raw: string } { + const bar = renderBar(Math.round(percent)); + const countdown = resetsAt != null ? formatCountdown(resetsAt) : ''; + const countdownText = countdown ? dim(` (${countdown})`) : ''; + const countdownRaw = countdown ? ` (${countdown})` : ''; + return { + text: dim(label + ' ') + bar.text + countdownText, + raw: `${label} ${bar.raw}${countdownRaw}`, + }; +} + export default async function usageQuota( ctx: GatherContext, ): Promise { if (!ctx.usage) return null; - const { fiveHourPercent, sevenDayPercent } = ctx.usage; + const { fiveHourPercent, sevenDayPercent, fiveHourResetsAt, sevenDayResetsAt } = ctx.usage; const parts: { text: string; raw: string }[] = []; if (fiveHourPercent !== null) { - const bar = renderBar(Math.round(fiveHourPercent)); - parts.push({ text: dim('5h ') + bar.text, raw: `5h ${bar.raw}` }); + parts.push(renderQuotaWindow('5h', fiveHourPercent, fiveHourResetsAt)); } if (sevenDayPercent !== null) { - const bar = renderBar(Math.round(sevenDayPercent)); - parts.push({ text: dim('7d ') + bar.text, raw: `7d ${bar.raw}` }); + parts.push(renderQuotaWindow('7d', sevenDayPercent, sevenDayResetsAt)); } if (parts.length === 0) return null; const sep = dim(' \u00B7 '); - const text = dim('Session ') + parts.map((p) => p.text).join(sep); - const raw = 'Session ' + parts.map((p) => p.raw).join(' \u00B7 '); - return { text, raw }; + return { + text: parts.map((p) => p.text).join(sep), + raw: parts.map((p) => p.raw).join(' \u00B7 '), + }; } diff --git a/src/cli/hud/config.ts b/src/cli/hud/config.ts index c63d61ce..5fbd76c1 100644 --- a/src/cli/hud/config.ts +++ b/src/cli/hud/config.ts @@ -4,7 +4,9 @@ import { homedir } from 'node:os'; import type { HudConfig, ComponentId } from './types.js'; /** - * All 16 HUD components in display order. + * Default HUD components in display order. + * sessionDuration is intentionally omitted — the component is retained + * in the type system and render map but excluded from display by default. */ export const HUD_COMPONENTS: readonly ComponentId[] = [ 'directory', @@ -16,7 +18,6 @@ export const HUD_COMPONENTS: readonly ComponentId[] = [ 'model', 'contextUsage', 'versionBadge', - 'sessionDuration', 'sessionCost', 'usageQuota', 'todoProgress', diff --git a/src/cli/hud/cost-history.ts b/src/cli/hud/cost-history.ts new file mode 100644 index 00000000..8f8dfe78 --- /dev/null +++ b/src/cli/hud/cost-history.ts @@ -0,0 +1,310 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; +import type { CostAggregation } from './types.js'; + +const SECONDS_1_HOUR = 3600; +const SECONDS_PER_DAY = 86400; +const SECONDS_7_DAYS = 7 * SECONDS_PER_DAY; +const SECONDS_30_DAYS = 30 * SECONDS_PER_DAY; +const SECONDS_90_DAYS = 90 * SECONDS_PER_DAY; +const ARCHIVE_TRIM_THRESHOLD = 500; +const CACHE_TTL_MS = 30_000; + +interface SessionEntry { + session_id: string; + cost_usd: number; + timestamp: number; + cwd: string; +} + +/** Returns the current time as a Unix epoch in whole seconds. */ +function nowEpoch(): number { + return Math.floor(Date.now() / 1000); +} + +function isSessionEntry(value: unknown): value is SessionEntry { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + return ( + typeof obj.session_id === 'string' && + typeof obj.cost_usd === 'number' && + typeof obj.timestamp === 'number' && + typeof obj.cwd === 'string' + ); +} + +let sessionsDirCreated = false; +let cachedAggregation: { value: CostAggregation | null; expiresAt: number } | null = null; + +/** + * Returns the paths used for cost storage. + * Respects DEVFLOW_DIR env for testability. + */ +export function getCostFilePaths(): { sessionsDir: string; archivePath: string } { + const devflowDir = + process.env.DEVFLOW_DIR || + path.join(process.env.HOME || homedir(), '.devflow'); + const sessionsDir = path.join(devflowDir, 'costs', 'sessions'); + const archivePath = path.join(devflowDir, 'costs', 'archive.jsonl'); + return { sessionsDir, archivePath }; +} + +/** + * Persist the current session cost atomically. + * Fire-and-forget — all errors are swallowed to avoid blocking HUD render. + * Periodic cleanup runs every ~50 seconds (when timestamp % 50 === 0). + */ +export function persistSessionCost( + sessionId: string, + costUsd: number, + cwd: string, +): void { + if (costUsd <= 0 || !Number.isFinite(costUsd)) return; + // Sanitize sessionId to prevent path traversal (defense-in-depth) + if (!sessionId || /[/\\]/.test(sessionId)) return; + + try { + const { sessionsDir } = getCostFilePaths(); + if (!sessionsDirCreated) { + fs.mkdirSync(sessionsDir, { recursive: true }); + sessionsDirCreated = true; + } + + const entry: SessionEntry = { + session_id: sessionId, + cost_usd: costUsd, + timestamp: nowEpoch(), + cwd, + }; + + const filePath = path.join(sessionsDir, `${sessionId}.json`); + const tmpPath = `${filePath}.tmp`; + const data = JSON.stringify(entry); + + // Atomic write: write to .tmp then rename + try { + fs.writeFileSync(tmpPath, data, { encoding: 'utf-8', flag: 'wx' }); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') { + // Stale .tmp from prior crash — unlink and retry once + try { fs.unlinkSync(tmpPath); } catch { /* race — already removed */ } + fs.writeFileSync(tmpPath, data, { encoding: 'utf-8', flag: 'wx' }); + } else { + throw err; + } + } + fs.renameSync(tmpPath, filePath); + + // Periodic cleanup: run every ~50 seconds + if (entry.timestamp % 50 === 0) { + runCleanup(); + } + } catch { + // Non-fatal: HUD must not block on cost persistence errors + } +} + +/** Remove orphaned .tmp files in sessionsDir that are older than 1 hour. */ +function cleanOrphanedTmpFiles(sessionsDir: string, nowSeconds: number, files: string[]): void { + for (const filename of files) { + if (!filename.endsWith('.tmp')) continue; + const filePath = path.join(sessionsDir, filename); + try { + const stat = fs.statSync(filePath); + const ageSeconds = nowSeconds - Math.floor(stat.mtimeMs / 1000); + if (ageSeconds > SECONDS_1_HOUR) { + fs.unlinkSync(filePath); + } + } catch { /* non-fatal */ } + } +} + +/** Archive session JSON files older than 24 hours into the archive JSONL. */ +function archiveStaleSessionFiles( + sessionsDir: string, + archivePath: string, + nowSeconds: number, + files: string[], +): void { + for (const filename of files) { + if (!filename.endsWith('.json')) continue; + const filePath = path.join(sessionsDir, filename); + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed: unknown = JSON.parse(raw); + if (!isSessionEntry(parsed)) continue; + const age = nowSeconds - parsed.timestamp; + if (age > SECONDS_PER_DAY) { + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + fs.appendFileSync(archivePath, JSON.stringify(parsed) + '\n'); + fs.unlinkSync(filePath); + } + } catch { /* non-fatal: skip malformed files */ } + } +} + +/** + * Clean up stale session files, orphaned .tmp files, and trim archive. + * Called periodically from persistSessionCost. + */ +export function runCleanup(): void { + try { + const { sessionsDir, archivePath } = getCostFilePaths(); + const nowSeconds = nowEpoch(); + + let sessionFiles: string[]; + try { + sessionFiles = fs.readdirSync(sessionsDir); + } catch { + return; + } + + cleanOrphanedTmpFiles(sessionsDir, nowSeconds, sessionFiles); + archiveStaleSessionFiles(sessionsDir, archivePath, nowSeconds, sessionFiles); + + // Trim archive.jsonl when it exceeds threshold + trimArchive(archivePath, nowSeconds); + } catch { /* non-fatal */ } +} + +function trimArchive(archivePath: string, nowSeconds: number): void { + try { + const raw = fs.readFileSync(archivePath, 'utf-8'); + const lines = raw.split('\n').filter((l) => l.trim().length > 0); + if (lines.length <= ARCHIVE_TRIM_THRESHOLD) return; + + // Remove entries older than 90 days + const cutoff = nowSeconds - SECONDS_90_DAYS; + const retained = lines.filter((line) => { + try { + const parsed: unknown = JSON.parse(line); + if (!isSessionEntry(parsed)) return false; + return parsed.timestamp >= cutoff; + } catch { + return false; + } + }); + + fs.writeFileSync(archivePath, retained.join('\n') + '\n', 'utf-8'); + } catch { /* non-fatal */ } +} + +/** Insert or replace entry in map, keeping the one with the higher cost_usd. */ +function upsertMax(map: Map, entry: SessionEntry): void { + const existing = map.get(entry.session_id); + if (!existing || entry.cost_usd > existing.cost_usd) { + map.set(entry.session_id, entry); + } +} + +/** Read all valid SessionEntry objects from the sessions directory. */ +function readSessionEntries(sessionsDir: string): Map { + const map = new Map(); + try { + const files = fs.readdirSync(sessionsDir); + for (const filename of files) { + if (!filename.endsWith('.json')) continue; + try { + const raw = fs.readFileSync(path.join(sessionsDir, filename), 'utf-8'); + const parsed: unknown = JSON.parse(raw); + if (isSessionEntry(parsed)) { + upsertMax(map, parsed); + } + } catch { /* skip malformed */ } + } + } catch { /* sessions dir may not exist yet */ } + return map; +} + +/** Read all valid SessionEntry objects from the archive JSONL file. */ +function readArchiveEntries(archivePath: string): SessionEntry[] { + const entries: SessionEntry[] = []; + try { + const raw = fs.readFileSync(archivePath, 'utf-8'); + const lines = raw.split('\n').filter((l) => l.trim().length > 0); + for (const line of lines) { + try { + const parsed: unknown = JSON.parse(line); + if (isSessionEntry(parsed)) { + entries.push(parsed); + } + } catch { /* skip malformed JSONL lines */ } + } + } catch { /* archive may not exist yet */ } + return entries; +} + +/** + * Aggregate costs from all session files and archive. + * The currentSessionId + currentCostUsd parameters are authoritative + * (from stdin) and override any file-based entry for the same session. + * Results are cached for CACHE_TTL_MS to avoid repeated filesystem reads. + */ +export function aggregateCosts( + currentSessionId: string, + currentCostUsd: number, +): CostAggregation | null { + // Return cached result if still valid + if (cachedAggregation !== null && Date.now() < cachedAggregation.expiresAt) { + return cachedAggregation.value; + } + + try { + const { sessionsDir, archivePath } = getCostFilePaths(); + + const sessionMap = readSessionEntries(sessionsDir); + + for (const entry of readArchiveEntries(archivePath)) { + upsertMax(sessionMap, entry); + } + + // Override/add current session from stdin (authoritative), only when cost > 0 + if (currentCostUsd > 0) { + const currentEntry: SessionEntry = { + session_id: currentSessionId, + cost_usd: currentCostUsd, + timestamp: nowEpoch(), + cwd: '', + }; + const existingCurrent = sessionMap.get(currentSessionId); + if (!existingCurrent || currentCostUsd >= existingCurrent.cost_usd) { + sessionMap.set(currentSessionId, currentEntry); + } + } + + if (sessionMap.size === 0) { + cachedAggregation = { value: null, expiresAt: Date.now() + CACHE_TTL_MS }; + return null; + } + + const nowSeconds = nowEpoch(); + const cutoff7d = nowSeconds - SECONDS_7_DAYS; + const cutoff30d = nowSeconds - SECONDS_30_DAYS; + + let weeklyCost = 0; + let monthlyCost = 0; + let hasWeekly = false; + let hasMonthly = false; + + for (const entry of sessionMap.values()) { + if (entry.timestamp >= cutoff30d) { + monthlyCost += entry.cost_usd; + hasMonthly = true; + } + if (entry.timestamp >= cutoff7d) { + weeklyCost += entry.cost_usd; + hasWeekly = true; + } + } + + const result: CostAggregation = { + weeklyCost: hasWeekly ? weeklyCost : null, + monthlyCost: hasMonthly ? monthlyCost : null, + }; + cachedAggregation = { value: result, expiresAt: Date.now() + CACHE_TTL_MS }; + return result; + } catch { + return null; + } +} diff --git a/src/cli/hud/credentials.ts b/src/cli/hud/credentials.ts deleted file mode 100644 index 2545f660..00000000 --- a/src/cli/hud/credentials.ts +++ /dev/null @@ -1,115 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { homedir } from 'node:os'; -import { execFile } from 'node:child_process'; - -const KEYCHAIN_TIMEOUT = 3000; // 3s - -export interface OAuthCredentials { - accessToken: string; - subscriptionType?: string; -} - -const DEBUG = !!process.env.DEVFLOW_HUD_DEBUG; - -function debugLog(msg: string, data?: Record): void { - if (!DEBUG) return; - const entry = { ts: new Date().toISOString(), source: 'credentials', msg, ...data }; - fs.appendFileSync('/tmp/hud-debug.log', JSON.stringify(entry) + '\n'); -} - -/** Resolve the Claude config directory, respecting CLAUDE_CONFIG_DIR. */ -export function getClaudeDir(): string { - return ( - process.env.CLAUDE_CONFIG_DIR || - path.join(process.env.HOME || homedir(), '.claude') - ); -} - -/** Read OAuth credentials from ~/.claude/.credentials.json. Injectable claudeDir for tests. */ -export function readCredentialsFile(claudeDir?: string): OAuthCredentials | null { - try { - const dir = claudeDir ?? getClaudeDir(); - const filePath = path.join(dir, '.credentials.json'); - const raw = fs.readFileSync(filePath, 'utf-8'); - const creds = JSON.parse(raw) as Record; - const oauth = creds.claudeAiOauth as Record | undefined; - const accessToken = oauth?.accessToken; - if (typeof accessToken !== 'string' || !accessToken) return null; - const subscriptionType = - typeof oauth?.subscriptionType === 'string' ? oauth.subscriptionType : undefined; - debugLog('credentials file read', { filePath, hasSubscriptionType: !!subscriptionType }); - return { accessToken, subscriptionType }; - } catch { - return null; - } -} - -/** Read OAuth token from macOS Keychain. Returns null on non-darwin or failure. */ -export function readKeychainToken(): Promise { - if (process.platform !== 'darwin') return Promise.resolve(null); - - return new Promise((resolve) => { - execFile( - '/usr/bin/security', - ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], - { timeout: KEYCHAIN_TIMEOUT }, - (err, stdout) => { - if (err || !stdout.trim()) { - debugLog('keychain read failed', { error: err?.message }); - resolve(null); - return; - } - try { - const parsed = JSON.parse(stdout.trim()) as Record; - const oauth = parsed.claudeAiOauth as Record | undefined; - const token = oauth?.accessToken; - if (typeof token === 'string' && token) { - debugLog('keychain token found'); - resolve(token); - } else { - debugLog('keychain: no accessToken in parsed data'); - resolve(null); - } - } catch { - // Keychain value might be the raw token string - const trimmed = stdout.trim(); - if (trimmed.length > 20) { - debugLog('keychain: raw token string'); - resolve(trimmed); - } else { - debugLog('keychain: unparseable value'); - resolve(null); - } - } - }, - ); - }); -} - -/** - * Get OAuth credentials using platform-appropriate strategy. - * macOS: Keychain first, then file fallback. Other platforms: file only. - * Hybrid: if Keychain has token but no subscriptionType, merge from file. - */ -export async function getCredentials(): Promise { - const fileCreds = readCredentialsFile(); - - if (process.platform !== 'darwin') { - debugLog('non-darwin: file credentials only', { found: !!fileCreds }); - return fileCreds; - } - - // macOS: try Keychain first - const keychainToken = await readKeychainToken(); - if (keychainToken) { - // Merge subscriptionType from file if Keychain doesn't have it - const subscriptionType = fileCreds?.subscriptionType; - debugLog('using keychain token', { hasSubscriptionType: !!subscriptionType }); - return { accessToken: keychainToken, subscriptionType }; - } - - // Fallback to file - debugLog('keychain failed, falling back to file', { found: !!fileCreds }); - return fileCreds; -} diff --git a/src/cli/hud/index.ts b/src/cli/hud/index.ts index 809d6153..e692147a 100644 --- a/src/cli/hud/index.ts +++ b/src/cli/hud/index.ts @@ -5,15 +5,48 @@ import { readStdin } from './stdin.js'; import { loadConfig, resolveComponents } from './config.js'; import { gatherGitStatus } from './git.js'; import { parseTranscript } from './transcript.js'; -import { fetchUsageData } from './usage-api.js'; +import { persistSessionCost, aggregateCosts } from './cost-history.js'; import { gatherConfigCounts } from './components/config-counts.js'; import { getLearningCounts } from './learning-counts.js'; import { getActiveNotification } from './notifications.js'; import { render } from './render.js'; -import type { GatherContext } from './types.js'; +import type { GatherContext, StdinData, UsageData } from './types.js'; const OVERALL_TIMEOUT = 2000; // 2 second overall timeout +/** + * Extract usage quota data from stdin rate_limits field. + * Returns null if rate_limits is absent. + * Percentages are clamped to 0-100. + */ +function extractUsageFromStdin(stdin: StdinData): UsageData | null { + if (!stdin.rate_limits) return null; + + const fiveHour = stdin.rate_limits.five_hour; + const sevenDay = stdin.rate_limits.seven_day; + + const fiveHourPercent = + typeof fiveHour?.used_percentage === 'number' + ? Math.max(0, Math.min(100, fiveHour.used_percentage)) + : null; + const sevenDayPercent = + typeof sevenDay?.used_percentage === 'number' + ? Math.max(0, Math.min(100, sevenDay.used_percentage)) + : null; + + const fiveHourResetsAt = + typeof fiveHour?.resets_at === 'number' ? fiveHour.resets_at : null; + const sevenDayResetsAt = + typeof sevenDay?.resets_at === 'number' ? sevenDay.resets_at : null; + + return { + fiveHourPercent, + sevenDayPercent, + fiveHourResetsAt, + sevenDayResetsAt, + }; +} + async function main(): Promise { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), OVERALL_TIMEOUT), @@ -53,20 +86,22 @@ async function run(): Promise { const needsTranscript = components.has('todoProgress') || components.has('configCounts'); - const needsUsage = components.has('usageQuota'); const needsConfigCounts = components.has('configCounts'); const needsLearningCounts = components.has('learningCounts'); const needsNotifications = components.has('notifications'); + const needsSessionCost = components.has('sessionCost'); // Parallel data gathering — only fetch what's needed - const [git, transcript, usage] = await Promise.all([ + const [git, transcript] = await Promise.all([ needsGit ? gatherGitStatus(cwd) : Promise.resolve(null), needsTranscript && stdin.transcript_path ? parseTranscript(stdin.transcript_path) : Promise.resolve(null), - needsUsage ? fetchUsageData() : Promise.resolve(null), ]); + // Extract usage quota from stdin rate_limits (replaces OAuth fetch) + const usage = components.has('usageQuota') ? extractUsageFromStdin(stdin) : null; + // Session start time from transcript file creation time let sessionStartTime: number | null = null; if (stdin.transcript_path) { @@ -91,6 +126,18 @@ async function run(): Promise { ? getActiveNotification(cwd) : null; + // Cost tracking: persist current session cost, aggregate for weekly/monthly + const sessionId = stdin.session_id; + const costUsd = stdin.cost?.total_cost_usd ?? 0; + + if (needsSessionCost && sessionId && costUsd) { + persistSessionCost(sessionId, costUsd, cwd); + } + + const costHistory = needsSessionCost && sessionId + ? aggregateCosts(sessionId, costUsd) + : null; + // Terminal width via stderr (stdout is piped to Claude Code) const terminalWidth = process.stderr.columns || 120; @@ -102,6 +149,7 @@ async function run(): Promise { configCounts: configCountsData, learningCounts: learningCountsData, notifications: notificationsData, + costHistory, config: { ...config, components: resolved } as GatherContext['config'], devflowDir, sessionStartTime, diff --git a/src/cli/hud/render.ts b/src/cli/hud/render.ts index 38b0fedf..4af57860 100644 --- a/src/cli/hud/render.ts +++ b/src/cli/hud/render.ts @@ -51,7 +51,7 @@ const LINE_GROUPS: (ComponentId[] | null)[] = [ // Section 1: Info (3 lines) ['directory', 'gitBranch', 'gitAheadBehind', 'releaseInfo', 'worktreeCount', 'diffStats'], ['contextUsage', 'usageQuota'], - ['model', 'sessionDuration', 'sessionCost', 'configCounts'], + ['model', 'configCounts', 'sessionCost'], // --- section break --- null, // Section 2: Activity diff --git a/src/cli/hud/types.ts b/src/cli/hud/types.ts index 77737ed9..23333fd8 100644 --- a/src/cli/hud/types.ts +++ b/src/cli/hud/types.ts @@ -12,10 +12,14 @@ export interface StdinData { cost?: { total_cost_usd?: number }; session_id?: string; transcript_path?: string; + rate_limits?: { + five_hour?: { used_percentage?: number; /** Epoch seconds */ resets_at?: number }; + seven_day?: { used_percentage?: number; /** Epoch seconds */ resets_at?: number }; + }; } /** - * Component IDs — the 16 HUD components. + * Component IDs — all HUD component identifiers. Not all may be enabled by default (see HUD_COMPONENTS in config.ts). */ export type ComponentId = | 'directory' @@ -84,11 +88,21 @@ export interface TranscriptData { } /** - * Usage API data. + * Usage quota data extracted from stdin rate_limits. */ export interface UsageData { fiveHourPercent: number | null; sevenDayPercent: number | null; + fiveHourResetsAt: number | null; + sevenDayResetsAt: number | null; +} + +/** + * Aggregated cost across sessions for weekly/monthly tracking. + */ +export interface CostAggregation { + weeklyCost: number | null; + monthlyCost: number | null; } /** @@ -137,6 +151,7 @@ export interface GatherContext { configCounts: ConfigCountsData | null; learningCounts: LearningCountsData | null; notifications?: NotificationData | null; + costHistory: CostAggregation | null; config: HudConfig & { components: ComponentId[] }; devflowDir: string; sessionStartTime: number | null; diff --git a/src/cli/hud/usage-api.ts b/src/cli/hud/usage-api.ts deleted file mode 100644 index fbfa88aa..00000000 --- a/src/cli/hud/usage-api.ts +++ /dev/null @@ -1,110 +0,0 @@ -import * as fs from 'node:fs'; -import { readCache, writeCache, readCacheStale } from './cache.js'; -import { getCredentials } from './credentials.js'; -import type { UsageData } from './types.js'; - -const USAGE_CACHE_KEY = 'usage'; -const USAGE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes -const USAGE_FAIL_TTL = 15 * 1000; // 15 seconds -const API_TIMEOUT = 1_500; // Must fit within 2s overall HUD timeout -const BACKOFF_CACHE_KEY = 'usage-backoff'; - -interface BackoffState { - retryAfter: number; - delay: number; -} - -const DEBUG = !!process.env.DEVFLOW_HUD_DEBUG; - -function debugLog(msg: string, data?: Record): void { - if (!DEBUG) return; - const entry = { ts: new Date().toISOString(), source: 'usage-api', msg, ...data }; - fs.appendFileSync('/tmp/hud-debug.log', JSON.stringify(entry) + '\n'); -} - -/** - * Fetch usage quota data from the Anthropic API. - * Uses caching with backoff for rate limiting. Returns null on failure. - */ -export async function fetchUsageData(): Promise { - // Check backoff - const backoff = readCache(BACKOFF_CACHE_KEY); - if (backoff && Date.now() < backoff.retryAfter) { - debugLog('skipped: backoff active', { retryAfter: backoff.retryAfter }); - return readCacheStale(USAGE_CACHE_KEY); - } - - // Check cache - const cached = readCache(USAGE_CACHE_KEY); - if (cached) return cached; - - const creds = await getCredentials(); - if (!creds) { - debugLog('no OAuth credentials found'); - return null; - } - const token = creds.accessToken; - - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), API_TIMEOUT); - - debugLog('fetching usage', { timeout: API_TIMEOUT }); - - const response = await fetch('https://api.anthropic.com/api/oauth/usage', { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'anthropic-beta': 'oauth-2025-04-20', - }, - signal: controller.signal, - }); - - clearTimeout(timeout); - - if (response.status === 429) { - const retryAfter = parseInt( - response.headers.get('Retry-After') || '60', - 10, - ); - const delay = Math.min(retryAfter * 1000, 5 * 60 * 1000); - writeCache( - BACKOFF_CACHE_KEY, - { retryAfter: Date.now() + delay, delay }, - delay, - ); - debugLog('rate limited (429)', { retryAfter, delay }); - return readCacheStale(USAGE_CACHE_KEY); - } - - if (!response.ok) { - debugLog('non-200 response', { status: response.status, statusText: response.statusText }); - writeCache(USAGE_CACHE_KEY, null, USAGE_FAIL_TTL); - return readCacheStale(USAGE_CACHE_KEY); - } - - const body = (await response.json()) as Record; - const fiveHour = body.five_hour as Record | undefined; - const sevenDay = body.seven_day as Record | undefined; - - const data: UsageData = { - fiveHourPercent: - typeof fiveHour?.utilization === 'number' - ? Math.round(Math.max(0, Math.min(100, fiveHour.utilization))) - : null, - sevenDayPercent: - typeof sevenDay?.utilization === 'number' - ? Math.round(Math.max(0, Math.min(100, sevenDay.utilization))) - : null, - }; - - debugLog('usage fetched', { fiveHour: data.fiveHourPercent, sevenDay: data.sevenDayPercent }); - writeCache(USAGE_CACHE_KEY, data, USAGE_CACHE_TTL); - return data; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - debugLog('fetch failed', { error: message }); - writeCache(USAGE_CACHE_KEY, null, USAGE_FAIL_TTL); - return readCacheStale(USAGE_CACHE_KEY); - } -} diff --git a/tests/cost-history.test.ts b/tests/cost-history.test.ts new file mode 100644 index 00000000..98d339da --- /dev/null +++ b/tests/cost-history.test.ts @@ -0,0 +1,519 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +// Must set DEVFLOW_DIR before importing to control getCostFilePaths. +// cost-history.ts has module-level singletons (sessionsDirCreated, cachedAggregation) +// that must be reset between tests. vi.resetModules() clears the module cache so each +// dynamic import gets a fresh module instance with those singletons reset to initial values. + +let tmpDir: string; + +function setDevflowDir(dir: string): void { + process.env.DEVFLOW_DIR = dir; +} + +function getSessionsDir(): string { + return path.join(tmpDir, 'costs', 'sessions'); +} + +function getArchivePath(): string { + return path.join(tmpDir, 'costs', 'archive.jsonl'); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cost-history-test-')); + setDevflowDir(tmpDir); + // Reset module cache so module-level singletons (sessionsDirCreated, cachedAggregation) + // are re-initialized on the next dynamic import. + vi.resetModules(); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete process.env.DEVFLOW_DIR; +}); + +// Dynamic imports so DEVFLOW_DIR is respected +async function importCostHistory() { + // Dynamic import — getCostFilePaths reads DEVFLOW_DIR at call time + const mod = await import('../src/cli/hud/cost-history.js'); + return mod; +} + +describe('getCostFilePaths', () => { + it('uses DEVFLOW_DIR env var', async () => { + const { getCostFilePaths } = await importCostHistory(); + const { sessionsDir, archivePath } = getCostFilePaths(); + expect(sessionsDir).toBe(path.join(tmpDir, 'costs', 'sessions')); + expect(archivePath).toBe(path.join(tmpDir, 'costs', 'archive.jsonl')); + }); +}); + +describe('persistSessionCost', () => { + it('creates sessions/ directory on first write', async () => { + const { persistSessionCost } = await importCostHistory(); + expect(fs.existsSync(getSessionsDir())).toBe(false); + persistSessionCost('session-1', 1.42, '/test/cwd'); + expect(fs.existsSync(getSessionsDir())).toBe(true); + }); + + it('writes sessions/{session_id}.json with correct format', async () => { + const { persistSessionCost } = await importCostHistory(); + persistSessionCost('session-abc', 2.50, '/my/project'); + + const filePath = path.join(getSessionsDir(), 'session-abc.json'); + expect(fs.existsSync(filePath)).toBe(true); + const raw = fs.readFileSync(filePath, 'utf-8'); + const entry = JSON.parse(raw) as { session_id: string; cost_usd: number; timestamp: number; cwd: string }; + expect(entry.session_id).toBe('session-abc'); + expect(entry.cost_usd).toBe(2.50); + expect(typeof entry.timestamp).toBe('number'); + expect(entry.timestamp).toBeGreaterThan(0); + expect(entry.cwd).toBe('/my/project'); + }); + + it('performs atomic write (no .tmp file after completion)', async () => { + const { persistSessionCost } = await importCostHistory(); + persistSessionCost('session-atomic', 1.00, '/cwd'); + + const sessionsDir = getSessionsDir(); + const files = fs.readdirSync(sessionsDir); + // Only the final .json file, no .tmp + expect(files).toContain('session-atomic.json'); + expect(files.filter((f) => f.endsWith('.tmp'))).toHaveLength(0); + }); + + it('skips write when cost is 0', async () => { + const { persistSessionCost } = await importCostHistory(); + persistSessionCost('session-zero', 0, '/cwd'); + expect(fs.existsSync(getSessionsDir())).toBe(false); + }); + + it('skips write when cost is undefined/falsy', async () => { + const { persistSessionCost } = await importCostHistory(); + // TypeScript won't normally let us pass undefined, but test the runtime guard + persistSessionCost('session-undef', undefined as unknown as number, '/cwd'); + expect(fs.existsSync(getSessionsDir())).toBe(false); + }); + + it('overwrites same session file on subsequent renders', async () => { + const { persistSessionCost } = await importCostHistory(); + persistSessionCost('session-overwrite', 1.00, '/cwd'); + persistSessionCost('session-overwrite', 2.00, '/cwd'); + + const filePath = path.join(getSessionsDir(), 'session-overwrite.json'); + const raw = fs.readFileSync(filePath, 'utf-8'); + const entry = JSON.parse(raw) as { cost_usd: number }; + expect(entry.cost_usd).toBe(2.00); + }); + + it('concurrent sessions write to separate files', async () => { + const { persistSessionCost } = await importCostHistory(); + persistSessionCost('session-a', 1.00, '/cwd'); + persistSessionCost('session-b', 2.00, '/cwd'); + persistSessionCost('session-c', 3.00, '/cwd'); + + const files = fs.readdirSync(getSessionsDir()).filter((f) => f.endsWith('.json')); + expect(files).toHaveLength(3); + expect(files).toContain('session-a.json'); + expect(files).toContain('session-b.json'); + expect(files).toContain('session-c.json'); + }); + + it('path traversal guard: sessionId containing / does not create a file', async () => { + const { persistSessionCost } = await importCostHistory(); + persistSessionCost('../../etc/passwd', 1.00, '/cwd'); + expect(fs.existsSync(getSessionsDir())).toBe(false); + }); + + it('path traversal guard: sessionId containing \\ does not create a file', async () => { + const { persistSessionCost } = await importCostHistory(); + persistSessionCost('..\\evil', 1.00, '/cwd'); + expect(fs.existsSync(getSessionsDir())).toBe(false); + }); + + it('path traversal guard: empty string sessionId does not create a file', async () => { + const { persistSessionCost } = await importCostHistory(); + persistSessionCost('', 1.00, '/cwd'); + expect(fs.existsSync(getSessionsDir())).toBe(false); + }); +}); + +describe('runCleanup', () => { + it('archives session files older than 24 hours to archive.jsonl', async () => { + const { persistSessionCost } = await importCostHistory(); + + // Directly write a stale session file (timestamp > 24h ago) + const sessionsDir = getSessionsDir(); + fs.mkdirSync(sessionsDir, { recursive: true }); + const staleTimestamp = Math.floor(Date.now() / 1000) - 2 * 86400; // 2 days ago + const staleEntry = { + session_id: 'stale-session', + cost_usd: 3.50, + timestamp: staleTimestamp, + cwd: '/cwd', + }; + fs.writeFileSync( + path.join(sessionsDir, 'stale-session.json'), + JSON.stringify(staleEntry), + ); + + // Trigger cleanup by calling persistSessionCost with a timestamp divisible by 50. + // We control the clock indirectly: find the next epoch second % 50 === 0. + // Simpler approach: call the internal via a sessionId that triggers it on a + // matching second. Instead, directly call with a cost and verify via side-effects + // by calling persistSessionCost repeatedly until cleanup fires — this is fragile. + // Better: export runCleanup or test it via the archiveStaleSessionFiles path. + // Since runCleanup is not exported, we write a fresh session and then manually + // verify by directly writing a file with a stale timestamp and calling + // persistSessionCost with a specially crafted epoch. + // + // The simplest approach without exporting runCleanup: write the stale file, + // then use the module's internal trigger (timestamp % 50 === 0). + // We mock Date.now to return an epoch where the seconds % 50 === 0. + const nowMs = Date.now(); + const nowSec = Math.floor(nowMs / 1000); + // Find the nearest future second divisible by 50 + const targetSec = nowSec + (50 - (nowSec % 50)) % 50; + const savedNow = Date.now; + // Temporarily override Date.now so that nowEpoch() returns targetSec + Date.now = () => targetSec * 1000; + try { + persistSessionCost('trigger-cleanup', 1.00, '/cwd'); + } finally { + Date.now = savedNow; + } + + // stale-session.json should have been archived + expect(fs.existsSync(path.join(sessionsDir, 'stale-session.json'))).toBe(false); + const archivePath = getArchivePath(); + expect(fs.existsSync(archivePath)).toBe(true); + const archiveContent = fs.readFileSync(archivePath, 'utf-8'); + const lines = archiveContent.split('\n').filter((l) => l.trim().length > 0); + const found = lines.some((line) => { + try { + const parsed = JSON.parse(line) as { session_id: string }; + return parsed.session_id === 'stale-session'; + } catch { + return false; + } + }); + expect(found).toBe(true); + }); + + it('removes orphaned .tmp files older than 1 hour', async () => { + const { runCleanup } = await importCostHistory(); + const sessionsDir = getSessionsDir(); + fs.mkdirSync(sessionsDir, { recursive: true }); + + const tmpPath = path.join(sessionsDir, 'stale.json.tmp'); + fs.writeFileSync(tmpPath, 'stale-data'); + // Backdate mtime to 2 hours ago + const twoHoursAgo = Date.now() - 2 * 3600 * 1000; + fs.utimesSync(tmpPath, new Date(twoHoursAgo), new Date(twoHoursAgo)); + + runCleanup(); + + expect(fs.existsSync(tmpPath)).toBe(false); + }); + + it('keeps .tmp files younger than 1 hour', async () => { + const { runCleanup } = await importCostHistory(); + const sessionsDir = getSessionsDir(); + fs.mkdirSync(sessionsDir, { recursive: true }); + + const tmpPath = path.join(sessionsDir, 'recent.json.tmp'); + fs.writeFileSync(tmpPath, 'recent-data'); + // mtime is now (just created) — well within 1 hour + + runCleanup(); + + expect(fs.existsSync(tmpPath)).toBe(true); + }); + + it('trims archive entries older than 90 days when exceeding threshold', async () => { + const { runCleanup } = await importCostHistory(); + const sessionsDir = getSessionsDir(); + const archivePath = getArchivePath(); + fs.mkdirSync(sessionsDir, { recursive: true }); + + const nowSec = Math.floor(Date.now() / 1000); + const lines: string[] = []; + + // 300 entries from 100 days ago (older than 90-day cutoff) + for (let i = 0; i < 300; i++) { + lines.push(JSON.stringify({ + session_id: `old-${i}`, + cost_usd: 1.00, + timestamp: nowSec - 100 * 86400, + cwd: '/cwd', + })); + } + // 250 entries from 10 days ago (within 90-day cutoff) + for (let i = 0; i < 250; i++) { + lines.push(JSON.stringify({ + session_id: `recent-${i}`, + cost_usd: 2.00, + timestamp: nowSec - 10 * 86400, + cwd: '/cwd', + })); + } + + fs.writeFileSync(archivePath, lines.join('\n') + '\n'); + expect(lines).toHaveLength(550); // exceeds 500 threshold + + runCleanup(); + + const trimmed = fs.readFileSync(archivePath, 'utf-8') + .split('\n') + .filter((l) => l.trim().length > 0); + // Only the 250 recent entries should remain + expect(trimmed).toHaveLength(250); + const allRecent = trimmed.every((line) => { + const entry = JSON.parse(line) as { session_id: string }; + return entry.session_id.startsWith('recent-'); + }); + expect(allRecent).toBe(true); + }); + + it('does not trim archive when under threshold', async () => { + const { runCleanup } = await importCostHistory(); + const sessionsDir = getSessionsDir(); + const archivePath = getArchivePath(); + fs.mkdirSync(sessionsDir, { recursive: true }); + + const nowSec = Math.floor(Date.now() / 1000); + const lines: string[] = []; + // 100 entries (well under 500 threshold), all old + for (let i = 0; i < 100; i++) { + lines.push(JSON.stringify({ + session_id: `old-${i}`, + cost_usd: 1.00, + timestamp: nowSec - 100 * 86400, + cwd: '/cwd', + })); + } + + fs.writeFileSync(archivePath, lines.join('\n') + '\n'); + + runCleanup(); + + const after = fs.readFileSync(archivePath, 'utf-8') + .split('\n') + .filter((l) => l.trim().length > 0); + // All 100 entries remain — under threshold, no trimming + expect(after).toHaveLength(100); + }); +}); + +describe('aggregateCosts', () => { + it('returns null when no files exist', async () => { + const { aggregateCosts } = await importCostHistory(); + const result = aggregateCosts('session-1', 0); + expect(result).toBeNull(); + }); + + it('reads single session file correctly', async () => { + const { persistSessionCost, aggregateCosts } = await importCostHistory(); + persistSessionCost('session-1', 5.00, '/cwd'); + const result = aggregateCosts('session-1', 5.00); + expect(result).not.toBeNull(); + expect(result!.weeklyCost).toBeGreaterThan(0); + expect(result!.monthlyCost).toBeGreaterThan(0); + }); + + it('reads multiple concurrent session files', async () => { + const { persistSessionCost, aggregateCosts } = await importCostHistory(); + persistSessionCost('session-a', 1.00, '/cwd'); + persistSessionCost('session-b', 2.00, '/cwd'); + persistSessionCost('session-c', 3.00, '/cwd'); + + const result = aggregateCosts('session-x', 4.00); + expect(result).not.toBeNull(); + // session-a + session-b + session-c + session-x = 10.00 + expect(result!.weeklyCost).toBeCloseTo(10.00, 2); + expect(result!.monthlyCost).toBeCloseTo(10.00, 2); + }); + + it('reads archive.jsonl entries', async () => { + const { aggregateCosts } = await importCostHistory(); + + const archivePath = getArchivePath(); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + const nowSeconds = Math.floor(Date.now() / 1000); + const entry = JSON.stringify({ + session_id: 'archived-1', + cost_usd: 7.50, + timestamp: nowSeconds - 2 * 86400, // 2 days ago (within week) + cwd: '/cwd', + }); + fs.writeFileSync(archivePath, entry + '\n'); + + const result = aggregateCosts('current-session', 1.00); + expect(result).not.toBeNull(); + // archived-1 (7.50) + current (1.00) = 8.50 + expect(result!.weeklyCost).toBeCloseTo(8.50, 2); + }); + + it('merges session files and archive correctly', async () => { + const { persistSessionCost, aggregateCosts } = await importCostHistory(); + + // Write an active session + persistSessionCost('session-active', 3.00, '/cwd'); + + // Write an archive entry + const archivePath = getArchivePath(); + const nowSeconds = Math.floor(Date.now() / 1000); + const archiveEntry = JSON.stringify({ + session_id: 'session-archived', + cost_usd: 4.00, + timestamp: nowSeconds - 3 * 86400, // 3 days ago + cwd: '/cwd', + }); + fs.writeFileSync(archivePath, archiveEntry + '\n'); + + const result = aggregateCosts('session-current', 2.00); + expect(result).not.toBeNull(); + // active(3) + archived(4) + current(2) = 9 + expect(result!.weeklyCost).toBeCloseTo(9.00, 2); + }); + + it('current session stdin is authoritative (overrides file)', async () => { + const { persistSessionCost, aggregateCosts } = await importCostHistory(); + // File has old cost + persistSessionCost('session-current', 1.00, '/cwd'); + // Stdin reports higher cost + const result = aggregateCosts('session-current', 5.00); + expect(result).not.toBeNull(); + // Should use stdin cost (5.00), not file cost (1.00) + expect(result!.weeklyCost).toBeCloseTo(5.00, 2); + }); + + it('deduplicates by session_id (takes max cost)', async () => { + const { aggregateCosts } = await importCostHistory(); + + // Write archive with two entries for same session + const archivePath = getArchivePath(); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + const nowSeconds = Math.floor(Date.now() / 1000); + const lines = [ + JSON.stringify({ session_id: 'dup-session', cost_usd: 2.00, timestamp: nowSeconds - 3600, cwd: '/cwd' }), + JSON.stringify({ session_id: 'dup-session', cost_usd: 5.00, timestamp: nowSeconds - 1800, cwd: '/cwd' }), + ]; + fs.writeFileSync(archivePath, lines.join('\n') + '\n'); + + const result = aggregateCosts('other-session', 1.00); + expect(result).not.toBeNull(); + // dup-session deduplicates to max=5.00, plus other-session=1.00 = 6.00 + expect(result!.weeklyCost).toBeCloseTo(6.00, 2); + }); + + it('weekly sum: only sessions within last 7 days', async () => { + const { aggregateCosts } = await importCostHistory(); + + const archivePath = getArchivePath(); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + const nowSeconds = Math.floor(Date.now() / 1000); + const lines = [ + // Within 7 days + JSON.stringify({ session_id: 'recent', cost_usd: 10.00, timestamp: nowSeconds - 3 * 86400, cwd: '/cwd' }), + // Outside 7 days + JSON.stringify({ session_id: 'old', cost_usd: 50.00, timestamp: nowSeconds - 10 * 86400, cwd: '/cwd' }), + ]; + fs.writeFileSync(archivePath, lines.join('\n') + '\n'); + + const result = aggregateCosts('current-session', 2.00); + expect(result).not.toBeNull(); + // recent(10) + current(2) = 12; old(50) excluded from weekly + expect(result!.weeklyCost).toBeCloseTo(12.00, 2); + // old(50) + recent(10) + current(2) = 62 (within 30 days) + expect(result!.monthlyCost).toBeCloseTo(62.00, 2); + }); + + it('monthly sum: only sessions within last 30 days', async () => { + const { aggregateCosts } = await importCostHistory(); + + const archivePath = getArchivePath(); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + const nowSeconds = Math.floor(Date.now() / 1000); + const lines = [ + // Within 30 days + JSON.stringify({ session_id: 'monthly', cost_usd: 20.00, timestamp: nowSeconds - 25 * 86400, cwd: '/cwd' }), + // Outside 30 days + JSON.stringify({ session_id: 'veryold', cost_usd: 100.00, timestamp: nowSeconds - 35 * 86400, cwd: '/cwd' }), + ]; + fs.writeFileSync(archivePath, lines.join('\n') + '\n'); + + const result = aggregateCosts('current-session', 1.00); + expect(result).not.toBeNull(); + // monthly(20) + current(1) = 21; veryold excluded + expect(result!.monthlyCost).toBeCloseTo(21.00, 2); + // Only current(1) within 7 days + expect(result!.weeklyCost).toBeCloseTo(1.00, 2); + }); + + it('ignores malformed JSON lines in archive', async () => { + const { aggregateCosts } = await importCostHistory(); + + const archivePath = getArchivePath(); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + const nowSeconds = Math.floor(Date.now() / 1000); + const lines = [ + 'not-valid-json', + JSON.stringify({ session_id: 'valid', cost_usd: 5.00, timestamp: nowSeconds - 1800, cwd: '/cwd' }), + '{"broken":', + ]; + fs.writeFileSync(archivePath, lines.join('\n') + '\n'); + + const result = aggregateCosts('current', 0); + // valid(5) is summed; malformed lines are ignored; current=0 means only file + expect(result).not.toBeNull(); + expect(result!.weeklyCost).toBeCloseTo(5.00, 2); + }); + + it('ignores malformed session files', async () => { + const { aggregateCosts } = await importCostHistory(); + + const sessionsDir = getSessionsDir(); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync(path.join(sessionsDir, 'bad.json'), 'not-json'); + const nowSeconds = Math.floor(Date.now() / 1000); + fs.writeFileSync( + path.join(sessionsDir, 'good.json'), + JSON.stringify({ session_id: 'good', cost_usd: 3.00, timestamp: nowSeconds, cwd: '/cwd' }), + ); + + const result = aggregateCosts('current', 0); + expect(result).not.toBeNull(); + // good(3) is counted; bad.json skipped + expect(result!.weeklyCost).toBeCloseTo(3.00, 2); + }); + + it('returns weeklyCost: null when no sessions in 7 days (only old archive entries)', async () => { + const { aggregateCosts } = await importCostHistory(); + + const archivePath = getArchivePath(); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + const nowSeconds = Math.floor(Date.now() / 1000); + // Entry is older than 7 days but within 30 days + const entry = JSON.stringify({ + session_id: 'old-session', + cost_usd: 15.00, + timestamp: nowSeconds - 10 * 86400, + cwd: '/cwd', + }); + fs.writeFileSync(archivePath, entry + '\n'); + + // currentCostUsd=0 means no current session contribution + const result = aggregateCosts('current-session', 0); + // No session within last 7 days (current=0 is not counted) + // Session map contains old-session only (10 days old) + // weeklyCost is null because nothing is within 7 days + // monthlyCost = 15 (within 30 days) + expect(result).not.toBeNull(); + expect(result!.weeklyCost).toBeNull(); + expect(result!.monthlyCost).toBeCloseTo(15.00, 2); + }); +}); diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts deleted file mode 100644 index 5233d054..00000000 --- a/tests/credentials.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { readCredentialsFile, readKeychainToken, getCredentials, getClaudeDir } from '../src/cli/hud/credentials.js'; - -vi.mock('node:child_process', () => ({ - execFile: vi.fn(), -})); - -describe('getClaudeDir', () => { - const originalEnv = process.env.CLAUDE_CONFIG_DIR; - - afterEach(() => { - if (originalEnv !== undefined) { - process.env.CLAUDE_CONFIG_DIR = originalEnv; - } else { - delete process.env.CLAUDE_CONFIG_DIR; - } - }); - - it('respects CLAUDE_CONFIG_DIR', () => { - process.env.CLAUDE_CONFIG_DIR = '/custom/claude'; - expect(getClaudeDir()).toBe('/custom/claude'); - }); - - it('falls back to ~/.claude', () => { - delete process.env.CLAUDE_CONFIG_DIR; - const result = getClaudeDir(); - expect(result).toContain('.claude'); - }); -}); - -describe('readCredentialsFile', () => { - it('returns credentials from valid file', () => { - const dir = '/tmp/test-creds-' + Date.now(); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, '.credentials.json'), - JSON.stringify({ - claudeAiOauth: { accessToken: 'test-token-123', subscriptionType: 'pro' }, - }), - ); - const result = readCredentialsFile(dir); - expect(result).toEqual({ accessToken: 'test-token-123', subscriptionType: 'pro' }); - fs.rmSync(dir, { recursive: true }); - }); - - it('returns null for missing file', () => { - const result = readCredentialsFile('/tmp/nonexistent-' + Date.now()); - expect(result).toBeNull(); - }); - - it('returns null for malformed JSON', () => { - const dir = '/tmp/test-creds-bad-' + Date.now(); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, '.credentials.json'), 'not-json'); - const result = readCredentialsFile(dir); - expect(result).toBeNull(); - fs.rmSync(dir, { recursive: true }); - }); - - it('returns null when accessToken is missing', () => { - const dir = '/tmp/test-creds-notoken-' + Date.now(); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, '.credentials.json'), - JSON.stringify({ claudeAiOauth: { subscriptionType: 'pro' } }), - ); - const result = readCredentialsFile(dir); - expect(result).toBeNull(); - fs.rmSync(dir, { recursive: true }); - }); - - it('omits subscriptionType when not present', () => { - const dir = '/tmp/test-creds-nosub-' + Date.now(); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, '.credentials.json'), - JSON.stringify({ claudeAiOauth: { accessToken: 'tok' } }), - ); - const result = readCredentialsFile(dir); - expect(result).toEqual({ accessToken: 'tok', subscriptionType: undefined }); - fs.rmSync(dir, { recursive: true }); - }); -}); - -describe('readKeychainToken', () => { - it('returns null on non-darwin platforms', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); - try { - const result = await readKeychainToken(); - expect(result).toBeNull(); - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform); - } - } - }); -}); - -describe('getCredentials', () => { - it('returns file credentials on non-darwin', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); - - const dir = '/tmp/test-getcreds-' + Date.now(); - const originalEnv = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = dir; - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, '.credentials.json'), - JSON.stringify({ claudeAiOauth: { accessToken: 'file-token' } }), - ); - - try { - const result = await getCredentials(); - expect(result).toEqual({ accessToken: 'file-token', subscriptionType: undefined }); - } finally { - Object.defineProperty(process, 'platform', originalPlatform!); - if (originalEnv !== undefined) { - process.env.CLAUDE_CONFIG_DIR = originalEnv; - } else { - delete process.env.CLAUDE_CONFIG_DIR; - } - fs.rmSync(dir, { recursive: true }); - } - }); - - it('returns null when no credentials available on non-darwin', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); - - const originalEnv = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = '/tmp/nonexistent-' + Date.now(); - - try { - const result = await getCredentials(); - expect(result).toBeNull(); - } finally { - Object.defineProperty(process, 'platform', originalPlatform!); - if (originalEnv !== undefined) { - process.env.CLAUDE_CONFIG_DIR = originalEnv; - } else { - delete process.env.CLAUDE_CONFIG_DIR; - } - } - }); -}); diff --git a/tests/hud-components.test.ts b/tests/hud-components.test.ts index 9afdf91b..217cd255 100644 --- a/tests/hud-components.test.ts +++ b/tests/hud-components.test.ts @@ -9,7 +9,7 @@ import diffStats from '../src/cli/hud/components/diff-stats.js'; import model from '../src/cli/hud/components/model.js'; import contextUsage from '../src/cli/hud/components/context-usage.js'; import sessionDuration from '../src/cli/hud/components/session-duration.js'; -import usageQuota from '../src/cli/hud/components/usage-quota.js'; +import usageQuota, { formatCountdown } from '../src/cli/hud/components/usage-quota.js'; import todoProgress from '../src/cli/hud/components/todo-progress.js'; import sessionCost from '../src/cli/hud/components/session-cost.js'; import releaseInfo from '../src/cli/hud/components/release-info.js'; @@ -24,6 +24,8 @@ function makeCtx(overrides: Partial = {}): GatherContext { transcript: null, usage: null, configCounts: null, + learningCounts: null, + costHistory: null, config: { enabled: true, detail: false, components: [] }, devflowDir: '/test/.devflow', sessionStartTime: null, @@ -248,7 +250,7 @@ describe('contextUsage component', () => { }); const result = await contextUsage(ctx); expect(result).not.toBeNull(); - expect(result!.raw).toContain('Current Session '); + expect(result!.raw).toContain('Context '); expect(result!.raw).toContain('25%'); expect(result!.raw).toContain('\u2588'); // filled bar expect(result!.raw).toContain('\u2591'); // empty bar @@ -347,11 +349,13 @@ describe('sessionDuration component', () => { }); describe('usageQuota component', () => { - it('shows Session label with both windows when available', async () => { - const ctx = makeCtx({ usage: { fiveHourPercent: 45, sevenDayPercent: 70 } }); + it('shows both windows when available (no Session prefix)', async () => { + const ctx = makeCtx({ + usage: { fiveHourPercent: 45, sevenDayPercent: 70, fiveHourResetsAt: null, sevenDayResetsAt: null }, + }); const result = await usageQuota(ctx); expect(result).not.toBeNull(); - expect(result!.raw).toContain('Session'); + expect(result!.raw).not.toContain('Session'); expect(result!.raw).toContain('5h'); expect(result!.raw).toContain('45%'); expect(result!.raw).toContain('7d'); @@ -360,20 +364,24 @@ describe('usageQuota component', () => { }); it('shows only 5h window when 7d is null', async () => { - const ctx = makeCtx({ usage: { fiveHourPercent: 30, sevenDayPercent: null } }); + const ctx = makeCtx({ + usage: { fiveHourPercent: 30, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, + }); const result = await usageQuota(ctx); expect(result).not.toBeNull(); - expect(result!.raw).toContain('Session'); + expect(result!.raw).not.toContain('Session'); expect(result!.raw).toContain('5h'); expect(result!.raw).toContain('30%'); expect(result!.raw).not.toContain('7d'); }); it('shows only 7d window when 5h is null', async () => { - const ctx = makeCtx({ usage: { fiveHourPercent: null, sevenDayPercent: 70 } }); + const ctx = makeCtx({ + usage: { fiveHourPercent: null, sevenDayPercent: 70, fiveHourResetsAt: null, sevenDayResetsAt: null }, + }); const result = await usageQuota(ctx); expect(result).not.toBeNull(); - expect(result!.raw).toContain('Session'); + expect(result!.raw).not.toContain('Session'); expect(result!.raw).toContain('7d'); expect(result!.raw).toContain('70%'); expect(result!.raw).not.toContain('5h'); @@ -386,10 +394,111 @@ describe('usageQuota component', () => { }); it('returns null when both percentages are null', async () => { - const ctx = makeCtx({ usage: { fiveHourPercent: null, sevenDayPercent: null } }); + const ctx = makeCtx({ + usage: { fiveHourPercent: null, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, + }); const result = await usageQuota(ctx); expect(result).toBeNull(); }); + + it('shows countdown with hours+minutes for 5h window', async () => { + // Add 30 extra seconds as buffer to avoid off-by-one-minute edge cases + const twoHours15MinFromNow = Math.floor(Date.now() / 1000) + 2 * 3600 + 15 * 60 + 30; + const ctx = makeCtx({ + usage: { fiveHourPercent: 45, sevenDayPercent: null, fiveHourResetsAt: twoHours15MinFromNow, sevenDayResetsAt: null }, + }); + const result = await usageQuota(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('(2h 15m)'); + }); + + it('shows countdown with days+hours for 7d window', async () => { + // Add 30 extra seconds as buffer + const threeDays12HoursFromNow = Math.floor(Date.now() / 1000) + 3 * 86400 + 12 * 3600 + 30; + const ctx = makeCtx({ + usage: { fiveHourPercent: null, sevenDayPercent: 70, fiveHourResetsAt: null, sevenDayResetsAt: threeDays12HoursFromNow }, + }); + const result = await usageQuota(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('(3d 12h)'); + }); + + it('shows countdown with minutes only', async () => { + // Add 30 extra seconds as buffer + const fortyFiveMinFromNow = Math.floor(Date.now() / 1000) + 45 * 60 + 30; + const ctx = makeCtx({ + usage: { fiveHourPercent: 20, sevenDayPercent: null, fiveHourResetsAt: fortyFiveMinFromNow, sevenDayResetsAt: null }, + }); + const result = await usageQuota(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('(45m)'); + }); + + it('countdown is placed after percent in parentheses', async () => { + const twoHoursFromNow = Math.floor(Date.now() / 1000) + 2 * 3600; + const ctx = makeCtx({ + usage: { fiveHourPercent: 45, sevenDayPercent: null, fiveHourResetsAt: twoHoursFromNow, sevenDayResetsAt: null }, + }); + const result = await usageQuota(ctx); + expect(result).not.toBeNull(); + const raw = result!.raw; + const percentIdx = raw.indexOf('45%'); + const parenIdx = raw.indexOf('('); + expect(percentIdx).toBeLessThan(parenIdx); + }); + + it('expired resets_at shows no countdown', async () => { + const oneHourAgo = Math.floor(Date.now() / 1000) - 3600; + const ctx = makeCtx({ + usage: { fiveHourPercent: 45, sevenDayPercent: null, fiveHourResetsAt: oneHourAgo, sevenDayResetsAt: null }, + }); + const result = await usageQuota(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).not.toContain('('); + }); + + it('null resets_at shows no countdown', async () => { + const ctx = makeCtx({ + usage: { fiveHourPercent: 45, sevenDayPercent: 70, fiveHourResetsAt: null, sevenDayResetsAt: null }, + }); + const result = await usageQuota(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).not.toContain('('); + }); +}); + +describe('formatCountdown', () => { + it('returns empty string for a past timestamp', () => { + const oneHourAgo = Math.floor(Date.now() / 1000) - 3600; + expect(formatCountdown(oneHourAgo)).toBe(''); + }); + + it('returns hours and minutes for a 2h 30m remaining timestamp', () => { + const twoHours30Min = Math.floor(Date.now() / 1000) + 2 * 3600 + 30 * 60 + 30; + expect(formatCountdown(twoHours30Min)).toBe('2h 30m'); + }); + + it('returns days and hours for a 2d 5h remaining timestamp', () => { + const twoDays5Hours = Math.floor(Date.now() / 1000) + 2 * 86400 + 5 * 3600 + 30; + expect(formatCountdown(twoDays5Hours)).toBe('2d 5h'); + }); + + it('returns minutes only when under 1 hour remaining', () => { + const fortyFiveMin = Math.floor(Date.now() / 1000) + 45 * 60 + 30; + expect(formatCountdown(fortyFiveMin)).toBe('45m'); + }); + + it('omits zero-minute sub-unit for exact day counts', () => { + // Exactly 3 days: no leftover hours + const exactlyThreeDays = Math.floor(Date.now() / 1000) + 3 * 86400 + 30; + expect(formatCountdown(exactlyThreeDays)).toBe('3d'); + }); + + it('omits zero-minute sub-unit for exact hour counts', () => { + // Exactly 2 hours: no leftover minutes + const exactlyTwoHours = Math.floor(Date.now() / 1000) + 2 * 3600 + 30; + expect(formatCountdown(exactlyTwoHours)).toBe('2h'); + }); }); describe('todoProgress component', () => { @@ -446,6 +555,41 @@ describe('sessionCost component', () => { const result = await sessionCost(ctx); expect(result).toBeNull(); }); + + it('shows weekly and monthly cost when costHistory is present', async () => { + const ctx = makeCtx({ + stdin: { cost: { total_cost_usd: 1.42 } }, + costHistory: { weeklyCost: 18.50, monthlyCost: 62.30 }, + }); + const result = await sessionCost(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('$1.42'); + expect(result!.raw).toContain('$18.50/wk'); + expect(result!.raw).toContain('$62.30/mo'); + }); + + it('shows session cost only when costHistory is null', async () => { + const ctx = makeCtx({ + stdin: { cost: { total_cost_usd: 1.42 } }, + costHistory: null, + }); + const result = await sessionCost(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toBe('$1.42'); + expect(result!.raw).not.toContain('/wk'); + expect(result!.raw).not.toContain('/mo'); + }); + + it('shows weekly but not monthly when only weeklyCost is present', async () => { + const ctx = makeCtx({ + stdin: { cost: { total_cost_usd: 1.42 } }, + costHistory: { weeklyCost: 18.50, monthlyCost: null }, + }); + const result = await sessionCost(ctx); + expect(result).not.toBeNull(); + expect(result!.raw).toContain('$18.50/wk'); + expect(result!.raw).not.toContain('/mo'); + }); }); describe('releaseInfo component', () => { diff --git a/tests/hud-render.test.ts b/tests/hud-render.test.ts index 65113c33..9b699445 100644 --- a/tests/hud-render.test.ts +++ b/tests/hud-render.test.ts @@ -38,6 +38,7 @@ function makeCtx( usage: null, configCounts: null, learningCounts: null, + costHistory: null, config: { enabled: true, detail: false, @@ -73,25 +74,23 @@ describe('render', () => { expect(raw).toContain('\u00B7'); }); - it('shows session data when available', async () => { + it('shows usage quota when available', async () => { const ctx = makeCtx({ - sessionStartTime: Date.now() - 15 * 60 * 1000, - usage: { fiveHourPercent: 30, sevenDayPercent: null }, + usage: { fiveHourPercent: 30, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, }); const output = await render(ctx); const lines = output.split('\n').filter((l) => l.length > 0); expect(lines).toHaveLength(3); const raw = stripAnsi(output); - expect(raw).toContain('15m'); - expect(raw).toContain('Session'); + expect(raw).toContain('5h'); expect(raw).toContain('30%'); }); it('shows activity section with todos and config counts', async () => { const ctx = makeCtx({ sessionStartTime: Date.now() - 5 * 60 * 1000, - usage: { fiveHourPercent: 20, sevenDayPercent: null }, + usage: { fiveHourPercent: 20, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, transcript: { tools: [], agents: [], @@ -116,7 +115,7 @@ describe('render', () => { expect(raw).toContain('3 rules'); expect(raw).toContain('1 MCPs'); expect(raw).toContain('4 hooks'); - expect(raw).toContain('Session'); + expect(raw).toContain('5h'); }); it('components that return null are excluded', async () => { @@ -145,7 +144,7 @@ describe('render', () => { it('inserts blank line between info and activity sections', async () => { const ctx = makeCtx({ sessionStartTime: Date.now() - 5 * 60 * 1000, - usage: { fiveHourPercent: 20, sevenDayPercent: null }, + usage: { fiveHourPercent: 20, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, transcript: { tools: [], agents: [], @@ -167,7 +166,7 @@ describe('render', () => { it('no blank line when activity section is empty', async () => { const ctx = makeCtx({ sessionStartTime: Date.now() - 5 * 60 * 1000, - usage: { fiveHourPercent: 20, sevenDayPercent: null }, + usage: { fiveHourPercent: 20, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, }); const output = await render(ctx); const lines = output.split('\n'); @@ -205,7 +204,7 @@ describe('config', () => { expect(resolveComponents(config)).toEqual(['versionBadge']); }); - it('HUD_COMPONENTS has 16 components', () => { - expect(HUD_COMPONENTS).toHaveLength(16); + it('HUD_COMPONENTS has 15 components (sessionDuration retained but omitted from defaults)', () => { + expect(HUD_COMPONENTS).toHaveLength(15); }); });