codeburn/src/menubar-json.ts
Resham Joshi 495a254338 feat(mac): native Swift menubar app + one-command install
Introduces mac/ with a native SwiftUI menubar app that replaces the
previous SwiftBar plugin entirely. Install via `npx codeburn menubar`,
which downloads the .app from GitHub Releases, strips Gatekeeper
quarantine, and drops it into ~/Applications.

Highlights

- mac/ SwiftUI app: agent tabs, Today/7/30/Month/All period switcher,
  Trend/Forecast/Pulse/Stats/Plan insights, activity + model
  breakdowns, optimize findings, CSV/JSON export, Star-on-GitHub
  banner, live 60s refresh, instant currency switching with offline FX
  cache.
- Security: CodeburnCLI argv-based spawn (no shell interpretation),
  SafeFile symlink guards + O_NOFOLLOW writes, FX rate clamping to
  [0.0001, 1_000_000], keychain filtered to account == "default",
  removed byte-window credential log, in-flight refresh guard, POSIX
  flock on config.json writes, TerminalLauncher validates argv before
  AppleScript interpolation.
- Performance: shared static NumberFormatter (thousands of allocations
  per popover redraw eliminated), concurrent pipe drain with 20 MB cap
  + 60s timeout in DataClient, Observation-tracked reactive UI, 5-min
  payload cache keyed on (period, provider).
- CLI: new `codeburn menubar` subcommand that downloads + installs +
  launches the .app (no clone, no build). New `status --format
  menubar-json` payload builder. `export` rewritten to produce a
  folder of one-table-per-file CSVs with a `.codeburn-export` marker
  so arbitrary -o paths cannot be silently deleted.
- Removed: src/menubar.ts (SwiftBar plugin generator),
  install-menubar / uninstall-menubar subcommands, `status --format
  menubar` directive output, tests/menubar.test.ts,
  tests/security/menubar-injection.test.ts.
- Release: .github/workflows/release-menubar.yml builds universal
  binary, assembles .app, ad-hoc signs, zips, uploads on mac-v* tag
  push. Runs on the free macos-latest runner.

Tests

- 230 TypeScript tests pass
- 10 Swift CapacityEstimator tests pass
- TypeScript typecheck clean
- Swift release build clean
2026-04-17 16:55:56 -07:00

182 lines
5 KiB
TypeScript

/// Rollup of one time window (today / 7 days / 30 days / month / all) used as the canonical
/// input to the menubar payload. Built inside the CLI and also consumed by the day-aggregator
/// when hydrating per-day cache entries.
export type PeriodData = {
label: string
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; calls: number }>
}
export type ProviderCost = {
name: string
cost: number
}
import type { OptimizeResult } from './optimize.js'
const TOP_ACTIVITIES_LIMIT = 20
const TOP_MODELS_LIMIT = 20
const TOP_FINDINGS_LIMIT = 10
const HISTORY_DAYS_LIMIT = 365
const SYNTHETIC_MODEL_NAME = '<synthetic>'
export type DailyModelBreakdown = {
name: string
cost: number
calls: number
inputTokens: number
outputTokens: number
}
export type DailyHistoryEntry = {
date: string
cost: number
calls: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
topModels: DailyModelBreakdown[]
}
export type MenubarPayload = {
generated: string
current: {
label: string
cost: number
calls: number
sessions: number
oneShotRate: number | null
inputTokens: number
outputTokens: number
cacheHitPercent: number
topActivities: Array<{
name: string
cost: number
turns: number
oneShotRate: number | null
}>
topModels: Array<{
name: string
cost: number
calls: number
}>
providers: Record<string, number>
}
optimize: {
findingCount: number
savingsUSD: number
topFindings: Array<{
title: string
impact: 'high' | 'medium' | 'low'
savingsUSD: number
}>
}
history: {
daily: DailyHistoryEntry[]
}
}
function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null {
if (editTurns === 0) return null
return oneShotTurns / editTurns
}
function aggregateOneShotRate(categories: PeriodData['categories']): number | null {
let edits = 0
let oneShots = 0
for (const cat of categories) {
edits += cat.editTurns
oneShots += cat.oneShotTurns
}
if (edits === 0) return null
return oneShots / edits
}
function cacheHitPercent(inputTokens: number, cacheReadTokens: number): number {
const denom = inputTokens + cacheReadTokens
if (denom === 0) return 0
return (cacheReadTokens / denom) * 100
}
function buildTopActivities(categories: PeriodData['categories']): MenubarPayload['current']['topActivities'] {
return categories.slice(0, TOP_ACTIVITIES_LIMIT).map(cat => ({
name: cat.name,
cost: cat.cost,
turns: cat.turns,
oneShotRate: oneShotRateFor(cat.editTurns, cat.oneShotTurns),
}))
}
function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] {
return models
.filter(m => m.name !== SYNTHETIC_MODEL_NAME)
.slice(0, TOP_MODELS_LIMIT)
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls }))
}
function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {
if (!optimize || optimize.findings.length === 0) {
return { findingCount: 0, savingsUSD: 0, topFindings: [] }
}
const { findings, costRate } = optimize
const totalSavingsUSD = findings.reduce((s, f) => s + f.tokensSaved * costRate, 0)
const topFindings = findings.slice(0, TOP_FINDINGS_LIMIT).map(f => ({
title: f.title,
impact: f.impact,
savingsUSD: f.tokensSaved * costRate,
}))
return {
findingCount: findings.length,
savingsUSD: totalSavingsUSD,
topFindings,
}
}
function buildProviders(providers: ProviderCost[]): Record<string, number> {
const map: Record<string, number> = {}
for (const p of providers) {
if (p.cost < 0) continue
map[p.name.toLowerCase()] = p.cost
}
return map
}
function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['history'] {
if (!daily || daily.length === 0) return { daily: [] }
const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
const trimmed = sorted.slice(-HISTORY_DAYS_LIMIT)
return { daily: trimmed }
}
export function buildMenubarPayload(
current: PeriodData,
providers: ProviderCost[],
optimize: OptimizeResult | null,
dailyHistory?: DailyHistoryEntry[],
): MenubarPayload {
return {
generated: new Date().toISOString(),
current: {
label: current.label,
cost: current.cost,
calls: current.calls,
sessions: current.sessions,
oneShotRate: aggregateOneShotRate(current.categories),
inputTokens: current.inputTokens,
outputTokens: current.outputTokens,
cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens),
topActivities: buildTopActivities(current.categories),
topModels: buildTopModels(current.models),
providers: buildProviders(providers),
},
optimize: buildOptimize(optimize),
history: buildHistory(dailyHistory),
}
}