Kintsugi for your session: failed operations crack a lacquer tile, and the run that fixes them repairs it with animated gold seams


Three commands fail and crack their tiles; after the fixes, two tiles are mended with gold seams and the build stays broken. Watch the MP4
Kintsugi for your Claude Code session. Every operation that fails (a test run, a type-check, an Edit whose text wasn't found, a fetch that 404s) becomes a glossy indigo lacquer tile, and the failure cracks it: braille fracture lines race out from an impact point while the tile shakes. When a later run of the same operation succeeds, the cracks fill with gold. A bright seam sweeps along each fracture, then glints for a moment. The tile is mended and more beautiful than before. Failures nobody fixed stay broken.
◈ 7 cracks · 5 repaired in gold · 2 still broken. It stacks with other mods' bands./kintsugi: opens the full wall in a pane, newest tiles first, each labelled with the operation and its counts (× failures, ◆ repairs), plus a list of what is still broken./kintsugi band off|on, /kintsugi clear, and /kintsugi gallery (cracks and repairs across your last 200 sessions).An operation is keyed by tool and target: the exact shell command (so cd web && npm test and cd api && npm test are different tiles; the label shows the short form), the file for an edit, the URL for a fetch, the query or pattern and path for a search, and every plain argument for an MCP tool (issue 1 and issue 2 are different tiles). Successes only matter for an operation that already broke. Interruptions, refusals, edits held for review, and shell runs that were backgrounded or timed out prove nothing either way. A fetch that returns an HTTP error (4xx/5xx) counts as a failure. /kintsugi clear resets the wall and this session's counts; the band and pane count every crack and repair this session, even ones whose tiles have rolled off the 48-tile wall.
The animation runs at about 30fps only while a crack is growing or gold is sweeping. Otherwise nothing is repainted. On the desktop surface (no Raster) the band and pane fall back to text.
/plugin marketplace add ccdwyer/claude-mods
/plugin install fault-lacquer@ccdwyer-mods
/reload-plugins
claude plugin validate .
claude plugin test .
Events this mod hooks, as claude plugin validate reads the module:
session.start: registers /kintsugi and starts the frame clocksession.end: adds the session's summary to the lifetime gallerycommand.run for /kintsugitool.call: after the call runs, notes whether it failed or succeededui.render for AbovePrompt (the band) and for its own Pane (the wall)Engine calls it makes: $.clock.every, $.command.register, $.state.get, $.state.set, $.store.get, $.store.set, $.ui.blit, $.ui.invalidate, $.ui.open, $.ui.resolve.
A tool.call hook sits in the middle of every tool call: it can see the call, refuse it, or add context to its result. This mod only reads whether the call failed; it never refuses or changes a call.
It runs entirely on your machine. It sends nothing over the network. It looks only at whether each tool call failed or succeeded and at the call's command, file path or URL, which it uses to label tiles. It keeps the session's wall in Claude Code's session state and, at the end of a session, a small summary (counts and the label of the most-failed operation) in Claude Code's local plugin store.
The mod collects no analytics or telemetry, and its author receives no data from it.
Full policy: PRIVACY.md.
MIT
hooks/register.tsx 262 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Exhibit, Tile } from '../types'
5import { applyOutcome, bandCells, isAnimating, isInterruption, isUnfinished, opOf, statsLine, statsOf, wallCells, wallLayout } from './lacquer'
6import type { Stats, WallLayout } from './lacquer'
7
8const tiles = atom({ plugin: 'fault-lacquer', key: 'tiles' } as const, [])
9const band = atom({ plugin: 'fault-lacquer', key: 'band' } as const, true)
10const totals = atom({ plugin: 'fault-lacquer', key: 'totals' } as const, { cracks: 0, repaired: 0 })
11
12const PANE = 'fault-lacquer'
13const BAND_KEY = 'strip'
14const WALL_KEY = 'wall'
15const FRAME_MS = 33
16const GALLERY = 'gallery'
17const GALLERY_MAX = 200
18const TILE_MIN_ROWS = 7
19
20// The animation's own bookkeeping lives in the module: it is cosmetic, and a reload starts it afresh.
21let wall: Tile[] = []
22let frame = 0
23let settled = true
24// The frame clock runs only while something animates.
25let timer: { cancel: () => void } | null = null
26// The newest frame painted, so a slow frame never paints over a newer one.
27let painted = 0
28let bandSite: { requestId: string; columns: number } | null = null
29let paneSite: { requestId: string; layout: WallLayout; maxRows: number } | null = null
30
31/** One animation frame: repaint whatever is mounted while a crack grows or gold sweeps, then go quiet. */
32async function tick($: EngineInterface): Promise<void> {
33 const now = Date.now()
34 const animating = isAnimating(wall, now)
35 if (!animating && settled) {
36 // Nothing moves: stop the clock until the next outcome starts it again.
37 if (timer !== null) timer.cancel()
38 timer = null
39 return
40 }
41 // One more frame after the animation ends, so the resting state is what stays on screen.
42 settled = !animating
43 frame += 1
44 const mine = frame
45 const opts = { now, frame }
46 if (mine < painted) return
47 painted = mine
48 if (bandSite !== null) {
49 const cells = bandCells(wall, bandSite.columns, opts).encode()
50 const res = await $.ui.blit({ requestId: bandSite.requestId, key: BAND_KEY, cells, columns: bandSite.columns, rows: 1 })
51 if (res.deny !== undefined) bandSite = null
52 }
53 if (paneSite !== null) {
54 const layout = paneSite.layout
55 if (wallLayout(wall.length, layout.columns, paneSite.maxRows).rows !== layout.rows) {
56 // More tiles than the mounted Raster holds: a resize is a redraw, not a blit.
57 $.ui.invalidate('ui.render')
58 return
59 }
60 const cells = wallCells(wall, layout, opts).encode()
61 const res = await $.ui.blit({ requestId: paneSite.requestId, key: WALL_KEY, cells, columns: layout.columns, rows: layout.rows })
62 if (res.deny !== undefined) paneSite = null
63 }
64}
65
66/** Starts the frame clock if it is not running; the callback returns its frame, so frames never overlap. */
67function startClock($: EngineInterface): void {
68 if (timer !== null) return
69 timer = $.clock.every(FRAME_MS, () => tick($))
70}
71
72/** The session's stats: every crack and repair counted, and what is broken on the wall now. */
73function sessionStats(list: readonly Tile[], t: { cracks: number; repaired: number }): Stats {
74 return { cracks: t.cracks, repaired: t.repaired, broken: list.filter(x => x.state === 'cracked').length }
75}
76
77/** Adds this session to the lifetime gallery, newest last. */
78async function exhibit($: EngineInterface, list: readonly Tile[]): Promise<void> {
79 const t = await read($, totals)
80 if (t.cracks === 0 && list.length === 0) return
81 const s = sessionStats(list, t)
82 const worst = list.slice().sort((p, q) => q.fails - p.fails)[0]
83 const entry: Exhibit = { at: Date.now(), cracks: s.cracks, repaired: s.repaired, broken: s.broken, worst: worst === undefined ? '' : worst.label }
84 const prior = await $.store.get(GALLERY)
85 const past = Array.isArray(prior) ? (prior as Exhibit[]) : []
86 await $.store.set(GALLERY, [...past, entry].slice(-GALLERY_MAX))
87}
88
89/** The lifetime gallery as one line. */
90async function galleryLine($: EngineInterface): Promise<string> {
91 const prior = await $.store.get(GALLERY)
92 const past = Array.isArray(prior) ? (prior as Exhibit[]) : []
93 if (past.length === 0) return 'Fault Lacquer gallery: no finished sessions yet.'
94 let cracks = 0
95 let repaired = 0
96 for (const x of past) {
97 cracks += x.cracks
98 repaired += x.repaired
99 }
100 const kept = past.length >= GALLERY_MAX ? ` (the last ${GALLERY_MAX})` : ''
101 return `Fault Lacquer gallery: ${past.length} session${past.length === 1 ? '' : 's'}${kept} · ${cracks} crack${cracks === 1 ? '' : 's'} · ${repaired} repaired in gold.`
102}
103
104export const register: Register = on => {
105 on('session.start', async ($, e, next) => {
106 await $.command.register({
107 name: 'kintsugi',
108 description: 'Fault Lacquer: open the kintsugi wall of failed and mended operations',
109 argumentHint: '[band on|off | gallery | clear]',
110 immediate: true,
111 })
112 wall = await read($, tiles)
113 timer = null
114 settled = false
115 startClock($)
116 return next(e)
117 })
118
119 on('session.end', async ($, e, next) => {
120 await exhibit($, await read($, tiles))
121 // A /clear or a resume starts a new session without a session.start: drop the ended wall from screen.
122 wall = []
123 bandSite = null
124 paneSite = null
125 await update($, tiles, () => [])
126 await update($, totals, () => ({ cracks: 0, repaired: 0 }))
127 $.ui.invalidate('ui.render')
128 return next(e)
129 })
130
131 on('command.run', { command: 'kintsugi' }, async ($, e) => {
132 const args = e.args.trim().toLowerCase()
133 if (args === 'band on' || args === 'band off') {
134 await update($, band, () => args === 'band on')
135 return { text: `Fault Lacquer: band ${args === 'band on' ? 'on' : 'off'}.` }
136 }
137 if (args === 'gallery') return { text: await galleryLine($) }
138 if (args === 'clear') {
139 // A clean slate: the wall and this session's crack and repair counts (the gallery keeps past sessions).
140 wall = await update($, tiles, () => [])
141 await update($, totals, () => ({ cracks: 0, repaired: 0 }))
142 return { text: 'Fault Lacquer: the wall is clear.' }
143 }
144 await $.ui.open({ id: PANE, title: 'Kintsugi', rows: 16 })
145 const list = await read($, tiles)
146 const t = await read($, totals)
147 return { text: list.length === 0 && t.cracks === 0 ? 'Fault Lacquer: nothing has broken yet.' : `Fault Lacquer: ${statsLine(sessionStats(list, t))}.` }
148 })
149
150 on('tool.call', async ($, e, next) => {
151 const ran = await next(e)
152 if (ran.deny !== undefined) return ran
153 const op = opOf(e as { tool: string })
154 if (op === null) return ran
155 const result = ran.result as Record<string, unknown> | undefined
156 // A run that never finished in the foreground, or an edit held for review, proves nothing either way.
157 if (e.tool === 'Bash' && isUnfinished(result)) return ran
158 if (result !== undefined && result !== null && typeof result === 'object' && result.staged === true) return ran
159 // A fetch that came back with an HTTP error is a failure even though the tool itself succeeded.
160 const httpFailed = e.tool === 'WebFetch' && typeof result?.code === 'number' && (result.code as number) >= 400
161 const failed = ran.isError === true || httpFailed
162 if (ran.isError === true && isInterruption(String(ran.text ?? (typeof ran.result === 'string' ? ran.result : '')))) return ran
163 const now = Date.now()
164 // Decided against the current wall (update's compare-and-swap), never a snapshot taken before the call ran.
165 let changed = false
166 const next_ = await update($, tiles, cur => {
167 const out = applyOutcome(cur, op, failed, now)
168 changed = out !== cur
169 return out
170 })
171 if (!changed) return ran
172 wall = next_
173 await update($, totals, t => (failed ? { ...t, cracks: t.cracks + 1 } : { ...t, repaired: t.repaired + 1 }))
174 settled = false
175 startClock($)
176 return ran
177 })
178
179 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
180 if (e.props.hasSurvey) return next(e)
181 const list = await read($, tiles)
182 const isOn = await read($, band)
183 if (!isOn || list.length === 0) {
184 if (bandSite !== null && bandSite.requestId === e.requestId) bandSite = null
185 return next(e)
186 }
187 const t = await read($, totals)
188 // One row here: the mods beneath get the rest of the band.
189 const below = await next({ ...e, props: { ...e.props, maxRows: Math.max(0, e.props.maxRows - 1) } })
190 const { Box, Text } = $.ui.resolve(e)
191 if (e.surface !== 'terminal') {
192 return (
193 <Box flexDirection="column">
194 <Text>◈ kintsugi: {statsLine(sessionStats(list, t))}</Text>
195 {below}
196 </Box>
197 )
198 }
199 const { Raster } = $.ui.resolve(e)
200 const columns = Math.max(20, Math.min(512, e.props.bodyColumns))
201 bandSite = { requestId: e.requestId, columns }
202 const cells = bandCells(list, columns, { now: Date.now(), frame }, sessionStats(list, t)).encode()
203 return (
204 <Box flexDirection="column">
205 <Raster key={BAND_KEY} columns={columns} rows={1} cells={cells} />
206 {below}
207 </Box>
208 )
209 })
210
211 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
212 const list = await read($, tiles)
213 const s = sessionStats(list, await read($, totals))
214 const { Box, Text } = $.ui.resolve(e)
215 if (list.length === 0) {
216 paneSite = null
217 return (
218 <Box flexDirection="column">
219 <Text bold>Kintsugi</Text>
220 <Text dimColor>Nothing has broken yet. When an operation fails its tile cracks; the run that fixes it mends the crack in gold.</Text>
221 </Box>
222 )
223 }
224 const broken = list.filter(t => t.state === 'cracked').map(t => t.label)
225 if (e.surface !== 'terminal') {
226 paneSite = null
227 return (
228 <Box flexDirection="column">
229 <Text bold>Kintsugi</Text>
230 <Text>{statsLine(s)}</Text>
231 {list.map(t => (
232 <Text key={t.key}>
233 {t.state === 'gold' ? '◆ mended' : '× broken'} · {t.label} · failed {t.fails}×
234 </Text>
235 ))}
236 </Box>
237 )
238 }
239 const { Raster } = $.ui.resolve(e)
240 // The pane's own body, less the header and the legend lines: never the whole terminal.
241 const bodyRows = e.props.scroll?.bodyRows ?? Math.floor((e.viewport?.rows ?? 40) / 3)
242 const maxRows = Math.max(TILE_MIN_ROWS, bodyRows - 4)
243 const layout = wallLayout(list.length, e.props.bodyColumns, maxRows)
244 paneSite = { requestId: e.requestId, layout, maxRows }
245 const cells = wallCells(list, layout, { now: Date.now(), frame }).encode()
246 return (
247 <Box flexDirection="column">
248 <Text bold color="yellow">
249 ◈ KINTSUGI · {statsLine(s)}
250 </Text>
251 <Raster key={WALL_KEY} columns={layout.columns} rows={layout.rows} cells={cells} />
252 {layout.shown < list.length && <Text dimColor>{list.length - layout.shown} older tiles not shown.</Text>}
253 <Text dimColor>
254 Lacquer tile = an operation that failed. White cracks: still broken. Gold seams: a later run fixed it. × failures ◆ repairs.
255 </Text>
256 {broken.length > 0 && <Text color="red">still broken: {broken.slice(0, 8).join(' · ')}</Text>}
257 </Box>
258 )
259 })
260}
261
262hooks/lacquer.ts 491 lines1import type { Tile } from '../types'
2
3/** The terminal's own colour, as a Raster cell spells it. */
4export const DEFAULT = 0x01000000
5
6export const TILE_COLUMNS = 16
7export const TILE_ROWS = 5 // four art rows and a label row
8export const MAX_TILES = 48
9export const CRACK_MS = 650
10export const SHAKE_MS = 260
11export const GOLD_MS = 1300
12export const SHIMMER_MS = 3200
13export const FLASH_MS = 900
14
15const PORCELAIN = 0xf3efe4
16const PORCELAIN_DIM = 0xb9b4a6
17const GOLD = 0xffcc4d
18const GOLD_DEEP = 0xc9921c
19const GLINT = 0xfff6cf
20const BROKEN_RED = 0xff5d6c
21const LABEL_BG = 0x0b0c26
22const GAP_BG = 0x06061a
23
24// ── Raster plumbing ──────────────────────────────────────────────────────────
25
26/** Standard padded base64, without relying on the environment's encoders. */
27export function base64(bytes: Uint8Array): string {
28 const abc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
29 let out = ''
30 let i = 0
31 for (; i + 2 < bytes.length; i += 3) {
32 const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8) | (bytes[i + 2] as number)
33 out += abc[(n >> 18) & 63]! + abc[(n >> 12) & 63]! + abc[(n >> 6) & 63]! + abc[n & 63]!
34 }
35 const rest = bytes.length - i
36 if (rest === 1) {
37 const n = (bytes[i] as number) << 16
38 out += abc[(n >> 18) & 63]! + abc[(n >> 12) & 63]! + '=='
39 } else if (rest === 2) {
40 const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8)
41 out += abc[(n >> 18) & 63]! + abc[(n >> 12) & 63]! + abc[(n >> 6) & 63]! + '='
42 }
43 return out
44}
45
46export class Grid {
47 readonly cells: Uint32Array
48 constructor(
49 readonly columns: number,
50 readonly rows: number,
51 ) {
52 this.cells = new Uint32Array(columns * rows * 3)
53 for (let i = 0; i < columns * rows; i += 1) {
54 this.cells[i * 3] = 0x20
55 this.cells[i * 3 + 1] = DEFAULT
56 this.cells[i * 3 + 2] = DEFAULT
57 }
58 }
59
60 set(x: number, y: number, code: number, fg: number, bg: number): void {
61 if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return
62 const i = (y * this.columns + x) * 3
63 this.cells[i] = code
64 this.cells[i + 1] = fg
65 this.cells[i + 2] = bg
66 }
67
68 /** Writes text, one printable width-1 character per cell; anything else becomes '?'. */
69 text(x: number, y: number, text: string, fg: number, bg: number): number {
70 let col = x
71 for (const ch of text) {
72 if (col >= this.columns) break
73 this.set(col, y, safeCode(ch), fg, bg)
74 col += 1
75 }
76 return col
77 }
78
79 encode(): string {
80 const bytes = new Uint8Array(this.cells.length * 4)
81 for (let i = 0; i < this.cells.length; i += 1) {
82 const v = this.cells[i] as number
83 bytes[i * 4] = v & 0xff
84 bytes[i * 4 + 1] = (v >>> 8) & 0xff
85 bytes[i * 4 + 2] = (v >>> 16) & 0xff
86 bytes[i * 4 + 3] = (v >>> 24) & 0xff
87 }
88 return base64(bytes)
89 }
90}
91
92/** A code point the Raster accepts: printable, width 1, in the BMP. Everything else is '?'. */
93export function safeCode(ch: string): number {
94 const c = ch.codePointAt(0) ?? 0x3f
95 // A conservative allowlist of characters every terminal draws in exactly one cell: ASCII,
96 // Latin, Greek and Cyrillic letters (no combining marks), common punctuation, arrows, math,
97 // box drawing, blocks, geometric shapes and braille. Joiners, variation selectors and
98 // everything else draw as "?".
99 const ranges: Array<[number, number]> = [
100 [0x20, 0x7e], [0xa0, 0xac], [0xae, 0x2ff], [0x370, 0x482], [0x48a, 0x52f],
101 [0x2010, 0x2027], [0x2030, 0x205e], [0x20a0, 0x20bf], [0x2100, 0x214f], [0x2190, 0x21ff],
102 [0x2200, 0x22ff], [0x2500, 0x25ff], [0x2800, 0x28ff],
103 ]
104 for (const [lo, hi] of ranges) if (c >= lo && c <= hi) return c
105 return 0x3f
106}
107
108export function rgb(r: number, g: number, b: number): number {
109 const c = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
110 return (c(r) << 16) | (c(g) << 8) | c(b)
111}
112
113function mix(a: number, b: number, t: number): number {
114 const k = Math.max(0, Math.min(1, t))
115 return rgb(
116 ((a >> 16) & 255) * (1 - k) + ((b >> 16) & 255) * k,
117 ((a >> 8) & 255) * (1 - k) + ((b >> 8) & 255) * k,
118 (a & 255) * (1 - k) + (b & 255) * k,
119 )
120}
121
122// ── Operations → tiles ───────────────────────────────────────────────────────
123
124export type Op = { key: string; label: string; tool: string }
125
126function baseName(path: string): string {
127 const parts = path.split('/').filter(p => p.length > 0)
128 return parts[parts.length - 1] ?? path
129}
130
131/** A shell command as a short display label: no leading `cd x &&`, no env assignments, single spaces. Display only: never identity. */
132export function normalizeCommand(command: string): string {
133 let text = command.replace(/\s+/g, ' ').trim()
134 for (let guard = 0; guard < 4; guard += 1) {
135 const cd = /^cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*/.exec(text)
136 if (cd === null) break
137 text = text.slice(cd[0].length)
138 }
139 text = text.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, '')
140 return text
141}
142
143function shorten(text: string, room: number): string {
144 return text.length > room ? `${text.slice(0, room - 1)}…` : text
145}
146
147/** Which tile a tool call belongs to, or null for calls that are not operations worth a tile. */
148export function opOf(e: { tool: string; [k: string]: unknown }): Op | null {
149 const tool = String(e.tool)
150 const file = typeof e.file_path === 'string' ? e.file_path : typeof e.notebook_path === 'string' ? e.notebook_path : undefined
151 if (tool === 'Bash') {
152 // Identity is the exact command: `cd web && npm test` and `cd api && npm test` are different operations,
153 // and whitespace inside quotes matters. The label is the short, normalized form.
154 const exact = String(e.command ?? '').trim()
155 if (exact.length === 0) return null
156 return { key: `Bash:${exact}`, label: shorten(normalizeCommand(exact), TILE_COLUMNS - 2), tool }
157 }
158 if (file !== undefined && file.length > 0) {
159 const verb = tool === 'NotebookEdit' ? 'Nb' : tool
160 return { key: `${tool}:${file}`, label: shorten(`${verb} ${baseName(file)}`, TILE_COLUMNS - 2), tool }
161 }
162 const short = tool.startsWith('mcp__') ? tool.split('__').slice(1).join('/') : tool
163 // The arguments that define the operation: for the built-in lookups their target and scope (a retried
164 // WebFetch with a different prompt is the same fetch); for an MCP tool every plain argument (the issue number).
165 const fields = IDENTITY[tool] ?? (tool.startsWith('mcp__') ? Object.keys(e).filter(k => !IGNORED_ARGS.has(k)) : null)
166 if (fields === null) return null
167 const args = fields
168 .slice()
169 .sort()
170 .map(k => [k, e[k]] as const)
171 .filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object')
172 if (args.length === 0 && !tool.startsWith('mcp__')) return null
173 const target = [e.url, e.pattern, e.query].find(v => typeof v === 'string' && v.length > 0)
174 const shown = typeof target === 'string' ? `${short} ${target}` : short
175 return { key: `${tool}:${JSON.stringify(args)}`, label: shorten(shown, TILE_COLUMNS - 2), tool }
176}
177
178const IDENTITY: Record<string, string[]> = {
179 WebFetch: ['url'],
180 WebSearch: ['query', 'allowed_domains', 'blocked_domains'],
181 Grep: ['pattern', 'path', 'glob', 'type'],
182 Glob: ['pattern', 'path'],
183}
184
185const IGNORED_ARGS = new Set(['tool', 'tool_use_id', 'agentId', 'consent', 'description'])
186
187/**
188 * A result the person or the engine cut short is not a failure of the operation. Only the engine's own
189 * short messages count: a test log that merely contains "interrupted" is a real failure.
190 */
191export function isInterruption(text: string): boolean {
192 const t = text.trim()
193 if (t.length > 300) return false
194 return /^\[?request interrupted by user[^\]]*\]?$|^the user (?:doesn't want|rejected|declined)|request was aborted|aborted before completion|^interrupted\b/i.test(t)
195}
196
197/** A Bash run that never finished in the foreground (backgrounded, timed out, interrupted) proved nothing. */
198export function isUnfinished(result: unknown): boolean {
199 if (result === null || typeof result !== 'object') return false
200 const r = result as Record<string, unknown>
201 return r.interrupted === true || r.backgroundTaskId !== undefined || r.backgroundedByUser === true || r.timedOutAfterMs !== undefined || r.backgroundedByTurnAbort === true
202}
203
204function hashSeed(text: string): number {
205 let h = 2166136261
206 for (let i = 0; i < text.length; i += 1) {
207 h ^= text.charCodeAt(i)
208 h = Math.imul(h, 16777619)
209 }
210 return h >>> 0
211}
212
213/** The wall after one finished operation: a failure cracks (or re-cracks) its tile; a success repairs a cracked one. */
214export function applyOutcome(tiles: readonly Tile[], op: Op, failed: boolean, now: number): Tile[] {
215 const index = tiles.findIndex(t => t.key === op.key)
216 if (index < 0) {
217 if (!failed) return tiles as Tile[]
218 const tile: Tile = { key: op.key, label: op.label, tool: op.tool, fails: 1, repairs: 0, state: 'cracked', seed: hashSeed(op.key), crackedAt: now, repairedAt: 0, lastAt: now }
219 return trim([...tiles, tile])
220 }
221 const was = tiles[index] as Tile
222 let tile: Tile
223 if (failed) tile = { ...was, fails: was.fails + 1, state: 'cracked', crackedAt: now, lastAt: now }
224 else if (was.state === 'cracked') tile = { ...was, repairs: was.repairs + 1, state: 'gold', repairedAt: now, lastAt: now }
225 else return tiles as Tile[]
226 const out = tiles.slice()
227 out[index] = tile
228 return out
229}
230
231/** Keep the wall bounded: drop the oldest repaired tiles first, then the oldest broken ones. */
232function trim(tiles: Tile[]): Tile[] {
233 if (tiles.length <= MAX_TILES) return tiles
234 const out = tiles.slice()
235 while (out.length > MAX_TILES) {
236 let victim = -1
237 for (let i = 0; i < out.length; i += 1) if ((out[i] as Tile).state === 'gold') { victim = i; break }
238 out.splice(victim < 0 ? 0 : victim, 1)
239 }
240 return out
241}
242
243export type Stats = { cracks: number; repaired: number; broken: number }
244
245export function statsOf(tiles: readonly Tile[]): Stats {
246 let cracks = 0
247 let repaired = 0
248 let broken = 0
249 for (const t of tiles) {
250 cracks += t.fails
251 if (t.state === 'gold') repaired += 1
252 else broken += 1
253 }
254 return { cracks, repaired, broken }
255}
256
257export function statsLine(s: Stats): string {
258 const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
259 return `${plural(s.cracks, 'crack')} · ${s.repaired} repaired in gold · ${s.broken} still broken`
260}
261
262/** True while any tile is mid-animation at `now`. */
263export function isAnimating(tiles: readonly Tile[], now: number): boolean {
264 return tiles.some(t => now - t.crackedAt < Math.max(CRACK_MS, FLASH_MS) || (t.state === 'gold' && now - t.repairedAt < GOLD_MS + SHIMMER_MS))
265}
266
267// ── Crack geometry ───────────────────────────────────────────────────────────
268
269function prng(seed: number): () => number {
270 let s = seed >>> 0 || 1
271 return () => {
272 s ^= s << 13
273 s >>>= 0
274 s ^= s >>> 17
275 s ^= s << 5
276 s >>>= 0
277 return s / 4294967296
278 }
279}
280
281export type Point = { x: number; y: number }
282
283/**
284 * The tile's cracks in sub-pixels (2 across and 4 down per cell): from each impact point, a few jagged
285 * branches running out to the edges. One impact per failure, up to three, all from the tile's seed.
286 */
287export function crackPaths(seed: number, fails: number, width: number, height: number): Point[][] {
288 const paths: Point[][] = []
289 const impacts = Math.max(1, Math.min(3, fails))
290 for (let k = 0; k < impacts; k += 1) {
291 const rand = prng(seed + k * 7919)
292 const ox = width * (0.3 + rand() * 0.4)
293 const oy = height * (0.25 + rand() * 0.5)
294 const branches = 3 + Math.floor(rand() * 2)
295 const turn = rand() * Math.PI * 2
296 for (let b = 0; b < branches; b += 1) {
297 let angle = turn + (b / branches) * Math.PI * 2 + (rand() - 0.5) * 0.9
298 let x = ox
299 let y = oy
300 const path: Point[] = [{ x: Math.round(x), y: Math.round(y) }]
301 for (let step = 0; step < 60; step += 1) {
302 angle += (rand() - 0.5) * 0.9
303 x += Math.cos(angle) * 1.6
304 y += Math.sin(angle) * 1.1
305 if (x < 0 || y < 0 || x > width - 1 || y > height - 1) break
306 path.push({ x: Math.round(x), y: Math.round(y) })
307 }
308 paths.push(path)
309 }
310 }
311 return paths
312}
313
314/** Every sub-pixel along a path, in order, with a gap-free line between its points. */
315export function rasterize(path: readonly Point[]): Point[] {
316 const out: Point[] = []
317 for (let i = 0; i < path.length; i += 1) {
318 const a = path[i] as Point
319 if (i === 0) {
320 out.push(a)
321 continue
322 }
323 const p = path[i - 1] as Point
324 let x0 = p.x
325 let y0 = p.y
326 const dx = Math.abs(a.x - x0)
327 const dy = -Math.abs(a.y - y0)
328 const sx = x0 < a.x ? 1 : -1
329 const sy = y0 < a.y ? 1 : -1
330 let err = dx + dy
331 for (let guard = 0; guard < 64; guard += 1) {
332 if (x0 === a.x && y0 === a.y) break
333 const e2 = 2 * err
334 if (e2 >= dy) {
335 err += dy
336 x0 += sx
337 }
338 if (e2 <= dx) {
339 err += dx
340 y0 += sy
341 }
342 out.push({ x: x0, y: y0 })
343 }
344 }
345 return out
346}
347
348const BRAILLE_BITS = [
349 [0x01, 0x08],
350 [0x02, 0x10],
351 [0x04, 0x20],
352 [0x40, 0x80],
353]
354
355// ── Drawing ──────────────────────────────────────────────────────────────────
356
357/** Glossy lacquer: deep indigo with a soft diagonal highlight; a broken tile shows two shard tones. */
358function lacquerBg(cx: number, cy: number, cols: number, rows: number, shard: boolean, warm: boolean): number {
359 const t = (cx / Math.max(1, cols - 1)) * 0.6 + (cy / Math.max(1, rows - 1)) * 0.4
360 let base = mix(0x34389a, 0x0f1036, t)
361 const gloss = Math.abs(cx - cy * 2 - cols * 0.25)
362 if (gloss < 2.2) base = mix(base, 0x7d83e0, 0.35 * (1 - gloss / 2.2))
363 if (shard) base = mix(base, 0x0a0b25, 0.32)
364 if (warm) base = mix(base, 0x3a2a12, 0.12)
365 return base
366}
367
368export type DrawOptions = { now: number; frame: number }
369
370/** One tile at (x0, y0) in grid cells: lacquer, shards, cracks growing or filled with gold, label and counts. */
371export function drawTile(grid: Grid, tile: Tile, x0: number, y0: number, opts: DrawOptions): void {
372 const { now, frame } = opts
373 const artRows = TILE_ROWS - 1
374 const width = TILE_COLUMNS * 2
375 const height = artRows * 4
376 const sinceCrack = now - tile.crackedAt
377 const shake = sinceCrack < SHAKE_MS ? (frame % 2 === 0 ? 1 : -1) : 0
378 const growth = Math.min(1, sinceCrack / CRACK_MS)
379 const gold = tile.state === 'gold'
380 const sinceGold = now - tile.repairedAt
381 const sweep = gold ? Math.min(1, sinceGold / GOLD_MS) : 0
382 const shimmering = gold && sinceGold >= GOLD_MS && sinceGold < GOLD_MS + SHIMMER_MS
383 const paths = crackPaths(tile.seed, tile.fails, width, height)
384
385 // Per sub-pixel: 1 = porcelain crack, 2 = gold seam, 3 = gold glint.
386 const marks = new Uint8Array(width * height)
387 const first = paths[0] ?? []
388 for (const path of paths) {
389 const dots = rasterize(path)
390 const shown = Math.ceil(dots.length * growth)
391 for (let i = 0; i < shown; i += 1) {
392 const d = dots[i] as Point
393 if (d.x < 0 || d.y < 0 || d.x >= width || d.y >= height) continue
394 const along = i / Math.max(1, dots.length - 1)
395 let mark = 1
396 if (gold && along <= sweep) mark = 2
397 if (shimmering) {
398 const phase = ((sinceGold - GOLD_MS) / 900) % 1.6
399 if (Math.abs(along - phase) < 0.08) mark = 3
400 }
401 if (gold && sweep < 1 && Math.abs(along - sweep) < 0.05) mark = 3
402 marks[d.y * width + d.x] = Math.max(marks[d.y * width + d.x] as number, mark)
403 }
404 }
405
406 // Which side of the first crack a cell falls on decides its shard tone.
407 const a = first[0] ?? { x: width / 2, y: height / 2 }
408 const b = first[first.length - 1] ?? { x: width, y: height }
409 for (let cy = 0; cy < artRows; cy += 1) {
410 for (let cx = 0; cx < TILE_COLUMNS; cx += 1) {
411 const px = cx * 2 + 1
412 const py = cy * 4 + 2
413 const side = (b.x - a.x) * (py - a.y) - (b.y - a.y) * (px - a.x) > 0
414 const bg = lacquerBg(cx, cy, TILE_COLUMNS, artRows, side, gold)
415 let bits = 0
416 let best = 0
417 for (let dy = 0; dy < 4; dy += 1) {
418 for (let dx = 0; dx < 2; dx += 1) {
419 const m = marks[(cy * 4 + dy) * width + cx * 2 + dx] as number
420 if (m > 0) {
421 bits |= (BRAILLE_BITS[dy] as number[])[dx] as number
422 best = Math.max(best, m)
423 }
424 }
425 }
426 const fg = best === 3 ? GLINT : best === 2 ? (frame % 20 < 10 || !shimmering ? GOLD : GOLD_DEEP) : growth < 1 ? PORCELAIN : PORCELAIN_DIM
427 grid.set(x0 + cx + shake, y0 + cy, bits === 0 ? 0x20 : 0x2800 + bits, bits === 0 ? DEFAULT : fg, bg)
428 }
429 }
430
431 // Label row: what broke, and how often it broke and was mended.
432 const flash = tile.state === 'cracked' && now - tile.crackedAt < FLASH_MS && frame % 6 < 3
433 const labelBg = flash ? 0x4a0f1c : LABEL_BG
434 for (let cx = 0; cx < TILE_COLUMNS; cx += 1) grid.set(x0 + cx, y0 + artRows, 0x20, DEFAULT, labelBg)
435 const fails = `×${tile.fails}`
436 const mends = tile.repairs > 0 ? ` ◆${tile.repairs}` : ''
437 const start = x0 + TILE_COLUMNS - fails.length - mends.length - 1
438 grid.text(x0 + 1, y0 + artRows, shorten(tile.label, Math.max(1, start - x0 - 2)), gold ? GOLD : PORCELAIN, labelBg)
439 grid.text(start, y0 + artRows, fails, BROKEN_RED, labelBg)
440 if (mends.length > 0) grid.text(start + fails.length, y0 + artRows, mends, GOLD, labelBg)
441}
442
443export type WallLayout = { columns: number; rows: number; perRow: number; shown: number }
444
445/** How a wall of `count` tiles fits `columns` cells across and at most `maxRows` down. */
446export function wallLayout(count: number, columns: number, maxRows: number): WallLayout {
447 const cols = Math.max(TILE_COLUMNS + 2, Math.min(512, columns))
448 const perRow = Math.max(1, Math.floor((cols - 1) / (TILE_COLUMNS + 1)))
449 const fitRows = Math.max(1, Math.floor((Math.min(256, maxRows) - 1) / (TILE_ROWS + 1)))
450 const shown = Math.min(count, perRow * fitRows)
451 const tileRows = Math.max(1, Math.ceil(shown / perRow))
452 return { columns: cols, rows: Math.min(256, tileRows * (TILE_ROWS + 1) + 1), perRow, shown }
453}
454
455/** The full wall: newest tiles first, on a near-black grout. */
456export function wallCells(tiles: readonly Tile[], layout: WallLayout, opts: DrawOptions): Grid {
457 const grid = new Grid(layout.columns, layout.rows)
458 for (let y = 0; y < layout.rows; y += 1) for (let x = 0; x < layout.columns; x += 1) grid.set(x, y, 0x20, DEFAULT, GAP_BG)
459 const order = tiles.slice().sort((p, q) => q.lastAt - p.lastAt).slice(0, layout.shown)
460 order.forEach((tile, i) => {
461 const x = 1 + (i % layout.perRow) * (TILE_COLUMNS + 1)
462 const y = 1 + Math.floor(i / layout.perRow) * (TILE_ROWS + 1)
463 drawTile(grid, tile, x, y, opts)
464 })
465 return grid
466}
467
468/** The band: one row of mini-tiles (broken ones crack-white, mended ones gold), then the tally. */
469export function bandCells(tiles: readonly Tile[], columns: number, opts: DrawOptions, stats?: Stats): Grid {
470 const cols = Math.max(20, Math.min(512, columns))
471 const grid = new Grid(cols, 1)
472 const order = tiles.slice().sort((p, q) => q.lastAt - p.lastAt)
473 const s = stats ?? statsOf(tiles)
474 const tally = ` ◈ ${statsLine(s)}`
475 const room = Math.max(0, Math.floor((cols - tally.length - 1) / 3))
476 let x = 0
477 for (const tile of order.slice(0, room)) {
478 const gold = tile.state === 'gold'
479 const fresh = !gold && opts.now - tile.crackedAt < FLASH_MS && opts.frame % 6 < 3
480 const glint = gold && opts.now - tile.repairedAt < GOLD_MS && opts.frame % 4 < 2
481 const bg = fresh ? 0x5a1222 : gold ? 0x2a2050 : 0x1d1f5c
482 const fg = gold ? (glint ? GLINT : GOLD) : PORCELAIN
483 grid.set(x, 0, gold ? 0x2871 : 0x2847, fg, bg) // ⡱ ⡇
484 grid.set(x + 1, 0, gold ? 0x288e : 0x28b8, fg, bg) // ⢎ ⢸
485 x += 3
486 }
487 if (order.length > room && room > 0) grid.text(Math.max(0, x - 1), 0, '…', PORCELAIN_DIM, DEFAULT)
488 grid.text(x, 0, tally, s.broken > 0 ? PORCELAIN : GOLD, DEFAULT)
489 return grid
490}
491types/index.d.ts 28 lines1/** One operation's tile on the wall. It exists from the operation's first failure on. */
2export type Tile = {
3 key: string
4 label: string
5 tool: string
6 fails: number
7 repairs: number
8 state: 'cracked' | 'gold'
9 seed: number
10 crackedAt: number
11 repairedAt: number
12 lastAt: number
13}
14
15/** One finished session, as the lifetime gallery keeps it. */
16export type Exhibit = { at: number; cracks: number; repaired: number; broken: number; worst: string }
17
18declare module 'claude-code' {
19 interface PluginState {
20 'fault-lacquer': {
21 tiles: Tile[]
22 band: boolean
23 // Every crack and repair this session, kept apart from the bounded wall (which drops old tiles).
24 totals: { cracks: number; repaired: number }
25 }
26 }
27}
28