diff --git a/src/dashboard.tsx b/src/dashboard.tsx index b20cb02f..243aa8dd 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -531,6 +531,30 @@ function SkillsAndAgents({ projects, pw, bw }: { projects: ProjectSummary[]; pw: ) } +// Claude Code only: real subagent-transcript spend by agentType +// (workflow-subagent / Explore / general-purpose / …). Returns null when there +// are no agent transcripts, so it never shows for other providers. +function ClaudeAgentTypes({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const merged: Record = {} + for (const project of projects) { for (const session of project.sessions) { + if (!session.agentType) continue + const e = merged[session.agentType] ?? { uses: 0, cost: 0 } + e.uses += session.apiCalls; e.cost += session.totalCostUSD; merged[session.agentType] = e + } } + const sorted = Object.entries(merged).sort(([, a], [, b]) => b.cost - a.cost) + if (sorted.length === 0) return null + const maxCost = sorted[0]?.[1]?.cost ?? 0 + const nw = Math.max(6, pw - bw - 22) + return ( + + {''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)}{'cost'.padStart(8)} + {sorted.slice(0, 10).map(([name, d]) => ( + {fit(name, nw)}{String(d.uses).padStart(6)}{formatCost(d.cost).padStart(8)} + ))} + + ) +} + const PROVIDER_DISPLAY_NAMES: Record = { all: 'All', claude: 'Claude', @@ -718,7 +742,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets, {isCursor ? ( ) : ( - <> + <> )} ) diff --git a/src/main.ts b/src/main.ts index 883ab504..a81f7567 100644 --- a/src/main.ts +++ b/src/main.ts @@ -320,6 +320,10 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: const bashMap: Record = {} const skillMap: Record = {} const subagentMap: Record = {} + // Claude Code only: real subagent-transcript spend grouped by agentType + // (workflow-subagent / Explore / general-purpose / …). Distinct from + // subagentMap, which is Task-tool-input based and never sees workflow agents. + const agentTypeMap: Record = {} for (const sess of sessions) { for (const [tool, d] of Object.entries(sess.toolBreakdown)) { toolMap[tool] = (toolMap[tool] ?? 0) + d.calls @@ -342,6 +346,12 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: subagentMap[sat].cost += d.costUSD subagentMap[sat].savings += d.savingsUSD } + if (sess.agentType) { + if (!agentTypeMap[sess.agentType]) agentTypeMap[sess.agentType] = { calls: 0, cost: 0, savings: 0 } + agentTypeMap[sess.agentType].calls += sess.apiCalls + agentTypeMap[sess.agentType].cost += sess.totalCostUSD + agentTypeMap[sess.agentType].savings += sess.totalSavingsUSD + } } const sortedMap = (m: Record) => @@ -392,6 +402,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: shellCommands: sortedMap(bashMap), skills: Object.entries(skillMap).sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)).map(([name, d]) => ({ name, turns: d.turns, cost: convertCost(d.cost), savings: convertCost(d.savings) })), subagents: Object.entries(subagentMap).sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)).map(([name, d]) => ({ name, calls: d.calls, cost: convertCost(d.cost), savings: convertCost(d.savings) })), + claudeAgentTypes: Object.entries(agentTypeMap).sort(([, a], [, b]) => (b.cost + b.savings) - (a.cost + a.savings)).map(([name, d]) => ({ name, calls: d.calls, cost: convertCost(d.cost), savings: convertCost(d.savings) })), topSessions, } } diff --git a/src/parser.ts b/src/parser.ts index dec65742..21eed277 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1463,28 +1463,47 @@ async function parseSessionFile( } } -async function collectJsonlFiles(dirPath: string): Promise { +// Recursively collect every `.jsonl` under `dir`. Subagent transcripts live in +// `subagents/`, and workflow/ultracode runs nest a further level deep +// (`subagents/workflows//agent-*.jsonl`); a flat scan misses those, so their +// usage went uncounted whenever the workflow feature was on. (#470) +async function collectJsonlInto(dir: string, out: Set): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) + for (const e of entries) { + const p = join(dir, e.name) + if (e.isDirectory()) await collectJsonlInto(p, out) + else if (e.name.endsWith('.jsonl')) out.add(p) + } +} + +export async function collectJsonlFiles(dirPath: string): Promise { const files = await readdir(dirPath).catch(() => []) const jsonlFiles = new Set(files.filter(f => f.endsWith('.jsonl')).map(f => join(dirPath, f))) - const directSubagentsPath = join(dirPath, 'subagents') - const directSubFiles = await readdir(directSubagentsPath).catch(() => []) - for (const sf of directSubFiles) { - if (sf.endsWith('.jsonl')) jsonlFiles.add(join(directSubagentsPath, sf)) - } - + await collectJsonlInto(join(dirPath, 'subagents'), jsonlFiles) for (const entry of files) { if (entry.endsWith('.jsonl')) continue - const subagentsPath = join(dirPath, entry, 'subagents') - const subFiles = await readdir(subagentsPath).catch(() => []) - for (const sf of subFiles) { - if (sf.endsWith('.jsonl')) jsonlFiles.add(join(subagentsPath, sf)) - } + await collectJsonlInto(join(dirPath, entry, 'subagents'), jsonlFiles) } return [...jsonlFiles] } +// Claude Code subagent transcripts (`subagents/.../agent-*.jsonl`) have a sibling +// `.meta.json` carrying the `agentType` (e.g. `workflow-subagent`, `Explore`). +// Returns undefined for ordinary session files, which carry no agent type. +export async function readAgentType(filePath: string): Promise { + if (!/[\\/]subagents[\\/]/.test(filePath)) return undefined + const metaPath = filePath.replace(/\.jsonl$/, '.meta.json') + try { + const t = (JSON.parse(await readFile(metaPath, 'utf8')) as { agentType?: unknown }).agentType + if (typeof t === 'string' && t.trim()) return t.trim().slice(0, 100) + } catch { /* missing or unreadable meta */ } + // Workflow agents always live under `subagents/workflows/`, so fall back to that + // even when the meta sidecar is absent. + return /[\\/]subagents[\\/]workflows[\\/]/.test(filePath) ? 'workflow-subagent' : undefined +} + async function scanProjectDirs( dirs: Array<{ path: string; name: string }>, seenMsgIds: Set, @@ -1541,6 +1560,7 @@ async function scanProjectDirs( canonicalProjectName: canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined, mcpInventory: extractMcpInventory(entries), turns: turns.map(parsedTurnToCachedTurn), + agentType: await readAgentType(filePath), } ;(diskCache as { _dirty?: boolean })._dirty = true } catch (err) { @@ -1594,6 +1614,7 @@ async function scanProjectDirs( const projectName = cachedFile.canonicalProjectName ?? dirName const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv) + session.agentType = cachedFile.agentType if (session.apiCalls > 0) { const projectKey = cachedFile.canonicalCwd diff --git a/src/session-cache.ts b/src/session-cache.ts index c7735ac7..29360287 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -57,6 +57,10 @@ export type CachedFile = { canonicalProjectName?: string mcpInventory: string[] turns: CachedTurn[] + // Claude Code only: for a subagent transcript (`subagents/.../agent-*.jsonl`), + // the `agentType` from its sibling `.meta.json` (e.g. `workflow-subagent`, + // `Explore`, `general-purpose`). Drives the Claude-scoped agent-type breakdown. + agentType?: string // Negative-result marker: this file threw while parsing at the recorded // fingerprint. Cached so we don't re-read + re-throw it on every refresh; it // is re-parsed only when the file changes (fingerprint differs). Carries no @@ -76,7 +80,7 @@ export type SessionCache = { // ── Constants ────────────────────────────────────────────────────────── -export const CACHE_VERSION = 3 +export const CACHE_VERSION = 4 const CACHE_FILE = 'session-cache.json' const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 diff --git a/src/types.ts b/src/types.ts index 8a6b37c9..67c1e190 100644 --- a/src/types.ts +++ b/src/types.ts @@ -126,6 +126,10 @@ export type ClassifiedTurn = ParsedTurn & { export type SessionSummary = { sessionId: string project: string + // Claude Code only: agent type of a subagent transcript session + // (`workflow-subagent`, `Explore`, `general-purpose`, …); undefined for + // ordinary sessions. Drives the Claude-scoped agent-type breakdown. + agentType?: string firstTimestamp: string lastTimestamp: string totalCostUSD: number diff --git a/tests/parser-subagent-collection.test.ts b/tests/parser-subagent-collection.test.ts new file mode 100644 index 00000000..890809e5 --- /dev/null +++ b/tests/parser-subagent-collection.test.ts @@ -0,0 +1,61 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join, basename } from 'path' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { collectJsonlFiles, readAgentType } from '../src/parser.js' + +let root: string +beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'codeburn-collect-')) }) +afterEach(async () => { await rm(root, { recursive: true, force: true }) }) + +describe('collectJsonlFiles', () => { + // Regression for #470: workflow/ultracode subagent transcripts live nested at + // `/subagents/workflows//agent-*.jsonl`. A flat scan dropped them, + // so usage went uncounted whenever the workflow feature was on. + it('collects nested workflow subagent transcripts, not just top-level subagent files', async () => { + const sessionDir = join(root, 'session-1') + const wfDir = join(sessionDir, 'subagents', 'workflows', 'wf_abc') + await mkdir(wfDir, { recursive: true }) + + await writeFile(join(root, 'session-1.jsonl'), '{}\n') + await writeFile(join(sessionDir, 'subagents', 'agent-direct.jsonl'), '{}\n') + await writeFile(join(wfDir, 'agent-nested.jsonl'), '{}\n') + // Sidecar metadata must never be picked up as a transcript. + await writeFile(join(wfDir, 'agent-nested.meta.json'), '{}\n') + + const found = (await collectJsonlFiles(root)).map(f => basename(f)).sort() + + expect(found).toContain('session-1.jsonl') + expect(found).toContain('agent-direct.jsonl') + expect(found).toContain('agent-nested.jsonl') + expect(found).not.toContain('agent-nested.meta.json') + }) + + it('returns an empty list for a missing directory without throwing', async () => { + await expect(collectJsonlFiles(join(root, 'does-not-exist'))).resolves.toEqual([]) + }) +}) + +describe('readAgentType (Claude-scoped agent-type detection)', () => { + it('reads agentType from a subagent transcript’s sibling .meta.json', async () => { + const dir = join(root, 'session', 'subagents') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'agent-x.jsonl'), '{}\n') + await writeFile(join(dir, 'agent-x.meta.json'), JSON.stringify({ agentType: 'Explore' })) + expect(await readAgentType(join(dir, 'agent-x.jsonl'))).toBe('Explore') + }) + + it('falls back to workflow-subagent for nested workflow agents without a meta', async () => { + const dir = join(root, 'session', 'subagents', 'workflows', 'wf_1') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'agent-y.jsonl'), '{}\n') + expect(await readAgentType(join(dir, 'agent-y.jsonl'))).toBe('workflow-subagent') + }) + + it('returns undefined for an ordinary (non-subagent) session file', async () => { + await writeFile(join(root, 'session.jsonl'), '{}\n') + expect(await readAgentType(join(root, 'session.jsonl'))).toBeUndefined() + }) +})