The Witcher 3 persona: vitality, stamina and toxicity HUD above the prompt, game sounds, Corvo Bianco and Kaer Morhen themes, campfire spinner and status…

Claude Code dressed as The Witcher 3: the game's vitality, stamina and toxicity bars above the prompt, Geralt's lines when an answer is ready, two themes (Corvo Bianco and Kaer Morhen), campfire spinner lines and a status line where main takes the silver sword.
All the texts (HUD, spinner verbs, tips, status line, subagent panel) are in Ukrainian.
jq for /witcher, 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./witcher 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 witcher@claude-code-flow
Then, inside Claude Code:
/witcher # Corvo Bianco: warm wood and gold
/witcher kaer-morhen # Kaer Morhen: cold stone and gold
Restart Claude Code (/exit, then claude -c) and you're on the Path.
| Part | Comes with | What it shows | |
|---|---|---|---|
| HUD above the prompt | the plugin | Toxicity is the context % and carries a mark at 30 %, the point to hand the session off; /config witcher.toxicityThreshold=40 moves it to your own. Stamina is the 5-hour limit and vitality the 7-day limit, both draining like the game's bars. | |
| Sounds | the plugin | The level-up chime when an answer is ready, «Something to think about» the moment Claude asks for a permission or a question. Each moment takes any clip of the set; see below. | |
| Themes | the plugin | Corvo Bianco and Kaer Morhen, listed in /theme. | |
| Spinner verbs and tips | /witcher | 55 verbs and 76 tips: lines from the books and in the characters' voices. A long turn moves to the next verb every 30 s; /config witcher.spinnerSeconds=60 makes it 60. | |
| Status line | /witcher | `❖ project ⚔ СРІБЛО · main [model \ | effort]`. A working branch takes the steel sword. |
| Subagent panel | /witcher | Agents on a contract, three lines each: Explore follows its witcher senses, Plan is Vesemir at the map, a general-purpose agent a hired sword. | |
| Terminal profile | /witcher, 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 | level-up |
| Claude asks for a permission or a question | needsYouSound | something-to-think-about |
| The break timer rings | timerDoneSound | lullaby-of-woe |
Pick one in /config (it lists the clips), type /config witcher.turnDoneSound=loot, or just tell Claude which clip you want where. CLAUDE_SOUND=off mutes all three.
The clips:
| Clip | What you hear |
|---|---|
level-up | The game's level-up chime |
something-to-think-about | “There's something to think about, folks” |
lullaby-of-woe | The song Lullaby of Woe |
you-learned-something | “You learned something, but not everything” |
loot | The game's loot pickup |
new-quest | The game's new-quest sound |
quest-updated | The game's quest-updated sound |
quest-completed | The game's quest-completed sound |
geralt-soitbegins | Geralt: “So it begins” |
geralt-wtf | Geralt: “What the fuck was that?” |
hmm | Geralt's “Hmm” |
damn-it | “Come on, damn it” |
she-fixed-it | “She fixed it” |
i-could-use-the-practice | “Try me, peasant. I could use the practice” |
if-you-don-t-shut-up | “And I'll take your tongue if you don't shut up” |
i-will-kill-you | “Challenge me again… look at me the wrong way, and I'll kill you” |
lalala | Music with a woman singing “la-la-la” |
/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, Lullaby of Woe plays (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 Corvo Bianco colours (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×500 window in the middle of the main monitor; /config witcher.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 richmond. Install both and switch with one command: /richmond puts AFC Richmond on duty, /witcher brings the Witcher 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.
/witcher 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 /witcher runs scripts/wear.sh, which writes them:
theme, spinnerVerbs, spinnerTipsOverride, statusLine, subagentStatusLine;enabledPlugins, only if witcher 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. /witcher off puts them back and silences the HUD and sounds of every persona plugin; they stay installed, and /witcher 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/witcher
claude plugin test plugins/witcher # the HUD and the /witcher command
bash plugins/witcher/tests/wear.test.sh # wear.sh against a throwaway config
bash plugins/witcher/tests/sound.test.sh # which persona plays, and which clip
bash plugins/witcher/tests/timer.test.sh # timer.sh with systemd, the banner and the break screen stubbed
An unofficial, non-commercial fan project, made under the CD PROJEKT RED Fan Content Guidelines. The Witcher and its characters belong to CD PROJEKT S.A.; the original books are by Andrzej Sapkowski. The clips are short excerpts: voice lines and game sounds of a few seconds, the longest 14 seconds of Lullaby of Woe. The break-screen GIFs in persona/breaks/ are short loops from the game's cutscenes, taken from GIPHY and Tenor and cut down to a few seconds. If you hold the rights and want them out, open an issue.
The break screen is set in Alegreya by Huerta Tipográfica, under the SIL Open Font License 1.1 (fonts/OFL.txt); the two files are static SemiBold and Regular instances of the variable font from Google Fonts.
hooks/register.tsx 342 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// The game's HUD: toxicity climbs as the session drinks context, stamina
8// drains fast and comes back (the 5-hour window), vitality drains slowly (the
9// 7-day window). Running agents are the engine's own panel below the prompt
10// (subagentStatusLine), not this band.
11const usage = atom({ plugin: 'witcher', key: 'usage' } as const, {
12 ctx: null,
13 fiveHour: null,
14 fiveHourReset: null,
15 sevenDay: null,
16} as Usage)
17
18// A session is meant to be handed off to a fresh one at 30 % of context, so the
19// toxicity bar marks that line the way the game marks the overdose threshold.
20// Where to hand off is a habit, not a law, so `toxicityThreshold` moves it.
21const DEFAULT_TOXICITY_THRESHOLD = 30
22// Past the mark by this much the handoff is overdue, and the bar turns red.
23const TOXICITY_OVERDUE_BY = 10
24
25function bar(percent: number, width: number): string {
26 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
27 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
28}
29
30// Toxicity carries the threshold mark inside the bar, like the game's line.
31function toxicityBar(percent: number, threshold: number, width: number): string {
32 const cells = bar(percent, width).split('')
33 const mark = Math.round((threshold / 100) * width)
34 if (mark < width) cells[mark] = cells[mark] === '▰' ? '▮' : '│'
35 return cells.join('')
36}
37
38function hhmm(iso: string | null): string {
39 if (!iso) return ''
40 const d = new Date(iso)
41 if (Number.isNaN(d.getTime())) return ''
42 const h = String(d.getHours()).padStart(2, '0')
43 const m = String(d.getMinutes()).padStart(2, '0')
44 return `${h}:${m}`
45}
46
47type Dollar = EngineInterface
48
49async function refreshUsage($: Dollar) {
50 const u = await $.session.usage()
51 const limit = (kind: string) => u.rateLimits.find(r => r.kind === kind)
52 const next: Usage = {
53 ctx: u.context.percent ?? (u.context.tokens ? Math.round((u.context.tokens / u.context.window) * 100) : null),
54 fiveHour: limit('five_hour')?.percentUsed ?? null,
55 fiveHourReset: limit('five_hour')?.resetsAt ?? null,
56 sevenDay: limit('seven_day')?.percentUsed ?? null,
57 }
58 await update($, usage, () => next)
59}
60
61// Several persona plugins can sit side by side; personas/active names the one
62// on duty and the others keep quiet. Undefined when the marker can't be read:
63// at session start that means nobody has chosen yet, so a lone plugin works
64// straight after install; mid-session it is a switch in progress.
65async function isOnDuty($: Dollar): Promise<boolean | undefined> {
66 try {
67 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
68 const active = String(await $.fs.read(`${dir}/personas/active`)).trim()
69 return active === 'witcher'
70 } catch {
71 return undefined
72 }
73}
74
75// Unknown until first asked: a default either way would flash the wrong band.
76let onDuty: boolean | undefined
77let stopWatching: (() => void) | undefined
78
79// The switch can come from another plugin's command or another session, and
80// neither can reach this module, so it polls the marker and redraws on change.
81const DUTY_POLL_MS = 3000
82
83// The break timer's state files, shared by every session and both personas
84async function timerDir($: Dollar): Promise<string | null> {
85 try {
86 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
87 return `${dir}/persona-timer`
88 } catch {
89 return null
90 }
91}
92
93async function readOrNull($: Dollar, path: string): Promise<string | null> {
94 try {
95 return String(await $.fs.read(path))
96 } catch {
97 return null
98 }
99}
100
101async function refreshTimer($: Dollar): Promise<boolean> {
102 const dir = await timerDir($)
103 if (!dir) return false
104 return applyTimerFiles(
105 await readOrNull($, `${dir}/timer.json`),
106 await readOrNull($, `${dir}/rang.json`),
107 await readOrNull($, `${dir}/seen`),
108 )
109}
110
111async function runTimer($: Dollar, args: string): Promise<string> {
112 const r = await $.process
113 .run(['bash', `${$.plugin.root}/scripts/timer.sh`, ...timerArgs(args)])
114 .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
115 return (r.exitCode === 0 ? r.stdout : r.stderr).trim()
116}
117
118async function recheckDuty($: Dollar) {
119 const timerChanged = await refreshTimer($)
120 const now = await isOnDuty($)
121 if (now !== undefined && now !== onDuty) {
122 onDuty = now
123 $.ui.invalidate('ui.render')
124 } else if (timerChanged && onDuty) {
125 $.ui.invalidate('ui.render')
126 }
127}
128
129// The engine samples the spinner word from the verbs it read at session start,
130// so a persona put on mid-session would keep the previous one's words; on duty,
131// this plugin swaps in its own.
132let verbs: readonly string[] = []
133
134async function loadVerbs($: Dollar) {
135 try {
136 verbs = JSON.parse(String(await $.fs.read(`${$.plugin.root}/persona/verbs.json`))).verbs ?? []
137 } catch {
138 verbs = []
139 }
140}
141
142// The engine samples its word once per turn, so the verb starts from it (or
143// from a hash of a word that isn't this persona's) and moves `step` verbs on,
144// one step per `spinnerSeconds`: a long turn doesn't sit on one line, and
145// redraws within a step keep the same verb.
146export function ownVerb(sampled: string, own: readonly string[], step = 0): string {
147 if (own.length === 0) return sampled
148 let start = own.indexOf(sampled)
149 if (start < 0) {
150 let h = 0
151 for (const ch of sampled) h = (h * 31 + ch.charCodeAt(0)) >>> 0
152 start = h % own.length
153 }
154 return own[(start + step) % own.length]!
155}
156
157// When each sampled word was first drawn: the main spinner and each agent's
158// carry their own word, so each counts its steps from its own start.
159const firstSeen = new Map<string, number>()
160
161async function stepsSince($: Dollar, word: string, stepMs: number): Promise<number> {
162 const now = await $.clock.now()
163 if (firstSeen.size > 64) firstSeen.clear()
164 const start = firstSeen.get(word) ?? now
165 firstSeen.set(word, start)
166 return Math.floor((now - start) / stepMs)
167}
168
169// Redraws often enough that a step lands within a few seconds of falling due
170const SPINNER_TICK_MS = 5000
171// The break countdown shows seconds
172const TIMER_TICK_MS = 1000
173
174// Wearing the persona rewrites the person's own settings, so it is a command
175// they run, never a side effect of installing the plugin. wear.sh owns the
176// list of themes and says what changed and where it shows up.
177async function wear($: Dollar, args: string): Promise<string> {
178 const arg = args.trim()
179 const argv = ['bash', `${$.plugin.root}/scripts/wear.sh`, ...(arg === 'off' ? ['off'] : ['on', ...(arg ? [arg] : [])])]
180 const r = await $.process.run(argv).catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
181 if (r.exitCode !== 0) return `Failed: ${r.stderr.trim()}`
182 return r.stdout.trim()
183}
184
185export const register: Register = (on, options) => {
186 const stepMs = Math.max(10, Number(options.spinnerSeconds) || 30) * 1000
187 // Same bounds as toxicityThreshold in plugin.json; the clamp covers a value the host saved unchecked
188 const threshold = Math.min(90, Math.max(5, Number(options.toxicityThreshold) || DEFAULT_TOXICITY_THRESHOLD))
189
190 on('session.start', async ($, e, next) => {
191 await $.command.register({
192 name: 'witcher',
193 description: 'Put on The Witcher 3 persona: theme, spinner, status line, terminal profile',
194 argumentHint: '[corvo-bianco | kaer-morhen | off]',
195 })
196 // Both persona plugins carry /timer and the first to start owns it; the
197 // other's refusal must not cost it the rest of its start
198 await $.command.register(TIMER_COMMAND).catch(() => undefined)
199 // The persona on duty when the timer rings may not be the plugin that set
200 // it, so each plugin says where its phrases and clip live
201 const dir = await timerDir($)
202 if (dir) await $.fs.write(`${dir}/roots/witcher`, $.plugin.root).catch(() => undefined)
203 await refreshTimer($)
204 onDuty = (await isOnDuty($)) ?? true
205 // Each session start re-arms the poll with its own `$`
206 stopWatching?.()
207 const duty = $.clock.every(DUTY_POLL_MS, () => void recheckDuty($))
208 const tick = $.clock.every(SPINNER_TICK_MS, () => {
209 if (onDuty) $.ui.invalidate('ui.render')
210 })
211 const countdown = $.clock.every(TIMER_TICK_MS, async () => {
212 if (onDuty && isCounting(await $.clock.now())) $.ui.invalidate('ui.render')
213 })
214 stopWatching = () => {
215 duty.cancel()
216 tick.cancel()
217 countdown.cancel()
218 }
219 await loadVerbs($)
220 await refreshUsage($)
221 return next(e)
222 })
223
224 on('command.run', { command: 'witcher' }, async ($, e) => {
225 const text = await wear($, e.args)
226 await recheckDuty($)
227 return { text }
228 })
229
230 on('command.run', { command: 'timer' }, async ($, e) => {
231 const text = await runTimer($, e.args)
232 await refreshTimer($)
233 $.ui.invalidate('ui.render')
234 return { text }
235 })
236
237 on('prompt.submit', async ($, e, next) => {
238 const id = takeSeen()
239 if (id) {
240 const dir = await timerDir($)
241 if (dir) await $.fs.write(`${dir}/seen`, id).catch(() => undefined)
242 if (onDuty) $.ui.invalidate('ui.render')
243 }
244 return next(e)
245 })
246
247 on('session.end', async ($, e, next) => {
248 if (isExit(e.reason)) {
249 // Every exit hook shares one short bound, so reap takes what is left of it
250 const timeoutMs = Math.max(200, Math.floor(next.budget.remainingMs))
251 await $.process.run(['bash', `${$.plugin.root}/scripts/timer.sh`, 'reap'], { timeoutMs }).catch(() => undefined)
252 }
253 return next(e)
254 })
255
256 on('session.measure', async ($, e, next) => {
257 await refreshUsage($)
258 return next(e)
259 })
260
261 on('turn.complete', async ($, e, next) => {
262 await refreshUsage($)
263 return next(e)
264 })
265
266 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
267 // A message (compacting, a hook running) says what is happening, and on the
268 // desktop the word names the step being done; both stay as they are
269 if (!onDuty || e.surface !== 'terminal' || e.props.message !== null) return next(e)
270 const step = await stepsSince($, e.props.word, stepMs)
271 return next({ ...e, props: { ...e.props, word: ownVerb(e.props.word, verbs, step) } })
272 })
273
274 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
275 onDuty ??= (await isOnDuty($)) ?? true
276 if (e.props.hasSurvey || !onDuty) return next(e)
277
278 const u = await read($, usage)
279 const { Box, Text } = $.ui.resolve(e)
280
281 const wide = e.props.bodyColumns >= 110
282 const w = wide ? 10 : 6
283 const label = (long: string, short: string) => (wide ? long : short)
284
285 const tox = u.ctx ?? 0
286 const toxColor = tox >= threshold + TOXICITY_OVERDUE_BY ? 'error' : tox >= threshold ? 'warning' : 'success'
287 // The bar reads like the game (full = good, it drains); the number reads
288 // like /usage (how much is spent), tagged ctx / 5h / 7d so it cannot be
289 // mistaken for what is left.
290 const leftColor = (used: number) => (used >= 90 ? 'error' : used >= 70 ? 'warning' : 'success')
291 const rest = timerViewAt(await $.clock.now())
292
293 return (
294 <Box flexDirection="column" marginTop={1}>
295 <Box flexDirection="row">
296 <Text>
297 <Text color={toxColor}>☠ </Text>
298 <Text dimColor>{label('Токсичність ', '')}</Text>
299 <Text color={toxColor}>{toxicityBar(tox, threshold, w)}</Text>
300 <Text dimColor> ctx </Text>
301 <Text color={toxColor}>{tox}%</Text>
302 <Text dimColor> </Text>
303 </Text>
304 {u.fiveHour !== null ? (
305 <Text>
306 <Text color="warning">⚡ </Text>
307 <Text dimColor>{label('Витривалість ', '')}</Text>
308 <Text color={leftColor(u.fiveHour)}>{bar(100 - u.fiveHour, w)}</Text>
309 <Text dimColor> 5h </Text>
310 <Text color={leftColor(u.fiveHour)}>{u.fiveHour}%</Text>
311 <Text dimColor>{u.fiveHourReset ? ` ↻ ${hhmm(u.fiveHourReset)}` : ''}</Text>
312 <Text dimColor> </Text>
313 </Text>
314 ) : null}
315 {u.sevenDay !== null ? (
316 <Text>
317 <Text color="error">♥ </Text>
318 <Text dimColor>{label('Вітальність ', '')}</Text>
319 <Text color={leftColor(u.sevenDay)}>{bar(100 - u.sevenDay, w)}</Text>
320 <Text dimColor> 7d </Text>
321 <Text color={leftColor(u.sevenDay)}>{u.sevenDay}%</Text>
322 </Text>
323 ) : null}
324 {rest.kind === 'counting' ? (
325 <Text>
326 <Text dimColor> </Text>
327 <Text color="claude">☾ </Text>
328 <Text dimColor>{label('Медитація через ', '')}</Text>
329 <Text color="claude">{formatLeft(rest.leftMs)}</Text>
330 </Text>
331 ) : rest.kind === 'rang' ? (
332 <Text>
333 <Text dimColor> </Text>
334 <Text color="claude" bold>☾ Час медитації</Text>
335 </Text>
336 ) : null}
337 </Box>
338 </Box>
339 )
340 })
341}
342hooks/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 13 lines1export type Usage = {
2 ctx: number | null
3 fiveHour: number | null
4 fiveHourReset: string | null
5 sevenDay: number | null
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'witcher': { usage: Usage }
11 }
12}
13