Skip to content

WIP feat(ccusage): agent command — hierarchical team grouping, resumable session IDs, AI titles#895

Draft
thomasttvo wants to merge 27 commits intoryoppippi:mainfrom
thomasttvo:feat/agent-tracking
Draft

WIP feat(ccusage): agent command — hierarchical team grouping, resumable session IDs, AI titles#895
thomasttvo wants to merge 27 commits intoryoppippi:mainfrom
thomasttvo:feat/agent-tracking

Conversation

@thomasttvo
Copy link

@thomasttvo thomasttvo commented Mar 17, 2026

Problem

When using Claude Code's team/agent system heavily, there's no way to see session relationships, costs, or easily resume past sessions. You have to manually inspect JSONL files to understand which sessions are related or find a session UUID for claude --resume.

Solution

This PR adds a new ccusage agent command that provides a rich, hierarchical view of Claude Code agent sessions.

1. Hierarchical team grouping

Team members are nested under their lead session using indentation, making multi-agent work sessions immediately scannable:

Agent                                         │ Model            │ Msgs │  Cost  │  %   │ Cache R │ Cache W
──────────────────────────────────────────────┼──────────────────┼──────┼────────┼──────┼─────────┼────────
Investigating ccusage session file origins    │                  │      │        │      │         │
dff4e7a9-f255-46f2-91f3-fc8f4c367118         │ claude-sonnet-4-6│ 120  │ $1.23  │ 44%  │  1.2M   │  45K
  └─ learner                                  │ claude-opus-4-6  │  65  │ $0.69  │ 25%  │   800K  │  22K
  └─ researcher                               │ claude-opus-4-6  │  43  │ $0.44  │ 16%  │   540K  │  18K
  └─ implementer                              │ claude-opus-4-6  │  88  │ $0.92  │ 33%  │   960K  │  31K
──────────────────────────────────────────────┼──────────────────┼──────┼────────┼──────┼─────────┼────────
Fixing PR iteration loop                      │                  │      │        │      │         │
2748feee-b651-4a4b-b2df-00a0fb6d4801         │ claude-opus-4-6  │  55  │ $0.61  │ 22%  │   620K  │  19K
  └─ pr-reviewer                              │ claude-opus-4-6  │  31  │ $0.38  │ 14%  │   410K  │  12K

2. Full session UUID on lead rows

Lead rows display the full 36-char UUID, enabling direct session resume:

# Copy UUID from ccusage agent output, paste directly:
claude --resume dff4e7a9-f255-46f2-91f3-fc8f4c367118
# → Resumes the session immediately, no search dialog

3. AI-generated session titles

Lead rows show a human-readable title generated via Claude Haiku API. The generator uses compaction summaries from the session JSONL as input to produce concise, descriptive titles (≤60 chars). When no compaction summary is available, the title falls back to a truncated session ID. Slugs were removed entirely as a title source. Titles are cached to ~/.claude/session-titles/<sessionId> to avoid re-generating on every invocation.

4. Orphan team discovery

Sessions spawned as Claude Code team members are linked back to their lead via a 3-phase chain:

  1. Persistent cache (~/.claude/team-lead-cache/): populated by a PostToolUse hook at TeamCreate time
  2. Subagent directory scan: reads {leadSessionId}/subagents/ directories written by Claude Code
  3. JSONL scan: finds TeamCreate tool_use entries in lead session JSONL files as a final fallback

5. Cost % column

A % column shows each session's share of total spend — useful for spotting expensive workers at a glance.

Technical details

New files:

  • agent.ts: the full ccusage agent command implementation including discoverOrphanTeams() (3-phase parent discovery) and hierarchical rendering
  • agent-id.ts: public agent identifier utilities
  • cache-team-lead.sh: PostToolUse hook that captures team→lead mappings at TeamCreate time
  • find-agent-id.mjs: script for discovering agent IDs from session files

Modified files:

  • data-loader.ts: new loader to fetch per-agent usage data
  • table.ts: per-column width overrides to accommodate 36-char UUIDs
  • commands/index.ts: registers the new agent subcommand
  • config-schema.json / package.json: schema and dependency additions

Implementation notes:

  • peekTeamName() reads only the first 5 lines of a JSONL file to extract teamName efficiently
  • Array content handling: Claude Code JSONL content field is sometimes a string, sometimes {type:"text", text:"..."}[] with optional isMeta: true entries — both forms are handled
  • Column 0 responsive minimum widened from 28 → 38 chars to accommodate 36-char UUIDs without truncation

Test plan

  • ccusage agent shows team members nested under their lead session
  • Lead rows display full 36-char UUIDs
  • claude --resume <uuid-from-ccusage> resumes directly without interactive search
  • Session titles show AI-generated summaries where available
  • Sessions without a parent are still shown (ungrouped, at bottom)
  • % column sums to ~100% across all rows

Summary by CodeRabbit

  • New Features

    • Per-agent usage reporter: grouped by team/lead with AI-assisted session titles, JSON and rich table outputs, agent-level aggregations, filters (time, team, session) and breakdowns.
    • Agent discovery tool and a hook to cache team-lead mappings.
    • Public agent identifier utilities and a loader to fetch per-agent usage.
    • Table display: per-column width overrides.
  • Documentation

    • Added "Agent Command Notes" and expanded testing guidelines (Vitest globals, model testing, mock data, avoid dynamic imports).

thomasvo and others added 13 commits March 16, 2026 13:48
- mtime pre-filter: skip JSONL files older than --since cutoff (3816 → 40 files for today-only)
- lead disambiguation: prefix lead entries with project directory name
- shortProjectName: skip known container dirs (IdeaProjects, Projects, Desktop, etc.)
- agent column: increase min width to 28 to fit team/agent-name format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n titles

- Insert newline after '/' in agent display names so team/member names
  wrap at a semantic boundary instead of truncating with '…'
- Change session title priority: compaction summary > legacy summary >
  slug > first user message (was: slug first)
- Raise JSONL scan cap from 50 to 500 lines for compaction summaries
- Bump title cache version to v3 to force re-derivation
- Increase title char limit from 50 to 60

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix compaction title extraction that was looking for wrong data format:
- Read `isCompactSummary: true` user entries instead of assistant/acompact
- Parse plain text "Primary Request and Intent" section instead of XML tags
- Two-phase scan: quick first 50 lines for metadata, then full-file scan
  with cheap string pre-filter for compaction entries deep in the file
- Truncate titles at word boundaries instead of mid-word
- Bump cache to v6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ume IDs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…maries

Sessions without compaction data now get meaningful AI-generated titles
instead of raw first-user-message text or meaningless 3-word slugs.

Uses Claude Haiku via direct API call, authenticated through Claude Code's
OAuth credentials (Keychain → credentials file → ANTHROPIC_API_KEY fallback).
All uncached sessions are batched into a single API call for speed.

Title priority: cached → compaction summary → AI-generated → slug.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace meaningless 3-word slugs (e.g. "bright-wiggling-sky") with
truncated session IDs (first 8 chars) as the last-resort fallback
when no compaction title, AI title, or user messages are available.

- Remove all slug references from resolveSessionTitle() and resolveLeadDisplayNames()
- Bump cache version v9 → v10 to invalidate stale slug-based caches
- Clean up doc comments and type signatures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…utput

Read team configs from ~/.claude/teams/ to map team members to their
lead sessions. Display members indented with tree prefix (└─) under
their lead's row, sorted by total group cost.

- Load teamName → leadSessionId mapping from team config files
- Group team members under resolved lead session titles
- Show orphan lead headers (lead not in current data range) using
  cached session titles
- Ungrouped members (no team config) keep existing team/member format

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
User messages in JSONL can have content as either a string or an array
of content blocks (multimodal format). The title generation filter only
handled string content, silently dropping all array-format messages.

Also filters out isMeta entries which are system-injected skill content,
not real user messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a percentage column showing each row's cost as a share of total.
Groups team members without a config under their teamName header
instead of showing flat team/member rows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Teams without ~/.claude/teams/ configs (e.g. ephemeral teams spawned
via TeamCreate) were shown as standalone root groups instead of nested
under their parent lead session.

Three-phase discovery chain in discoverOrphanTeams():
- Phase 0: Read from ~/.claude/team-lead-cache/ (populated by
  PostToolUse hook at TeamCreate time, survives JSONL compaction)
- Phase 1: Scan {leadSessionId}/subagents/ directories for team member
  JSONL files referencing orphan team names
- Phase 2: Scan lead JSONL files for TeamCreate tool_use entries that
  reference orphan teams (pre-compaction fallback)

Includes hooks/cache-team-lead.sh PostToolUse hook that captures
team_name → session_id at TeamCreate time. Symlink to
~/.claude/hooks/ for activation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…encing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rows

`claude --resume` requires the full UUID — short prefixes open the
interactive search UI instead of resuming directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Raised responsive minimum for column 0 from 28 to 38 in
ResponsiveTable and set colWidthOverrides to 46 so UUIDs
are never truncated — required for `claude --resume`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Mar 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new agent CLI command and supporting infrastructure: agent identity utilities, agent-level aggregation and data loader exports, multi-source title resolution with caching and optional AI generation, team/lead grouping, JSON and terminal-table outputs, config-schema additions, helper scripts, and a PostToolUse hook.

Changes

Cohort / File(s) Summary
Agent Command & Registry
apps/ccusage/src/commands/agent.ts, apps/ccusage/src/commands/index.ts
New agent subcommand implemented and registered; heavy logic for aggregation, title resolution (cache/JSONL/AI), grouping, JSON/jq and formatted-table output.
Agent Identity Utilities & Exports
apps/ccusage/src/agent-id.ts, apps/ccusage/package.json
New helpers deriveAgentId, deriveAgentRole, shortProjectName; package exports updated to expose ./agent-id.
Data Loader Extensions
apps/ccusage/src/data-loader.ts
Adds AgentUsage type and loadAgentUsageData export; extends usageDataSchema with optional teamName and agentName; integrates agent-id derivation and aggregation.
Configuration Schema
apps/ccusage/config-schema.json
Adds an agent configuration block under defaults and per-command (daily, session) entries with filtering, formatting, and output options.
Table Display Enhancements
packages/terminal/src/table.ts
Adds colWidthOverrides?: Record<number, number> to TableOptions and applies per-column width caps in ResponsiveTable (initial and resize logic).
Agent Discovery Script & Hook
scripts/find-agent-id.mjs, hooks/cache-team-lead.sh
New Node script to scan JSONL for agent IDs with filters; new PostToolUse hook to persist TeamCreate -> session mappings under ~/.claude/team-lead-cache.
Docs, Lint, Misc
apps/ccusage/CLAUDE.md, eslint.config.js
Docs: added "Agent Command Notes" and updated testing guidance. ESLint ignore list expanded to include scripts.

Sequence Diagram

sequenceDiagram
    participant User
    participant AgentCmd as Agent Command
    participant DataLoader as Data Loader
    participant JSONL as JSONL Files
    participant Cache as Title Cache
    participant AI as Claude AI
    participant Output as Formatter

    User->>AgentCmd: invoke `agent` with filters/options
    AgentCmd->>AgentCmd: merge config & args
    AgentCmd->>DataLoader: loadAgentUsageData(filters)
    DataLoader->>JSONL: scan Claude paths / read JSONL
    DataLoader-->>AgentCmd: return AgentUsage[]

    AgentCmd->>Cache: load lead/session title caches
    alt missing titles
        AgentCmd->>JSONL: parse compaction/summary for titles
        JSONL-->>AgentCmd: extracted titles
        AgentCmd->>Cache: store extracted titles
        alt still missing
            AgentCmd->>AI: generateAITitlesBatch(sessions)
            AI-->>AgentCmd: AI titles
            AgentCmd->>Cache: persist AI-derived titles
        end
    end

    AgentCmd->>AgentCmd: group by team/lead and compute totals
    alt json/jq requested
        AgentCmd->>Output: emit JSON (optionally filtered by jq)
    else
        AgentCmd->>Output: render responsive table (colWidthOverrides applied)
    end
    Output-->>User: display report
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • ryoppippi

Poem

🐰

I hopped through JSONL with whiskers bright,
Found agents, leads, and titles in the night,
I cached a session, asked Claude for a line,
Grouped costs and trimmed tables so output looks fine,
A rabbit cheers — your agent report takes flight!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 92.31% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The PR title clearly describes the main feature: a new agent command with hierarchical team grouping, resumable session IDs, and AI-generated titles. It directly aligns with the substantial changes across multiple files implementing this feature.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@thomasttvo thomasttvo marked this pull request as ready for review March 17, 2026 03:04
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/ccusage/CLAUDE.md`:
- Around line 68-69: Update the note to reflect that the UI now shows full
36-char session UUIDs on lead rows rather than `lead-xxxx`; explicitly state
that lead identifiers should be full UUIDs (36 chars) so designers/testers avoid
truncation and layout changes that would break direct `--resume` usage, and also
clarify that the agent column must support realistic `team/agent-name` values
(e.g., `ccusage-fork/cli-dev`) when rendered alongside full UUIDs so
width/layout tests use full UUID + long agent names together.

In `@apps/ccusage/config-schema.json`:
- Around line 611-615: The "days" property in the JSON schema currently allows
negative and fractional values; update the "days" definition in
config-schema.json to restrict it to positive integers by changing the type to
"integer" and adding a minimum constraint (e.g., "minimum": 1) so only whole
numbers >=1 are valid while preserving the existing
description/markdownDescription.

In `@apps/ccusage/src/agent-id.ts`:
- Around line 82-89: shortProjectName currently loses hyphenated basenames by
splitting on every '-'; change it to only strip leading container-dir tokens and
preserve the remainder joined back with '-' (or use original path metadata if
available): in shortProjectName, iterate from the start removing tokens that are
in CONTAINER_DIRS, then join the remaining tokens with '-' and return that
(fallback to last token or encoded if nothing left); this keeps names like
"ccusage-fork" intact while still removing container prefixes.

In `@apps/ccusage/src/commands/agent.ts`:
- Around line 1299-1301: The percent calculation uses a conditional that still
renders '<0.1' for actual zero values and the totals row always prints '100'
when totals.totalCost is zero; update the pct/pctStr logic to explicitly handle
totals.totalCost === 0 (and likewise in the totals row computation) so that when
totals.totalCost is zero you set the percent string to '0' (or '0.0' to match
formatting) instead of '<0.1', and ensure the totals percent displayed (the code
building the totals row around line 1334) uses the same zero-total guard to
display '0' rather than '100'. Use the existing variables pct, pctStr,
totals.totalCost and data.totalCost to implement this single guarded branch for
both per-row and totals calculations.
- Around line 283-291: The outbound fetch calls (e.g., the POST to
OAUTH_REFRESH_URL that uses refreshToken/OAUTH_CLIENT_ID and the other fetch in
the same file around lines 337-348) lack timeout/abort handling and can hang;
wrap each fetch call with an AbortController, start a timer (e.g., configurable
constant like REQUEST_TIMEOUT_MS) that calls controller.abort() after the
timeout, pass controller.signal to fetch, and clear the timer after the fetch
completes; also catch and handle the abort error separately so the CLI returns a
meaningful error instead of hanging.
- Around line 887-890: The code writes cache files using raw teamName (in the
loop over newlyDiscovered) which allows path traversal; sanitize the filename
before using it with writeFile by encoding or safe-normalizing teamName (e.g.,
use a slug/encode function or replace path separators) and join with cacheDir
when calling writeFile (references: cacheDir, newlyDiscovered loop, writeFile).
Additionally, apply the inverse decode/decoding logic when reading cache
filenames so lookups map back to original team names (ensure whatever
sanitize/encode function used here is reversible or store a mapping file).
- Around line 1113-1140: The code still reads ctx.values directly after calling
mergeConfigWithArgs; update the since/until/days/all logic and all subsequent
option reads to use the merged "values" object returned by mergeConfigWithArgs
instead of ctx.values so merged config overrides are respected. Specifically,
replace references to ctx.values.since, ctx.values.until, ctx.values.days,
ctx.values.all and the parameters passed into loadAgentUsageData (mode, offline,
timezone, locale, teamFilter, sessionFilter) with values.since, values.until,
values.days, values.all and values.mode, values.offline, values.timezone,
values.locale, values.team, values.session; also apply the same switch from
ctx.values to values for the later checks around instances, compact and
breakdown to ensure consistent behavior.

In `@apps/ccusage/src/data-loader.ts`:
- Around line 1530-1549: The current mtime prefilter (using options.since ->
cutoff and stat on projectFilteredWithBase to produce dateFilteredWithBase) can
incorrectly drop valid records; change the logic so we do NOT hard-filter files
by filesystem mtime. Instead, keep all entries from projectFilteredWithBase
(i.e., set dateFilteredWithBase = projectFilteredWithBase) and perform the since
cutoff using each record's internal timestamp during JSONL parsing; if you want
to keep the stat check for perf, only use it as a heuristic (e.g., mark files as
"likely old" but still include them) rather than returning null in the
Promise.all branch. Ensure references: options.since, cutoff, stat,
projectFilteredWithBase, and dateFilteredWithBase are updated accordingly.

In `@hooks/cache-team-lead.sh`:
- Around line 21-23: Replace the raw TEAM_NAME usage as a filename with a safe,
deterministic encoded key (e.g., hex or base64url of sha256(TEAM_NAME) or a
strict whitelist/slugify) when creating and writing to CACHE_DIR so values like
"../settings.local.json" cannot escape; update the write in the hook (where
CACHE_DIR, TEAM_NAME, SESSION_ID are used) to write to
"$CACHE_DIR/<encoded_key>" and mirror the exact same encoding/decoding logic in
the agent read path (the code in apps/ccusage/src/commands/agent.ts) so reads
and writes remain compatible and safe.

In `@packages/terminal/src/table.ts`:
- Around line 231-235: Validate colWidthOverrides before using them: in the
method that computes column width (the block referencing
this.colWidthOverrides[index] and the other occurrence around the same logic),
check that the override is a finite number and at least the minimum allowed
width (e.g., Number.isFinite(override) && override >= 10) before applying
Math.min/Math.max; if invalid (NaN, non-finite, <=0, or less than min), ignore
the override and fall back to the existing width + padding logic so columns
cannot collapse. Ensure you apply the same validation in both places where
this.colWidthOverrides[index] is consumed.

In `@scripts/find-agent-id.mjs`:
- Around line 49-50: getTeamLeadFiles (and the other path-building helpers that
assemble team/config or project scan paths) currently hard-code homedir-based
roots (~/.claude and ~/.config/claude); instead read the configured Claude roots
from the CLAUDE_CONFIG_DIR environment variable (splitting on path.delimiter)
and fall back to the two default roots, then build team and project config paths
by joining each root with the relative subpaths (e.g., 'teams', teamName,
'config.json'). Update getTeamLeadFiles to iterate those configured roots when
locating config.json and apply the same pattern to the other helpers that
construct team and project scan paths so --team and --session respect
CLAUDE_CONFIG_DIR.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aef64110-5a69-48f5-bc38-93ead378e98f

📥 Commits

Reviewing files that changed from the base of the PR and between b81913f and e420bc2.

📒 Files selected for processing (11)
  • apps/ccusage/CLAUDE.md
  • apps/ccusage/config-schema.json
  • apps/ccusage/package.json
  • apps/ccusage/src/agent-id.ts
  • apps/ccusage/src/commands/agent.ts
  • apps/ccusage/src/commands/index.ts
  • apps/ccusage/src/data-loader.ts
  • eslint.config.js
  • hooks/cache-team-lead.sh
  • packages/terminal/src/table.ts
  • scripts/find-agent-id.mjs

thomasvo and others added 4 commits March 17, 2026 09:44
…es only

AI-generated titles were producing bogus results like Hello World
Program for sessions without compaction summaries. Now sessions
without a compaction or legacy summary show UUID-only with timestamp.

Removed: generateAITitlesBatch, getClaudeAuthHeaders, OAuth token
refresh, user message collection for AI input, nano-spawn dependency.
Bumped cache version to v11 to invalidate stale AI-generated caches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Filter out short/trivial user messages (< 10 chars or single words like
'hello', 'ok') before feeding to AI title generation. Also improve the
prompt to focus on the user's task/goal. Bump cache version to v11 to
invalidate stale titles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sessions with no substantive messages no longer cache a truncated ID as
a 'title'. Instead they show just the short ID + timestamp without a
trailing separator. Only cache real AI-generated titles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/ccusage/src/commands/agent.ts (1)

646-651: O(n²) complexity from indexOf in loop.

needsAI.indexOf(item) inside the loop creates O(n²) complexity. While unlikely to matter for typical session counts, this is easily avoidable.

♻️ Proposed refactor
-for (const item of needsAI) {
+for (let i = 0; i < needsAI.length; i++) {
+	const item = needsAI[i]!;
 	const agent = agents[item.agentIdx]!;
-	const aiTitle = aiTitles.get(needsAI.indexOf(item) + 1);
+	const aiTitle = aiTitles.get(i + 1);
 	const finalTitle = aiTitle ?? agent.sessionId?.slice(0, 8) ?? 'Untitled';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 646 - 651, The loop over
needsAI is using needsAI.indexOf(item) which causes O(n²); change the iteration
to use an index-aware loop (e.g., for (let i = 0; i < needsAI.length; i++) or
needsAI.forEach((item, i) => ...)) so you can compute aiTitle = aiTitles.get(i +
1) directly and then set agent.agentId (the rest of the logic using
agents[item.agentIdx], formatStartTime(item.startTime, timezone), and
agent.sessionId stays the same).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/ccusage/src/commands/agent.ts`:
- Around line 1056-1071: loadLeadTitleCache currently loads titles without
validating the cache version, which can return stale data; update
loadLeadTitleCache to read the first line of each cached file and compare it to
the existing CACHE_VERSION constant (same validation used in
resolveSessionTitle) and only set cache.set(sessionId, title) when the version
matches; if the version mismatches or the first line is missing, skip that file
(treat as no cached title) and keep the existing try/catch behavior for IO
errors.

---

Nitpick comments:
In `@apps/ccusage/src/commands/agent.ts`:
- Around line 646-651: The loop over needsAI is using needsAI.indexOf(item)
which causes O(n²); change the iteration to use an index-aware loop (e.g., for
(let i = 0; i < needsAI.length; i++) or needsAI.forEach((item, i) => ...)) so
you can compute aiTitle = aiTitles.get(i + 1) directly and then set
agent.agentId (the rest of the logic using agents[item.agentIdx],
formatStartTime(item.startTime, timezone), and agent.sessionId stays the same).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5d23196b-3686-4312-b7a9-a1dbac8cd3af

📥 Commits

Reviewing files that changed from the base of the PR and between 31228cd and 9caecb6.

📒 Files selected for processing (1)
  • apps/ccusage/src/commands/agent.ts

truncateAtWord now appends … when text is truncated beyond maxLen.
Cache version bumped to v12 to regenerate titles with ellipsis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@thomasttvo
Copy link
Author

bugbot review

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (5)
apps/ccusage/src/commands/agent.ts (5)

1061-1069: ⚠️ Potential issue | 🟡 Minor

Validate the cache version before loading orphan lead titles.

resolveSessionTitle() writes a versioned v11 payload, but this reader accepts any second line. Old invalidated cache entries can still resurface here and show stale orphan-lead headers.

Suggested fix
 async function loadLeadTitleCache(teamLeadMap: Map<string, string>): Promise<Map<string, string>> {
 	const cache = new Map<string, string>();
 	const cacheDir = path.join(os.homedir(), '.claude', 'session-titles');
+	const CACHE_VERSION = 'v11';
 	for (const sessionId of new Set(teamLeadMap.values())) {
 		try {
 			const raw = await readFile(path.join(cacheDir, sessionId), 'utf-8');
 			const lines = raw.trim().split('\n');
-			if (lines.length >= 2 && lines[1] != null && lines[1] !== '') {
+			if (
+				lines[0] === CACHE_VERSION &&
+				lines.length >= 3 &&
+				lines[1] != null &&
+				lines[1] !== ''
+			) {
 				cache.set(sessionId, lines[1]);
 			}
 		} catch {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 1061 - 1069,
loadLeadTitleCache is reading the second line of cached files without validating
the file version, allowing old/invalid entries to be used; change
loadLeadTitleCache to check the file's version header (e.g., verify lines[0] ===
'v11' or the expected version emitted by resolveSessionTitle) before using
lines[1] as the title, and skip/cache only when the version matches and lines[1]
is non-empty (use the existing cacheDir and sessionId variables and preserve the
current try/catch behavior).

283-291: ⚠️ Potential issue | 🟠 Major

Add abort timeouts to both outbound fetch calls.

Both requests can block ccusage agent indefinitely on a slow or broken connection. Title refresh/generation should fail fast instead of hanging the whole command.

Suggested fix
+const REQUEST_TIMEOUT_MS = 15_000;
+
@@
 			const response = await fetch(OAUTH_REFRESH_URL, {
 				method: 'POST',
+				signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
 				headers: { 'Content-Type': 'application/json' },
 				body: JSON.stringify({
@@
 			const response = await fetch('https://api.anthropic.com/v1/messages', {
 				method: 'POST',
+				signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
 				headers: {
 					'Content-Type': 'application/json',

Also applies to: 337-348

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 283 - 291, The two outbound
fetches (the refresh call to OAUTH_REFRESH_URL and the other fetch in the same
file around the token/generation block) must use an AbortController with a short
timeout so the agent doesn't hang indefinitely; modify the fetch invocations
(the one that builds the body with grant_type:'refresh_token' and the other
token/generation fetch) to create an AbortController, pass controller.signal
into the fetch options, set a setTimeout to call controller.abort() after a
reasonable timeout (e.g., a few seconds), and clear the timeout after the fetch
completes/fails to avoid leaks; ensure error handling distinguishes AbortError
for a clean fast-fail path.

764-773: ⚠️ Potential issue | 🔴 Critical

Encode teamName before using it as a cache filename.

teamName comes from session/tool data and is joined directly into ~/.claude/team-lead-cache. A crafted value containing separators or .. can escape the cache directory and overwrite arbitrary files writable by the user.

Suggested fix
+function toCacheFileName(teamName: string): string {
+	return encodeURIComponent(teamName);
+}
+
@@
 		const cacheFiles = await readdir(cacheDir);
 		for (const file of cacheFiles) {
-			if (!orphanTeams.has(file)) {
+			const teamName = decodeURIComponent(file);
+			if (!orphanTeams.has(teamName)) {
 				continue;
 			}
 			try {
 				const leadSessionId = (await readFile(path.join(cacheDir, file), 'utf-8')).trim();
 				if (leadSessionId !== '') {
-					teamLeadMap.set(file, leadSessionId);
-					orphanTeams.delete(file);
+					teamLeadMap.set(teamName, leadSessionId);
+					orphanTeams.delete(teamName);
 				}
 			} catch {
@@
 			await mkdir(cacheDir, { recursive: true });
 			for (const [teamName, leadSessionId] of newlyDiscovered) {
-				await writeFile(path.join(cacheDir, teamName), leadSessionId, 'utf-8');
+				await writeFile(
+					path.join(cacheDir, toCacheFileName(teamName)),
+					leadSessionId,
+					'utf-8',
+				);
 			}

Also applies to: 897-899

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 764 - 773, The code is
vulnerable because raw team names are used as cache filenames under cacheDir;
ensure you encode/sanitize teamName before using it in path.join so it cannot
traverse directories or include path separators. Update all places that build
paths for the team-lead cache (where cacheDir, path.join(cacheDir, file) and
wherever you write the cache, e.g., the writeFile usage around the later block
at lines referenced 897-899) to transform the team name into a safe filename
(for example using a deterministic encoding like base64 or a strict
whitelist/sanitizer) when creating keys for orphanTeams, teamLeadMap, and when
reading/writing files so both readFile and writeFile use the encoded name.

1308-1309: ⚠️ Potential issue | 🟡 Minor

Guard the % column when total cost is zero.

With an all-zero dataset, rows render <0.1 and the totals row renders 100, which is misleading.

Suggested fix
-				const pct = totals.totalCost > 0 ? (data.totalCost / totals.totalCost) * 100 : 0;
-				const pctStr = pct < 0.1 ? '<0.1' : pct.toFixed(1);
+				const hasCostBase = totals.totalCost > 0;
+				const pct = hasCostBase ? (data.totalCost / totals.totalCost) * 100 : 0;
+				const pctStr = !hasCostBase ? '0.0' : pct < 0.1 ? '<0.1' : pct.toFixed(1);
@@
-				pc.yellow('100'),
+				pc.yellow(totals.totalCost > 0 ? '100' : '0.0'),

Also applies to: 1343-1343

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 1308 - 1309, The percent
column displays misleading values when totals.totalCost is zero; update the
percent calculation and formatting around pct and pctStr so that when
totals.totalCost === 0 you set pct = 0 and render a sensible string (e.g., "0"
or "0.0" or "—") instead of applying the "<0.1" rule or showing "100" in the
totals row; ensure the same guard is applied where the totals row is rendered
(references: pct, pctStr, totals.totalCost, data.totalCost, and the totals row
rendering code).

1113-1147: ⚠️ Potential issue | 🟠 Major

Use mergedOptions consistently after mergeConfigWithArgs().

After merging config and CLI args, the command falls back to ctx.values for date filters, loader inputs, instances, compact, breakdown, and jq. Config-backed agent.* values are silently ignored.

Suggested fix
 	const config = loadConfig(ctx.values.config, ctx.values.debug);
 	const mergedOptions: typeof ctx.values = mergeConfigWithArgs(ctx, config, ctx.values.debug);
+	const values = mergedOptions;
@@
-	let since = ctx.values.since;
-	const until = ctx.values.until;
+	let since = values.since;
+	const until = values.until;
@@
-		if (ctx.values.days != null) {
+		if (values.days != null) {
 			const now = new Date();
 			const daysAgo = new Date(now);
-			daysAgo.setDate(daysAgo.getDate() - (ctx.values.days - 1));
-			since = formatLocalDateYYYYMMDD(daysAgo, ctx.values.timezone);
-		} else if (!ctx.values.all) {
+			daysAgo.setDate(daysAgo.getDate() - (values.days - 1));
+			since = formatLocalDateYYYYMMDD(daysAgo, values.timezone);
+		} else if (!values.all) {
 			// Default: today only
-			since = formatLocalDateYYYYMMDD(new Date(), ctx.values.timezone);
+			since = formatLocalDateYYYYMMDD(new Date(), values.timezone);
 		}
 	}
@@
-			mode: ctx.values.mode,
-			offline: ctx.values.offline,
-			timezone: ctx.values.timezone,
-			locale: ctx.values.locale,
-			teamFilter: ctx.values.team,
-			sessionFilter: ctx.values.session,
+			mode: values.mode,
+			offline: values.offline,
+			timezone: values.timezone,
+			locale: values.locale,
+			teamFilter: values.team,
+			sessionFilter: values.session,
@@
-		const displayData = ctx.values.instances ? agentData : aggregateByRole(agentData);
+		const displayData = values.instances ? agentData : aggregateByRole(agentData);

Also applies to: 1163-1167, 1175-1175, 1227-1228, 1280-1280, 1322-1322

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 1113 - 1147, The code merges
CLI/config into mergedOptions via mergeConfigWithArgs but continues to read from
ctx.values; update the date resolution and all subsequent usages to read from
mergedOptions instead of ctx.values so config-backed agent.* settings are
honored: use mergedOptions.since / mergedOptions.until / mergedOptions.days /
mergedOptions.all when computing since (and pass
mergedOptions.timezone/locale/mode/offline/team/session), and replace
ctx.values.instances, mergedOptions.compact, mergedOptions.breakdown, and
mergedOptions.jq (and any other ctx.values references noted around
loadAgentUsageData and subsequent blocks) so loadAgentUsageData and later logic
use mergedOptions consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/ccusage/src/commands/agent.ts`:
- Around line 1165-1167: The code calls resolveLeadDisplayNames(displayData,
ctx.values.timezone) even when the CLI is in offline mode; change the check so
title resolution is skipped when the effective offline flag is set (e.g.
ctx.values.offline or the repo's effectiveOffline setting) — i.e., only call
resolveLeadDisplayNames if !ctx.values.instances && !ctx.values.offline (or the
equivalent effectiveOffline property) so no Anthropic/claude or network work
runs in offline mode; also ensure resolveLeadDisplayNames itself early-returns
if an offline flag is passed through.
- Around line 667-669: The current no-title fallback truncates the session ID to
8 chars when setting agent.agentId, which prevents direct resume by full UUID;
change the assignment to use the full agent.sessionId (not just slice(0,8)) when
available, e.g. build agent.agentId from agent.sessionId + " · " +
formatStartTime(item.startTime, timezone) while still defaulting to 'Untitled'
only if sessionId is missing; update the code around agent.sessionId,
agent.agentId, formatStartTime, item.startTime and timezone accordingly.
- Around line 620-621: The call to resolveSessionTitle(agent.sessionId,
agent.project, claudePaths) can throw on IO errors and currently aborts the
whole report; wrap that await in a try/catch around the call in the agent report
loop so any thrown errors are caught, log or debug the error (optional) and set
titleInfo to null on error so the code falls back to the existing slug/session
display instead of exiting; specifically modify the block using
resolveSessionTitle to handle exceptions and continue processing the remaining
sessions.

---

Duplicate comments:
In `@apps/ccusage/src/commands/agent.ts`:
- Around line 1061-1069: loadLeadTitleCache is reading the second line of cached
files without validating the file version, allowing old/invalid entries to be
used; change loadLeadTitleCache to check the file's version header (e.g., verify
lines[0] === 'v11' or the expected version emitted by resolveSessionTitle)
before using lines[1] as the title, and skip/cache only when the version matches
and lines[1] is non-empty (use the existing cacheDir and sessionId variables and
preserve the current try/catch behavior).
- Around line 283-291: The two outbound fetches (the refresh call to
OAUTH_REFRESH_URL and the other fetch in the same file around the
token/generation block) must use an AbortController with a short timeout so the
agent doesn't hang indefinitely; modify the fetch invocations (the one that
builds the body with grant_type:'refresh_token' and the other token/generation
fetch) to create an AbortController, pass controller.signal into the fetch
options, set a setTimeout to call controller.abort() after a reasonable timeout
(e.g., a few seconds), and clear the timeout after the fetch completes/fails to
avoid leaks; ensure error handling distinguishes AbortError for a clean
fast-fail path.
- Around line 764-773: The code is vulnerable because raw team names are used as
cache filenames under cacheDir; ensure you encode/sanitize teamName before using
it in path.join so it cannot traverse directories or include path separators.
Update all places that build paths for the team-lead cache (where cacheDir,
path.join(cacheDir, file) and wherever you write the cache, e.g., the writeFile
usage around the later block at lines referenced 897-899) to transform the team
name into a safe filename (for example using a deterministic encoding like
base64 or a strict whitelist/sanitizer) when creating keys for orphanTeams,
teamLeadMap, and when reading/writing files so both readFile and writeFile use
the encoded name.
- Around line 1308-1309: The percent column displays misleading values when
totals.totalCost is zero; update the percent calculation and formatting around
pct and pctStr so that when totals.totalCost === 0 you set pct = 0 and render a
sensible string (e.g., "0" or "0.0" or "—") instead of applying the "<0.1" rule
or showing "100" in the totals row; ensure the same guard is applied where the
totals row is rendered (references: pct, pctStr, totals.totalCost,
data.totalCost, and the totals row rendering code).
- Around line 1113-1147: The code merges CLI/config into mergedOptions via
mergeConfigWithArgs but continues to read from ctx.values; update the date
resolution and all subsequent usages to read from mergedOptions instead of
ctx.values so config-backed agent.* settings are honored: use
mergedOptions.since / mergedOptions.until / mergedOptions.days /
mergedOptions.all when computing since (and pass
mergedOptions.timezone/locale/mode/offline/team/session), and replace
ctx.values.instances, mergedOptions.compact, mergedOptions.breakdown, and
mergedOptions.jq (and any other ctx.values references noted around
loadAgentUsageData and subsequent blocks) so loadAgentUsageData and later logic
use mergedOptions consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5940e311-b06f-49ad-964a-541c5e01b4da

📥 Commits

Reviewing files that changed from the base of the PR and between 9caecb6 and 084a984.

📒 Files selected for processing (1)
  • apps/ccusage/src/commands/agent.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (7)
apps/ccusage/src/commands/agent.ts (7)

620-621: ⚠️ Potential issue | 🟠 Major

Wrap resolveSessionTitle in try-catch to prevent one bad session from aborting the report.

If resolveSessionTitle throws due to an unreadable JSONL file, the entire command exits. This is a best-effort title decoration — failures should fall back gracefully.

💡 Suggested fix
-		const titleInfo = await resolveSessionTitle(agent.sessionId, agent.project, claudePaths);
+		let titleInfo: Awaited<ReturnType<typeof resolveSessionTitle>> = null;
+		try {
+			titleInfo = await resolveSessionTitle(agent.sessionId, agent.project, claudePaths);
+		} catch {
+			// File became unreadable — fall through to fallback
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 620 - 621, Wrap the call to
resolveSessionTitle(agent.sessionId, agent.project, claudePaths) in a try-catch
so a thrown error from unreadable JSONL doesn't abort the whole command: call
resolveSessionTitle inside try, assign its result to titleInfo on success, and
on catch set titleInfo to a safe fallback (null/undefined or empty
object/string), and log the error at debug/verbose level mentioning
agent.sessionId and agent.project so the report generation continues for other
sessions; update any downstream usage of titleInfo to handle the fallback
gracefully.

1113-1150: ⚠️ Potential issue | 🟠 Major

Merged config not consistently used after mergeConfigWithArgs.

mergedOptions is created at line 1114 but the code continues to read from ctx.values for core options (since, until, days, all, mode, offline, timezone, locale, team, session). This bypasses any config file overrides.

💡 Suggested fix
 		// Resolve date range: explicit --since/--until > --days > --all > default (today)
-		let since = ctx.values.since;
-		const until = ctx.values.until;
+		let since = mergedOptions.since;
+		const until = mergedOptions.until;

 		if (since == null && until == null) {
-			if (ctx.values.days != null) {
+			if (mergedOptions.days != null) {
 				const now = new Date();
 				const daysAgo = new Date(now);
-				daysAgo.setDate(daysAgo.getDate() - (ctx.values.days - 1));
-				since = formatLocalDateYYYYMMDD(daysAgo, ctx.values.timezone);
-			} else if (!ctx.values.all) {
+				daysAgo.setDate(daysAgo.getDate() - (mergedOptions.days - 1));
+				since = formatLocalDateYYYYMMDD(daysAgo, mergedOptions.timezone);
+			} else if (!mergedOptions.all) {
 				// Default: today only
-				since = formatLocalDateYYYYMMDD(new Date(), ctx.values.timezone);
+				since = formatLocalDateYYYYMMDD(new Date(), mergedOptions.timezone);
 			}
 		}

 		// ... and similarly for loadAgentUsageData call:
-			mode: ctx.values.mode,
-			offline: ctx.values.offline,
-			timezone: ctx.values.timezone,
-			locale: ctx.values.locale,
-			teamFilter: ctx.values.team,
-			sessionFilter: ctx.values.session,
+			mode: mergedOptions.mode,
+			offline: mergedOptions.offline,
+			timezone: mergedOptions.timezone,
+			locale: mergedOptions.locale,
+			teamFilter: mergedOptions.team,
+			sessionFilter: mergedOptions.session,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 1113 - 1150, The code
creates mergedOptions via mergeConfigWithArgs but continues to read directly
from ctx.values, so config overrides are ignored; replace all uses of ctx.values
(specifically json, jq, since, until, days, all, mode, offline, timezone,
locale, team, session and any other flags read in this block) with mergedOptions
equivalents (e.g., use mergedOptions.json / mergedOptions.jq for useJson,
compute since/until/days/all from mergedOptions, and pass
mergedOptions.mode/offline/timezone/locale/team/session into loadAgentUsageData
and onProgress) so the merged configuration is consistently used throughout this
function.

894-904: ⚠️ Potential issue | 🔴 Critical

Unsanitized teamName used as filesystem path — path traversal risk.

Line 899 writes cache files using raw teamName. If teamName contains path separators (e.g., ../) or other special characters, files could be written outside the intended cache directory.

💡 Suggested fix: encode team names for safe filesystem use
+function toSafeFileName(name: string): string {
+	return encodeURIComponent(name);
+}
+
 	// Persist newly discovered mappings so they survive JSONL compaction
 	if (newlyDiscovered.size > 0) {
 		try {
 			await mkdir(cacheDir, { recursive: true });
 			for (const [teamName, leadSessionId] of newlyDiscovered) {
-				await writeFile(path.join(cacheDir, teamName), leadSessionId, 'utf-8');
+				await writeFile(path.join(cacheDir, toSafeFileName(teamName)), leadSessionId, 'utf-8');
 			}

Also update the cache reading logic (lines 766-774) to decode filenames:

 		for (const file of cacheFiles) {
-			if (!orphanTeams.has(file)) {
+			const teamName = decodeURIComponent(file);
+			if (!orphanTeams.has(teamName)) {
 				continue;
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 894 - 904, The cache write
uses raw teamName keys from newlyDiscovered to create filenames under cacheDir,
which allows path traversal; update the write path to use a safe filename
encoding (e.g., base64 or encodeURIComponent) instead of raw teamName before
calling path.join and writeFile, and ensure mkdir(cacheDir, { recursive: true })
remains; then mirror the change in the cache reading logic that iterates
filenames (the reader that maps filenames back to team names) to decode the
stored filenames back to teamName so reads/writes are consistent (refer to
newlyDiscovered, cacheDir, mkdir, writeFile to locate the write site and the
cache-reading routine to update decoding).

1165-1169: ⚠️ Potential issue | 🟠 Major

Honor offline mode before resolving AI titles.

resolveLeadDisplayNames can invoke the Anthropic API or spawn the Claude CLI, but this call happens even when --offline is set. Offline mode should skip network-dependent title generation.

💡 Suggested fix
 		// Resolve lead display names (session titles) for aggregated view
-		if (!ctx.values.instances) {
+		if (!ctx.values.instances && !mergedOptions.offline) {
 			await resolveLeadDisplayNames(displayData, ctx.values.timezone);
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 1165 - 1169, The code calls
resolveLeadDisplayNames(...) even in offline mode; update the conditional to
honor the offline flag by skipping title generation when ctx.values.offline is
true—i.e., only call resolveLeadDisplayNames(displayData, ctx.values.timezone)
if !ctx.values.instances AND !ctx.values.offline (or return early when
ctx.values.offline is set) so that no Anthropic/Claude CLI network or subprocess
work runs while offline.

1062-1077: ⚠️ Potential issue | 🟡 Minor

Cache version not validated — may load stale titles.

loadLeadTitleCache reads cached titles without checking the v12 version prefix, unlike resolveSessionTitle which validates it. This could display stale titles from older cache formats.

💡 Suggested fix
 async function loadLeadTitleCache(teamLeadMap: Map<string, string>): Promise<Map<string, string>> {
 	const cache = new Map<string, string>();
 	const cacheDir = path.join(os.homedir(), '.claude', 'session-titles');
+	const CACHE_VERSION = 'v12';
 	for (const sessionId of new Set(teamLeadMap.values())) {
 		try {
 			const raw = await readFile(path.join(cacheDir, sessionId), 'utf-8');
 			const lines = raw.trim().split('\n');
-			if (lines.length >= 2 && lines[1] != null && lines[1] !== '') {
+			if (
+				lines[0] === CACHE_VERSION &&
+				lines.length >= 3 &&
+				lines[1] != null &&
+				lines[1] !== ''
+			) {
 				cache.set(sessionId, lines[1]);
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 1062 - 1077,
loadLeadTitleCache currently accepts titles from cache without validating the
cache version header, which can yield stale/invalid titles; update
loadLeadTitleCache to read the cached file header (e.g., check lines[0] ===
'v12' or the same version prefix used by resolveSessionTitle) and only set
cache.set(sessionId, lines[1]) when the version matches and lines[1] is present,
otherwise treat it as a miss (skip/ignore) so behavior matches
resolveSessionTitle; ensure you still handle readFile errors the same way.

667-670: ⚠️ Potential issue | 🟠 Major

Preserve full session UUID in the no-title fallback for claude --resume.

When AI title generation returns nothing, only the first 8 characters are shown. This breaks the PR objective of enabling direct resume via claude --resume <uuid>.

💡 Suggested fix
 			} else {
 				// No AI title (no substantive messages) — show truncated ID + time only
 				const shortId = agent.sessionId?.slice(0, 8) ?? 'Untitled';
-				agent.agentId = `${shortId} · ${formatStartTime(item.startTime, timezone)}`;
+				agent.agentId = agent.sessionId != null
+					? `${shortId} · ${formatStartTime(item.startTime, timezone)} · ${agent.sessionId}`
+					: `Untitled · ${formatStartTime(item.startTime, timezone)}`;
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 667 - 670, The fallback that
shows only the first 8 chars of the session ID breaks resume because the full
UUID is needed; update the no-title branch so agent.agentId uses the full
agent.sessionId (falling back to 'Untitled' if missing) instead of
agent.sessionId?.slice(0,8), e.g. set agent.agentId = `${agent.sessionId ??
'Untitled'} · ${formatStartTime(item.startTime, timezone)}` while keeping
formatStartTime and timezone usage unchanged.

1309-1311: ⚠️ Potential issue | 🟡 Minor

Percent column is misleading when total cost is zero.

Line 1310 shows <0.1 even for actual zero costs, and line 1344 always shows 100 in totals regardless of whether there's any cost data. This is confusing for zero-cost datasets.

💡 Suggested fix
-				const pct = totals.totalCost > 0 ? (data.totalCost / totals.totalCost) * 100 : 0;
-				const pctStr = pct < 0.1 ? '<0.1' : pct.toFixed(1);
+				const hasCostBase = totals.totalCost > 0;
+				const pct = hasCostBase ? (data.totalCost / totals.totalCost) * 100 : 0;
+				const pctStr = !hasCostBase ? '0.0' : pct < 0.1 && pct > 0 ? '<0.1' : pct.toFixed(1);

 // ... and for the totals row:
-				pc.yellow('100'),
+				pc.yellow(totals.totalCost > 0 ? '100' : '0.0'),

Also applies to: 1344-1344

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccusage/src/commands/agent.ts` around lines 1309 - 1311, The percent
calculation and totals display are misleading for zero-cost datasets; update the
pct/pctStr logic near the lines that compute "const pct = totals.totalCost > 0 ?
(data.totalCost / totals.totalCost) * 100 : 0;" and "const pctStr = pct < 0.1 ?
'<0.1' : pct.toFixed(1);" so that when totals.totalCost === 0 you emit "0" (or
"0.0" per formatting standard) instead of "<0.1" or other values, and likewise
change the totals-row percent generation (the code that builds the totals table
row around where totals are pushed, currently showing "100") to display "0" when
totals.totalCost === 0; ensure you still apply the "<0.1" rule only when
totals.totalCost > 0 and pct > 0 but < 0.1.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/ccusage/src/commands/agent.ts`:
- Around line 250-258: The code currently constructs a hardcoded credsPath
(~/.claude/.credentials.json) when creds is null; replace that logic to use the
project's getClaudePaths() helper to resolve the credentials file location
instead. In the block where creds is null (the creds variable check), call
getClaudePaths(), derive the credentials file path from its returned paths (the
same location other code uses), then await readFile on that resolved path and
JSON.parse into creds; preserve the existing try/catch behavior around
readFile/JSON parsing to handle missing/invalid files.
- Around line 360-368: The spawn fallback for the Claude CLI can hang because it
lacks a timeout; wrap the call in an AbortSignal.timeout and pass the signal
into spawn, e.g., create a signal (AbortSignal.timeout(TIMEOUT_MS) or reuse the
module's existing timeout constant), call spawn('claude', ['-p', prompt,
'--model', 'haiku'], { signal }), and handle the abort path in the catch: if
aborted, ensure any started process is killed/cleaned up and continue without
blocking; update references around spawn, proc, and output in the try/catch to
account for the signal and proper cleanup.

---

Duplicate comments:
In `@apps/ccusage/src/commands/agent.ts`:
- Around line 620-621: Wrap the call to resolveSessionTitle(agent.sessionId,
agent.project, claudePaths) in a try-catch so a thrown error from unreadable
JSONL doesn't abort the whole command: call resolveSessionTitle inside try,
assign its result to titleInfo on success, and on catch set titleInfo to a safe
fallback (null/undefined or empty object/string), and log the error at
debug/verbose level mentioning agent.sessionId and agent.project so the report
generation continues for other sessions; update any downstream usage of
titleInfo to handle the fallback gracefully.
- Around line 1113-1150: The code creates mergedOptions via mergeConfigWithArgs
but continues to read directly from ctx.values, so config overrides are ignored;
replace all uses of ctx.values (specifically json, jq, since, until, days, all,
mode, offline, timezone, locale, team, session and any other flags read in this
block) with mergedOptions equivalents (e.g., use mergedOptions.json /
mergedOptions.jq for useJson, compute since/until/days/all from mergedOptions,
and pass mergedOptions.mode/offline/timezone/locale/team/session into
loadAgentUsageData and onProgress) so the merged configuration is consistently
used throughout this function.
- Around line 894-904: The cache write uses raw teamName keys from
newlyDiscovered to create filenames under cacheDir, which allows path traversal;
update the write path to use a safe filename encoding (e.g., base64 or
encodeURIComponent) instead of raw teamName before calling path.join and
writeFile, and ensure mkdir(cacheDir, { recursive: true }) remains; then mirror
the change in the cache reading logic that iterates filenames (the reader that
maps filenames back to team names) to decode the stored filenames back to
teamName so reads/writes are consistent (refer to newlyDiscovered, cacheDir,
mkdir, writeFile to locate the write site and the cache-reading routine to
update decoding).
- Around line 1165-1169: The code calls resolveLeadDisplayNames(...) even in
offline mode; update the conditional to honor the offline flag by skipping title
generation when ctx.values.offline is true—i.e., only call
resolveLeadDisplayNames(displayData, ctx.values.timezone) if
!ctx.values.instances AND !ctx.values.offline (or return early when
ctx.values.offline is set) so that no Anthropic/Claude CLI network or subprocess
work runs while offline.
- Around line 1062-1077: loadLeadTitleCache currently accepts titles from cache
without validating the cache version header, which can yield stale/invalid
titles; update loadLeadTitleCache to read the cached file header (e.g., check
lines[0] === 'v12' or the same version prefix used by resolveSessionTitle) and
only set cache.set(sessionId, lines[1]) when the version matches and lines[1] is
present, otherwise treat it as a miss (skip/ignore) so behavior matches
resolveSessionTitle; ensure you still handle readFile errors the same way.
- Around line 667-670: The fallback that shows only the first 8 chars of the
session ID breaks resume because the full UUID is needed; update the no-title
branch so agent.agentId uses the full agent.sessionId (falling back to
'Untitled' if missing) instead of agent.sessionId?.slice(0,8), e.g. set
agent.agentId = `${agent.sessionId ?? 'Untitled'} ·
${formatStartTime(item.startTime, timezone)}` while keeping formatStartTime and
timezone usage unchanged.
- Around line 1309-1311: The percent calculation and totals display are
misleading for zero-cost datasets; update the pct/pctStr logic near the lines
that compute "const pct = totals.totalCost > 0 ? (data.totalCost /
totals.totalCost) * 100 : 0;" and "const pctStr = pct < 0.1 ? '<0.1' :
pct.toFixed(1);" so that when totals.totalCost === 0 you emit "0" (or "0.0" per
formatting standard) instead of "<0.1" or other values, and likewise change the
totals-row percent generation (the code that builds the totals table row around
where totals are pushed, currently showing "100") to display "0" when
totals.totalCost === 0; ensure you still apply the "<0.1" rule only when
totals.totalCost > 0 and pct > 0 but < 0.1.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 812db9ca-e9c6-4cd0-acbf-343a72d60af2

📥 Commits

Reviewing files that changed from the base of the PR and between 084a984 and 0a9b325.

📒 Files selected for processing (1)
  • apps/ccusage/src/commands/agent.ts

…ate headers

- Filter [No content provided] and other bracketed placeholders from cached
  and compaction/summary titles using isValidTitle()
- Use joinParts() to prevent trailing · when title parts are empty/missing
- Deduplicate lead groups by sessionId to prevent duplicate header rows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@thomasttvo thomasttvo marked this pull request as draft March 17, 2026 17:48
@thomasttvo thomasttvo changed the title feat(ccusage): agent command — hierarchical team grouping, resumable session IDs, AI titles WIP feat(ccusage): agent command — hierarchical team grouping, resumable session IDs, AI titles Mar 17, 2026
thomasvo and others added 2 commits March 17, 2026 11:00
…nt, fix team grouping

- Display full session UUID on line 2 below title for all titled lead rows
- Extract inner text from <teammate-message> XML blocks instead of filtering them
- Fix lead entries with teamName but no agentName falling to ungrouped
- Skip truncation for multiline cells in ResponsiveTable (wordWrap handles them)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove isSubstantive gate for userMessages collection so even short
  messages like "config" get sent to AI for title generation
- Keep isSubstantive gate only for deterministic userMessageTitle fallback
- Change AI prompt from "max 8 words" to "max 40 characters" for
  predictable column width
- Tighten post-filter from 80 to 50 chars as safety net
- Bump cache version to v13 to regenerate all titles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ia AI

- Tighten AI prompt to "max 30 chars, 3-5 words, noun phrase only"
- Remove post-filter length check on AI-generated titles
- Extract <command-args> content from slash command messages
- Strip teammate-message preamble ("You are a ... on team ...")
- Feed compaction summaries to AI title generator instead of using directly
- Remove title truncation from display path
- Bump cache to v15 to force regeneration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@thomasttvo
Copy link
Author

CodeRabbit Review — All Threads Addressed

Critical

#1 — Path traversal in cache writes: Added sanitizePathComponent() that strips traversal sequences and invalid chars via path.basename(name.replace(/[^\w-]/g, '_')). Applied to team-lead cache writes.

Major

#2shortProjectName returns single segment: Now joins last 2 non-container segments for disambiguation (e.g. ccusage-fork instead of just fork). Added Users/home to container dirs list.

#3ctx.values vs mergedOptions: All references in run() now use mergedOptions (the merged config+CLI result) instead of raw ctx.values.

#4 — mtime prefilter on JSONL files: This is in data-loader.ts which is pre-existing code, not part of this PR's diff. Good suggestion for a follow-up.

#5 — Hardcoded ~/.claude paths: The session-titles/ and team-lead-cache/ directories are ccusage's own cache dirs (not Claude config). Using ~/.claude as the cache root is intentional since it's the user's Claude home. The credentials path (fix #10) now tries both ~/.claude and ~/.config/claude.

#6 — Unhandled resolveSessionTitle rejection: Wrapped in try/catch so one bad JSONL file doesn't abort the entire report.

#7offline flag doesn't skip AI generation: Added offline parameter to resolveLeadDisplayNames(). AI batch is skipped when offline; fallback assigns truncated session IDs instead.

Minor

#8loadLeadTitleCache doesn't check version: Added lines[0] === CACHE_VERSION check. Also hoisted CACHE_VERSION to module scope (was duplicated in two functions).

#9 — Percent column shows <0.1 for zero cost: Now shows '-' when data.totalCost === 0. Totals row also shows '-' when total cost is 0.

#10 — Credentials path hardcoded: Now tries both ~/.claude/.credentials.json and ~/.config/claude/.credentials.json.

#11colWidthOverrides not validated: The value 46 is hardcoded, not user-supplied. No runtime validation needed for a constant.

#12days accepts negatives/decimals: Added Math.max(1, Math.floor(...)) to clamp to positive integers.

#13 — HTTP calls have no timeout: Added AbortSignal.timeout(10_000) for OAuth refresh and AbortSignal.timeout(30_000) for Claude API calls.

#14 — Comment references old lead-hash format: Updated comments to clarify lead-xxxx (4-char session prefix) format.

Dismiss

#15 — Full UUID shown: Fixed in earlier commit — full 36-char UUID now displayed on second line of titled rows.

- Sanitize cache path components against traversal attacks
- Join last 2 non-container segments in shortProjectName for disambiguation
- Use mergedOptions instead of ctx.values throughout run()
- Wrap resolveSessionTitle in try/catch for resilience
- Skip AI generation in offline mode with fallback IDs
- Add CACHE_VERSION check to loadLeadTitleCache (hoisted to module scope)
- Show '-' instead of '<0.1' for zero-cost percent column
- Try both ~/.claude and ~/.config/claude for credentials
- Clamp days param to positive integers
- Add AbortSignal timeouts to HTTP fetch calls
- Update comments to clarify lead-xxxx format

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@thomasttvo
Copy link
Author

bugbot review

thomasvo and others added 4 commits March 17, 2026 12:21
- Add explicit constraint against verb/gerund starts in AI prompt
- Add 3 BAD → GOOD few-shot examples for clarity
- Bump cache version v15 → v16 to regenerate stale titles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a user message starts with a UUID on its own line (e.g. pasting a
session ID), the title generator was using that UUID as input instead of
the actual descriptive text on subsequent lines. Now skips lines matching
bare UUID/hash patterns and uses the first substantive line instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Increase user message collection from 3 to 5 for richer AI context
- Cap per-session AI input at 1000 chars to avoid token waste
- Filter bare UUID/hash lines from fallback hint display
- Apply UUID filtering to both online and offline fallback paths

Sessions with sparse early messages (like 10817cae which started with a
pasted UUID) now get enough context for descriptive titles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@thomasttvo thomasttvo marked this pull request as ready for review March 18, 2026 17:45
@thomasttvo thomasttvo marked this pull request as draft March 21, 2026 20:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant