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.

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.
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.
๐
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.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.
You never start or stop the timer:
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.
Under /config, or pluginConfigs in settings.json:
| Setting | Default | What it does |
|---|---|---|
workMinutes | 25 | Minutes of work before the alert |
breakMinutes | 5 | Idle minutes that count as a break, and how often the alert repeats |
moveSeconds | 1 | Seconds the mouse has to keep moving to count (macOS); 0 counts every movement |
sound | true | Whether each alert plays a chime (macOS) |
checkIn | true | Whether Claude stops to sum up at the alert; off leaves only the alert |
emoji | true | Whether ๐
marks the count and the alerts; off, the footer reads pomodoro 12/25m |
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.A Focus silences the banner unless it allows your terminal: System Settings โ Focus โ (your Focus) โ Allowed Notifications โ Apps.
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.
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
hooks/register.ts 280 lines1import 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}
280types/index.d.ts 8 lines1declare 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