A timer that brings up a small task to do while Claude works.

A Claude Code mod that reminds you to do a short task you choose (10 push-ups, wash the dishes, stretch) on a timer. The aim is to take the break while Claude is working, not while you are.
Status: the core timer (slice 1a) is in, and its first UI (slice 1b, a VS Code status bar) is merged in clear-resume and ships in its next release. It is being built in the open, one slice at a time, each slice starting from a brief in docs/briefs/. Every slice is built in one long Claude Code session that clear-resume keeps going by itself across automatic clears, and each one is screen-recorded.
The mod owns the timer. It writes the current task and when it comes due to ~/.spare-cycles/state.json. Any UI that can read a file shows it, and sends Done, Skip or Snooze back by writing ~/.spare-cycles/action.json. With several Claude Code sessions open, only one of them runs the timer.
The first UI is a status bar item in the clear-resume VS Code extension: a countdown to the next task, Done, Skip or Snooze on click, and one notification when a task comes due. It is off by default; turn on clearResume.spareCycles in VS Code settings. A terminal UI comes later.
claude plugin marketplace add m4cd4r4/spare-cycles
claude plugin install spare-cycles@spare-cycles
The install screen asks for the options; change them later in /config.
| Option | Default | Meaning |
|---|---|---|
interval | 45 | Minutes from Done or Skip to the next task coming due |
snooze | 5 | Minutes a Snooze pushes the current task back |
tasks | 10 push-ups, Wash the dishes, Stretch for 2 minutes | Comma-separated, used in order and wrapping round |
The recorded build sessions run with interval=2 and snooze=1, so a reminder comes due on camera. Day to day, keep the defaults or set your own.
Give the list at install, or change it later in /config:
claude plugin install spare-cycles@spare-cycles --config "tasks=20 squats, Refill water, Walk to the letterbox"
Tasks come up one at a time in the order given. Done or Skip moves to the next one, and the list starts again from the top after the last.
MIT
hooks/core.ts 149 lines1// The mod: wiring, file I/O and the lock. The timer's logic is in reducer.ts; this file
2// reads the clock, the store and the files, feeds them through it and writes back what
3// changed.
4
5import type { EngineInterface, Register } from 'claude-code'
6import {
7 claimLock, configFrom, parseAction, parseLock, parseTimer, reduce, start, stateFile, taskAt,
8 type Action, type Config, type Lock, type Timer,
9} from './reducer.ts'
10
11const TICK_MS = 2_000
12// A lock not renewed for three ticks is taken over.
13const STALE_MS = 3 * TICK_MS
14const TIMER_KEY = 'timer'
15
16// What one run of the timer needs between ticks. Module variables start over on a
17// reload, as the engine's pending timers are cancelled with the old module.
18type Run = {
19 config: Config
20 owner: string
21 statePath: string
22 actionPath: string
23 lockPath: string
24 // This process held the lock on its last tick.
25 isHolding: boolean
26 isTicking: boolean
27}
28
29let isStarted = false
30
31// Identifies this process, not the session: set once per process and inherited by
32// what it starts, so it survives a hot reload and /clear.
33async function ownerId($: EngineInterface): Promise<string> {
34 const held = await $.env.get('SPARE_CYCLES_OWNER')
35 if (held !== undefined && held !== '') return held
36 const minted = crypto.randomUUID()
37 await $.env.set('SPARE_CYCLES_OWNER', minted)
38 return minted
39}
40
41async function folder($: EngineInterface): Promise<string | null> {
42 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
43 return home === undefined || home === '' ? null : `${home.replace(/[\\/]+$/, '')}/.spare-cycles`
44}
45
46async function readAction($: EngineInterface, path: string): Promise<Action | null> {
47 if (!(await $.fs.exists(path))) return null
48 const text = await $.fs.read(path).catch(() => null)
49 return typeof text === 'string' ? parseAction(text) : null
50}
51
52async function readLock($: EngineInterface, path: string): Promise<Lock | null> {
53 if (!(await $.fs.exists(path))) return null
54 const text = await $.fs.read(path).catch(() => null)
55 return typeof text === 'string' ? parseLock(text) : null
56}
57
58async function tick($: EngineInterface, run: Run): Promise<void> {
59 const { config } = run
60 const now = await $.clock.now()
61
62 // Only the lock's holder runs the timer; the others check again next tick.
63 const claim = claimLock(await readLock($, run.lockPath), run.owner, now, STALE_MS)
64 if (!claim.isHeld) {
65 run.isHolding = false
66 return
67 }
68 await $.fs.write(run.lockPath, `${JSON.stringify(claim.lock)}\n`)
69 const isTakingOver = !run.isHolding
70 run.isHolding = true
71
72 const stored = parseTimer(await $.store.get(TIMER_KEY))
73 let timer: Timer = stored ?? start(config, now)
74 let isChanged = stored === null
75
76 const action = await readAction($, run.actionPath)
77 if (action !== null) {
78 const step = reduce(timer, { type: 'action', action, now }, config)
79 timer = step.timer
80 isChanged ||= step.isChanged
81 }
82 const step = reduce(timer, { type: 'tick', now }, config)
83 timer = step.timer
84 isChanged ||= step.isChanged
85
86 if (isChanged) await $.store.set(TIMER_KEY, timer)
87 if (isChanged || isTakingOver) {
88 const state = stateFile(timer, config, run.owner, now)
89 await $.fs.write(run.statePath, `${JSON.stringify(state, null, 2)}\n`)
90 }
91 if (step.isComingDue) $.ui.toast(`Spare cycles: ${taskAt(config, timer.taskIndex)}`)
92}
93
94// One tick at a time: a slow one makes the next period skip rather than overlap.
95async function guardedTick($: EngineInterface, run: Run): Promise<void> {
96 if (run.isTicking) return
97 run.isTicking = true
98 try {
99 await tick($, run)
100 } catch (error) {
101 $.ui.log(`spare-cycles: tick failed: ${String(error)}`, { to: 'debug' })
102 } finally {
103 run.isTicking = false
104 }
105}
106
107async function begin($: EngineInterface, config: Config): Promise<void> {
108 const dir = await folder($)
109 if (dir === null) {
110 $.ui.log('spare-cycles: neither USERPROFILE nor HOME is set; the timer is off.', { to: 'debug' })
111 return
112 }
113 const run: Run = {
114 config,
115 owner: await ownerId($),
116 statePath: `${dir}/state.json`,
117 actionPath: `${dir}/action.json`,
118 lockPath: `${dir}/lock.json`,
119 isHolding: false,
120 isTicking: false,
121 }
122 await guardedTick($, run)
123 $.clock.every(TICK_MS, () => void guardedTick($, run))
124}
125
126// Start on the first event the mod sees. session.start fires once per process and
127// on each reload, never on /clear; the interval runs on through a /clear.
128function ensureStarted($: EngineInterface, config: Config): void {
129 if (isStarted) return
130 isStarted = true
131 void begin($, config).catch(error => {
132 isStarted = false
133 $.ui.log(`spare-cycles: could not start: ${String(error)}`, { to: 'debug' })
134 })
135}
136
137export const register: Register = (on, options) => {
138 const config = configFrom(options)
139
140 on('session.start', ($, e, next) => {
141 ensureStarted($, config)
142 return next(e)
143 })
144 on('prompt.submit', ($, e, next) => {
145 ensureStarted($, config)
146 return next(e)
147 })
148}
149hooks/reducer.ts 198 lines1// The timer's logic: pure functions of state, config and time. No I/O, no clock.
2// core.ts reads the clock and the files and feeds what it finds through here.
3
4export const MINUTE = 60_000
5
6export const DEFAULT_TASKS = ['10 push-ups', 'Wash the dishes', 'Stretch for 2 minutes']
7
8export type Config = {
9 intervalMs: number
10 snoozeMs: number
11 tasks: readonly string[]
12}
13
14// What the core keeps in $.store, so a new owner carries on where the last stopped.
15export type Timer = {
16 taskIndex: number
17 dueAt: number
18 isDue: boolean
19 lastAction: string | null
20 // The `at` of the last action applied; an action not newer than this does nothing.
21 lastActionAt: number
22}
23
24export type ActionName = 'done' | 'skip' | 'snooze'
25
26export type Action = { action: ActionName; at: number }
27
28export type Event =
29 | { type: 'tick'; now: number }
30 | { type: 'action'; action: Action; now: number }
31
32export type Step = {
33 timer: Timer
34 // The timer changed and state.json should be rewritten.
35 isChanged: boolean
36 // This step is the moment the task came due.
37 isComingDue: boolean
38}
39
40export function parseTasks(text: unknown): string[] {
41 if (typeof text !== 'string') return [...DEFAULT_TASKS]
42 const tasks = text.split(',').map(task => task.trim()).filter(task => task !== '')
43 return tasks.length > 0 ? tasks : [...DEFAULT_TASKS]
44}
45
46export function parseMinutes(value: unknown, fallback: number): number {
47 const minutes = typeof value === 'string' ? Number(value) : value
48 return typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0
49 ? minutes
50 : fallback
51}
52
53export function configFrom(options: Readonly<Record<string, unknown>>): Config {
54 return {
55 intervalMs: parseMinutes(options.interval, 45) * MINUTE,
56 snoozeMs: parseMinutes(options.snooze, 5) * MINUTE,
57 tasks: parseTasks(options.tasks),
58 }
59}
60
61// An action.json's text, or null when it is not a well-formed action.
62export function parseAction(text: string): Action | null {
63 let value: unknown
64 try {
65 value = JSON.parse(text)
66 } catch {
67 return null
68 }
69 if (typeof value !== 'object' || value === null) return null
70 const { action, at } = value as Record<string, unknown>
71 const isKnown = action === 'done' || action === 'skip' || action === 'snooze'
72 if (!isKnown || typeof at !== 'number' || !Number.isFinite(at)) return null
73 return { action, at }
74}
75
76// A timer read back from $.store, or null when it is missing or not a timer.
77export function parseTimer(value: unknown): Timer | null {
78 if (typeof value !== 'object' || value === null) return null
79 const { taskIndex, dueAt, isDue, lastAction, lastActionAt } = value as Record<string, unknown>
80 const isTimer =
81 Number.isInteger(taskIndex) && (taskIndex as number) >= 0 &&
82 typeof dueAt === 'number' && Number.isFinite(dueAt) &&
83 typeof isDue === 'boolean' &&
84 (lastAction === null || typeof lastAction === 'string') &&
85 typeof lastActionAt === 'number' && Number.isFinite(lastActionAt)
86 if (!isTimer) return null
87 return {
88 taskIndex: taskIndex as number,
89 dueAt,
90 isDue,
91 lastAction: lastAction as string | null,
92 lastActionAt,
93 }
94}
95
96export function taskAt(config: Config, index: number): string {
97 const tasks = config.tasks.length > 0 ? config.tasks : DEFAULT_TASKS
98 return tasks[index % tasks.length] ?? tasks[0] ?? ''
99}
100
101// A fresh timer: the first task, due one interval from now.
102export function start(config: Config, now: number): Timer {
103 return { taskIndex: 0, dueAt: now + config.intervalMs, isDue: false, lastAction: null, lastActionAt: 0 }
104}
105
106export function reduce(timer: Timer, event: Event, config: Config): Step {
107 const unchanged: Step = { timer, isChanged: false, isComingDue: false }
108
109 if (event.type === 'tick') {
110 if (timer.isDue || event.now < timer.dueAt) return unchanged
111 return { timer: { ...timer, isDue: true }, isChanged: true, isComingDue: true }
112 }
113
114 const { action, now } = event
115 if (action.at <= timer.lastActionAt) return unchanged
116
117 const task = taskAt(config, timer.taskIndex)
118 const lastAction = `${action.action}: ${task}`
119 if (action.action === 'snooze') {
120 return {
121 timer: { ...timer, dueAt: now + config.snoozeMs, isDue: false, lastAction, lastActionAt: action.at },
122 isChanged: true,
123 isComingDue: false,
124 }
125 }
126 const taskCount = config.tasks.length > 0 ? config.tasks.length : DEFAULT_TASKS.length
127 return {
128 timer: {
129 taskIndex: (timer.taskIndex + 1) % taskCount,
130 dueAt: now + config.intervalMs,
131 isDue: false,
132 lastAction,
133 lastActionAt: action.at,
134 },
135 isChanged: true,
136 isComingDue: false,
137 }
138}
139
140// The state.json a UI reads.
141export type StateFile = {
142 version: 1
143 task: string
144 dueAt: number
145 isDue: boolean
146 lastAction: string | null
147 owner: string
148 updatedAt: number
149}
150
151export function stateFile(timer: Timer, config: Config, owner: string, now: number): StateFile {
152 return {
153 version: 1,
154 task: taskAt(config, timer.taskIndex),
155 dueAt: timer.dueAt,
156 isDue: timer.isDue,
157 lastAction: timer.lastAction,
158 owner,
159 updatedAt: now,
160 }
161}
162
163// lock.json: which process runs the timer, and when it last said it still does.
164export type Lock = { owner: string; heartbeat: number }
165
166export type Claim = {
167 // The lock as it should stand after this tick; write it when isHeld.
168 lock: Lock | null
169 // This process runs the timer this tick.
170 isHeld: boolean
171}
172
173// A lock.json's text, or null when it is not a well-formed lock.
174export function parseLock(text: string): Lock | null {
175 let value: unknown
176 try {
177 value = JSON.parse(text)
178 } catch {
179 return null
180 }
181 if (typeof value !== 'object' || value === null) return null
182 const { owner, heartbeat } = value as Record<string, unknown>
183 if (typeof owner !== 'string' || owner === '') return null
184 if (typeof heartbeat !== 'number' || !Number.isFinite(heartbeat)) return null
185 return { owner, heartbeat }
186}
187
188// Keep the lock if it is ours, take it if it is missing or stale, and leave it alone
189// if another process holds it. A heartbeat far in the future counts as stale too, so a
190// clock that jumped back cannot leave the lock held for ever.
191export function claimLock(held: Lock | null, owner: string, now: number, staleMs: number): Claim {
192 const isStale = held !== null && (now - held.heartbeat > staleMs || held.heartbeat - now > staleMs)
193 if (held === null || held.owner === owner || isStale) {
194 return { lock: { owner, heartbeat: now }, isHeld: true }
195 }
196 return { lock: held, isHeld: false }
197}
198