Add tooling breakdowns to CLI and menubar (#373)
Some checks are pending
CI / semgrep (push) Waiting to run

* Fix Antigravity provider detection, Codex fork double-counting, and tab ordering

Antigravity:
- Handle ephemeral port (--https_server_port 0) via lsof fallback
- Force reparse when cached turns are 0 (server may have been unavailable)
- Persist precomputed costUSD in session cache for correct pricing
- Map gemini-pro-agent to gemini-3.1-pro pricing
- Add display names for gemini-pro-agent and gemini-3.5-flash-low

Codex:
- Detect forked sessions via forked_from_id in session_meta
- Skip replayed parent events within 5s of fork creation time
- Use parent session ID in dedup key so parent+fork don't double-count

Menubar:
- Sort provider tabs by cost descending instead of enum declaration order

* Add tooling breakdowns to CLI dashboard and menubar

Surface skills, subagents, tools, and MCP server usage in both the
text dashboard and menubar app. Parse subagent types from Agent/Task
tool calls, aggregate with skills into a merged "Skills & Agents"
panel. Remove Top Sessions panel from CLI dashboard.
This commit is contained in:
Resham Joshi 2026-05-21 14:38:14 -07:00 committed by GitHub
parent a51d819a47
commit 33b1abbbef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 276 additions and 44 deletions

View file

@ -105,13 +105,18 @@ struct CurrentBlock: Codable, Sendable {
let topSessions: [TopSessionEntry]
let retryTax: RetryTax
let routingWaste: RoutingWaste
let tools: [ToolEntry]
let skills: [SkillEntry]
let subagents: [SubagentEntry]
let mcpServers: [McpServerEntry]
}
extension CurrentBlock {
enum CodingKeys: String, CodingKey {
case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens,
cacheHitPercent, topActivities, topModels, providers, topProjects,
modelEfficiency, topSessions, retryTax, routingWaste
modelEfficiency, topSessions, retryTax, routingWaste,
tools, skills, subagents, mcpServers
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
@ -131,6 +136,10 @@ extension CurrentBlock {
topSessions = try c.decodeIfPresent([TopSessionEntry].self, forKey: .topSessions) ?? []
retryTax = try c.decodeIfPresent(RetryTax.self, forKey: .retryTax) ?? RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: [])
routingWaste = try c.decodeIfPresent(RoutingWaste.self, forKey: .routingWaste) ?? RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: [])
tools = try c.decodeIfPresent([ToolEntry].self, forKey: .tools) ?? []
skills = try c.decodeIfPresent([SkillEntry].self, forKey: .skills) ?? []
subagents = try c.decodeIfPresent([SubagentEntry].self, forKey: .subagents) ?? []
mcpServers = try c.decodeIfPresent([McpServerEntry].self, forKey: .mcpServers) ?? []
}
}
@ -195,6 +204,28 @@ struct TopSessionEntry: Codable, Sendable {
let date: String
}
struct ToolEntry: Codable, Sendable {
let name: String
let calls: Int
}
struct SkillEntry: Codable, Sendable {
let name: String
let turns: Int
let cost: Double
}
struct SubagentEntry: Codable, Sendable {
let name: String
let calls: Int
let cost: Double
}
struct McpServerEntry: Codable, Sendable {
let name: String
let calls: Int
}
struct OptimizeBlock: Codable, Sendable {
let findingCount: Int
let savingsUSD: Double
@ -230,7 +261,11 @@ extension MenubarPayload {
modelEfficiency: [],
topSessions: [],
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: [])
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
tools: [],
skills: [],
subagents: [],
mcpServers: []
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: [])

View file

@ -36,6 +36,8 @@ struct MenuBarContent: View {
Divider().opacity(0.5)
ModelsSection()
Divider().opacity(0.5)
ToolingSection()
Divider().opacity(0.5)
FindingsSection()
}
}

View file

@ -0,0 +1,137 @@
import SwiftUI
struct ToolingSection: View {
@Environment(AppStore.self) private var store
@State private var isExpanded: Bool = false
private var skillsAndAgents: [CostRowData] {
let current = store.payload.current
var merged: [String: (uses: Int, cost: Double)] = [:]
for s in current.skills {
let e = merged[s.name, default: (0, 0)]
merged[s.name] = (e.uses + s.turns, e.cost + s.cost)
}
for a in current.subagents {
let e = merged[a.name, default: (0, 0)]
merged[a.name] = (e.uses + a.calls, e.cost + a.cost)
}
return merged
.map { CostRowData(name: $0.key, uses: $0.value.uses, cost: $0.value.cost) }
.sorted { $0.cost > $1.cost }
}
var body: some View {
let current = store.payload.current
let combined = skillsAndAgents
let hasAny = !current.tools.isEmpty || !combined.isEmpty || !current.mcpServers.isEmpty
if hasAny {
CollapsibleSection(caption: "Tooling", isExpanded: $isExpanded) {
VStack(alignment: .leading, spacing: 12) {
if !current.tools.isEmpty {
ToolingSubsection(title: "Tools") {
let maxCalls = current.tools.map(\.calls).max() ?? 1
ForEach(current.tools, id: \.name) { t in
CallsRow(name: t.name, calls: t.calls, maxCalls: maxCalls)
}
}
}
if !combined.isEmpty {
ToolingSubsection(title: "Skills & Agents") {
let maxCost = max(combined.map(\.cost).max() ?? 0.01, 0.01)
ForEach(combined, id: \.name) { d in
CostRow(name: d.name, cost: d.cost, count: d.uses, countLabel: "uses", maxCost: maxCost)
}
}
}
if !current.mcpServers.isEmpty {
ToolingSubsection(title: "MCP Servers") {
let maxCalls = current.mcpServers.map(\.calls).max() ?? 1
ForEach(current.mcpServers, id: \.name) { m in
CallsRow(name: m.name, calls: m.calls, maxCalls: maxCalls)
}
}
}
}
}
}
}
}
private struct CostRowData {
let name: String
let uses: Int
let cost: Double
}
private struct ToolingSubsection<Content: View>: View {
let title: String
let content: Content
init(title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.system(size: 10.5, weight: .semibold))
.foregroundStyle(.tertiary)
.textCase(.uppercase)
.tracking(0.5)
content
}
}
}
private struct CallsRow: View {
let name: String
let calls: Int
let maxCalls: Int
var body: some View {
HStack(spacing: 8) {
FixedBar(fraction: Double(calls) / Double(max(maxCalls, 1)))
.frame(width: 40, height: 5)
Text(name)
.font(.system(size: 12, weight: .medium))
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Text("\(calls)")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 36, alignment: .trailing)
}
.padding(.vertical, 1)
}
}
private struct CostRow: View {
let name: String
let cost: Double
let count: Int
let countLabel: String
let maxCost: Double
var body: some View {
HStack(spacing: 8) {
FixedBar(fraction: cost / max(maxCost, 0.01))
.frame(width: 40, height: 5)
Text(name)
.font(.system(size: 12, weight: .medium))
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Text("\(count)")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 30, alignment: .trailing)
Text(cost.asCompactCurrency())
.font(.codeMono(size: 11, weight: .medium))
.tracking(-0.2)
.frame(minWidth: 46, alignment: .trailing)
}
.padding(.vertical, 1)
}
}

View file

@ -40,12 +40,12 @@ const PANEL_COLORS = {
overview: '#FF8C42',
daily: '#5B9EF5',
project: '#5BF5A0',
sessions: '#FF6B6B',
model: '#E05BF5',
activity: '#F5C85B',
tools: '#5BF5E0',
mcp: '#F55BE0',
bash: '#F5A05B',
skills: '#7B68EE',
}
const PROVIDER_COLORS: Record<string, string> = {
@ -465,43 +465,6 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr
)
}
const TOP_SESSIONS_DATE_LEN = 10
const TOP_SESSIONS_COST_COL = 8
const TOP_SESSIONS_CALLS_COL = 6
function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const allSessions = projects.flatMap(p =>
p.sessions.map(s => ({ ...s, projectPath: p.projectPath }))
)
const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5)
if (top.length === 0) {
return <Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}><Text dimColor>No sessions</Text></Panel>
}
const maxCost = top[0].totalCostUSD
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME)
return (
<Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{'calls'.padStart(TOP_SESSIONS_CALLS_COL)}</Text>
{top.map((session, i) => {
const date = session.firstTimestamp
? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN)
: '----------'
const label = `${date} ${shortProject(session.projectPath)}`
return (
<Text key={`${session.sessionId}-${i}`} wrap="truncate-end">
<HBar value={session.totalCostUSD} max={maxCost} width={bw} />
<Text dimColor> {fit(label, nw - 1)}</Text>
<Text color={GOLD}>{formatCost(session.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)}</Text>
<Text>{String(session.apiCalls).padStart(TOP_SESSIONS_CALLS_COL)}</Text>
</Text>
)
})}
</Panel>
)
}
function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const mcpTotals: Record<string, number> = {}
@ -537,6 +500,26 @@ function BashBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: n
)
}
function SkillsAndAgents({ 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) {
for (const [skill, d] of Object.entries(session.skillBreakdown)) { const e = merged[skill] ?? { uses: 0, cost: 0 }; e.uses += d.turns; e.cost += d.costUSD; merged[skill] = e }
for (const [agent, d] of Object.entries(session.subagentBreakdown)) { const e = merged[agent] ?? { uses: 0, cost: 0 }; e.uses += d.calls; e.cost += d.costUSD; merged[agent] = e }
} }
const sorted = Object.entries(merged).sort(([, a], [, b]) => b.cost - a.cost)
if (sorted.length === 0) return <Panel title="Skills & Agents" color={PANEL_COLORS.skills} width={pw}><Text dimColor>No skill/agent usage</Text></Panel>
const maxCost = sorted[0]?.[1]?.cost ?? 0
const nw = Math.max(6, pw - bw - 22)
return (
<Panel title="Skills & Agents" color={PANEL_COLORS.skills} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'uses'.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',
@ -712,12 +695,11 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
<Box flexDirection="column" width={dashWidth}>
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} planUsages={planUsages} />
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
<TopSessions projects={projects} pw={dashWidth} bw={barWidth} />
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
{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><McpBreakdown projects={projects} pw={dashWidth} bw={barWidth} /></>
<><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></>
)}
</Box>
)

View file

@ -6,7 +6,7 @@ import { loadPricing, setModelAliases } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js'
import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { type PeriodData, type ProviderCost } from './menubar-json.js'
import { type PeriodData, type ProviderCost, type BreakdownArrays } from './menubar-json.js'
import { buildMenubarPayload } from './menubar-json.js'
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
@ -290,6 +290,8 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const toolMap: Record<string, number> = {}
const mcpMap: Record<string, number> = {}
const bashMap: Record<string, number> = {}
const skillMap: Record<string, { turns: number; cost: number }> = {}
const subagentMap: Record<string, { calls: number; cost: number }> = {}
for (const sess of sessions) {
for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
@ -300,6 +302,16 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
for (const [cmd, d] of Object.entries(sess.bashBreakdown)) {
bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls
}
for (const [skill, d] of Object.entries(sess.skillBreakdown)) {
if (!skillMap[skill]) skillMap[skill] = { turns: 0, cost: 0 }
skillMap[skill].turns += d.turns
skillMap[skill].cost += d.costUSD
}
for (const [sat, d] of Object.entries(sess.subagentBreakdown)) {
if (!subagentMap[sat]) subagentMap[sat] = { calls: 0, cost: 0 }
subagentMap[sat].calls += d.calls
subagentMap[sat].cost += d.costUSD
}
}
const sortedMap = (m: Record<string, number>) =>
@ -334,6 +346,8 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
tools: sortedMap(toolMap),
mcpServers: sortedMap(mcpMap),
shellCommands: sortedMap(bashMap),
skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).map(([name, d]) => ({ name, turns: d.turns, cost: convertCost(d.cost) })),
subagents: Object.entries(subagentMap).sort(([, a], [, b]) => b.cost - a.cost).map(([name, d]) => ({ name, calls: d.calls, cost: convertCost(d.cost) })),
topSessions,
}
}
@ -680,8 +694,27 @@ program
byModel: routingWasteByModel.slice(0, 5),
}
const breakdowns: BreakdownArrays = (() => {
const toolMap: Record<string, number> = {}
const skillMap: Record<string, { turns: number; cost: number }> = {}
const subagentMap: Record<string, { calls: number; cost: number }> = {}
const mcpMap: Record<string, number> = {}
for (const p of scanProjects) for (const s of p.sessions) {
for (const [t, d] of Object.entries(s.toolBreakdown)) { if (!t.startsWith('lang:')) toolMap[t] = (toolMap[t] ?? 0) + d.calls }
for (const [sk, d] of Object.entries(s.skillBreakdown)) { const e = skillMap[sk] ?? { turns: 0, cost: 0 }; e.turns += d.turns; e.cost += d.costUSD; skillMap[sk] = e }
for (const [sa, d] of Object.entries(s.subagentBreakdown)) { const e = subagentMap[sa] ?? { calls: 0, cost: 0 }; e.calls += d.calls; e.cost += d.costUSD; subagentMap[sa] = e }
for (const [m, d] of Object.entries(s.mcpBreakdown)) { mcpMap[m] = (mcpMap[m] ?? 0) + d.calls }
}
return {
tools: Object.entries(toolMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })),
skills: Object.entries(skillMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })),
subagents: Object.entries(subagentMap).sort(([, a], [, b]) => b.cost - a.cost).slice(0, 10).map(([name, d]) => ({ name, ...d })),
mcpServers: Object.entries(mcpMap).sort(([, a], [, b]) => b - a).slice(0, 10).map(([name, calls]) => ({ name, calls })),
}
})()
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste)))
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns)))
return
}

View file

@ -123,6 +123,10 @@ export type MenubarPayload = {
savingsUSD: number
}>
}
tools: Array<{ name: string; calls: number }>
skills: Array<{ name: string; turns: number; cost: number }>
subagents: Array<{ name: string; calls: number; cost: number }>
mcpServers: Array<{ name: string; calls: number }>
}
optimize: {
findingCount: number
@ -246,6 +250,13 @@ function buildTopSessions(sessions: PeriodData['topSessions']): MenubarPayload['
.map(s => ({ project: s.project, cost: s.cost, calls: s.calls, date: s.date }))
}
export type BreakdownArrays = {
tools?: MenubarPayload['current']['tools']
skills?: MenubarPayload['current']['skills']
subagents?: MenubarPayload['current']['subagents']
mcpServers?: MenubarPayload['current']['mcpServers']
}
export function buildMenubarPayload(
current: PeriodData,
providers: ProviderCost[],
@ -253,6 +264,7 @@ export function buildMenubarPayload(
dailyHistory?: DailyHistoryEntry[],
retryTax?: MenubarPayload['current']['retryTax'],
routingWaste?: MenubarPayload['current']['routingWaste'],
breakdowns?: BreakdownArrays,
): MenubarPayload {
return {
generated: new Date().toISOString(),
@ -273,6 +285,10 @@ export function buildMenubarPayload(
topSessions: buildTopSessions(current.topSessions ?? []),
retryTax: retryTax ?? { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: routingWaste ?? { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: breakdowns?.tools ?? [],
skills: breakdowns?.skills ?? [],
subagents: breakdowns?.subagents ?? [],
mcpServers: breakdowns?.mcpServers ?? [],
},
optimize: buildOptimize(optimize),
history: buildHistory(dailyHistory),

View file

@ -908,6 +908,17 @@ function extractSkillNames(content: ContentBlock[]): string[] {
.filter(name => name.length > 0)
}
function extractSubagentTypes(content: ContentBlock[]): string[] {
return content
.filter((b): b is ToolUseBlock => b.type === 'tool_use' && (b.name === 'Agent' || b.name === 'Task'))
.map(b => {
const input = (b.input ?? {}) as Record<string, unknown>
const raw = input['subagent_type']
return typeof raw === 'string' ? raw.trim() : ''
})
.filter(name => name.length > 0)
}
function extractCoreTools(tools: string[]): string[] {
return tools.filter(t => !t.startsWith('mcp__'))
}
@ -981,6 +992,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
const tools = extractToolNames(msg.content ?? [])
const skills = extractSkillNames(msg.content ?? [])
const subagentTypes = extractSubagentTypes(msg.content ?? [])
const costUSD = calculateCost(
msg.model,
tokens.inputTokens,
@ -1002,6 +1014,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
tools,
mcpTools: extractMcpTools(tools),
skills,
subagentTypes,
hasAgentSpawn: tools.includes('Agent'),
hasPlanMode: tools.includes('EnterPlanMode'),
speed: usage.speed ?? 'standard',
@ -1145,6 +1158,7 @@ function buildSessionSummary(
const bashBreakdown: SessionSummary['bashBreakdown'] = Object.create(null)
const categoryBreakdown: SessionSummary['categoryBreakdown'] = Object.create(null)
const skillBreakdown: SessionSummary['skillBreakdown'] = Object.create(null)
const subagentBreakdown: SessionSummary['subagentBreakdown'] = Object.create(null)
let totalCost = 0
let totalInput = 0
@ -1218,6 +1232,11 @@ function buildSessionSummary(
bashBreakdown[cmd] = bashBreakdown[cmd] ?? { calls: 0 }
bashBreakdown[cmd].calls++
}
for (const sat of call.subagentTypes) {
subagentBreakdown[sat] = subagentBreakdown[sat] ?? { calls: 0, costUSD: 0 }
subagentBreakdown[sat].calls++
subagentBreakdown[sat].costUSD += call.costUSD
}
if (!firstTs || call.timestamp < firstTs) firstTs = call.timestamp
if (!lastTs || call.timestamp > lastTs) lastTs = call.timestamp
@ -1242,6 +1261,7 @@ function buildSessionSummary(
bashBreakdown,
categoryBreakdown,
skillBreakdown,
subagentBreakdown,
...(mcpInventory && mcpInventory.length > 0 ? { mcpInventory } : {}),
}
}
@ -1499,6 +1519,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
tools,
mcpTools: extractMcpTools(tools),
skills: [],
subagentTypes: [],
hasAgentSpawn: tools.includes('Agent'),
hasPlanMode: tools.includes('EnterPlanMode'),
speed: call.speed,
@ -1537,6 +1558,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
tools: call.tools,
bashCommands: call.bashCommands,
skills: [],
subagentTypes: [],
deduplicationKey: call.deduplicationKey,
project: call.project,
projectPath: call.projectPath,
@ -1554,6 +1576,7 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
tools: call.tools,
bashCommands: call.bashCommands,
skills: call.skills,
subagentTypes: call.subagentTypes,
deduplicationKey: call.deduplicationKey,
}
}
@ -1630,6 +1653,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
tools: call.tools,
mcpTools: extractMcpTools(call.tools),
skills: call.skills,
subagentTypes: call.subagentTypes ?? [],
hasAgentSpawn: call.tools.includes('Agent'),
hasPlanMode: call.tools.includes('EnterPlanMode'),
speed: call.speed,

View file

@ -27,6 +27,7 @@ export type CachedCall = {
tools: string[]
bashCommands: string[]
skills: string[]
subagentTypes: string[]
deduplicationKey: string
project?: string
projectPath?: string

View file

@ -77,6 +77,7 @@ export type ParsedApiCall = {
tools: string[]
mcpTools: string[]
skills: string[]
subagentTypes: string[]
hasAgentSpawn: boolean
hasPlanMode: boolean
speed: 'standard' | 'fast'
@ -127,6 +128,7 @@ export type SessionSummary = {
bashBreakdown: Record<string, { calls: number }>
categoryBreakdown: Record<TaskCategory, { turns: number; costUSD: number; retries: number; editTurns: number; oneShotTurns: number }>
skillBreakdown: Record<string, { turns: number; costUSD: number; editTurns: number; oneShotTurns: number }>
subagentBreakdown: Record<string, { calls: number; costUSD: number }>
// Observed MCP tools available in this session, captured from
// `attachment.deferred_tools_delta.addedNames` entries. Union across all
// turns. Each name is a fully-qualified `mcp__<server>__<tool>` identifier.