From 530db320c8aacc1c3c3412a24d02859e2be6b1b4 Mon Sep 17 00:00:00 2001 From: hobe Date: Thu, 14 May 2026 13:50:48 +0900 Subject: [PATCH] feat: add profile usage summary - Change requestId dedupe to use request: without sessionId, so forked sessions do not double-count. - Reuse the existing subagent filters before parsing usage lines. - Keep estimatedCostUsd and surface it via a COST column. - Replace the random fallback key with a stable session line key. - Restore the previous aimux dir in usage tests after each run. - Add usage-specific hardening for malformed/non-numeric token values. --- README.md | 4 + src/cli.tsx | 70 ++++++++++- src/core/index.ts | 3 + src/core/sessionScanner.ts | 2 +- src/core/usage.test.ts | 231 +++++++++++++++++++++++++++++++++++++ src/core/usage.ts | 230 ++++++++++++++++++++++++++++++++++++ 6 files changed, 536 insertions(+), 4 deletions(-) create mode 100644 src/core/usage.test.ts create mode 100644 src/core/usage.ts diff --git a/README.md b/README.md index d9f2027..88945c2 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ aimux run w # prefix match → work aimux run o -m claude-sonnet-4-6 # one-time model override aimux run w --resume # flags pass through to Claude CLI aimux status # dashboard +aimux usage # token usage by profile for the last 7 days +aimux usage --all # all known transcript usage # Set default model per profile (quote model names with special chars) aimux profile update w -m claude-opus-4-6 @@ -74,6 +76,8 @@ aimux profile update o -m "claude-opus-4-6[1m]" | `aimux init` | Auto-detect Claude dirs, create config, migrate profiles | | `aimux init --source ` | Initialize with explicit source directory | | `aimux status` | TUI dashboard — profiles, auth, symlink health | +| `aimux usage` | Show token usage by profile from Claude transcript metadata | +| `aimux usage --profile work --since 24h` | Show usage for one profile over a recent window | | `aimux run [profile]` | Launch AI CLI with correct env and model | | `aimux run` | Interactive picker — history pre-selects last used profile | | `aimux run w` | Prefix matching — launches `work` if unambiguous | diff --git a/src/cli.tsx b/src/cli.tsx index f41c181..6a2e232 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from 'commander'; import type { AimuxConfig } from './types/index.js'; +import type { ProfileUsageSummary } from './core/index.js'; import { rmSync, existsSync, cpSync, mkdirSync, appendFileSync, readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { dirname, join } from 'node:path'; @@ -11,6 +12,7 @@ import { syncProfile, syncAllProfiles, checkAllProfiles, launchProfile, getLastProfile, recordHistory, getProfile, looksLikeSubcommand, + summarizeUsage, parseSinceDuration, totalTokens, } from './core/index.js'; function requireConfig(): AimuxConfig { @@ -56,6 +58,42 @@ function formatSyncSummary(result: { return parts.join(', '); } +function formatInteger(value: number): string { + return Math.round(value).toLocaleString('en-US'); +} + +function formatUsd(value: number): string { + return `$${value.toFixed(2)}`; +} + +function topModels(models: Map): string { + const entries = Array.from(models.entries()).sort((a, b) => b[1] - a[1]); + if (entries.length === 0) return '-'; + return entries.slice(0, 2).map(([model]) => model).join(', '); +} + +function printUsageTable(summaries: ProfileUsageSummary[]): void { + const headers = ['PROFILE', 'SESS', 'REQ', 'INPUT', 'CACHE+', 'CACHE', 'OUTPUT', 'TOTAL', 'COST', 'MODELS']; + const rows = summaries.map((s) => [ + s.profile, + formatInteger(s.sessions), + formatInteger(s.requests), + formatInteger(s.inputTokens), + formatInteger(s.cacheCreationInputTokens), + formatInteger(s.cacheReadInputTokens), + formatInteger(s.outputTokens), + formatInteger(totalTokens(s)), + formatUsd(s.estimatedCostUsd), + topModels(s.models), + ]); + const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length))); + console.log(headers.map((h, i) => h.padEnd(widths[i])).join(' ')); + console.log(widths.map((w) => '-'.repeat(w)).join(' ')); + for (const row of rows) { + console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' ')); + } +} + const program = new Command(); @@ -74,6 +112,32 @@ program render(); }); +program + .command('usage') + .description('Show token usage by profile from Claude transcript metadata') + .option('-p, --profile ', 'Only show one profile (supports prefix matching)') + .option('--since ', 'Only include usage since duration: 24h, 7d, 4w', '7d') + .option('--all', 'Include all known transcript usage') + .action(async (options: { profile?: string; since: string; all?: boolean }) => { + try { + const config = requireConfig(); + const profile = options.profile ? resolveProfile(config, options.profile) : undefined; + const sinceMs = options.all ? undefined : parseSinceDuration(options.since); + const summaries = summarizeUsage(config, { profile, sinceMs }); + printUsageTable(summaries); + if (!options.all) { + console.log(`\nWindow: ${options.since}`); + } + console.log('Source: shared projects/*.jsonl transcript usage metadata; duplicate requestIds counted once.'); + if (summaries.some((s) => s.profile === 'unknown')) { + console.log('Note: unknown means aimux could not map a transcript session to a profile.'); + } + } catch (err) { + console.error(`Error: ${(err as Error).message}`); + process.exit(1); + } + }); + program .command('init') .description('Initialize aimux — detect and migrate existing Claude directories') @@ -680,7 +744,7 @@ program COMPREPLY=() cur="\${COMP_WORDS[COMP_CWORD]}" prev="\${COMP_WORDS[COMP_CWORD-1]}" - commands="init run status profile rebuild doctor auth completions" + commands="init run status usage profile rebuild doctor auth completions" case "\${prev}" in run|auth) @@ -700,7 +764,7 @@ complete -F _aimux aimux console.log(`#compdef aimux _aimux() { local -a commands profiles - commands=(init run status profile rebuild doctor auth completions) + commands=(init run status usage profile rebuild doctor auth completions) profiles=(${profiles}) _arguments '1:command:($commands)' '*::arg:->args' @@ -717,7 +781,7 @@ _aimux() { _aimux # Add to ~/.zshrc: eval "$(aimux completions zsh)"`); } else if (shell === 'fish') { - console.log(`complete -c aimux -n '__fish_use_subcommand' -a 'init run status profile rebuild doctor auth completions' + console.log(`complete -c aimux -n '__fish_use_subcommand' -a 'init run status usage profile rebuild doctor auth completions' complete -c aimux -n '__fish_seen_subcommand_from run' -a '${profiles}' complete -c aimux -n '__fish_seen_subcommand_from profile' -a 'add list update remove clone' complete -c aimux -n '__fish_seen_subcommand_from auth' -a 'login status' diff --git a/src/core/index.ts b/src/core/index.ts index 0960bc5..1f8e6e4 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -46,3 +46,6 @@ export type { DetectedDir, InitResult } from './init.js'; export { buildRunParams, launchProfile, looksLikeSubcommand } from './run.js'; export type { RunOptions, RunParams } from './run.js'; + +export { summarizeUsage, parseSinceDuration, totalTokens } from './usage.js'; +export type { ProfileUsageSummary, UsageOptions, UsageTotals } from './usage.js'; diff --git a/src/core/sessionScanner.ts b/src/core/sessionScanner.ts index 86c73f3..e13b672 100644 --- a/src/core/sessionScanner.ts +++ b/src/core/sessionScanner.ts @@ -125,7 +125,7 @@ export function parseSessionJsonl( return { cwd, intent, createdAtMs, events, isSubagent }; } -function quickFirstLineType(filePath: string): string | null { +export function quickFirstLineType(filePath: string): string | null { let fd: number | undefined; try { fd = openSync(filePath, 'r'); diff --git a/src/core/usage.test.ts b/src/core/usage.test.ts new file mode 100644 index 0000000..f0cdd18 --- /dev/null +++ b/src/core/usage.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { AimuxConfig } from '../types/index.js'; +import { getAimuxDir, setAimuxDir } from './paths.js'; +import { summarizeUsage, parseSinceDuration, totalTokens } from './usage.js'; + +const TEST_DIR = join(tmpdir(), `aimux-usage-test-${Date.now()}`); +const NOW_TS = '2026-05-14T00:00:00.000Z'; +const LATER_TS = '2026-05-14T00:00:01.000Z'; +const OLD_TS = '2026-05-10T00:00:00.000Z'; +const CUTOFF_TS = '2026-05-13T00:00:00.000Z'; + +let originalAimuxDir: string; + +function makeConfig(): AimuxConfig { + return { + version: 1, + shared_source: join(TEST_DIR, 'shared'), + profiles: { + main: { cli: 'claude', path: join(TEST_DIR, 'shared'), is_source: true }, + work: { cli: 'claude', path: join(TEST_DIR, 'profiles', 'work') }, + }, + private: ['.credentials.json'], + }; +} + +function writeProfileSession(profile: string, sessionId: string, modified: number) { + writeProfileSessions(profile, [{ sessionId, modified }]); +} + +function writeProfileSessions( + profile: string, + sessions: Array<{ sessionId: string; modified: number }>, +) { + const profilePath = profile === 'main' ? join(TEST_DIR, 'shared') : join(TEST_DIR, 'profiles', profile); + mkdirSync(profilePath, { recursive: true }); + const projects: Record = {}; + for (const { sessionId, modified } of sessions) { + projects[`/tmp/project-${sessionId}`] = { + lastSessionId: sessionId, + lastSessionModified: modified, + }; + } + writeFileSync( + join(profilePath, '.claude.json'), + JSON.stringify({ projects }), + ); +} + +function writeTranscript(cwdHash: string, sessionId: string, lines: unknown[]) { + const dir = join(TEST_DIR, 'shared', 'projects', cwdHash); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${sessionId}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n')); +} + +function assistantLine( + sessionId: string, + requestId: string, + usage: Record, + timestamp = NOW_TS, +) { + return { + type: 'assistant', + requestId, + timestamp, + sessionId, + message: { + id: `msg-${requestId}`, + model: 'claude-opus-4-7', + usage, + }, + }; +} + +function queueOperationLine() { + return { + type: 'queue-operation', + operation: 'task', + timestamp: NOW_TS, + }; +} + +beforeEach(() => { + originalAimuxDir = getAimuxDir(); + mkdirSync(TEST_DIR, { recursive: true }); + setAimuxDir(join(TEST_DIR, '.aimux')); +}); + +afterEach(() => { + setAimuxDir(originalAimuxDir); + rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +describe('summarizeUsage', () => { + it('attributes transcript usage to profiles via .claude.json session ownership', () => { + writeProfileSession('work', 'session-a', 1000); + writeTranscript('-tmp-project', 'session-a', [ + assistantLine('session-a', 'req-1', { + input_tokens: 10, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 30, + output_tokens: 40, + }), + ]); + + const summaries = summarizeUsage(makeConfig()); + const work = summaries.find((s) => s.profile === 'work')!; + expect(work.sessions).toBe(1); + expect(work.requests).toBe(1); + expect(totalTokens(work)).toBe(100); + expect(work.models.get('claude-opus-4-7')).toBe(1); + }); + + it('deduplicates repeated transcript lines for the same requestId', () => { + writeProfileSession('work', 'session-a', 1000); + const repeated = assistantLine('session-a', 'req-1', { + input_tokens: 10, + output_tokens: 5, + }); + writeTranscript('-tmp-project', 'session-a', [repeated, repeated]); + + const work = summarizeUsage(makeConfig()).find((s) => s.profile === 'work')!; + expect(work.requests).toBe(1); + expect(work.inputTokens).toBe(10); + expect(work.outputTokens).toBe(5); + }); + + it('deduplicates forked sessions that share a requestId', () => { + writeProfileSessions('work', [ + { sessionId: 'session-original', modified: 1000 }, + { sessionId: 'session-fork', modified: 2000 }, + ]); + writeTranscript('-tmp-project', 'session-original', [ + assistantLine('session-original', 'req-shared', { + input_tokens: 10, + output_tokens: 5, + }), + ]); + writeTranscript('-tmp-project', 'session-fork', [ + assistantLine('session-fork', 'req-shared', { + input_tokens: 10, + output_tokens: 5, + }, LATER_TS), + ]); + + const work = summarizeUsage(makeConfig()).find((s) => s.profile === 'work')!; + expect(work.requests).toBe(1); + expect(work.inputTokens).toBe(10); + expect(work.outputTokens).toBe(5); + }); + + it('skips subagent transcripts', () => { + writeProfileSession('work', 'session-subagent', 1000); + writeTranscript('-tmp-project', 'session-subagent', [ + queueOperationLine(), + assistantLine('session-subagent', 'req-subagent', { + input_tokens: 1000, + output_tokens: 500, + }), + ]); + + const work = summarizeUsage(makeConfig()).find((s) => s.profile === 'work')!; + expect(work.requests).toBe(0); + expect(totalTokens(work)).toBe(0); + }); + + it('counts stable line fallback keys when request identifiers are missing', () => { + writeProfileSession('work', 'session-a', 1000); + const line = assistantLine('session-a', '', { + input_tokens: 10, + estimated_cost_usd: 0.01, + }); + delete line.requestId; + delete line.message.id; + writeTranscript('-tmp-project', 'session-a', [line, line]); + + const work = summarizeUsage(makeConfig()).find((s) => s.profile === 'work')!; + expect(work.requests).toBe(2); + expect(work.inputTokens).toBe(20); + expect(work.estimatedCostUsd).toBe(0.02); + }); + + it('ignores malformed non-numeric usage values', () => { + writeProfileSession('work', 'session-a', 1000); + writeTranscript('-tmp-project', 'session-a', [ + assistantLine('session-a', 'req-bad', { + input_tokens: '10', + output_tokens: Number.NaN, + }), + ]); + + const work = summarizeUsage(makeConfig()).find((s) => s.profile === 'work')!; + expect(work.requests).toBe(1); + expect(totalTokens(work)).toBe(0); + }); + + it('filters by profile and since timestamp', () => { + writeProfileSession('main', 'session-main', 1000); + writeProfileSession('work', 'session-work', 1000); + writeTranscript('-tmp-project', 'session-main', [ + assistantLine('session-main', 'req-main', { input_tokens: 100 }, OLD_TS), + ]); + writeTranscript('-tmp-project', 'session-work', [ + assistantLine('session-work', 'req-old', { input_tokens: 100 }, OLD_TS), + assistantLine('session-work', 'req-new', { input_tokens: 200 }), + ]); + + const summaries = summarizeUsage(makeConfig(), { + profile: 'work', + sinceMs: Date.parse(CUTOFF_TS), + }); + expect(summaries.map((s) => s.profile)).toEqual(['work']); + expect(summaries[0].requests).toBe(1); + expect(summaries[0].inputTokens).toBe(200); + }); +}); + +describe('parseSinceDuration', () => { + it('parses hours, days, and weeks', () => { + const now = Date.parse(NOW_TS); + expect(parseSinceDuration('24h', now)).toBe(now - 24 * 60 * 60 * 1000); + expect(parseSinceDuration('7d', now)).toBe(now - 7 * 24 * 60 * 60 * 1000); + expect(parseSinceDuration('2w', now)).toBe(now - 14 * 24 * 60 * 60 * 1000); + }); + + it('rejects invalid durations', () => { + expect(() => parseSinceDuration('yesterday')).toThrow('Invalid duration'); + }); +}); diff --git a/src/core/usage.ts b/src/core/usage.ts new file mode 100644 index 0000000..e1418a1 --- /dev/null +++ b/src/core/usage.ts @@ -0,0 +1,230 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import type { AimuxConfig } from '../types/index.js'; +import { expandHome } from './paths.js'; +import { loadSessionHistory } from './sessionHistory.js'; +import { buildProfileSessionMap } from './profileSessionMap.js'; +import { parseSessionJsonl, quickFirstLineType } from './sessionScanner.js'; + +export interface UsageTotals { + inputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + outputTokens: number; + estimatedCostUsd: number; +} + +export interface ProfileUsageSummary extends UsageTotals { + profile: string; + sessions: number; + requests: number; + models: Map; +} + +export interface UsageOptions { + sinceMs?: number; + profile?: string; +} + +interface TranscriptLine { + type?: string; + timestamp?: string; + requestId?: string; + uuid?: string; + sessionId?: string; + message?: { + id?: string; + model?: string; + usage?: UsagePayload; + }; +} + +interface UsagePayload { + input_tokens?: unknown; + cache_creation_input_tokens?: unknown; + cache_read_input_tokens?: unknown; + output_tokens?: unknown; + estimated_cost_usd?: unknown; + cost_usd?: unknown; +} + +function parseJson(line: string): TranscriptLine | null { + try { + return JSON.parse(line) as TranscriptLine; + } catch { + return null; + } +} + +function emptySummary(profile: string): ProfileUsageSummary { + return { + profile, + sessions: 0, + requests: 0, + inputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + outputTokens: 0, + estimatedCostUsd: 0, + models: new Map(), + }; +} + +function numberValue(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +function addUsage(summary: ProfileUsageSummary, usage: UsagePayload): void { + summary.inputTokens += numberValue(usage.input_tokens); + summary.cacheCreationInputTokens += numberValue(usage.cache_creation_input_tokens); + summary.cacheReadInputTokens += numberValue(usage.cache_read_input_tokens); + summary.outputTokens += numberValue(usage.output_tokens); + summary.estimatedCostUsd += numberValue(usage.estimated_cost_usd ?? usage.cost_usd); +} + +function resolveLineTime(line: TranscriptLine, fallbackMs: number): number { + if (typeof line.timestamp === 'string') { + const parsed = Date.parse(line.timestamp); + if (!Number.isNaN(parsed)) return parsed; + } + return fallbackMs; +} + +function requestKey(sessionId: string, line: TranscriptLine, lineIndex: number): string { + if (line.requestId) return `request:${line.requestId}`; + if (line.message?.id) return `${sessionId}:message:${line.message.id}`; + if (line.uuid) return `${sessionId}:uuid:${line.uuid}`; + return `${sessionId}:line:${lineIndex}`; +} + +function formatModel(model: string | undefined): string { + return model && model.trim() ? model : 'unknown'; +} + +export function parseSinceDuration(input: string, nowMs = Date.now()): number { + const match = input.trim().match(/^(\d+(?:\.\d+)?)([hdw])$/i); + if (!match) { + throw new Error(`Invalid duration '${input}'. Use values like 24h, 7d, or 4w.`); + } + const amount = Number(match[1]); + const unit = match[2].toLowerCase(); + const multiplier = + unit === 'h' + ? 60 * 60 * 1000 + : unit === 'd' + ? 24 * 60 * 60 * 1000 + : 7 * 24 * 60 * 60 * 1000; + return nowMs - amount * multiplier; +} + +export function summarizeUsage(config: AimuxConfig, options: UsageOptions = {}): ProfileUsageSummary[] { + const projectsRoot = join(expandHome(config.shared_source), 'projects'); + const summaries = new Map(); + const sessionSets = new Map>(); + const seenRequests = new Set(); + const history = loadSessionHistory(); + const profileMap = buildProfileSessionMap(config); + + for (const profile of Object.keys(config.profiles)) { + summaries.set(profile, emptySummary(profile)); + sessionSets.set(profile, new Set()); + } + + if (!existsSync(projectsRoot)) { + return Array.from(summaries.values()).filter((s) => !options.profile || s.profile === options.profile); + } + + let cwdDirs: string[]; + try { + cwdDirs = readdirSync(projectsRoot); + } catch { + return Array.from(summaries.values()).filter((s) => !options.profile || s.profile === options.profile); + } + + for (const cwdDir of cwdDirs) { + const dirPath = join(projectsRoot, cwdDir); + let files: string[]; + try { + files = readdirSync(dirPath).filter((f) => f.endsWith('.jsonl')); + } catch { + continue; + } + + for (const file of files) { + const filePath = join(dirPath, file); + let stat; + try { + stat = statSync(filePath); + } catch { + continue; + } + if (options.sinceMs !== undefined && stat.mtimeMs < options.sinceMs) continue; + if (quickFirstLineType(filePath) === 'queue-operation') continue; + if (parseSessionJsonl(filePath).isSubagent) continue; + + const fallbackSessionId = file.replace(/\.jsonl$/, ''); + const fallbackProfile = + history.get(fallbackSessionId)?.profile ?? profileMap.get(fallbackSessionId)?.profile ?? 'unknown'; + let lines: string[]; + try { + lines = readFileSync(filePath, 'utf-8').split('\n'); + } catch { + continue; + } + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + if (!raw) continue; + const line = parseJson(raw); + const usage = line?.message?.usage; + if (!line || line.type !== 'assistant' || !usage) continue; + const lineMs = resolveLineTime(line, stat.mtimeMs); + if (options.sinceMs !== undefined && lineMs < options.sinceMs) continue; + + const sessionId = line.sessionId ?? fallbackSessionId; + const profile = + history.get(sessionId)?.profile ?? profileMap.get(sessionId)?.profile ?? fallbackProfile; + if (options.profile && profile !== options.profile) continue; + + const key = requestKey(sessionId, line, i); + if (seenRequests.has(key)) continue; + seenRequests.add(key); + + if (!summaries.has(profile)) { + summaries.set(profile, emptySummary(profile)); + sessionSets.set(profile, new Set()); + } + const summary = summaries.get(profile)!; + summary.requests += 1; + addUsage(summary, usage); + const model = formatModel(line.message?.model); + summary.models.set(model, (summary.models.get(model) ?? 0) + 1); + sessionSets.get(profile)!.add(sessionId); + } + } + } + + for (const [profile, sessions] of sessionSets) { + const summary = summaries.get(profile); + if (summary) summary.sessions = sessions.size; + } + + return Array.from(summaries.values()) + .filter((s) => !options.profile || s.profile === options.profile) + .sort((a, b) => { + if (a.profile === 'unknown') return 1; + if (b.profile === 'unknown') return -1; + const totalA = a.inputTokens + a.cacheCreationInputTokens + a.cacheReadInputTokens + a.outputTokens; + const totalB = b.inputTokens + b.cacheCreationInputTokens + b.cacheReadInputTokens + b.outputTokens; + return totalB - totalA || a.profile.localeCompare(b.profile); + }); +} + +export function totalTokens(summary: UsageTotals): number { + return ( + summary.inputTokens + + summary.cacheCreationInputTokens + + summary.cacheReadInputTokens + + summary.outputTokens + ); +}