mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-21 02:07:12 +00:00
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
118 lines
3.4 KiB
TypeScript
118 lines
3.4 KiB
TypeScript
import { randomBytes } from 'crypto'
|
|
import { existsSync } from 'fs'
|
|
import { mkdir, open, readFile, rename, unlink } from 'fs/promises'
|
|
import { homedir } from 'os'
|
|
import { join } from 'path'
|
|
|
|
export const DAILY_CACHE_VERSION = 2
|
|
const DAILY_CACHE_FILENAME = 'daily-cache.json'
|
|
|
|
export type DailyEntry = {
|
|
date: string
|
|
cost: number
|
|
calls: number
|
|
sessions: number
|
|
inputTokens: number
|
|
outputTokens: number
|
|
cacheReadTokens: number
|
|
cacheWriteTokens: number
|
|
editTurns: number
|
|
oneShotTurns: number
|
|
models: Record<string, {
|
|
calls: number
|
|
cost: number
|
|
inputTokens: number
|
|
outputTokens: number
|
|
cacheReadTokens: number
|
|
cacheWriteTokens: number
|
|
}>
|
|
categories: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }>
|
|
providers: Record<string, { calls: number; cost: number }>
|
|
}
|
|
|
|
export type DailyCache = {
|
|
version: number
|
|
lastComputedDate: string | null
|
|
days: DailyEntry[]
|
|
}
|
|
|
|
function getCacheDir(): string {
|
|
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
|
|
}
|
|
|
|
function getCachePath(): string {
|
|
return join(getCacheDir(), DAILY_CACHE_FILENAME)
|
|
}
|
|
|
|
function emptyCache(): DailyCache {
|
|
return { version: DAILY_CACHE_VERSION, lastComputedDate: null, days: [] }
|
|
}
|
|
|
|
function isValidCache(parsed: unknown): parsed is DailyCache {
|
|
if (!parsed || typeof parsed !== 'object') return false
|
|
const c = parsed as Partial<DailyCache>
|
|
if (c.version !== DAILY_CACHE_VERSION) return false
|
|
if (!Array.isArray(c.days)) return false
|
|
return true
|
|
}
|
|
|
|
export async function loadDailyCache(): Promise<DailyCache> {
|
|
const path = getCachePath()
|
|
if (!existsSync(path)) return emptyCache()
|
|
try {
|
|
const raw = await readFile(path, 'utf-8')
|
|
const parsed: unknown = JSON.parse(raw)
|
|
if (!isValidCache(parsed)) return emptyCache()
|
|
return parsed
|
|
} catch {
|
|
return emptyCache()
|
|
}
|
|
}
|
|
|
|
export async function saveDailyCache(cache: DailyCache): Promise<void> {
|
|
const dir = getCacheDir()
|
|
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
|
|
const finalPath = getCachePath()
|
|
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
|
|
const payload = JSON.stringify(cache)
|
|
const handle = await open(tempPath, 'w', 0o600)
|
|
try {
|
|
await handle.writeFile(payload, { encoding: 'utf-8' })
|
|
await handle.sync()
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
try {
|
|
await rename(tempPath, finalPath)
|
|
} catch (err) {
|
|
try { await unlink(tempPath) } catch { /* ignore */ }
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate: string): DailyCache {
|
|
const seen = new Set(cache.days.map(d => d.date))
|
|
const merged = [...cache.days]
|
|
for (const day of incoming) {
|
|
if (seen.has(day.date)) continue
|
|
seen.add(day.date)
|
|
merged.push(day)
|
|
}
|
|
merged.sort((a, b) => a.date.localeCompare(b.date))
|
|
const nextLast = cache.lastComputedDate && cache.lastComputedDate > newestDate
|
|
? cache.lastComputedDate
|
|
: newestDate
|
|
return { version: DAILY_CACHE_VERSION, lastComputedDate: nextLast, days: merged }
|
|
}
|
|
|
|
export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] {
|
|
return cache.days.filter(d => d.date >= start && d.date <= end)
|
|
}
|
|
|
|
let lockChain: Promise<unknown> = Promise.resolve()
|
|
|
|
export function withDailyCacheLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
const next = lockChain.then(() => fn())
|
|
lockChain = next.catch(() => undefined)
|
|
return next
|
|
}
|