AFC Richmond persona: match HUD above the prompt, Roy Kent sounds, Nelson Road themes, locker-room spinner and status lines, a /timer break screen

Claude Code dressed as AFC Richmond from Ted Lasso: a match HUD above the prompt, Roy Kent's "let's go again" when an answer is ready, two Nelson Road themes, locker-room spinner lines and a status line where main is a home game.
All the texts (HUD, spinner verbs, tips, status line, subagent panel) are in Ukrainian.
jq for /richmond, the status lines and the sounds (sudo apt install jq).paplay (PulseAudio or PipeWire, default on Ubuntu). Without it the plugin stays silent./timer) needs systemd's user manager (systemd-run --user) and, for its break screen, an X11 session (Wayland works through XWayland) and GTK 3 with PyGObject under /usr/bin/python3: sudo apt install python3-gi python3-gi-cairo if they are missing./richmond skips it. The colours are in persona/terminal/*.dconf, so you can copy them into your own terminal.claude plugin marketplace add kaisa-kucherenko/claude-code-flow
claude plugin install richmond@claude-code-flow
Then, inside Claude Code:
/richmond # Nelson Road: navy and gold
/richmond diamond-dogs # Diamond Dogs: graphite and blue
Restart Claude Code (/exit, then claude -c) and you're on the pitch.
| Part | Comes with | What it shows | |
|---|---|---|---|
| HUD above the prompt | the plugin | The session as a match: context % is the match clock (1st half → 2nd half → extra time at 30 %, the point to hand the session off → penalties from 40 %); /config richmond.toxicityThreshold=40 moves extra time to your own, and penalties follow 10 points later. Five balls for the 5-hour limit, the BELIEVE sign for the 7-day limit, one letter per day. | |
| Sounds | the plugin | Roy's "All right, let's go again!" when an answer is ready, Roy swearing the moment Claude asks for a permission or a question. Each moment takes any clip of the set; see below. | |
| Themes | the plugin | Nelson Road and Diamond Dogs, listed in /theme. | |
| Spinner verbs and tips | /richmond | 60 verbs and 77 tips in the characters' voices. A long turn moves to the next verb every 30 s; /config richmond.spinnerSeconds=60 makes it 60. | |
| Status line | /richmond | `⚑ project ВДОМА · main [model \ | effort]`. A working branch is an away game, and its score is commits ahead : behind. |
| Subagent panel | /richmond | Agents as the squad and staff, three lines each: Explore is the scout, Plan is Beard at the whiteboard. | |
| Terminal profile | /richmond, GNOME Terminal | Background, palette and cursor for new tabs; your font stays. | |
| Break timer | the plugin | /timer 45m counts down in the band and rings with a break screen; see below. |
Three moments have a sound, and each takes any clip from sounds/:
| Moment | Setting | Default |
|---|---|---|
| An answer is ready, nothing left running in the background | turnDoneSound | roy-lets-go-again |
| Claude asks for a permission or a question | needsYouSound | roy-fuck-no |
| The break timer rings | timerDoneSound | ted-enough-for-today |
Pick one in /config (it lists the clips), type /config richmond.turnDoneSound=keeley-well-done, or just tell Claude which clip you want where. CLAUDE_SOUND=off mutes all three.
The clips:
| Clip | What you hear |
|---|---|
roy-lets-go-again | Roy: “All right, let's go again” |
roy-fuck-no | Roy: “Fuck no!” |
ted-enough-for-today | Ted: “I think that's enough for today”, then Roy whistles the team in |
roy-okay-were-done | Roy: “Okay, we're done” |
roy-whistle | Roy's whistle |
roy-kent-chant | The crowd: “Roy Kent! He's here, he's there…” |
keeley-well-done | Keeley: “Well done” |
dani-football-is-life | Dani before his penalty: “Football is life” |
dani-football-is-death | Dani: “Football is death!” |
na-na-dani-rojas | “Na-na-na…”, then “Dani Rojas” as he rejoins the team |
jamie-tartt-chant | The pub sings “Jamie Tartt, ta-da-da-da-da” |
bonfire-richmond-till-we-die | The team at the bonfire: “Richmond till we die” |
so-long-farewell-v1 | The team's farewell song to Ted, the opening |
so-long-farewell-v2 | The farewell song, “the sun has gone to bed” |
/timer 45m (or 45, 1h) reminds you to take a break once that time is up. /timer alone says how long is left, /timer off cancels it, and a new time replaces the running one. One timer serves every open Claude Code session, the desktop app's included; the band counts it down as «◷ Тайм-аут через 32:10».
When it is up a break screen opens over your work, Ted calls practice while Roy whistles the team in (or the clip you picked), and the band shows «◷ Тайм-аут» until your next prompt. The screen carries «ТАЙМ-АУТ», a line from persona/breaks.json and one of the GIFs in persona/breaks/, in the Nelson Road navy and gold (screen in the same file). Its «Назад на поле» button unlocks after 10 seconds, so an Enter typed mid-sentence cannot dismiss it unseen; Esc works from then on too.
It opens as an 800×520 window in the middle of the main monitor; /config richmond.breakScreen=fullscreen covers every monitor instead. Whichever persona is on duty at that moment speaks, and a persona without a break screen, or a screen that fails to open, rings with a GNOME banner. With no persona on, the banner says “Time for a break” and the desktop's alarm plays; the countdown in the band shows only while a persona is on duty. CLAUDE_SOUND=off mutes the clip.
The timer belongs to Claude Code, not to your desktop: when the last session exits it is cancelled, and if none is left when it is due it stays silent. A new /timer or the last session exiting also closes a break screen still open. systemd counts the time (a transient unit, claude-persona-timer), so it rings once however many sessions are open.
The marketplace also has witcher. Install both and switch with one command: /witcher puts the Witcher on duty, /richmond brings Richmond back. The HUD, sounds, status line and spinner verbs follow within seconds, in every open session. The theme takes one pick in /theme (a plugin can't switch a custom theme in a running session), tips change from the next session and the terminal colours from the next tab. Until you pick one, both play their sounds and one HUD covers the other.
/richmond changes, and how to undo itThe plugin can't set your theme, spinner verbs, tips or status line itself: those are keys in your ~/.claude/settings.json. So /richmond runs scripts/wear.sh, which writes them:
theme, spinnerVerbs, spinnerTipsOverride, statusLine, subagentStatusLine;enabledPlugins, only if richmond is switched off there: it is switched back on;~/.claude/personas/active, which names the persona on duty: with several persona plugins installed, only that one shows its HUD and plays sounds;The first run saves the previous values of the five keys in the first line to ~/.claude/personas/backup.json. /richmond off puts them back and silences the HUD and sounds of every persona plugin; they stay installed, and /richmond puts the persona back on. Run it before claude plugin uninstall, or the status lines keep running from their copies. Everything else in your settings stays as it is, including whatever you changed while the persona was on. The status line scripts are copied to ~/.claude/personas/ because the plugin's own folder changes path with every update.
claude plugin validate plugins/richmond
claude plugin test plugins/richmond # the HUD and the /richmond command
bash plugins/richmond/tests/wear.test.sh # wear.sh against a throwaway config
bash plugins/richmond/tests/sound.test.sh # which persona plays, and which clip
bash plugins/richmond/tests/timer.test.sh # timer.sh with systemd, the banner and the break screen stubbed
An unofficial, non-commercial fan project. Ted Lasso, AFC Richmond and their characters belong to their rights holders (Apple TV+, Warner Bros. Television). The clips are short excerpts: lines of a second or two, and chants and songs of up to 18 seconds. The break-screen GIFs in persona/breaks/ are short loops of scenes from the series, taken from Tenor. If you hold the rights and want them out, open an issue.
The break screen is set in Fira Sans and Fira Sans Extra Condensed by the Mozilla Foundation and Telefonica S.A., under the SIL Open Font License 1.1 (fonts/OFL.txt); the three files are the unmodified static fonts from Google Fonts.
hooks/register.tsx 381 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Usage } from '../types'
5import { applyTimerFiles, formatLeft, isCounting, isExit, takeSeen, timerArgs, timerViewAt, TIMER_COMMAND } from './timer'
6
7// Nelson Road's HUD: the session is one match whose clock is the context fill,
8// the 5-hour window is five balls (one per hour) and the 7-day window is the
9// BELIEVE sign over the locker-room door (one letter per day). Both resources
10// read as what is left, so a full row is good news; the match clock reads as
11// time spent, so a late minute is the warning.
12const usage = atom({ plugin: 'richmond', key: 'usage' } as const, {
13 ctx: null,
14 fiveHour: null,
15 fiveHourReset: null,
16 sevenDay: null,
17 sevenDayReset: null,
18} as Usage)
19
20// The match follows a session that is handed off to a fresh one between 30 and
21// 40 % of context: regular time ends at 30, extra time at 40, then penalties.
22// Where to hand off is a habit, not a law, so `toxicityThreshold` moves full time.
23const DEFAULT_FULL_TIME = 30
24// Extra time lasts this long past full time; the shoot-out starts after it.
25const EXTRA_TIME_LENGTH = 10
26
27// The sign's canonical look: yellow capitals on a royal-blue board.
28const SIGN_BLUE = '#2F4BB0'
29const SIGN_YELLOW = '#FFD93D'
30const SIGN_FADED = '#7F8FD6'
31const SIGN_TORN = '#8a2b2b'
32
33type Phase = { label: string; kicks?: string; tone: 'text' | 'warning' | 'error' }
34
35// Context percent → match phase. One number on the band is enough: the percent
36// is what the handoff decision is made on, the phase says how late it is.
37export function phase(ctx: number, fullTime = DEFAULT_FULL_TIME): Phase {
38 const extraTime = fullTime + EXTRA_TIME_LENGTH
39 if (ctx <= 0) return { label: 'Стартовий свисток', tone: 'text' }
40 // Two equal halves, so half time moves with full time
41 if (ctx < fullTime / 2) return { label: '1-й тайм', tone: 'text' }
42 if (ctx < fullTime) return { label: '2-й тайм', tone: 'text' }
43 if (ctx < extraTime) return { label: 'Додатковий час', tone: 'warning' }
44 // One kick every two percent; after the fifth the shoot-out just goes on,
45 // kick by kick, the way a real one does.
46 const kicks = Math.floor((ctx - extraTime) / 2)
47 if (kicks <= 5) return { label: 'Серія пенальті', kicks: '●'.repeat(kicks) + '◯'.repeat(5 - kicks), tone: 'error' }
48 return { label: 'Серія пенальті', kicks: `●●●●● +${kicks - 5}`, tone: 'error' }
49}
50
51// Whole units left out of `total`, so the last ball / letter only goes once
52// the window is actually spent.
53export function unitsLeft(percentUsed: number, total: number): number {
54 const left = Math.min(100, Math.max(0, 100 - percentUsed))
55 return Math.ceil((left / 100) * total)
56}
57
58const DAYS = ['нд', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб']
59
60function hhmm(iso: string | null): string {
61 if (!iso) return ''
62 const d = new Date(iso)
63 if (Number.isNaN(d.getTime())) return ''
64 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
65}
66
67function dayHhmm(iso: string | null): string {
68 if (!iso) return ''
69 const d = new Date(iso)
70 if (Number.isNaN(d.getTime())) return ''
71 return `${DAYS[d.getDay()]} ${hhmm(iso)}`
72}
73
74type Dollar = EngineInterface
75
76async function refreshUsage($: Dollar) {
77 const u = await $.session.usage()
78 const limit = (kind: string) => u.rateLimits.find(r => r.kind === kind)
79 const next: Usage = {
80 ctx: u.context.percent ?? (u.context.tokens ? Math.round((u.context.tokens / u.context.window) * 100) : null),
81 fiveHour: limit('five_hour')?.percentUsed ?? null,
82 fiveHourReset: limit('five_hour')?.resetsAt ?? null,
83 sevenDay: limit('seven_day')?.percentUsed ?? null,
84 sevenDayReset: limit('seven_day')?.resetsAt ?? null,
85 }
86 await update($, usage, () => next)
87}
88
89// Several persona plugins can sit side by side; personas/active names the one
90// on duty and the others keep quiet. Undefined when the marker can't be read:
91// at session start that means nobody has chosen yet, so a lone plugin works
92// straight after install; mid-session it is a switch in progress.
93async function isOnDuty($: Dollar): Promise<boolean | undefined> {
94 try {
95 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
96 const active = String(await $.fs.read(`${dir}/personas/active`)).trim()
97 return active === 'richmond'
98 } catch {
99 return undefined
100 }
101}
102
103// Unknown until first asked: a default either way would flash the wrong band.
104let onDuty: boolean | undefined
105let stopWatching: (() => void) | undefined
106
107// The switch can come from another plugin's command or another session, and
108// neither can reach this module, so it polls the marker and redraws on change.
109const DUTY_POLL_MS = 3000
110
111// The break timer's state files, shared by every session and both personas
112async function timerDir($: Dollar): Promise<string | null> {
113 try {
114 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
115 return `${dir}/persona-timer`
116 } catch {
117 return null
118 }
119}
120
121async function readOrNull($: Dollar, path: string): Promise<string | null> {
122 try {
123 return String(await $.fs.read(path))
124 } catch {
125 return null
126 }
127}
128
129async function refreshTimer($: Dollar): Promise<boolean> {
130 const dir = await timerDir($)
131 if (!dir) return false
132 return applyTimerFiles(
133 await readOrNull($, `${dir}/timer.json`),
134 await readOrNull($, `${dir}/rang.json`),
135 await readOrNull($, `${dir}/seen`),
136 )
137}
138
139async function runTimer($: Dollar, args: string): Promise<string> {
140 const r = await $.process
141 .run(['bash', `${$.plugin.root}/scripts/timer.sh`, ...timerArgs(args)])
142 .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
143 return (r.exitCode === 0 ? r.stdout : r.stderr).trim()
144}
145
146async function recheckDuty($: Dollar) {
147 const timerChanged = await refreshTimer($)
148 const now = await isOnDuty($)
149 if (now !== undefined && now !== onDuty) {
150 onDuty = now
151 $.ui.invalidate('ui.render')
152 } else if (timerChanged && onDuty) {
153 $.ui.invalidate('ui.render')
154 }
155}
156
157// The engine samples the spinner word from the verbs it read at session start,
158// so a persona put on mid-session would keep the previous one's words; on duty,
159// this plugin swaps in its own.
160let verbs: readonly string[] = []
161
162async function loadVerbs($: Dollar) {
163 try {
164 verbs = JSON.parse(String(await $.fs.read(`${$.plugin.root}/persona/verbs.json`))).verbs ?? []
165 } catch {
166 verbs = []
167 }
168}
169
170// The engine samples its word once per turn, so the verb starts from it (or
171// from a hash of a word that isn't this persona's) and moves `step` verbs on,
172// one step per `spinnerSeconds`: a long turn doesn't sit on one line, and
173// redraws within a step keep the same verb.
174export function ownVerb(sampled: string, own: readonly string[], step = 0): string {
175 if (own.length === 0) return sampled
176 let start = own.indexOf(sampled)
177 if (start < 0) {
178 let h = 0
179 for (const ch of sampled) h = (h * 31 + ch.charCodeAt(0)) >>> 0
180 start = h % own.length
181 }
182 return own[(start + step) % own.length]!
183}
184
185// When each sampled word was first drawn: the main spinner and each agent's
186// carry their own word, so each counts its steps from its own start.
187const firstSeen = new Map<string, number>()
188
189async function stepsSince($: Dollar, word: string, stepMs: number): Promise<number> {
190 const now = await $.clock.now()
191 if (firstSeen.size > 64) firstSeen.clear()
192 const start = firstSeen.get(word) ?? now
193 firstSeen.set(word, start)
194 return Math.floor((now - start) / stepMs)
195}
196
197// Redraws often enough that a step lands within a few seconds of falling due
198const SPINNER_TICK_MS = 5000
199// The break countdown shows seconds
200const TIMER_TICK_MS = 1000
201
202// Wearing the persona rewrites the person's own settings, so it is a command
203// they run, never a side effect of installing the plugin. wear.sh owns the
204// list of themes and says what changed and where it shows up.
205async function wear($: Dollar, args: string): Promise<string> {
206 const arg = args.trim()
207 const argv = ['bash', `${$.plugin.root}/scripts/wear.sh`, ...(arg === 'off' ? ['off'] : ['on', ...(arg ? [arg] : [])])]
208 const r = await $.process.run(argv).catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
209 if (r.exitCode !== 0) return `Failed: ${r.stderr.trim()}`
210 return r.stdout.trim()
211}
212
213export const register: Register = (on, options) => {
214 const stepMs = Math.max(10, Number(options.spinnerSeconds) || 30) * 1000
215 // Same bounds as toxicityThreshold in plugin.json; the clamp covers a value the host saved unchecked
216 const fullTime = Math.min(90, Math.max(5, Number(options.toxicityThreshold) || DEFAULT_FULL_TIME))
217
218 on('session.start', async ($, e, next) => {
219 await $.command.register({
220 name: 'richmond',
221 description: 'Put on the AFC Richmond persona: theme, spinner, status line, terminal profile',
222 argumentHint: '[nelson-road | diamond-dogs | off]',
223 })
224 // Both persona plugins carry /timer and the first to start owns it; the
225 // other's refusal must not cost it the rest of its start
226 await $.command.register(TIMER_COMMAND).catch(() => undefined)
227 // The persona on duty when the timer rings may not be the plugin that set
228 // it, so each plugin says where its phrases and clip live
229 const dir = await timerDir($)
230 if (dir) await $.fs.write(`${dir}/roots/richmond`, $.plugin.root).catch(() => undefined)
231 await refreshTimer($)
232 onDuty = (await isOnDuty($)) ?? true
233 // Each session start re-arms the poll with its own `$`
234 stopWatching?.()
235 const duty = $.clock.every(DUTY_POLL_MS, () => void recheckDuty($))
236 const tick = $.clock.every(SPINNER_TICK_MS, () => {
237 if (onDuty) $.ui.invalidate('ui.render')
238 })
239 const countdown = $.clock.every(TIMER_TICK_MS, async () => {
240 if (onDuty && isCounting(await $.clock.now())) $.ui.invalidate('ui.render')
241 })
242 stopWatching = () => {
243 duty.cancel()
244 tick.cancel()
245 countdown.cancel()
246 }
247 await loadVerbs($)
248 await refreshUsage($)
249 return next(e)
250 })
251
252 on('command.run', { command: 'richmond' }, async ($, e) => {
253 const text = await wear($, e.args)
254 await recheckDuty($)
255 return { text }
256 })
257
258 on('command.run', { command: 'timer' }, async ($, e) => {
259 const text = await runTimer($, e.args)
260 await refreshTimer($)
261 $.ui.invalidate('ui.render')
262 return { text }
263 })
264
265 on('prompt.submit', async ($, e, next) => {
266 const id = takeSeen()
267 if (id) {
268 const dir = await timerDir($)
269 if (dir) await $.fs.write(`${dir}/seen`, id).catch(() => undefined)
270 if (onDuty) $.ui.invalidate('ui.render')
271 }
272 return next(e)
273 })
274
275 on('session.end', async ($, e, next) => {
276 if (isExit(e.reason)) {
277 // Every exit hook shares one short bound, so reap takes what is left of it
278 const timeoutMs = Math.max(200, Math.floor(next.budget.remainingMs))
279 await $.process.run(['bash', `${$.plugin.root}/scripts/timer.sh`, 'reap'], { timeoutMs }).catch(() => undefined)
280 }
281 return next(e)
282 })
283
284 on('session.measure', async ($, e, next) => {
285 await refreshUsage($)
286 return next(e)
287 })
288
289 on('turn.complete', async ($, e, next) => {
290 await refreshUsage($)
291 return next(e)
292 })
293
294 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
295 // A message (compacting, a hook running) says what is happening, and on the
296 // desktop the word names the step being done; both stay as they are
297 if (!onDuty || e.surface !== 'terminal' || e.props.message !== null) return next(e)
298 const step = await stepsSince($, e.props.word, stepMs)
299 return next({ ...e, props: { ...e.props, word: ownVerb(e.props.word, verbs, step) } })
300 })
301
302 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
303 onDuty ??= (await isOnDuty($)) ?? true
304 if (e.props.hasSurvey || !onDuty) return next(e)
305
306 const u = await read($, usage)
307 const { Box, Text } = $.ui.resolve(e)
308
309 const wide = e.props.bodyColumns >= 110
310 const label = (long: string, short: string) => (wide ? long : short)
311 const resourceColor = (used: number) => (used >= 90 ? 'error' : used >= 70 ? 'warning' : 'success')
312 const rest = timerViewAt(await $.clock.now())
313
314 const ctx = u.ctx ?? 0
315 const match = phase(ctx, fullTime)
316
317 const balls = u.fiveHour !== null ? unitsLeft(u.fiveHour, 5) : 0
318 const letters = u.sevenDay !== null ? unitsLeft(u.sevenDay, 7) : 7
319 const torn = u.sevenDay !== null && letters === 0
320 const sign = 'BELIEVE'.split('')
321
322 return (
323 <Box flexDirection="column" marginTop={1}>
324 <Box flexDirection="row">
325 <Text>
326 <Text color={match.tone}>⏱ </Text>
327 <Text color={match.tone} bold={match.tone === 'error'}>{match.label}</Text>
328 {match.kicks ? <Text color={match.tone}> {match.kicks}</Text> : null}
329 <Text dimColor> ctx </Text>
330 <Text color={match.tone}>{ctx}%</Text>
331 <Text dimColor> ┃ </Text>
332 </Text>
333 {u.fiveHour !== null ? (
334 <Text>
335 <Text color={resourceColor(u.fiveHour)}>{'⚽'.repeat(balls)}</Text>
336 <Text dimColor>{'◯ '.repeat(5 - balls)}</Text>
337 <Text dimColor> {label('5h ', '')}</Text>
338 <Text color={resourceColor(u.fiveHour)}>{u.fiveHour}%</Text>
339 <Text dimColor>{u.fiveHourReset ? ` ↻ ${hhmm(u.fiveHourReset)}` : ''}</Text>
340 <Text dimColor> ┃ </Text>
341 </Text>
342 ) : null}
343 {u.sevenDay !== null ? (
344 <Text>
345 {torn ? <Text color="error">✂ </Text> : null}
346 <Text backgroundColor={torn ? SIGN_TORN : SIGN_BLUE}> </Text>
347 {sign.map((ch, i) => (
348 <Text
349 key={String(i)}
350 backgroundColor={torn ? SIGN_TORN : SIGN_BLUE}
351 color={i < letters ? SIGN_YELLOW : SIGN_FADED}
352 bold={i < letters}
353 >
354 {ch}
355 </Text>
356 ))}
357 <Text backgroundColor={torn ? SIGN_TORN : SIGN_BLUE}> </Text>
358 <Text dimColor> {label('7d ', '')}</Text>
359 <Text color={resourceColor(u.sevenDay)}>{u.sevenDay}%</Text>
360 <Text dimColor>{u.sevenDayReset ? ` ↻ ${dayHhmm(u.sevenDayReset)}` : ''}</Text>
361 </Text>
362 ) : null}
363 {rest.kind === 'counting' ? (
364 <Text>
365 <Text dimColor> ┃ </Text>
366 <Text color={SIGN_YELLOW}>◷ </Text>
367 <Text dimColor>{label('Тайм-аут через ', '')}</Text>
368 <Text color={SIGN_YELLOW}>{formatLeft(rest.leftMs)}</Text>
369 </Text>
370 ) : rest.kind === 'rang' ? (
371 <Text>
372 <Text dimColor> ┃ </Text>
373 <Text color={SIGN_YELLOW} bold>◷ Тайм-аут</Text>
374 </Text>
375 ) : null}
376 </Box>
377 </Box>
378 )
379 })
380}
381hooks/timer.ts 82 lines1// The /timer break reminder as the HUD sees it. scripts/timer.sh owns the timer
2// itself (systemd counts the time and rings), so every session, whichever
3// plugin set it, reads the same files under persona-timer/. The engine does not
4// let `$` cross an import, so this file holds the state and the rules and each
5// register.tsx does the reading and writing.
6
7export type TimerView = { kind: 'none' } | { kind: 'counting'; leftMs: number } | { kind: 'rang' }
8
9let timer: { id: string; endsAt: number } | null = null
10let rang: { id: string } | null = null
11let seen: string | null = null
12
13function parse(text: string | null): Record<string, unknown> | null {
14 if (!text) return null
15 try {
16 return JSON.parse(text) as Record<string, unknown>
17 } catch {
18 return null
19 }
20}
21
22// Takes what the three state files hold now (null when missing) and says
23// whether what the band shows has changed
24export function applyTimerFiles(timerText: string | null, rangText: string | null, seenText: string | null): boolean {
25 const t = parse(timerText)
26 const r = parse(rangText)
27 const nextTimer = t && typeof t.endsAt === 'number' ? { id: String(t.id), endsAt: t.endsAt } : null
28 const nextRang = r ? { id: String(r.id) } : null
29 const nextSeen = seenText?.trim() || null
30 const changed = nextTimer?.id !== timer?.id || nextRang?.id !== rang?.id || nextSeen !== seen
31 timer = nextTimer
32 rang = nextRang
33 seen = nextSeen
34 return changed
35}
36
37// The ring clears timer.json within seconds of the end; one still there long
38// after is a timer lost to a reboot or a killed session, not one about to ring
39const STALE_AFTER_MS = 60_000
40
41export function isCounting(now: number): boolean {
42 return timer !== null && now - timer.endsAt < STALE_AFTER_MS
43}
44
45export function timerViewAt(now: number): TimerView {
46 if (timer && now - timer.endsAt < STALE_AFTER_MS) return { kind: 'counting', leftMs: Math.max(0, timer.endsAt - now) }
47 if (rang && rang.id !== seen) return { kind: 'rang' }
48 return { kind: 'none' }
49}
50
51// A prompt means the person is back: the reminder has done its job, in every
52// session at once. Returns the id to record as seen, or null when nothing rang.
53export function takeSeen(): string | null {
54 if (!rang || rang.id === seen) return null
55 seen = rang.id
56 return seen
57}
58
59export function formatLeft(ms: number): string {
60 const total = Math.ceil(ms / 1000)
61 const h = Math.floor(total / 3600)
62 const m = Math.floor((total % 3600) / 60)
63 const ss = String(total % 60).padStart(2, '0')
64 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
65}
66
67export function timerArgs(args: string): string[] {
68 const arg = args.trim()
69 return arg === '' ? ['status'] : arg.toLowerCase() === 'off' ? ['off'] : ['set', arg]
70}
71
72// A /clear or a resume goes on in the same process, so only a real exit counts
73export function isExit(reason: string): boolean {
74 return reason === 'prompt_input_exit' || reason === 'logout' || reason === 'other'
75}
76
77export const TIMER_COMMAND = {
78 name: 'timer',
79 description: 'Remind me to take a break: /timer 45m or 1h; no argument shows the timer, off cancels it',
80 argumentHint: '[45m | 1h | off]',
81}
82types/index.d.ts 14 lines1export type Usage = {
2 ctx: number | null
3 fiveHour: number | null
4 fiveHourReset: string | null
5 sevenDay: number | null
6 sevenDayReset: string | null
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'richmond': { usage: Usage }
12 }
13}
14