Local usage board for Claude Code. A hooks module records one metadata row per turn (exact engine cost, tokens by model, tool calls, agent spawns, context…

Observability is a local Claude Code hooks module. It records one metadata row per completed main-thread turn, including aborted, refused, and failed turns. Recording is off until you enable it in each project.
Run these in Claude Code:
/plugin marketplace add Eigenwise/eigenwise-toolshed
/plugin install observability@eigenwise-toolshed --scope project
On Claude Code 2.1.275 or later one line does both: /plugin install observability --marketplace Eigenwise/eigenwise-toolshed. Reload plugins or start a new session, then run /observability:track on in each project you want recorded.
/observability:track on enables recording for the current project./observability:track off stops future recording. Existing history stays on disk./observability:track status reports whether the current project records turns./observability:board rebuilds and opens the local board.Installing the plugin does not opt any project in.
The board shows cost, agent time, tokens, sessions, quota usage, project activity, model mix, tool calls and failures, ticket totals, session outcomes, and activity streaks. Filter by project, period, or session name. Cost uses the engine's own /cost figure, with per-turn differences from its session total. There are no price tables or estimates. Agent time sums turn durations; it can exceed elapsed wall time when sessions overlap.
When Quality Gate is installed, sessions also show the latest test status (marked stale after edits) and maximum CC, with SPLIT in red for new or complexity-raised bodies at CC 6 or higher.
The Claude config directory is CLAUDE_CONFIG_DIR when set, otherwise ~/.claude (%USERPROFILE%\.claude on Windows). Ledgers live at <claude config dir>/observability/ledger/<project-slug>.jsonl. The slug is the project root's basename (characters outside letters, digits, underscore, dot, and hyphen replaced by underscores) followed by a six-character hash of the full path, so two projects with the same folder name keep separate ledgers. The generated board is <claude config dir>/observability/board.html.
Metadata includes project names, session identifiers and titles (or the first prompt line, up to 60 characters), model names, token counts, exact engine cost, timing, tool names and counts, agent types, context usage, quota usage, compaction, outcomes, and a ticket identifier when present. Full prompts, responses, code, tool inputs and results, credentials, and environment values are not recorded. Session names can contain sensitive text, so treat the ledger and board as private files.
Nothing leaves the machine. The board is a self-contained local HTML file with no server, network requests, or remote sinks. Turning tracking off keeps existing files; deleting history is a separate manual file deletion.
The previous collection services, database observer, container dashboards, remote exporters, and statusline are removed. Old SQLite data is not migrated or deleted. It remains in %LOCALAPPDATA%\Eigenwise\Workbench\observability.db, or ~/.local/share/Eigenwise/Workbench/observability.db when LOCALAPPDATA was not set. Remove old service startup entries and telemetry export settings separately if you configured them.
hooks/register.ts 199 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { boardHtml } from '../lib/board.mjs'
5import {
6 addAgent,
7 addStep,
8 addSubagentUsage,
9 addToolCall,
10 appendLine,
11 foldTurn,
12 freshRecording,
13 nextRecording,
14 notePrompt,
15 projectSlug,
16 qualityForTurn,
17 rowsSince,
18 tokensOf,
19} from './ledger.ts'
20import type { TurnRow } from './ledger.ts'
21
22const BOARD_DAYS = 90
23const DAY_MS = 24 * 60 * 60 * 1000
24// $.fs.read refuses files over 4 MiB, so a ledger is rolled aside well before that.
25const LEDGER_CAP = 3 * 1024 * 1024
26
27const recording = atom({ plugin: 'observability', key: 'turn' } as const, freshRecording())
28
29type Project = { root: string; slug: string }
30
31async function readProject($: EngineInterface): Promise<Project> {
32 const repo = await $.session.repo().catch(() => null)
33 const root = repo?.root ?? (await $.session.cwd())
34 return { root, slug: projectSlug(root) }
35}
36
37const trackKey = (project: Project) => `track:${project.root}`
38const isTracked = async ($: EngineInterface, project: Project) => (await $.store.get(trackKey(project))) === true
39
40async function observabilityDir($: EngineInterface): Promise<string> {
41 const configured = await $.env.get('CLAUDE_CONFIG_DIR')
42 if (configured !== undefined) return `${configured}/observability`
43 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
44 return `${home}/.claude/observability`
45}
46
47const ledgerFile = async ($: EngineInterface, project: Project) => `${await observabilityDir($)}/ledger/${project.slug}.jsonl`
48
49async function appendRow($: EngineInterface, file: string, row: TurnRow) {
50 const existing = (await $.fs.exists(file)) ? await $.fs.read(file) : ''
51 if (existing.length < LEDGER_CAP) return $.fs.write(file, appendLine(existing, row))
52 await $.fs.write(file.replace(/\.jsonl$/, `.${Date.parse(row.at)}.jsonl`), existing)
53 await $.fs.write(file, appendLine('', row))
54}
55
56// The board shows the last 90 days of every project's ledgers, rolled-aside ones included.
57async function writeBoard($: EngineInterface): Promise<string> {
58 const dir = await observabilityDir($)
59 const entries = await $.fs.list(`${dir}/ledger`).catch(() => [])
60 const ledgers = entries.filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl'))
61 const texts = await Promise.all(ledgers.map(entry => $.fs.read(`${dir}/ledger/${entry.name}`).catch(() => '')))
62 const now = await $.clock.now()
63 const rows = texts.flatMap(text => rowsSince(text, now - BOARD_DAYS * DAY_MS))
64 const file = `${dir}/board.html`
65 await $.fs.write(file, boardHtml(rows, now))
66 return file
67}
68
69async function openFile($: EngineInterface, file: string) {
70 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
71 if (isWindows) return $.process.run(['cmd', '/c', 'start', '', file.replace(/\//g, '\\')])
72 const isMac = (await $.process.run(['uname'])).stdout.trim() === 'Darwin'
73 return $.process.run([isMac ? 'open' : 'xdg-open', file])
74}
75
76// What each `/observability:track` argument sets; undefined only reports.
77const TRACK_CHOICES: Record<string, boolean | undefined> = { on: true, off: false, status: undefined, '': undefined }
78
79async function trackCommand($: EngineInterface, args: string): Promise<{ text: string }> {
80 const choice = args.trim()
81 if (!Object.hasOwn(TRACK_CHOICES, choice)) return { text: 'Usage: /observability:track on|off|status' }
82 const project = await readProject($)
83 const tracked = TRACK_CHOICES[choice]
84 if (tracked !== undefined) await $.store.set(trackKey(project), tracked)
85 return { text: await trackingStatus($, project) }
86}
87
88async function trackingStatus($: EngineInterface, project: Project): Promise<string> {
89 if (!(await isTracked($, project))) return `observability: not recording turns for ${project.root}`
90 return `observability: recording turns for ${project.root}, into ${await ledgerFile($, project)}`
91}
92
93async function boardCommand($: EngineInterface): Promise<{ text: string }> {
94 const file = await writeBoard($)
95 await openFile($, file)
96 return { text: `observability: board written to ${file}` }
97}
98
99let pendingBoard: Timer | undefined
100
101function scheduleBoard($: EngineInterface) {
102 if (pendingBoard !== undefined) return
103 pendingBoard = $.clock.after(2000, () => {
104 pendingBoard = undefined
105 void writeBoard($).catch(() => $.ui.status('observability: could not rebuild the board'))
106 })
107}
108
109export const register: Register = on => {
110 on('session.start', async ($, e, next) => {
111 await $.command.register({ name: 'track', description: 'Record this project’s turns into the observability ledger', argumentHint: 'on|off|status' })
112 await $.command.register({ name: 'board', description: 'Build the observability board from every tracked project and open it' })
113 const usage = await $.session.usage()
114 await update($, recording, current => ({ ...current, costUsd: usage.cost?.usd ?? current.costUsd }))
115 return next(e)
116 }).catch(($, e, next) => next(e))
117
118 on('command.run', { command: 'track' }, ($, e) => trackCommand($, e.args)).catch(() => ({ text: 'observability: track failed' }))
119
120 on('command.run', { command: 'board' }, $ => boardCommand($)).catch(() => ({ text: 'observability: could not build or open the board' }))
121
122 on('prompt.submit', async ($, e, next) => {
123 const text = e.text.trim()
124 if (e.origin.kind !== 'plugin' && e.command === undefined && text !== '') await update($, recording, current => notePrompt(current, text))
125 return next(e)
126 }).catch(($, e, next) => next(e))
127
128 on('turn.step', async function* ($, e, next) {
129 const startedAt = await $.clock.now()
130 const result = yield* next(e)
131 const latencyMs = (await $.clock.now()) - startedAt
132 await update($, recording, current => addStep(current, { model: e.model, latencyMs, usage: tokensOf(result.usage) }))
133 return result
134 }).catch(async function* ($, e, next) {
135 return yield* next(e)
136 })
137
138 on('tool.call', async ($, e, next) => {
139 const startedAt = await $.clock.now()
140 const result = await next(e)
141 const ms = (await $.clock.now()) - startedAt
142 const call = { tool: e.tool, ms, isError: result.isError === true, isDenied: result.deny !== undefined }
143 await update($, recording, current => addToolCall(current, call))
144 return result
145 }).catch(($, e, next) => next(e))
146
147 on('agent.spawn', async ($, e, next) => {
148 await update($, recording, current => addAgent(current, e.subagentType))
149 return next(e)
150 }).catch(($, e, next) => next(e))
151
152 on('session.compact', async ($, e, next) => {
153 await update($, recording, current => ({ ...current, compacted: true }))
154 return next(e)
155 }).catch(($, e, next) => next(e))
156
157 on('turn.complete', async ($, e, next) => {
158 if (e.agentId !== undefined) {
159 await update($, recording, current => addSubagentUsage(current, tokensOf(e.usage)))
160 return next(e)
161 }
162 const project = await readProject($)
163 const sessionUsage = await $.session.usage()
164 const end = {
165 at: new Date(await $.clock.now()).toISOString(),
166 project: project.slug,
167 session: await $.session.id(),
168 startedAt: sessionUsage.startedAt,
169 turn: await $.session.turns(),
170 reason: e.reason,
171 durationMs: e.durationMs,
172 usage: tokensOf(e.usage),
173 sessionUsage,
174 model: await $.session.model(),
175 version: (await $.session.version()).version,
176 }
177 let row: TurnRow | undefined
178 await update($, recording, current => {
179 row = foldTurn(current, end)
180 return nextRecording(current, row.costUsd)
181 })
182 const result = await next(e)
183 if (row !== undefined && (await isTracked($, project))) {
184 const { value } = await $.state.get({ plugin: 'quality-gate', key: 'turn' })
185 const quality = qualityForTurn(value?.[await $.session.cwd()], row)
186 if (quality !== undefined) row.quality = quality
187 await appendRow($, await ledgerFile($, project), row).catch(() => $.ui.status('observability: could not write the ledger'))
188 scheduleBoard($)
189 }
190 return result
191 }).catch(($, e, next) => next(e))
192
193 // A /clear starts a new task: its name and ticket come from the next prompt.
194 on('session.end', async ($, e, next) => {
195 if (e.reason === 'clear') await update($, recording, current => freshRecording(current.costUsd))
196 return next(e)
197 }).catch(($, e, next) => next(e))
198}
199lib/board.mjs 817 lines1// The observability board: one self-contained HTML page built from ledger rows.
2// No server, no network, no CDN: inline CSS, inline SVG and one script.
3// The folds below run in Node for tests and are inlined into the page through toString(),
4// so the page and the tests read the same code.
5
6export function dayKey(ms) {
7 const date = new Date(ms)
8 return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0')
9}
10
11// Oldest first, ending today; calendar steps so a DST change never skips or repeats a day.
12export function dayKeys(now, count) {
13 const keys = []
14 for (let back = count - 1; back >= 0; back--) {
15 const date = new Date(now)
16 date.setDate(date.getDate() - back)
17 keys.push(dayKey(date.getTime()))
18 }
19 return keys
20}
21
22export function finite(value) {
23 return Number.isFinite(value) ? value : 0
24}
25
26export function sum(values) {
27 let total = 0
28 for (const value of values) total += value
29 return total
30}
31
32export function usageTokens(usage) {
33 const counts = usage || {}
34 return finite(counts.input) + finite(counts.output) + finite(counts.cacheRead) + finite(counts.cacheWrite)
35}
36
37export function contextPercent(row) {
38 const context = row.context || {}
39 if (Number.isFinite(context.percent)) return context.percent
40 return context.tokens > 0 && context.window > 0 ? (context.tokens / context.window) * 100 : 0
41}
42
43export function modelRequests(row) {
44 let requests = 0
45 for (const stats of Object.values(row.models || {})) requests += finite(stats.requests)
46 return requests
47}
48
49export function agentSpawns(row) {
50 return sum(Object.values(row.agents || {}).map(finite))
51}
52
53export function addTicket(tickets, ticket) {
54 if (ticket && !tickets.includes(ticket)) tickets.push(ticket)
55}
56
57export function startSession(row) {
58 return {
59 name: '',
60 project: row.project,
61 model: row.model,
62 turns: 0,
63 requests: 0,
64 costUsd: 0,
65 durationMs: 0,
66 tokens: 0,
67 contextPeak: 0,
68 outcome: row.reason,
69 tickets: [],
70 compactions: 0,
71 agents: 0,
72 started: row.startedAt,
73 ended: -Infinity,
74 }
75}
76
77export function foldSessionQuality(session, row) {
78 if (!row.quality) return
79 const quality = session.quality || { ccMax: 0, split: false, ended: -Infinity }
80 quality.ccMax = Math.max(quality.ccMax, row.quality.ccMax)
81 quality.split = quality.split || row.quality.split > 0
82 const at = Date.parse(row.at)
83 if (at >= quality.ended) Object.assign(quality, { ended: at, tests: row.quality.tests })
84 session.quality = quality
85}
86
87// Sums every turn; name, model, cost and outcome come from the newest turn, since cost is the session total.
88export function foldSessionRow(session, row) {
89 const current = session || startSession(row)
90 foldSessionQuality(current, row)
91 current.turns += 1
92 current.requests += modelRequests(row)
93 current.durationMs += finite(row.durationMs)
94 current.tokens += usageTokens(row.usage)
95 current.contextPeak = Math.max(current.contextPeak, contextPercent(row))
96 current.compactions += row.compacted ? 1 : 0
97 current.agents += agentSpawns(row)
98 addTicket(current.tickets, row.ticket)
99 const at = Date.parse(row.at)
100 if (at >= current.ended) Object.assign(current, { ended: at, name: row.name || current.name, model: row.model, costUsd: finite(row.costUsd), outcome: row.reason })
101 return current
102}
103
104// One entry per session + startedAt, newest first.
105export function sessionsFrom(rows) {
106 const sessions = new Map()
107 for (const row of rows) {
108 const key = row.session + '@' + row.startedAt
109 sessions.set(key, foldSessionRow(sessions.get(key), row))
110 }
111 return [...sessions.values()].sort(
112 (a, b) => b.started - a.started,
113 )
114}
115
116export function accumulate(totals, name, stats, fields) {
117 const entry = totals[name] || (totals[name] = { name })
118 for (const field of fields) entry[field] = (entry[field] || 0) + finite(stats[field])
119}
120
121export function finishModel(model) {
122 const inputSide = model.input + model.cacheRead + model.cacheWrite
123 return Object.assign(model, {
124 tokens: usageTokens(model),
125 meanLatencyMs: model.requests > 0 ? model.latencyMs / model.requests : 0,
126 cacheShare: inputSide > 0 ? model.cacheRead / inputSide : 0,
127 })
128}
129
130export function modelMix(rows) {
131 const models = {}
132 const fields = ['requests', 'latencyMs', 'input', 'output', 'cacheRead', 'cacheWrite']
133 for (const row of rows) for (const [name, stats] of Object.entries(row.models || {})) accumulate(models, name, stats, fields)
134 return Object.values(models).map(finishModel).sort(
135 (a, b) => b.tokens - a.tokens,
136 )
137}
138
139export function toolRanking(rows) {
140 const tools = {}
141 for (const row of rows) for (const [name, stats] of Object.entries(row.tools || {})) accumulate(tools, name, stats, ['calls', 'errors', 'denies', 'ms'])
142 const ranked = Object.values(tools).sort(
143 (a, b) => b.calls - a.calls,
144 )
145 return ranked.slice(0, 10)
146}
147
148export function ticketTotals(rows) {
149 const tickets = {}
150 for (const row of rows) {
151 if (row.ticket) accumulate(tickets, row.ticket, { costUsd: row.turnCostUsd, durationMs: row.durationMs, turns: 1, tokens: usageTokens(row.usage) }, ['costUsd', 'durationMs', 'turns', 'tokens'])
152 }
153 return Object.values(tickets).sort(
154 (a, b) => b.costUsd - a.costUsd,
155 )
156}
157
158export function perDay(rows, keys, pick) {
159 const values = new Array(keys.length).fill(0)
160 for (const row of rows) {
161 const position = keys.indexOf(dayKey(Date.parse(row.at)))
162 if (position >= 0) values[position] += pick(row)
163 }
164 return values
165}
166
167// A streak still counts when today has no turn yet: it runs up to yesterday.
168export function currentStreak(values) {
169 let streak = 0
170 for (let day = values[values.length - 1] > 0 ? values.length - 1 : values.length - 2; day >= 0 && values[day] > 0; day--) streak++
171 return streak
172}
173
174export function streaks(values) {
175 let run = 0
176 let longest = 0
177 let active = 0
178 for (const value of values) {
179 run = value > 0 ? run + 1 : 0
180 active += value > 0 ? 1 : 0
181 longest = Math.max(longest, run)
182 }
183 return { active, longest, current: currentStreak(values) }
184}
185
186// Rate limits are account-wide, so only the newest turn of any project speaks for them.
187export function newestRateLimits(rows) {
188 if (!rows.length) return []
189 let newest = rows[0]
190 for (const row of rows) if (row.at > newest.at) newest = row
191 return newest.rateLimits || []
192}
193
194export function resetMs(value) {
195 if (typeof value === 'string') return Date.parse(value)
196 return value < 1e12 ? value * 1000 : value
197}
198
199const SHARED = [
200 dayKey, dayKeys, finite, sum, usageTokens, contextPercent, modelRequests, agentSpawns, addTicket, startSession,
201 foldSessionQuality, foldSessionRow, sessionsFrom, accumulate, finishModel, modelMix, toolRanking, ticketTotals, perDay, currentStreak,
202 streaks, newestRateLimits, resetMs,
203]
204
205const DARK = 'color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#f0efec;--ink-2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--axis:#383835;--border:rgba(255,255,255,.10);--hover:rgba(255,255,255,.06);--seg-on:#2c2c2a;--tip:#0d0d0d;--s1:#3987e5;--s2:#d95926;--s3:#199e70;--s4:#c98500;--s5:#d55181;--s6:#008300;--s7:#9085e9;--s8:#e66767;--s-other:#6b6a64;--good-ink:#0ca30c;--bad-ink:#e66767;--track:#0d366b;--h0:#242423;--h1:#104281;--h2:#1c5cab;--h3:#2a78d6;--h4:#5598e7;--h5:#9ec5f4;--shadow:none'
206
207const CSS = `:root{color-scheme:light;--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink-2:#52514e;--muted:#898781;--grid:#e1e0d9;--axis:#c3c2b7;--border:rgba(11,11,11,.10);--hover:rgba(11,11,11,.05);--seg-on:#fff;--tip:#fff;--s1:#2a78d6;--s2:#eb6834;--s3:#1baf7a;--s4:#eda100;--s5:#e87ba4;--s6:#008300;--s7:#6250d6;--s8:#e34948;--s-other:#a3a29b;--good:#0ca30c;--warning:#fab219;--serious:#ec835a;--critical:#d03b3b;--good-ink:#006300;--bad-ink:#b02a2a;--track:#cde2fb;--h0:#eeede8;--h1:#cde2fb;--h2:#9ec5f4;--h3:#5598e7;--h4:#256abf;--h5:#104281;--shadow:0 1px 2px rgba(11,11,11,.04)}
208:root[data-theme=dark]{${DARK}}
209@media (prefers-color-scheme:dark){:root[data-theme=system]{${DARK}}}
210*{box-sizing:border-box}html,body{margin:0}[hidden]{display:none!important}
211body{background:var(--page);color:var(--ink);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
212button{font:inherit;color:inherit}
213:focus-visible{outline:2px solid var(--s1);outline-offset:2px}
214.num,td.r,th.r,.axis{font-variant-numeric:tabular-nums}
215.app{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:100vh}
216.side{border-right:1px solid var(--border);padding:22px 14px;display:flex;flex-direction:column;gap:2px;position:sticky;top:0;height:100vh;overflow-y:auto}
217.brand{display:flex;align-items:center;gap:10px;font-weight:650;font-size:15px;padding:0 10px 22px}
218.mark{width:22px;height:22px;flex:none;color:var(--s1)}
219.side-label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:6px 10px}
220.nav-item{display:flex;align-items:center;gap:10px;width:100%;border:0;background:transparent;color:var(--ink-2);padding:7px 10px;border-radius:8px;cursor:pointer;text-align:left}
221.nav-item .name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
222.nav-item:hover{background:var(--hover)}.nav-item.on{background:var(--hover);color:var(--ink);font-weight:550}
223.nav-item .n{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
224.swatch{width:8px;height:8px;border-radius:50%;flex:none;background:var(--c)}
225.side-foot{margin-top:auto;display:flex;align-items:center;gap:10px;font-size:12px;color:var(--muted);padding:12px 2px 0 10px;border-top:1px solid var(--border)}.foot-text{flex:1;min-width:0}
226.theme{display:grid;place-items:center;width:32px;height:32px;flex:none;border:0;border-radius:8px;background:transparent;color:var(--ink-2);cursor:pointer}.theme:hover{background:var(--hover)}.theme svg{display:none}
227.theme[data-choice=system] .i-system,.theme[data-choice=light] .i-light,.theme[data-choice=dark] .i-dark{display:block}
228main{padding:24px 32px 56px;min-width:0;max-width:1440px;width:100%;margin:0 auto}
229.top{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;margin-bottom:20px;flex-wrap:wrap}
230.crumb{color:var(--muted);font-size:13px}.crumb b{color:var(--ink-2);font-weight:500}
231h1{margin:2px 0 0;font-size:26px;font-weight:650;letter-spacing:-.01em;overflow-wrap:anywhere}.top p{margin:2px 0 0;color:var(--ink-2)}
232h2{margin:0 0 4px;font-size:18px;font-weight:600}
233h3{margin:0;font-size:15px;font-weight:600}h3 .n{color:var(--muted);font-weight:400;margin-left:6px}
234.seg{display:inline-flex;background:var(--hover);border:1px solid var(--border);border-radius:10px;padding:3px;flex:none;flex-wrap:wrap}
235.seg button{border:0;background:transparent;color:var(--ink-2);font-size:13px;padding:5px 12px;border-radius:7px;cursor:pointer}
236.seg button.on{background:var(--seg-on);color:var(--ink);box-shadow:0 0 0 1px var(--border),0 1px 2px rgba(0,0,0,.06)}
237.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow);margin-bottom:14px}
238.sub{font-size:12px;color:var(--muted)}
239.empty{padding:28px}.empty p{margin:0;color:var(--ink-2)}
240.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:14px}.kpis .card{margin:0}
241.kpi{padding:16px 18px 12px;min-width:0}
242.kpi-label{color:var(--ink-2);font-size:13px}
243.kpi-value{font-size:28px;font-weight:600;line-height:1.2;margin-top:2px;white-space:nowrap}
244.delta{font-size:12px;font-weight:500;margin-left:8px;color:var(--ink-2);white-space:nowrap}.delta.good{color:var(--good-ink)}.delta.bad{color:var(--bad-ink)}
245.kpi-note{color:var(--muted);font-size:12px}
246.spark{display:block;width:100%;height:36px;margin-top:8px}
247.card-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap;padding:18px 20px 0}
248.quota .meters{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:18px 28px;padding:14px 20px 18px}
249.meter-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}.meter-head b{font-size:22px;font-weight:600}
250.bar-track{height:8px;border-radius:4px;background:var(--track);overflow:hidden;margin:6px 0 6px}
251.bar-fill{height:100%;border-radius:4px;background:var(--s1)}
252.meter.warning .bar-fill{background:var(--warning)}.meter.critical .bar-fill{background:var(--critical)}
253.meter-foot{font-size:12px;color:var(--muted);display:flex;gap:6px;align-items:center}
254.status-icon{font-style:normal;font-weight:700}.meter.warning .status-icon{color:var(--warning)}.meter.critical .status-icon{color:var(--critical)}
255.chart-card{padding:18px 20px 16px}
256.chart-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap}
257.label{color:var(--ink-2);font-size:13px}.big{font-size:44px;font-weight:600;line-height:1.1;margin-top:2px}
258.chart-wrap{position:relative;margin-top:10px}
259#stream-chart{display:block;width:100%;height:280px;overflow:visible;cursor:crosshair}
260.axis{fill:var(--muted);font-size:11px}.gridline{stroke:var(--grid);stroke-width:1}.baseline{stroke:var(--axis);stroke-width:1}
261.crosshair{stroke:var(--ink-2);stroke-width:1}.empty-chart{fill:var(--muted);font-size:13px}
262.tip{position:absolute;pointer-events:none;background:var(--tip);color:var(--ink);border:1px solid var(--border);border-radius:10px;padding:8px 10px;font-size:12px;box-shadow:0 8px 24px rgba(0,0,0,.18);min-width:150px;z-index:2}
263.tip-head{color:var(--ink-2);margin-bottom:2px}.tip-total{font-weight:600;font-size:13px;margin-bottom:4px}
264.tip-row{display:flex;align-items:center;gap:6px;white-space:nowrap}.tip-row .name{flex:1;max-width:180px;overflow:hidden;text-overflow:ellipsis}.tip-row .v{font-variant-numeric:tabular-nums;margin-left:12px}
265.legend{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}
266.chip{display:inline-flex;align-items:center;gap:7px;padding:4px 10px;border-radius:999px;background:transparent;border:1px solid var(--border);color:var(--ink);font-size:12px;cursor:pointer;max-width:260px}
267.chip .label-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chip[aria-pressed=false]{color:var(--muted)}.chip[aria-pressed=false] .swatch{background:transparent;box-shadow:inset 0 0 0 1.5px var(--c)}
268.table-view{margin-top:10px;font-size:13px}.table-view summary{cursor:pointer;color:var(--ink-2);width:max-content}.table-view .table-wrap{max-height:320px;overflow:auto;margin-top:8px}
269.grid2{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:14px;align-items:start}.grid2>.card{margin-bottom:14px}
270.list{padding:18px 0 6px}
271.list-head{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:0 20px 12px;flex-wrap:wrap}
272.key{display:flex;gap:12px;font-size:12px;color:var(--muted)}.key span{display:inline-flex;align-items:center;gap:5px}.key i{width:8px;height:8px;border-radius:2px;background:var(--c)}
273.search{display:flex;align-items:center;gap:8px;background:var(--hover);border:1px solid var(--border);border-radius:9px;padding:5px 10px;width:240px;color:var(--muted)}
274.search input{border:0;outline:0;background:transparent;color:var(--ink);font:inherit;font-size:13px;width:100%}
275.table-wrap{overflow-x:auto}
276table{width:100%;border-collapse:collapse;font-size:13px}
277th{text-align:left;font-weight:400;color:var(--muted);font-size:12px;padding:0 10px 8px;white-space:nowrap}
278th:first-child,td:first-child{padding-left:20px}th:last-child,td:last-child{padding-right:20px}
279td{padding:9px 10px;border-top:1px solid var(--grid);white-space:nowrap;vertical-align:middle}
280th.r,td.r{text-align:right}td.dim{color:var(--muted)}td.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
281td.wrap{white-space:normal;min-width:220px;max-width:380px}.title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
282.inline-bar{display:flex;align-items:center;gap:8px;min-width:150px}.inline-bar .bar-track{flex:1;margin:0;height:6px;background:transparent}.inline-bar .v{min-width:56px;text-align:right;font-variant-numeric:tabular-nums}
283.stack{display:flex;gap:2px;height:6px}.stack i{display:block;height:100%;background:var(--c)}.stack i:first-child{border-radius:3px 0 0 3px}.stack i:last-child{border-radius:0 3px 3px 0}.stack i:only-child{border-radius:3px}
284.mini{display:inline-flex;align-items:center;gap:8px}.mini .bar-track{width:56px;height:6px;margin:0}
285.flag{display:inline-flex;align-items:center;gap:5px}.flag i{font-style:normal;font-weight:700;color:var(--c)}
286.pill{display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:2px 9px;border-radius:999px;border:1px solid var(--border);color:var(--ink-2)}
287.pill i{font-style:normal;font-weight:700;color:var(--c,var(--muted))}
288td.empty-row{text-align:center;color:var(--muted);padding:28px}
289.heat{padding:18px 20px}
290.heat-grid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:28px;align-items:start}
291#heat{display:block;max-width:100%;height:auto;margin-top:12px}.hx{stroke:var(--surface);stroke-width:2}.hx0{fill:var(--h0)}.hx1{fill:var(--h1)}.hx2{fill:var(--h2)}.hx3{fill:var(--h3)}.hx4{fill:var(--h4)}.hx5{fill:var(--h5)}.day{fill:var(--muted);font-size:11px}
292.heroes{display:grid;grid-template-columns:1fr 1fr;gap:16px}.hero-label{color:var(--ink-2);font-size:12px}.hero-value{font-size:30px;font-weight:600;line-height:1.2}.hero-sub{font-size:12px;color:var(--muted)}
293#facts{margin-top:14px}.fact{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-top:1px solid var(--grid);color:var(--ink-2);font-size:13px}.fact span:last-child{color:var(--ink);font-variant-numeric:tabular-nums;text-align:right}
294footer{color:var(--muted);font-size:12px;margin-top:20px}
295@media (max-width:1280px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}
296@media (max-width:1100px){.grid2,.heat-grid{grid-template-columns:minmax(0,1fr)}}
297@media (max-width:860px){.app{grid-template-columns:minmax(0,1fr)}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--border);padding:12px 16px}
298.side-label,.foot-text{display:none}.side-foot{margin:0 0 0 auto;padding:0;border:0}.brand{padding:2px 10px 2px 0}#projects{display:flex;gap:4px;flex-wrap:wrap;width:100%;order:3}.nav-item{width:auto;max-width:220px}
299main{padding:18px 16px 40px}.big{font-size:34px}}
300@media (max-width:520px){.kpis{grid-template-columns:minmax(0,1fr)}.top{align-items:stretch}.top .seg{align-self:flex-start}.search{width:100%}.list-head{padding:0 16px 12px}th:first-child,td:first-child{padding-left:16px}th:last-child,td:last-child{padding-right:16px}.chart-card,.heat{padding:16px}.big{font-size:30px}#stream-chart{height:220px}.heroes{grid-template-columns:1fr}}`
301
302// Everything that reaches the DOM from the data goes through textContent or setAttribute, never as HTML.
303const APP = String.raw`
304var data = JSON.parse(document.getElementById('board-data').textContent)
305var now = data.now
306var rows = data.rows.slice().sort(function (a, b) { return a.at < b.at ? -1 : a.at > b.at ? 1 : 0 })
307var NS = 'http://www.w3.org/2000/svg'
308var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
309var WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
310var SLOTS = 8
311var state = { project: '', period: 30, metric: 'cost', query: '', hidden: {} }
312// Colour follows the project, by first appearance, so a filter never repaints a survivor.
313var seen = Array.from(new Set(rows.map(function (r) { return r.project })))
314
315function byId(id) { return document.getElementById(id) }
316function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e }
317function svgEl(tag, attrs, parent) { var e = document.createElementNS(NS, tag); for (var k in attrs) e.setAttribute(k, attrs[k]); if (parent) parent.append(e); return e }
318function two(n) { return (n < 10 ? '0' : '') + n }
319function colorOf(project) { var i = seen.indexOf(project); return i >= 0 && i < SLOTS ? 'var(--s' + (i + 1) + ')' : 'var(--s-other)' }
320function swatch(color) { var s = el('span', 'swatch'); s.style.setProperty('--c', color); return s }
321function plural(n, word) { return n + ' ' + word + (n === 1 ? '' : 's') }
322function money(n) { return n > 0 && n < 0.01 ? '<$0.01' : '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
323function compact(n) {
324 var a = Math.abs(n)
325 if (a < 1000) return String(Math.round(n))
326 if (a < 1e6) return +(n / 1e3).toFixed(1) + 'K'
327 if (a < 1e9) return +(n / 1e6).toFixed(1) + 'M'
328 return +(n / 1e9).toFixed(1) + 'B'
329}
330function duration(ms) {
331 var s = Math.round(ms / 1000)
332 if (s < 60) return s + 's'
333 if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's'
334 var m = Math.round(s / 60)
335 if (m >= 2880) return Math.floor(m / 1440) + 'd ' + Math.floor(m % 1440 / 60) + 'h'
336 return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
337}
338function latency(ms) { return ms < 1000 ? Math.round(ms) + ' ms' : (ms / 1000).toFixed(1) + ' s' }
339function percent(share) { return Math.round(share * 100) + '%' }
340function shortDate(key) { return MONTHS[+key.slice(5, 7) - 1] + ' ' + +key.slice(8, 10) }
341function clock(ms) { var d = new Date(ms); return two(d.getHours()) + ':' + two(d.getMinutes()) }
342function stamp(ms) { return shortDate(dayKey(ms)) + ', ' + clock(ms) }
343function sessionKey(r) { return r.session + '@' + r.startedAt }
344
345var METRICS = {
346 cost: { label: 'Cost', pick: function (r) { return finite(r.turnCostUsd) }, format: money },
347 time: { label: 'Agent time', pick: function (r) { return finite(r.durationMs) }, format: duration },
348 tokens: { label: 'Tokens', pick: function (r) { return usageTokens(r.usage) }, format: compact },
349 turns: { label: 'Turns', pick: function () { return 1 }, format: compact }
350}
351
352function scoped() { return state.project ? rows.filter(function (r) { return r.project === state.project }) : rows }
353function within(list, keys) { var set = new Set(keys); return list.filter(function (r) { return set.has(dayKey(Date.parse(r.at))) }) }
354function periodText() { return state.period === 90 ? 'last 90 days, all the ledger keeps' : 'last ' + state.period + ' days' }
355function sessionCount(list) { return new Set(list.map(sessionKey)).size }
356function sessionsPerDay(list, keys) {
357 var sets = keys.map(function () { return new Set() }), index = new Map(keys.map(function (k, i) { return [k, i] }))
358 list.forEach(function (r) { var i = index.get(dayKey(Date.parse(r.at))); if (i !== undefined) sets[i].add(sessionKey(r)) })
359 return sets.map(function (s) { return s.size })
360}
361function projectsIn(list) { var present = new Set(list.map(function (r) { return r.project })); return seen.filter(function (p) { return present.has(p) }) }
362
363// Monotone cubic through the points (as d3's curveMonotoneX): never overshoots a day's value.
364function smooth(p) {
365 var n = p.length, m = [], d = 'M' + p[0][0].toFixed(1) + ',' + p[0][1].toFixed(1)
366 function slope(i) { return (p[i + 1][1] - p[i][1]) / (p[i + 1][0] - p[i][0]) }
367 if (n < 3) return n < 2 ? d : d + 'L' + p[1][0].toFixed(1) + ',' + p[1][1].toFixed(1)
368 for (var i = 1; i < n - 1; i++) {
369 var s0 = slope(i - 1), s1 = slope(i)
370 m[i] = (Math.sign(s0) + Math.sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), Math.abs(s0 + s1) / 4) || 0
371 }
372 m[0] = (3 * slope(0) - m[1]) / 2; m[n - 1] = (3 * slope(n - 2) - m[n - 2]) / 2
373 for (i = 0; i < n - 1; i++) {
374 var dx = (p[i + 1][0] - p[i][0]) / 3
375 d += 'C' + (p[i][0] + dx).toFixed(1) + ',' + (p[i][1] + m[i] * dx).toFixed(1) + ' ' + (p[i + 1][0] - dx).toFixed(1) + ',' + (p[i + 1][1] - m[i + 1] * dx).toFixed(1) + ' ' + p[i + 1][0].toFixed(1) + ',' + p[i + 1][1].toFixed(1)
376 }
377 return d
378}
379function niceMax(v) {
380 if (!(v > 0)) return 1
381 var step = Math.pow(10, Math.floor(Math.log10(v))), steps = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]
382 for (var i = 0; i < steps.length; i++) if (steps[i] * step >= v) return steps[i] * step
383 return 10 * step
384}
385
386function sidebar() {
387 var nav = byId('projects'), counts = {}, sessions = sessionsFrom(rows); nav.textContent = ''
388 sessions.forEach(function (s) { counts[s.project] = (counts[s.project] || 0) + 1 })
389 var latest = {}; rows.forEach(function (r) { latest[r.project] = r.at })
390 var order = seen.slice().sort(function (a, b) { return latest[b] < latest[a] ? -1 : latest[b] > latest[a] ? 1 : 0 })
391 ;[['', 'All projects', sessions.length]].concat(order.map(function (p) { return [p, p, counts[p] || 0] })).forEach(function (item) {
392 var b = el('button', 'nav-item' + (state.project === item[0] ? ' on' : '')); b.type = 'button'; b.title = item[1]
393 b.setAttribute('aria-pressed', String(state.project === item[0]))
394 if (item[0]) b.append(swatch(colorOf(item[0])))
395 b.append(el('span', 'name', item[1]), el('span', 'n', String(item[2])))
396 b.addEventListener('click', function () { state.project = item[0]; state.hidden = {}; render() })
397 nav.append(b)
398 })
399 byId('generated').textContent = 'Local board · ' + stamp(now)
400}
401
402function header(current) {
403 var name = state.project || 'All projects'
404 byId('crumb').textContent = name
405 byId('title').textContent = name
406 byId('subtitle').textContent = plural(sessionCount(current), 'session') + ' · ' + plural(current.length, 'turn') + ' · ' + periodText()
407}
408
409function renderEmpty(current) {
410 var box = byId('empty')
411 box.hidden = current.length > 0
412 byId('empty-title').textContent = rows.length ? 'No turns in the ' + periodText() : 'Nothing recorded yet'
413 byId('empty-text').textContent = rows.length ? 'Pick a longer period or another project.' : 'Run /observability:track on in a project. Every finished turn there lands on this board.'
414}
415
416function sparkline(values, color) {
417 var W = 120, H = 32, s = svgEl('svg', { viewBox: '0 0 120 32', preserveAspectRatio: 'none', 'class': 'spark', 'aria-hidden': 'true' })
418 var max = Math.max.apply(null, values.concat([0]))
419 var pts = values.map(function (v, i) { return [values.length > 1 ? i * W / (values.length - 1) : W / 2, max > 0 ? H - 3 - v / max * (H - 6) : H - 3] })
420 var line = smooth(pts)
421 svgEl('path', { d: line + 'L' + W + ',' + H + 'L0,' + H + 'Z', style: 'fill:' + color + ';fill-opacity:.1' }, s)
422 svgEl('path', { d: line, fill: 'none', style: 'stroke:' + color, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', 'vector-effect': 'non-scaling-stroke' }, s)
423 return s
424}
425
426var KPIS = [
427 { label: 'Cost', metric: 'cost', upIsBad: true },
428 { label: 'Agent time', metric: 'time' },
429 { label: 'Tokens', metric: 'tokens' },
430 { label: 'Sessions', total: sessionCount, daily: sessionsPerDay, format: String }
431]
432function renderKpis(list, keys, current) {
433 var wrap = byId('kpis'), previous = within(list, dayKeys(now, state.period * 2).slice(0, state.period)); wrap.textContent = ''
434 wrap.hidden = !rows.length
435 KPIS.forEach(function (k) {
436 var M = METRICS[k.metric]
437 var total = k.total || function (l) { return sum(l.map(M.pick)) }
438 var daily = k.daily || function (l, ks) { return perDay(l, ks, M.pick) }
439 var format = k.format || M.format
440 var v = total(current), p = total(previous)
441 var tile = el('div', 'card kpi'), value = el('div', 'kpi-value', format(v))
442 if (p > 0) {
443 var pct = Math.round((v - p) / p * 100)
444 var tone = !k.upIsBad || pct === 0 ? '' : pct > 0 ? ' bad' : ' good'
445 value.append(el('span', 'delta' + tone, (pct > 0 ? '↑ ' : pct < 0 ? '↓ ' : '') + Math.abs(pct) + '%'))
446 }
447 tile.append(el('div', 'kpi-label', k.label), value, el('div', 'kpi-note', p > 0 ? 'vs the ' + state.period + ' days before' : periodText()), sparkline(daily(list, keys), 'var(--s1)'))
448 wrap.append(tile)
449 })
450}
451
452var QUOTA_LABELS = { five_hour: '5-hour window', seven_day: '7-day window', seven_day_opus: '7-day Opus window', seven_day_sonnet: '7-day Sonnet window' }
453function renderQuota() {
454 var limits = newestRateLimits(rows), box = byId('quota-meters'); box.textContent = ''
455 byId('quota').hidden = !limits.length
456 if (!limits.length) return
457 var newest = rows[rows.length - 1]
458 byId('quota-sub').textContent = 'As of the last turn, ' + stamp(Date.parse(newest.at))
459 limits.forEach(function (limit) {
460 var used = Math.max(0, Math.min(100, finite(limit.percentUsed)))
461 var level = used >= 90 ? 'critical' : used >= 70 ? 'warning' : 'normal'
462 var meter = el('div', 'meter ' + level), head = el('div', 'meter-head'), track = el('div', 'bar-track'), fill = el('div', 'bar-fill'), foot = el('div', 'meter-foot')
463 track.setAttribute('role', 'meter'); track.setAttribute('aria-valuemin', '0'); track.setAttribute('aria-valuemax', '100'); track.setAttribute('aria-valuenow', String(Math.round(used)))
464 var label = QUOTA_LABELS[limit.kind] || String(limit.kind).replace(/_/g, ' ')
465 track.setAttribute('aria-label', label)
466 head.append(el('span', 'label', label), el('b', '', Math.round(used) + '%'))
467 fill.style.width = used + '%'; track.append(fill)
468 if (level !== 'normal') foot.append(el('i', 'status-icon', level === 'critical' ? '!' : '▲'), el('span', '', level === 'critical' ? 'Near the limit.' : 'Getting close.'))
469 var at = resetMs(limit.resetsAt)
470 foot.append(el('span', '', Number.isFinite(at) ? (at > now ? 'Resets in ' + duration(at - now) + ', ' : 'Reset ') + WEEKDAYS[new Date(at).getDay()] + ' ' + clock(at) : 'Reset time not reported'))
471 meter.append(head, track, foot)
472 box.append(meter)
473 })
474}
475
476function streamGroups(projects) {
477 var groups = projects.filter(function (p) { return seen.indexOf(p) < SLOTS }).map(function (p) { return { name: p, color: colorOf(p), members: [p] } })
478 var rest = projects.filter(function (p) { return seen.indexOf(p) >= SLOTS })
479 if (rest.length) groups.push({ name: 'Other (' + rest.length + ')', color: 'var(--s-other)', members: rest })
480 return groups
481}
482
483function renderStream(current, keys) {
484 byId('stream').hidden = !current.length
485 if (!current.length) return
486 var M = METRICS[state.metric], box = byId('stream-chart'), tip = byId('tip'); tip.hidden = true; state.cursor = null
487 var groups = streamGroups(projectsIn(current)), visible = groups.filter(function (g) { return !state.hidden[g.name] })
488 var series = visible.map(function (g) { return perDay(current.filter(function (r) { return g.members.indexOf(r.project) >= 0 }), keys, M.pick) })
489 var totals = keys.map(function (_, i) { return series.reduce(function (a, s) { return a + s[i] }, 0) })
490 byId('stream-label').textContent = M.label + ', ' + periodText()
491 byId('stream-total').textContent = M.format(sum(totals))
492 box.setAttribute('aria-label', M.label + ' per project and day, ' + periodText() + '. The table below lists the same values.')
493 legend(groups)
494 streamTable(visible, series, totals, keys, M)
495 var n = keys.length, W = Math.max(320, Math.round(box.getBoundingClientRect().width) || 900), H = Math.round(box.getBoundingClientRect().height) || 280
496 var left = 52, right = 10, top = 12, base = H - 26
497 box.setAttribute('viewBox', '0 0 ' + W + ' ' + H); box.textContent = ''
498 var x = function (i) { return left + (n > 1 ? i * (W - left - right) / (n - 1) : (W - left - right) / 2) }
499 var max = niceMax(Math.max.apply(null, totals.concat([0])))
500 var y = function (v) { return base - v / max * (base - top) }
501 ;[0.5, 1].forEach(function (f) {
502 svgEl('line', { x1: left, x2: W - right, y1: y(max * f), y2: y(max * f), 'class': 'gridline' }, box)
503 svgEl('text', { x: left - 8, y: y(max * f) + 4, 'text-anchor': 'end', 'class': 'axis' }, box).textContent = M.format(max * f)
504 })
505 svgEl('text', { x: left - 8, y: base + 4, 'text-anchor': 'end', 'class': 'axis' }, box).textContent = M.format(0)
506 for (var w = 0, step = Math.max(1, Math.ceil(n / 7)); w < n; w += step) svgEl('text', { x: x(w), y: H - 4, 'class': 'axis', 'text-anchor': w ? 'middle' : 'start' }, box).textContent = shortDate(keys[w])
507 var acc = keys.map(function () { return 0 }), tops = []
508 series.forEach(function (s, li) {
509 var lower = acc, upper = acc.map(function (a, i) { return a + s[i] }), color = visible[li].color; acc = upper
510 var up = smooth(upper.map(function (v, i) { return [x(i), y(v)] })), low = smooth(lower.map(function (v, i) { return [x(i), y(v)] }).reverse())
511 svgEl('path', { d: up + 'L' + low.slice(1) + 'Z', style: 'fill:' + color + ';fill-opacity:.22' }, box)
512 tops.push({ d: up, color: color, values: upper })
513 })
514 // The top edge of each layer is its 2px line; the surface-coloured underlay keeps neighbouring edges apart.
515 tops.forEach(function (t) { svgEl('path', { d: t.d, fill: 'none', style: 'stroke:var(--surface)', 'stroke-width': 4 }, box) })
516 tops.forEach(function (t) { svgEl('path', { d: t.d, fill: 'none', style: 'stroke:' + t.color, 'stroke-width': 2, 'stroke-linejoin': 'round' }, box) })
517 svgEl('line', { x1: left, x2: W - right, y1: base, y2: base, 'class': 'baseline' }, box)
518 var hair = svgEl('line', { y1: top, y2: base, 'class': 'crosshair', visibility: 'hidden' }, box)
519 var dots = tops.map(function (t) { return svgEl('circle', { r: 4, style: 'fill:' + t.color + ';stroke:var(--surface)', 'stroke-width': 2, visibility: 'hidden' }, box) })
520 function show(i) {
521 var r = box.getBoundingClientRect(), scale = (r.width || W) / W
522 hair.setAttribute('x1', x(i)); hair.setAttribute('x2', x(i)); hair.setAttribute('visibility', 'visible')
523 dots.forEach(function (d, li) { d.setAttribute('cx', x(i)); d.setAttribute('cy', y(tops[li].values[i])); d.setAttribute('visibility', 'visible') })
524 tip.textContent = ''
525 tip.append(el('div', 'tip-head', WEEKDAYS[new Date(keys[i] + 'T12:00').getDay()] + ' ' + shortDate(keys[i])), el('div', 'tip-total', M.format(totals[i])))
526 for (var li = visible.length - 1; li >= 0; li--) {
527 if (!series[li][i] || visible.length < 2) continue
528 var row = el('div', 'tip-row'); row.append(swatch(visible[li].color), el('span', 'name', visible[li].name), el('span', 'v', M.format(series[li][i]))); tip.append(row)
529 }
530 var px = x(i) * scale, width = r.width || W
531 tip.style.left = Math.min(Math.max(0, px + 14), Math.max(0, width - 200)) + 'px'
532 tip.style.top = Math.max(0, y(totals[i]) * scale - 30) + 'px'
533 tip.hidden = false
534 state.cursor = i
535 }
536 function hide() { hair.setAttribute('visibility', 'hidden'); dots.forEach(function (d) { d.setAttribute('visibility', 'hidden') }); tip.hidden = true }
537 box.onmousemove = function (e) {
538 var r = box.getBoundingClientRect(), px = (e.clientX - r.left) / (r.width || W) * W
539 show(Math.max(0, Math.min(n - 1, Math.round((px - left) / Math.max(1, W - left - right) * (n - 1)))))
540 }
541 box.onmouseleave = hide
542 box.onblur = hide
543 box.onfocus = function () { show(n - 1) }
544 box.onkeydown = function (e) {
545 var i = state.cursor == null ? n - 1 : state.cursor
546 if (e.key === 'ArrowLeft') i = Math.max(0, i - 1)
547 else if (e.key === 'ArrowRight') i = Math.min(n - 1, i + 1)
548 else if (e.key === 'Home') i = 0
549 else if (e.key === 'End') i = n - 1
550 else return
551 if (e.preventDefault) e.preventDefault()
552 show(i)
553 }
554}
555
556function legend(groups) {
557 var box = byId('legend'); box.textContent = ''
558 if (groups.length < 2) return
559 groups.forEach(function (g) {
560 var b = el('button', 'chip'); b.type = 'button'; b.setAttribute('aria-pressed', String(!state.hidden[g.name])); b.title = (state.hidden[g.name] ? 'Show ' : 'Hide ') + g.name
561 b.append(swatch(g.color), el('span', 'label-text', g.name))
562 b.addEventListener('click', function () { state.hidden[g.name] = !state.hidden[g.name]; var list = scoped(), keys = dayKeys(now, state.period); renderStream(within(list, keys), keys) })
563 box.append(b)
564 })
565}
566
567function streamTable(visible, series, totals, keys, M) {
568 var head = byId('stream-head'), body = byId('stream-rows'), tr = el('tr'); head.textContent = ''; body.textContent = ''
569 tr.append(el('th', '', 'Day'))
570 visible.forEach(function (g) { tr.append(el('th', 'r', g.name)) })
571 tr.append(el('th', 'r', 'Total')); head.append(tr)
572 for (var i = keys.length - 1; i >= 0; i--) {
573 if (!totals[i]) continue
574 var row = el('tr'); row.append(el('td', '', shortDate(keys[i])))
575 series.forEach(function (s) { row.append(el('td', 'r', M.format(s[i]))) })
576 row.append(el('td', 'r', M.format(totals[i]))); body.append(row)
577 }
578}
579
580function inlineBar(value, max, text, color) {
581 var wrap = el('div', 'inline-bar'), track = el('div', 'bar-track'), fill = el('div', 'bar-fill')
582 fill.style.width = Math.max(2, max > 0 ? value / max * 100 : 0) + '%'
583 if (color) fill.style.background = color
584 track.append(fill); wrap.append(track, el('span', 'v', text)); return wrap
585}
586function cell(content, cls) { var td = el('td', cls || ''); if (typeof content === 'string') td.textContent = content; else td.append(content); return td }
587function miniMeter(share, text) {
588 var wrap = el('span', 'mini'), track = el('span', 'bar-track'), fill = el('span', 'bar-fill')
589 fill.style.display = 'block'; fill.style.width = Math.min(100, Math.max(0, share * 100)) + '%'; track.style.display = 'block'
590 track.append(fill); wrap.append(track, el('span', '', text)); return wrap
591}
592
593function renderModels(current) {
594 var mix = modelMix(current), body = byId('models-rows'); body.textContent = ''
595 byId('models').hidden = !mix.length
596 if (!mix.length) return
597 var requests = sum(mix.map(function (m) { return m.requests })), read = sum(mix.map(function (m) { return m.cacheRead })), side = sum(mix.map(function (m) { return m.input + m.cacheRead + m.cacheWrite }))
598 byId('models-sub').textContent = plural(requests, 'request') + ' · cache reads ' + percent(side ? read / side : 0) + ' of input'
599 var max = mix[0].tokens
600 mix.forEach(function (m) {
601 var tr = el('tr')
602 tr.append(cell(m.name, 'mono'), cell(inlineBar(m.tokens, max, compact(m.tokens))), cell(compact(m.requests), 'r'), cell(m.requests ? latency(m.meanLatencyMs) : '–', 'r'), cell(miniMeter(m.cacheShare, percent(m.cacheShare))))
603 body.append(tr)
604 })
605}
606
607function flag(count, icon, color) {
608 if (!count) return el('span', 'dim', '–')
609 var f = el('span', 'flag'), i = el('i', '', icon); f.style.setProperty('--c', color); f.append(i, el('span', '', compact(count))); return f
610}
611function renderTools(current) {
612 var tools = toolRanking(current), body = byId('tools-rows'); body.textContent = ''
613 byId('tools').hidden = !tools.length
614 if (!tools.length) return
615 var max = tools[0].calls
616 tools.forEach(function (t) {
617 var ok = Math.max(0, t.calls - t.errors - t.denies), wrap = el('div', 'inline-bar'), stack = el('div', 'stack')
618 stack.style.width = Math.max(2, max > 0 ? t.calls / max * 100 : 0) + '%'; stack.style.flex = 'none'
619 ;[[ok, 'var(--s1)'], [t.errors, 'var(--critical)'], [t.denies, 'var(--warning)']].forEach(function (part) {
620 if (!part[0]) return
621 var seg = el('i'); seg.style.setProperty('--c', part[1]); seg.style.flexGrow = String(part[0]); stack.append(seg)
622 })
623 var holder = el('div', ''); holder.style.flex = '1'; holder.append(stack)
624 wrap.append(holder, el('span', 'v', compact(t.calls)))
625 var tr = el('tr')
626 tr.append(cell(t.name, 'mono'), cell(wrap), cell(flag(t.errors, '✕', 'var(--critical)'), 'r'), cell(flag(t.denies, '⊘', 'var(--warning)'), 'r'), cell(t.calls ? latency(t.ms / t.calls) : '–', 'r'))
627 body.append(tr)
628 })
629}
630
631function renderTickets(current) {
632 var tickets = ticketTotals(current), body = byId('tickets-rows'); body.textContent = ''
633 byId('tickets').hidden = !tickets.length
634 if (!tickets.length) return
635 byId('tickets-sub').textContent = plural(tickets.length, 'ticket') + ' · ' + money(sum(tickets.map(function (t) { return t.costUsd })))
636 var max = tickets[0].costUsd
637 tickets.forEach(function (t) {
638 var tr = el('tr')
639 tr.append(cell(t.name, 'mono'), cell(compact(t.turns), 'r'), cell(compact(t.tokens), 'r'), cell(duration(t.durationMs), 'r'), cell(inlineBar(t.costUsd, max, money(t.costUsd))))
640 body.append(tr)
641 })
642}
643
644var OUTCOMES = { answer: ['var(--good)', '✓', 'Answered'], aborted: ['var(--warning)', '■', 'Aborted'], refusal: ['var(--serious)', '⊘', 'Refused'], error: ['var(--critical)', '✕', 'Error'] }
645function pill(reason) {
646 var o = OUTCOMES[reason] || ['var(--muted)', '•', String(reason || 'unknown')], p = el('span', 'pill'), i = el('i', '', o[1])
647 p.style.setProperty('--c', o[0]); p.append(i, el('span', '', o[2])); return p
648}
649function sessionTests(quality) {
650 if (!quality) return cell('–')
651 return cell(quality.tests.status + (quality.tests.stale ? ' stale' : ''))
652}
653function sessionComplexity(quality) {
654 if (!quality) return cell('–')
655 var result = cell(String(quality.ccMax) + (quality.split ? ' SPLIT' : ' ok'))
656 if (quality.split) result.style.color = 'var(--bad-ink)'
657 return result
658}
659function renderSessions(list, keys) {
660 var start = new Date(keys[0] + 'T00:00').getTime(), q = state.query.trim().toLowerCase(), body = byId('sessions-rows'); body.textContent = ''
661 var all = sessionsFrom(list).filter(function (s) { return s.ended >= start })
662 byId('sessions').hidden = !all.length
663 var shown = all.filter(function (s) { return !q || [s.name, s.project, s.model].concat(s.tickets).join(' ').toLowerCase().indexOf(q) >= 0 })
664 byId('sessions-count').textContent = String(shown.length)
665 if (!shown.length) { var tr = el('tr'), td = el('td', 'empty-row', 'No session matches.'); td.colSpan = 11; tr.append(td); body.append(tr); return }
666 shown.forEach(function (s) {
667 var tr = el('tr'), name = el('td', 'wrap'), title = el('div', 'title', s.name || 'Untitled session')
668 title.title = s.name
669 var bits = [state.project ? '' : s.project, stamp(s.started)].concat(s.tickets)
670 if (s.compactions) bits.push('compacted ×' + s.compactions)
671 if (s.agents) bits.push(plural(s.agents, 'subagent'))
672 name.append(title, el('div', 'sub', bits.filter(Boolean).join(' · ')))
673 tr.append(name, cell(s.model || '–', 'mono'), cell(String(s.turns), 'r'), cell(compact(s.requests), 'r'), cell(money(s.costUsd), 'r'), cell(duration(s.durationMs), 'r'), cell(compact(s.tokens), 'r'), cell(miniMeter(s.contextPeak / 100, Math.round(s.contextPeak) + '%')), cell(pill(s.outcome)), sessionTests(s.quality), sessionComplexity(s.quality))
674 body.append(tr)
675 })
676}
677
678function hexPath(cx, cy, r) {
679 var d = ''
680 for (var k = 0; k < 6; k++) { var a = Math.PI / 3 * k - Math.PI / 2; d += (k ? 'L' : 'M') + (cx + r * Math.cos(a)).toFixed(1) + ',' + (cy + r * Math.sin(a)).toFixed(1) }
681 return d + 'Z'
682}
683// The last 12 weeks as a honeycomb: a column per week, a row per weekday, one blue ramp for agent time.
684function renderActivity(list, current) {
685 var s = byId('heat'), keys = dayKeys(now, 84), vals = perDay(list, keys, METRICS.time.pick), max = Math.max.apply(null, vals.concat([0])); s.textContent = ''
686 byId('activity').hidden = !(max > 0)
687 if (!(max > 0)) return
688 var r = 15, w = Math.sqrt(3) * r, left = 34, top = r + 2, firstDow = new Date(keys[0] + 'T12:00').getDay(), cols = Math.ceil((84 + firstDow) / 7)
689 keys.forEach(function (k, i) {
690 var pos = i + firstDow, row = pos % 7, level = vals[i] > 0 ? Math.max(1, Math.ceil(vals[i] / max * 5)) : 0
691 var p = svgEl('path', { d: hexPath(left + Math.floor(pos / 7) * w + (row % 2 ? w / 2 : 0), top + row * r * 1.5, r), 'class': 'hx hx' + level }, s)
692 svgEl('title', {}, p).textContent = WEEKDAYS[row] + ' ' + shortDate(k) + ': ' + (vals[i] ? duration(vals[i]) : 'no turns')
693 })
694 ;['Mon', 'Wed', 'Fri'].forEach(function (d, i) { svgEl('text', { x: 0, y: top + (1 + 2 * i) * r * 1.5 + 4, 'class': 'day' }, s).textContent = d })
695 var right = left + cols * w + w / 2, ly = top + 6 * r * 1.5 + r + 18
696 svgEl('text', { x: right - 34 - 5 * 14 - 12, y: ly + 4, 'text-anchor': 'end', 'class': 'day' }, s).textContent = 'Less'
697 ;[0, 1, 2, 3, 4, 5].forEach(function (level, i) { svgEl('path', { d: hexPath(right - 34 - (5 - i) * 14, ly, 6.5), 'class': 'hx hx' + level }, s) })
698 svgEl('text', { x: right, y: ly + 4, 'text-anchor': 'end', 'class': 'day' }, s).textContent = 'More'
699 s.setAttribute('viewBox', '0 0 ' + Math.ceil(right + 2) + ' ' + Math.ceil(ly + 10))
700 s.setAttribute('width', Math.ceil(right + 2))
701 var run = streaks(vals)
702 byId('h-active').textContent = String(run.active)
703 byId('h-active-sub').textContent = percent(run.active / 84) + ' of the last 84 days'
704 byId('h-streak').textContent = plural(run.current, 'day')
705 byId('h-streak-sub').textContent = 'longest ' + plural(run.longest, 'day')
706 var sessions = sessionsFrom(current), byDay = {}, facts = byId('facts'); facts.textContent = ''
707 current.forEach(function (t) { var k = dayKey(Date.parse(t.at)); byDay[k] = (byDay[k] || 0) + finite(t.durationMs) })
708 var busiest = Object.keys(byDay).sort(function (a, b) { return byDay[b] - byDay[a] })[0]
709 var longest = sessions.reduce(function (m, x) { return !m || x.durationMs > m.durationMs ? x : m }, null)
710 var compactions = sum(current.map(function (t) { return t.compacted ? 1 : 0 })), agents = sum(current.map(agentSpawns))
711 ;[['Busiest day', busiest ? shortDate(busiest) + ' · ' + duration(byDay[busiest]) : '–'],
712 ['Longest session', longest ? duration(longest.durationMs) : '–'],
713 ['Average time per session', sessions.length ? duration(sum(sessions.map(function (x) { return x.durationMs })) / sessions.length) : '–'],
714 ['Average cost per session', sessions.length ? money(sum(current.map(METRICS.cost.pick)) / sessions.length) : '–'],
715 ['Subagents spawned', compact(agents)],
716 ['Context compactions', compact(compactions)]].forEach(function (f) {
717 var row = el('div', 'fact'); row.append(el('span', '', f[0]), el('span', '', f[1])); facts.append(row)
718 })
719 byId('facts-sub').textContent = 'Facts cover the ' + periodText() + '.'
720}
721
722function render() {
723 var list = scoped(), keys = dayKeys(now, state.period), current = within(list, keys)
724 sidebar(); header(current); renderEmpty(current); renderKpis(list, keys, current); renderQuota()
725 renderStream(current, keys); renderModels(current); renderTools(current); renderTickets(current); renderSessions(list, keys); renderActivity(list, current)
726}
727
728function segment(id, apply) {
729 var seg = byId(id)
730 seg.addEventListener('click', function (e) {
731 var b = e.target.closest('button'); if (!b) return
732 Array.from(seg.children).forEach(function (x) { x.classList.toggle('on', x === b); x.setAttribute('aria-pressed', String(x === b)) })
733 apply(b)
734 })
735}
736segment('period', function (b) { state.period = +b.getAttribute('data-period'); render() })
737segment('metric', function (b) { state.metric = b.getAttribute('data-metric'); var list = scoped(), keys = dayKeys(now, state.period); renderStream(within(list, keys), keys) })
738byId('q').addEventListener('input', function (e) { state.query = e.target.value; renderSessions(scoped(), dayKeys(now, state.period)) })
739var resizing; window.addEventListener('resize', function () { clearTimeout(resizing); resizing = setTimeout(function () { var list = scoped(), keys = dayKeys(now, state.period); renderStream(within(list, keys), keys) }, 120) })
740
741var THEMES = ['system', 'light', 'dark']
742function applyTheme(choice) {
743 var next = THEMES[(THEMES.indexOf(choice) + 1) % THEMES.length], b = byId('theme')
744 document.documentElement.setAttribute('data-theme', choice)
745 b.setAttribute('data-choice', choice); b.setAttribute('aria-label', 'Theme: ' + choice + ', switch to ' + next); b.title = 'Theme: ' + choice
746}
747var saved = 'system'
748try { saved = localStorage.getItem('observability-board-theme') || 'system' } catch (e) {}
749applyTheme(THEMES.indexOf(saved) >= 0 ? saved : 'system')
750byId('theme').addEventListener('click', function () {
751 var next = THEMES[(THEMES.indexOf(byId('theme').getAttribute('data-choice')) + 1) % THEMES.length]
752 applyTheme(next)
753 try { localStorage.setItem('observability-board-theme', next) } catch (e) {}
754})
755
756render()
757`
758
759const THEME_BUTTON = '<button type="button" class="theme" id="theme" data-choice="system" aria-label="Theme: system" title="Theme: system"><svg class="i-system" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg><svg class="i-light" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg><svg class="i-dark" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>'
760
761const MARK = '<svg class="mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 17l5-6 4 3 6-8"/><path d="M3 21h18"/></svg>'
762
763const FAVICON = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#2a78d6" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17l5-6 4 3 6-8"/><path d="M3 21h18"/></svg>')
764
765const SEARCH_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>'
766
767// A leading ">" marks a right-aligned numeric column.
768const headerCell = (label) => (label.startsWith('>') ? `<th class="r">${label.slice(1)}</th>` : `<th>${label}</th>`)
769
770const head = (cells) => '<thead><tr>' + cells.map(headerCell).join('') + '</tr></thead>'
771
772const listCard = (id, title, columns, extra) =>
773 `<section id="${id}" class="card list" hidden><div class="list-head"><h3>${title}</h3>${extra}</div><div class="table-wrap"><table>${head(columns)}<tbody id="${id}-rows"></tbody></table></div></section>`
774
775const seg = (id, label, attribute, items, on) =>
776 `<div class="seg" id="${id}" role="group" aria-label="${label}">` +
777 items.map(([value, text]) => `<button type="button" data-${attribute}="${value}" aria-pressed="${value === on}"${value === on ? ' class="on"' : ''}>${text}</button>`).join('') +
778 '</div>'
779
780const TOOL_KEY = '<div class="key"><span><i style="--c:var(--s1)"></i>OK</span><span><i style="--c:var(--critical)"></i>Error</span><span><i style="--c:var(--warning)"></i>Denied</span></div>'
781
782const BODY = `<div class="app">
783<aside class="side"><div class="brand">${MARK}observability</div><div class="side-label">Projects</div><nav id="projects" aria-label="Projects"></nav><div class="side-foot"><div class="foot-text" id="generated"></div>${THEME_BUTTON}</div></aside>
784<main>
785<div class="top"><div><div class="crumb">Board / <b id="crumb"></b></div><h1 id="title"></h1><p id="subtitle"></p></div>
786${seg('period', 'Period', 'period', [['7', '7d'], ['30', '30d'], ['90', 'All (90d)']], '30')}</div>
787<section id="empty" class="card empty" hidden><h2 id="empty-title"></h2><p id="empty-text"></p></section>
788<section id="kpis" class="kpis" aria-label="Totals"></section>
789<section id="quota" class="card quota" hidden><div class="card-head"><h3>Plan usage</h3><span class="sub" id="quota-sub"></span></div><div class="meters" id="quota-meters"></div></section>
790<section id="stream" class="card chart-card" hidden><div class="chart-head"><div><div class="label" id="stream-label"></div><div class="big" id="stream-total"></div></div>
791${seg('metric', 'Metric', 'metric', [['cost', 'Cost'], ['time', 'Agent time'], ['tokens', 'Tokens'], ['turns', 'Turns']], 'cost')}</div>
792<div class="chart-wrap"><svg id="stream-chart" role="img" tabindex="0" aria-label="Per project and day"></svg><div class="tip" id="tip" hidden></div></div><div class="legend" id="legend"></div>
793<details class="table-view"><summary>Show as table</summary><div class="table-wrap"><table><thead id="stream-head"></thead><tbody id="stream-rows"></tbody></table></div></details></section>
794<div class="grid2">
795${listCard('models', 'Models', ['Model', 'Tokens', '>Requests', '>Mean latency', 'Cache reads'], '<span class="sub" id="models-sub"></span>')}
796${listCard('tools', 'Tools', ['Tool', 'Calls', '>Errors', '>Denied', '>Avg time'], TOOL_KEY)}
797</div>
798${listCard('tickets', 'Tickets', ['Ticket', '>Turns', '>Tokens', '>Agent time', 'Cost'], '<span class="sub" id="tickets-sub"></span>')}
799${listCard('sessions', 'Sessions<span class="n" id="sessions-count"></span>', ['Session', 'Model', '>Turns', '>Requests', '>Cost', '>Time', '>Tokens', 'Context peak', 'Outcome', 'Tests', 'CC'], `<label class="search">${SEARCH_ICON}<input id="q" type="search" placeholder="Search sessions" aria-label="Search sessions"></label>`)}
800<section id="activity" class="card heat" hidden><div class="heat-grid"><div><h3>Activity</h3><div class="sub">Agent time per day, last 12 weeks</div><svg id="heat" role="img" aria-label="Agent time per day, last 12 weeks"></svg></div>
801<div><div class="heroes"><div><div class="hero-label">Active days</div><div class="hero-value" id="h-active"></div><div class="hero-sub" id="h-active-sub"></div></div>
802<div><div class="hero-label">Current streak</div><div class="hero-value" id="h-streak"></div><div class="hero-sub" id="h-streak-sub"></div></div></div><div id="facts"></div><div class="sub" id="facts-sub"></div></div></div></section>
803<footer>Recorded by the observability mod in Claude Code. Cost is the session cost Claude Code itself reports, not a bill; nothing here is estimated. The ledger keeps the last 90 days.</footer>
804</main></div>`
805
806// JSON inside a script block: "<" and the two JS line separators are escaped so no value can end the block.
807const scriptJson = (value) => JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')
808
809export function boardHtml(rows, now) {
810 const data = scriptJson({ now, rows: Array.isArray(rows) ? rows : [] })
811 const script = '(function () {\n' + SHARED.map(String).join('\n') + '\n' + APP + '\n})()'
812 return `<!doctype html><html lang="en" data-theme="system"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Observability board</title><link rel="icon" href="${FAVICON}"><style>${CSS}</style></head>
813<body>${BODY}
814<script type="application/json" id="board-data">${data}</script>
815<script>${script}</script></body></html>`
816}
817hooks/ledger.ts 202 lines1import type { ModelUsage, SessionUsage, TurnCompleteReason } from 'claude-code'
2
3import type { ModelTally, QualityMeasurement, Recording, Tokens, ToolTally, TurnQuality } from '../types'
4
5export type TurnRow = {
6 v: 1
7 quality?: TurnQuality
8 at: string
9 project: string
10 session: string
11 startedAt: number
12 name: string
13 turn: number
14 reason: TurnCompleteReason
15 durationMs: number
16 costUsd: number
17 turnCostUsd: number
18 model: string
19 usage: Tokens
20 models: Record<string, ModelTally>
21 tools: Record<string, ToolTally>
22 agents: Record<string, number>
23 context: { tokens?: number; window: number; percent?: number }
24 rateLimits: Array<{ kind: string; percentUsed: number; resetsAt?: string }>
25 compacted: boolean
26 ticket?: string
27 version: string
28}
29
30// What the engine says when a main-thread turn completes, beside what the recording gathered.
31export type TurnEnd = {
32 at: string
33 project: string
34 session: string
35 startedAt: number
36 turn: number
37 reason: TurnCompleteReason
38 durationMs: number
39 usage: Tokens
40 sessionUsage: SessionUsage
41 model: string
42 version: string
43}
44
45const needsSplit = (body: QualityMeasurement['bodies'][number]): boolean => body.cc >= 6 && body.state !== 'legacy-unchanged'
46
47export function qualityForTurn(measurement: QualityMeasurement | undefined, row: TurnRow): TurnQuality | undefined {
48 if (measurement === undefined) return
49 if (!(Date.parse(measurement.at) >= Date.parse(row.at) - row.durationMs)) return
50 return {
51 at: measurement.at,
52 ccMax: Math.max(0, ...measurement.bodies.map(body => body.cc)),
53 split: measurement.bodies.filter(needsSplit).length,
54 unverified: measurement.unverified,
55 tests: { status: measurement.tests.status, stale: measurement.tests.stale },
56 }
57}
58
59export const NO_TOKENS: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
60
61export const tokensOf = (usage: ModelUsage | null | undefined): Tokens =>
62 usage
63 ? { input: usage.input_tokens, output: usage.output_tokens, cacheRead: usage.cache_read_input_tokens, cacheWrite: usage.cache_creation_input_tokens }
64 : NO_TOKENS
65
66export const addTokens = (a: Tokens, b: Tokens): Tokens => ({
67 input: a.input + b.input,
68 output: a.output + b.output,
69 cacheRead: a.cacheRead + b.cacheRead,
70 cacheWrite: a.cacheWrite + b.cacheWrite,
71})
72
73export const freshRecording = (costUsd = 0): Recording => ({
74 name: '',
75 costUsd,
76 subagentUsage: NO_TOKENS,
77 models: {},
78 tools: {},
79 agents: {},
80 compacted: false,
81})
82
83// The next turn starts its tallies over; the name, ticket and cost baseline carry on.
84export const nextRecording = (recording: Recording, costUsd: number): Recording => ({
85 ...freshRecording(costUsd),
86 name: recording.name,
87 ...(recording.ticket === undefined ? {} : { ticket: recording.ticket }),
88})
89
90export function addStep(recording: Recording, step: { model: string; latencyMs: number; usage: Tokens }): Recording {
91 const before = recording.models[step.model] ?? { ...NO_TOKENS, requests: 0, latencyMs: 0 }
92 const after = { ...addTokens(before, step.usage), requests: before.requests + 1, latencyMs: before.latencyMs + step.latencyMs }
93 return { ...recording, models: { ...recording.models, [step.model]: after } }
94}
95
96export function addToolCall(recording: Recording, call: { tool: string; ms: number; isError: boolean; isDenied: boolean }): Recording {
97 const before = recording.tools[call.tool] ?? { calls: 0, errors: 0, denies: 0, ms: 0 }
98 const after = {
99 calls: before.calls + 1,
100 errors: before.errors + Number(call.isError),
101 denies: before.denies + Number(call.isDenied),
102 ms: before.ms + call.ms,
103 }
104 return { ...recording, tools: { ...recording.tools, [call.tool]: after } }
105}
106
107export const addAgent = (recording: Recording, type: string): Recording => ({
108 ...recording,
109 agents: { ...recording.agents, [type]: (recording.agents[type] ?? 0) + 1 },
110})
111
112export const addSubagentUsage = (recording: Recording, usage: Tokens): Recording => ({
113 ...recording,
114 subagentUsage: addTokens(recording.subagentUsage, usage),
115})
116
117const toMicroDollars = (usd: number) => Math.round(usd * 1e6) / 1e6
118
119export function foldTurn(recording: Recording, end: TurnEnd): TurnRow {
120 const { cost, context, rateLimits } = end.sessionUsage
121 const costUsd = cost?.usd ?? recording.costUsd
122 // A cost below the baseline means the engine started its ledger over.
123 const turnCostUsd = costUsd >= recording.costUsd ? costUsd - recording.costUsd : costUsd
124 return {
125 v: 1,
126 at: end.at,
127 project: end.project,
128 session: end.session,
129 startedAt: end.startedAt,
130 name: recording.name,
131 turn: end.turn,
132 reason: end.reason,
133 durationMs: end.durationMs,
134 costUsd,
135 turnCostUsd: toMicroDollars(turnCostUsd),
136 model: end.model,
137 usage: addTokens(end.usage, recording.subagentUsage),
138 models: recording.models,
139 tools: recording.tools,
140 agents: recording.agents,
141 context: { tokens: context.tokens, window: context.window, percent: context.percent },
142 rateLimits: rateLimits.map(limit => ({ kind: limit.kind, percentUsed: limit.percentUsed, resetsAt: limit.resetsAt })),
143 compacted: recording.compacted,
144 ticket: recording.ticket,
145 version: end.version,
146 }
147}
148
149// A `[sidequest-route ... ticket=SQ-n ...]` marker anywhere wins over an SQ-n in the first line.
150export function ticketRef(text: string): string | undefined {
151 const routed = /ticket=(SQ-\d+)/.exec(text)
152 if (routed) return routed[1]
153 return /\bSQ-\d+\b/.exec(text.split('\n')[0] ?? '')?.[0]
154}
155
156export const firstLine = (text: string): string => (text.trim().split('\n')[0] ?? '').slice(0, 60)
157
158// The first prompt names the task; any prompt may name the ticket it works.
159export function notePrompt(recording: Recording, text: string): Recording {
160 const ticket = ticketRef(text)
161 return { ...recording, name: recording.name || firstLine(text), ...(ticket ? { ticket } : {}) }
162}
163
164// FNV-1a over the whole path: two projects named `app` in different folders get different ledgers.
165// The module has no node:crypto, and six hex digits are plenty for one machine's projects.
166const pathHash = (path: string): string => {
167 let hash = 0x811c9dc5
168 for (const char of path.replace(/\\/g, '/').toLowerCase()) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193) >>> 0
169 return hash.toString(16).padStart(8, '0').slice(0, 6)
170}
171
172export const projectSlug = (path: string): string => {
173 const trimmed = path.replace(/[\\/]+$/, '')
174 const basename = (trimmed.split(/[\\/]/).pop() ?? '').replace(/[^\w.-]/g, '_')
175 return `${basename}-${pathHash(trimmed)}`
176}
177
178export const appendLine = (existing: string, row: TurnRow): string =>
179 `${existing}${existing === '' || existing.endsWith('\n') ? '' : '\n'}${JSON.stringify(row)}\n`
180
181// Rows of one ledger at or after `since`; a line that does not parse (a write cut short) is skipped.
182export function rowsSince(text: string, since: number): TurnRow[] {
183 const rows: TurnRow[] = []
184 for (const line of text.split('\n')) {
185 const row = parseRow(line)
186 if (row !== undefined && Date.parse(row.at) >= since) rows.push(row)
187 }
188 return rows
189}
190
191const isTurnRow = (value: unknown): value is TurnRow =>
192 typeof value === 'object' && value !== null && 'at' in value && typeof value.at === 'string'
193
194function parseRow(line: string): TurnRow | undefined {
195 try {
196 const row: unknown = JSON.parse(line)
197 return isTurnRow(row) ? row : undefined
198 } catch {
199 return undefined
200 }
201}
202types/index.d.ts 42 lines1// The slice of quality-gate's published `turn` measurement this plugin reads; the owner's full contract lives in that plugin.
2export type QualityMeasurement = {
3 at: string
4 bodies: Array<{ cc: number; state: 'new' | 'modified-raised' | 'legacy-unchanged' }>
5 unverified: boolean
6 tests: { status: 'pass' | 'fail' | 'none'; stale: boolean }
7}
8
9export type TurnQuality = {
10 at: string
11 ccMax: number
12 split: number
13 unverified: boolean
14 tests: { status: 'pass' | 'fail' | 'none'; stale: boolean }
15}
16
17export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
18
19export type ModelTally = Tokens & { requests: number; latencyMs: number }
20
21export type ToolTally = { calls: number; errors: number; denies: number; ms: number }
22
23// The turn in flight, plus what carries over between turns of one session.
24export type Recording = {
25 name: string
26 ticket?: string
27 // The session cost when the previous main turn ended: the baseline for this turn's own cost.
28 costUsd: number
29 // Tokens of subagent turns that completed during this main turn.
30 subagentUsage: Tokens
31 models: Record<string, ModelTally>
32 tools: Record<string, ToolTally>
33 agents: Record<string, number>
34 compacted: boolean
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 observability: { turn: Recording }
40 }
41}
42