Auto-continue after a turn dies on a network or API error: a pixel-art CONTINUE? countdown with backoff and 3 lives per hour.

█▀█ █▀▀ █▀ █▀█ ▄▀█ █ █ █ █▄ █
█▀▄ ██▄ ▄█ █▀▀ █▀█ ▀▄▀▄▀ █ ▀█ CONTINUE? 9
The problem: a long, unattended turn dies on an API or connection error, and nothing happens until you come back and type continue. The study behind this library counted ~120 API connection errors, each followed by a hand-typed "continue" or "try again".
respawn watches for a turn that ends with reason: 'error' in the main loop, shows an orange arcade CONTINUE? countdown, and submits continue for you when it reaches zero. It backs off on repeated failures and has a limited number of lives, so it cannot loop forever.
/plugin marketplace add pourya7/claude-code-mods
/plugin install respawn@claude-code-mods
reason: 'error' (an API error that used up its retries) starts a countdown. Answers, interrupts (aborted), refusals and subagent turns never do.♥♥♡). With no lives left, the band shows GAME OVER and respawn does nothing more until you send a prompt yourself. GAME OVER holds even after the hour gives lives back, and a scheduled or background prompt does not end it. While it shows, the band counts down the minutes to the next life and refills the hearts as they return.GAME OVER, only your own prompt does)[GAME OVER]/respawn off[INSERT COIN] skips the wait and spends a life. Pressing it twice quickly still sends one continue.GAME OVER is showing. It steps aside for a survey and for a subagent's transcript view.| Command | What it does |
|---|---|
/respawn | Shows the state: READY!, CONTINUE? n, GAME OVER or OFF, the lives left this hour and the next wait. |
/respawn off | Cancels any countdown and switches respawn off for the rest of the session. |
/respawn on | Switches it back on. |
The band's buttons have hotkeys. Once the band has focus (ctrl+x tab or a click), press c for INSERT COIN, x for GAME OVER, or o for OK.
userConfig)| Field | Type | Default | Meaning |
|---|---|---|---|
lives | number | 3 | How many automatic continues respawn may spend in any rolling hour. 0 means it never continues by itself, but still shows GAME OVER when a turn dies. |
prompt | string | continue | The text respawn submits when the countdown ends. Empty means continue. |
You can change these in the /config menu, or under pluginConfigs.respawn in your settings.
The band above the prompt during a countdown. The second failure in a row has a 30s wait, with 27s left:
▶ CONTINUE? ♥♥♡ 1UP RESPAWN
▄██▄██▄ ▀▀█ ▀▀█ TURN DIED ON AN API ERROR
▀█████▀ █▀▀ ▄▀ SENDING "CONTINUE" IN 27S
▀█▀ ███ █ TRY 2 · TYPE ANYTHING TO CANCEL
[ INSERT COIN ] [ GAME OVER ]
After three continues inside the same hour:
▶ GAME OVER ♡♡♡ 1UP RESPAWN
▄██▄██▄ OUT OF LIVES THIS HOUR
▀█████▀ NEXT LIFE IN 59 MIN
▀█▀ TYPE ANYTHING TO PLAY ON
[ OK ]
These captures are plain text. In the terminal, the heart is red with a pink shine. The digits are half-block pixels: orange, with a yellow top row and a brown drop shadow. They use the PICO-8 palette.
The status line under the prompt shows the same state in under 40 columns, for example RESPAWN CONTINUE? 27 ♥♥♡ or RESPAWN GAME OVER ♡♡♡. VS Code and claude -p have no band, so the status line and /respawn are what you see there.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
None. No $.http. | None. No $.process. | None. No $.fs. The state is kept only for the session, in $.state. Nothing goes to $.store. | No. respawn makes no $.model call of its own. | Yes. When a countdown ends or you press INSERT COIN, it calls $.prompt.submit with the configured prompt (continue), as your own words. This is limited by lives per hour, and /respawn off switches it off. | Only that continue prompt, which goes to your own model as an ordinary turn. |
userConfig change causes, cancels the mod's timers. respawn picks a running countdown up again when it reloads, and submits at once if it fell due while it was reloading.aborted) is never retried, even if the network was the cause.claude plugin validate respawn
claude plugin test respawn
Pure logic lives in hooks/logic.ts (backoff, lives, transitions and text) and hooks/pixels.ts (the half-block sprite renderer and the digit font). hooks/register.tsx connects them to the engine. The tests use mock.clock and mount the band on both terminal and desktop.
hooks/register.tsx 270 lines1import { atom, read, update } from 'claude-code'
2import type { Register, Timer } from 'claude-code'
3
4import type { RespawnRun } from '../types'
5import {
6 IDLE_RUN,
7 PLUGIN,
8 cancelRun,
9 continuePrompt,
10 describeRun,
11 hearts,
12 isOwnPrompt,
13 isUserPrompt,
14 livesLeft,
15 livesOption,
16 minutesToNextLife,
17 refreshGameOver,
18 spendLife,
19 startCountdown,
20 statusLine,
21 tickRun,
22} from './logic'
23import { BROKEN_HEART, HEART, PALETTE, besides, digitsGrid, pixelRows } from './pixels'
24import type { Run } from './pixels'
25
26const run = atom({ plugin: 'respawn', key: 'run' } as const, IDLE_RUN)
27
28type Dollar = Parameters<Parameters<Parameters<Register>[0]>[1]>[0]
29type Settings = { lives: number; promptText: string }
30
31// Timers cannot live in $.state, and a reload cancels them along with this
32// variable; session.start (raised on a reload too) arms them again from `run`.
33let ticker: Timer | undefined
34
35const GAME_OVER_TICK_MS = 60_000
36
37function stopTicker(): void {
38 ticker?.cancel()
39 ticker = undefined
40}
41
42async function showStatus($: Dollar, settings: Settings, current: RespawnRun): Promise<void> {
43 $.ui.status(statusLine(current, settings.lives, await $.clock.now()))
44}
45
46/** Cancels a countdown and resets the backoff; a user prompt also ends GAME OVER. */
47async function cancel($: Dollar, settings: Settings, isUser: boolean): Promise<void> {
48 const current = await read($, run)
49 const isQuiet = current.phase === 'idle' && current.attempt === 0
50 if (isQuiet || (current.phase === 'gameover' && !isUser)) return
51 stopTicker()
52 const next = await update($, run, value => (value.phase === 'gameover' && !isUser ? value : cancelRun(value)))
53 await showStatus($, settings, next)
54}
55
56/** The countdown ran out or INSERT COIN was pressed: spend a life and continue. */
57async function insertCoin($: Dollar, settings: Settings): Promise<void> {
58 const now = await $.clock.now()
59 // Check and spend in one update, so two presses close together spend one life.
60 let isMine = false as boolean
61 const next = await update($, run, value => {
62 // update() runs this again on a version miss: the last call decides.
63 isMine = value.phase === 'countdown' && !value.isOff
64 return isMine ? spendLife(value, now) : value
65 })
66 if (!isMine) return
67 stopTicker()
68 await showStatus($, settings, next)
69 try {
70 await $.prompt.submit({ text: settings.promptText, asUser: true })
71 } catch {
72 $.ui.toast('RESPAWN: could not submit the continue prompt')
73 }
74}
75
76async function tick($: Dollar, settings: Settings): Promise<void> {
77 const current = await read($, run)
78 if (current.phase !== 'countdown') return stopTicker()
79 const now = await $.clock.now()
80 if (now >= current.dueAt) return insertCoin($, settings)
81 await showStatus($, settings, await update($, run, value => tickRun(value, now)))
82}
83
84/** GAME OVER: once a minute, move the "next life" minutes and the hearts. */
85async function tickGameOver($: Dollar, settings: Settings): Promise<void> {
86 const current = await read($, run)
87 if (current.phase !== 'gameover') return stopTicker()
88 const now = await $.clock.now()
89 const next = await update($, run, value => (value.phase === 'gameover' ? refreshGameOver(value, now) : value))
90 await showStatus($, settings, next)
91 if (next.spentAt.length === 0) stopTicker()
92}
93
94/** Starts the ticker that fits the phase in `run`, or none. */
95function arm($: Dollar, settings: Settings, current: RespawnRun): void {
96 stopTicker()
97 if (current.isOff) return
98 if (current.phase === 'countdown') {
99 ticker = $.clock.every(1000, () => void tick($, settings).catch(stopTicker))
100 } else if (current.phase === 'gameover' && current.spentAt.length > 0) {
101 ticker = $.clock.every(GAME_OVER_TICK_MS, () => void tickGameOver($, settings).catch(stopTicker))
102 }
103}
104
105async function startRespawn($: Dollar, settings: Settings): Promise<void> {
106 const now = await $.clock.now()
107 const before = await read($, run)
108 const next = await update($, run, value => (value.isOff ? value : startCountdown(value, now, settings.lives)))
109 await showStatus($, settings, next)
110 // A turn dying again in GAME OVER keeps the minute ticker already running.
111 if (before.phase === 'gameover' && next.phase === 'gameover' && ticker !== undefined) return
112 arm($, settings, next)
113}
114
115async function switchOff($: Dollar): Promise<void> {
116 stopTicker()
117 await update($, run, value => ({ ...cancelRun(value), isOff: true }))
118 $.ui.status(undefined)
119}
120
121export const register: Register = (on, options) => {
122 const settings: Settings = { lives: livesOption(options.lives), promptText: continuePrompt(options.prompt) }
123 const { lives, promptText } = settings
124
125 on('session.start', async ($, e, next) => {
126 try {
127 await $.command.register({
128 name: PLUGIN,
129 description: 'Show the auto-continue state; /respawn off or /respawn on',
130 argumentHint: '[off|on]',
131 immediate: true,
132 })
133 } catch {
134 $.ui.toast('RESPAWN: /respawn could not be registered')
135 }
136
137 // A reload (a code change or a userConfig change) cancelled our timers:
138 // pick the countdown or the GAME OVER minutes up where `run` left them.
139 const current = await read($, run)
140 if (current.phase === 'countdown' && !current.isOff) {
141 const now = await $.clock.now()
142 await showStatus($, settings, await update($, run, value => tickRun(value, now)))
143 }
144 arm($, settings, current)
145
146 return next(e)
147 })
148
149 on('turn.complete', async ($, e, next) => {
150 const result = await next(e)
151 if (e.agentId !== undefined) return result
152 if (e.reason === 'error') await startRespawn($, settings)
153 if (e.reason === 'answer') await cancel($, settings, false)
154
155 return result
156 })
157
158 on('prompt.submit', async ($, e, next) => {
159 if (!isOwnPrompt(e.origin)) await cancel($, settings, isUserPrompt(e.origin))
160
161 return next(e)
162 })
163
164 on('prompt.edit', async ($, e, next) => {
165 const isChange = e.inputText !== '' || e.start !== e.end
166 if (isChange) await cancel($, settings, true)
167
168 return next(e)
169 })
170
171 on('command.run', { command: 'respawn' }, async ($, e) => {
172 const word = e.args.trim().toLowerCase()
173 if (word === 'off') await switchOff($)
174 if (word === 'on') await update($, run, value => ({ ...value, isOff: false }))
175 const now = await $.clock.now()
176 const text = describeRun(await read($, run), lives, now)
177 const isKnown = word === '' || word === 'off' || word === 'on'
178
179 return { text: isKnown ? text : `${text}\nUsage: /respawn [off|on]` }
180 })
181
182 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
183 if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
184 const current = await read($, run)
185 if (current.isOff || current.phase === 'idle') return next(e)
186
187 const { Box, Button, Text } = $.ui.resolve(e)
188 const now = await $.clock.now()
189 const left = livesLeft(current.spentAt, lives, now)
190 const isOver = current.phase === 'gameover'
191
192 const sprite = isOver ? [...BROKEN_HEART] : besides([HEART, digitsGrid(current.secondsLeft)], 2)
193 const pixels = pixelRows(sprite).map(runs => (
194 <Box key="digit-row" flexDirection="row">
195 {runs.map((cell: Run) => (
196 <Text color={cell.color} backgroundColor={cell.backgroundColor}>
197 {cell.text}
198 </Text>
199 ))}
200 </Box>
201 ))
202
203 const minutesToLife = minutesToNextLife(current.spentAt, now)
204
205 const lines = isOver
206 ? [
207 <Text color={PALETTE.w}>{left === 0 ? 'OUT OF LIVES THIS HOUR' : 'LIVES ARE BACK'}</Text>,
208 <Box key="next-life">
209 <Text color={PALETTE.l}>
210 {left === 0 ? `NEXT LIFE IN ${minutesToLife} MIN` : `${left} READY, HELD FOR YOU`}
211 </Text>
212 </Box>,
213 <Text color={PALETTE.l}>TYPE ANYTHING TO PLAY ON</Text>,
214 ]
215 : [
216 <Text color={PALETTE.w}>TURN DIED ON AN API ERROR</Text>,
217 <Box flexDirection="row">
218 <Text color={PALETTE.l}>SENDING "{promptText.toUpperCase()}" IN </Text>
219 <Box key="seconds">
220 <Text color={PALETTE.y} bold>
221 {String(current.secondsLeft)}
222 </Text>
223 </Box>
224 <Text color={PALETTE.l}>S</Text>
225 </Box>,
226 <Text color={PALETTE.l}>TRY {String(current.attempt + 1)} · TYPE ANYTHING TO CANCEL</Text>,
227 ]
228
229 return (
230 <Box flexDirection="column">
231 <Box flexDirection="row">
232 <Box key="title">
233 <Text color={isOver ? PALETTE.r : PALETTE.o} bold>
234 {isOver ? '▶ GAME OVER' : '▶ CONTINUE?'}
235 </Text>
236 </Box>
237 <Text> </Text>
238 <Box key="lives">
239 <Text color={PALETTE.r}>{hearts(left, lives)}</Text>
240 </Box>
241 <Text color={PALETTE.d}> 1UP RESPAWN</Text>
242 </Box>
243 <Box flexDirection="row">
244 <Box flexDirection="column">{pixels}</Box>
245 <Box flexDirection="column" marginLeft={2}>
246 {lines}
247 </Box>
248 </Box>
249 {isOver ? (
250 <Box flexDirection="row">
251 <Button key="dismiss" label="OK" hotkey="o" role="dismiss" onPress={() => cancel($, settings, true)} />
252 </Box>
253 ) : (
254 <Box flexDirection="row">
255 <Button
256 key="insert-coin"
257 label="INSERT COIN"
258 hotkey="c"
259 variant="primary"
260 onPress={() => insertCoin($, settings)}
261 />
262 <Text> </Text>
263 <Button key="game-over" label="GAME OVER" hotkey="x" onPress={() => cancel($, settings, true)} />
264 </Box>
265 )}
266 </Box>
267 )
268 })
269}
270hooks/logic.ts 110 lines1import type { PromptOrigin } from 'claude-code'
2
3import type { RespawnRun } from '../types'
4
5export const PLUGIN = 'respawn'
6export const HOUR_MS = 60 * 60 * 1000
7export const BACKOFF_SECONDS: readonly number[] = [10, 30, 60, 120, 300]
8export const DEFAULT_PROMPT = 'continue'
9
10export const IDLE_RUN: RespawnRun = {
11 phase: 'idle',
12 attempt: 0,
13 dueAt: 0,
14 secondsLeft: 0,
15 spentAt: [],
16 minutesToLife: 0,
17 isOff: false,
18}
19
20/** Seconds to wait before the continue of a given step (0 = first). */
21export const backoffSeconds = (attempt: number): number =>
22 BACKOFF_SECONDS[Math.min(Math.max(0, attempt), BACKOFF_SECONDS.length - 1)] ?? 300
23
24/** The spend times still inside the rolling hour. */
25export const recentSpends = (spentAt: readonly number[], now: number): number[] =>
26 spentAt.filter(at => now - at < HOUR_MS)
27
28export const livesLeft = (spentAt: readonly number[], lives: number, now: number): number =>
29 Math.max(0, lives - recentSpends(spentAt, now).length)
30
31export const hearts = (left: number, total: number): string =>
32 '♥'.repeat(Math.max(0, left)) + '♡'.repeat(Math.max(0, total - left))
33
34/** Whole minutes until the oldest life spent this hour comes back; 0 when none is spent. */
35export const minutesToNextLife = (spentAt: readonly number[], now: number): number => {
36 const oldest = recentSpends(spentAt, now)[0]
37
38 return oldest === undefined ? 0 : Math.max(1, Math.ceil((oldest + HOUR_MS - now) / 60_000))
39}
40
41/** GAME OVER holds; only the lives coming back over the hour move. */
42export const refreshGameOver = (run: RespawnRun, now: number): RespawnRun => ({
43 ...run,
44 spentAt: recentSpends(run.spentAt, now),
45 minutesToLife: minutesToNextLife(run.spentAt, now),
46})
47
48/**
49 * A turn died on an error: count down at this step, or GAME OVER when out of
50 * lives. GAME OVER itself holds: only a user prompt (cancelRun) leaves it.
51 */
52export const startCountdown = (run: RespawnRun, now: number, lives: number): RespawnRun => {
53 if (run.phase === 'gameover') return refreshGameOver(run, now)
54 const spentAt = recentSpends(run.spentAt, now)
55 if (livesLeft(spentAt, lives, now) === 0) return refreshGameOver({ ...run, spentAt, phase: 'gameover', secondsLeft: 0 }, now)
56 const seconds = backoffSeconds(run.attempt)
57
58 return { ...run, spentAt, phase: 'countdown', dueAt: now + seconds * 1000, secondsLeft: seconds }
59}
60
61/** A continue was submitted: one life gone, the next error waits longer. */
62export const spendLife = (run: RespawnRun, now: number): RespawnRun => ({
63 ...run,
64 phase: 'idle',
65 attempt: run.attempt + 1,
66 secondsLeft: 0,
67 spentAt: [...recentSpends(run.spentAt, now), now],
68})
69
70/** Success, the user, or `/respawn off`: stop counting and start the backoff over. */
71export const cancelRun = (run: RespawnRun): RespawnRun => ({ ...run, phase: 'idle', attempt: 0, secondsLeft: 0 })
72
73export const tickRun = (run: RespawnRun, now: number): RespawnRun => ({
74 ...run,
75 secondsLeft: Math.max(0, Math.ceil((run.dueAt - now) / 1000)),
76})
77
78export const isOwnPrompt = (origin: PromptOrigin): boolean => origin.kind === 'plugin' && origin.name === PLUGIN
79
80/** The person: typed at the composer, sent from a phone, or the SDK host's own turn. */
81export const isUserPrompt = (origin: PromptOrigin): boolean =>
82 origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk'
83
84export const continuePrompt = (configured: unknown): string =>
85 typeof configured === 'string' && configured.trim() !== '' ? configured : DEFAULT_PROMPT
86
87export const livesOption = (configured: unknown): number =>
88 typeof configured === 'number' && Number.isFinite(configured) ? Math.max(0, Math.floor(configured)) : 3
89
90/** The status line under the prompt (under ~40 columns), or undefined when idle. */
91export const statusLine = (run: RespawnRun, lives: number, now: number): string | undefined => {
92 const life = hearts(livesLeft(run.spentAt, lives, now), lives)
93 if (run.phase === 'countdown') return `RESPAWN CONTINUE? ${run.secondsLeft} ${life}`
94 if (run.phase === 'gameover') return `RESPAWN GAME OVER ${life}`
95
96 return undefined
97}
98
99/** What `/respawn` answers. */
100export const describeRun = (run: RespawnRun, lives: number, now: number): string => {
101 const left = livesLeft(run.spentAt, lives, now)
102 const life = `${hearts(left, lives)} ${left}/${lives} lives this hour`
103 const next = `next wait ${backoffSeconds(run.attempt)}s`
104 if (run.isOff) return `RESPAWN OFF. ${life}. /respawn on to arm it again.`
105 if (run.phase === 'countdown') return `CONTINUE? ${run.secondsLeft} ${life}. /respawn off cancels.`
106 if (run.phase === 'gameover') return `GAME OVER ${life}. Respawn waits for your next prompt.`
107
108 return `READY! ${life}, ${next}. Watching for turns that die on an API error.`
109}
110hooks/pixels.ts 120 lines1/**
2 * Half-block pixel art: a sprite is a grid of palette keys ('.' is
3 * transparent); two pixel rows fold into one text row, the top pixel as the
4 * `▀`'s color and the bottom one as its background.
5 */
6
7/** PICO-8, keyed by one letter. */
8export const PALETTE = {
9 k: '#000000', // black
10 n: '#1D2B53', // navy
11 m: '#7E2553', // plum
12 g: '#008751', // green
13 b: '#AB5236', // brown
14 d: '#5F574F', // dark grey
15 l: '#C2C3C7', // light grey
16 w: '#FFF1E8', // white
17 r: '#FF004D', // red
18 o: '#FFA300', // orange: respawn's signature
19 y: '#FFEC27', // yellow
20 e: '#00E436', // lime
21 u: '#29ADFF', // blue
22 v: '#83769C', // lavender
23 i: '#FF77A8', // pink
24 c: '#FFCCAA', // peach
25} as const
26
27export type Run = { text: string; color?: string; backgroundColor?: string }
28
29const colorOf = (key: string | undefined): string | undefined =>
30 key === undefined || key === '.' ? undefined : (PALETTE as Record<string, string>)[key]
31
32const cellOf = (top: string | undefined, bottom: string | undefined): Run => {
33 if (top && bottom) return { text: '▀', color: top, backgroundColor: bottom }
34 if (top) return { text: '▀', color: top }
35 if (bottom) return { text: '▄', color: bottom }
36
37 return { text: ' ' }
38}
39
40const sameStyle = (a: Run, b: Run): boolean => a.color === b.color && a.backgroundColor === b.backgroundColor
41
42/** Folds a grid into text rows of styled runs, merging neighbours of one style. */
43export const pixelRows = (grid: readonly string[]): Run[][] => {
44 const rows: Run[][] = []
45 const width = Math.max(0, ...grid.map(line => line.length))
46 for (let y = 0; y < grid.length; y += 2) {
47 const runs: Run[] = []
48 for (let x = 0; x < width; x += 1) {
49 const cell = cellOf(colorOf(grid[y]?.[x]), colorOf(grid[y + 1]?.[x]))
50 const last = runs[runs.length - 1]
51 if (last && last.text[0] === cell.text && sameStyle(last, cell)) last.text += cell.text
52 else if (last && last.text[0] === ' ' && cell.text === ' ') last.text += ' '
53 else runs.push({ ...cell })
54 }
55 rows.push(runs)
56 }
57
58 return rows
59}
60
61/** Lines sprites up left to right, `gap` transparent columns apart, tops aligned. */
62export const besides = (sprites: readonly (readonly string[])[], gap = 1): string[] => {
63 const height = Math.max(0, ...sprites.map(sprite => sprite.length))
64 const widths = sprites.map(sprite => Math.max(0, ...sprite.map(line => line.length)))
65 const lines: string[] = []
66 for (let y = 0; y < height; y += 1) {
67 const parts = sprites.map((sprite, i) => (sprite[y] ?? '').padEnd(widths[i] ?? 0, '.'))
68 lines.push(parts.join('.'.repeat(gap)))
69 }
70
71 return lines
72}
73
74/** 3x5 arcade digits; `#` is lit. A sixth row is the drop shadow. */
75const FONT: Record<string, readonly string[]> = {
76 '0': ['###', '#.#', '#.#', '#.#', '###'],
77 '1': ['.#.', '##.', '.#.', '.#.', '###'],
78 '2': ['###', '..#', '###', '#..', '###'],
79 '3': ['###', '..#', '.##', '..#', '###'],
80 '4': ['#.#', '#.#', '###', '..#', '..#'],
81 '5': ['###', '#..', '###', '..#', '###'],
82 '6': ['###', '#..', '###', '#.#', '###'],
83 '7': ['###', '..#', '..#', '.#.', '.#.'],
84 '8': ['###', '#.#', '###', '#.#', '###'],
85 '9': ['###', '#.#', '###', '..#', '###'],
86}
87
88/** One digit: yellow top row, orange body, brown shadow under the base. */
89const digitGrid = (digit: string, lit: string, shadow: string): string[] => {
90 const glyph = FONT[digit] ?? FONT['0'] ?? []
91 const rows = glyph.map((line, y) => line.replace(/#/g, y === 0 ? 'y' : lit))
92 const base = glyph[glyph.length - 1] ?? '...'
93
94 return [...rows, base.replace(/#/g, shadow)]
95}
96
97/** A number in big pixel digits, 6 pixels (3 text rows) tall. */
98export const digitsGrid = (value: number, lit = 'o', shadow = 'b'): string[] =>
99 besides(String(Math.max(0, Math.floor(value))).split('').map(digit => digitGrid(digit, lit, shadow)), 1)
100
101/** The 1UP heart, 7x6. */
102export const HEART: readonly string[] = [
103 '.rr.rr.',
104 'riirrrr',
105 'rirrrrr',
106 '.rrrrr.',
107 '..rrr..',
108 '...r...',
109]
110
111/** The heart once the lives ran out: grey and cracked. */
112export const BROKEN_HEART: readonly string[] = [
113 '.dd.dd.',
114 'dddkddd',
115 'ddkdddd',
116 '.ddkdd.',
117 '..dkd..',
118 '...d...',
119]
120types/index.d.ts 25 lines1export type RespawnPhase = 'idle' | 'countdown' | 'gameover'
2
3export type RespawnRun = {
4 /** idle: nothing to say; countdown: CONTINUE? is ticking; gameover: out of lives. */
5 phase: RespawnPhase
6 /** Continues spent since the last success or cancel: the backoff step. */
7 attempt: number
8 /** When the countdown ends, in ms since the epoch. */
9 dueAt: number
10 /** Whole seconds left on the countdown, as drawn. */
11 secondsLeft: number
12 /** When each life was spent, in ms since the epoch (last hour kept). */
13 spentAt: number[]
14 /** GAME OVER: whole minutes until the oldest spent life comes back (0 = none spent). */
15 minutesToLife: number
16 /** `/respawn off` switched it off for this session. */
17 isOff: boolean
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 respawn: { run: RespawnRun }
23 }
24}
25