Skip to content
Open
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
35 changes: 34 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ import { renderOverview } from './overview.js'
import { runWebDashboard } from './web-dashboard.js'
import { hostname } from 'os'
import { runShareServer } from './sharing/share-run.js'
import { addRemote, linkRemote, pullDevices, renderDevices } from './sharing/host.js'
import { addRemote, linkRemote, pullDevices, renderDevices, summarizeDeviceUsage } from './sharing/host.js'
import { browse } from './sharing/discovery.js'
import { promptChoice } from './sharing/prompt.js'
import { loadRemotes, saveRemotes } from './sharing/store.js'
import type { UsageQuery } from './sharing/share-server.js'
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize } from './optimize.js'
import { renderCompare } from './compare.js'
Expand Down Expand Up @@ -148,6 +149,15 @@ function assertProvider(value: string, command: string): void {
process.exit(1)
}

function assertScope(value: string, allowed: readonly string[], command: string): void {
if (!allowed.includes(value)) {
process.stderr.write(
`codeburn ${command}: unknown scope "${value}". Valid values: ${allowed.join(', ')}.\n`
)
process.exit(1)
}
}

async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
await loadPricing()
const { range, label } = getDateRange(period)
Expand Down Expand Up @@ -605,6 +615,7 @@ program
.command('status')
.description('Compact status output (today + month)')
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
.option('--scope <scope>', 'Usage scope for menubar-json: local, combined', 'local')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
Expand All @@ -616,6 +627,7 @@ program
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.action(async (opts) => {
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
assertScope(opts.scope, ['local', 'combined'], 'status')
assertProvider(opts.provider, 'status')
if (opts.day && (opts.from || opts.to)) {
process.stderr.write('error: --day cannot be combined with --from or --to\n')
Expand All @@ -625,6 +637,14 @@ program
process.stderr.write('error: --days cannot be combined with --day, --from, or --to\n')
process.exit(1)
}
if (opts.format === 'menubar-json' && opts.scope === 'combined' && opts.days) {
process.stderr.write('error: --scope combined cannot be combined with --days\n')
process.exit(1)
}
if (opts.scope === 'combined' && (opts.provider !== 'all' || opts.project.length > 0 || opts.exclude.length > 0)) {
process.stderr.write('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)\n')
process.exit(1)
}
await loadPricing()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
Expand All @@ -644,6 +664,19 @@ program
daysSelection,
optimize: opts.optimize !== false,
})
if (opts.scope === 'combined') {
const query: UsageQuery = customRange
? { from: opts.from, to: opts.to }
: daySelection
? { from: daySelection.day, to: daySelection.day }
: { period: opts.period }
const localGetUsage = async (): Promise<typeof payload> => payload
const results = await pullDevices(localGetUsage, query, hostname(), {})
payload.combined = summarizeDeviceUsage(results, {
start: toDateString(periodInfo.range.start),
end: toDateString(periodInfo.range.end),
})
}
console.log(JSON.stringify(payload))
return
}
Expand Down
32 changes: 32 additions & 0 deletions src/menubar-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,37 @@ export type LocalModelSavings = {
byProvider: Array<{ name: string; calls: number; savingsUSD: number }>
}

export type DeviceSummary = {
id: string
name: string
local: boolean
error?: string
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
}

export type CombinedUsage = {
perDevice: DeviceSummary[]
combined: {
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
deviceCount: number
reachableCount: number
}
}

export type MenubarPayload = {
generated: string
current: {
Expand Down Expand Up @@ -180,6 +211,7 @@ export type MenubarPayload = {
history: {
daily: DailyHistoryEntry[]
}
combined?: CombinedUsage
}

function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null {
Expand Down
129 changes: 96 additions & 33 deletions src/sharing/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@ import { sanitizeForSharing } from './sanitize.js'
import type { DiscoveredDevice } from './discovery.js'
import type { UsageQuery } from './share-server.js'
import { getSharingDir, loadRemotes, saveRemotes, type RemoteDevice } from './store.js'
import type { MenubarPayload } from '../menubar-json.js'
import type { CombinedUsage, DeviceSummary, MenubarPayload } from '../menubar-json.js'
import { formatCost } from '../currency.js'
import { renderTable } from '../text-table.js'
import { Chalk } from 'chalk'

export type { CombinedUsage, DeviceSummary } from '../menubar-json.js'

// Minimal shape we read from a device's usage payload (the menubar payload).
// Cache create/read are only in the daily history, so we sum those.
type DevicePayload = {
current?: { cost?: number; calls?: number; sessions?: number; inputTokens?: number; outputTokens?: number }
history?: { daily?: Array<{ cacheReadTokens?: number; cacheWriteTokens?: number }> }
history?: { daily?: Array<{ date?: string; cacheReadTokens?: number; cacheWriteTokens?: number }> }
}

type SummaryWindow = {
start: string
end: string
}

export type DeviceUsage = {
Expand All @@ -25,6 +32,80 @@ export type DeviceUsage = {
error?: string
}

const zeroUsage = {
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
}

function num(n: number | undefined): number {
return n ?? 0
}

function summarizeOneDevice(d: DeviceUsage, window?: SummaryWindow): DeviceSummary {
const error = d.error !== undefined ? d.error : d.payload === undefined ? 'no usage payload' : undefined
if (error !== undefined || d.payload === undefined) {
return {
id: d.id,
name: d.name,
local: d.local,
error,
...zeroUsage,
}
}

const cur = d.payload.current
const daily = (d.payload.history?.daily ?? []).filter((e) => {
if (window === undefined) return true
return e.date !== undefined && window.start <= e.date && e.date <= window.end
})
const inputTokens = num(cur?.inputTokens)
const outputTokens = num(cur?.outputTokens)
const cacheCreateTokens = daily.reduce((s, e) => s + num(e.cacheWriteTokens), 0)
const cacheReadTokens = daily.reduce((s, e) => s + num(e.cacheReadTokens), 0)
return {
id: d.id,
name: d.name,
local: d.local,
cost: num(cur?.cost),
calls: num(cur?.calls),
sessions: num(cur?.sessions),
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
totalTokens: inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens,
}
}

export function summarizeDeviceUsage(results: DeviceUsage[], window?: SummaryWindow): CombinedUsage {
const perDevice = results.map((d) => summarizeOneDevice(d, window))
const combined = perDevice.reduce(
(a, d) => {
if (d.error !== undefined) return a
return {
cost: a.cost + d.cost,
calls: a.calls + d.calls,
sessions: a.sessions + d.sessions,
inputTokens: a.inputTokens + d.inputTokens,
outputTokens: a.outputTokens + d.outputTokens,
cacheCreateTokens: a.cacheCreateTokens + d.cacheCreateTokens,
cacheReadTokens: a.cacheReadTokens + d.cacheReadTokens,
totalTokens: a.totalTokens + d.totalTokens,
deviceCount: a.deviceCount,
reachableCount: a.reachableCount + 1,
}
},
{ ...zeroUsage, deviceCount: perDevice.length, reachableCount: 0 },
)
return { perDevice, combined }
}

function parseHostPort(input: string, defaultPort: number): { host: string; port: number } {
const idx = input.lastIndexOf(':')
if (idx > 0 && /^\d+$/.test(input.slice(idx + 1))) {
Expand Down Expand Up @@ -118,46 +199,28 @@ export async function pullDevices(
// Joined "Totals by machine" report: one row per device plus a bold Combined
// row. Tokens are shown as full, comma-grouped numbers.
export function renderDevices(results: DeviceUsage[]): string {
const num = (n: number | undefined): number => n ?? 0
const n = (x: number): string => Math.round(x).toLocaleString()
const money = (x: number): string => formatCost(x).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,')
const rows = results.map((d) => {
const cur = d.payload?.current
const daily = d.payload?.history?.daily ?? []
const input = num(cur?.inputTokens)
const output = num(cur?.outputTokens)
const cacheCreate = daily.reduce((s, e) => s + num(e.cacheWriteTokens), 0)
const cacheRead = daily.reduce((s, e) => s + num(e.cacheReadTokens), 0)
return {
name: d.name + (d.local ? ' (this Mac)' : ''),
error: d.error,
cost: num(cur?.cost),
input,
output,
cacheCreate,
cacheRead,
total: input + output + cacheCreate + cacheRead,
}
})
const combined = rows.reduce(
(a, r) => ({
cost: a.cost + r.cost,
input: a.input + r.input,
output: a.output + r.output,
cacheCreate: a.cacheCreate + r.cacheCreate,
cacheRead: a.cacheRead + r.cacheRead,
total: a.total + r.total,
}),
{ cost: 0, input: 0, output: 0, cacheCreate: 0, cacheRead: 0, total: 0 },
)
const summary = summarizeDeviceUsage(results)
const rows = summary.perDevice.map((d) => ({
name: d.name + (d.local ? ' (this Mac)' : ''),
error: d.error,
cost: d.cost,
input: d.inputTokens,
output: d.outputTokens,
cacheCreate: d.cacheCreateTokens,
cacheRead: d.cacheReadTokens,
total: d.totalTokens,
}))
const combined = summary.combined

const tableRows = [
...rows.map((r) =>
r.error
? [r.name, r.error, '-', '-', '-', '-', '-']
: [r.name, money(r.cost), n(r.total), n(r.input), n(r.output), n(r.cacheCreate), n(r.cacheRead)],
),
['Combined', money(combined.cost), n(combined.total), n(combined.input), n(combined.output), n(combined.cacheCreate), n(combined.cacheRead)],
['Combined', money(combined.cost), n(combined.totalTokens), n(combined.inputTokens), n(combined.outputTokens), n(combined.cacheCreateTokens), n(combined.cacheReadTokens)],
]
const table = renderTable(
[
Expand Down
Loading
Loading