SLOPSHOPPER

puppy-console

a pane with one card per production pipeline, read from small status files

newpanecommandtoasttimer
v0.3.0MITupdated 2026-10-26nonbiri1004/puppy-mods/puppy-console
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · puppy-console
│ ┃ console ✕ › fix the failing auth test and add an audit log call │ ┃ checked just now [ refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ can't read the status folder: no status ⎿ Read 6 lines │ ┃ folder set ⏺ Update(src/auth.ts) │ ┃ set it in /config → puppy-console → status ⎿ Added 2 lines, removed 1 line │ ┃ folder ⏺ 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 │ │ › /console │ ⎿ puppy-console: pipeline cards opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · console
checked just now [ refresh ] can't read the status folder: no status folder set set it in /config → puppy-console → status folder
README

puppy-console

a pane beside the chat with one card per production pipeline: what it is doing now, what waits for you, the last thing it made, and what comes next.

the mod only reads. each pipeline writes one small json file into a folder you pick, and every file there becomes a card. a new pipeline needs no change to the mod: it only starts writing its file. the format is in FORMAT.md.

┌ music ─────────────────────── running · 1m ago · studio-mac ┐
│ 05_autumn · 42/70 usable                                    │
│ [the puppy at work]  ■■■■■■□■■■■■■■■□■■■…                   │
│ waiting for you                                             │
│ · hear 3 songs in 05_autumn: #7, #12, #31                   │
│ next: making songs                                          │
└─────────────────────────────────────────────────────────────┘
  • cards that wait for you come first, then errors, then running, then idle.
  • when a card turns waiting, error or quiet, or a waiting card gets a new item, a short notice shows in the corner (once, not every minute). turn it off with notices in /config.
  • a pipeline that promised to write every few minutes (stale_after_sec) and went quiet gets a "quiet" mark, so a sleeping computer doesn't look like it is still working.
  • a file in the folder that isn't a status file is named at the bottom with the reason, never dropped without a word.
  • the folder can be a synced one (google drive, dropbox), so a pipeline on another computer gets a card too.
  • the desktop app draws a small picture per card (the music card has the puppy and one square per song; the ai-video card groups jobs by review gate). the terminal shows the same as text.

install

in a claude code session in your terminal:

/plugin install puppy-console --marketplace nonbiri1004/puppy-mods

answer y to add the marketplace, then pick a scope. set status folder to the folder your pipelines write into (~ is your home folder). you can change it later in /config.

commands

commandwhat it does
/consoleopens the pane. it reads the folder again every minute; r reads it now

develop

claude plugin validate puppy-console
claude plugin test puppy-console

license

MIT

Source 5 files
hooks/register.tsx 158 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Problem, Snapshot, Status } from '../types'
5import { detailLines, picture, W } from './draw'
6import { ago, alerts, expandHome, isStale, parseStatus, sortCards, textBar, titleOf } from './format'
7
8const PANE = 'puppy-console'
9const REFRESH_MS = 60_000
10
11const snapshot = atom({ plugin: 'puppy-console', key: 'snapshot' } as const, null)
12
13const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
14
15// Every *.json in the folder is one card. A file that does not parse becomes a problem line,
16// never a missing card without a word: the writer may be halfway through, or on an old format.
17export async function loadFolder($: EngineInterface, dir: string): Promise<Snapshot> {
18  const loadedAt = await $.clock.now()
19  if (!dir) return { dir, cards: [], problems: [], dirError: 'no status folder set', loadedAt }
20  let entries
21  try {
22    entries = await $.fs.list(dir)
23  } catch (err) {
24    return { dir, cards: [], problems: [], dirError: errorText(err), loadedAt }
25  }
26  const cards: Status[] = []
27  const problems: Problem[] = []
28  for (const f of entries) {
29    if (f.kind !== 'file' || !f.name.endsWith('.json') || f.name.startsWith('.')) continue
30    try {
31      const s = parseStatus(await $.fs.read(`${dir}/${f.name}`))
32      if (`${s.pipeline}.json` !== f.name) throw new Error(`"pipeline" is ${s.pipeline}, so the file should be ${s.pipeline}.json`)
33      cards.push(s)
34    } catch (err) {
35      problems.push({ file: f.name, reason: errorText(err) })
36    }
37  }
38  return { dir, cards: sortCards(cards), problems, dirError: null, loadedAt }
39}
40
41// One refresh at a time: the timer and the button share the running one,
42// so an older read can never finish last and overwrite a newer one.
43let running: Promise<void> | null = null
44function refresh($: EngineInterface, setting: string, isNotifying: boolean): Promise<void> {
45  running ??= (async () => {
46    const dir = expandHome(setting.trim(), await $.env.get('HOME'))
47    const prev = await read($, snapshot)
48    const next = await loadFolder($, dir)
49    await update($, snapshot, () => next)
50    // A toast shows in this conversation only, for ten seconds; each open conversation tells its own.
51    if (isNotifying) for (const line of alerts(prev, next)) $.ui.toast(line, { timeoutMs: 10_000 })
52  })().finally(() => { running = null })
53  return running
54}
55
56// A detail of an unexpected shape loses its picture, not the whole pane.
57function safePicture(s: Status, stale: boolean) {
58  try {
59    return picture(s, stale)
60  } catch {
61    return null
62  }
63}
64
65function safeLines(s: Status): string[] {
66  try {
67    return detailLines(s)
68  } catch {
69    return []
70  }
71}
72
73export const register: Register = (on, options) => {
74  const setting = String(options.status_dir ?? '')
75  const isNotifying = options.notify !== 'off'
76
77  on('session.start', async ($, e, next) => {
78    await $.command.register({ name: 'console', description: 'Open the pipeline cards' })
79    void refresh($, setting, isNotifying)
80    // Like setInterval in a component's mount: each tick reads the folder again and the pane redraws.
81    $.clock.every(REFRESH_MS, () => refresh($, setting, isNotifying))
82    return next(e)
83  })
84
85  on('command.run', { command: 'console' }, async $ => {
86    await refresh($, setting, isNotifying)
87    await $.ui.open({ id: PANE, title: 'console' })
88    return { text: 'pipeline cards opened.' }
89  })
90
91  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
92    const ui = $.ui.resolve(e)
93    const { Box, Text, Button } = ui
94    const snap = await read($, snapshot)
95    const now = await $.clock.now()
96    if (!snap) return <Text dimColor>loading…</Text>
97
98    const hasSvg = e.surface !== 'terminal' && 'Svg' in ui
99
100    const card = (s: Status) => {
101      const stale = isStale(s, now)
102      const pic = hasSvg ? safePicture(s, stale) : null
103      const stateColor = s.state === 'error' ? 'error' : s.state === 'waiting' ? 'warning' : s.state === 'running' ? 'success' : undefined
104      return (
105        <Box key={s.pipeline} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} gap="xs">
106          <Box flexDirection="row" justifyContent="space-between">
107            <Text weight="semibold">{titleOf(s)}</Text>
108            <Text dimColor>
109              <Text color={stateColor}>{stale ? 'quiet' : s.state}</Text>
110              {' · '}{ago(now - Date.parse(s.updated))}{s.host ? ` · ${s.host}` : ''}
111            </Text>
112          </Box>
113          <Text>{s.headline}</Text>
114          {stale && <Text color="warning">no update for {ago(now - Date.parse(s.updated)).replace(' ago', '')}; is {s.host ?? 'the computer'} asleep?</Text>}
115          {pic && 'Svg' in ui
116            ? <ui.Svg source={pic.source} alt={pic.alt} isInteractive />
117            : s.progress && <Text mono dimColor>{textBar(s.progress)}</Text>}
118          {/* no picture (the terminal): the same details as text */}
119          {!pic && safeLines(s).map(line => <Text dimColor>{line}</Text>)}
120          {(s.waiting ?? []).length > 0 && (
121            <Box flexDirection="column">
122              <Text color="warning">waiting for you</Text>
123              {(s.waiting ?? []).slice(0, 5).map(w => <Text key={w.id}>· {w.text}</Text>)}
124              {(s.waiting ?? []).length > 5 && <Text dimColor>· and {(s.waiting ?? []).length - 5} more</Text>}
125            </Box>
126          )}
127          {s.last_output && (
128            <Text dimColor>last: {s.last_output.label}{s.last_output.at ? ` · ${ago(now - Date.parse(s.last_output.at))}` : ''}</Text>
129          )}
130          {s.next && !(s.waiting ?? []).some(w => w.text === s.next) && <Text dimColor>next: {s.next}</Text>}
131        </Box>
132      )
133    }
134
135    return (
136      <Box flexDirection="column" gap={1}>
137        <Box flexDirection="row" justifyContent="space-between">
138          <Text dimColor>checked {ago(now - snap.loadedAt)}</Text>
139          <Button key="refresh" label="refresh" hotkey="r" onPress={() => refresh($, setting, isNotifying)} />
140        </Box>
141        {snap.dirError && (
142          <Box flexDirection="column">
143            <Text color="error">can't read the status folder: {snap.dirError}</Text>
144            <Text dimColor>set it in /config → puppy-console → status folder{snap.dir ? ` (now ${snap.dir})` : ''}</Text>
145          </Box>
146        )}
147        {!snap.dirError && snap.cards.length === 0 && snap.problems.length === 0 && (
148          <Text dimColor>no status files in {snap.dir} yet</Text>
149        )}
150        {snap.cards.map(card)}
151        {snap.problems.map(p => (
152          <Text key={`p:${p.file}`} dimColor>skipped {p.file}: {p.reason}</Text>
153        ))}
154      </Box>
155    )
156  })
157}
158
hooks/draw.ts 198 lines
1// The picture under each card, as an SVG string: a progress bar for any pipeline, and a richer
2// picture for the details this mod knows (music, ai-video). Warm greys, light by day and dark by
3// night, low contrast; the only orange is the puppy's headphones.
4import type { MusicDetail, Stage, Status, VideoDetail, VideoJob } from '../types'
5import { sit, sitBlink, sleep0, sleep1 } from './puppy'
6
7export const W = 640
8
9const esc = (s: string) =>
10  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
11
12// Cut a label to fit; SVG text does not wrap. n is in Latin letters; a CJK character counts as two.
13const wide = (c: string) => /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(c)
14export function cut(s: string, n: number): string {
15  let used = 0
16  const chars = [...s]
17  for (let i = 0; i < chars.length; i++) {
18    used += wide(chars[i]!) ? 2 : 1
19    if (used > n) return `${chars.slice(0, i).join('').slice(0, Math.max(0, i - 1))}…`
20  }
21  return s
22}
23
24const STYLE = `<style>
25  svg{--card:#f8f7f4;--line:#dcdad4;--text:#2a2926;--muted:#6d6a64;--ok:#7f9a72;--listen:#c2a768;--reject:#b98276}
26  @media (prefers-color-scheme: dark){svg{--card:#232321;--line:#353431;--text:#e7e5e0;--muted:#9b9891;
27    --ok:#86a078;--listen:#bfa66a;--reject:#b07e73}}
28  .t{fill:var(--text);font-family:ui-sans-serif,system-ui,sans-serif}
29  .m{fill:var(--muted);font-family:ui-sans-serif,system-ui,sans-serif}
30  .px{font-family:ui-monospace,Menlo,monospace;letter-spacing:.5px}
31</style>`
32
33// The width and height in the markup let the pane fit the picture to its own width (never wider
34// than W) and keep the shape; guessing the pane's pixel width from its columns left empty space.
35const svg = (h: number, body: string) =>
36  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${h}" viewBox="0 0 ${W} ${h}">${STYLE}${body}</svg>`
37
38function bar(x: number, y: number, w: number, part: number, fill = 'var(--ok)') {
39  const f = Math.max(0, Math.min(1, part)) * w
40  return `<rect x="${x}" y="${y}" width="${w}" height="4" rx="2" fill="var(--line)"/>` +
41    `<rect x="${x}" y="${y}" width="${f.toFixed(1)}" height="4" rx="2" fill="${fill}"/>`
42}
43
44export type Picture = { source: string; height: number; alt: string }
45
46function progressPicture(s: Status): Picture | null {
47  const p = s.progress
48  if (!p || !p.total) return null
49  const label = `${p.done}/${p.total}${p.unit ? ` ${p.unit}` : ''}`
50  return {
51    height: 24,
52    alt: label,
53    source: svg(24, bar(0, 10, W - 150, p.done / p.total) +
54      `<text class="m px" x="${W}" y="16" font-size="11" text-anchor="end">${esc(cut(label, 22))}</text>`),
55  }
56}
57
58// Two frames swapped by SMIL: the puppy blinks while it works and breathes while it sleeps.
59function puppy(x: number, y: number, scale: number, isWorking: boolean) {
60  const [a, b, dur, keyTimes] = isWorking ? [sit, sitBlink, '4s', '0;0.94'] : [sleep0, sleep1, '3s', '0;0.5']
61  const frame = (rects: string, values: string) =>
62    `<g visibility="visible">${rects}<set attributeName="visibility" to="visible"/>` +
63    `<animate attributeName="visibility" values="${values}" keyTimes="${keyTimes}" dur="${dur}" repeatCount="indefinite" calcMode="discrete"/></g>`
64  return `<g transform="translate(${x} ${y}) scale(${scale})" shape-rendering="crispEdges">` +
65    frame(a, 'visible;hidden') + frame(b, 'hidden;visible') + '</g>'
66}
67
68function notes(x: number, y: number) {
69  return [0, 1.3, 2.6].map((delay, i) =>
70    `<text class="m" x="${x + i * 10}" y="${y}" font-size="13" opacity="0">♪` +
71    `<animate attributeName="y" values="${y};${y - 30}" dur="3.9s" begin="${delay}s" repeatCount="indefinite"/>` +
72    `<animate attributeName="opacity" values="0;.8;0" dur="3.9s" begin="${delay}s" repeatCount="indefinite"/></text>`,
73  ).join('')
74}
75
76export const MAX_SQUARES = 300
77
78const STRIP_COLOR: Record<string, string> = {
79  o: 'var(--ok)', l: 'var(--listen)', r: 'var(--reject)', x: 'var(--line)', '?': 'var(--muted)',
80}
81
82// The puppy at work (or asleep), then one square per song made.
83function musicPicture(s: Status, isStale: boolean): Picture | null {
84  const d = (s.detail ?? {}) as MusicDetail
85  const b = d.batches?.[0]
86  if (!b) return null
87  const H = 120
88  const isWorking = s.state === 'running' && !isStale
89  let body = puppy(0, H - 26 * 2.6 - 18, 2.6, isWorking)
90  if (isWorking) body += notes(70, 40)
91  // the host is already in the card's header; the picture keeps to the studio itself
92  const comfy = d.comfy ? (d.comfy.up ? 'comfy up' : 'comfy down') : ''
93
94  // 30 squares per row; they shrink so a batch of 220 songs still fits. Past MAX_SQUARES only the
95  // newest are drawn, so the squares keep a size and the SVG stays under the element's size limit.
96  const all = typeof b.strip === 'string' ? b.strip : ''
97  const strip = all.slice(-MAX_SQUARES)
98  const [x0, y0, per] = [120, 6, 30]
99  const rows = Math.max(1, Math.ceil(strip.length / per))
100  const step = Math.min(16, 86 / rows)
101  const size = step - 2
102  ;[...strip].forEach((c, i) => {
103    const [x, y] = [x0 + (i % per) * step, y0 + Math.floor(i / per) * step]
104    body += c === 'e'
105      ? `<rect x="${(x + 0.5).toFixed(1)}" y="${(y + 0.5).toFixed(1)}" width="${(size - 1).toFixed(1)}" height="${(size - 1).toFixed(1)}" rx="1" fill="none" stroke="var(--reject)"/>`
106      : `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${size.toFixed(1)}" height="${size.toFixed(1)}" rx="1" fill="${STRIP_COLOR[c] ?? 'var(--line)'}"/>`
107  })
108  if (!strip) body += `<text class="m" x="${x0}" y="40" font-size="11">no songs yet</text>`
109  if (all.length > strip.length) body += `<text class="m" x="${W}" y="${H - 18}" font-size="9" text-anchor="end">newest ${strip.length} of ${all.length}</text>`
110  const legend: [string, string][] = [['ok', 'var(--ok)'], ['to hear', 'var(--listen)'], ['rejected', 'var(--reject)']]
111  legend.forEach(([name, fill], i) => {
112    body += `<rect x="${x0 + i * 78}" y="${H - 12}" width="8" height="8" rx="1" fill="${fill}"/>` +
113      `<text class="m" x="${x0 + 12 + i * 78}" y="${H - 4}" font-size="12">${name}</text>`
114  })
115  const pace = b.avg_gen_sec ? `${Math.round(b.avg_gen_sec / 60)} min / song` : ''
116  const right = [comfy, pace].filter(Boolean).join(' · ')
117  if (right) body += `<text class="m px" x="${W}" y="${H - 4}" font-size="12" text-anchor="end">${esc(right)}</text>`
118  return {
119    height: H,
120    alt: `${b.name}: ${b.ok ?? 0} ok, ${b.needs_listen ?? 0} to hear, ${b.rejected ?? 0} rejected`,
121    source: svg(H, body),
122  }
123}
124
125export const STAGES: readonly Stage[] = ['plan', 'clips', 'voice', 'compose', 'final', 'done']
126// The two review gates only you can pass.
127const GATE: ReadonlySet<Stage> = new Set(['plan', 'final'])
128
129export function byStage(jobs: VideoJob[]): Record<Stage, VideoJob[]> {
130  const out = Object.fromEntries(STAGES.map(st => [st, [] as VideoJob[]])) as Record<Stage, VideoJob[]>
131  for (const j of jobs) (out[j.stage] ?? out.plan).push(j)
132  return out
133}
134
135// One column per stage, a chip per job; the review gates have a dashed outline.
136function videoPicture(s: Status): Picture | null {
137  const d = (s.detail ?? {}) as VideoDetail
138  const jobs = d.jobs ?? []
139  if (!jobs.length) return null
140  const lanes = byStage(jobs)
141  const colW = W / STAGES.length
142  const shown = 4
143  const H = 36 + shown * 20 + 30
144  let body = ''
145  STAGES.forEach((st, i) => {
146    const x = i * colW
147    body += `<rect x="${x + 2}" y="0.5" width="${colW - 4}" height="${H - 31}" rx="6" fill="var(--card)" stroke="var(--line)"${GATE.has(st) ? ' stroke-dasharray="4 3"' : ''}/>`
148    body += `<text class="m px" x="${x + 10}" y="16" font-size="11">${st.toUpperCase()}${GATE.has(st) ? ' · YOU' : ''}</text>`
149    const list = lanes[st]
150    list.slice(0, shown).forEach((j, k) => {
151      const name = j.job.split('/').pop() ?? j.job
152      body += `<text class="${st === 'done' ? 'm' : 't'}" x="${x + 10}" y="${38 + k * 20}" font-size="14">${esc(cut(name, 12))}</text>`
153    })
154    if (list.length > shown) body += `<text class="m" x="${x + 10}" y="${38 + shown * 20}" font-size="12">+${list.length - shown} more</text>`
155  })
156  const spend = d.spend_usd ?? 0
157  const cap = d.cap_usd ?? 0
158  if (cap > 0) {
159    body += bar(0, H - 14, W - 190, spend / cap, spend / cap > 0.8 ? 'var(--reject)' : 'var(--ok)')
160    body += `<text class="m px" x="${W}" y="${H - 8}" font-size="13" text-anchor="end">spent $${spend.toFixed(2)} of $${cap.toFixed(2)}</text>`
161  }
162  const waiting = jobs.filter(j => GATE.has(j.stage)).length
163  return {
164    height: H,
165    alt: `${jobs.length} jobs, ${waiting} at a review gate; spent $${spend.toFixed(2)} of $${cap.toFixed(2)}`,
166    source: svg(H, body),
167  }
168}
169
170// The same details as text, for surfaces without Svg (the terminal).
171export function detailLines(s: Status): string[] {
172  if (s.pipeline === 'music') {
173    const d = (s.detail ?? {}) as MusicDetail
174    const b = d.batches?.[0]
175    if (!b) return []
176    const failed = b.errors ? ` · failed ${b.errors}` : ''
177    const comfy = d.comfy ? ` · comfy ${d.comfy.up ? 'up' : 'down'}` : ''
178    return [`${b.name}: made ${b.generated ?? 0} · ok ${b.ok ?? 0} · to hear ${b.needs_listen ?? 0} · rejected ${b.rejected ?? 0}${failed}${comfy}`]
179  }
180  if (s.pipeline === 'ai-video') {
181    const d = (s.detail ?? {}) as VideoDetail
182    const jobs = d.jobs ?? []
183    if (!jobs.length) return []
184    const lanes = byStage(jobs)
185    const lines = STAGES.filter(st => lanes[st].length).map(st =>
186      `${st}${GATE.has(st) ? ' (you)' : ''}: ${lanes[st].map(j => j.job.split('/').pop() ?? j.job).join(', ')}`)
187    if (d.cap_usd) lines.push(`spent $${(d.spend_usd ?? 0).toFixed(2)} of $${d.cap_usd.toFixed(2)}`)
188    return lines
189  }
190  return []
191}
192
193export function picture(s: Status, isStale: boolean): Picture | null {
194  if (s.pipeline === 'music') return musicPicture(s, isStale) ?? progressPicture(s)
195  if (s.pipeline === 'ai-video') return videoPicture(s) ?? progressPicture(s)
196  return progressPicture(s)
197}
198
hooks/format.ts 112 lines
1// Reading one status file (FORMAT.md). Pure functions, so the tests need no engine.
2import type { Snapshot, State, Status } from '../types'
3
4const STATES: readonly State[] = ['running', 'idle', 'waiting', 'error']
5
6const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
7
8// Only what the card needs is checked; a pipeline can add any field it likes.
9export function parseStatus(text: string): Status {
10  let v: unknown
11  try {
12    v = JSON.parse(text)
13  } catch {
14    throw new Error('not json (maybe being written; next refresh reads it again)')
15  }
16  if (!isObject(v) || v.format !== 'pipeline-status') throw new Error('no "format": "pipeline-status" (an older status file?)')
17  if (v.version !== 1) throw new Error(`format version ${String(v.version)}; this mod reads version 1`)
18  if (typeof v.pipeline !== 'string' || !v.pipeline) throw new Error('no "pipeline"')
19  if (typeof v.updated !== 'string' || Number.isNaN(Date.parse(v.updated))) throw new Error('"updated" is not a time')
20  if (!STATES.includes(v.state as State)) throw new Error(`"state" must be one of ${STATES.join(', ')}`)
21  if (typeof v.headline !== 'string') throw new Error('no "headline"')
22  if (v.waiting != null && !Array.isArray(v.waiting)) throw new Error('"waiting" is not a list')
23  // Optional fields of the wrong type are dropped, so one odd field can't break the sorting or the drawing.
24  const str = (x: unknown) => (typeof x === 'string' ? x : undefined)
25  const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : undefined)
26  const p = isObject(v.progress) ? v.progress : null
27  const last = isObject(v.last_output) && typeof v.last_output.label === 'string' ? v.last_output : null
28  return {
29    ...(v as unknown as Status),
30    title: str(v.title),
31    host: str(v.host),
32    stale_after_sec: num(v.stale_after_sec),
33    next: str(v.next) ?? null,
34    progress: p && num(p.done) != null
35      ? { done: num(p.done)!, total: num(p.total) ?? null, ...(str(p.unit) ? { unit: str(p.unit) } : {}) }
36      : null,
37    last_output: last
38      ? { label: last.label as string, ...(str(last.at) ? { at: str(last.at) } : {}), ...(str(last.path) ? { path: str(last.path) } : {}) }
39      : null,
40    waiting: ((v.waiting as unknown[] | undefined) ?? []).filter(isObject).map(w => ({
41      id: String(w.id ?? ''),
42      text: String(w.text ?? ''),
43      ...(typeof w.since === 'string' ? { since: w.since } : {}),
44    })),
45  }
46}
47
48// Quiet for longer than the writer promised. A file without stale_after_sec is never stale.
49export function isStale(s: Status, now: number): boolean {
50  return s.stale_after_sec != null && now - Date.parse(s.updated) > s.stale_after_sec * 1000
51}
52
53// Cards that need you come first: waiting, then error, then running, then idle; ties by title.
54const ORDER: Record<State, number> = { waiting: 0, error: 1, running: 2, idle: 3 }
55export function sortCards(cards: Status[]): Status[] {
56  return [...cards].sort((a, b) => ORDER[a.state] - ORDER[b.state] || titleOf(a).localeCompare(titleOf(b)))
57}
58
59export const titleOf = (s: Status) => s.title || s.pipeline
60
61export function ago(ms: number): string {
62  const min = Math.round(ms / 60_000)
63  if (min < 1) return 'just now'
64  if (min < 60) return `${min}m ago`
65  const h = Math.floor(min / 60)
66  if (h < 48) return `${h}h ${min % 60}m ago`
67  return `${Math.floor(h / 24)}d ago`
68}
69
70// A text progress bar for surfaces without Svg: ▓▓▓▓░░░░░░ 42/70 usable songs
71export function textBar(p: NonNullable<Status['progress']>, width = 10): string {
72  const unit = p.unit ? ` ${p.unit}` : ''
73  if (!p.total) return `${p.done}${unit}`
74  const filled = Math.round(Math.max(0, Math.min(1, p.done / p.total)) * width)
75  return `${'▓'.repeat(filled)}${'░'.repeat(width - filled)} ${p.done}/${p.total}${unit}`
76}
77
78// "~/x" → "<home>/x"; the folder setting is typed by hand.
79export function expandHome(path: string, home: string | undefined): string {
80  return home && (path === '~' || path.startsWith('~/')) ? home + path.slice(1) : path
81}
82
83// What a card needs from you right now: its state, or "quiet" when the writer went silent.
84const markOf = (s: Status, now: number) => (isStale(s, now) ? 'quiet' : s.state)
85const ALERT = new Set(['waiting', 'error', 'quiet'])
86
87// The lines to tell the person, comparing the last read with this one: a card that turned
88// waiting, error or quiet, or new waiting items on a card that was already waiting.
89// No previous read (the pane just started) is a baseline, not news.
90export function alerts(prev: Snapshot | null, next: Snapshot): string[] {
91  if (!prev) return []
92  const before = new Map(prev.cards.map(s => [s.pipeline, s]))
93  const out: string[] = []
94  for (const s of next.cards) {
95    const was = before.get(s.pipeline)
96    const mark = markOf(s, next.loadedAt)
97    const wasMark = was ? markOf(was, prev.loadedAt) : undefined
98    const items = s.waiting ?? []
99    if (ALERT.has(mark) && mark !== wasMark) {
100      const why = mark === 'quiet'
101        ? `no update for ${ago(next.loadedAt - Date.parse(s.updated)).replace(' ago', '')}; is ${s.host ?? 'the computer'} asleep?`
102        : mark === 'waiting' && items[0] ? items[0].text : s.headline
103      out.push(`${titleOf(s)} · ${mark}: ${why}`)
104    } else if (mark === 'waiting') {
105      const seen = new Set((was?.waiting ?? []).map(w => w.id))
106      const fresh = items.filter(w => !seen.has(w.id))
107      if (fresh.length) out.push(`${titleOf(s)} · new: ${fresh[0]!.text}${fresh.length > 1 ? ` (+${fresh.length - 1} more)` : ''}`)
108    }
109  }
110  return out
111}
112
hooks/puppy.ts 7 lines
1// Generated from nonbiri1004/baibai-brand src/sprite.ts by `npm run export`. Do not edit; change baibai-brand and sync.
2// 34×26 frames (the 32×24 grid plus its outline), one SVG rect per horizontal run.
3export const sit = '<rect x="17" y="0" width="5" height="1" fill="#2b2230"/><rect x="15" y="1" width="2" height="1" fill="#2b2230"/><rect x="17" y="1" width="5" height="1" fill="#a8501c"/><rect x="22" y="1" width="2" height="1" fill="#2b2230"/><rect x="14" y="2" width="1" height="1" fill="#2b2230"/><rect x="15" y="2" width="1" height="1" fill="#f6ecdc"/><rect x="16" y="2" width="1" height="1" fill="#a8501c"/><rect x="17" y="2" width="2" height="1" fill="#2b2230"/><rect x="19" y="2" width="3" height="1" fill="#f6ecdc"/><rect x="22" y="2" width="1" height="1" fill="#a8501c"/><rect x="23" y="2" width="1" height="1" fill="#f6ecdc"/><rect x="24" y="2" width="1" height="1" fill="#2b2230"/><rect x="14" y="3" width="1" height="1" fill="#2b2230"/><rect x="15" y="3" width="1" height="1" fill="#a8501c"/><rect x="16" y="3" width="9" height="1" fill="#f6ecdc"/><rect x="25" y="3" width="1" height="1" fill="#2b2230"/><rect x="14" y="4" width="1" height="1" fill="#2b2230"/><rect x="15" y="4" width="1" height="1" fill="#a8501c"/><rect x="16" y="4" width="10" height="1" fill="#f6ecdc"/><rect x="26" y="4" width="1" height="1" fill="#2b2230"/><rect x="13" y="5" width="1" height="1" fill="#2b2230"/><rect x="14" y="5" width="4" height="1" fill="#f08a3c"/><rect x="18" y="5" width="9" height="1" fill="#f6ecdc"/><rect x="27" y="5" width="1" height="1" fill="#2b2230"/><rect x="13" y="6" width="1" height="1" fill="#2b2230"/><rect x="14" y="6" width="1" height="1" fill="#a8501c"/><rect x="15" y="6" width="3" height="1" fill="#f08a3c"/><rect x="18" y="6" width="4" height="1" fill="#f6ecdc"/><rect x="22" y="6" width="1" height="1" fill="#1c1614"/><rect x="23" y="6" width="4" height="1" fill="#f6ecdc"/><rect x="27" y="6" width="1" height="1" fill="#2b2230"/><rect x="13" y="7" width="1" height="1" fill="#2b2230"/><rect x="14" y="7" width="1" height="1" fill="#a8501c"/><rect x="15" y="7" width="3" height="1" fill="#f08a3c"/><rect x="18" y="7" width="4" height="1" fill="#f6ecdc"/><rect x="22" y="7" width="1" height="1" fill="#1c1614"/><rect x="23" y="7" width="4" height="1" fill="#f6ecdc"/><rect x="27" y="7" width="1" height="1" fill="#2b2230"/><rect x="13" y="8" width="1" height="1" fill="#2b2230"/><rect x="14" y="8" width="1" height="1" fill="#a8501c"/><rect x="15" y="8" width="3" height="1" fill="#f08a3c"/><rect x="18" y="8" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="8" width="2" height="1" fill="#2b2230"/><rect x="13" y="9" width="1" height="1" fill="#2b2230"/><rect x="14" y="9" width="4" height="1" fill="#f08a3c"/><rect x="18" y="9" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="9" width="2" height="1" fill="#1c1614"/><rect x="30" y="9" width="1" height="1" fill="#2b2230"/><rect x="9" y="10" width="5" height="1" fill="#2b2230"/><rect x="14" y="10" width="5" height="1" fill="#dba77a"/><rect x="19" y="10" width="10" height="1" fill="#f6ecdc"/><rect x="29" y="10" width="1" height="1" fill="#2b2230"/><rect x="7" y="11" width="2" height="1" fill="#2b2230"/><rect x="9" y="11" width="5" height="1" fill="#f6ecdc"/><rect x="14" y="11" width="5" height="1" fill="#dba77a"/><rect x="19" y="11" width="9" height="1" fill="#f6ecdc"/><rect x="28" y="11" width="1" height="1" fill="#2b2230"/><rect x="6" y="12" width="1" height="1" fill="#2b2230"/><rect x="7" y="12" width="7" height="1" fill="#f6ecdc"/><rect x="14" y="12" width="2" height="1" fill="#c08a5c"/><rect x="16" y="12" width="2" height="1" fill="#dba77a"/><rect x="18" y="12" width="7" height="1" fill="#f6ecdc"/><rect x="25" y="12" width="1" height="1" fill="#e8877a"/><rect x="26" y="12" width="2" height="1" fill="#2b2230"/><rect x="5" y="13" width="1" height="1" fill="#2b2230"/><rect x="6" y="13" width="18" height="1" fill="#f6ecdc"/><rect x="24" y="13" width="2" height="1" fill="#2b2230"/><rect x="5" y="14" width="1" height="1" fill="#2b2230"/><rect x="6" y="14" width="13" height="1" fill="#f6ecdc"/><rect x="19" y="14" width="1" height="1" fill="#2b2230"/><rect x="20" y="14" width="3" height="1" fill="#f6ecdc"/><rect x="23" y="14" width="1" height="1" fill="#2b2230"/><rect x="4" y="15" width="1" height="1" fill="#2b2230"/><rect x="5" y="15" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="15" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="15" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="15" width="1" height="1" fill="#2b2230"/><rect x="20" y="15" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="15" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="15" width="1" height="1" fill="#2b2230"/><rect x="4" y="16" width="1" height="1" fill="#2b2230"/><rect x="5" y="16" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="16" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="16" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="16" width="1" height="1" fill="#2b2230"/><rect x="20" y="16" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="16" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="16" width="1" height="1" fill="#2b2230"/><rect x="3" y="17" width="2" height="1" fill="#2b2230"/><rect x="5" y="17" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="17" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="17" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="17" width="1" height="1" fill="#2b2230"/><rect x="20" y="17" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="17" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="17" width="1" height="1" fill="#2b2230"/><rect x="2" y="18" width="1" height="1" fill="#2b2230"/><rect x="3" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="5" y="18" width="1" height="1" fill="#2b2230"/><rect x="6" y="18" width="10" height="1" fill="#f6ecdc"/><rect x="16" y="18" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="18" width="1" height="1" fill="#2b2230"/><rect x="20" y="18" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="18" width="1" height="1" fill="#2b2230"/><rect x="1" y="19" width="1" height="1" fill="#2b2230"/><rect x="2" y="19" width="6" height="1" fill="#f6ecdc"/><rect x="8" y="19" width="3" height="1" fill="#dcc7a8"/><rect x="11" y="19" width="5" height="1" fill="#f6ecdc"/><rect x="16" y="19" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="19" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="19" width="1" height="1" fill="#2b2230"/><rect x="20" y="19" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="19" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="19" width="1" height="1" fill="#2b2230"/><rect x="1" y="20" width="1" height="1" fill="#2b2230"/><rect x="2" y="20" width="15" height="1" fill="#dcc7a8"/><rect x="17" y="20" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="20" width="1" height="1" fill="#2b2230"/><rect x="20" y="20" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="20" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="20" width="1" height="1" fill="#2b2230"/><rect x="2" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="21" width="2" height="1" fill="#dcc7a8"/><rect x="5" y="21" width="1" height="1" fill="#2b2230"/><rect x="6" y="21" width="13" height="1" fill="#dcc7a8"/><rect x="19" y="21" width="1" height="1" fill="#2b2230"/><rect x="20" y="21" width="3" height="1" fill="#dcc7a8"/><rect x="23" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="22" width="2" height="1" fill="#2b2230"/><rect x="6" y="22" width="2" height="1" fill="#2b2230"/><rect x="8" y="22" width="6" height="1" fill="#dcc7a8"/><rect x="14" y="22" width="5" height="1" fill="#2b2230"/><rect x="20" y="22" width="3" height="1" fill="#2b2230"/><rect x="8" y="23" width="6" height="1" fill="#2b2230"/>'
4export const sitBlink = '<rect x="17" y="0" width="5" height="1" fill="#2b2230"/><rect x="15" y="1" width="2" height="1" fill="#2b2230"/><rect x="17" y="1" width="5" height="1" fill="#a8501c"/><rect x="22" y="1" width="2" height="1" fill="#2b2230"/><rect x="14" y="2" width="1" height="1" fill="#2b2230"/><rect x="15" y="2" width="1" height="1" fill="#f6ecdc"/><rect x="16" y="2" width="1" height="1" fill="#a8501c"/><rect x="17" y="2" width="2" height="1" fill="#2b2230"/><rect x="19" y="2" width="3" height="1" fill="#f6ecdc"/><rect x="22" y="2" width="1" height="1" fill="#a8501c"/><rect x="23" y="2" width="1" height="1" fill="#f6ecdc"/><rect x="24" y="2" width="1" height="1" fill="#2b2230"/><rect x="14" y="3" width="1" height="1" fill="#2b2230"/><rect x="15" y="3" width="1" height="1" fill="#a8501c"/><rect x="16" y="3" width="9" height="1" fill="#f6ecdc"/><rect x="25" y="3" width="1" height="1" fill="#2b2230"/><rect x="14" y="4" width="1" height="1" fill="#2b2230"/><rect x="15" y="4" width="1" height="1" fill="#a8501c"/><rect x="16" y="4" width="10" height="1" fill="#f6ecdc"/><rect x="26" y="4" width="1" height="1" fill="#2b2230"/><rect x="13" y="5" width="1" height="1" fill="#2b2230"/><rect x="14" y="5" width="4" height="1" fill="#f08a3c"/><rect x="18" y="5" width="9" height="1" fill="#f6ecdc"/><rect x="27" y="5" width="1" height="1" fill="#2b2230"/><rect x="13" y="6" width="1" height="1" fill="#2b2230"/><rect x="14" y="6" width="1" height="1" fill="#a8501c"/><rect x="15" y="6" width="3" height="1" fill="#f08a3c"/><rect x="18" y="6" width="9" height="1" fill="#f6ecdc"/><rect x="27" y="6" width="1" height="1" fill="#2b2230"/><rect x="13" y="7" width="1" height="1" fill="#2b2230"/><rect x="14" y="7" width="1" height="1" fill="#a8501c"/><rect x="15" y="7" width="3" height="1" fill="#f08a3c"/><rect x="18" y="7" width="3" height="1" fill="#f6ecdc"/><rect x="21" y="7" width="2" height="1" fill="#1c1614"/><rect x="23" y="7" width="4" height="1" fill="#f6ecdc"/><rect x="27" y="7" width="1" height="1" fill="#2b2230"/><rect x="13" y="8" width="1" height="1" fill="#2b2230"/><rect x="14" y="8" width="1" height="1" fill="#a8501c"/><rect x="15" y="8" width="3" height="1" fill="#f08a3c"/><rect x="18" y="8" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="8" width="2" height="1" fill="#2b2230"/><rect x="13" y="9" width="1" height="1" fill="#2b2230"/><rect x="14" y="9" width="4" height="1" fill="#f08a3c"/><rect x="18" y="9" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="9" width="2" height="1" fill="#1c1614"/><rect x="30" y="9" width="1" height="1" fill="#2b2230"/><rect x="9" y="10" width="5" height="1" fill="#2b2230"/><rect x="14" y="10" width="5" height="1" fill="#dba77a"/><rect x="19" y="10" width="10" height="1" fill="#f6ecdc"/><rect x="29" y="10" width="1" height="1" fill="#2b2230"/><rect x="7" y="11" width="2" height="1" fill="#2b2230"/><rect x="9" y="11" width="5" height="1" fill="#f6ecdc"/><rect x="14" y="11" width="5" height="1" fill="#dba77a"/><rect x="19" y="11" width="9" height="1" fill="#f6ecdc"/><rect x="28" y="11" width="1" height="1" fill="#2b2230"/><rect x="6" y="12" width="1" height="1" fill="#2b2230"/><rect x="7" y="12" width="7" height="1" fill="#f6ecdc"/><rect x="14" y="12" width="2" height="1" fill="#c08a5c"/><rect x="16" y="12" width="2" height="1" fill="#dba77a"/><rect x="18" y="12" width="7" height="1" fill="#f6ecdc"/><rect x="25" y="12" width="3" height="1" fill="#2b2230"/><rect x="5" y="13" width="1" height="1" fill="#2b2230"/><rect x="6" y="13" width="18" height="1" fill="#f6ecdc"/><rect x="24" y="13" width="1" height="1" fill="#2b2230"/><rect x="5" y="14" width="1" height="1" fill="#2b2230"/><rect x="6" y="14" width="13" height="1" fill="#f6ecdc"/><rect x="19" y="14" width="1" height="1" fill="#2b2230"/><rect x="20" y="14" width="3" height="1" fill="#f6ecdc"/><rect x="23" y="14" width="1" height="1" fill="#2b2230"/><rect x="4" y="15" width="1" height="1" fill="#2b2230"/><rect x="5" y="15" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="15" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="15" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="15" width="1" height="1" fill="#2b2230"/><rect x="20" y="15" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="15" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="15" width="1" height="1" fill="#2b2230"/><rect x="4" y="16" width="1" height="1" fill="#2b2230"/><rect x="5" y="16" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="16" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="16" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="16" width="1" height="1" fill="#2b2230"/><rect x="20" y="16" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="16" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="16" width="1" height="1" fill="#2b2230"/><rect x="3" y="17" width="2" height="1" fill="#2b2230"/><rect x="5" y="17" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="17" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="17" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="17" width="1" height="1" fill="#2b2230"/><rect x="20" y="17" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="17" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="17" width="1" height="1" fill="#2b2230"/><rect x="2" y="18" width="1" height="1" fill="#2b2230"/><rect x="3" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="5" y="18" width="1" height="1" fill="#2b2230"/><rect x="6" y="18" width="10" height="1" fill="#f6ecdc"/><rect x="16" y="18" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="18" width="1" height="1" fill="#2b2230"/><rect x="20" y="18" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="18" width="1" height="1" fill="#2b2230"/><rect x="1" y="19" width="1" height="1" fill="#2b2230"/><rect x="2" y="19" width="6" height="1" fill="#f6ecdc"/><rect x="8" y="19" width="3" height="1" fill="#dcc7a8"/><rect x="11" y="19" width="5" height="1" fill="#f6ecdc"/><rect x="16" y="19" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="19" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="19" width="1" height="1" fill="#2b2230"/><rect x="20" y="19" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="19" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="19" width="1" height="1" fill="#2b2230"/><rect x="1" y="20" width="1" height="1" fill="#2b2230"/><rect x="2" y="20" width="15" height="1" fill="#dcc7a8"/><rect x="17" y="20" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="20" width="1" height="1" fill="#2b2230"/><rect x="20" y="20" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="20" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="20" width="1" height="1" fill="#2b2230"/><rect x="2" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="21" width="2" height="1" fill="#dcc7a8"/><rect x="5" y="21" width="1" height="1" fill="#2b2230"/><rect x="6" y="21" width="13" height="1" fill="#dcc7a8"/><rect x="19" y="21" width="1" height="1" fill="#2b2230"/><rect x="20" y="21" width="3" height="1" fill="#dcc7a8"/><rect x="23" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="22" width="2" height="1" fill="#2b2230"/><rect x="6" y="22" width="2" height="1" fill="#2b2230"/><rect x="8" y="22" width="6" height="1" fill="#dcc7a8"/><rect x="14" y="22" width="5" height="1" fill="#2b2230"/><rect x="20" y="22" width="3" height="1" fill="#2b2230"/><rect x="8" y="23" width="6" height="1" fill="#2b2230"/>'
5export const sleep0 = '<rect x="19" y="7" width="5" height="1" fill="#2b2230"/><rect x="17" y="8" width="2" height="1" fill="#2b2230"/><rect x="19" y="8" width="5" height="1" fill="#a8501c"/><rect x="24" y="8" width="1" height="1" fill="#2b2230"/><rect x="16" y="9" width="1" height="1" fill="#2b2230"/><rect x="17" y="9" width="1" height="1" fill="#f6ecdc"/><rect x="18" y="9" width="1" height="1" fill="#a8501c"/><rect x="19" y="9" width="2" height="1" fill="#2b2230"/><rect x="21" y="9" width="3" height="1" fill="#f6ecdc"/><rect x="24" y="9" width="1" height="1" fill="#a8501c"/><rect x="25" y="9" width="2" height="1" fill="#2b2230"/><rect x="16" y="10" width="1" height="1" fill="#2b2230"/><rect x="17" y="10" width="1" height="1" fill="#a8501c"/><rect x="18" y="10" width="9" height="1" fill="#f6ecdc"/><rect x="27" y="10" width="1" height="1" fill="#2b2230"/><rect x="16" y="11" width="1" height="1" fill="#2b2230"/><rect x="17" y="11" width="1" height="1" fill="#a8501c"/><rect x="18" y="11" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="11" width="1" height="1" fill="#2b2230"/><rect x="15" y="12" width="1" height="1" fill="#2b2230"/><rect x="16" y="12" width="1" height="1" fill="#f6ecdc"/><rect x="17" y="12" width="2" height="1" fill="#f08a3c"/><rect x="19" y="12" width="9" height="1" fill="#f6ecdc"/><rect x="28" y="12" width="1" height="1" fill="#2b2230"/><rect x="15" y="13" width="1" height="1" fill="#2b2230"/><rect x="16" y="13" width="1" height="1" fill="#a8501c"/><rect x="17" y="13" width="3" height="1" fill="#f08a3c"/><rect x="20" y="13" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="13" width="1" height="1" fill="#2b2230"/><rect x="8" y="14" width="8" height="1" fill="#2b2230"/><rect x="16" y="14" width="1" height="1" fill="#a8501c"/><rect x="17" y="14" width="3" height="1" fill="#f08a3c"/><rect x="20" y="14" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="14" width="1" height="1" fill="#2b2230"/><rect x="6" y="15" width="2" height="1" fill="#2b2230"/><rect x="8" y="15" width="8" height="1" fill="#f6ecdc"/><rect x="16" y="15" width="1" height="1" fill="#a8501c"/><rect x="17" y="15" width="3" height="1" fill="#f08a3c"/><rect x="20" y="15" width="3" height="1" fill="#f6ecdc"/><rect x="23" y="15" width="2" height="1" fill="#1c1614"/><rect x="25" y="15" width="4" height="1" fill="#f6ecdc"/><rect x="29" y="15" width="3" height="1" fill="#2b2230"/><rect x="5" y="16" width="1" height="1" fill="#2b2230"/><rect x="6" y="16" width="10" height="1" fill="#f6ecdc"/><rect x="16" y="16" width="4" height="1" fill="#f08a3c"/><rect x="20" y="16" width="10" height="1" fill="#f6ecdc"/><rect x="30" y="16" width="2" height="1" fill="#1c1614"/><rect x="32" y="16" width="1" height="1" fill="#2b2230"/><rect x="4" y="17" width="1" height="1" fill="#2b2230"/><rect x="5" y="17" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="17" width="1" height="1" fill="#dba77a"/><rect x="17" y="17" width="2" height="1" fill="#f08a3c"/><rect x="19" y="17" width="2" height="1" fill="#dba77a"/><rect x="21" y="17" width="10" height="1" fill="#f6ecdc"/><rect x="31" y="17" width="1" height="1" fill="#2b2230"/><rect x="3" y="18" width="1" height="1" fill="#2b2230"/><rect x="4" y="18" width="12" height="1" fill="#f6ecdc"/><rect x="16" y="18" width="5" height="1" fill="#dba77a"/><rect x="21" y="18" width="9" height="1" fill="#f6ecdc"/><rect x="30" y="18" width="1" height="1" fill="#2b2230"/><rect x="3" y="19" width="1" height="1" fill="#2b2230"/><rect x="4" y="19" width="12" height="1" fill="#f6ecdc"/><rect x="16" y="19" width="2" height="1" fill="#c08a5c"/><rect x="18" y="19" width="2" height="1" fill="#dba77a"/><rect x="20" y="19" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="19" width="1" height="1" fill="#2b2230"/><rect x="2" y="20" width="1" height="1" fill="#2b2230"/><rect x="3" y="20" width="14" height="1" fill="#f6ecdc"/><rect x="17" y="20" width="2" height="1" fill="#dba77a"/><rect x="19" y="20" width="8" height="1" fill="#f6ecdc"/><rect x="27" y="20" width="3" height="1" fill="#2b2230"/><rect x="2" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="21" width="17" height="1" fill="#dcc7a8"/><rect x="20" y="21" width="1" height="1" fill="#2b2230"/><rect x="21" y="21" width="3" height="1" fill="#f6ecdc"/><rect x="24" y="21" width="6" height="1" fill="#dcc7a8"/><rect x="30" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="22" width="7" height="1" fill="#2b2230"/><rect x="10" y="22" width="7" height="1" fill="#dcc7a8"/><rect x="17" y="22" width="4" height="1" fill="#2b2230"/><rect x="21" y="22" width="9" height="1" fill="#dcc7a8"/><rect x="30" y="22" width="1" height="1" fill="#2b2230"/><rect x="10" y="23" width="7" height="1" fill="#2b2230"/><rect x="21" y="23" width="9" height="1" fill="#2b2230"/>'
6export const sleep1 = '<rect x="19" y="7" width="5" height="1" fill="#2b2230"/><rect x="17" y="8" width="2" height="1" fill="#2b2230"/><rect x="19" y="8" width="5" height="1" fill="#a8501c"/><rect x="24" y="8" width="1" height="1" fill="#2b2230"/><rect x="16" y="9" width="1" height="1" fill="#2b2230"/><rect x="17" y="9" width="1" height="1" fill="#f6ecdc"/><rect x="18" y="9" width="1" height="1" fill="#a8501c"/><rect x="19" y="9" width="2" height="1" fill="#2b2230"/><rect x="21" y="9" width="3" height="1" fill="#f6ecdc"/><rect x="24" y="9" width="1" height="1" fill="#a8501c"/><rect x="25" y="9" width="2" height="1" fill="#2b2230"/><rect x="16" y="10" width="1" height="1" fill="#2b2230"/><rect x="17" y="10" width="1" height="1" fill="#a8501c"/><rect x="18" y="10" width="9" height="1" fill="#f6ecdc"/><rect x="27" y="10" width="1" height="1" fill="#2b2230"/><rect x="16" y="11" width="1" height="1" fill="#2b2230"/><rect x="17" y="11" width="1" height="1" fill="#a8501c"/><rect x="18" y="11" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="11" width="1" height="1" fill="#2b2230"/><rect x="15" y="12" width="1" height="1" fill="#2b2230"/><rect x="16" y="12" width="1" height="1" fill="#f6ecdc"/><rect x="17" y="12" width="2" height="1" fill="#f08a3c"/><rect x="19" y="12" width="9" height="1" fill="#f6ecdc"/><rect x="28" y="12" width="1" height="1" fill="#2b2230"/><rect x="8" y="13" width="8" height="1" fill="#2b2230"/><rect x="16" y="13" width="1" height="1" fill="#a8501c"/><rect x="17" y="13" width="3" height="1" fill="#f08a3c"/><rect x="20" y="13" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="13" width="1" height="1" fill="#2b2230"/><rect x="6" y="14" width="2" height="1" fill="#2b2230"/><rect x="8" y="14" width="8" height="1" fill="#f6ecdc"/><rect x="16" y="14" width="1" height="1" fill="#a8501c"/><rect x="17" y="14" width="3" height="1" fill="#f08a3c"/><rect x="20" y="14" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="14" width="1" height="1" fill="#2b2230"/><rect x="5" y="15" width="1" height="1" fill="#2b2230"/><rect x="6" y="15" width="10" height="1" fill="#f6ecdc"/><rect x="16" y="15" width="1" height="1" fill="#a8501c"/><rect x="17" y="15" width="3" height="1" fill="#f08a3c"/><rect x="20" y="15" width="3" height="1" fill="#f6ecdc"/><rect x="23" y="15" width="2" height="1" fill="#1c1614"/><rect x="25" y="15" width="4" height="1" fill="#f6ecdc"/><rect x="29" y="15" width="3" height="1" fill="#2b2230"/><rect x="4" y="16" width="1" height="1" fill="#2b2230"/><rect x="5" y="16" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="16" width="4" height="1" fill="#f08a3c"/><rect x="20" y="16" width="10" height="1" fill="#f6ecdc"/><rect x="30" y="16" width="2" height="1" fill="#1c1614"/><rect x="32" y="16" width="1" height="1" fill="#2b2230"/><rect x="3" y="17" width="1" height="1" fill="#2b2230"/><rect x="4" y="17" width="12" height="1" fill="#f6ecdc"/><rect x="16" y="17" width="1" height="1" fill="#dba77a"/><rect x="17" y="17" width="2" height="1" fill="#f08a3c"/><rect x="19" y="17" width="2" height="1" fill="#dba77a"/><rect x="21" y="17" width="10" height="1" fill="#f6ecdc"/><rect x="31" y="17" width="1" height="1" fill="#2b2230"/><rect x="3" y="18" width="1" height="1" fill="#2b2230"/><rect x="4" y="18" width="12" height="1" fill="#f6ecdc"/><rect x="16" y="18" width="5" height="1" fill="#dba77a"/><rect x="21" y="18" width="9" height="1" fill="#f6ecdc"/><rect x="30" y="18" width="1" height="1" fill="#2b2230"/><rect x="3" y="19" width="2" height="1" fill="#2b2230"/><rect x="5" y="19" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="19" width="2" height="1" fill="#c08a5c"/><rect x="18" y="19" width="2" height="1" fill="#dba77a"/><rect x="20" y="19" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="19" width="1" height="1" fill="#2b2230"/><rect x="2" y="20" width="1" height="1" fill="#2b2230"/><rect x="3" y="20" width="14" height="1" fill="#f6ecdc"/><rect x="17" y="20" width="2" height="1" fill="#dba77a"/><rect x="19" y="20" width="8" height="1" fill="#f6ecdc"/><rect x="27" y="20" width="3" height="1" fill="#2b2230"/><rect x="2" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="21" width="17" height="1" fill="#dcc7a8"/><rect x="20" y="21" width="1" height="1" fill="#2b2230"/><rect x="21" y="21" width="3" height="1" fill="#f6ecdc"/><rect x="24" y="21" width="6" height="1" fill="#dcc7a8"/><rect x="30" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="22" width="7" height="1" fill="#2b2230"/><rect x="10" y="22" width="7" height="1" fill="#dcc7a8"/><rect x="17" y="22" width="4" height="1" fill="#2b2230"/><rect x="21" y="22" width="9" height="1" fill="#dcc7a8"/><rect x="30" y="22" width="1" height="1" fill="#2b2230"/><rect x="10" y="23" width="7" height="1" fill="#2b2230"/><rect x="21" y="23" width="9" height="1" fill="#2b2230"/>'
7
types/index.d.ts 68 lines
1// Any JSON value (what a status file can hold).
2export type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
3
4// One status file, as FORMAT.md describes it (format "pipeline-status", version 1).
5export type State = 'running' | 'idle' | 'waiting' | 'error'
6
7export type WaitingItem = { id: string; text: string; since?: string }
8
9export type Status = {
10  format: 'pipeline-status'
11  version: 1
12  pipeline: string
13  title?: string
14  host?: string
15  updated: string
16  stale_after_sec?: number
17  state: State
18  headline: string
19  progress?: { done: number; total: number | null; unit?: string } | null
20  waiting?: WaitingItem[]
21  last_output?: { label: string; at?: string; path?: string } | null
22  next?: string | null
23  detail?: { [key: string]: Json } | null
24}
25
26// detail of pipeline "music" (baibai-music tools/status_export.py)
27export type MusicBatch = {
28  name: string
29  strip?: string
30  est_usable?: number | null
31  target_usable?: number | null
32  generated?: number | null
33  ok?: number | null
34  needs_listen?: number | null
35  rejected?: number | null
36  errors?: number | null
37  avg_gen_sec?: number | null
38  status_tail?: string[]
39}
40export type MusicDetail = {
41  comfy?: { up: boolean; running: number | null; pending: number | null }
42  generating?: boolean | null
43  batches?: MusicBatch[]
44}
45
46// detail of pipeline "ai-video" (ai-video-pipeline)
47export type Stage = 'plan' | 'clips' | 'voice' | 'compose' | 'final' | 'done'
48export type VideoJob = { job: string; stage: Stage; spend_usd: number; cap_usd: number; updated?: string; next?: string }
49export type VideoDetail = { spend_usd?: number; cap_usd?: number; jobs?: VideoJob[] }
50
51// A file in the folder that is not a card, and why.
52export type Problem = { file: string; reason: string }
53
54export type Snapshot = {
55  dir: string
56  cards: Status[]
57  problems: Problem[]
58  // the folder itself could not be read (missing, not set)
59  dirError: string | null
60  loadedAt: number
61}
62
63declare module 'claude-code' {
64  interface PluginState {
65    'puppy-console': { snapshot: Snapshot | null }
66  }
67}
68