Skip to content

Commit 6e96ff7

Browse files
authored
feat(ccusage): support comma-separated agent paths (#1011)
* feat(ccusage): support comma-separated agent paths Add shared path-list parsing so Codex, OpenCode, Amp, and pi-agent can read usage data from comma-separated directory lists. This aligns the newer agent adapters with Claude path handling while preserving the existing single-directory defaults. Codex now aggregates all CODEX_HOME session roots and checks each home for fast pricing config. OpenCode and Amp discover files across all valid data roots, and pi-agent accepts comma-separated --pi-path and PI_AGENT_DIR values. Regenerate the config schema so piPath autocomplete documents comma-separated session directories. * fix(ccusage): deduplicate opencode db messages Filter OpenCode DB records by message id before appending them to the aggregated result. This prevents repeated database rows from multiple OPENCODE_DATA_DIR roots from being counted twice. Add a SQLite-backed regression test that creates two OpenCode roots with the same message id and confirms only one usage entry is returned. * docs(ccusage): document comma-separated source paths Document comma-separated data directory support for Codex, OpenCode, Amp, and pi-agent after the source-focused documentation restructure from main. The docs now show environment variable examples for multiple roots, note Codex speed auto detection across CODEX_HOME roots, and describe piPath support in the JSON config schema. * docs(ccusage): remove duplicate pi path example Remove a duplicated comma-separated pi-agent path example from the package README after adding the docs coverage.
1 parent 915e272 commit 6e96ff7

23 files changed

Lines changed: 515 additions & 147 deletions

apps/ccusage/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ bunx ccusage codex daily --speed fast
9191
bunx ccusage opencode weekly
9292
bunx ccusage amp session
9393
bunx ccusage pi daily --pi-path /path/to/sessions
94+
bunx ccusage pi daily --pi-path /path/to/sessions,/archive/pi/sessions
9495

9596
# Explicit unified report
9697
bunx ccusage daily --all

apps/ccusage/config-schema.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1982,8 +1982,8 @@
19821982
},
19831983
"piPath": {
19841984
"type": "string",
1985-
"description": "Path to pi-agent sessions directory",
1986-
"markdownDescription": "Path to pi-agent sessions directory"
1985+
"description": "Path or comma-separated paths to pi-agent sessions directories",
1986+
"markdownDescription": "Path or comma-separated paths to pi-agent sessions directories"
19871987
}
19881988
},
19891989
"additionalProperties": false,
@@ -2041,8 +2041,8 @@
20412041
},
20422042
"piPath": {
20432043
"type": "string",
2044-
"description": "Path to pi-agent sessions directory",
2045-
"markdownDescription": "Path to pi-agent sessions directory"
2044+
"description": "Path or comma-separated paths to pi-agent sessions directories",
2045+
"markdownDescription": "Path or comma-separated paths to pi-agent sessions directories"
20462046
}
20472047
},
20482048
"additionalProperties": false
@@ -2095,8 +2095,8 @@
20952095
},
20962096
"piPath": {
20972097
"type": "string",
2098-
"description": "Path to pi-agent sessions directory",
2099-
"markdownDescription": "Path to pi-agent sessions directory"
2098+
"description": "Path or comma-separated paths to pi-agent sessions directories",
2099+
"markdownDescription": "Path or comma-separated paths to pi-agent sessions directories"
21002100
}
21012101
},
21022102
"additionalProperties": false
@@ -2149,8 +2149,8 @@
21492149
},
21502150
"piPath": {
21512151
"type": "string",
2152-
"description": "Path to pi-agent sessions directory",
2153-
"markdownDescription": "Path to pi-agent sessions directory"
2152+
"description": "Path or comma-separated paths to pi-agent sessions directories",
2153+
"markdownDescription": "Path or comma-separated paths to pi-agent sessions directories"
21542154
}
21552155
},
21562156
"additionalProperties": false

apps/ccusage/src/adapter/amp/parser.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,5 +190,41 @@ if (import.meta.vitest != null) {
190190
},
191191
]);
192192
});
193+
194+
it('loads Amp thread usage events from comma-separated AMP_DATA_DIR entries', async () => {
195+
const createThread = (id: string, input: number): string =>
196+
JSON.stringify({
197+
id,
198+
usageLedger: {
199+
events: [
200+
{
201+
timestamp: '2026-05-01T01:02:03.000Z',
202+
model: 'claude-sonnet-4-20250514',
203+
credits: 1,
204+
tokens: {
205+
input,
206+
output: 1,
207+
},
208+
},
209+
],
210+
},
211+
});
212+
await using fixture1 = await createFixture({
213+
threads: {
214+
'a.json': createThread('thread-a', 10),
215+
},
216+
});
217+
await using fixture2 = await createFixture({
218+
threads: {
219+
'b.json': createThread('thread-b', 20),
220+
},
221+
});
222+
vi.stubEnv('AMP_DATA_DIR', `${fixture1.path},${fixture2.path}`);
223+
224+
await expect(loadAmpUsageEvents()).resolves.toMatchObject([
225+
{ threadId: 'thread-a', inputTokens: 10 },
226+
{ threadId: 'thread-b', inputTokens: 20 },
227+
]);
228+
});
193229
});
194230
}

apps/ccusage/src/adapter/amp/paths.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,40 @@
11
import os from 'node:os';
22
import path from 'node:path';
33
import process from 'node:process';
4-
import { collectFilesRecursive, hasFileRecursive, isDirectorySyncSafe } from '@ccusage/internal/fs';
4+
import { collectFilesRecursive, hasFileRecursive } from '@ccusage/internal/fs';
55
import { createFixture } from 'fs-fixture';
6+
import { getExistingDirectories, normalizePathList } from '../path-list.ts';
67

78
export const AMP_DATA_DIR_ENV = 'AMP_DATA_DIR';
89
export const AMP_THREADS_DIR_NAME = 'threads';
910
const DEFAULT_AMP_DIR = path.join(os.homedir(), '.local/share/amp');
1011

11-
export function getAmpPath(): string | null {
12-
const envPath = process.env[AMP_DATA_DIR_ENV];
13-
if (envPath != null && envPath.trim() !== '') {
14-
const normalizedPath = path.resolve(envPath);
15-
return isDirectorySyncSafe(normalizedPath) ? normalizedPath : null;
16-
}
12+
export function getAmpPaths(): string[] {
13+
return getExistingDirectories(
14+
normalizePathList(process.env[AMP_DATA_DIR_ENV], [DEFAULT_AMP_DIR]),
15+
);
16+
}
1717

18-
return isDirectorySyncSafe(DEFAULT_AMP_DIR) ? DEFAULT_AMP_DIR : null;
18+
export function getAmpPath(): string | null {
19+
return getAmpPaths()[0] ?? null;
1920
}
2021

2122
export async function discoverAmpThreadFiles(): Promise<string[]> {
22-
const ampPath = getAmpPath();
23-
if (ampPath == null) {
24-
return [];
25-
}
26-
return collectFilesRecursive(path.join(ampPath, AMP_THREADS_DIR_NAME), { extension: '.json' });
23+
const files = await Promise.all(
24+
getAmpPaths().map(async (ampPath) =>
25+
collectFilesRecursive(path.join(ampPath, AMP_THREADS_DIR_NAME), { extension: '.json' }),
26+
),
27+
);
28+
return files.flat();
2729
}
2830

2931
export async function detectAmpThreadFiles(): Promise<boolean> {
30-
const ampPath = getAmpPath();
31-
return ampPath == null
32-
? false
33-
: hasFileRecursive(path.join(ampPath, AMP_THREADS_DIR_NAME), { extension: '.json' });
32+
const results = await Promise.all(
33+
getAmpPaths().map(async (ampPath) =>
34+
hasFileRecursive(path.join(ampPath, AMP_THREADS_DIR_NAME), { extension: '.json' }),
35+
),
36+
);
37+
return results.some(Boolean);
3438
}
3539

3640
if (import.meta.vitest != null) {
@@ -54,6 +58,18 @@ if (import.meta.vitest != null) {
5458
expect(getAmpPath()).toBeNull();
5559
});
5660

61+
it('returns directories for comma-separated AMP_DATA_DIR entries', async () => {
62+
await using fixture1 = await createFixture({
63+
threads: {},
64+
});
65+
await using fixture2 = await createFixture({
66+
threads: {},
67+
});
68+
vi.stubEnv(AMP_DATA_DIR_ENV, `${fixture1.path}, ,${fixture2.path},`);
69+
70+
expect(getAmpPaths()).toEqual([path.resolve(fixture1.path), path.resolve(fixture2.path)]);
71+
});
72+
5773
it('discovers thread JSON files under the Amp data directory', async () => {
5874
await using fixture = await createFixture({
5975
threads: {

apps/ccusage/src/adapter/codex/parser.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ import {
1818
getFileWorkerThreadCount,
1919
} from '@ccusage/internal/workers';
2020
import { Result } from '@praha/byethrow';
21+
import { createFixture } from 'fs-fixture';
2122
import { logger } from '../../logger.ts';
22-
import { getCodexSessionsPath } from './paths.ts';
23+
import { getCodexSessionsPaths } from './paths.ts';
2324

2425
const LEGACY_FALLBACK_MODEL = 'gpt-5';
2526
const CODEX_JSONL_MARKERS = ['turn_context', '"type":"token_count"', '"type": "token_count"'];
@@ -383,8 +384,9 @@ function decodeTokenUsageEvents(encoded: EncodedTokenUsageEvents): TokenUsageEve
383384
return events;
384385
}
385386

386-
export async function loadTokenUsageEvents(): Promise<TokenUsageEvent[]> {
387-
const directoryPath = getCodexSessionsPath();
387+
async function loadTokenUsageEventsFromDirectory(
388+
directoryPath: string,
389+
): Promise<TokenUsageEvent[]> {
388390
const statResult = await Result.try({
389391
try: stat(directoryPath),
390392
catch: (error) => error,
@@ -405,6 +407,13 @@ export async function loadTokenUsageEvents(): Promise<TokenUsageEvent[]> {
405407
return fileEvents.flat().sort((a, b) => compareStrings(a.timestamp, b.timestamp));
406408
}
407409

410+
export async function loadTokenUsageEvents(): Promise<TokenUsageEvent[]> {
411+
const directoryEvents = await Promise.all(
412+
getCodexSessionsPaths().map(loadTokenUsageEventsFromDirectory),
413+
);
414+
return directoryEvents.flat().sort((a, b) => compareStrings(a.timestamp, b.timestamp));
415+
}
416+
408417
async function runCodexWorker(data: CodexWorkerData): Promise<void> {
409418
const results = [];
410419
const transferList: ArrayBuffer[] = [];
@@ -680,6 +689,53 @@ if (import.meta.vitest != null) {
680689
});
681690
});
682691

692+
describe('loadTokenUsageEvents', () => {
693+
afterEach(() => {
694+
vi.unstubAllEnvs();
695+
});
696+
697+
it('loads Codex usage events from comma-separated CODEX_HOME entries', async () => {
698+
const sessionLines = (model: string, inputTokens: number): string =>
699+
[
700+
JSON.stringify({
701+
timestamp: '2026-01-01T00:00:00.000Z',
702+
type: 'turn_context',
703+
payload: { model },
704+
}),
705+
JSON.stringify({
706+
timestamp: '2026-01-01T00:00:01.000Z',
707+
type: 'event_msg',
708+
payload: {
709+
type: 'token_count',
710+
info: {
711+
last_token_usage: {
712+
input_tokens: inputTokens,
713+
output_tokens: 1,
714+
total_tokens: inputTokens + 1,
715+
},
716+
},
717+
},
718+
}),
719+
].join('\n');
720+
await using fixture1 = await createFixture({
721+
sessions: {
722+
'a.jsonl': sessionLines('gpt-5.1', 10),
723+
},
724+
});
725+
await using fixture2 = await createFixture({
726+
sessions: {
727+
'b.jsonl': sessionLines('gpt-5.2', 20),
728+
},
729+
});
730+
vi.stubEnv('CODEX_HOME', `${fixture1.path},${fixture2.path}`);
731+
732+
await expect(loadTokenUsageEvents()).resolves.toMatchObject([
733+
{ sessionId: 'a', model: 'gpt-5.1', inputTokens: 10 },
734+
{ sessionId: 'b', model: 'gpt-5.2', inputTokens: 20 },
735+
]);
736+
});
737+
});
738+
683739
describe('getCodexWorkerThreadCount', () => {
684740
it('uses Claude-style bundled worker gating', () => {
685741
expect(getCodexWorkerThreadCount(100)).toBe(0);

apps/ccusage/src/adapter/codex/paths.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,31 @@ import path from 'node:path';
33
import process from 'node:process';
44
import { hasFileRecursive } from '@ccusage/internal/fs';
55
import { createFixture } from 'fs-fixture';
6+
import { normalizePathList } from '../path-list.ts';
67

78
export const CODEX_HOME_ENV = 'CODEX_HOME';
89
const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex');
910
const DEFAULT_SESSION_SUBDIR = 'sessions';
1011

12+
export function getCodexHomePaths(): string[] {
13+
return normalizePathList(process.env[CODEX_HOME_ENV], [DEFAULT_CODEX_DIR]);
14+
}
15+
16+
export function getCodexSessionsPaths(): string[] {
17+
return getCodexHomePaths().map((codexHome) => path.join(codexHome, DEFAULT_SESSION_SUBDIR));
18+
}
19+
1120
export function getCodexSessionsPath(): string {
12-
const codexHome = process.env[CODEX_HOME_ENV]?.trim();
13-
return path.join(
14-
codexHome == null || codexHome === '' ? DEFAULT_CODEX_DIR : path.resolve(codexHome),
15-
DEFAULT_SESSION_SUBDIR,
16-
);
21+
return getCodexSessionsPaths()[0]!;
1722
}
1823

1924
export async function detectCodex(): Promise<boolean> {
20-
return hasFileRecursive(getCodexSessionsPath(), { extension: '.jsonl' });
25+
const results = await Promise.all(
26+
getCodexSessionsPaths().map(async (sessionsPath) =>
27+
hasFileRecursive(sessionsPath, { extension: '.jsonl' }),
28+
),
29+
);
30+
return results.some(Boolean);
2131
}
2232

2333
if (import.meta.vitest != null) {
@@ -46,5 +56,20 @@ if (import.meta.vitest != null) {
4656

4757
await expect(detectCodex()).resolves.toBe(false);
4858
});
59+
60+
it('returns session directories for comma-separated CODEX_HOME entries', async () => {
61+
await using fixture1 = await createFixture({
62+
sessions: {},
63+
});
64+
await using fixture2 = await createFixture({
65+
sessions: {},
66+
});
67+
vi.stubEnv(CODEX_HOME_ENV, `${fixture1.path}, ,${fixture2.path},`);
68+
69+
expect(getCodexSessionsPaths()).toEqual([
70+
fixture1.getPath('sessions'),
71+
fixture2.getPath('sessions'),
72+
]);
73+
});
4974
});
5075
}

apps/ccusage/src/adapter/codex/pricing.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { CodexModelUsage, CodexSpeed } from './types.ts';
33
import path from 'node:path';
44
import { readTextFile } from '@ccusage/internal/fs';
55
import { Result } from '@praha/byethrow';
6-
import { getCodexSessionsPath } from './paths.ts';
6+
import { getCodexHomePaths } from './paths.ts';
77
import { prefetchCodexPricing } from './pricing-macro.ts' with { type: 'macro' };
88

99
const MILLION = 1_000_000;
@@ -63,17 +63,21 @@ export async function resolveCodexSpeed(requested?: string): Promise<CodexSpeed>
6363
if (speed !== 'auto') {
6464
return speed;
6565
}
66-
const configPath = path.join(path.dirname(getCodexSessionsPath()), 'config.toml');
67-
const result = await Result.try({
68-
try: readTextFile(configPath),
69-
catch: (error) => error,
70-
});
71-
if (Result.isFailure(result)) {
72-
return 'standard';
66+
for (const configPath of getCodexHomePaths().map((codexHome) =>
67+
path.join(codexHome, 'config.toml'),
68+
)) {
69+
const result = await Result.try({
70+
try: readTextFile(configPath),
71+
catch: (error) => error,
72+
});
73+
if (
74+
!Result.isFailure(result) &&
75+
/(?:^|\n)\s*service_tier\s*=\s*["']?(?:fast|priority)["']?/iu.test(result.value)
76+
) {
77+
return 'fast';
78+
}
7379
}
74-
return /(?:^|\n)\s*service_tier\s*=\s*["']?(?:fast|priority)["']?/iu.test(result.value)
75-
? 'fast'
76-
: 'standard';
80+
return 'standard';
7781
}
7882

7983
export async function getCodexPricing(

apps/ccusage/src/adapter/opencode/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import { createFixture } from 'fs-fixture';
66
import { logger } from '../../logger.ts';
77
import { createAgentPricingContext, defineAgentLogLoader } from '../shared.ts';
88
import { loadOpenCodeMessages } from './loader.ts';
9-
import { detectOpenCodeSources, getOpenCodePath } from './paths.ts';
9+
import { detectOpenCodeSources, getOpenCodePaths } from './paths.ts';
1010
import { calculateOpenCodeCost } from './pricing.ts';
1111

1212
export async function detectOpenCode(): Promise<boolean> {
13-
const openCodePath = getOpenCodePath();
14-
return openCodePath != null && (await detectOpenCodeSources(openCodePath));
13+
const results = await Promise.all(getOpenCodePaths().map(detectOpenCodeSources));
14+
return results.some(Boolean);
1515
}
1616

1717
function createOpenCodePricingContext(

0 commit comments

Comments
 (0)