SLOPSHOPPER

boot-sequence

A BIOS-style boot screen when a session starts: real checks of git, toolchains, devices, ports, context and disk, typed out and flipped to OK, WARN or FAIL

panebandcommandpromptprocesstimer
v0.2.0MITupdated 2026-10-02ccdwyer/boot-sequence
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · boot-sequence
│ ┃ Boot sequence ✕ › 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 │ │ › /boot │ ⎿ boot-sequence: Boot Sequence: replaying the boot checks. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Boot sequence
▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
README

Boot Sequence

Boot Sequence demo

Watch the MP4 · Screenshot · Screenshot

A BIOS-style boot screen for Claude Code. When a session starts, a gradient CLAUDE CODE wordmark sweeps in under a scanline, a memory test counts up, and a POST log types itself out above the prompt. Every line is a real check of your machine, and each one flips from a spinner to [ OK ], [ WARN ] or [ FAIL ]:

CheckWhat it reportsWarns when
CPUOS, architecture, core count
GITbranch, changed files, ahead/behind upstreamnot a repo, or behind upstream
TOOLCHAINnode, pnpm, python, xcode, java versions (whatever is installed)
DEVICESbooted iOS simulators and Android devices
PORTSdev servers listening (3000, 5173, 8081, 19000…)
MODSplugins that registered commands
CONTEXTcontext window used≥50% (fails at ≥85%)
DISKfree space on the project's diskunder 10 GB (fails under 2 GB)

Missing tools are skipped silently. The checks start after the prompt is ready and share a hard 5-second budget (each command gets at most 1.5 s of it), so startup is never slower. The checks that can fail, context and disk, run first. A check that runs out of time shows [ SKIP ], a check that only partly finished is marked partial (timed out), and the summary says PARTIAL or FAIL instead of pretending everything is fine. Disk warns at 90% used (or under 10 GB free) and fails at 97% (or under 2 GB). Git runs with --no-optional-locks, so the boot never takes the index lock.

If the band above the prompt is too short to show the log and the other mods' rows, it shows only the one-line summary.

When the log finishes it folds into a one-line summary (▣ BOOT OK 7 ok · 1 warn · 0 fail), as soon as you send a prompt or after a few seconds. Click skip (or press s while the band has focus, after a click or ctrl+x tab) to fold it early, x to hide the summary, and /boot to replay the whole sequence in a pane.

Settings

  • Animate the boot screen (on): off shows the finished log at once, with no typewriter or scanline.
  • Collapse after (seconds) (8): how long the finished log stays open.

The pixel logo uses a Raster, so it draws in the terminal. Other surfaces get the same log as plain text.

Install

/plugin marketplace add ccdwyer/claude-mods
/plugin install boot-sequence@ccdwyer-mods
/reload-plugins

Develop

claude plugin validate .
claude plugin test .

What it hooks

  • session.start: registers /boot and, in an interactive session, starts the boot 400 ms after the prompt is ready.
  • command.run{command=boot}: replays the boot in a pane.
  • prompt.submit: folds the log into its summary when you send a prompt (your own prompts only, not background notifications).
  • ui.render{component=AbovePrompt}: draws the boot log or its summary, composed above whatever other mods draw there.
  • ui.render{component=Pane}: draws the /boot replay.

It never refuses, rewrites or delays a tool call or a prompt.

Privacy

It runs entirely on your machine and sends nothing over the network. Full policy: PRIVACY.md.

License

MIT

Source 2 files
hooks/register.tsx 667 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Boot, Line, Status } from '../types'
5
6const boot = atom({ plugin: 'boot-sequence', key: 'boot' } as const, null)
7const tick = atom({ plugin: 'boot-sequence', key: 'tick' } as const, 0)
8const isCollapsed = atom({ plugin: 'boot-sequence', key: 'isCollapsed' } as const, false)
9const isHidden = atom({ plugin: 'boot-sequence', key: 'isHidden' } as const, false)
10// Set by the person's first prompt and never cleared by a boot, so a boot that
11// starts after it stays folded.
12const hasPrompted = atom({ plugin: 'boot-sequence', key: 'hasPrompted' } as const, false)
13
14const PANE = 'boot-sequence'
15const FRAME_MS = 60
16// Ticks the logo's scanline takes to sweep down, and characters typed per tick.
17const LOGO_TICKS = 10
18const CHARS_PER_TICK = 6
19// Each command gets at most this long, and never past the whole boot's deadline.
20const CHECK_MS = 1500
21const DEADLINE_MS = 5000
22// The person's own prompts; a channel relay can be someone else's message.
23const PERSON = ['composer', 'bridge', 'slack-ping']
24// The checks that can fail come first, so a slow machine still shows them.
25const IDS = ['context', 'disk', 'git', 'ports', 'devices', 'mods', 'cpu', 'toolchain']
26const LABELS: Record<string, string> = {
27  cpu: 'CPU', git: 'GIT', toolchain: 'TOOLCHAIN', devices: 'DEVICES',
28  ports: 'PORTS', mods: 'MODS', context: 'CONTEXT', disk: 'DISK',
29}
30const COLOR: Record<Status, string> = { run: '#6c7086', ok: '#a6e3a1', warn: '#f9e2af', fail: '#f38ba8', skip: '#6c7086' }
31const BADGE: Record<Status, string> = { run: '[ .... ]', ok: '[  OK  ]', warn: '[ WARN ]', fail: '[ FAIL ]', skip: '[ SKIP ]' }
32const DEV_PORTS = new Set([3000, 3001, 4200, 5173, 5174, 8000, 8080, 8081, 8888, 19000, 19001, 19006])
33
34// Timers live in the module: a reload drops them, and the state below survives.
35let stopTicker: (() => void) | null = null
36let runSeq = 0
37// Bumped whenever a ticker starts or stops, so a ticker armed after a newer one cancels itself.
38let tickerGen = 0
39// The band boot whose collapse is armed, so it is armed once, when its log has been typed out.
40let collapseArmedFor = 0
41
42type Result = { status: Status; detail: string } | null
43type Options = { animate?: boolean; collapseSeconds?: number }
44type Proc = { code: number; out: string; err: string; truncated: boolean }
45/** One boot's time budget, and whether a newer boot has taken over. */
46type Budget = { seq: number; deadline: number; timedOut: boolean }
47
48// ---------------------------------------------------------------- the logo
49
50// A 4x5 block font for the letters the logo needs.
51const FONT: Record<string, string[]> = {
52  C: ['.###', '#...', '#...', '#...', '.###'],
53  L: ['#...', '#...', '#...', '#...', '####'],
54  A: ['.##.', '#..#', '####', '#..#', '#..#'],
55  U: ['#..#', '#..#', '#..#', '#..#', '.##.'],
56  D: ['###.', '#..#', '#..#', '#..#', '###.'],
57  E: ['####', '#...', '###.', '#...', '####'],
58  O: ['.##.', '#..#', '#..#', '#..#', '.##.'],
59  ' ': ['..', '..', '..', '..', '..'],
60}
61const WORD = 'CLAUDE CODE'
62const LOGO_ROWS = 5
63const LOGO_COLS = [...WORD].reduce((n, ch) => n + (FONT[ch]?.[0]?.length ?? 0) + 1, 0) - 1
64
65const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
66function base64(bytes: Uint8Array): string {
67  let out = ''
68  for (let i = 0; i < bytes.length; i += 3) {
69    const a = bytes[i] ?? 0
70    const b = bytes[i + 1] ?? 0
71    const c = bytes[i + 2] ?? 0
72    out += B64[a >> 2]
73    out += B64[((a & 3) << 4) | (b >> 4)]
74    out += i + 1 < bytes.length ? B64[((b & 15) << 2) | (c >> 6)] : '='
75    out += i + 2 < bytes.length ? B64[c & 63] : '='
76  }
77  return out
78}
79
80function mix(a: number, b: number, t: number): number {
81  const ch = (shift: number) => Math.round(((a >> shift) & 255) * (1 - t) + ((b >> shift) & 255) * t)
82  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
83}
84
85/** The logo's cells at a tick: a gradient wordmark revealed by a scanline, then a sheen that sweeps across. */
86export function logoCells(at: number): string {
87  const words = new Uint32Array(LOGO_COLS * LOGO_ROWS * 3)
88  const revealed = Math.min(LOGO_ROWS, Math.floor((at / LOGO_TICKS) * (LOGO_ROWS + 1)))
89  const sheen = at > LOGO_TICKS ? ((at - LOGO_TICKS) * 2) % (LOGO_COLS + 24) - 12 : -99
90  let x = 0
91  for (const ch of WORD) {
92    const glyph = FONT[ch] ?? []
93    const width = glyph[0]?.length ?? 0
94    for (let gx = 0; gx < width; gx += 1) {
95      for (let y = 0; y < LOGO_ROWS; y += 1) {
96        const i = (y * LOGO_COLS + x + gx) * 3
97        const on = glyph[y]?.[gx] === '#'
98        const base = mix(0x89dceb, 0xcba6f7, (x + gx) / LOGO_COLS)
99        const lit = Math.abs(x + gx - sheen) < 3
100        if (y < revealed && on) {
101          words[i] = 0x2588
102          words[i + 1] = lit ? 0xffffff : base
103        } else if (y === revealed && revealed < LOGO_ROWS) {
104          words[i] = 0x2580
105          words[i + 1] = 0x94e2d5
106        } else {
107          words[i] = 0x20
108          words[i + 1] = 0x01000000
109        }
110        words[i + 2] = 0x01000000
111      }
112    }
113    x += width + 1
114  }
115  for (let i = 0; i < words.length; i += 3) if (words[i] === 0) { words[i] = 0x20; words[i + 1] = 0x01000000; words[i + 2] = 0x01000000 }
116  return base64(new Uint8Array(words.buffer))
117}
118
119// ---------------------------------------------------------------- the checks
120
121async function isLive($: EngineInterface, budget: Budget): Promise<boolean> {
122  if (budget.seq !== runSeq) return false
123  return (await $.clock.now()) < budget.deadline
124}
125
126/** Runs one command under the boot's budget; null when it could not run or ran out of time. */
127async function run($: EngineInterface, budget: Budget, argv: string[], cwd?: string): Promise<Proc | null> {
128  if (budget.seq !== runSeq) return null
129  const left = budget.deadline - (await $.clock.now())
130  if (left < 50) {
131    budget.timedOut = true
132    return null
133  }
134  const timeoutMs = Math.min(CHECK_MS, left)
135  const startedAt = await $.clock.now()
136  try {
137    const r = await $.process.run(argv, { timeoutMs, cwd })
138    return { code: r.exitCode, out: r.stdout, err: r.stderr, truncated: r.isStdoutTruncated || r.isStderrTruncated }
139  } catch {
140    // The host kills a child at its timeout and the call rejects: remember that it ran out of time.
141    if ((await $.clock.now()) - startedAt >= timeoutMs * 0.9) budget.timedOut = true
142    return null
143  }
144}
145
146function firstVersion(text: string): string | null {
147  const m = text.match(/(\d+\.\d+(?:\.\d+)?)/)
148  return m ? (m[1] as string) : null
149}
150
151async function checkCpu($: EngineInterface, budget: Budget): Promise<Result> {
152  const r = await run($, budget, ['uname', '-sm'])
153  if (r === null || r.code !== 0) return null
154  let cores = await run($, budget, ['sysctl', '-n', 'hw.ncpu'])
155  if (cores === null || cores.code !== 0) cores = await run($, budget, ['getconf', '_NPROCESSORS_ONLN'])
156  const n = cores !== null && cores.code === 0 && cores.out.trim() !== '' ? ` · ${cores.out.trim()} cores` : ''
157  return { status: 'ok', detail: `${r.out.trim().split('\n')[0]}${n}` }
158}
159
160async function checkMemory($: EngineInterface, budget: Budget): Promise<number> {
161  const r = await run($, budget, ['sysctl', '-n', 'hw.memsize'])
162  const bytes = r !== null && r.code === 0 ? Number(r.out.trim()) : NaN
163  if (Number.isFinite(bytes) && bytes > 0) return Math.round(bytes / 1048576)
164  // Linux has no hw.memsize; /proc/meminfo has the total in kB.
165  try {
166    const text = await $.fs.read('/proc/meminfo')
167    const m = String(text).match(/MemTotal:\s+(\d+)\s*kB/)
168    return m ? Math.round(Number(m[1]) / 1024) : 0
169  } catch {
170    return 0
171  }
172}
173
174async function checkGit($: EngineInterface, budget: Budget, cwd: string): Promise<Result> {
175  const branch = await run($, budget, ['git', '--no-optional-locks', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd)
176  // git missing or out of time: skip the line rather than guess.
177  if (branch === null || branch.code === 127) return null
178  if (branch.code !== 0) {
179    if (/not a git repository/i.test(branch.err)) return { status: 'warn', detail: 'not a git repository' }
180    return { status: 'warn', detail: 'git unavailable here' }
181  }
182  const name = branch.out.trim().split('\n')[0]
183  const status = await run($, budget, ['git', '--no-optional-locks', 'status', '--porcelain'], cwd)
184  if (status === null || status.code !== 0 || status.truncated) return { status: 'warn', detail: `${name} · status unavailable` }
185  const dirty = status.out.split('\n').filter(l => l.trim() !== '').length
186  const counts = await run($, budget, ['git', '--no-optional-locks', 'rev-list', '--left-right', '--count', '@{upstream}...HEAD'], cwd)
187  const changed = dirty === 0 ? 'clean' : `${dirty} changed`
188  // Out of time or failed for a reason other than "no upstream": say it is unknown, never "no upstream".
189  if (counts === null) return { status: 'warn', detail: `${name} · ${changed} · ahead/behind unavailable` }
190  if (counts.code !== 0) {
191    if (/no upstream|does not point to a branch|unknown revision/i.test(counts.err)) return { status: 'ok', detail: `${name} · ${changed} · no upstream` }
192    return { status: 'warn', detail: `${name} · ${changed} · ahead/behind unavailable` }
193  }
194  const [b, a] = counts.out.trim().split(/\s+/).map(Number)
195  const behind = b ?? 0
196  return { status: behind > 0 ? 'warn' : 'ok', detail: `${name} · ${changed} · ↑${a ?? 0} ↓${behind}` }
197}
198
199async function checkToolchain($: EngineInterface, budget: Budget): Promise<Result> {
200  const found: string[] = []
201  const tools: Array<[string, string[], boolean]> = [
202    ['node', ['node', '--version'], false],
203    ['pnpm', ['pnpm', '--version'], false],
204    ['python', ['python3', '--version'], false],
205    ['xcode', ['xcodebuild', '-version'], false],
206    // java prints its version on stderr.
207    ['java', ['java', '-version'], true],
208  ]
209  for (const [label, argv, onErr] of tools) {
210    const r = await run($, budget, argv)
211    if (r === null || r.code !== 0) continue
212    const v = firstVersion(onErr ? r.err : r.out)
213    if (v !== null) found.push(`${label} ${v}`)
214  }
215  if (found.length === 0) return null
216  return { status: 'ok', detail: found.join(' · ') }
217}
218
219async function checkDevices($: EngineInterface, budget: Budget): Promise<Result> {
220  const parts: string[] = []
221  const sims = await run($, budget, ['xcrun', 'simctl', 'list', 'devices', 'booted', '-j'])
222  if (sims !== null && sims.code === 0) {
223    try {
224      const parsed = JSON.parse(sims.out) as { devices?: Record<string, unknown[]> }
225      const n = Object.values(parsed.devices ?? {}).reduce((sum, list) => sum + list.length, 0)
226      parts.push(`${n} iOS sim${n === 1 ? '' : 's'}`)
227    } catch {
228      // A partial read: say nothing rather than guess.
229    }
230  }
231  const adb = await run($, budget, ['adb', 'devices'])
232  if (adb !== null && adb.code === 0) {
233    const n = adb.out.split('\n').filter(l => /\tdevice$/.test(l)).length
234    parts.push(`${n} android`)
235  }
236  if (parts.length === 0) return null
237  return { status: 'ok', detail: parts.join(' · ') }
238}
239
240async function checkPorts($: EngineInterface, budget: Budget): Promise<Result> {
241  const r = await run($, budget, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN'])
242  if (r === null || r.truncated) return null
243  const table = r.out.split('\n')
244  const hasTable = /^COMMAND\s/.test(table[0] ?? '')
245  // lsof exits 1 with an empty table when nothing listens; anything else unreadable is skipped.
246  if (!hasTable && !(r.code === 1 && r.out.trim() === '' && r.err.trim() === '')) return null
247  const seen = new Map<number, string>()
248  for (const line of table.slice(1)) {
249    const cols = line.trim().split(/\s+/)
250    const port = Number(cols[8]?.split(':').pop())
251    if (DEV_PORTS.has(port) && !seen.has(port)) seen.set(port, cols[0] ?? '?')
252  }
253  if (seen.size === 0) return { status: 'ok', detail: 'no dev servers listening' }
254  const list = [...seen.entries()].sort((a, b) => a[0] - b[0]).map(([p, cmd]) => `:${p} ${cmd}`)
255  return { status: 'ok', detail: list.slice(0, 5).join(' · ') }
256}
257
258async function checkMods($: EngineInterface): Promise<Result> {
259  try {
260    const commands = await $.command.list()
261    const own = commands.filter(c => c.source === 'plugin')
262    const plugins = new Set(own.map(c => c.plugin).filter((p): p is string => typeof p === 'string'))
263    return { status: 'ok', detail: `${plugins.size} plugin${plugins.size === 1 ? '' : 's'} · ${own.length} plugin commands` }
264  } catch {
265    return null
266  }
267}
268
269async function checkContext($: EngineInterface): Promise<Result> {
270  try {
271    const usage = await $.session.usage()
272    const ctx = usage.context
273    const pct = typeof ctx.percent === 'number' ? Math.round(ctx.percent) : ctx.tokens !== undefined ? Math.round((ctx.tokens / ctx.window) * 100) : 0
274    const kt = Math.round(ctx.window / 1000)
275    return { status: pct >= 85 ? 'fail' : pct >= 50 ? 'warn' : 'ok', detail: `${pct}% of ${kt}k used` }
276  } catch {
277    return { status: 'skip', detail: 'usage unavailable' }
278  }
279}
280
281async function checkDisk($: EngineInterface, budget: Budget, cwd: string): Promise<Result> {
282  // -P keeps each filesystem on one line, however long its name.
283  const r = await run($, budget, ['df', '-Pk', cwd])
284  if (r === null) return budget.timedOut ? null : { status: 'skip', detail: 'df failed' }
285  if (r.code !== 0) return { status: 'skip', detail: 'df failed' }
286  const cols = (r.out.split('\n')[1] ?? '').trim().split(/\s+/)
287  const freeKb = Number(cols[3])
288  const usedPct = Number(String(cols[4] ?? '').replace('%', ''))
289  if (!Number.isFinite(freeKb)) return { status: 'skip', detail: 'df output unreadable' }
290  const gb = freeKb / 1048576
291  const full = Number.isFinite(usedPct) ? ` · ${usedPct}% used` : ''
292  const isFail = gb < 2 || usedPct >= 97
293  const isWarn = gb < 10 || usedPct >= 90
294  return { status: isFail ? 'fail' : isWarn ? 'warn' : 'ok', detail: `${gb.toFixed(1)} GB free${full}` }
295}
296
297async function runCheck($: EngineInterface, budget: Budget, id: string, cwd: string): Promise<Result> {
298  switch (id) {
299    case 'cpu':
300      return checkCpu($, budget)
301    case 'git':
302      return checkGit($, budget, cwd)
303    case 'toolchain':
304      return checkToolchain($, budget)
305    case 'devices':
306      return checkDevices($, budget)
307    case 'ports':
308      return checkPorts($, budget)
309    case 'mods':
310      return checkMods($)
311    case 'context':
312      return checkContext($)
313    case 'disk':
314      return checkDisk($, budget, cwd)
315    default:
316      return null
317  }
318}
319
320// ---------------------------------------------------------------- the boot
321
322function typedLength(lines: Line[]): number {
323  return lines.reduce((n, l) => n + l.label.length + l.detail.length + 12, 0)
324}
325
326function stopTicking() {
327  tickerGen += 1
328  if (stopTicker !== null) stopTicker()
329  stopTicker = null
330}
331
332async function startTicker($: EngineInterface, animate: boolean) {
333  stopTicking()
334  const gen = tickerGen
335  if (!animate) {
336    await update($, tick, () => 1_000_000)
337    return
338  }
339  await update($, tick, () => 0)
340  // A newer boot started while this one awaited: it owns the clock.
341  if (gen !== tickerGen) return
342  // The callback returns advance's promise, so a slow frame never overlaps the next.
343  const handle = $.clock.every(FRAME_MS, () => advance($))
344  stopTicker = () => handle.cancel()
345}
346
347async function advance($: EngineInterface) {
348  const now = await update($, tick, n => n + 1)
349  const b = await read($, boot)
350  if (b === null) {
351    stopTicking()
352    return
353  }
354  const typedOut = b.isDone && now > LOGO_TICKS + typedLength(b.lines) / CHARS_PER_TICK + 10
355  // Folded or hidden in the band, nothing animates on screen: stop the clock.
356  const offScreen = b.where === 'band' && ((await read($, isCollapsed)) || (await read($, isHidden)))
357  if (typedOut || offScreen) {
358    stopTicking()
359    if (typedOut && b.where === 'band') armCollapse($, b.run, lastOptions)
360  }
361}
362
363let lastOptions: Options = {}
364
365/** Folds the band boot a while after its log has been fully typed (once per boot). */
366function armCollapse($: EngineInterface, seq: number, options: Options) {
367  if (collapseArmedFor === seq) return
368  collapseArmedFor = seq
369  const wait = Math.max(1, options.collapseSeconds ?? 8) * 1000
370  $.clock.after(wait, () => {
371    void collapseIf($, seq)
372  })
373}
374
375async function startBoot($: EngineInterface, where: 'band' | 'pane', options: Options) {
376  runSeq += 1
377  const seq = runSeq
378  const animate = options.animate !== false
379  lastOptions = options
380  const budget: Budget = { seq, deadline: (await $.clock.now()) + DEADLINE_MS, timedOut: false }
381  // A watchdog: whatever a check is still awaiting, the boot finishes shortly after its deadline.
382  $.clock.after(DEADLINE_MS + 1000, () => {
383    void finish($, seq, where, options)
384  })
385  // A boot in the band that starts after the person has already prompted stays folded.
386  const folded = where === 'band' && (await read($, hasPrompted))
387  await update($, isCollapsed, () => folded)
388  await update($, isHidden, () => false)
389  await update($, boot, (): Boot => ({ run: seq, lines: [], memoryMb: 0, isDone: false, where }))
390  await startTicker($, animate)
391  try {
392    const cwd = await $.session.cwd()
393    const memory = await checkMemory($, budget)
394    await update($, boot, cur => (cur !== null && cur.run === seq ? { ...cur, memoryMb: memory } : cur))
395    for (const id of IDS) {
396      if (budget.seq !== runSeq) return
397      const label = LABELS[id] ?? id.toUpperCase()
398      if (!(await isLive($, budget))) {
399        // Out of time: say so instead of leaving the line out.
400        const skipped: Line = { id, label, status: 'skip', detail: 'time limit' }
401        await update($, boot, (cur): Boot | null => (cur !== null && cur.run === seq ? { ...cur, lines: [...cur.lines, skipped] } : cur))
402        continue
403      }
404      const pending: Line = { id, label, status: 'run', detail: '' }
405      await update($, boot, (cur): Boot | null => (cur !== null && cur.run === seq ? { ...cur, lines: [...cur.lines, pending] } : cur))
406      budget.timedOut = false
407      const raw = await runCheck($, budget, id, cwd)
408      // Part of a check ran out of time: keep what it saw, but never call it complete.
409      const result: Result =
410        raw !== null && budget.timedOut && raw.status !== 'skip'
411          ? { status: raw.status === 'ok' ? 'warn' : raw.status, detail: `${raw.detail} · partial (timed out)` }
412          : raw
413      const timedOut = result === null && budget.seq === runSeq && (budget.timedOut || !(await isLive($, budget)))
414      await update($, boot, (cur): Boot | null => {
415        if (cur === null || cur.run !== seq || cur.isDone) return cur
416        let lines: Line[]
417        if (result !== null) lines = cur.lines.map((l): Line => (l.id === id ? { ...l, status: result.status, detail: result.detail } : l))
418        else if (timedOut) lines = cur.lines.map((l): Line => (l.id === id ? { ...l, status: 'skip', detail: 'timed out' } : l))
419        // A missing tool is skipped silently: its line goes away.
420        else lines = cur.lines.filter(l => l.id !== id)
421        return { ...cur, lines }
422      })
423    }
424  } catch {
425    // Whatever failed, say so: the boot below still finishes and folds, but never as OK.
426    await update($, boot, (cur): Boot | null =>
427      cur !== null && cur.run === seq && !cur.isDone
428        ? { ...cur, lines: [...cur.lines, { id: 'boot', label: 'BOOT', status: 'fail', detail: 'a check crashed; results incomplete' }] }
429        : cur,
430    )
431  } finally {
432    await finish($, seq, where, options)
433  }
434}
435
436async function finish($: EngineInterface, seq: number, where: 'band' | 'pane', options: Options) {
437  let wasDone = false
438  const done = await update($, boot, (cur): Boot | null => {
439    if (cur === null || cur.run !== seq) return cur
440    wasDone = cur.isDone
441    if (cur.isDone) return cur
442    // Anything still marked running when the boot ends did not finish.
443    const lines = cur.lines.map((l): Line => (l.status === 'run' ? { ...l, status: 'skip', detail: 'did not finish' } : l))
444    return { ...cur, lines, isDone: true }
445  })
446  if (done === null || done.run !== seq || wasDone) return
447  // Animated, the collapse is armed once the log has been typed out (in advance); still, it is armed now.
448  if (where === 'band' && (options.animate === false || stopTicker === null)) armCollapse($, seq, options)
449}
450
451async function collapseIf($: EngineInterface, seq: number) {
452  const b = await read($, boot)
453  if (b !== null && b.run === seq) await update($, isCollapsed, () => true)
454}
455
456async function registerBoot($: EngineInterface) {
457  try {
458    await $.command.register({ name: 'boot', description: 'Boot Sequence: replay the boot screen and its checks in a pane', immediate: true })
459  } catch {
460    // Commands are a convenience; the boot still runs.
461  }
462}
463
464/** The session's own boot, unless a /boot replay already started one. */
465async function startupBoot($: EngineInterface, options: Options) {
466  if ((await read($, boot)) !== null) return
467  await startBoot($, 'band', options)
468}
469
470export function tally(lines: Line[]): Record<Status, number> {
471  const out: Record<Status, number> = { run: 0, ok: 0, warn: 0, fail: 0, skip: 0 }
472  for (const l of lines) out[l.status] += 1
473  return out
474}
475
476// ---------------------------------------------------------------- drawing
477
478type Els = ReturnType<EngineInterface['ui']['resolve']>
479
480function typed(text: string, budget: number): string {
481  return budget >= text.length ? text : text.slice(0, Math.max(0, budget))
482}
483
484type Node = { type?: string; props?: Record<string, unknown>; children?: unknown }
485
486/** The rows a drawn tree takes: a Raster its rows, a column Box the sum, a row Box the tallest. */
487export function heightOf(el: unknown, columns = 80): number {
488  const wrapped = (text: string) => text.split('\n').reduce((n, line) => n + Math.max(1, Math.ceil(line.length / Math.max(1, columns))), 0)
489  if (el === null || el === undefined || el === false || el === true) return 0
490  if (typeof el === 'string' || typeof el === 'number') return wrapped(String(el))
491  if (Array.isArray(el)) return el.reduce((n: number, c) => n + heightOf(c, columns), 0)
492  const node = el as Node
493  if (node.type === 'Raster') return Number(node.props?.rows ?? 1)
494  const kids = Array.isArray(node.children) ? node.children : node.children === undefined ? [] : [node.children]
495  if (node.type === 'Text') {
496    const text = kids.map(k => (typeof k === 'string' || typeof k === 'number' ? String(k) : '')).join('')
497    return Math.max(1, wrapped(text))
498  }
499  if (node.type === 'Box') {
500    if (node.props?.flexDirection === 'column') return kids.reduce((n: number, c) => n + heightOf(c, columns), 0)
501    return Math.max(kids.length === 0 ? 0 : 1, ...kids.map(k => heightOf(k, columns)))
502  }
503  // Anything else: assume it may take two rows, so the other mods are never pushed out of view.
504  return 2
505}
506
507/** The full boot log at a tick, fitted into `room` rows: logo, BIOS header, memory test, then each check typed out. */
508function logTree(els: Els, b: Boot, at: number, columns: number, room: number, isTerminal: boolean): RenderElement[] {
509  const { Box, Text } = els
510  const rows: RenderElement[] = []
511  let used = 0
512  const hasRaster = isTerminal && 'Raster' in els && columns >= LOGO_COLS + 2 && room >= LOGO_ROWS + 4
513  if (hasRaster) {
514    const Raster = (els as { Raster: (p: { key: string; columns: number; rows: number; cells: string }) => RenderElement }).Raster
515    rows.push(<Raster key="logo" columns={LOGO_COLS} rows={LOGO_ROWS} cells={logoCells(at)} />)
516    used += LOGO_ROWS
517  } else {
518    rows.push(<Text key="logo" color="#89dceb" bold>{'▓▒░ CLAUDE CODE ░▒▓'}</Text>)
519    used += 1
520  }
521  const sinceLogo = Math.max(0, at - LOGO_TICKS)
522  let budget = sinceLogo * CHARS_PER_TICK
523  const header = 'CLAUDE CODE BIOS · POWER-ON SELF TEST'
524  if (room - used >= 2) {
525    rows.push(<Text key="hdr" color="#94e2d5">{typed(header, budget)}</Text>)
526    used += 1
527  }
528  budget -= header.length
529  if (b.memoryMb > 0 && budget > 0 && room - used >= 2) {
530    const target = b.memoryMb * 1024
531    const shown = Math.min(target, Math.round((target * Math.min(sinceLogo, 30)) / 30))
532    const done = shown >= target
533    rows.push(
534      <Box key="mem">
535        <Text color="#cdd6f4">{`MEMORY TEST  ${String(shown).padStart(9)}K `}</Text>
536        <Text color={done ? COLOR.ok : COLOR.run}>{done ? 'OK' : '..'}</Text>
537      </Box>,
538    )
539    used += 1
540  }
541  const lineRows: RenderElement[] = []
542  // Short of room, warnings and failures come first, then the rest in check order.
543  const keep = Math.max(1, room - used)
544  const ordered = b.lines.length <= keep ? b.lines : [...b.lines.filter(l => l.status === 'fail' || l.status === 'warn'), ...b.lines.filter(l => l.status !== 'fail' && l.status !== 'warn')].slice(0, keep)
545  const shownIds = new Set(ordered.map(l => l.id))
546  for (const l of b.lines.filter(x => shownIds.has(x.id))) {
547    if (budget <= 0) break
548    const left = `${l.label.padEnd(10)} `
549    const detailRoom = Math.max(8, columns - left.length - 11)
550    const detail = l.detail.length > detailRoom ? `${l.detail.slice(0, detailRoom - 1)}…` : l.detail
551    const shownLeft = typed(left + detail, budget)
552    budget -= left.length + detail.length
553    const finished = budget >= 0
554    const spin = '⣾⣽⣻⢿⡿⣟⣯⣷'[at % 8] as string
555    const badge = l.status === 'run' ? `[ ${spin}${spin}${spin}${spin} ]` : BADGE[l.status]
556    lineRows.push(
557      <Box key={`l-${l.id}`}>
558        <Text color={COLOR[l.status]}>{finished ? badge : '[      ]'}</Text>
559        <Text color="#cdd6f4">{` ${shownLeft}`}</Text>
560        {!finished && <Text color="#a6e3a1">{at % 2 === 0 ? '█' : ' '}</Text>}
561      </Box>,
562    )
563  }
564  return [...rows, ...lineRows]
565}
566
567function summaryText(b: Boot): string {
568  const t = tally(b.lines)
569  const verdict = b.lines.length === 0 || t.fail > 0 ? 'FAIL' : t.warn > 0 ? 'WARN' : t.skip > 0 ? 'PARTIAL' : 'OK'
570  const skipped = t.skip > 0 ? ` · ${t.skip} skipped` : ''
571  return `▣ BOOT ${verdict}  ${t.ok} ok · ${t.warn} warn · ${t.fail} fail${skipped}`
572}
573
574function summaryColor(b: Boot): string {
575  const t = tally(b.lines)
576  return b.lines.length === 0 || t.fail > 0 ? COLOR.fail : t.warn > 0 ? COLOR.warn : t.skip > 0 ? COLOR.skip : COLOR.ok
577}
578
579// ---------------------------------------------------------------- hooks
580
581export const register: Register = (on, options) => {
582  const opts = options as Options
583
584  on('session.start', async ($, e, next) => {
585    const out = await next(e)
586    // Neither the command nor the boot holds up the session's start.
587    void registerBoot($)
588    if (e.isInteractive) {
589      $.clock.after(400, () => {
590        void startupBoot($, opts)
591      })
592    }
593    return out
594  })
595
596  on('command.run', { command: 'boot' }, async $ => {
597    await $.ui.open({ id: PANE, title: 'Boot sequence' })
598    void startBoot($, 'pane', opts)
599    return { text: 'Boot Sequence: replaying the boot checks.' }
600  })
601
602  on('prompt.submit', async ($, e, next) => {
603    const out = await next(e)
604    // Only a prompt that actually entered folds the boot; a refused one changes nothing.
605    if (out.drop === undefined && PERSON.includes(e.origin.kind)) {
606      await update($, hasPrompted, () => true)
607      await update($, isCollapsed, () => true)
608    }
609    return out
610  })
611
612  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
613    const b = await read($, boot)
614    if (b === null || e.props.hasSurvey || (await read($, isHidden))) return next(e)
615    const below = await next(e)
616    const { Box, Text, Button } = $.ui.resolve(e)
617    // The rows the band shows at once, less what the mods beneath it need and the skip row.
618    const window = Math.max(1, e.props.scroll?.bodyRows ?? e.props.maxRows)
619    const cols = Math.max(20, e.props.bodyColumns)
620    const room = window - heightOf(below, cols) - 1
621    const collapsed = (await read($, isCollapsed)) || b.where === 'pane' || room < 4
622    if (collapsed) {
623      if (!b.isDone && b.where === 'pane') return below
624      // The band is full with the mods beneath: add nothing rather than push them out.
625      if (room < 0) return below
626      const full = b.isDone ? summaryText(b) : `▣ BOOT  running ${b.lines.filter(l => l.status !== 'run').length}/${IDS.length}`
627      const label = full.length > cols - 10 ? `${full.slice(0, Math.max(10, cols - 11))}…` : full
628      return (
629        <Box flexDirection="column">
630          <Box>
631            <Text color={b.isDone ? summaryColor(b) : COLOR.run}>{label}</Text>
632            <Text dimColor>{'  /boot '}</Text>
633            <Button key="hide" label="x" plain onPress={() => update($, isHidden, () => true)} />
634          </Box>
635          {below}
636        </Box>
637      )
638    }
639    const at = await read($, tick)
640    const columns = Math.max(20, e.props.bodyColumns)
641    return (
642      <Box flexDirection="column">
643        {logTree($.ui.resolve(e), b, at, columns, Math.min(room, 16), e.surface === 'terminal')}
644        <Box>
645          <Button key="skip" label="skip" hotkey="s" onPress={() => update($, isCollapsed, () => true)} />
646        </Box>
647        {below}
648      </Box>
649    )
650  })
651
652  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
653    const { Box, Text } = $.ui.resolve(e)
654    const b = await read($, boot)
655    if (b === null) return <Text dimColor>Starting…</Text>
656    const at = await read($, tick)
657    const columns = Math.max(20, e.props.bodyColumns)
658    const room = Math.max(6, (e.viewport?.rows ?? 30) - 4)
659    return (
660      <Box flexDirection="column">
661        {logTree($.ui.resolve(e), b, at, columns, room, e.surface === 'terminal')}
662        {b.isDone && <Text color="#94e2d5">{`\n${summaryText(b)}  ·  READY.`}</Text>}
663      </Box>
664    )
665  })
666}
667
types/index.d.ts 24 lines
1export type Status = 'run' | 'ok' | 'warn' | 'fail' | 'skip'
2export type Line = { id: string; label: string; status: Status; detail: string }
3export type Boot = {
4  /** Bumped on every boot, so a stale run never writes into a newer one. */
5  run: number
6  lines: Line[]
7  memoryMb: number
8  isDone: boolean
9  /** Where the full log draws: the band above the prompt, or the /boot pane. */
10  where: 'band' | 'pane'
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'boot-sequence': {
16      boot: Boot | null
17      tick: number
18      isCollapsed: boolean
19      isHidden: boolean
20      hasPrompted: boolean
21    }
22  }
23}
24