From 48ab15025c53da7be7197c5db56e592b18cc447b Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 11:04:24 +0300 Subject: [PATCH 01/14] feat(hud): add rate_limits to StdinData, fiveHourResetsAt/sevenDayResetsAt to UsageData, CostAggregation type, and costHistory to GatherContext Co-Authored-By: Claude --- src/cli/hud/types.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/cli/hud/types.ts b/src/cli/hud/types.ts index 77737ed..d32a03e 100644 --- a/src/cli/hud/types.ts +++ b/src/cli/hud/types.ts @@ -12,6 +12,10 @@ export interface StdinData { cost?: { total_cost_usd?: number }; session_id?: string; transcript_path?: string; + rate_limits?: { + five_hour?: { used_percentage?: number; resets_at?: number }; + seven_day?: { used_percentage?: number; resets_at?: number }; + }; } /** @@ -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; From 234afc7cfe8774565a5d4407c924e2ffe16b8fde Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 11:04:32 +0300 Subject: [PATCH 02/14] feat(hud): add cost-history module for persistent session cost tracking Implements persistSessionCost (atomic write per session), aggregateCosts (merges active files + archive, deduplicates by max cost_usd), and getCostFilePaths (testable path resolution via DEVFLOW_DIR env var). Cleanup runs periodically: archives 24h-old sessions, trims archive at 90 days. Co-Authored-By: Claude --- src/cli/hud/cost-history.ts | 251 ++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 src/cli/hud/cost-history.ts diff --git a/src/cli/hud/cost-history.ts b/src/cli/hud/cost-history.ts new file mode 100644 index 0000000..609d411 --- /dev/null +++ b/src/cli/hud/cost-history.ts @@ -0,0 +1,251 @@ +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_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 SECONDS_24_HOURS = 24 * SECONDS_PER_DAY; +const SECONDS_1_HOUR = 3600; +const ARCHIVE_TRIM_THRESHOLD = 500; + +interface SessionEntry { + session_id: string; + cost_usd: number; + timestamp: number; + cwd: string; +} + +/** + * 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) return; + + try { + const { sessionsDir } = getCostFilePaths(); + fs.mkdirSync(sessionsDir, { recursive: true }); + + const entry: SessionEntry = { + session_id: sessionId, + cost_usd: costUsd, + timestamp: Math.floor(Date.now() / 1000), + 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 + } +} + +/** + * Clean up stale session files, orphaned .tmp files, and trim archive. + * Called periodically from persistSessionCost. + */ +function runCleanup(): void { + try { + const { sessionsDir, archivePath } = getCostFilePaths(); + const nowSeconds = Math.floor(Date.now() / 1000); + + let sessionFiles: string[]; + try { + sessionFiles = fs.readdirSync(sessionsDir); + } catch { + return; + } + + for (const filename of sessionFiles) { + const filePath = path.join(sessionsDir, filename); + + // Clean orphaned .tmp files older than 1 hour + if (filename.endsWith('.tmp')) { + 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 */ } + continue; + } + + if (!filename.endsWith('.json')) continue; + + // Archive session files older than 24 hours + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + const entry = JSON.parse(raw) as SessionEntry; + const age = nowSeconds - entry.timestamp; + if (age > SECONDS_24_HOURS) { + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + fs.appendFileSync(archivePath, JSON.stringify(entry) + '\n'); + fs.unlinkSync(filePath); + } + } catch { /* non-fatal: skip malformed files */ } + } + + // 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 entry = JSON.parse(line) as SessionEntry; + return entry.timestamp >= cutoff; + } catch { + return false; + } + }); + + fs.writeFileSync(archivePath, retained.join('\n') + '\n', 'utf-8'); + } catch { /* non-fatal */ } +} + +/** + * 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. + */ +export function aggregateCosts( + currentSessionId: string, + currentCostUsd: number, +): CostAggregation | null { + try { + const { sessionsDir, archivePath } = getCostFilePaths(); + + const sessionMap = new Map(); + + // Read active session files + 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 entry = JSON.parse(raw) as SessionEntry; + if (typeof entry.session_id === 'string' && typeof entry.cost_usd === 'number') { + const existing = sessionMap.get(entry.session_id); + // Take the max cost entry for deduplication + if (!existing || entry.cost_usd > existing.cost_usd) { + sessionMap.set(entry.session_id, entry); + } + } + } catch { /* skip malformed */ } + } + } catch { /* sessions dir may not exist yet */ } + + // Read archive entries + 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 entry = JSON.parse(line) as SessionEntry; + if (typeof entry.session_id === 'string' && typeof entry.cost_usd === 'number') { + const existing = sessionMap.get(entry.session_id); + if (!existing || entry.cost_usd > existing.cost_usd) { + sessionMap.set(entry.session_id, entry); + } + } + } catch { /* skip malformed JSONL lines */ } + } + } catch { /* archive may not exist yet */ } + + if (sessionMap.size === 0 && !currentCostUsd) return null; + + // Override/add current session from stdin (authoritative), only when cost > 0 + if (currentCostUsd > 0) { + const currentEntry: SessionEntry = { + session_id: currentSessionId, + cost_usd: currentCostUsd, + timestamp: Math.floor(Date.now() / 1000), + cwd: '', + }; + const existingCurrent = sessionMap.get(currentSessionId); + if (!existingCurrent || currentCostUsd >= existingCurrent.cost_usd) { + sessionMap.set(currentSessionId, currentEntry); + } + } + + if (sessionMap.size === 0) return null; + + const nowSeconds = Math.floor(Date.now() / 1000); + 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; + } + } + + return { + weeklyCost: hasWeekly ? weeklyCost : null, + monthlyCost: hasMonthly ? monthlyCost : null, + }; + } catch { + return null; + } +} From 6accc39910ee77956c6c95942b9524842266795e Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 11:04:38 +0300 Subject: [PATCH 03/14] feat(hud): update usage-quota and session-cost components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usage-quota: drop Session prefix, add formatCountdown helper for reset timers (↻2h15m format), show countdown after label and before bar. session-cost: extend to show weekly ($18.50/wk) and monthly ($62.30/mo) cost totals from costHistory when available. Co-Authored-By: Claude --- src/cli/hud/components/session-cost.ts | 15 +++++++- src/cli/hud/components/usage-quota.ts | 52 +++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/cli/hud/components/session-cost.ts b/src/cli/hud/components/session-cost.ts index 16cff05..4450ce9 100644 --- a/src/cli/hud/components/session-cost.ts +++ b/src/cli/hud/components/session-cost.ts @@ -6,6 +6,17 @@ export default async function sessionCost( ): 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 8c634ed..a45ae18 100644 --- a/src/cli/hud/components/usage-quota.ts +++ b/src/cli/hud/components/usage-quota.ts @@ -27,27 +27,69 @@ 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: '2h15m', '3d12h', '45m' (compact, no spaces) + */ +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`; +} + 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}` }); + const countdown = fiveHourResetsAt != null ? formatCountdown(fiveHourResetsAt) : ''; + const countdownText = countdown ? dim(` \u21BB${countdown}`) : ''; + const countdownRaw = countdown ? ` \u21BB${countdown}` : ''; + parts.push({ + text: dim('5h') + countdownText + dim(' ') + bar.text, + raw: `5h${countdownRaw} ${bar.raw}`, + }); } if (sevenDayPercent !== null) { const bar = renderBar(Math.round(sevenDayPercent)); - parts.push({ text: dim('7d ') + bar.text, raw: `7d ${bar.raw}` }); + const countdown = sevenDayResetsAt != null ? formatCountdown(sevenDayResetsAt) : ''; + const countdownText = countdown ? dim(` \u21BB${countdown}`) : ''; + const countdownRaw = countdown ? ` \u21BB${countdown}` : ''; + parts.push({ + text: dim('7d') + countdownText + dim(' ') + bar.text, + raw: `7d${countdownRaw} ${bar.raw}`, + }); } 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 '); + const text = parts.map((p) => p.text).join(sep); + const raw = parts.map((p) => p.raw).join(' \u00B7 '); return { text, raw }; } From 82ca451236b74a9a18b831f7d69360153b9e111a Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 11:04:45 +0300 Subject: [PATCH 04/14] feat(hud): reorder Line 3 components and wire cost history in index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render.ts: reorder Line 3 to model · configCounts · sessionDuration · sessionCost. index.ts: remove OAuth fetchUsageData, add extractUsageFromStdin (reads rate_limits from stdin directly), wire persistSessionCost and aggregateCosts for cost tracking. costHistory passed into GatherContext. Co-Authored-By: Claude --- src/cli/hud/index.ts | 53 +++++++++++++++++++++++++++++++++++++++---- src/cli/hud/render.ts | 2 +- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/cli/hud/index.ts b/src/cli/hud/index.ts index 809d615..2b26b99 100644 --- a/src/cli/hud/index.ts +++ b/src/cli/hud/index.ts @@ -5,15 +5,43 @@ 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; + + return { + fiveHourPercent, + sevenDayPercent, + fiveHourResetsAt: fiveHour?.resets_at ?? null, + sevenDayResetsAt: sevenDay?.resets_at ?? null, + }; +} + async function main(): Promise { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), OVERALL_TIMEOUT), @@ -53,20 +81,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 +121,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 (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 +144,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 38b0fed..579c89b 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', 'sessionDuration', 'sessionCost'], // --- section break --- null, // Section 2: Activity From 90427e96b52a2de25b531e7a6ee0c25fa03b9183 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 11:04:57 +0300 Subject: [PATCH 05/14] =?UTF-8?q?feat(hud):=20delete=20usage-api.ts=20and?= =?UTF-8?q?=20credentials.ts=20=E2=80=94=20replaced=20by=20stdin=20extract?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth-based usage fetching removed; rate limits now read directly from stdin.rate_limits on each render. No network calls, no credentials needed. Co-Authored-By: Claude --- src/cli/hud/credentials.ts | 115 ------------------------------------- src/cli/hud/usage-api.ts | 110 ----------------------------------- 2 files changed, 225 deletions(-) delete mode 100644 src/cli/hud/credentials.ts delete mode 100644 src/cli/hud/usage-api.ts diff --git a/src/cli/hud/credentials.ts b/src/cli/hud/credentials.ts deleted file mode 100644 index 2545f66..0000000 --- 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/usage-api.ts b/src/cli/hud/usage-api.ts deleted file mode 100644 index fbfa88a..0000000 --- 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); - } -} From 08b4968ebaf52a1976f87c2b5a04872b7e4b58f6 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 11:05:06 +0300 Subject: [PATCH 06/14] test(hud): update hud tests and add cost-history test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hud-components.test.ts: add learningCounts/costHistory to makeCtx; update usageQuota tests to drop Session prefix, add resets_at fields, and test countdown rendering; add sessionCost tests for weekly/monthly. hud-render.test.ts: add costHistory to makeCtx; update usage assertions to drop Session prefix, add resets_at fields. cost-history.test.ts: new suite covering persistSessionCost (atomic writes, skip on zero cost, overwrite, concurrent sessions) and aggregateCosts (null handling, file reads, archive, dedup, window sums, malformed entry resilience). credentials.test.ts: deleted — credentials.ts removed. Co-Authored-By: Claude --- tests/cost-history.test.ts | 330 +++++++++++++++++++++++++++++++++++ tests/credentials.test.ts | 149 ---------------- tests/hud-components.test.ts | 131 +++++++++++++- tests/hud-render.test.ts | 13 +- 4 files changed, 460 insertions(+), 163 deletions(-) create mode 100644 tests/cost-history.test.ts delete mode 100644 tests/credentials.test.ts diff --git a/tests/cost-history.test.ts b/tests/cost-history.test.ts new file mode 100644 index 0000000..163e416 --- /dev/null +++ b/tests/cost-history.test.ts @@ -0,0 +1,330 @@ +import { describe, it, expect, beforeEach, afterEach } 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 +// We reset between tests via env manipulation. + +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); + // Re-import to pick up updated DEVFLOW_DIR +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete process.env.DEVFLOW_DIR; +}); + +// Dynamic imports so DEVFLOW_DIR is respected +async function importCostHistory() { + // Use dynamic import with cache-busting to pick up env changes + 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'); + }); +}); + +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 5233d05..0000000 --- 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 9afdf91..b5dc1c1 100644 --- a/tests/hud-components.test.ts +++ b/tests/hud-components.test.ts @@ -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, @@ -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,82 @@ 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('\u21BB2h15m'); + }); + + 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('\u21BB3d12h'); + }); + + 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('\u21BB45m'); + }); + + it('countdown is placed after label, before bar', 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(); + // Should be: "5h ↻2h ████░░░░ 45%" + const raw = result!.raw; + const labelIdx = raw.indexOf('5h'); + const countdownIdx = raw.indexOf('\u21BB'); + const barIdx = raw.indexOf('\u2588'); + const percentIdx = raw.indexOf('45%'); + expect(labelIdx).toBeLessThan(countdownIdx); + expect(countdownIdx).toBeLessThan(barIdx); + expect(barIdx).toBeLessThan(percentIdx); + }); + + 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('\u21BB'); + }); + + 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('\u21BB'); + }); }); describe('todoProgress component', () => { @@ -446,6 +526,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 65113c3..3d359eb 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, @@ -76,7 +77,7 @@ describe('render', () => { it('shows session data 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); @@ -84,14 +85,14 @@ describe('render', () => { 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 +117,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 +146,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 +168,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'); From d72c4b85fb3df7dce45f26e141122018465bb5fb Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 11:05:10 +0300 Subject: [PATCH 07/14] docs: update HUD preview to show reset timers and weekly/monthly cost Co-Authored-By: Claude --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a3589c6..c0d2a52 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, session duration, 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 +▓▓▓▓▓▓▓▓ 100% · 5h ↻2h15m ████░░░░ 45% · 7d ↻3d12h ████████ 70% +Opus 4.6 (1M) · 3 MCPs 2 rules · ⏱ 15m · $1.42 · $18.50/wk · $62.30/mo ``` **Security.** Deny lists block dangerous tool patterns out of the box — configurable during init. From 2272769a113c069ca7f26a8b833654d1d22d6888 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 22:14:38 +0300 Subject: [PATCH 08/14] docs: fix HUD preview in README to match actual component output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three discrepancies between the README preview and actual render: - Line 2 lacked 'Context' label prefix and used wrong quota format (↻ symbol with pre-bar countdown instead of parenthesized post-percent countdown) - Line 3 included ⏱ 15m (sessionDuration) which is removed from defaults - Feature description mentioned 'session duration' — removed Co-Authored-By: Claude --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c0d2a52..5d70152 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 with weekly/monthly totals, quota reset timers, 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 · main · +2 -1 · v2.0.0+3 -▓▓▓▓▓▓▓▓ 100% · 5h ↻2h15m ████░░░░ 45% · 7d ↻3d12h ████████ 70% -Opus 4.6 (1M) · 3 MCPs 2 rules · ⏱ 15m · $1.42 · $18.50/wk · $62.30/mo +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. From c856e697bff47f59b936c9e5c660654a37fa83e0 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 22:17:56 +0300 Subject: [PATCH 09/14] refactor(hud): add type guard, caching, and helper extraction in cost-history - Extract nowEpoch() helper to deduplicate Math.floor(Date.now() / 1000) across 4 call sites - Add isSessionEntry() type guard replacing all unsafe JSON.parse casts - Fix !costUsd guard to costUsd <= 0 || !Number.isFinite(costUsd) to correctly reject NaN and negative values - Guard mkdirSync with sessionsDirCreated module flag to avoid redundant syscalls - Extract cleanOrphanedTmpFiles() and archiveStaleSessionFiles() from runCleanup to reduce nesting - Extract upsertMax(), readSessionEntries(), readArchiveEntries() from aggregateCosts to thin the orchestrator - Add 30s aggregation cache to avoid repeated filesystem reads on every HUD render tick Co-Authored-By: Claude --- src/cli/hud/cost-history.ts | 220 +++++++++++++++++++++++------------- 1 file changed, 142 insertions(+), 78 deletions(-) diff --git a/src/cli/hud/cost-history.ts b/src/cli/hud/cost-history.ts index 609d411..57829db 100644 --- a/src/cli/hud/cost-history.ts +++ b/src/cli/hud/cost-history.ts @@ -3,13 +3,13 @@ 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 SECONDS_24_HOURS = 24 * SECONDS_PER_DAY; -const SECONDS_1_HOUR = 3600; const ARCHIVE_TRIM_THRESHOLD = 500; +const CACHE_TTL_MS = 30_000; interface SessionEntry { session_id: string; @@ -18,6 +18,25 @@ interface SessionEntry { 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. @@ -41,16 +60,21 @@ export function persistSessionCost( costUsd: number, cwd: string, ): void { - if (!costUsd) return; + 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(); - fs.mkdirSync(sessionsDir, { recursive: true }); + if (!sessionsDirCreated) { + fs.mkdirSync(sessionsDir, { recursive: true }); + sessionsDirCreated = true; + } const entry: SessionEntry = { session_id: sessionId, cost_usd: costUsd, - timestamp: Math.floor(Date.now() / 1000), + timestamp: nowEpoch(), cwd, }; @@ -81,6 +105,45 @@ export function persistSessionCost( } } +/** 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. @@ -88,7 +151,7 @@ export function persistSessionCost( function runCleanup(): void { try { const { sessionsDir, archivePath } = getCostFilePaths(); - const nowSeconds = Math.floor(Date.now() / 1000); + const nowSeconds = nowEpoch(); let sessionFiles: string[]; try { @@ -97,35 +160,8 @@ function runCleanup(): void { return; } - for (const filename of sessionFiles) { - const filePath = path.join(sessionsDir, filename); - - // Clean orphaned .tmp files older than 1 hour - if (filename.endsWith('.tmp')) { - 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 */ } - continue; - } - - if (!filename.endsWith('.json')) continue; - - // Archive session files older than 24 hours - try { - const raw = fs.readFileSync(filePath, 'utf-8'); - const entry = JSON.parse(raw) as SessionEntry; - const age = nowSeconds - entry.timestamp; - if (age > SECONDS_24_HOURS) { - fs.mkdirSync(path.dirname(archivePath), { recursive: true }); - fs.appendFileSync(archivePath, JSON.stringify(entry) + '\n'); - fs.unlinkSync(filePath); - } - } catch { /* non-fatal: skip malformed files */ } - } + cleanOrphanedTmpFiles(sessionsDir, nowSeconds, sessionFiles); + archiveStaleSessionFiles(sessionsDir, archivePath, nowSeconds, sessionFiles); // Trim archive.jsonl when it exceeds threshold trimArchive(archivePath, nowSeconds); @@ -142,8 +178,9 @@ function trimArchive(archivePath: string, nowSeconds: number): void { const cutoff = nowSeconds - SECONDS_90_DAYS; const retained = lines.filter((line) => { try { - const entry = JSON.parse(line) as SessionEntry; - return entry.timestamp >= cutoff; + const parsed: unknown = JSON.parse(line); + if (!isSessionEntry(parsed)) return false; + return parsed.timestamp >= cutoff; } catch { return false; } @@ -153,64 +190,86 @@ function trimArchive(archivePath: string, nowSeconds: number): void { } 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 = new Map(); + const sessionMap = readSessionEntries(sessionsDir); - // Read active session files - 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 entry = JSON.parse(raw) as SessionEntry; - if (typeof entry.session_id === 'string' && typeof entry.cost_usd === 'number') { - const existing = sessionMap.get(entry.session_id); - // Take the max cost entry for deduplication - if (!existing || entry.cost_usd > existing.cost_usd) { - sessionMap.set(entry.session_id, entry); - } - } - } catch { /* skip malformed */ } - } - } catch { /* sessions dir may not exist yet */ } - - // Read archive entries - 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 entry = JSON.parse(line) as SessionEntry; - if (typeof entry.session_id === 'string' && typeof entry.cost_usd === 'number') { - const existing = sessionMap.get(entry.session_id); - if (!existing || entry.cost_usd > existing.cost_usd) { - sessionMap.set(entry.session_id, entry); - } - } - } catch { /* skip malformed JSONL lines */ } - } - } catch { /* archive may not exist yet */ } + for (const entry of readArchiveEntries(archivePath)) { + upsertMax(sessionMap, entry); + } - if (sessionMap.size === 0 && !currentCostUsd) return null; + if (sessionMap.size === 0 && !currentCostUsd) { + cachedAggregation = { value: null, expiresAt: Date.now() + CACHE_TTL_MS }; + return null; + } // Override/add current session from stdin (authoritative), only when cost > 0 if (currentCostUsd > 0) { const currentEntry: SessionEntry = { session_id: currentSessionId, cost_usd: currentCostUsd, - timestamp: Math.floor(Date.now() / 1000), + timestamp: nowEpoch(), cwd: '', }; const existingCurrent = sessionMap.get(currentSessionId); @@ -219,9 +278,12 @@ export function aggregateCosts( } } - if (sessionMap.size === 0) return null; + if (sessionMap.size === 0) { + cachedAggregation = { value: null, expiresAt: Date.now() + CACHE_TTL_MS }; + return null; + } - const nowSeconds = Math.floor(Date.now() / 1000); + const nowSeconds = nowEpoch(); const cutoff7d = nowSeconds - SECONDS_7_DAYS; const cutoff30d = nowSeconds - SECONDS_30_DAYS; @@ -241,10 +303,12 @@ export function aggregateCosts( } } - return { + 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; } From 3acaa80caf2ea355962e273e87efb212edd43900 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 22:19:27 +0300 Subject: [PATCH 10/14] fix(hud): update JSDoc, add epoch-seconds annotations, guard cost persistence - formatCountdown JSDoc: remove stale "compact, no spaces" note (impl uses spaces) - renderQuotaWindow: add JSDoc showing rendered format - ComponentId JSDoc: replace "16 HUD components" count with accurate description - StdinData.rate_limits resets_at fields: annotate as Epoch seconds - sessionCost: add JSDoc documenting weekly/monthly totals - persistSessionCost call: guard with needsSessionCost so cost is only persisted when the sessionCost component is actually enabled Co-Authored-By: Claude --- src/cli/hud/components/session-cost.ts | 1 + src/cli/hud/components/usage-quota.ts | 47 ++++++++++++++------------ src/cli/hud/index.ts | 11 ++++-- src/cli/hud/types.ts | 6 ++-- 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/cli/hud/components/session-cost.ts b/src/cli/hud/components/session-cost.ts index 4450ce9..8c0f7e9 100644 --- a/src/cli/hud/components/session-cost.ts +++ b/src/cli/hud/components/session-cost.ts @@ -1,6 +1,7 @@ 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 { diff --git a/src/cli/hud/components/usage-quota.ts b/src/cli/hud/components/usage-quota.ts index a45ae18..5fcef9d 100644 --- a/src/cli/hud/components/usage-quota.ts +++ b/src/cli/hud/components/usage-quota.ts @@ -30,7 +30,7 @@ function renderBar(percent: number): { text: string; raw: string } { /** * Format seconds remaining until a reset timestamp into compact form. * Returns '' if the timestamp is in the past or not provided. - * Format: '2h15m', '3d12h', '45m' (compact, no spaces) + * Format: '2h 15m', '3d 12h', '45m' */ export function formatCountdown(resetsAtEpoch: number): string { const nowMs = Date.now(); @@ -46,17 +46,33 @@ export function formatCountdown(resetsAtEpoch: number): string { if (days > 0) { const hours = totalHours % 24; - return hours > 0 ? `${days}d${hours}h` : `${days}d`; + 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 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 { @@ -66,30 +82,17 @@ export default async function usageQuota( const parts: { text: string; raw: string }[] = []; if (fiveHourPercent !== null) { - const bar = renderBar(Math.round(fiveHourPercent)); - const countdown = fiveHourResetsAt != null ? formatCountdown(fiveHourResetsAt) : ''; - const countdownText = countdown ? dim(` \u21BB${countdown}`) : ''; - const countdownRaw = countdown ? ` \u21BB${countdown}` : ''; - parts.push({ - text: dim('5h') + countdownText + dim(' ') + bar.text, - raw: `5h${countdownRaw} ${bar.raw}`, - }); + parts.push(renderQuotaWindow('5h', fiveHourPercent, fiveHourResetsAt)); } if (sevenDayPercent !== null) { - const bar = renderBar(Math.round(sevenDayPercent)); - const countdown = sevenDayResetsAt != null ? formatCountdown(sevenDayResetsAt) : ''; - const countdownText = countdown ? dim(` \u21BB${countdown}`) : ''; - const countdownRaw = countdown ? ` \u21BB${countdown}` : ''; - parts.push({ - text: dim('7d') + countdownText + dim(' ') + bar.text, - raw: `7d${countdownRaw} ${bar.raw}`, - }); + parts.push(renderQuotaWindow('7d', sevenDayPercent, sevenDayResetsAt)); } if (parts.length === 0) return null; const sep = dim(' \u00B7 '); - const text = parts.map((p) => p.text).join(sep); - const raw = 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/index.ts b/src/cli/hud/index.ts index 2b26b99..e692147 100644 --- a/src/cli/hud/index.ts +++ b/src/cli/hud/index.ts @@ -34,11 +34,16 @@ function extractUsageFromStdin(stdin: StdinData): UsageData | null { ? 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: fiveHour?.resets_at ?? null, - sevenDayResetsAt: sevenDay?.resets_at ?? null, + fiveHourResetsAt, + sevenDayResetsAt, }; } @@ -125,7 +130,7 @@ async function run(): Promise { const sessionId = stdin.session_id; const costUsd = stdin.cost?.total_cost_usd ?? 0; - if (sessionId && costUsd) { + if (needsSessionCost && sessionId && costUsd) { persistSessionCost(sessionId, costUsd, cwd); } diff --git a/src/cli/hud/types.ts b/src/cli/hud/types.ts index d32a03e..23333fd 100644 --- a/src/cli/hud/types.ts +++ b/src/cli/hud/types.ts @@ -13,13 +13,13 @@ export interface StdinData { session_id?: string; transcript_path?: string; rate_limits?: { - five_hour?: { used_percentage?: number; resets_at?: number }; - seven_day?: { used_percentage?: number; resets_at?: number }; + 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' From c34e846db8e27c375c1c5b6cf3b7b31cf745b2ea Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 22:22:32 +0300 Subject: [PATCH 11/14] test(hud): add path traversal, formatCountdown, and cleanup tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix module isolation: add vi.resetModules() in beforeEach so module-level singletons (sessionsDirCreated, cachedAggregation) reset between tests; this fixes 15 pre-existing test failures - Fix misleading comment: "cache-busting" → accurate description of why dynamic import is used (DEVFLOW_DIR read at call time) - Add path traversal guard tests for persistSessionCost (/, \, empty string) - Add runCleanup test: verifies stale session files (>24h) are archived to archive.jsonl, triggered by mocking Date.now to a timestamp % 50 === 0 - Add formatCountdown describe block: past timestamp, 2h 30m, 2d 5h, 45m minutes-only, exact day (no hours sub-unit), exact hour (no minutes sub-unit) Co-Authored-By: Claude --- tests/cost-history.test.ts | 95 ++++++++++++++++++++++++++++++++++-- tests/hud-components.test.ts | 59 ++++++++++++++++------ 2 files changed, 134 insertions(+), 20 deletions(-) diff --git a/tests/cost-history.test.ts b/tests/cost-history.test.ts index 163e416..516dcea 100644 --- a/tests/cost-history.test.ts +++ b/tests/cost-history.test.ts @@ -1,10 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +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 -// We reset between tests via env manipulation. +// 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; @@ -23,7 +25,9 @@ function getArchivePath(): string { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cost-history-test-')); setDevflowDir(tmpDir); - // Re-import to pick up updated DEVFLOW_DIR + // Reset module cache so module-level singletons (sessionsDirCreated, cachedAggregation) + // are re-initialized on the next dynamic import. + vi.resetModules(); }); afterEach(() => { @@ -33,7 +37,7 @@ afterEach(() => { // Dynamic imports so DEVFLOW_DIR is respected async function importCostHistory() { - // Use dynamic import with cache-busting to pick up env changes + // Dynamic import — getCostFilePaths reads DEVFLOW_DIR at call time const mod = await import('../src/cli/hud/cost-history.js'); return mod; } @@ -117,6 +121,87 @@ describe('persistSessionCost', () => { 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 (via persistSessionCost)', () => { + 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); + }); }); describe('aggregateCosts', () => { diff --git a/tests/hud-components.test.ts b/tests/hud-components.test.ts index b5dc1c1..217cd25 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'; @@ -250,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 @@ -409,7 +409,7 @@ describe('usageQuota component', () => { }); const result = await usageQuota(ctx); expect(result).not.toBeNull(); - expect(result!.raw).toContain('\u21BB2h15m'); + expect(result!.raw).toContain('(2h 15m)'); }); it('shows countdown with days+hours for 7d window', async () => { @@ -420,7 +420,7 @@ describe('usageQuota component', () => { }); const result = await usageQuota(ctx); expect(result).not.toBeNull(); - expect(result!.raw).toContain('\u21BB3d12h'); + expect(result!.raw).toContain('(3d 12h)'); }); it('shows countdown with minutes only', async () => { @@ -431,25 +431,20 @@ describe('usageQuota component', () => { }); const result = await usageQuota(ctx); expect(result).not.toBeNull(); - expect(result!.raw).toContain('\u21BB45m'); + expect(result!.raw).toContain('(45m)'); }); - it('countdown is placed after label, before bar', async () => { + 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(); - // Should be: "5h ↻2h ████░░░░ 45%" const raw = result!.raw; - const labelIdx = raw.indexOf('5h'); - const countdownIdx = raw.indexOf('\u21BB'); - const barIdx = raw.indexOf('\u2588'); const percentIdx = raw.indexOf('45%'); - expect(labelIdx).toBeLessThan(countdownIdx); - expect(countdownIdx).toBeLessThan(barIdx); - expect(barIdx).toBeLessThan(percentIdx); + const parenIdx = raw.indexOf('('); + expect(percentIdx).toBeLessThan(parenIdx); }); it('expired resets_at shows no countdown', async () => { @@ -459,7 +454,7 @@ describe('usageQuota component', () => { }); const result = await usageQuota(ctx); expect(result).not.toBeNull(); - expect(result!.raw).not.toContain('\u21BB'); + expect(result!.raw).not.toContain('('); }); it('null resets_at shows no countdown', async () => { @@ -468,7 +463,41 @@ describe('usageQuota component', () => { }); const result = await usageQuota(ctx); expect(result).not.toBeNull(); - expect(result!.raw).not.toContain('\u21BB'); + 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'); }); }); From 531709d3971a7ba862c6ec812ca999ee4de53adb Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 22:26:13 +0300 Subject: [PATCH 12/14] refactor(hud): remove dead early-return guard in aggregateCosts --- src/cli/hud/cost-history.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/cli/hud/cost-history.ts b/src/cli/hud/cost-history.ts index 57829db..a57aa01 100644 --- a/src/cli/hud/cost-history.ts +++ b/src/cli/hud/cost-history.ts @@ -259,11 +259,6 @@ export function aggregateCosts( upsertMax(sessionMap, entry); } - if (sessionMap.size === 0 && !currentCostUsd) { - cachedAggregation = { value: null, expiresAt: Date.now() + CACHE_TTL_MS }; - return null; - } - // Override/add current session from stdin (authoritative), only when cost > 0 if (currentCostUsd > 0) { const currentEntry: SessionEntry = { From 12c33f47ed98227298528c21ea67a7ea57c130d4 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 22:55:52 +0300 Subject: [PATCH 13/14] test(hud): add cleanup and archive trimming tests, export runCleanup --- src/cli/hud/cost-history.ts | 2 +- tests/cost-history.test.ts | 106 +++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/cli/hud/cost-history.ts b/src/cli/hud/cost-history.ts index a57aa01..8f8dfe7 100644 --- a/src/cli/hud/cost-history.ts +++ b/src/cli/hud/cost-history.ts @@ -148,7 +148,7 @@ function archiveStaleSessionFiles( * Clean up stale session files, orphaned .tmp files, and trim archive. * Called periodically from persistSessionCost. */ -function runCleanup(): void { +export function runCleanup(): void { try { const { sessionsDir, archivePath } = getCostFilePaths(); const nowSeconds = nowEpoch(); diff --git a/tests/cost-history.test.ts b/tests/cost-history.test.ts index 516dcea..98d339d 100644 --- a/tests/cost-history.test.ts +++ b/tests/cost-history.test.ts @@ -141,7 +141,7 @@ describe('persistSessionCost', () => { }); }); -describe('runCleanup (via persistSessionCost)', () => { +describe('runCleanup', () => { it('archives session files older than 24 hours to archive.jsonl', async () => { const { persistSessionCost } = await importCostHistory(); @@ -202,6 +202,110 @@ describe('runCleanup (via persistSessionCost)', () => { }); 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', () => { From 6d837e039e7e34de7fca2a80157491244ebdfbb6 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Mon, 20 Apr 2026 23:27:47 +0300 Subject: [PATCH 14/14] feat(hud): rename context label and remove sessionDuration from defaults --- src/cli/hud/components/context-usage.ts | 4 ++-- src/cli/hud/config.ts | 5 +++-- src/cli/hud/render.ts | 2 +- tests/hud-render.test.ts | 8 +++----- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/cli/hud/components/context-usage.ts b/src/cli/hud/components/context-usage.ts index 479e7de..c860f70 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/config.ts b/src/cli/hud/config.ts index c63d61c..5fbd76c 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/render.ts b/src/cli/hud/render.ts index 579c89b..4af5786 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', 'configCounts', 'sessionDuration', 'sessionCost'], + ['model', 'configCounts', 'sessionCost'], // --- section break --- null, // Section 2: Activity diff --git a/tests/hud-render.test.ts b/tests/hud-render.test.ts index 3d359eb..9b69944 100644 --- a/tests/hud-render.test.ts +++ b/tests/hud-render.test.ts @@ -74,9 +74,8 @@ 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, fiveHourResetsAt: null, sevenDayResetsAt: null }, }); const output = await render(ctx); @@ -84,7 +83,6 @@ describe('render', () => { expect(lines).toHaveLength(3); const raw = stripAnsi(output); - expect(raw).toContain('15m'); expect(raw).toContain('5h'); expect(raw).toContain('30%'); }); @@ -206,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); }); });