Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 25 additions & 1 deletion src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { uses: number; cost: number }> = {}
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 (
<Panel title="Claude Agent Types" color={PANEL_COLORS.skills} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)}{'cost'.padStart(8)}</Text>
{sorted.slice(0, 10).map(([name, d]) => (
<Text key={name} wrap="truncate-end"><HBar value={d.cost} max={maxCost} width={bw} /><Text> {fit(name, nw)}</Text><Text>{String(d.uses).padStart(6)}</Text><Text color={GOLD}>{formatCost(d.cost).padStart(8)}</Text></Text>
))}
</Panel>
)
}

const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
all: 'All',
claude: 'Claude',
Expand Down Expand Up @@ -718,7 +742,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
{isCursor ? (
<ToolBreakdown projects={projects} pw={dashWidth} bw={barWidth} title="Languages" filterPrefix="lang:" />
) : (
<><Row wide={wide} width={dashWidth}><ToolBreakdown projects={projects} pw={pw} bw={barWidth} /><BashBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><Row wide={wide} width={dashWidth}><SkillsAndAgents projects={projects} pw={pw} bw={barWidth} /><McpBreakdown projects={projects} pw={pw} bw={barWidth} /></Row></>
<><Row wide={wide} width={dashWidth}><ToolBreakdown projects={projects} pw={pw} bw={barWidth} /><BashBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><Row wide={wide} width={dashWidth}><SkillsAndAgents projects={projects} pw={pw} bw={barWidth} /><McpBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><Row wide={wide} width={dashWidth}><ClaudeAgentTypes projects={projects} pw={pw} bw={barWidth} /></Row></>
)}
</Box>
)
Expand Down
11 changes: 11 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,10 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const bashMap: Record<string, number> = {}
const skillMap: Record<string, { turns: number; cost: number; savings: number }> = {}
const subagentMap: Record<string, { calls: number; cost: number; savings: number }> = {}
// 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<string, { calls: number; cost: number; savings: number }> = {}
for (const sess of sessions) {
for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
Expand All @@ -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<string, number>) =>
Expand Down Expand Up @@ -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,
}
}
Expand Down
45 changes: 33 additions & 12 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1463,28 +1463,47 @@ async function parseSessionFile(
}
}

async function collectJsonlFiles(dirPath: string): Promise<string[]> {
// Recursively collect every `.jsonl` under `dir`. Subagent transcripts live in
// `subagents/`, and workflow/ultracode runs nest a further level deep
// (`subagents/workflows/<wf>/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<string>): Promise<void> {
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<string[]> {
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<string | undefined> {
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<string>,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions tests/parser-subagent-collection.test.ts
Original file line number Diff line number Diff line change
@@ -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
// `<session>/subagents/workflows/<wf>/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()
})
})