SLOPSHOPPER

artifact-dashboard

A one-line summary of the project's shared Kanban artifact above the prompt, read through Claude's own ArtifactData tool

newbandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · artifact-dashboard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /board ⎿ artifact-dashboard: No board linked. Run /board <artifact url> [collection]. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

artifact-dashboard

A shared Kanban board for a project, with a live one-line summary of it above the prompt.

The board is a claude.ai artifact with a database: one document per card, { title, column, owner }. Every Claude session working on the project reads and writes the same cards, so the board is shared project state rather than one session's notes. This mod draws a line like this above the prompt:

▦ board todo 4  doing 2  review 1  done 9  · read 14:32 · Fix login → done

What it does

  • Reads the board itself. It calls Claude Code's own ArtifactData tool through $.tool.call (action: 'list') and counts the cards per column. That's a tool call, not a model call, so it costs no tokens.
  • Watches Claude's writes. A tool.call hook on ArtifactData sees every card Claude adds, moves or deletes on the linked board, and updates the summary immediately. Until the next read confirms them, the line says unconfirmed. A write whose if_version pin missed (db_write.committed: false) is ignored.
  • Tells Claude the board exists. Once per conversation it adds a short projectBoard block (about 130 tokens) to the first message: the board's URL, the card shape, and the rule to read before writing, pin every write with if_version, and only move its own task's cards.
  • Never opens a permission dialog unasked. Before an automatic read it asks $.tool.check. If the answer isn't allow, it skips the read and the line says reading needs approval: run /board refresh.

Cost per turn

WhenModel tokensOther
First message of a conversationabout 130 input tokens, once (the projectBoard block). It sits in the cached prefix after that.none
/board <url> mid-sessionabout 130 input tokens, once (the same brief, as the command's context)one ArtifactData list
Turn end, after Claude wrote to the board0one ArtifactData list, if reading is allowed
Turn end, mirror older than 2 minutes0one ArtifactData list, if reading is allowed
Any other turn end0nothing
Drawing the line0reads $.state only

The mod never calls $.model.* or spawns an agent. When Claude moves a card, that's Claude's own tool call and is billed like any other tool call. Turn the mod off with /board off: the line and the automatic reads stop at once, at session start too, and stay off after a restart. The brief is not taken back from a conversation that already carries it (that would re-render the cached first message); conversations started while the mod is off get no brief.

Set up

  1. Ask Claude for the board, for example: "Make a Kanban artifact with a shared database. Collection cards, one document per card with title, column (backlog, todo, doing, review, blocked, done) and owner." Claude builds the page with the artifact db capability.
  2. Link it. You can do this per machine: /board https://claude.ai/artifact/&lt;id&gt; (optionally followed by a collection name), which is stored per project root in this mod's $.store. Or link it once for everyone by committing .claude/board.json: ``json { "url": "https://claude.ai/artifact/<id>", "collection": "cards" } `` The repo file wins over the stored link, so every session in the project finds the same board.
  3. Optional: to let the mod read on its own, allow ArtifactData in your permission settings. Without that, run /board refresh when you want a read. An allow rule for ArtifactData also lets Claude write without asking, so choose deliberately.

Commands

/board shows the status. The subcommands:

  • /board <url> [collection] links a board.
  • /board refresh reads it now, and may ask for permission.
  • /board off and /board on turn the mod off and on.
  • /board forget drops the stored link.

Composes with

  • Any band mod (token-weather and others). It draws {await next(e)} under its own line, so other mods' bands still show.
  • A next-steps supervisor or quiz mod. The summary is plain $.state that any plugin can read: $.state.get({ plugin: 'artifact-dashboard', key: 'view' }) gives { columns, total, readAt, isConfirmed, lastMove, note }. A supervisor mod can put that in its fork's prompt and ask "did this turn move the card it should have?" without a board read of its own (a fork has no tools).
  • A mode selector. Another plugin can hide the board for a mode by hooking state.set on { plugin: 'artifact-dashboard', key: 'isOff' }.

Load it

claude plugin validate templates/artifact-dashboard
claude plugin test templates/artifact-dashboard
claude --plugin-dir templates/artifact-dashboard

It needs a session where ArtifactData exists, meaning one signed in to claude.ai with artifacts. Without it, the line shows the last stored mirror and says the tool isn't available.

What it cannot see

  • Changes made from the artifact page or by another session show up only at the next read (a turn end after 2 minutes, or /board refresh). Nothing pushes them to the mod.
  • It counts at most 1000 cards (one list page). Above that, the line says the counts are partial.
  • A card is any document with a column (or status, or lane). Documents without one are not counted.
  • Claude's reads with out_dir (documents saved to files) or a lowered as_level are ignored: neither shows the board as it stands. profiles lookups are ignored too.
Source 2 files
hooks/register.tsx 483 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { BoardLink, BoardView, Card } from '../types'
5
6type Engine = EngineInterface
7
8const view = atom({ plugin: 'artifact-dashboard', key: 'view' } as const, null as BoardView | null)
9const isOff = atom({ plugin: 'artifact-dashboard', key: 'isOff' } as const, false)
10
11const TOOL = 'ArtifactData'
12const COMMAND = 'board'
13const ORDER = ['backlog', 'todo', 'doing', 'review', 'blocked', 'done']
14const STALE_MS = 2 * 60_000
15const MAX_CARDS = 500
16const WRITES = ['set', 'update', 'delete', 'str_replace', 'batch']
17
18type Write = {
19  op: string
20  collection?: string
21  doc_id?: string
22  data?: {}
23  file_path?: string
24  field?: string
25  old_str?: string
26  new_str?: string
27  replace_all?: boolean
28}
29
30// Module state is rebuilt lazily by ensure(), so a hot reload costs one store read.
31let loading: Promise<BoardLink | null> | undefined
32let cards: Record<string, Card> = {}
33let readAt = 0
34let isDirty = false
35let lastMove: string | undefined
36let note: string | undefined
37let isCommandRegistered = false
38
39const isObject = (value: unknown): value is Record<string, unknown> =>
40  typeof value === 'object' && value !== null && !Array.isArray(value)
41
42const text = (value: unknown) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)
43
44const idOf = (url: string | undefined) => url?.match(/artifact\/([A-Za-z0-9_-]+)/)?.[1]
45
46const asLink = (value: unknown): BoardLink | null => {
47  if (!isObject(value) || !idOf(text(value.url))) return null
48  return { url: String(value.url), collection: text(value.collection) ?? 'cards' }
49}
50
51const mirrorKey = (board: BoardLink) => `mirror:${idOf(board.url)}:${board.collection}`
52
53const quietly = async <T,>($: Engine, what: string, work: () => Promise<T>): Promise<T | undefined> => {
54  try {
55    return await work()
56  } catch (err) {
57    $.ui.log(`artifact-dashboard: ${what} failed: ${err}`)
58    return undefined
59  }
60}
61
62// A card is any document with a column; status and lane are accepted so a board
63// Claude built with other field names still counts.
64const toCard = (data: unknown, id: string): Card | undefined => {
65  if (!isObject(data)) return undefined
66  const column = text(data.column) ?? text(data.status) ?? text(data.lane)
67  if (!column) return undefined
68  const owner = text(data.owner) ?? text(data.assignee)
69  return { title: text(data.title) ?? text(data.name) ?? id, column, ...(owner ? { owner } : {}) }
70}
71
72const docOf = (item: unknown) => {
73  if (!isObject(item)) return undefined
74  const id = text(item.doc_id) ?? text(item.id) ?? text(item.docId)
75  if (!id) return undefined
76  const card = toCard(isObject(item.data) ? item.data : item, id)
77  return card ? { id, card } : undefined
78}
79
80const parseJson = (raw: string): unknown => {
81  try {
82    return JSON.parse(raw)
83  } catch {
84    const at = raw.indexOf('{')
85    if (at === -1) return undefined
86    try {
87      return JSON.parse(raw.slice(at))
88    } catch {
89      return undefined
90    }
91  }
92}
93
94// Fallback for a result with no structured record: walk whatever JSON the text
95// holds for arrays of documents instead of trusting one wrapper shape.
96const parseDocs = (raw: string | undefined) => {
97  const json = raw ? parseJson(raw) : undefined
98  if (json === undefined) return undefined
99  const found: Record<string, Card> = {}
100  let isList = false
101  const walk = (node: unknown, depth: number) => {
102    if (depth > 6 || node === null || typeof node !== 'object') return
103    if (Array.isArray(node)) {
104      isList = true
105      for (const item of node) {
106        const doc = docOf(item)
107        if (doc) found[doc.id] = doc.card
108        else walk(item, depth + 1)
109      }
110      return
111    }
112    const single = depth === 0 ? docOf(node) : undefined
113    if (single) {
114      found[single.id] = single.card
115      isList = true
116      return
117    }
118    for (const value of Object.values(node)) walk(value, depth + 1)
119  }
120  walk(json, 0)
121  return isList ? { cards: found, isPartial: /next_cursor"\s*:\s*"/.test(raw ?? '') } : undefined
122}
123
124// The declared record (BuiltinToolResults.ArtifactData, its db_read arm) first;
125// the model-facing text only when a build answers without it.
126const docsOf = (ran: ToolCallResult) => {
127  const record = isObject(ran.result) ? ran.result.db_read : undefined
128  // A record without docs (out_dir saved them to files, or a get found nothing)
129  // says nothing about the cards; reading its text would empty the mirror.
130  if (isObject(record) && !Array.isArray(record.docs)) return undefined
131  if (isObject(record) && Array.isArray(record.docs)) {
132    const cards: Record<string, Card> = {}
133    for (const item of record.docs) {
134      const doc = docOf(item)
135      if (doc) cards[doc.id] = doc.card
136    }
137    return { cards, isPartial: typeof record.next_cursor === 'string' }
138  }
139  return parseDocs(ran.text)
140}
141
142// db_write.committed is false when an if_version pin missed: nothing was written.
143const isCommitted = (ran: ToolCallResult) => {
144  const record = isObject(ran.result) ? ran.result.db_write : undefined
145  return !(isObject(record) && record.committed === false)
146}
147
148const columnsOf = (all: Card[]) => {
149  const counts = new Map<string, number>()
150  for (const card of all) counts.set(card.column, (counts.get(card.column) ?? 0) + 1)
151  const rank = (name: string) => {
152    const at = ORDER.indexOf(name.toLowerCase())
153    return at === -1 ? ORDER.length : at
154  }
155  return [...counts]
156    .sort((a, b) => rank(a[0]) - rank(b[0]) || a[0].localeCompare(b[0]))
157    .map(([name, count]) => ({ name, count }))
158}
159
160const brief = (board: BoardLink) =>
161  [
162    `This project's shared task board is the artifact ${board.url}, database collection "${board.collection}".`,
163    'Each card is one document: { title, column, owner }, column one of backlog, todo, doing, review, blocked, done.',
164    'Other Claude sessions read and write the same board. Use the ArtifactData tool: read before you write,',
165    'pin every write with if_version, and only move or add the cards your own task touches.',
166    'When you start a task that has a card, move it to doing; when you finish, move it to review or done.',
167  ].join(' ')
168
169const publish = async ($: Engine, board: BoardLink | null) => {
170  if (!board) {
171    await update($, view, () => null)
172    return
173  }
174  const all = Object.values(cards)
175  const next: BoardView = {
176    columns: columnsOf(all),
177    total: all.length,
178    readAt,
179    isConfirmed: !isDirty,
180    ...(lastMove ? { lastMove } : {}),
181    ...(note ? { note } : {}),
182  }
183  await update($, view, () => next)
184  await $.store
185    .set(mirrorKey(board), { cards: trimmed(cards), readAt })
186    .catch(err => $.ui.log(`artifact-dashboard: saving the mirror failed: ${err}`))
187}
188
189// The store outlives this code: re-check each saved card rather than trust its shape.
190const restored = (saved: Record<string, unknown>) => {
191  const out: Record<string, Card> = {}
192  for (const [id, data] of Object.entries(saved)) {
193    const card = toCard(data, id)
194    if (card) out[id] = card
195  }
196  return out
197}
198
199const trimmed = (all: Record<string, Card>) => Object.fromEntries(Object.entries(all).slice(-MAX_CARDS))
200
201// The repo file wins so every session in the project, on any machine, finds the
202// same board without anyone running /board.
203const loadLink = async ($: Engine) => {
204  const root = await $.session.root()
205  const fromRepo = await $.fs
206    .read(`${root}/.claude/board.json`)
207    .then(raw => asLink(parseJson(raw)))
208    .catch(() => null)
209  if (fromRepo) return fromRepo
210  return asLink(await $.store.get(`board:${root}`).catch(() => undefined))
211}
212
213const ensure = ($: Engine): Promise<BoardLink | null> => {
214  loading ??= (async () => {
215    // $.state lives for the session; the off switch has to outlive it.
216    if ((await $.store.get('isOff').catch(() => undefined)) === true) await update($, isOff, () => true)
217    const board = (await quietly($, 'loading the board link', () => loadLink($))) ?? null
218    if (board) {
219      const saved = await $.store.get(mirrorKey(board)).catch(() => undefined)
220      if (isObject(saved) && isObject(saved.cards)) {
221        cards = restored(saved.cards)
222        readAt = typeof saved.readAt === 'number' ? saved.readAt : 0
223      }
224    }
225    await quietly($, 'drawing the mirror', () => publish($, board))
226    return board
227  })()
228  // A failed load is retried on the next call instead of being cached for the session.
229  const current = loading
230  current.catch(() => {
231    if (loading === current) loading = undefined
232  })
233  return current
234}
235
236const relink = async ($: Engine, board: BoardLink | null) => {
237  loading = Promise.resolve(board)
238  cards = {}
239  readAt = 0
240  isDirty = false
241  lastMove = undefined
242  note = undefined
243  if (board) {
244    const saved = await $.store.get(mirrorKey(board)).catch(() => undefined)
245    if (isObject(saved) && isObject(saved.cards)) cards = restored(saved.cards)
246  }
247  await publish($, board)
248}
249
250const failure = (ran: ToolCallResult) => {
251  if (ran.deny !== undefined) return `refused: ${ran.deny}`
252  if (ran.isError) return `failed: ${(ran.text ?? '').slice(0, 120)}`
253  return undefined
254}
255
256// Reads the whole collection with Claude's own tool. Unasked, it never opens a
257// permission dialog: it reads only where the check already says allow.
258const refresh = async ($: Engine, isAsked: boolean): Promise<string> => {
259  const board = await ensure($)
260  if (!board) return 'No board linked. Run /board <artifact url>, or commit .claude/board.json.'
261  // Every unasked read (session start, turn end) honours /board off.
262  if (!isAsked && (await read($, isOff))) return 'Board summary is off; run /board on.'
263  const input = { action: 'list' as const, url: board.url, collection: board.collection, query: { limit: 1000 } }
264  const stop = async (why: string) => {
265    note = why
266    await publish($, board)
267    return why
268  }
269  // $.tool.list is no gate: a deferred tool may be missing from it and still
270  // callable, so a rejected check or call is what says the tool is not here.
271  try {
272    if (!isAsked && (await $.tool.check({ tool: TOOL, input })).decision !== 'allow') {
273      return await stop('reading needs approval: run /board refresh')
274    }
275  } catch {
276    return await stop(`${TOOL} is not available here; showing the last mirror`)
277  }
278  const ran = await $.tool.call({ tool: TOOL, ...input }).catch(() => undefined)
279  if (!ran) return await stop(`${TOOL} is not available here; showing the last mirror`)
280  const why = failure(ran)
281  const docs = why ? undefined : docsOf(ran)
282  if (!docs) return await stop(why ? `read ${why}` : 'could not parse the list result; showing the last mirror')
283  cards = docs.cards
284  readAt = await $.clock.now()
285  isDirty = false
286  note = docs.isPartial ? 'more than 1000 cards; counts are partial' : undefined
287  await publish($, board)
288  return `Read ${Object.keys(cards).length} cards from the board.`
289}
290
291const writeData = async ($: Engine, write: Write) => {
292  if (isObject(write.data)) return write.data
293  if (!write.file_path) return undefined
294  const raw = await $.fs.read(write.file_path).catch(() => undefined)
295  return raw === undefined ? undefined : parseJson(raw)
296}
297
298const move = (id: string, before: Card | undefined, after: Card | undefined) => {
299  if (after && after.column !== before?.column) lastMove = `${after.title} → ${after.column}`
300  if (after) cards[id] = after
301  else delete cards[id]
302}
303
304const applyWrite = async ($: Engine, board: BoardLink, write: Write) => {
305  const id = write.doc_id
306  if (!id || write.collection !== board.collection) return
307  const before = cards[id]
308  if (write.op === 'delete') return move(id, before, undefined)
309  if (write.op === 'str_replace') {
310    if (!before || !write.field || !write.old_str) return
311    const field = write.field as keyof Card
312    const old = before[field]
313    if (typeof old !== 'string') return
314    const swapped = write.replace_all
315      ? old.split(write.old_str).join(write.new_str ?? '')
316      : old.replace(write.old_str, write.new_str ?? '')
317    return move(id, before, { ...before, [field]: swapped })
318  }
319  const data = await writeData($, write)
320  if (!isObject(data)) return
321  if (write.op === 'set') return move(id, before, toCard(data, id))
322  // An update merges; a field written as { __delete__: true } is removed.
323  const merged: Record<string, unknown> = { ...(before ?? {}) }
324  for (const [key, value] of Object.entries(data)) {
325    if (isObject(value) && value.__delete__ === true) delete merged[key]
326    else merged[key] = value
327  }
328  move(id, before, toCard(merged, id))
329}
330
331// Sees every ArtifactData call that reaches the engine: the model's, and this
332// mod's own reads. Writes become unconfirmed until the next read lands.
333const observe = async ($: Engine, e: Record<string, unknown>, ran: ToolCallResult) => {
334  const board = await ensure($)
335  if (!board || failure(ran) || idOf(text(e.url)) !== idOf(board.url)) return
336  const action = String(e.action)
337  if (action === 'list' || action === 'query' || action === 'get') {
338    // out_dir answers with file paths, and a lowered as_level reads refused
339    // documents as missing: neither is the board as it stands.
340    if (e.collection !== board.collection || e.out_dir !== undefined || e.as_level !== undefined) return
341    const docs = docsOf(ran)
342    if (!docs) return
343    const query = isObject(e.query) ? e.query : {}
344    const isWhole = action === 'list' && !docs.isPartial && !query.cursor
345    cards = isWhole ? docs.cards : { ...cards, ...docs.cards }
346    if (isWhole) {
347      readAt = await $.clock.now()
348      isDirty = false
349    }
350  } else if (WRITES.includes(action)) {
351    if (!isCommitted(ran)) return
352    const writes = action === 'batch' ? (Array.isArray(e.writes) ? (e.writes as Write[]) : []) : [{ ...e, op: action } as Write]
353    for (const write of writes) await applyWrite($, board, write)
354    isDirty = true
355  } else {
356    return
357  }
358  await publish($, board)
359}
360
361const clock = (at: number) => {
362  const time = new Date(at)
363  return `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`
364}
365
366const registerCommand = async ($: Engine) => {
367  if (isCommandRegistered) return
368  await $.command.register({
369    name: COMMAND,
370    description: 'Shared Kanban artifact: /board <url> [collection] | refresh | off | on | forget',
371    argumentHint: '[url | refresh | off | on | forget]',
372  })
373  isCommandRegistered = true
374}
375
376const status = async ($: Engine) => {
377  const board = await ensure($)
378  if (!board) return 'No board linked. Run /board <artifact url> [collection].'
379  const shown = await read($, view)
380  const counts = shown?.columns.map(c => `${c.name} ${c.count}`).join(', ') || 'no cards yet'
381  const when = readAt ? `last read ${clock(readAt)}` : 'never read'
382  const pending = isDirty ? ', with writes not yet read back' : ''
383  const moved = lastMove ? ` Last move: ${lastMove}.` : ''
384  return `Board ${board.url} (${board.collection}): ${counts}; ${when}${pending}.${moved}`
385}
386
387export const register: Register = on => {
388  on('session.start', async ($, e, next) => {
389    const result = await next(e)
390    await quietly($, 'registering /board', () => registerCommand($))
391    await quietly($, 'reading the board', () => refresh($, false))
392    return result
393  })
394
395  on('command.run', { command: COMMAND }, async ($, e) => {
396    const [word = '', extra] = e.args.trim().split(/\s+/)
397    const answer = await quietly($, `/board ${word}`, async () => {
398      if (word === '') return { text: await status($) }
399      if (word === 'refresh') return { text: await refresh($, true) }
400      if (word === 'off' || word === 'on') {
401        await update($, isOff, () => word === 'off')
402        await $.store.set('isOff', word === 'off')
403        return { text: word === 'off' ? 'Board summary hidden; no more reads.' : 'Board summary back on.' }
404      }
405      const root = await $.session.root()
406      if (word === 'forget') {
407        await $.store.delete(`board:${root}`)
408        await relink($, null)
409        return { text: 'Board link forgotten for this project (.claude/board.json, if any, still wins).' }
410      }
411      const board = asLink({ url: word, collection: extra })
412      if (!board) return { text: `Not an artifact link: ${word}` }
413      await $.store.set(`board:${root}`, board)
414      await relink($, board)
415      const outcome = await refresh($, true)
416      // Mid-session the first message's context is already sent, so the brief
417      // rides on this command's output instead of invalidating that context.
418      return { text: `Linked ${board.url} (${board.collection}). ${outcome}`, context: [brief(board)] }
419    })
420    return answer ?? { text: '/board failed; see the debug log.' }
421  })
422
423  on('prompt.context', async ($, e, next) => {
424    const result = await next(e)
425    const board = await quietly($, 'loading the board link', () => ensure($))
426    if (!board || (await read($, isOff))) return result
427    return { ...result, blocks: [...result.blocks, { name: 'projectBoard', text: brief(board) }] }
428  })
429
430  on('tool.call', { tool: TOOL }, async ($, e, next) => {
431    const ran = await next(e)
432    await quietly($, 'mirroring a board call', () => observe($, e as unknown as Record<string, unknown>, ran))
433    return ran
434  })
435
436  // No model call ever: a turn end reads the board only after a write needs
437  // confirming or once the mirror is older than STALE_MS.
438  on('turn.complete', async ($, e, next) => {
439    const result = await next(e)
440    if (e.agentId !== undefined) return result
441    await quietly($, 'registering /board', () => registerCommand($))
442    const board = await quietly($, 'loading the board link', () => ensure($))
443    if (!board || (await read($, isOff))) return result
444    const now = await $.clock.now()
445    if (isDirty || now - readAt > STALE_MS) await quietly($, 'reading the board', () => refresh($, false))
446    return result
447  })
448
449  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
450    const board = await read($, view)
451    if (e.props.hasSurvey || board === null || (await read($, isOff))) return next(e)
452    const { Box, Text } = $.ui.resolve(e)
453    const columns = board.columns.length ? board.columns : [{ name: 'empty', count: 0 }]
454    const freshness = board.isConfirmed
455      ? board.readAt
456        ? `read ${clock(board.readAt)}`
457        : 'not read yet'
458      : 'unconfirmed'
459
460    return (
461      <Box flexDirection="column">
462        <Box>
463          <Text wrap="truncate-end">
464            <Text bold color="cyan">
465              ▦ board{' '}
466            </Text>
467            {columns.map(c => (
468              <Text key={c.name}>
469                {c.name} <Text bold>{c.count}</Text>
470                {'  '}
471              </Text>
472            ))}
473            <Text dimColor>· {freshness}</Text>
474            {board.lastMove ? <Text dimColor> · {board.lastMove}</Text> : null}
475            {board.note ? <Text color="yellow"> · {board.note}</Text> : null}
476          </Text>
477        </Box>
478        {await next(e)}
479      </Box>
480    )
481  })
482}
483
types/index.d.ts 23 lines
1export type Card = { title: string; column: string; owner?: string }
2
3export type BoardLink = { url: string; collection: string }
4
5export type BoardColumn = { name: string; count: number }
6
7// isConfirmed is false while the counts include writes this session observed
8// but has not yet read back from the artifact.
9export type BoardView = {
10  columns: BoardColumn[]
11  total: number
12  readAt: number
13  isConfirmed: boolean
14  lastMove?: string
15  note?: string
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'artifact-dashboard': { view: BoardView | null; isOff: boolean }
21  }
22}
23