SLOPSHOPPER

observability

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…

guardcommandstatuspromptprocesstimer
★ 283v1.1.0MITupdated 2026-10-19Eigenwise/eigenwise-toolshed/plugins/observability
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · observability
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /track ⎿ observability: observability: not recording turns for /work/app ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Observability

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.

Install

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.

Commands

  • /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.

What the board shows

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.

Local data and privacy

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.

Breaking change

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.

Source 4 files
hooks/register.ts 199 lines
1import { 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}
199
lib/board.mjs 817 lines
1// 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}
817
hooks/ledger.ts 202 lines
1import 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}
202
types/index.d.ts 42 lines
1// 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