SLOPSHOPPER

fault-lacquer

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

newpanebandguardcommandtimer
v0.2.0MITupdated 2026-10-02ccdwyer/fault-lacquer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fault-lacquer
│ ┃ Kintsugi ✕ › fix the failing auth test and add an audit log call │ ┃ ◈ KINTSUGI · 1 crack · 0 repaired in gold · │ ┃ 1 still broken ⏺ 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. │ ┃ Lacquer tile = an operation that failed. │ ┃ White cracks: still broken. Gold seams: a ✻ Worked for 42s · done 4:20 PM │ ┃ later run fixed it. × failures ◆ repairs. │ ┃ still broken: bun test › /kintsugi │ ⎿ fault-lacquer: Fault Lacquer: 1 crack · 0 repaired in gold · 1 s │ │ ⡇⢸ ◈ 1 crack · 0 repaired in gold · 1 still broken ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⡇⢸ ◈ 1 crack · 0 repaired in gold · 1 still broken ⟨Claude Code's own drawing⟩
Pane · Kintsugi
◈ KINTSUGI · 1 crack · 0 repaired in gold · 1 still broken ⡀ Lacquer tile = an operation that failed. White cracks: still broken. Gold seams: a later run fixed it. × failures ◆ repairs. still broken: bun test
README

Fault Lacquer

Fault Lacquer demo

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.

  • Band above the prompt: a row of mini-tiles (white cracks for broken, gold for mended) and the tally, e.g. ◈ 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.

Install

/plugin marketplace add ccdwyer/claude-mods
/plugin install fault-lacquer@ccdwyer-mods
/reload-plugins

Develop

claude plugin validate .
claude plugin test .

What it hooks

Events this mod hooks, as claude plugin validate reads the module:

  • session.start: registers /kintsugi and starts the frame clock
  • session.end: adds the session's summary to the lifetime gallery
  • command.run for /kintsugi
  • tool.call: after the call runs, notes whether it failed or succeeded
  • ui.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.

Privacy

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.

License

MIT

Source 3 files
hooks/register.tsx 262 lines
1import { 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
262
hooks/lacquer.ts 491 lines
1import 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}
491
types/index.d.ts 28 lines
1/** 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