SLOPSHOPPER

faultline

KOZMOS Faultline: the error lens in a sidebar. Every failed or denied tool call, refused or errored turn and API error, grouped by a masked signature with…

newpaneguardcommandtimeragents
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/faultline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · faultline
│ ┃ KOZMOS · Faultline ✕ › fix the failing auth test and add an audit log call │ ┃ ⚡ FAULTLINE 1 failures │ ┃ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██ ⏺ Read(src/auth.ts) │ ┃ 10 min ago now ⎿ Read 6 lines │ ┃ 1 recent · 1 signatures · 1 agents ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 1× Bash error: expected #, got # ⏺ Bash(bun test) │ ┃ 08:53:20 → 08:53:20 · main ▾ more copy ⎿ 3 pass, 1 fail │ ┃ on Run the test suite │ ┃ │ src/auth.test.ts: ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ✓ refreshes expired token │ ┃ │ ✓ rejects a bad signature ✻ Worked for 42s · done 4:20 PM │ ┃ │ … │ › /faultline │ ⎿ faultline: Faultline open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Faultline
⚡ FAULTLINE 1 failures ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██ 10 min ago now 1 recent · 1 signatures · 1 agents 1× Bash error: expected #, got # 08:53:20 → 08:53:20 · main ▾ more copy on Run the test suite │ src/auth.test.ts: │ ✓ refreshes expired token │ ✓ rejects a bad signature │ …
README

KOZMOS Faultline

The error lens: a sidebar of everything that went wrong this session.

  • Every failed tool call (isError) and every denied or refused one (a hook's deny, a permission refusal), from the main loop and from subagents.
  • Turns that ended on a refusal (with the API's category and explanation) or on an API error, and API errors seen through the StopFailure hook (rate_limit, overloaded, … with their details).
  • Failures are grouped by a signature: the tool plus the first telling error line with numbers, paths, URLs and ids masked, so "File does not exist: /a.ts" and "… /lib/b.ts" are one group.
  • Each group shows its count, first and last time, the agents that hit it, what the first call was about, the first lines of the first error (▾ more unfolds the full text) and a copy button.
  • A header severity strip: 20 cells over the last 10 minutes, redder with more failures.

/faultline toggles the pane, /faultline list prints the top signatures. Set autoOpen to open it on session start.

Türkçe

Hata merceği: oturumda ters giden her şeyi gösteren kenar çubuğu.

  • Ana döngüden ve alt ajanlardan gelen her başarısız araç çağrısı (isError) ve reddedilen her çağrı (bir hook'un deny'ı, izin reddi).
  • Ret (API'nin kategorisi ve açıklamasıyla) ya da API hatasıyla biten turlar ve StopFailure hook'u ile görülen API hataları (rate_limit, overloaded, …).
  • Hatalar bir imza ile gruplanır: araç ve sayıları, yolları, URL'leri ve kimlikleri maskelenmiş ilk anlamlı hata satırı.
  • Her grup; sayısını, ilk ve son görülme zamanını, ona takılan ajanları, ilk çağrının neyle ilgili olduğunu, ilk hatanın ilk satırlarını (▾ more tamamını açar) ve bir copy düğmesini gösterir.
  • Başlıkta son 10 dakikanın şiddet şeridi: 20 hücre, hata arttıkça kızarır.

/faultline paneli açar/kapatır, /faultline list en sık imzaları yazar. Oturum başında açılması için autoOpen ayarını açın.

Source 4 files
hooks/register.tsx 239 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Fault, FaultKind, FaultlineSnap, FaultlineView } from '../types'
5import { KZ, hash, pxOf, toolDetail } from './lib/kz.ts'
6import {
7  BUCKET_MS, DENIED, WINDOW_MS, addFault, agentsOf, altOf, buckets, clockOf, copyText, ember, emptySnap, faultSvg, headerSvg,
8  kindColor, kindLabel, recentCount, textLines,
9} from './lens.ts'
10
11const PANE = 'kz-faultline'
12const TITLE = 'KOZMOS · Faultline'
13const SHOWN = 50
14const snapAtom = atom({ plugin: 'faultline', key: 'snap' } as const, null)
15const viewAtom = atom({ plugin: 'faultline', key: 'view' } as const, { expanded: [] })
16
17let live: FaultlineSnap = emptySnap()
18let isSeeded = false
19let lastPublished = ''
20const agentNames = new Map<string, string>()
21
22async function seed($: EngineInterface): Promise<void> {
23  if (isSeeded) return
24  isSeeded = true
25  try {
26    const kept = await read($, snapAtom)
27    if (kept) live = structuredClone(kept)
28  } catch {
29    // Nothing kept: start empty.
30  }
31}
32
33async function publish($: EngineInterface): Promise<void> {
34  const key = JSON.stringify(live)
35  if (key === lastPublished) return
36  lastPublished = key
37  const next = structuredClone(live)
38  await update($, snapAtom, () => next)
39}
40
41async function whoOf($: EngineInterface, agentId: string | undefined): Promise<string> {
42  if (agentId === undefined) return 'main'
43  const known = agentNames.get(agentId)
44  if (known) return known
45  try {
46    for (const a of await $.agent.list()) agentNames.set(a.id, a.description || a.type)
47  } catch {
48    // The roster is not readable now; fall back to the id.
49  }
50  return agentNames.get(agentId) ?? `agent ${agentId.slice(0, 6)}`
51}
52
53async function record($: EngineInterface, tool: string, kind: FaultKind, text: string, agentId: string | undefined, detail?: string): Promise<void> {
54  await seed($)
55  addFault(live, { tool, kind, text, at: await $.clock.now(), who: await whoOf($, agentId), detail: detail || undefined })
56  await publish($)
57}
58
59/** Moves the severity strip on while it has something to show. */
60async function tick($: EngineInterface): Promise<void> {
61  const now = await $.clock.now()
62  const at = Math.floor(now / BUCKET_MS) * BUCKET_MS
63  if (live.hits.length > 0) {
64    live.now = at
65    live.hits = live.hits.filter(t => t > now - WINDOW_MS)
66    await publish($)
67  }
68}
69
70async function toggle($: EngineInterface): Promise<boolean> {
71  if ((await $.ui.panes()).some(p => p.id === PANE)) {
72    await $.ui.close({ id: PANE })
73    return false
74  }
75  await $.ui.open({ id: PANE, title: TITLE })
76  return true
77}
78
79function summary(s: FaultlineSnap): string {
80  if (!s.total) return 'Faultline: no failures this session.'
81  const lines = [`Faultline: ${s.total} failures in ${s.faults.length} signatures, ${recentCount(s)} in the last 10 minutes.`]
82  for (const f of s.faults.slice(0, 8)) lines.push(`  ${String(f.count).padStart(3)}×  ${kindLabel(f)}  ${f.head}`)
83  return lines.join('\n')
84}
85
86const keyOf = (f: Fault): string => hash(f.sig).toString(36)
87
88export const register: Register = (on, options) => {
89  on('session.start', async ($, e, next) => {
90    const started = await next(e)
91    live = emptySnap(await $.clock.now())
92    isSeeded = true
93    lastPublished = ''
94    agentNames.clear()
95    await $.command.register({ name: 'faultline', description: 'KOZMOS: toggle the Faultline error-lens sidebar (/faultline list prints the top signatures)', argumentHint: '[list]', immediate: true })
96    await publish($).catch(() => undefined)
97    $.clock.every(15_000, () => void tick($).catch(() => undefined))
98    if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
99    return started
100  })
101
102  on('command.run', { command: 'faultline' }, async ($, e) => {
103    if (e.args.trim() === 'list') {
104      await seed($)
105      return { text: summary(live) }
106    }
107    return { text: (await toggle($)) ? 'Faultline open.' : 'Faultline closed.' }
108  })
109
110  on('agent.spawn', async ($, e, next) => {
111    const r = await next(e)
112    if (r.deny === undefined && r.agentId) agentNames.set(r.agentId, e.description || e.subagentType)
113    return r
114  }).catch(($, e, next) => next(e))
115
116  on('tool.call', async ($, e, next) => {
117    const ran = await next(e)
118    if (ran.deny !== undefined || ran.isError) {
119      const tool = String(e.tool)
120      const text = ran.deny ?? ran.text ?? (typeof ran.result === 'string' ? ran.result : 'failed')
121      const kind: FaultKind = ran.deny !== undefined || DENIED.test(text) ? 'denied' : 'error'
122      await record($, tool, kind, text, e.agentId, toolDetail(tool, e)).catch(() => undefined)
123    }
124    return ran
125  }).catch(($, e, next) => next(e))
126
127  on('turn.complete', async ($, e, next) => {
128    if (e.reason === 'refusal') {
129      const r = e.refusal
130      const text = `Refusal${r.category ? `: ${r.category}` : ''}${r.explanation ? `\n${r.explanation}` : ''}`
131      await record($, 'turn', 'turn', text, e.agentId, `turn ${e.turnId.slice(0, 8)}`).catch(() => undefined)
132    } else if (e.reason === 'error') {
133      await record($, 'turn', 'turn', 'Turn ended on an API error (retries exhausted or the context limit)', e.agentId, `turn ${e.turnId.slice(0, 8)}`).catch(() => undefined)
134    }
135    return next(e)
136  }).catch(($, e, next) => next(e))
137
138  on('classic.StopFailure', async ($, e, next) => {
139    const text = `API error: ${e.error}${e.error_details ? `\n${e.error_details}` : ''}`
140    await record($, 'api', 'api', text, e.agent_id, e.last_assistant_message ? `after: ${e.last_assistant_message.slice(0, 60)}` : undefined).catch(() => undefined)
141    return next(e)
142  }).catch(($, e, next) => next(e))
143
144  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
145    const snap = (await read($, snapAtom)) ?? emptySnap()
146    const view: FaultlineView = await read($, viewAtom)
147    const ui = $.ui.resolve(e)
148    const { Box, Text, Button } = ui
149    const maxCount = Math.max(1, ...snap.faults.map(f => f.count))
150    const recent = recentCount(snap)
151    const toggleOpen = (sig: string) => update($, viewAtom, v => ({
152      expanded: v.expanded.includes(sig) ? v.expanded.filter(s => s !== sig) : [...v.expanded, sig].slice(-50),
153    }))
154    const actions = (f: Fault, isOpen: boolean) => (
155      <Box key={`a-${keyOf(f)}`} flexDirection="row" gap={2} flexShrink={0}>
156        <Button key={`x-${keyOf(f)}`} label={isOpen ? '▴ less' : '▾ more'} plain dimColor onPress={() => toggleOpen(f.sig)} />
157        <Button key={`cp-${keyOf(f)}`} label="copy" plain dimColor onPress={p => void $.ui.copy({ text: copyText(f), surface: p.surface }).catch(() => undefined)} />
158      </Box>
159    )
160    const empty = snap.faults.length === 0
161      ? <Text key="empty" color={KZ.green}>✓ No failures yet. Failed or denied tool calls, refusals and API errors land here.</Text>
162      : null
163    const more = snap.faults.length > SHOWN ? <Text key="more" dimColor>… {snap.faults.length - SHOWN} older signatures not shown</Text> : null
164
165    if ('Svg' in ui && e.surface !== 'terminal') {
166      const { Svg, Code } = ui
167      const W = pxOf(e.props.bodyColumns, 44)
168      const head = headerSvg(snap, W)
169      return (
170        <Box flexDirection="column" gap={1}>
171          <Svg key="head" source={head.source} alt={`Faultline: ${snap.total} failures, ${recent} in the last 10 minutes`} width={W} height={head.height} />
172          {snap.faults.slice(0, SHOWN).map(f => {
173            const isOpen = view.expanded.includes(f.sig)
174            const card = faultSvg(f, W, maxCount, isOpen)
175            return (
176              <Box key={`f-${keyOf(f)}`} flexDirection="column">
177                <Svg key={`s-${keyOf(f)}`} source={card.source} alt={altOf(f)} width={W} height={card.height} />
178                {isOpen ? <Code key={`c-${keyOf(f)}`} source={textLines(f, true).join('\n')} language="text" wrap="wrap" /> : null}
179                {actions(f, isOpen)}
180              </Box>
181            )
182          })}
183          {empty}
184          {more}
185        </Box>
186      )
187    }
188
189    const cols = Math.max(30, e.props.bodyColumns || 44)
190    const strip = buckets(snap.hits, snap.now)
191    const top = Math.max(1, ...strip)
192    const cell = Math.max(1, Math.floor((cols - 2) / strip.length))
193    return (
194      <Box flexDirection="column">
195        <Box flexDirection="row" justifyContent="space-between">
196          <Text bold color={recent ? KZ.red : KZ.mist}>⚡ FAULTLINE</Text>
197          <Text color={snap.total ? KZ.red : KZ.mist}>{snap.total} failures</Text>
198        </Box>
199        <Text wrap="truncate-end">
200          {strip.map((n, i) => (
201            <Text key={`st${i}`} color={ember(n / top)}>{(n ? '█' : '▁').repeat(cell)}</Text>
202          ))}
203        </Text>
204        <Box flexDirection="row" justifyContent="space-between">
205          <Text dimColor>10 min ago</Text>
206          <Text dimColor>now</Text>
207        </Box>
208        <Text wrap="truncate-end">
209          <Text color={recent ? KZ.red : KZ.green}>{recent} recent</Text>
210          <Text dimColor> · {snap.faults.length} signatures · {agentsOf(snap)} agents</Text>
211        </Text>
212        {snap.faults.slice(0, SHOWN).map(f => {
213          const isOpen = view.expanded.includes(f.sig)
214          const c = kindColor(f.kind)
215          return (
216            <Box key={`f-${keyOf(f)}`} flexDirection="column" marginTop={1}>
217              <Text wrap="truncate-end">
218                <Text backgroundColor={ember(0.25 + (f.count / maxCount) * 0.75)} color="#ffffff" bold> {f.count}× </Text>
219                <Text color={c} bold> {kindLabel(f)} </Text>
220                <Text>{f.head}</Text>
221              </Text>
222              <Box flexDirection="row" justifyContent="space-between">
223                <Text dimColor wrap="truncate-end">  {clockOf(f.first)} → {clockOf(f.last)} · {f.agents.map(a => (a === 'main' ? 'main' : `◈ ${a}`)).join(', ')}</Text>
224                {actions(f, isOpen)}
225              </Box>
226              {f.detail ? <Text dimColor wrap="truncate-end">  on {f.detail}</Text> : null}
227              {textLines(f, isOpen).map((l, i) => (
228                <Text key={`l-${keyOf(f)}-${i}`} color={i === 0 ? KZ.red : undefined} dimColor={i > 0} wrap={isOpen ? 'wrap' : 'truncate-end'}>  │ {l}</Text>
229              ))}
230            </Box>
231          )
232        })}
233        {empty}
234        {more}
235      </Box>
236    )
237  })
238}
239
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/lens.ts 203 lines
1// Faultline: pure helpers (no `$`): error signatures, grouping, the severity
2// strip and the desktop drawings.
3import type { Fault, FaultKind, FaultlineSnap } from '../types'
4import { KZ, clip, fitText, mix, svg, svgText, toolName } from './lib/kz.ts'
5
6export const MAX_FAULTS = 300
7export const MAX_TEXT = 1500
8export const WINDOW_MS = 10 * 60_000
9export const BUCKETS = 20
10export const BUCKET_MS = WINDOW_MS / BUCKETS
11
12export const emptySnap = (now = 0): FaultlineSnap => ({ faults: [], total: 0, hits: [], now })
13
14const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g
15const NOISE = /^(?:exit code:?\s*-?\d+|error:?|<\/?[a-z_-]+>)$/i
16const LOUD = /error|fail|fatal|not found|cannot|can't|unable|denied|refused|rejected|exception|no such|invalid|timed? ?out|killed|panic|missing|unexpected|forbidden|unauthori[sz]ed|overloaded|rate.?limit/i
17export const DENIED = /doesn't want to proceed|did not want to proceed|user (?:rejected|denied|declined)|was rejected|permission .*denied|denied by|blocked by|not allowed/i
18
19/** The line of an error text that says what went wrong. */
20export function tellingLine(text: string): string {
21  const lines = text
22    .replace(ANSI, '')
23    .replace(/<\/?(?:tool_use_error|error|stderr|stdout)>/gi, '\n')
24    .split(/\r?\n/)
25    .map(l => l.trim())
26    .filter(l => l && !NOISE.test(l))
27  return lines.find(l => LOUD.test(l)) ?? lines[0] ?? 'unknown error'
28}
29
30/** Masks what varies between two instances of one error: URLs, paths, ids, numbers. */
31export function mask(line: string): string {
32  return line
33    .replace(/\bhttps?:\/\/\S+/gi, '<url>')
34    .replace(/\b[A-Za-z]:[\\/][^\s'"`:;,)\]]*/g, '<path>')
35    .replace(/(^|[\s'"`(=:])(?:~|\.{1,2})?(?:\/[^\s'"`:;,)\]]+)+/g, '$1<path>')
36    .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<id>')
37    .replace(/\b(?=[0-9a-f]*\d)[0-9a-f]{7,}\b/gi, '<id>')
38    .replace(/\d+(?:\.\d+)*/g, '#')
39    .replace(/\s+/g, ' ')
40    .trim()
41}
42
43/** The signature of one failure: its group key and the masked line shown for the group. */
44export function signatureOf(tool: string, text: string): { sig: string; head: string } {
45  const exit = /exit code:?\s*(-?\d+)/i.exec(text)
46  const head = clip(`${exit && tool !== 'api' ? 'exit # · ' : ''}${mask(tellingLine(text))}`, 120)
47  return { sig: `${tool}|${head}`, head }
48}
49
50export type FaultEvent = { tool: string; kind: FaultKind; text: string; at: number; who: string; detail?: string }
51
52/** Folds one failure into the snapshot: a new signature, or one more of a known one. */
53export function addFault(s: FaultlineSnap, ev: FaultEvent): Fault {
54  const { sig, head } = signatureOf(ev.tool, ev.text)
55  const known = s.faults.find(f => f.sig === sig)
56  let f: Fault
57  if (known) {
58    f = { ...known, count: known.count + 1, last: ev.at, agents: known.agents.includes(ev.who) ? known.agents : [...known.agents, ev.who].slice(-5) }
59  } else {
60    const text = ev.text.replace(ANSI, '').trim() || head
61    f = {
62      sig, tool: ev.tool, kind: ev.kind, head, detail: ev.detail, firstText: text.slice(0, MAX_TEXT), isTruncated: text.length > MAX_TEXT,
63      count: 1, first: ev.at, last: ev.at, agents: [ev.who],
64    }
65  }
66  s.faults = [f, ...s.faults.filter(x => x.sig !== sig)].slice(0, MAX_FAULTS)
67  s.total++
68  s.hits = [...s.hits.filter(t => t > ev.at - WINDOW_MS), ev.at].slice(-300)
69  s.now = Math.max(s.now, ev.at)
70  return f
71}
72
73/** Failures per 30 s over the last 10 minutes, oldest first. */
74export function buckets(hits: readonly number[], now: number): number[] {
75  const out = Array.from({ length: BUCKETS }, () => 0)
76  for (const t of hits) {
77    const age = now - t
78    if (age < 0 || age >= WINDOW_MS) continue
79    const i = BUCKETS - 1 - Math.floor(age / BUCKET_MS)
80    out[i] = out[i]! + 1 // 0 ≤ age < WINDOW_MS puts i in the strip
81  }
82  return out
83}
84
85/** Red by intensity: a dark ember at 0, full red at 1. */
86export function ember(k: number): string {
87  return k <= 0 ? '#4b5563' : mix('#7f1d1d', KZ.red, Math.min(1, k))
88}
89
90export function kindColor(k: FaultKind): string {
91  return k === 'denied' ? KZ.amber : k === 'api' ? KZ.magenta : k === 'turn' ? KZ.violet : KZ.red
92}
93
94export function kindLabel(f: Fault): string {
95  return f.kind === 'denied' ? 'denied' : f.kind === 'api' ? 'API' : f.kind === 'turn' ? 'turn' : toolName(f.tool)
96}
97
98export function clockOf(at: number): string {
99  const d = new Date(at)
100  const p = (n: number) => String(n).padStart(2, '0')
101  return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
102}
103
104export function recentCount(s: FaultlineSnap): number {
105  return s.hits.filter(t => t > s.now - WINDOW_MS).length
106}
107
108export function agentsOf(s: FaultlineSnap): number {
109  return new Set(s.faults.flatMap(f => f.agents)).size
110}
111
112/** What the copy button puts on the clipboard. */
113export function copyText(f: Fault): string {
114  return [
115    `${kindLabel(f)} — ${f.count}× (first ${clockOf(f.first)}, last ${clockOf(f.last)}; ${f.agents.join(', ')})`,
116    f.detail ? `on: ${f.detail}` : '',
117    '',
118    f.firstText + (f.isTruncated ? '\n…' : ''),
119  ].filter((l, i) => l || i === 2).join('\n')
120}
121
122/** The lines of the first error shown folded (`few`) or unfolded. */
123export function textLines(f: Fault, isOpen: boolean): string[] {
124  const lines = f.firstText.split(/\r?\n/).filter((l, i, all) => l.trim() || (i > 0 && i < all.length - 1))
125  const keep = isOpen ? 40 : 3
126  const out = lines.slice(0, keep)
127  if (lines.length > keep || (isOpen && f.isTruncated)) out.push('…')
128  return out
129}
130
131// ---------------------------------------------------------------------------
132// Desktop drawings.
133
134export function headerSvg(s: FaultlineSnap, W: number): { source: string; height: number } {
135  const H = 128
136  const parts: string[] = []
137  const recent = recentCount(s)
138  const b = buckets(s.hits, s.now)
139  const top = Math.max(1, ...b)
140  parts.push(`<defs><linearGradient id="fg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${KZ.red}" stop-opacity="${recent ? 0.2 : 0.08}"/><stop offset="1" stop-color="${KZ.amber}" stop-opacity=".03"/></linearGradient></defs>`)
141  parts.push(`<rect class="p" width="${W}" height="${H}" rx="14"/><rect width="${W}" height="${H}" rx="14" fill="url(#fg)"/>`)
142  // The fault-line glyph: a jagged crack.
143  parts.push(`<path d="M16 16 L26 28 L20 31 L32 44" fill="none" stroke="${recent ? KZ.red : KZ.mist}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" class="${recent ? 'pulse' : ''}"/>`)
144  parts.push(svgText(44, 27, 'FAULTLINE', { size: 13, weight: 800 }))
145  parts.push(svgText(44, 41, recent ? `${recent} failure${recent === 1 ? '' : 's'} in the last 10 min` : 'quiet for the last 10 min', { cls: 'm', size: 10.5 }))
146  parts.push(svgText(W - 14, 27, String(s.total), { size: 20, weight: 800, anchor: 'end', fill: s.total ? KZ.red : KZ.mist }))
147  parts.push(svgText(W - 14, 41, 'failures', { cls: 'm', size: 9.5, anchor: 'end' }))
148  // Severity strip.
149  const gx = 14
150  const gw = W - 28
151  const cw = gw / BUCKETS
152  b.forEach((n, i) => {
153    const k = n / top
154    parts.push(`<rect x="${(gx + i * cw + 1).toFixed(1)}" y="56" width="${(cw - 2).toFixed(1)}" height="22" rx="4" ${n ? `fill="${ember(k)}"` : 'class="k"'}><title>${n} in ${30 * (BUCKETS - i)}–${30 * (BUCKETS - i - 1)} s ago</title></rect>`)
155  })
156  parts.push(svgText(gx, 90, '10 min ago', { cls: 'm', size: 8.5 }))
157  parts.push(svgText(gx + gw, 90, 'now', { cls: 'm', size: 8.5, anchor: 'end' }))
158  const chips: [string, string][] = [
159    [`${s.faults.length} signatures`, KZ.mist],
160    [`${agentsOf(s)} agents`, KZ.violet],
161    [`${s.faults.filter(f => f.kind === 'denied').length} denied`, KZ.amber],
162  ]
163  let cx = gx
164  for (const [t, c] of chips) {
165    const w = t.length * 6 + 16
166    parts.push(`<rect x="${cx}" y="100" width="${w}" height="18" rx="9" fill="${c}" opacity=".16"/>`)
167    parts.push(svgText(cx + w / 2, 113, t, { size: 10, weight: 600, anchor: 'middle', fill: c }))
168    cx += w + 6
169  }
170  return { source: svg(W, H, parts.join('')), height: H }
171}
172
173export function faultSvg(f: Fault, W: number, maxCount: number, isOpen: boolean): { source: string; height: number } {
174  const lines = isOpen ? [] : textLines(f, false).slice(0, 2)
175  const H = 58 + lines.length * 14 + (lines.length ? 6 : 0)
176  const c = kindColor(f.kind)
177  const k = f.count / Math.max(1, maxCount)
178  const parts: string[] = []
179  parts.push(`<rect class="p" width="${W}" height="${H}" rx="12"/>`)
180  parts.push(`<rect width="4" height="${H}" rx="2" fill="${c}"/>`)
181  // Count badge, redder with more hits.
182  const label = f.count > 999 ? '999+' : `${f.count}×`
183  const bw = Math.max(30, label.length * 7.5 + 10)
184  parts.push(`<rect x="12" y="10" width="${bw}" height="20" rx="10" fill="${ember(0.25 + k * 0.75)}"/>`)
185  parts.push(svgText(12 + bw / 2, 24, label, { size: 11, weight: 800, anchor: 'middle', fill: '#fff' }))
186  const kl = kindLabel(f)
187  const kw = Math.min(120, kl.length * 6.4 + 14)
188  parts.push(`<rect x="${18 + bw}" y="10" width="${kw}" height="20" rx="6" fill="${c}" opacity=".16"/>`)
189  parts.push(svgText(18 + bw + kw / 2, 24, fitText(kl, 10.5, kw - 8), { size: 10.5, weight: 700, anchor: 'middle', fill: c }))
190  parts.push(svgText(W - 12, 24, clockOf(f.last), { cls: 'm', size: 10, anchor: 'end' }))
191  parts.push(svgText(14, 46, fitText(f.head, 11.5, W - 26), { size: 11.5, weight: 600 }))
192  const meta = [`first ${clockOf(f.first)}`, f.agents.join(', '), f.detail ?? ''].filter(Boolean).join(' · ')
193  parts.push(svgText(14, 60 - 2, fitText(meta, 9.5, W - 26), { cls: 'm', size: 9.5 }))
194  lines.forEach((l, i) => {
195    parts.push(svgText(14, 76 + i * 14, fitText(l, 10, W - 26), { cls: 's', size: 10, mono: true }))
196  })
197  return { source: svg(W, H, parts.join('')), height: H }
198}
199
200export function altOf(f: Fault): string {
201  return `${f.count} times ${kindLabel(f)}: ${f.head}; first ${clockOf(f.first)}, last ${clockOf(f.last)}; ${f.agents.join(', ')}`
202}
203
types/index.d.ts 47 lines
1/** What failed: a tool error, a refused/denied tool call, a turn that ended badly, an API error. */
2export type FaultKind = 'error' | 'denied' | 'turn' | 'api'
3
4/** One error signature: every failure whose tool and masked first line match. */
5export type Fault = {
6  /** `tool|masked line`: the group key. */
7  sig: string
8  /** The tool (`Bash`, `mcp__x__y`), or `turn` / `api`. */
9  tool: string
10  kind: FaultKind
11  /** The first telling error line with numbers, paths, URLs and ids masked. */
12  head: string
13  /** What the first failing call was about (its command, file, URL). */
14  detail?: string
15  /** The full error text of the first occurrence, clipped at 1500 characters. */
16  firstText: string
17  isTruncated: boolean
18  count: number
19  /** First and last time seen, `$.clock.now()` milliseconds. */
20  first: number
21  last: number
22  /** Who hit it: `main` and subagent descriptions, at most 5. */
23  agents: string[]
24}
25
26export type FaultlineSnap = {
27  /** Most recently seen first, at most 300 signatures. */
28  faults: Fault[]
29  /** Failures over the whole session. */
30  total: number
31  /** Times of the failures of the last 10 minutes (at most 300). */
32  hits: number[]
33  /** The clock the severity strip is drawn at, moved while it has recent hits. */
34  now: number
35}
36
37export type FaultlineView = {
38  /** Signatures whose full error text is unfolded. */
39  expanded: string[]
40}
41
42declare module 'claude-code' {
43  interface PluginState {
44    faultline: { snap: FaultlineSnap | null; view: FaultlineView }
45  }
46}
47