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


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 ]:
| Check | What it reports | Warns when |
|---|---|---|
| CPU | OS, architecture, core count | |
| GIT | branch, changed files, ahead/behind upstream | not a repo, or behind upstream |
| TOOLCHAIN | node, pnpm, python, xcode, java versions (whatever is installed) | |
| DEVICES | booted iOS simulators and Android devices | |
| PORTS | dev servers listening (3000, 5173, 8081, 19000…) | |
| MODS | plugins that registered commands | |
| CONTEXT | context window used | ≥50% (fails at ≥85%) |
| DISK | free space on the project's disk | under 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.
The pixel logo uses a Raster, so it draws in the terminal. Other surfaces get the same log as plain text.
/plugin marketplace add ccdwyer/claude-mods
/plugin install boot-sequence@ccdwyer-mods
/reload-plugins
claude plugin validate .
claude plugin test .
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.
It runs entirely on your machine and sends nothing over the network. Full policy: PRIVACY.md.
MIT
hooks/register.tsx 667 lines1import { 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}
667types/index.d.ts 24 lines1export 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