SLOPSHOPPER

pomodoro

Pomodoro timer: a notification after a work block without a break, and a check-in where Claude stops to sum up and ask whether to go on.

newspinnerguardtoaststatusprompt
v0.2.0MITupdated 2026-10-23LRNZ09/pomodoro
A shopper browsing a rack in a slop shop
README

Pomodoro plugin for Claude Code and the Claude desktop app

A break timer for long sessions in Claude Code and the Claude desktop app, with a check-in where Claude sums up and asks whether to go on.

Install

At the prompt of a Claude Code terminal session:

/plugin install pomodoro --marketplace LRNZ09/pomodoro

Answer y to add the marketplace, then press Enter to install it for your user (or pick another scope).

From a shell:

claude plugin install pomodoro --marketplace LRNZ09/pomodoro

In the desktop app, where /plugin doesn't run: click + next to the prompt box, then Plugins, Add marketplace with LRNZ09/pomodoro, then Add plugin.

Features

  • Count. Shows how long you've worked since your last break, at the right of the prompt footer beside its other labels: ๐Ÿ… 12/25m, in the terminal and the desktop app. Once a break is due, a status line under the prompt says so too; Claude Code draws a plugin's status line in yellow with a โš , as โš  pomodoro: ๐Ÿ… 27m, time for a break. A phone following the session shows that line only.
  • Alert. After a 25-minute block it plays a chime and shows a toast and a desktop notification. The alert repeats every 5 minutes until you take a break, but not once you've been away from the keyboard for 2 minutes (the break may already be under way).
  • Check-in. Once the block is up, Claude gets a note with its next tool result. The note tells it to stop and give you, in three lines at most: what it did since the break, whether it's still on the original goal or has drifted into a side problem, and what it would do next. Then Claude asks whether to go on. A line in the transcript tells you why Claude stopped. The check-in is sent once for every 25 minutes without a break, and only while Claude is working: it never waits in an idle session for your next prompt. It reaches the main conversation only, so during a long-running subagent it comes once the subagent returns.

It runs on a clock, a tick every minute, so the alert comes on time even while you're reading code and not typing to Claude.

Breaks

You never start or stop the timer:

  • 5 minutes with no keyboard or mouse input (macOS idle time) counts as a break, and the next block starts when you're back.
  • Mouse movement counts only once it goes on for a second, so the nudge a keep-awake app's mouse jiggler gives every minute doesn't keep a break from ever starting. Keys, clicks and scrolls count at once.
  • Idle time is this Mac's own: 5 minutes spent on another computer counts as a break, unless you use this Mac's keyboard or trackpad in between (Universal Control included).
  • A closed lid, or a stretch with no Claude Code session open, also counts as a break.
  • The timer is one per person, shared by all your open sessions, so three sessions don't mean three notifications.

On Linux, on Windows, or over SSH, there's no system idle time to read, so it falls back to Claude Code activity: 5 minutes with no prompt or tool call in any session counts as a break.

Settings

Under /config, or pluginConfigs in settings.json:

SettingDefaultWhat it does
workMinutes25Minutes of work before the alert
breakMinutes5Idle minutes that count as a break, and how often the alert repeats
moveSeconds1Seconds the mouse has to keep moving to count (macOS); 0 counts every movement
soundtrueWhether each alert plays a chime (macOS)
checkIntrueWhether Claude stops to sum up at the alert; off leaves only the alert
emojitrueWhether ๐Ÿ… marks the count and the alerts; off, the footer reads pomodoro 12/25m

Alerts

  • Banner. It goes through your Claude Code notification channel (preferredNotifChannel), so your terminal shows it. While the Claude tab is in front, Ghostty shows it for a few seconds only, then removes it, so it's easy to miss. Where the terminal can't raise one, it falls back to osascript on macOS: Apple Terminal has no notification channel, and the desktop app has no terminal. macOS credits that banner to Script Editor. If you turned Claude Code notifications off, there's no banner.
  • Chime. Plain audio, not a notification, so you hear it even when the banner is easy to miss, and through a Focus (Work, Do Not Disturb).

A Focus silences the banner unless it allows your terminal: System Settings โ†’ Focus โ†’ (your Focus) โ†’ Allowed Notifications โ†’ Apps.

Requirements

Claude Code 2.1.304 or later. The plugin is a mod (a TypeScript hooks module), which older versions don't load. It runs in terminal sessions and in the desktop app, not in VS Code or claude -p.

Development

From a clone of this repository:

claude plugin validate .
claude plugin test .
claude --plugin-dir .

The last one starts a session with the mod loaded from the folder; saving a file reloads it.

Formatting and linting use Biome with the @lrnz09/biome-config preset:

bun install
bun run check
bun run fix

License

MIT

Source 2 files
hooks/register.ts 280 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3// Shared by every open session through $.store: one pomodoro per person, not per session.
4type Timer = {
5	blockStart?: number
6	seenAt?: number
7	alertedAt?: number
8	lastInput?: number
9}
10
11const MINUTE = 60_000
12// The last check delivered, kept in $.state: a reload (a /config change) starts the module over, not the session.
13const SENT = { plugin: 'pomodoro', key: 'sentKey' } as const
14const LABEL = { plugin: 'pomodoro', key: 'label' } as const
15
16// This session's own: whether its clock runs, its last prompt or tool call, the check-in now due, and the last one delivered.
17let isStarted = false
18let lastActivity = 0
19let due: { key: number; text: string } | undefined
20let sentKey: number | undefined
21// The watcher's input: the stretch of mouse movement under way, and the last input that counts (undefined: no watcher).
22const stretch = { start: 0, last: 0 }
23let lastInputThatCounts: number | undefined
24
25// Movement less than this apart is one stretch; the watcher looks 4 times a second.
26const STRETCH_GAP = 1_000
27// Prints the time of the latest input each time it changes (macOS HID idle time, 4 times a second), and with a `k`
28// before it the time of the latest key, click or scroll: a mouse jiggler only moves the pointer.
29const WATCHER = `ObjC.import('CoreGraphics')
30const out = $.NSFileHandle.fileHandleWithStandardOutput
31const latest = (...types) => Math.round(Date.now() - 1e3 * Math.min(...types.map((t) => $.CGEventSourceSecondsSinceLastEventType(1, t))))
32const print = (line) => out.writeData($(line + '\\n').dataUsingEncoding($.NSUTF8StringEncoding))
33let lastAny = 0
34let lastKey = 0
35for (;;) {
36	const any = latest(0xffffffff)
37	const key = latest(10, 12, 1, 3, 25, 22)
38	if (any - lastAny > 50) print(lastAny = any)
39	if (key - lastKey > 50) print('k' + (lastKey = key))
40	delay(0.25)
41}`
42
43const check = (minutes: number) =>
44	`[Pomodoro] The user has been working for ${minutes} minutes without a break. ` +
45	'Pause now: finish only what would otherwise be left half-done, start nothing new, and tell them in at most three short lines: ' +
46	'what was done since the break, whether it still serves the original goal or has drifted into a side problem, and what you would do next. ' +
47	'Ask whether to go on, change course or stop, remind them to take a short break, then end your turn and wait for their answer.'
48
49// Keys, clicks and scrolls count at once, mouse movement once its stretch lasts `minMs`: a mouse jiggler's nudge
50// (keep-awake apps) is a blip.
51function sawInput(line: string, minMs: number) {
52	const isKey = line.startsWith('k')
53	const at = Number(isKey ? line.slice(1) : line)
54	if (!(at > 0)) return
55	if (!isKey) {
56		if (at - stretch.last > STRETCH_GAP) stretch.start = at
57		stretch.last = at
58	}
59	// The first line is the input before the watcher started, as ioreg tells it: a reload doesn't end a break.
60	if (lastInputThatCounts === undefined || isKey || at - stretch.start >= minMs)
61		lastInputThatCounts = Math.max(lastInputThatCounts ?? 0, at)
62}
63
64// Leaving the loop, or the module unloading, ends the child.
65// ponytail: one watcher per open session; share one through $.store if many sessions matter
66async function watch($: EngineInterface, minMs: number) {
67	try {
68		const watcher = $.process.spawn({
69			argv: ['osascript', '-l', 'JavaScript', '-e', WATCHER],
70			cwd: '/',
71		})
72		for await (const { stream, text } of watcher)
73			if (stream === 'stdout')
74				for (const line of text.split('\n')) sawInput(line, minMs)
75	} catch {}
76	// It ended or never started: ioreg stands in, where every input counts.
77	lastInputThatCounts = undefined
78}
79
80// Time since input that counts, from the watcher; else macOS input idle time, unless over SSH (ioreg sees the
81// console's input); else time since this session's last activity.
82async function idleMs($: EngineInterface, now: number) {
83	if (lastInputThatCounts !== undefined) return now - lastInputThatCounts
84	if (!(await $.env.get('SSH_CONNECTION'))) {
85		try {
86			const { stdout } = await $.process.run(
87				['ioreg', '-r', '-c', 'IOHIDSystem', '-d', '1'],
88				{ cwd: '/', timeoutMs: 5_000 },
89			)
90			const ns = stdout.match(/"HIDIdleTime" = (\d+)/)?.[1]
91			if (ns !== undefined) return Number(ns) / 1e6
92		} catch {}
93	}
94	return now - lastActivity
95}
96
97// ๐Ÿ… before a line, unless the setting turns it off; `word` stands in where the line needs a name to make sense.
98const tomato = (options: PluginOptions, word = '') =>
99	options.emoji === true ? '๐Ÿ… ' : word
100
101async function alert(
102	$: EngineInterface,
103	text: string,
104	hasSound: boolean,
105	mark: string,
106) {
107	$.ui.toast(`${mark}${text}`, { timeoutMs: 15_000 })
108	// Ghostty shows the banner of the tab in front for a few seconds only, and a Focus silences it; not a chime (macOS).
109	if (hasSound)
110		await $.process
111			.run(['afplay', '/System/Library/Sounds/Glass.aiff'], { cwd: '/' })
112			.catch(() => {})
113	// osascript only where the terminal can't raise one; notifications turned off stay off (macOS).
114	const sent = await $.ui
115		.notify(text, { title: 'Pomodoro' })
116		.catch(() => undefined)
117	if (
118		!sent ||
119		(!sent.isSent &&
120			(sent.reason === 'no-channel' || sent.reason === 'no-surface'))
121	) {
122		await $.process
123			.run(
124				[
125					'osascript',
126					'-e',
127					`display notification "${text}" with title "Pomodoro"`,
128				],
129				{ cwd: '/' },
130			)
131			.catch(() => {})
132	}
133}
134
135// The count is a mode label in the prompt footer. The status line, which Claude Code marks โš , only says a break is due.
136function show($: EngineInterface, label: string, warning?: string) {
137	void $.state.set(LABEL, label).catch(() => {})
138	$.ui.status(warning)
139}
140
141async function tick($: EngineInterface, options: PluginOptions) {
142	const workMs = Number(options.workMinutes) * MINUTE
143	const breakMs = Math.max(1, Number(options.breakMinutes)) * MINUTE
144	const now = await $.clock.now()
145	const timer = ((await $.store.get('timer')) ?? {}) as Timer
146	// The latest input any session has seen, so an idle session can't end the block another one is working in.
147	const lastInput = Math.max(timer.lastInput ?? 0, now - (await idleMs($, now)))
148	const idle = now - lastInput
149
150	if (idle >= breakMs) {
151		due = undefined
152		await $.store.set('timer', { seenAt: now, lastInput })
153		show($, `${tomato(options, 'pomodoro ')}on a break`)
154		return
155	}
156	// Ticks are a minute apart: look again the moment this stretch without input would become a break.
157	if (idle > breakMs - MINUTE)
158		$.clock.after(
159			breakMs - idle + 1_000,
160			() => void tick($, options).catch(() => {}),
161		)
162
163	// No tick for a break's length (two ticks at least): the machine slept or no session was open.
164	const isGap =
165		timer.seenAt === undefined ||
166		now - timer.seenAt >= Math.max(breakMs, 2 * MINUTE)
167	const blockStart =
168		isGap || timer.blockStart === undefined ? now : timer.blockStart
169	const worked = now - blockStart
170	const minutes = Math.floor(worked / MINUTE)
171	const isDue = worked >= workMs
172	const alertedAt = timer.alertedAt ?? 0
173	// Not once the person has been away a while: that may be the break already under way.
174	const shouldAlert =
175		isDue &&
176		idle < 2 * MINUTE &&
177		(alertedAt < blockStart || now - alertedAt >= breakMs)
178
179	// ponytail: read-then-write, so two sessions ticking in the same instant can both alert
180	await $.store.set('timer', {
181		blockStart,
182		seenAt: now,
183		alertedAt: shouldAlert ? now : alertedAt,
184		lastInput,
185	})
186	show(
187		$,
188		`${tomato(options, 'pomodoro ')}${minutes}/${options.workMinutes}m`,
189		isDue ? `${tomato(options)}${minutes}m, time for a break` : undefined,
190	)
191	// One check per work period without a break, keyed by when that period ended.
192	due =
193		isDue && options.checkIn
194			? {
195					key: blockStart + Math.floor(worked / workMs) * workMs,
196					text: check(minutes),
197				}
198			: undefined
199	if (shouldAlert) {
200		const text = `${minutes} minutes without a break. Stand up and look away from the screen for ${breakMs / MINUTE}.`
201		await alert($, text, options.sound === true, tomato(options))
202	}
203}
204
205// The clock runs where the count shows: at the terminal, or once the desktop app attaches. Never in -p, nor in VS Code,
206// which attaches too but draws no footer labels.
207async function begin($: EngineInterface, options: PluginOptions) {
208	if (isStarted) return
209	isStarted = true
210	// Read before the first tick, which is what makes a check due.
211	sentKey = await $.state.get(SENT).then(
212		(read) => read.value,
213		() => undefined,
214	)
215	// Like ioreg, the watcher sees the console's input, not the input of an SSH session.
216	if (!(await $.env.get('SSH_CONNECTION')))
217		void watch($, Number(options.moveSeconds) * 1_000)
218	// A failed tick is retried by the next one.
219	void tick($, options).catch(() => {})
220	$.clock.every(MINUTE, () => void tick($, options).catch(() => {}))
221}
222
223export const register: Register = (on, options) => {
224	on('session.start', async ($, e, next) => {
225		// A reload while the desktop app is attached raises no new session.attach.
226		if (e.isInteractive || (await $.session.surfaces()).includes('desktop'))
227			await begin($, options)
228		return next(e)
229	})
230
231	on('session.attach', { surface: 'desktop' }, async ($, e, next) => {
232		await begin($, options)
233		return next(e)
234	})
235
236	// Reading the label while drawing redraws the footer on each new count (terminal, desktop).
237	on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
238		const { value } = await $.state.get(LABEL)
239		return next(
240			value === undefined
241				? e
242				: { ...e, props: { ...e.props, modes: [...e.props.modes, value] } },
243		)
244	})
245
246	on('prompt.submit', async ($, e, next) => {
247		lastActivity = await $.clock.now()
248		return next(e)
249	})
250
251	// The check rides a tool result of the main loop: Claude reads it mid-turn, and it never waits in an idle session.
252	on('tool.call', async ($, e, next) => {
253		lastActivity = await $.clock.now()
254		const ran = await next(e)
255		if (
256			due === undefined ||
257			due.key === sentKey ||
258			e.agentId !== undefined ||
259			ran.deny !== undefined
260		)
261			return ran
262		sentKey = due.key
263		void $.state.set(SENT, due.key).catch(() => {})
264		void $.session
265			.append({
266				message: {
267					type: 'system',
268					content: [
269						{
270							type: 'text',
271							text: `${tomato(options)}Pomodoro: asked Claude to pause and check in.`,
272						},
273					],
274				},
275			})
276			.catch(() => {})
277		return { ...ran, context: [...(ran.context ?? []), due.text] }
278	})
279}
280
types/index.d.ts 8 lines
1declare module 'claude-code' {
2	interface PluginState {
3		// The work period whose check-in this session was given, kept across reloads,
4		// and the count the prompt footer shows.
5		pomodoro: { sentKey: number; label: string }
6	}
7}
8