Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0a5b2dc
fix(ccusage): fix 4 agent command bugs
Mar 16, 2026
f08ba8d
fix(ccusage): wrap agent names at / boundary and prioritize compactio…
Mar 17, 2026
86cfe28
fix(ccusage): extract compaction titles from isCompactSummary entries
Mar 17, 2026
037b238
feat(ccusage): add find-agent-id script for recovering past agent res…
Mar 17, 2026
7d1a006
feat(ccusage): generate AI titles for sessions without compaction sum…
Mar 17, 2026
925cffc
refactor(ccusage): remove slug fallback, use truncated sessionId instead
Mar 17, 2026
5adbe50
feat(ccusage): group team members under their lead session in agent o…
Mar 17, 2026
8c73db1
fix(ccusage): handle array content and isMeta in user message extraction
Mar 17, 2026
4d28ef2
feat(ccusage): add % column and group all team members under headers
Mar 17, 2026
7d4a40b
feat(ccusage): discover orphan team→lead mappings for proper nesting
Mar 17, 2026
49b6b96
feat(ccusage): show truncated session ID on lead rows for cross-refer…
Mar 17, 2026
f5336f3
fix(ccusage): use full session UUID instead of 8-char prefix on lead …
Mar 17, 2026
e420bc2
fix(ccusage): widen Agent column to fit full 36-char session UUID
Mar 17, 2026
31228cd
refactor(ccusage): remove AI title generation, use compaction summari…
Mar 17, 2026
a275023
Revert "refactor(ccusage): remove AI title generation, use compaction…
Mar 17, 2026
9caecb6
fix(ccusage): improve AI title quality by filtering trivial messages
Mar 17, 2026
084a984
fix(ccusage): remove trailing · when session has no title
Mar 17, 2026
0a9b325
fix(ccusage): add ellipsis on truncated titles, bump cache to v12
Mar 17, 2026
ba842f3
fix(ccusage): fix title placeholders, trailing separators, and duplic…
Mar 17, 2026
58a6bee
fix(ccusage): show UUIDs on titled rows, parse teammate-message conte…
Mar 17, 2026
294ca4e
fix(ccusage): improve AI title generation for all sessions
Mar 17, 2026
5121f2d
fix(ccusage): strict AI titles, command-args extraction, compaction v…
Mar 17, 2026
31769cb
fix(ccusage): address CodeRabbit review feedback on agent command
Mar 17, 2026
422493b
fix(ccusage): enforce noun-phrase AI titles with few-shot examples
Mar 17, 2026
a1c57f0
fix(ccusage): skip bare UUIDs when extracting title from user messages
Mar 17, 2026
11bceb7
fix(ccusage): collect 5 user messages for AI titles, cap at 1000 chars
Mar 17, 2026
813687c
fix(ccusage): replace magic number with headers.length in separator row
Mar 17, 2026
676b0d6
fix(ccusage): filter bare URLs and condense multi-line messages for t…
Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/ccusage/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ This package contains the core ccusage functionality:
3. Calculates costs using LiteLLM pricing database
4. Outputs formatted tables or JSON

## Agent Command Notes

- **Pre-filter files by mtime**: The agent command scans session JSONL files. When a date filter is active (e.g., today only), check file modification time before parsing — avoids loading 3800+ files when only a handful are relevant.
- **Lead identifiability**: `lead-xxxx` entries (4-char session hash) are unidentifiable without project/directory context. Include the project name or working directory so users can map each lead to what they were working on.
- **Agent column width**: Must accommodate `team/agent-name` format (e.g., `ccusage-fork/cli-dev`) — test with realistic team names, not just short placeholders.
Comment thread
thomasttvo marked this conversation as resolved.

## Testing Guidelines

- **In-Source Testing**: Tests are written in the same files using `if (import.meta.vitest != null)` blocks
Expand Down
119 changes: 119 additions & 0 deletions apps/ccusage/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,125 @@
},
"additionalProperties": false
},
"agent": {
"type": "object",
"properties": {
"since": {
"type": "string",
"description": "Filter from date (YYYYMMDD format)",
"markdownDescription": "Filter from date (YYYYMMDD format)"
},
"until": {
"type": "string",
"description": "Filter until date (YYYYMMDD format)",
"markdownDescription": "Filter until date (YYYYMMDD format)"
},
"json": {
"type": "boolean",
"description": "Output in JSON format",
"markdownDescription": "Output in JSON format",
"default": false
},
"mode": {
"type": "string",
"enum": ["auto", "calculate", "display"],
"description": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)",
"markdownDescription": "Cost calculation mode: auto (use costUSD if exists, otherwise calculate), calculate (always calculate), display (always use costUSD)",
"default": "auto"
},
"debug": {
"type": "boolean",
"description": "Show pricing mismatch information for debugging",
"markdownDescription": "Show pricing mismatch information for debugging",
"default": false
},
"debugSamples": {
"type": "number",
"description": "Number of sample discrepancies to show in debug output (default: 5)",
"markdownDescription": "Number of sample discrepancies to show in debug output (default: 5)",
"default": 5
},
"order": {
"type": "string",
"enum": ["desc", "asc"],
"description": "Sort order: desc (newest first) or asc (oldest first)",
"markdownDescription": "Sort order: desc (newest first) or asc (oldest first)",
"default": "asc"
},
"breakdown": {
"type": "boolean",
"description": "Show per-model cost breakdown",
"markdownDescription": "Show per-model cost breakdown",
"default": false
},
"offline": {
"type": "boolean",
"description": "Use cached pricing data for Claude models instead of fetching from API",
"markdownDescription": "Use cached pricing data for Claude models instead of fetching from API",
"default": false
},
"color": {
"type": "boolean",
"description": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect.",
"markdownDescription": "Enable colored output (default: auto). FORCE_COLOR=1 has the same effect."
},
"noColor": {
"type": "boolean",
"description": "Disable colored output (default: auto). NO_COLOR=1 has the same effect.",
"markdownDescription": "Disable colored output (default: auto). NO_COLOR=1 has the same effect."
},
"timezone": {
"type": "string",
"description": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone",
"markdownDescription": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone"
},
"locale": {
"type": "string",
"description": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)",
"markdownDescription": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)",
"default": "en-CA"
},
"jq": {
"type": "string",
"description": "Process JSON output with jq command (requires jq binary, implies --json)",
"markdownDescription": "Process JSON output with jq command (requires jq binary, implies --json)"
},
"compact": {
"type": "boolean",
"description": "Force compact mode for narrow displays (better for screenshots)",
"markdownDescription": "Force compact mode for narrow displays (better for screenshots)",
"default": false
},
"team": {
"type": "string",
"description": "Filter to a specific team name",
"markdownDescription": "Filter to a specific team name"
},
"session": {
"type": "string",
"description": "Filter to a specific session ID",
"markdownDescription": "Filter to a specific session ID"
},
"all": {
"type": "boolean",
"description": "Show all-time data instead of today only",
"markdownDescription": "Show all-time data instead of today only",
"default": false
},
"days": {
"type": "number",
"description": "Show data from the last N days",
"markdownDescription": "Show data from the last N days"
},
Comment thread
thomasttvo marked this conversation as resolved.
"instances": {
"type": "boolean",
"description": "Show per-instance breakdown instead of role grouping",
"markdownDescription": "Show per-instance breakdown instead of role grouping",
"default": false
}
},
"additionalProperties": false
},
"blocks": {
"type": "object",
"properties": {
Expand Down
2 changes: 2 additions & 0 deletions apps/ccusage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
},
"exports": {
".": "./src/index.ts",
"./agent-id": "./src/agent-id.ts",
"./calculate-cost": "./src/calculate-cost.ts",
"./data-loader": "./src/data-loader.ts",
"./debug": "./src/debug.ts",
Expand All @@ -39,6 +40,7 @@
},
"exports": {
".": "./dist/index.js",
"./agent-id": "./dist/agent-id.js",
"./calculate-cost": "./dist/calculate-cost.js",
"./data-loader": "./dist/data-loader.js",
"./debug": "./dist/debug.js",
Expand Down
192 changes: 192 additions & 0 deletions apps/ccusage/src/agent-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* @fileoverview Utility for deriving human-readable agent IDs from usage data
*
* Produces IDs like "my-team/researcher-a1b2", "coder-c3d4", or "lead-e5f6"
* based on the teamName, agentName, and sessionId fields in JSONL entries.
*
* @module agent-id
*/

type AgentEntry = {
teamName?: string | undefined;
agentName?: string | undefined;
sessionId?: string | undefined;
};

/**
* Derives a human-readable agent ID from a usage entry's metadata.
*
* - teamName + agentName + sessionId → `{teamName}/{agentName}-{sessionId[0:4]}`
* - only agentName + sessionId → `{agentName}-{sessionId[0:4]}`
* - neither (main agent) → `lead-{sessionId[0:4]}`
* - no sessionId → just `lead`, `{agentName}`, or `{teamName}/{agentName}`
*/
export function deriveAgentId(entry: AgentEntry): string {
const { teamName, agentName, sessionId } = entry;
const suffix = sessionId != null ? `-${sessionId.slice(0, 4)}` : '';

if (teamName != null && agentName != null) {
return `${teamName}/${agentName}${suffix}`;
}
if (agentName != null) {
return `${agentName}${suffix}`;
}
return `lead${suffix}`;
}

/**
* Derives a role name (without session hash suffix) for grouping agent instances.
*
* - teamName + agentName → `{teamName}/{agentName}`
* - only agentName → `{agentName}`
* - neither → `lead`
*/
export function deriveAgentRole(entry: AgentEntry): string {
const { teamName, agentName } = entry;

if (teamName != null && agentName != null) {
return `${teamName}/${agentName}`;
}
if (agentName != null) {
return agentName;
}
return 'lead';
}

// Common parent/container directories that don't identify a specific project
const CONTAINER_DIRS = new Set([
'Users',
'home',
'Projects',
'projects',
'IdeaProjects',
'repos',
'Repos',
'repositories',
'src',
'code',
'Code',
'workspace',
'Workspace',
'workspaces',
'Documents',
'Desktop',
'Downloads',
'dev',
'Dev',
'development',
]);

/**
* Extracts a short human-readable project name from an encoded project path.
* The encoded path uses '-' as a path separator (e.g. '-Users-thomas-IdeaProjects-diana').
* Returns the last non-container segment, skipping generic directory names like 'IdeaProjects'.
*/
export function shortProjectName(encoded: string): string {
const parts = encoded.split('-').filter(Boolean);
// Find the last non-container segment
for (let i = parts.length - 1; i >= 0; i--) {
if (!CONTAINER_DIRS.has(parts[i]!)) {
// Include the preceding non-container segment for disambiguation
if (i > 0 && !CONTAINER_DIRS.has(parts[i - 1]!)) {
return `${parts[i - 1]}-${parts[i]}`;
}
return parts[i]!;
}
}
return parts.at(-1) ?? encoded;
Comment thread
thomasttvo marked this conversation as resolved.
}

if (import.meta.vitest != null) {
const { describe, it, expect } = import.meta.vitest;

describe('deriveAgentRole', () => {
it('returns teamName/agentName when both present', () => {
expect(
deriveAgentRole({
teamName: 'ccusage-fork',
agentName: 'researcher',
sessionId: 'a1b2c3d4',
}),
).toBe('ccusage-fork/researcher');
});

it('returns agentName when no teamName', () => {
expect(deriveAgentRole({ agentName: 'coder', sessionId: 'deadbeef' })).toBe('coder');
});

it('returns lead for main agent', () => {
expect(deriveAgentRole({ sessionId: 'e5f6a7b8' })).toBe('lead');
});

it('returns lead when no fields present', () => {
expect(deriveAgentRole({})).toBe('lead');
});
});

describe('deriveAgentId', () => {
it('returns teamName/agentName-sessionPrefix when all fields present', () => {
expect(
deriveAgentId({ teamName: 'ccusage-fork', agentName: 'researcher', sessionId: 'a1b2c3d4' }),
).toBe('ccusage-fork/researcher-a1b2');
});

it('returns agentName-sessionPrefix when no teamName', () => {
expect(deriveAgentId({ agentName: 'coder', sessionId: 'deadbeef' })).toBe('coder-dead');
});

it('returns lead-sessionPrefix for main agent (no teamName/agentName)', () => {
expect(deriveAgentId({ sessionId: 'e5f6a7b8' })).toBe('lead-e5f6');
});

it('returns lead when no fields present', () => {
expect(deriveAgentId({})).toBe('lead');
});

it('returns teamName/agentName without suffix when no sessionId', () => {
expect(deriveAgentId({ teamName: 'my-team', agentName: 'worker' })).toBe('my-team/worker');
});

it('returns agentName without suffix when no sessionId or teamName', () => {
expect(deriveAgentId({ agentName: 'solo' })).toBe('solo');
});

it('handles short sessionId gracefully', () => {
expect(deriveAgentId({ sessionId: 'ab' })).toBe('lead-ab');
});
});

describe('shortProjectName', () => {
it('returns last segment for normal project path', () => {
expect(shortProjectName('-Users-thomas-IdeaProjects-diana')).toBe('diana');
});

it('skips container directory IdeaProjects', () => {
expect(shortProjectName('-Users-thomas-IdeaProjects')).toBe('thomas');
});

it('skips container directory Projects', () => {
expect(shortProjectName('-Users-thomas-Projects')).toBe('thomas');
});

it('skips container directory Desktop', () => {
expect(shortProjectName('-home-user-Desktop')).toBe('user');
});

it('returns last segment when not a container', () => {
expect(shortProjectName('-Users-thomas-myapp')).toBe('thomas-myapp');
});

it('joins last two non-container segments for disambiguation', () => {
expect(shortProjectName('-Users-me-IdeaProjects-ccusage-fork')).toBe('ccusage-fork');
});

it('falls back to last segment when all are containers', () => {
expect(shortProjectName('-Projects')).toBe('Projects');
});

it('returns encoded string for empty input', () => {
expect(shortProjectName('')).toBe('');
});
});
}
Loading