mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
Add tooling breakdowns to CLI and menubar (#373)
Some checks are pending
CI / semgrep (push) Waiting to run
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:
parent
a51d819a47
commit
33b1abbbef
9 changed files with 276 additions and 44 deletions
|
|
@ -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: [])
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ struct MenuBarContent: View {
|
|||
Divider().opacity(0.5)
|
||||
ModelsSection()
|
||||
Divider().opacity(0.5)
|
||||
ToolingSection()
|
||||
Divider().opacity(0.5)
|
||||
FindingsSection()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
137
mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift
Normal file
137
mac/Sources/CodeBurnMenubar/Views/ToolingSection.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
37
src/main.ts
37
src/main.ts
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type CachedCall = {
|
|||
tools: string[]
|
||||
bashCommands: string[]
|
||||
skills: string[]
|
||||
subagentTypes: string[]
|
||||
deduplicationKey: string
|
||||
project?: string
|
||||
projectPath?: string
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue