- setView('all')}>
- All devices
-
- {devices.map((d) => (
- setView(d.name)}>
- {d.name}
- {d.local ? ' (this Mac)' : ''}
-
- ))}
+
+
- ) : (
-
- )}
+ {searchOpen && setSearchOpen(false)} onPaired={() => void refetch()} />}
- {isError && (
-
)
}
diff --git a/dash/src/components/DeviceSearchModal.tsx b/dash/src/components/DeviceSearchModal.tsx
new file mode 100644
index 00000000..8181c371
--- /dev/null
+++ b/dash/src/components/DeviceSearchModal.tsx
@@ -0,0 +1,123 @@
+import { useEffect, useState } from 'react'
+
+import { scanDevices, pairDevice, type DiscoveredDevice } from '@/lib/api'
+
+export function DeviceSearchModal({ onClose, onPaired }: { onClose: () => void; onPaired: () => void }) {
+ const [scanning, setScanning] = useState(true)
+ const [found, setFound] = useState
+
- )}
+
+
+
+ {PERIODS.map((p) => (
+
+ ))}
+
+
+ {(['cost', 'tokens'] as Unit[]).map((u) => (
+
+ ))}
+
+
+
- {showCombined ? (
- Failed to load: {String((error as Error)?.message)}
- )}
-
+ {pending.length > 0 && (
+
+
+ )}
+
+
+
+ Incoming pairing request
+
+ {pending.map((p) => (
+
+
+
+ ))}
+ + “{p.name}” wants to pair with this device. +
++ Confirm this code matches on that device: {p.code} +
+
+
+
+
+
+
+ )
+}
diff --git a/dash/src/components/UsageChart.tsx b/dash/src/components/UsageChart.tsx
index 65c9796c..8408b5ee 100644
--- a/dash/src/components/UsageChart.tsx
+++ b/dash/src/components/UsageChart.tsx
@@ -1,8 +1,10 @@
import { useMemo } from 'react'
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
-import type { DailyEntry } from '@/lib/api'
-import { CHART_COLORS, compactUsd, label, usd } from '@/lib/utils'
+import type { DailyEntry, DeviceUsage } from '@/lib/api'
+import { CHART_COLORS, compactUsd, fmtTokens, label, usd } from '@/lib/utils'
+
+export type Unit = 'cost' | 'tokens'
const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
function fmtDay(d: string): string {
@@ -12,55 +14,53 @@ function fmtDay(d: string): string {
const TOP_N = 6
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function ChartTooltip({ active, payload, label: lbl }: any) {
- if (!active || !payload?.length) return null
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const items = payload.filter((p: any) => p.value > 0).sort((a: any, b: any) => b.value - a.value)
- if (!items.length) return null
+type Series = { key: string; label: string; color: string }
+
+function makeTooltip(labels: Record e.stopPropagation()}
+ >
+
+
+
+
+ Search local devices
+
+
+
+
+
+ {scanning ? (
+
+
+
+ Looking for devices on your network...
+
+ ) : found.length === 0 ? (
+ + No devices found. On your other Mac run codeburn share on the same Wi-Fi. +
+ ) : ( +
+ {found.map((d) => (
+
+ )}
+
+ {status &&
+
+ ))}
+
+
+
+
+
+ {d.paired ? (
+ Connected
+ ) : pairing === d.fingerprint ? (
+ code {d.code}
+ ) : (
+
+ )}
+ {d.name}
+
+ {d.host}:{d.port}
+
+ {status}
} + {error &&{error}
} +
-
{fmtDay(String(lbl))}
-
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- {items.slice(0, 6).map((p: any) => (
-
-
- {label(String(p.dataKey))}
- {usd(p.value)}
+ return function ChartTooltip({ active, payload, label: lbl }: any) {
+ if (!active || !payload?.length) return null
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const items = payload.filter((p: any) => p.value > 0).sort((a: any, b: any) => b.value - a.value)
+ if (!items.length) return null
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const total = items.reduce((s: number, p: any) => s + p.value, 0)
+ return (
+ ()
- for (const d of daily) for (const m of d.topModels) totals.set(m.name, (totals.get(m.name) ?? 0) + m.cost)
- const top = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_N).map(([k]) => k)
- const topSet = new Set(top)
- const hasOther = [...totals.keys()].some((k) => !topSet.has(k))
- const seriesKeys = hasOther ? [...top, 'Other'] : top
- const rowData = daily.map((d) => {
- const row: Record = { period: d.date }
- for (const k of seriesKeys) row[k] = 0
- for (const m of d.topModels) {
- const key = topSet.has(m.name) ? m.name : 'Other'
- row[key] = (row[key] as number) + m.cost
- }
- return row
- })
- return { rows: rowData, series: seriesKeys }
- }, [daily])
-
+function StackedBars({
+ rows,
+ series,
+ labels,
+ unit,
+}: {
+ rows: Array>
+ series: Series[]
+ labels: Record
+ unit: Unit
+}) {
+ const fmt = unit === 'tokens' ? fmtTokens : usd
+ const axisFmt = (v: number | string) => (unit === 'tokens' ? fmtTokens(Number(v)) : compactUsd(Number(v)))
+ const Tip = makeTooltip(labels, fmt)
return (
@@ -80,15 +80,15 @@ export function UsageChart({ daily }: { daily: DailyEntry[] }) {
axisLine={false}
width={50}
tick={{ fontSize: 11, fill: 'var(--color-tertiary-foreground)' }}
- tickFormatter={(v) => compactUsd(Number(v))}
+ tickFormatter={axisFmt}
/>
- } />
+ } />
{series.map((s, i) => (
@@ -98,3 +98,66 @@ export function UsageChart({ daily }: { daily: DailyEntry[] }) {
)
}
+
+// Spend (or tokens) per day, stacked by model (single device).
+export function UsageChart({ daily, unit = 'cost' }: { daily: DailyEntry[]; unit?: Unit }) {
+ const { rows, series, labels } = useMemo(() => {
+ const measure = (m: { cost: number; inputTokens: number; outputTokens: number }) =>
+ unit === 'tokens' ? m.inputTokens + m.outputTokens : m.cost
+ const totals = new Map()
+ for (const d of daily) for (const m of d.topModels) totals.set(m.name, (totals.get(m.name) ?? 0) + measure(m))
+ const top = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_N).map(([k]) => k)
+ const topSet = new Set(top)
+ const hasOther = [...totals.keys()].some((k) => !topSet.has(k))
+ const keys = hasOther ? [...top, 'Other'] : top
+ const rowData = daily.map((d) => {
+ const row: Record = { period: d.date }
+ for (const k of keys) row[k] = 0
+ for (const m of d.topModels) {
+ const key = topSet.has(m.name) ? m.name : 'Other'
+ row[key] = (row[key] as number) + measure(m)
+ }
+ return row
+ })
+ const series: Series[] = keys.map((k, i) => ({ key: k, label: label(k), color: CHART_COLORS[i % CHART_COLORS.length]! }))
+ const labels = Object.fromEntries(series.map((s) => [s.key, s.label]))
+ return { rows: rowData, series, labels }
+ }, [daily, unit])
+
+ return
+}
+
+// Spend (or tokens) per day, stacked by device (one color per device) for the All view.
+export function DeviceUsageChart({ devices, unit = 'cost' }: { devices: DeviceUsage[]; unit?: Unit }) {
+ const { rows, series, labels } = useMemo(() => {
+ const named = devices.filter((d) => d.payload)
+ const dailyOf = (d: DeviceUsage) => d.payload?.history?.daily ?? []
+ // Stable key + color per device (by unique id) so a device keeps its color
+ // and its bars don't remount when another device drops/returns between
+ // polls, and two devices sharing a hostname never collide.
+ const keyOf = (d: DeviceUsage) => 'dev_' + d.id.replace(/[^a-zA-Z0-9]/g, '_')
+ const colorOf = (id: string) => {
+ let h = 0
+ for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0
+ return CHART_COLORS[Math.abs(h) % CHART_COLORS.length]!
+ }
+ const dates = [...new Set(named.flatMap((d) => dailyOf(d).map((e) => e.date)))].sort((a, b) => a.localeCompare(b))
+ const series: Series[] = named.map((d) => ({
+ key: keyOf(d),
+ label: d.name + (d.local ? ' (this Mac)' : ''),
+ color: colorOf(d.id),
+ }))
+ const rowData = dates.map((date) => {
+ const row: Record = { period: date }
+ named.forEach((d) => {
+ const e = dailyOf(d).find((x) => x.date === date)
+ row[keyOf(d)] = e ? (unit === 'tokens' ? e.inputTokens + e.outputTokens : e.cost) : 0
+ })
+ return row
+ })
+ const labels = Object.fromEntries(series.map((s) => [s.key, s.label]))
+ return { rows: rowData, series, labels }
+ }, [devices, unit])
+
+ return
+}
diff --git a/dash/src/index.css b/dash/src/index.css
index 94793e3f..d44ea94f 100644
--- a/dash/src/index.css
+++ b/dash/src/index.css
@@ -1,52 +1,54 @@
-@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap");
+@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@300..600&display=swap");
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
/*
- * Midnight theme for CodeBurn. Near-black surfaces, a single warm orange
- * accent, and a warm orange -> gold 10-stop ramp for stacked charts.
+ * Archival warmth + forensic precision. Warm paper surfaces, ink text, a
+ * forest-green accent, and a green -> gold -> terracotta ramp for stacked
+ * charts. No pure white background, no pure black, no cold neutrals.
*/
:root {
font-family: "Geist", "Geist Fallback", system-ui, sans-serif;
- --radius: 0.5rem;
+ --radius: 0.375rem;
- --background: #0a0a0b;
- --outer-background: #000000;
- --foreground: #e7e7ea;
- --card: #0b0b0d;
- --card-foreground: #ffffff;
- --popover: #131316;
- --popover-foreground: #ffffff;
- --muted: #121215;
- --muted-foreground: #cfcfd4;
- --tertiary-foreground: #8a8a92;
- --border: #1b1b1f;
- --input: #2a2a2e;
- --interactive-secondary: #141417;
- --interactive-secondary-hover: #1d1d21;
- --active-primary: #221a12;
- --accent: #1c1c20;
- --accent-foreground: #ffffff;
- --subtle: #6b6b73;
- --primary: #ff8c42;
- --primary-foreground: #1a0f06;
- --ring: #ff8c42;
- --positive: #5bf58c;
+ --background: #f6f4ef;
+ --outer-background: #e9e6de;
+ --foreground: #16181d;
+ --card: #ffffff;
+ --card-foreground: #16181d;
+ --popover: #ffffff;
+ --popover-foreground: #16181d;
+ --muted: #efece4;
+ --muted-foreground: #5d626b;
+ --tertiary-foreground: #8a857c;
+ --heading: #2c5242;
+ --border: rgba(23, 27, 32, 0.08);
+ --input: rgba(23, 27, 32, 0.14);
+ --interactive-secondary: rgba(23, 27, 32, 0.04);
+ --interactive-secondary-hover: rgba(23, 27, 32, 0.08);
+ --active-primary: #ffffff;
+ --accent: #efece4;
+ --accent-foreground: #16181d;
+ --subtle: #8a857c;
+ --primary: #1f8a5b;
+ --primary-foreground: #ffffff;
+ --ring: #1f8a5b;
+ --positive: #1f8a5b;
- --chart-1: #ff8c42;
- --chart-2: #ffa94d;
- --chart-3: #f97316;
- --chart-4: #ffc35e;
- --chart-5: #fb923c;
- --chart-6: #fbbf24;
- --chart-7: #f59e0b;
- --chart-8: #fdba74;
- --chart-9: #eab308;
- --chart-10: #d97742;
- --chart-grid-stroke: #19191c;
+ --chart-1: #1f8a5b;
+ --chart-2: #4fd394;
+ --chart-3: #2c5242;
+ --chart-4: #d99a3c;
+ --chart-5: #c8541f;
+ --chart-6: #2f5fd0;
+ --chart-7: #7aa86f;
+ --chart-8: #b5403a;
+ --chart-9: #3f8f6b;
+ --chart-10: #a98b4f;
+ --chart-grid-stroke: rgba(23, 27, 32, 0.07);
- color-scheme: dark;
+ color-scheme: light;
}
@theme inline {
@@ -60,6 +62,7 @@
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-tertiary-foreground: var(--tertiary-foreground);
+ --color-heading: var(--heading);
--color-border: var(--border);
--color-input: var(--input);
--color-interactive-secondary: var(--interactive-secondary);
@@ -85,6 +88,9 @@
--color-chart-10: var(--chart-10);
--color-chart-grid-stroke: var(--chart-grid-stroke);
+ --font-display: "Alga", Georgia, "Times New Roman", serif;
+ --font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
+
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
@@ -102,8 +108,8 @@
}
body {
@apply bg-outer-background text-foreground;
- letter-spacing: -0.011em;
- font-weight: 450;
+ letter-spacing: -0.006em;
+ font-weight: 400;
margin: 0;
}
::selection {
@@ -125,7 +131,7 @@
background-image: linear-gradient(
90deg,
transparent 0%,
- color-mix(in oklch, var(--foreground) 8%, transparent) 50%,
+ color-mix(in oklch, var(--foreground) 7%, transparent) 50%,
transparent 100%
);
background-size: 200% 100%;
diff --git a/dash/src/lib/api.ts b/dash/src/lib/api.ts
index 42d9c226..6e04d1ed 100644
--- a/dash/src/lib/api.ts
+++ b/dash/src/lib/api.ts
@@ -35,6 +35,12 @@ export type Current = {
topProjects: Array<{ name: string; cost: number; sessions: number; avgCostPerSession: number }>
tools: Array<{ name: string; calls: number }>
subagents: Array<{ name: string; calls: number; cost: number }>
+ skills: Array<{ name: string; turns: number; cost: number }>
+ mcpServers: Array<{ name: string; calls: number }>
+ modelEfficiency: Array<{ name: string; costPerEdit: number; oneShotRate: number }>
+ localModelSavings: { totalUSD: number }
+ retryTax: { totalUSD: number; retries: number }
+ routingWaste: { totalSavingsUSD: number }
}
export type Payload = {
@@ -50,16 +56,71 @@ export async function fetchUsage(period: Period, provider: string): Promise
+ return {
+ generated: p.generated,
+ current: {
+ label: c.label ?? '',
+ cost: c.cost ?? 0,
+ calls: c.calls ?? 0,
+ sessions: c.sessions ?? 0,
+ oneShotRate: c.oneShotRate ?? null,
+ inputTokens: c.inputTokens ?? 0,
+ outputTokens: c.outputTokens ?? 0,
+ cacheHitPercent: c.cacheHitPercent ?? 0,
+ codexCredits: c.codexCredits ?? 0,
+ topActivities: c.topActivities ?? [],
+ topModels: c.topModels ?? [],
+ providers: c.providers ?? {},
+ topProjects: c.topProjects ?? [],
+ tools: c.tools ?? [],
+ subagents: c.subagents ?? [],
+ skills: c.skills ?? [],
+ mcpServers: c.mcpServers ?? [],
+ modelEfficiency: c.modelEfficiency ?? [],
+ localModelSavings: c.localModelSavings ?? { totalUSD: 0 },
+ retryTax: c.retryTax ?? { totalUSD: 0, retries: 0 },
+ routingWaste: c.routingWaste ?? { totalSavingsUSD: 0 },
+ },
+ history: {
+ daily: (p.history?.daily ?? []).map((d) => ({
+ date: d.date,
+ cost: d.cost ?? 0,
+ calls: d.calls ?? 0,
+ inputTokens: d.inputTokens ?? 0,
+ outputTokens: d.outputTokens ?? 0,
+ cacheReadTokens: d.cacheReadTokens ?? 0,
+ cacheWriteTokens: d.cacheWriteTokens ?? 0,
+ topModels: (d.topModels ?? []).map((m) => ({
+ name: m.name,
+ cost: m.cost ?? 0,
+ calls: m.calls ?? 0,
+ inputTokens: m.inputTokens ?? 0,
+ outputTokens: m.outputTokens ?? 0,
+ })),
+ })),
+ },
+ }
+}
+
export async function fetchDevices(period: Period, provider: string): Promise<{ devices: DeviceUsage[] }> {
const res = await fetch(`/api/devices?period=${encodeURIComponent(period)}&provider=${encodeURIComponent(provider)}`)
if (!res.ok) throw new Error(`Request failed (${res.status})`)
- return res.json() as Promise<{ devices: DeviceUsage[] }>
+ const data = (await res.json()) as { devices: DeviceUsage[] }
+ return { devices: (data.devices ?? []).map((d) => ({ ...d, payload: normalizePayload(d.payload) })) }
}
export const PERIODS: Array<{ key: Period; label: string }> = [
@@ -69,3 +130,56 @@ export const PERIODS: Array<{ key: Period; label: string }> = [
{ key: 'month', label: 'Month' },
{ key: 'all', label: 'All' },
]
+
+export type DiscoveredDevice = {
+ name: string
+ host: string
+ port: number
+ fingerprint: string
+ code: string
+ paired: boolean
+}
+
+export async function scanDevices(): Promise {
+ const res = await fetch('/api/devices/scan')
+ if (!res.ok) throw new Error(`Scan failed (${res.status})`)
+ const json = (await res.json()) as { found: DiscoveredDevice[] }
+ return json.found
+}
+
+export async function pairDevice(d: DiscoveredDevice): Promise<{ ok: boolean; name?: string; error?: string }> {
+ const res = await fetch('/api/devices/pair', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ name: d.name, host: d.host, port: d.port, fingerprint: d.fingerprint }),
+ })
+ return res.json() as Promise<{ ok: boolean; name?: string; error?: string }>
+}
+
+export type PendingPairing = { id: string; name: string; code: string }
+export type ShareStatus = {
+ sharing: boolean
+ name: string
+ port: number
+ always: boolean
+ peers: number
+ pending: PendingPairing[]
+}
+
+const postJson = (path: string, body: unknown) =>
+ fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
+
+export async function shareStatus(): Promise {
+ const res = await fetch('/api/share/status')
+ if (!res.ok) throw new Error(`share status failed (${res.status})`)
+ return res.json() as Promise
+}
+export async function startShare(always: boolean): Promise {
+ return (await postJson('/api/share/start', { always })).json() as Promise
+}
+export async function stopShare(): Promise {
+ return (await postJson('/api/share/stop', {})).json() as Promise
+}
+export async function approvePairing(id: string, approve: boolean): Promise<{ ok: boolean }> {
+ return (await postJson('/api/share/approve', { id, approve })).json() as Promise<{ ok: boolean }>
+}
diff --git a/dash/src/lib/utils.ts b/dash/src/lib/utils.ts
index 2e0605aa..95f28c15 100644
--- a/dash/src/lib/utils.ts
+++ b/dash/src/lib/utils.ts
@@ -7,9 +7,11 @@ export function cn(...inputs: ClassValue[]): string {
export function usd(n: number | undefined | null): string {
const v = n == null || !isFinite(n) ? 0 : n
- const s = v >= 1 || v === 0 ? v.toFixed(2) : v >= 0.01 ? v.toFixed(3) : v.toFixed(2)
+ const sign = v < 0 ? '-' : ''
+ const a = Math.abs(v)
+ const s = a >= 1 || a === 0 ? a.toFixed(2) : a >= 0.01 ? a.toFixed(3) : a.toFixed(2)
const [int, dec] = s.split('.')
- return '$' + int!.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (dec ? '.' + dec : '')
+ return sign + '$' + int!.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (dec ? '.' + dec : '')
}
export function fmtTokens(n: number | undefined | null): string {
@@ -21,19 +23,24 @@ export function fmtTokens(n: number | undefined | null): string {
}
export function fmtNum(n: number | undefined | null): string {
- return (n ?? 0).toLocaleString()
+ const v = n == null || !isFinite(n) ? 0 : n
+ return v.toLocaleString()
}
export function compactUsd(n: number): string {
- if (n >= 1e6) return '$' + (n / 1e6).toFixed(1) + 'M'
- if (n >= 1e3) return '$' + (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + 'k'
- return '$' + Math.round(n)
+ if (!isFinite(n)) return '$0'
+ const sign = n < 0 ? '-' : ''
+ const a = Math.abs(n)
+ if (a >= 1e6) return sign + '$' + (a / 1e6).toFixed(1) + 'M'
+ if (a >= 1e3) return sign + '$' + (a / 1e3).toFixed(a >= 1e4 ? 0 : 1) + 'k'
+ return sign + '$' + Math.round(a)
}
-// Warm orange -> gold ramp for stacked series (mirrors --chart-* tokens).
+// Forest green -> gold -> terracotta ramp for stacked series (mirrors the
+// --chart-* tokens). Warm and on-brand, distinct enough to read when stacked.
export const CHART_COLORS = [
- '#ff8c42', '#ffa94d', '#f97316', '#ffc35e', '#fb923c',
- '#fbbf24', '#f59e0b', '#fdba74', '#eab308', '#d97742',
+ '#1f8a5b', '#4fd394', '#2c5242', '#d99a3c', '#c8541f',
+ '#2f5fd0', '#7aa86f', '#b5403a', '#3f8f6b', '#a98b4f',
]
const MODEL_LABELS: Record = {
diff --git a/dash/src/main.tsx b/dash/src/main.tsx
index b8ce191b..68fcb0ff 100644
--- a/dash/src/main.tsx
+++ b/dash/src/main.tsx
@@ -1,18 +1,47 @@
-import { StrictMode } from 'react'
+import { Component, StrictMode, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App'
import './index.css'
+// Last-resort guard: a render error (e.g. an unexpected payload from a peer on
+// a different version) shows a recoverable message instead of a blank page.
+class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
+ state = { error: null as Error | null }
+ static getDerivedStateFromError(error: Error) {
+ return { error }
+ }
+ render() {
+ if (this.state.error) {
+ return (
+
-
-
-
+
+
+
+
+
,
)
diff --git a/src/sharing/client.ts b/src/sharing/client.ts
index 60d2886e..41b8ddd9 100644
--- a/src/sharing/client.ts
+++ b/src/sharing/client.ts
@@ -24,6 +24,7 @@ function call(
path: string,
headers: Record = {},
body?: string,
+ timeoutMs = 15000,
): Promise {
return new Promise((resolve, reject) => {
const req = request(
@@ -36,6 +37,9 @@ function call(
cert: ep.identity.cert,
rejectUnauthorized: false,
checkServerIdentity: () => undefined,
+ // Fresh socket per request so the pinned-fingerprint check always reads
+ // this connection's certificate, never a pooled/keep-alive one.
+ agent: false,
headers: { ...headers, ...(body ? { 'content-type': 'application/json' } : {}) },
},
(res) => {
@@ -54,6 +58,7 @@ function call(
},
)
req.on('error', reject)
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('peer timed out')))
if (body) req.write(body)
req.end()
})
@@ -70,7 +75,9 @@ export function pair(ep: PeerEndpoint, pin: string, name: string): Promise {
- return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name }))
+ // Stays open while the peer's user decides; give it longer than the server's
+ // 60s approval prompt.
+ return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name }), 65_000)
}
export function fetchUsage(ep: PeerEndpoint, token: string, query: UsageQuery = {}): Promise {
diff --git a/src/sharing/host.ts b/src/sharing/host.ts
index 158782b4..8009a351 100644
--- a/src/sharing/host.ts
+++ b/src/sharing/host.ts
@@ -1,9 +1,11 @@
import { hello, pair, pairRequest, fetchUsage } from './client.js'
import { loadOrCreateIdentity } from './identity.js'
import { pairingCode } from './pairing.js'
+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 { formatCost } from '../currency.js'
import { formatTokens } from '../format.js'
@@ -13,6 +15,7 @@ type DevicePayload = {
}
export type DeviceUsage = {
+ id: string // stable unique id (cert fingerprint for remotes, 'local' for this device)
name: string
local: boolean
payload?: DevicePayload
@@ -89,17 +92,24 @@ export async function pullDevices(
const identity = await loadOrCreateIdentity(dir)
const remotes = await loadRemotes(dir)
- const results: DeviceUsage[] = [{ name: localName, local: true, payload: await localGetUsage(query) }]
- for (const r of remotes) {
- try {
- const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query)
- if (res.status === 200) results.push({ name: r.name, local: false, payload: res.json as DevicePayload })
- else results.push({ name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` })
- } catch (e) {
- results.push({ name: r.name, local: false, error: e instanceof Error ? e.message : String(e) })
- }
- }
- return results
+ const local: DeviceUsage = { id: 'local', name: localName, local: true, payload: await localGetUsage(query) }
+ // Pull every remote concurrently and isolate failures, so one slow or
+ // powered-off device degrades to an error row instead of blocking the rest.
+ const remoteResults = await Promise.all(
+ remotes.map(async (r): Promise => {
+ try {
+ const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query)
+ // Re-sanitize on receipt: do not trust the sender to have stripped its
+ // own project names/sessions (it may run an older build). Belt and
+ // suspenders alongside the sender-side sanitize.
+ if (res.status === 200) return { id: r.fingerprint, name: r.name, local: false, payload: sanitizeForSharing(res.json as MenubarPayload) }
+ return { id: r.fingerprint, name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` }
+ } catch (e) {
+ return { id: r.fingerprint, name: r.name, local: false, error: e instanceof Error ? e.message : String(e) }
+ }
+ }),
+ )
+ return [local, ...remoteResults]
}
export function renderDevices(results: DeviceUsage[]): string {
diff --git a/src/sharing/pairing.ts b/src/sharing/pairing.ts
index faec7778..7aab37cd 100644
--- a/src/sharing/pairing.ts
+++ b/src/sharing/pairing.ts
@@ -44,23 +44,32 @@ export class PairingWindow {
readonly pin: string
readonly openedAt: number
private used = false
+ private attempts = 0
- constructor(ttlMs = 60_000, now: number = Date.now(), pin: string = generatePin()) {
+ constructor(ttlMs = 60_000, now: number = Date.now(), pin: string = generatePin(), maxAttempts = 5) {
this.ttlMs = ttlMs
this.pin = pin
this.openedAt = now
+ this.maxAttempts = maxAttempts
}
private readonly ttlMs: number
+ private readonly maxAttempts: number
isOpen(now: number = Date.now()): boolean {
return !this.used && now - this.openedAt <= this.ttlMs
}
// Verify a submitted PIN. A correct match consumes the window (one-time use).
+ // Wrong guesses are counted and the window closes after maxAttempts, so a
+ // 6-digit PIN cannot be brute-forced by a LAN peer within the TTL.
verify(pin: string, now: number = Date.now()): boolean {
if (!this.isOpen(now)) return false
- if (!constantTimeEqual(pin, this.pin)) return false
+ if (!constantTimeEqual(pin, this.pin)) {
+ this.attempts += 1
+ if (this.attempts >= this.maxAttempts) this.used = true
+ return false
+ }
this.used = true
return true
}
diff --git a/src/sharing/sanitize.ts b/src/sharing/sanitize.ts
index d0b77cd9..a09f5118 100644
--- a/src/sharing/sanitize.ts
+++ b/src/sharing/sanitize.ts
@@ -1,9 +1,11 @@
import type { MenubarPayload } from '../menubar-json.js'
-// Strip identifying detail before usage leaves the device. We share aggregate
-// numbers (cost, tokens, models, tools, activities, daily) but never project
-// names, paths, or per-session detail, so "what you are working on" stays on
-// the machine that produced it. Only the totals travel.
+// Strip identifying detail before usage leaves the device. We never share
+// project names, file paths, or per-session detail (the strongest signal of
+// "what you are working on"). We DO share aggregate numbers plus model, tool,
+// task, subagent, skill, and MCP-server usage, since the dashboard surfaces
+// those per device. If a user names a subagent/skill after a client, that name
+// would travel; revisit if that becomes a concern.
export function sanitizeForSharing(payload: MenubarPayload): MenubarPayload {
return {
...payload,
diff --git a/src/sharing/share-controller.ts b/src/sharing/share-controller.ts
new file mode 100644
index 00000000..b6c1917b
--- /dev/null
+++ b/src/sharing/share-controller.ts
@@ -0,0 +1,151 @@
+import { randomUUID } from 'crypto'
+
+import { loadOrCreateIdentity, type Identity } from './identity.js'
+import { PeerStore } from './pairing.js'
+import { ShareServer, type PairRequest, type UsageQuery } from './share-server.js'
+import { advertise } from './discovery.js'
+import { getSharingDir, loadPeers, savePeers } from './store.js'
+
+export type PendingPairing = { id: string; name: string; code: string }
+export type ShareStatus = {
+ sharing: boolean
+ name: string
+ port: number
+ always: boolean
+ peers: number
+ pending: PendingPairing[]
+}
+
+const IDLE_TIMEOUT_MS = 10 * 60_000
+
+// Runs the secure share server inside the dashboard process so the user can
+// turn sharing on/off from the browser. Incoming approve-style pairings are
+// queued and surfaced to the UI instead of prompting a terminal.
+export class ShareController {
+ private server: ShareServer | null = null
+ private ad: ReturnType | null = null
+ private peers: PeerStore | null = null
+ private identity: Identity | null = null
+ private always = false
+ private idleTimer: ReturnType | null = null
+ private lastActivity = 0
+ private readonly dir = getSharingDir()
+ private readonly pending = new Map<
+ string,
+ { name: string; code: string; fingerprint: string; resolve: (ok: boolean) => void; timer: ReturnType }
+ >()
+
+ constructor(
+ private readonly getUsage: (q: UsageQuery) => Promise,
+ private readonly port = 7777,
+ ) {}
+
+ private async getIdentity(): Promise {
+ if (!this.identity) this.identity = await loadOrCreateIdentity(this.dir)
+ return this.identity
+ }
+
+ isSharing(): boolean {
+ return !!this.server
+ }
+
+ async start(always: boolean): Promise {
+ if (this.server) {
+ this.always = always
+ this.refreshIdleWatch()
+ return
+ }
+ const identity = await this.getIdentity()
+ this.peers = new PeerStore(await loadPeers(this.dir))
+ const server = new ShareServer({
+ identity,
+ peers: this.peers,
+ getUsage: this.getUsage,
+ onPaired: () => {
+ if (this.peers) void savePeers(this.peers.list(), this.dir)
+ },
+ approve: (req) => this.enqueueApproval(req),
+ })
+ // listen() can reject (e.g. EADDRINUSE); only commit state after it binds,
+ // so a failed start never leaves us reporting always/sharing incorrectly.
+ await server.listen(this.port, '0.0.0.0')
+ this.always = always
+ this.server = server
+ this.ad = advertise({ name: identity.name, port: this.port, fingerprint: identity.fingerprint })
+ this.lastActivity = Date.now()
+ server.server.on('request', () => {
+ this.lastActivity = Date.now()
+ })
+ this.refreshIdleWatch()
+ }
+
+ private refreshIdleWatch(): void {
+ if (this.idleTimer) {
+ clearInterval(this.idleTimer)
+ this.idleTimer = null
+ }
+ if (this.always) return
+ this.idleTimer = setInterval(() => {
+ if (Date.now() - this.lastActivity > IDLE_TIMEOUT_MS) void this.stop()
+ }, 30_000)
+ this.idleTimer.unref?.()
+ }
+
+ async stop(): Promise {
+ if (this.idleTimer) {
+ clearInterval(this.idleTimer)
+ this.idleTimer = null
+ }
+ for (const p of this.pending.values()) {
+ clearTimeout(p.timer)
+ p.resolve(false)
+ }
+ this.pending.clear()
+ await this.ad?.stop().catch(() => {})
+ await this.server?.close().catch(() => {})
+ this.ad = null
+ this.server = null
+ }
+
+ private enqueueApproval(req: PairRequest): Promise {
+ // One outstanding request per device, and a hard cap, so a LAN peer cannot
+ // flood the approval prompt or bury a legitimate request.
+ for (const p of this.pending.values()) if (p.fingerprint === req.fingerprint) return Promise.resolve(false)
+ if (this.pending.size >= 8) return Promise.resolve(false)
+ return new Promise((resolve) => {
+ const id = randomUUID()
+ const timer = setTimeout(() => {
+ this.pending.delete(id)
+ resolve(false)
+ }, 60_000)
+ timer.unref?.()
+ this.pending.set(id, { name: req.name, code: req.code, fingerprint: req.fingerprint, resolve, timer })
+ })
+ }
+
+ listPending(): PendingPairing[] {
+ return [...this.pending.entries()].map(([id, p]) => ({ id, name: p.name, code: p.code }))
+ }
+
+ resolvePending(id: string, approve: boolean): boolean {
+ const p = this.pending.get(id)
+ if (!p) return false
+ clearTimeout(p.timer)
+ this.pending.delete(id)
+ p.resolve(approve)
+ return true
+ }
+
+ async status(): Promise {
+ const identity = await this.getIdentity()
+ const peers = this.peers ? this.peers.list().length : (await loadPeers(this.dir)).length
+ return {
+ sharing: this.isSharing(),
+ name: identity.name,
+ port: this.port,
+ always: this.always,
+ peers,
+ pending: this.listPending(),
+ }
+ }
+}
diff --git a/src/sharing/share-server.ts b/src/sharing/share-server.ts
index bc6c9899..5c5c7bca 100644
--- a/src/sharing/share-server.ts
+++ b/src/sharing/share-server.ts
@@ -37,6 +37,11 @@ export class ShareServer {
void this.handle(req, res)
},
)
+ // Swallow server-level socket/TLS errors (e.g. a malformed handshake from a
+ // LAN peer) so they can never crash the host process. `listen()` attaches
+ // its own one-time handler for bind failures.
+ this.server.on('error', () => {})
+ this.server.on('tlsClientError', () => {})
}
// Open a one-time pairing window and return the PIN to show the user.
@@ -72,6 +77,21 @@ export class ShareServer {
res.writeHead(code, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
+ try {
+ await this.route(url, req, res, json)
+ } catch (err) {
+ // Never leave a request hanging (a hung peer makes the caller time out
+ // and drop this device); always answer, even on an internal error.
+ if (!res.headersSent) json(500, { error: err instanceof Error ? err.message : String(err) })
+ }
+ }
+
+ private async route(
+ url: URL,
+ req: IncomingMessage,
+ res: ServerResponse,
+ json: (code: number, body: unknown) => void,
+ ): Promise {
// Unauthenticated: just enough for a joiner to learn who this is and whether
// pairing is currently open. No usage data here.
diff --git a/src/sharing/store.ts b/src/sharing/store.ts
index 717222e7..2f52178f 100644
--- a/src/sharing/store.ts
+++ b/src/sharing/store.ts
@@ -1,4 +1,4 @@
-import { readFile, writeFile, mkdir } from 'fs/promises'
+import { readFile, writeFile, mkdir, chmod } from 'fs/promises'
import { join, dirname } from 'path'
import { getConfigFilePath } from '../config.js'
@@ -28,9 +28,13 @@ async function readJson(path: string, fallback: T): Promise {
}
}
+// These files hold bearer tokens, so keep them owner-only (0600) like the TLS
+// private key. mkdir/writeFile modes only apply on creation, so chmod enforces
+// it on files that already exist from an earlier version.
async function writeJson(path: string, data: unknown): Promise {
- await mkdir(dirname(path), { recursive: true })
- await writeFile(path, JSON.stringify(data, null, 2))
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
+ await writeFile(path, JSON.stringify(data, null, 2), { mode: 0o600 })
+ await chmod(path, 0o600).catch(() => {})
}
// Peers allowed to pull from this device (the sharing side, used by ShareServer).
@@ -48,3 +52,13 @@ export function loadRemotes(dir: string = getSharingDir()): Promise {
return writeJson(join(dir, 'remote-devices.json'), remotes)
}
+
+// Whether the dashboard should keep sharing on (opt-in always-live). Persisted
+// so `codeburn web` resumes the chosen state on launch.
+export async function loadShareAlways(dir: string = getSharingDir()): Promise {
+ const s = await readJson(join(dir, 'web-share.json'), { always: false } as { always?: boolean })
+ return !!s.always
+}
+export function saveShareAlways(always: boolean, dir: string = getSharingDir()): Promise {
+ return writeJson(join(dir, 'web-share.json'), { always })
+}
diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts
index fe442058..16e0e754 100644
--- a/src/web-dashboard.ts
+++ b/src/web-dashboard.ts
@@ -11,7 +11,25 @@ import { hostname } from 'os'
import { loadPricing } from './models.js'
import { buildMenubarPayloadForRange } from './usage-aggregator.js'
import { getDateRange, parseDateRangeFlags, formatDateRangeLabel, toPeriod } from './cli-date.js'
-import { pullDevices } from './sharing/host.js'
+import { pullDevices, linkRemote } from './sharing/host.js'
+import { browse } from './sharing/discovery.js'
+import { loadOrCreateIdentity } from './sharing/identity.js'
+import { pairingCode } from './sharing/pairing.js'
+import { getSharingDir, loadRemotes, loadShareAlways, saveShareAlways } from './sharing/store.js'
+import { ShareController } from './sharing/share-controller.js'
+import { sanitizeForSharing } from './sharing/sanitize.js'
+
+function readBody(req: import('http').IncomingMessage): Promise {
+ return new Promise((resolve, reject) => {
+ let body = ''
+ req.on('data', (c) => {
+ body += c
+ if (body.length > 1_000_000) reject(new Error('request body too large'))
+ })
+ req.on('end', () => resolve(body))
+ req.on('error', reject)
+ })
+}
const HERE = dirname(fileURLToPath(import.meta.url))
@@ -73,10 +91,36 @@ export async function runWebDashboard(opts: {
await loadPricing()
const dashDir = resolveDashDir()
+ // Sharing this device serves the SANITIZED aggregate (no project names/paths
+ // or per-session detail), unlike the local /api/usage which shows everything.
+ const shareGetUsage = async (q: { period?: string; from?: string; to?: string }) => {
+ const customRange = parseDateRangeFlags(q.from, q.to)
+ const periodInfo = customRange
+ ? { range: customRange, label: formatDateRangeLabel(q.from, q.to) }
+ : getDateRange(toPeriod(q.period ?? opts.period))
+ return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false }))
+ }
+ const share = new ShareController(shareGetUsage)
+ if (await loadShareAlways()) await share.start(true).catch(() => {})
+
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url ?? '/', 'http://localhost')
+ // Loopback-only server. Reject any request not addressed to localhost
+ // (defeats DNS rebinding, which would otherwise let a website you visit
+ // read your local usage) and any cross-origin request (CSRF). The local
+ // payload is unsanitized, so this guard is what keeps it on your machine.
+ const reqHost = (req.headers.host ?? '').replace(/:\d+$/, '')
+ const loopback = reqHost === '127.0.0.1' || reqHost === 'localhost' || reqHost === '::1' || reqHost === '[::1]'
+ const origin = req.headers.origin
+ const originOk = !origin || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(origin)
+ if (!loopback || !originOk) {
+ res.writeHead(403, { 'content-type': 'text/plain' })
+ res.end('Forbidden')
+ return
+ }
+
if (url.pathname === '/api/usage') {
const period = url.searchParams.get('period') ?? opts.period
const provider = url.searchParams.get('provider') ?? opts.provider
@@ -117,6 +161,92 @@ export async function runWebDashboard(opts: {
return
}
+ // This device's own identity (name + fingerprint) for the pairing UI.
+ if (url.pathname === '/api/identity') {
+ const id = await loadOrCreateIdentity(getSharingDir())
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
+ res.end(JSON.stringify({ name: id.name, fingerprint: id.fingerprint }))
+ return
+ }
+
+ // Discover devices currently sharing on the local network (mDNS). Each
+ // carries the confirm code to match, and whether it is already paired.
+ if (url.pathname === '/api/devices/scan') {
+ const dir = getSharingDir()
+ const id = await loadOrCreateIdentity(dir)
+ const pairedFps = new Set((await loadRemotes(dir)).map((r) => r.fingerprint))
+ const found = await browse(2500)
+ const list = found
+ .filter((d) => d.fingerprint !== id.fingerprint)
+ .map((d) => ({
+ name: d.name,
+ host: d.host,
+ port: d.port,
+ fingerprint: d.fingerprint,
+ code: pairingCode(id.fingerprint, d.fingerprint),
+ paired: pairedFps.has(d.fingerprint),
+ }))
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
+ res.end(JSON.stringify({ found: list }))
+ return
+ }
+
+ // Pair with a chosen discovered device. Blocks until the other device
+ // approves (or declines / times out), then stores the link.
+ if (url.pathname === '/api/devices/pair' && req.method === 'POST') {
+ if (!(req.headers['content-type'] ?? '').includes('application/json')) {
+ res.writeHead(415, { 'content-type': 'application/json' })
+ res.end(JSON.stringify({ ok: false, error: 'content-type must be application/json' }))
+ return
+ }
+ const body = JSON.parse((await readBody(req)) || '{}') as { name: string; host: string; port: number; fingerprint: string }
+ try {
+ const device = await linkRemote(body)
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
+ res.end(JSON.stringify({ ok: true, name: device.name }))
+ } catch (err) {
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
+ res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }))
+ }
+ return
+ }
+
+ // Share-this-device controls. Status carries the pending pairing requests
+ // so the SPA can poll one endpoint and surface approvals in the browser.
+ if (url.pathname === '/api/share/status') {
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
+ res.end(JSON.stringify(await share.status()))
+ return
+ }
+ if (url.pathname === '/api/share/start' && req.method === 'POST') {
+ const body = JSON.parse((await readBody(req)) || '{}') as { always?: boolean }
+ let startError: string | undefined
+ try {
+ await share.start(!!body.always)
+ await saveShareAlways(!!body.always)
+ } catch (err) {
+ // e.g. EADDRINUSE when a CLI `codeburn share` already holds the port.
+ startError = err instanceof Error ? err.message : String(err)
+ }
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
+ res.end(JSON.stringify({ ...(await share.status()), error: startError }))
+ return
+ }
+ if (url.pathname === '/api/share/stop' && req.method === 'POST') {
+ await share.stop()
+ await saveShareAlways(false)
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
+ res.end(JSON.stringify(await share.status()))
+ return
+ }
+ if (url.pathname === '/api/share/approve' && req.method === 'POST') {
+ const body = JSON.parse((await readBody(req)) || '{}') as { id?: string; approve?: boolean }
+ const ok = typeof body.id === 'string' && share.resolvePending(body.id, !!body.approve)
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
+ res.end(JSON.stringify({ ok }))
+ return
+ }
+
if (!dashDir) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(NOT_BUILT_PAGE)
@@ -157,6 +287,8 @@ export async function runWebDashboard(opts: {
})
server.listen(opts.port, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
})
+ // Durable handler so a post-bind socket error never crashes the process.
+ server.on('error', () => {})
const url = `http://127.0.0.1:${port}`
if (!dashDir) {
@@ -165,6 +297,11 @@ export async function runWebDashboard(opts: {
process.stdout.write(`\n CodeBurn dashboard at ${url}\n Press Ctrl+C to stop.\n\n`)
if (opts.open) openBrowser(url)
+ // Withdraw the mDNS advertisement and close the share server cleanly on exit.
+ process.on('SIGINT', () => {
+ void share.stop().finally(() => process.exit(0))
+ })
+
await new Promise(() => {
/* run until interrupted */
})
diff --git a/tests/sharing/pairing.test.ts b/tests/sharing/pairing.test.ts
index 5410cc9e..cd950c13 100644
--- a/tests/sharing/pairing.test.ts
+++ b/tests/sharing/pairing.test.ts
@@ -66,6 +66,14 @@ describe('PairingWindow', () => {
expect(w.verify('123456', 1100)).toBe(true)
expect(w.verify('123456', 1200)).toBe(false)
})
+ it('closes after too many wrong guesses (no brute force within the window)', () => {
+ const w = new PairingWindow(10_000, 1000, '123456', 5)
+ for (let i = 0; i < 5; i++) expect(w.verify('000000', 1000 + i)).toBe(false)
+ // window is now locked even though the TTL has not expired
+ expect(w.isOpen(1100)).toBe(false)
+ // and the correct PIN no longer works
+ expect(w.verify('123456', 1100)).toBe(false)
+ })
})
describe('PeerStore', () => {
+
- )
+ )
+ }
}
-export function UsageChart({ daily }: { daily: DailyEntry[] }) {
- const { rows, series } = useMemo(() => {
- const totals = new Map{fmtDay(String(lbl))}
+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ {items.slice(0, 6).map((p: any) => (
+
-
+
+ {labels[String(p.dataKey)] ?? String(p.dataKey)}
+ {fmt(p.value)}
+
+ ))}
+
+ Total
+ {fmt(total)}
- ))}
-
- Total
- {usd(total)}
+
+ )
+ }
+ return this.props.children
+ }
+}
+
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000, retry: 1 } },
})
createRoot(document.getElementById('root')!).render(
Something went wrong rendering the dashboard.
+{String(this.state.error.message)}
+ +
+