SLOPSHOPPER

witcher

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

newbandspinnercommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · witcher
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /witcher ☠ ▰▰▮▱▱▱ ctx 49% ⚡ ▰▰▰▰▱▱ 5h 31% ↻ 09:53 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☠ ▰▰▮▱▱▱ ctx 49% ⚡ ▰▰▰▰▱▱ 5h 31% ↻ 09:53
README

witcher — The Witcher 3 persona for Claude Code

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.

Requirements

  • Linux. Built and tested on Ubuntu 22.04 with GNOME Terminal 3.44 and Claude Code 2.1.296. The HUD needs Claude Code 2.1.287 or later (mods on by default).
  • jq for /witcher, the status lines and the sounds (sudo apt install jq).
  • Sounds play through paplay (PulseAudio or PipeWire, default on Ubuntu). Without it the plugin stays silent.
  • The break timer (/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.
  • The terminal profile is for GNOME Terminal. Where it isn't installed, /witcher skips it. The colours are in persona/terminal/*.dconf, so you can copy them into your own terminal.

Install

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.

What you get

PartComes withWhat it shows
HUD above the promptthe pluginToxicity 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.
Soundsthe pluginThe 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.
Themesthe pluginCorvo Bianco and Kaer Morhen, listed in /theme.
Spinner verbs and tips/witcher55 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/witcherAgents 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 TerminalBackground, palette and cursor for new tabs; your font stays.
Break timerthe plugin/timer 45m counts down in the band and rings with a break screen; see below.

Sounds

Three moments have a sound, and each takes any clip from sounds/:

MomentSettingDefault
An answer is ready, nothing left running in the backgroundturnDoneSoundlevel-up
Claude asks for a permission or a questionneedsYouSoundsomething-to-think-about
The break timer ringstimerDoneSoundlullaby-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:

ClipWhat you hear
level-upThe game's level-up chime
something-to-think-about“There's something to think about, folks”
lullaby-of-woeThe song Lullaby of Woe
you-learned-something“You learned something, but not everything”
lootThe game's loot pickup
new-questThe game's new-quest sound
quest-updatedThe game's quest-updated sound
quest-completedThe game's quest-completed sound
geralt-soitbeginsGeralt: “So it begins”
geralt-wtfGeralt: “What the fuck was that?”
hmmGeralt'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”
lalalaMusic with a woman singing “la-la-la”

Break timer

/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.

Switching personas

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.

What /witcher changes, and how to undo it

The 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 default GNOME Terminal profile.

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.

Development

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

Rights

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.

Source 3 files
hooks/register.tsx 342 lines
1import { 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}
342
hooks/timer.ts 82 lines
1// 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}
82
types/index.d.ts 13 lines
1export 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