1212 */
1313
1414import { Router , Request , Response } from 'express' ;
15+ import * as fs from 'fs' ;
16+ import * as path from 'path' ;
17+ import * as os from 'os' ;
1518import {
1619 loadDailyUsageData ,
1720 loadMonthlyUsageData ,
@@ -29,6 +32,169 @@ import {
2932 getCacheAge ,
3033} from './usage-disk-cache' ;
3134
35+ // ============================================================================
36+ // Multi-Instance Support - Aggregate usage from CCS profiles
37+ // ============================================================================
38+
39+ /** Path to CCS instances directory */
40+ const CCS_INSTANCES_DIR = path . join ( os . homedir ( ) , '.ccs' , 'instances' ) ;
41+
42+ /**
43+ * Get list of CCS instance paths that have usage data
44+ * Only returns instances with existing projects/ directory
45+ */
46+ function getInstancePaths ( ) : string [ ] {
47+ if ( ! fs . existsSync ( CCS_INSTANCES_DIR ) ) {
48+ return [ ] ;
49+ }
50+
51+ try {
52+ const entries = fs . readdirSync ( CCS_INSTANCES_DIR , { withFileTypes : true } ) ;
53+ return entries
54+ . filter ( ( entry ) => entry . isDirectory ( ) )
55+ . map ( ( entry ) => path . join ( CCS_INSTANCES_DIR , entry . name ) )
56+ . filter ( ( instancePath ) => {
57+ // Only include instances that have a projects directory
58+ const projectsPath = path . join ( instancePath , 'projects' ) ;
59+ return fs . existsSync ( projectsPath ) ;
60+ } ) ;
61+ } catch {
62+ console . error ( '[!] Failed to read CCS instances directory' ) ;
63+ return [ ] ;
64+ }
65+ }
66+
67+ /**
68+ * Load usage data from a specific instance by temporarily setting CLAUDE_CONFIG_DIR
69+ * Returns empty arrays if instance has no usage data
70+ */
71+ async function loadInstanceData ( instancePath : string ) : Promise < {
72+ daily : DailyUsage [ ] ;
73+ monthly : MonthlyUsage [ ] ;
74+ session : SessionUsage [ ] ;
75+ } > {
76+ const originalConfigDir = process . env . CLAUDE_CONFIG_DIR ;
77+
78+ try {
79+ // Set CLAUDE_CONFIG_DIR to instance path for better-ccusage to read from
80+ process . env . CLAUDE_CONFIG_DIR = instancePath ;
81+
82+ const [ daily , monthly , session ] = await Promise . all ( [
83+ loadDailyUsageData ( ) as Promise < DailyUsage [ ] > ,
84+ loadMonthlyUsageData ( ) as Promise < MonthlyUsage [ ] > ,
85+ loadSessionData ( ) as Promise < SessionUsage [ ] > ,
86+ ] ) ;
87+
88+ return { daily, monthly, session } ;
89+ } catch ( _err ) {
90+ // Instance may have no usage data - that's OK
91+ const instanceName = path . basename ( instancePath ) ;
92+ console . log ( `[i] No usage data in instance: ${ instanceName } ` ) ;
93+ return { daily : [ ] , monthly : [ ] , session : [ ] } ;
94+ } finally {
95+ // Restore original env var
96+ if ( originalConfigDir === undefined ) {
97+ delete process . env . CLAUDE_CONFIG_DIR ;
98+ } else {
99+ process . env . CLAUDE_CONFIG_DIR = originalConfigDir ;
100+ }
101+ }
102+ }
103+
104+ /**
105+ * Merge daily usage data from multiple sources
106+ * Combines entries with same date by aggregating tokens
107+ */
108+ function mergeDailyData ( sources : DailyUsage [ ] [ ] ) : DailyUsage [ ] {
109+ const dateMap = new Map < string , DailyUsage > ( ) ;
110+
111+ for ( const source of sources ) {
112+ for ( const day of source ) {
113+ const existing = dateMap . get ( day . date ) ;
114+ if ( existing ) {
115+ // Aggregate tokens for same date
116+ existing . inputTokens += day . inputTokens ;
117+ existing . outputTokens += day . outputTokens ;
118+ existing . cacheCreationTokens += day . cacheCreationTokens ;
119+ existing . cacheReadTokens += day . cacheReadTokens ;
120+ existing . totalCost += day . totalCost ;
121+ // Merge unique models
122+ const modelSet = new Set ( [ ...existing . modelsUsed , ...day . modelsUsed ] ) ;
123+ existing . modelsUsed = Array . from ( modelSet ) ;
124+ // Merge model breakdowns by aggregating same modelName
125+ for ( const breakdown of day . modelBreakdowns ) {
126+ const existingBreakdown = existing . modelBreakdowns . find (
127+ ( b ) => b . modelName === breakdown . modelName
128+ ) ;
129+ if ( existingBreakdown ) {
130+ existingBreakdown . inputTokens += breakdown . inputTokens ;
131+ existingBreakdown . outputTokens += breakdown . outputTokens ;
132+ existingBreakdown . cacheCreationTokens += breakdown . cacheCreationTokens ;
133+ existingBreakdown . cacheReadTokens += breakdown . cacheReadTokens ;
134+ existingBreakdown . cost += breakdown . cost ;
135+ } else {
136+ existing . modelBreakdowns . push ( { ...breakdown } ) ;
137+ }
138+ }
139+ } else {
140+ // Clone to avoid mutating original
141+ dateMap . set ( day . date , {
142+ ...day ,
143+ modelsUsed : [ ...day . modelsUsed ] ,
144+ modelBreakdowns : day . modelBreakdowns . map ( ( b ) => ( { ...b } ) ) ,
145+ } ) ;
146+ }
147+ }
148+ }
149+
150+ return Array . from ( dateMap . values ( ) ) . sort ( ( a , b ) => a . date . localeCompare ( b . date ) ) ;
151+ }
152+
153+ /**
154+ * Merge monthly usage data from multiple sources
155+ */
156+ function mergeMonthlyData ( sources : MonthlyUsage [ ] [ ] ) : MonthlyUsage [ ] {
157+ const monthMap = new Map < string , MonthlyUsage > ( ) ;
158+
159+ for ( const source of sources ) {
160+ for ( const month of source ) {
161+ const existing = monthMap . get ( month . month ) ;
162+ if ( existing ) {
163+ existing . inputTokens += month . inputTokens ;
164+ existing . outputTokens += month . outputTokens ;
165+ existing . cacheCreationTokens += month . cacheCreationTokens ;
166+ existing . cacheReadTokens += month . cacheReadTokens ;
167+ existing . totalCost += month . totalCost ;
168+ const modelSet = new Set ( [ ...existing . modelsUsed , ...month . modelsUsed ] ) ;
169+ existing . modelsUsed = Array . from ( modelSet ) ;
170+ } else {
171+ monthMap . set ( month . month , { ...month , modelsUsed : [ ...month . modelsUsed ] } ) ;
172+ }
173+ }
174+ }
175+
176+ return Array . from ( monthMap . values ( ) ) . sort ( ( a , b ) => a . month . localeCompare ( b . month ) ) ;
177+ }
178+
179+ /**
180+ * Merge session data from multiple sources
181+ * Deduplicates by sessionId (same session shouldn't appear in multiple instances)
182+ */
183+ function mergeSessionData ( sources : SessionUsage [ ] [ ] ) : SessionUsage [ ] {
184+ const sessionMap = new Map < string , SessionUsage > ( ) ;
185+
186+ for ( const source of sources ) {
187+ for ( const session of source ) {
188+ // Use sessionId as unique key - later entries overwrite earlier ones
189+ sessionMap . set ( session . sessionId , session ) ;
190+ }
191+ }
192+
193+ return Array . from ( sessionMap . values ( ) ) . sort (
194+ ( a , b ) => new Date ( b . lastActivity ) . getTime ( ) - new Date ( a . lastActivity ) . getTime ( )
195+ ) ;
196+ }
197+
32198export const usageRoutes = Router ( ) ;
33199
34200/** Query parameters for usage endpoints */
@@ -195,17 +361,57 @@ let isRefreshing = false;
195361
196362/**
197363 * Load fresh data from better-ccusage and update both memory and disk caches
364+ * Aggregates data from default ~/.claude/ AND all CCS instances
198365 */
199366async function refreshFromSource ( ) : Promise < {
200367 daily : DailyUsage [ ] ;
201368 monthly : MonthlyUsage [ ] ;
202369 session : SessionUsage [ ] ;
203370} > {
204- const [ daily , monthly , session ] = await Promise . all ( [
371+ // Load default data (from ~/.claude/ or current CLAUDE_CONFIG_DIR)
372+ const defaultData = await Promise . all ( [
205373 loadDailyUsageData ( ) as Promise < DailyUsage [ ] > ,
206374 loadMonthlyUsageData ( ) as Promise < MonthlyUsage [ ] > ,
207375 loadSessionData ( ) as Promise < SessionUsage [ ] > ,
208- ] ) ;
376+ ] ) . then ( ( [ daily , monthly , session ] ) => ( { daily, monthly, session } ) ) ;
377+
378+ // Load data from all CCS instances sequentially (to avoid env var race condition)
379+ const instancePaths = getInstancePaths ( ) ;
380+ const instanceDataResults : Array < {
381+ daily : DailyUsage [ ] ;
382+ monthly : MonthlyUsage [ ] ;
383+ session : SessionUsage [ ] ;
384+ } > = [ ] ;
385+
386+ for ( const instancePath of instancePaths ) {
387+ try {
388+ const data = await loadInstanceData ( instancePath ) ;
389+ instanceDataResults . push ( data ) ;
390+ } catch ( err ) {
391+ const instanceName = path . basename ( instancePath ) ;
392+ console . error ( `[!] Failed to load instance ${ instanceName } :` , err ) ;
393+ }
394+ }
395+
396+ // Collect successful instance data
397+ const allDailySources : DailyUsage [ ] [ ] = [ defaultData . daily ] ;
398+ const allMonthlySources : MonthlyUsage [ ] [ ] = [ defaultData . monthly ] ;
399+ const allSessionSources : SessionUsage [ ] [ ] = [ defaultData . session ] ;
400+
401+ for ( const result of instanceDataResults ) {
402+ allDailySources . push ( result . daily ) ;
403+ allMonthlySources . push ( result . monthly ) ;
404+ allSessionSources . push ( result . session ) ;
405+ }
406+
407+ if ( instanceDataResults . length > 0 ) {
408+ console . log ( `[i] Aggregated usage data from ${ instanceDataResults . length } CCS instance(s)` ) ;
409+ }
410+
411+ // Merge all data sources
412+ const daily = mergeDailyData ( allDailySources ) ;
413+ const monthly = mergeMonthlyData ( allMonthlySources ) ;
414+ const session = mergeSessionData ( allSessionSources ) ;
209415
210416 // Update in-memory cache
211417 const now = Date . now ( ) ;
0 commit comments