codeburn/src/web-dashboard.ts
Resham Joshi 9b20dfd7dc
feat(context): per-session context token tree across CLI, TUI, and dashboard (#592)
* feat(context): add experimental context command with per-session token tree

Reconstructs context breakdown for a Claude Code session
from its transcript: messages and tokens by role, block type, and tool,
split into full-session history vs the live window since the last
compaction (following preservedSegment.headUuid).

Block tokens are chars/4 estimates, images estimated from sniffed PNG or
JPEG dimensions, reasoning derived per message from output_tokens minus
visible output since transcripts strip thinking text. The exact context
size comes from the last assistant message's API usage, and the gap to
the estimate is shown as derived system prompt and tool overhead.

* feat(context): show session titles in context --list

Reads the latest ai-title entry (summary entry as fallback for older
sessions) from one tail and one head chunk of each transcript, so the
list stays fast on 100MB files.

* feat(dash): context explorer page with Claude Code and Codex session trees

Adds a Context page to the web dashboard: a navbar toggle, a provider
picker (Claude Code / Codex), the 15 most recent sessions with titles,
and per-session expandable details showing the context token tree, the
exact live context vs window, and a live/full-history scope toggle.

Server side adds /api/context/sessions and /api/context/tree with an
mtime-keyed tree cache, plus a Codex rollout builder: response items
feed the tree, compacted entries (with replacement_history) split live
window from full history, reasoning comes exact from cumulative
token_count totals, and model_context_window gives the real window.

* feat(context): interactive TUI for the context command

codeburn context with no arguments now opens an ink TUI: arrow keys to
move, enter to expand a session's token tree inline (with a spinner
while the transcript reads), tab to switch between Claude Code and
Codex, f to toggle live window vs full history, q to quit. Flag and id
forms keep the plain output for scripts, and non-TTY runs fall back to
the static list. Tree rows are shared with the static renderer via
snapshotRows.

* fix(context): review fixes across window detection, codex parsing, and surfaces

Window sizes are no longer guessed from token counts alone: opus-4-8 and
[1m] model ids map to the 1M window (a 190K opus-4-8 session used to
render as 95% of a 200K window), and Codex omits the percentage instead
of borrowing Anthropic constants when the rollout lacks
model_context_window.

Codex compaction accounting now reads the encrypted compaction item and
developer messages from replacement_history, so post-compaction windows
stop undercounting. Both builders stream with largeLineAsBuffer so one
oversized line cannot silently truncate the walk.

The tree API stops sending session.filePath, serves the flattened rows
so the dash renders the same tree as the CLI and TUI, resolves ids
directly instead of re-scanning every session file per request, and the
tree cache evicts LRU. Session discovery honors CLAUDE_CONFIG_DIRS,
CLAUDE_CONFIG_DIR, and CODEX_HOME, stats files in parallel, and id
lookups stat only matching files.

CLI gains --provider codex and machine-readable --list --json; --full
now opens the TUI in full scope instead of bypassing it. The TUI shows
build errors instead of a frozen spinner and drops the ref-plus-counter
repaint for plain state. The dashboard scopes the usage error banner and
device sidebar to the Usage page.
2026-07-02 05:00:03 +02:00

414 lines
18 KiB
TypeScript

import { createServer, type Server } from 'http'
import { exec } from 'child_process'
import { readFile } from 'fs/promises'
import { existsSync } from 'fs'
import { join, normalize, extname, dirname, sep } from 'path'
import { fileURLToPath } from 'url'
import { AddressInfo } from 'net'
import { hostname } from 'os'
import { loadPricing } from './models.js'
import { buildMenubarPayloadForRange } from './usage-aggregator.js'
import type { MenubarPayload } from './menubar-json.js'
import { periodInfoFromQuery, UsageQueryError } from './cli-date.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'
import { buildContextTree, findClaudeSession, listRecentTitledSessions, snapshotRows, type ContextTreeResult, type SessionRef } from './context-tree.js'
import { buildCodexContextTree, findCodexSession, listRecentCodexSessions } from './context-tree-codex.js'
function readBody(req: import('http').IncomingMessage): Promise<string> {
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)
})
}
function writeJsonError(res: import('http').ServerResponse, status: number, error: string): void {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ error }))
}
// Cap on the cached local payload, matched to the parser's own session cache
// (parser.ts) so the assembled payload is never staler than its source data.
const LOCAL_PAYLOAD_TTL_MS = 180_000
const HERE = dirname(fileURLToPath(import.meta.url))
// Locate the built React dashboard (dist/dash). Works both when running from a
// published package (dist/dash next to the bundled CLI) and from source.
function resolveDashDir(): string | null {
const candidates = [
process.env['CODEBURN_DASH_DIR'],
join(HERE, 'dash'),
join(HERE, '..', 'dist', 'dash'),
join(HERE, '..', 'dash', 'dist'),
].filter(Boolean) as string[]
for (const dir of candidates) {
if (existsSync(join(dir, 'index.html'))) return dir
}
return null
}
const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.json': 'application/json; charset=utf-8',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.map': 'application/json',
}
const NOT_BUILT_PAGE =
'<!doctype html><meta charset="utf-8">' +
'<body style="font-family:system-ui;background:#0a0a0b;color:#e7e7ea;padding:48px;line-height:1.6">' +
'<h2>Dashboard not built yet</h2>' +
'<p>Build the web UI once, then reload:</p>' +
'<pre style="background:#141417;padding:12px 16px;border-radius:8px;color:#ff8c42">cd dash &amp;&amp; npm install &amp;&amp; npm run build</pre>' +
'<p>The CLI keeps serving the live data API in the meantime.</p></body>'
function openBrowser(url: string): void {
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open'
try {
exec(`${cmd} ${url}`)
} catch {
/* user can open it manually */
}
}
export async function runWebDashboard(opts: {
period: string
provider: string
from?: string
to?: string
project: string[]
exclude: string[]
port: number
open: boolean
}): Promise<Server> {
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 periodInfo = periodInfoFromQuery(q, opts.period)
return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false }))
}
const share = new ShareController(shareGetUsage)
if (await loadShareAlways()) await share.start(true).catch(() => {})
// The server is long-lived, so cache this machine's parsed payload (the CLI
// process cannot). Store the promise before any await so concurrent identical
// requests collapse into one parse instead of racing.
const localPayloadCache = new Map<string, { at: number; payload: Promise<MenubarPayload> }>()
const getLocalPayload = (period: string, provider: string, from?: string, to?: string): Promise<MenubarPayload> => {
const key = `${period}|${provider}|${from ?? ''}|${to ?? ''}`
const hit = localPayloadCache.get(key)
if (hit && Date.now() - hit.at < LOCAL_PAYLOAD_TTL_MS) return hit.payload
const periodInfo = periodInfoFromQuery({ period, from, to }, opts.period)
const payload = buildMenubarPayloadForRange(periodInfo, { provider, project: opts.project, exclude: opts.exclude, optimize: false })
const now = Date.now()
localPayloadCache.set(key, { at: now, payload })
for (const [k, v] of localPayloadCache) if (now - v.at >= LOCAL_PAYLOAD_TTL_MS) localPayloadCache.delete(k)
void payload.catch(() => localPayloadCache.delete(key))
return payload
}
// Context trees re-read a whole transcript (up to 100MB), so cache each by
// file version. Keyed on mtime: an active session invalidates itself.
const contextTreeCache = new Map<string, Promise<ContextTreeResult>>()
const getContextTree = (provider: 'claude' | 'codex', ref: SessionRef): Promise<ContextTreeResult> => {
const key = `${provider}:${ref.sessionId}:${ref.mtimeMs}`
const hit = contextTreeCache.get(key)
if (hit) {
// Re-insert so eviction is LRU rather than insertion order.
contextTreeCache.delete(key)
contextTreeCache.set(key, hit)
return hit
}
const tree = provider === 'claude' ? buildContextTree(ref) : buildCodexContextTree(ref)
contextTreeCache.set(key, tree)
void tree.catch(() => contextTreeCache.delete(key))
while (contextTreeCache.size > 12) {
const oldest = contextTreeCache.keys().next().value
if (oldest === undefined) break
contextTreeCache.delete(oldest)
}
return tree
}
// Embed this machine's prewarmed payload in index.html for an instant first
// paint with no data round-trip. Only the local device is inlined: no remote
// network wait, and paired devices stream in via the live fetch right after.
const serveIndexHtml = async (res: import('http').ServerResponse, filePath: string): Promise<void> => {
const html = await readFile(filePath, 'utf8')
const payload = await getLocalPayload(opts.period, opts.provider, opts.from, opts.to)
const devices = [{ id: 'local', name: hostname(), local: true, payload }]
// Escape every '<' so a device/model/project name can't close the <script>.
const json = JSON.stringify({ devices }).replace(/</g, String.fromCharCode(92) + 'u003c')
const injected = html.replace('<script type="module"', `<script>window.__CODEBURN_BOOTSTRAP__=${json}</script>\n <script type="module"`)
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
res.end(injected)
}
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
const from = url.searchParams.get('from') ?? opts.from
const to = url.searchParams.get('to') ?? opts.to
let payload
try {
payload = await getLocalPayload(period, provider, from, to)
} catch (err) {
if (!(err instanceof UsageQueryError)) throw err
writeJsonError(res, 400, err.message)
return
}
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify(payload))
return
}
// This machine plus every paired device, each kept separate. Remote
// payloads arrive already sanitized (aggregate numbers only).
if (url.pathname === '/api/devices') {
const period = url.searchParams.get('period') ?? opts.period
const provider = url.searchParams.get('provider') ?? opts.provider
const from = url.searchParams.get('from') ?? opts.from
const to = url.searchParams.get('to') ?? opts.to
let results
try {
results = await pullDevices(() => getLocalPayload(period, provider, from, to), { period, from, to }, hostname(), {})
} catch (err) {
if (!(err instanceof UsageQueryError)) throw err
writeJsonError(res, 400, err.message)
return
}
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify({ devices: results }))
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
}
// Session ids are resolved against the discovered session files only,
// never joined into a path from user input, and responses never carry
// local file paths.
if (url.pathname === '/api/context/sessions') {
const provider = url.searchParams.get('provider') ?? 'claude'
if (provider !== 'claude' && provider !== 'codex') {
writeJsonError(res, 400, 'provider must be claude or codex')
return
}
const refs = provider === 'claude' ? await listRecentTitledSessions(15) : await listRecentCodexSessions(15)
const sessions = refs.map((r) => ({ provider, sessionId: r.sessionId, project: r.project, title: r.title, mtimeMs: r.mtimeMs, sizeBytes: r.sizeBytes }))
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify({ sessions }))
return
}
if (url.pathname === '/api/context/tree') {
const provider = url.searchParams.get('provider') ?? 'claude'
const id = url.searchParams.get('id') ?? ''
if ((provider !== 'claude' && provider !== 'codex') || !id) {
writeJsonError(res, 400, 'provider (claude|codex) and id are required')
return
}
const ref = provider === 'claude' ? await findClaudeSession(id) : await findCodexSession(id)
if (!ref || ref.sessionId !== id) {
writeJsonError(res, 404, `no ${provider} session ${id}`)
return
}
const tree = await getContextTree(provider, ref)
const payload = {
...tree,
session: { sessionId: tree.session.sessionId, project: tree.session.project, mtimeMs: tree.session.mtimeMs, sizeBytes: tree.session.sizeBytes },
effectiveRows: snapshotRows(tree.effective),
fullRows: snapshotRows(tree.full),
}
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(JSON.stringify(payload))
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)
return
}
let pathname = decodeURIComponent(url.pathname)
if (pathname === '/' || pathname === '') pathname = '/index.html'
const filePath = normalize(join(dashDir, pathname))
if (filePath !== dashDir && !filePath.startsWith(dashDir + sep)) {
res.writeHead(403)
res.end('Forbidden')
return
}
try {
if (extname(filePath) === '.html') {
await serveIndexHtml(res, filePath)
} else {
const buf = await readFile(filePath)
res.writeHead(200, { 'content-type': CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream' })
res.end(buf)
}
} catch {
// Unknown path: serve index.html so the SPA can route it.
await serveIndexHtml(res, join(dashDir, 'index.html'))
}
} catch (err) {
res.writeHead(500, { 'content-type': 'application/json' })
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
}
})
const port = await new Promise<number>((resolve, reject) => {
server.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
} else {
reject(err)
}
})
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', () => {})
void Promise.resolve().then(() => getLocalPayload(opts.period, opts.provider, opts.from, opts.to)).catch(() => {})
const url = `http://127.0.0.1:${port}`
if (!dashDir) {
process.stdout.write(`\n Dashboard UI is not built. Run: cd dash && npm install && npm run build\n`)
}
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))
})
return server
await new Promise<never>(() => {
/* run until interrupted */
})
}