SLOPSHOPPER

spare-cycles

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

newtoastprompttimer
v0.1.0MITupdated 2026-10-07m4cd4r4/spare-cycles/plugin
A shopper browsing a rack in a slop shop
README

Spare Cycles

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.

How it works

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.

Install

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.

OptionDefaultMeaning
interval45Minutes from Done or Skip to the next task coming due
snooze5Minutes a Snooze pushes the current task back
tasks10 push-ups, Wash the dishes, Stretch for 2 minutesComma-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.

Setting your tasks

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.

Licence

MIT

Source 2 files
hooks/core.ts 149 lines
1// 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}
149
hooks/reducer.ts 198 lines
1// 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