A Pomodoro timer for Claude Code, shared across sessions, with a HUD in the prompt footer.

A Pomodoro timer that lives inside Claude Code. One timer is shared by every Claude Code session on your machine: start it in one terminal and every other session shows the same countdown in the prompt footer, like 🍅 focus 18:42. When a phase ends you get a macOS notification with a system sound, and during a break your prompts can be allowed, warned about or blocked.
In a Claude Code terminal session:
/plugin install pomodoro --marketplace tiemotm/claude-pomodoro
Answer y to add the marketplace, then pick a scope (user scope makes it available in every session).
| Command | What it does |
|---|---|
/pomodoro start [focus] [break] | Start a new series. Minutes are optional, e.g. /pomodoro start 50 10. |
/pomodoro next [focus] [break] | Step forward: start the break after a focus, or the next focus after a break (skips the rest of a running break). |
/pomodoro pause | Freeze the running focus or break. |
/pomodoro resume | Continue where you paused. |
/pomodoro stop | End the series. |
/pomodoro stats | Today, this week and all-time numbers. |
/pomodoro | Current status and help. |
By default nothing starts by itself: after a focus the timer waits in done until /pomodoro next starts the break, and after a break it waits in ready until /pomodoro next starts the next focus. An idle desk never counts as focus time. Set autoBreak to start breaks right away. start begins a new series, next continues one; stats track both.
Set these under /config. Values given to a command win for that series.
| Field | Default | Meaning |
|---|---|---|
focusMinutes | 25 | Focus length, 1-180 |
breakMinutes | 5 | Break length, 1-60 |
breakPrompts | warn | During a break: allow, warn (toast, prompt goes through) or block |
bypassPrefix | >> | A prompt starting with this goes through even when blocked; the prefix is removed |
notify | true | macOS notification when a phase ends |
focusEndSound | Glass | System sound when focus ends, or none |
breakEndSound | Hero | System sound when a break ends, or none |
autoBreak | false | Start the break as soon as focus ends instead of waiting for /pomodoro next |
Only prompts you type are gated. Slash commands, ! shell commands, background task notices, /loop triggers and claude -p runs always pass.
Notifications use osascript and are macOS only. The banner comes from "Script Editor": if you see nothing on first use, allow notifications for Script Editor under System Settings > Notifications. On other platforms you still get the in-app toast.
claude --plugin-dir . # run it from this folder
claude plugin test . # unit and engine tests
claude plugin validate . # manifest and hooks check
MIT
hooks/register.ts 138 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { type Config, readConfig } from './config'
5import { endText, notificationArgv } from './notify'
6import { book, EMPTY_STATS, formatStats, isStats, type Stats } from './stats'
7import { apply, formatClock, HELP, IDLE, isCommandName, isTimer, label, type Timer, type TimerEvent, tick } from './timer'
8
9const hud = atom({ plugin: 'pomodoro', key: 'hud' } as const, '')
10
11async function loadTimer($: EngineInterface): Promise<Timer> {
12 const raw = await $.store.get('timer')
13 if (raw === undefined) return IDLE
14 if (isTimer(raw)) return raw
15 await $.store.set('timer', IDLE)
16 $.ui.toast('🍅 Pomodoro state reset')
17 return IDLE
18}
19
20async function loadStats($: EngineInterface): Promise<Stats> {
21 const raw = await $.store.get('stats')
22 return isStats(raw) ? raw : EMPTY_STATS
23}
24
25// The OS banner goes out once, from the session that advanced the store.
26function notifyOs($: EngineInterface, cfg: Config, ev: TimerEvent) {
27 const body = endText(ev)
28 if (!cfg.notify) return
29 const sound = ev.kind === 'focusEnded' ? cfg.focusEndSound : cfg.breakEndSound
30 $.process
31 .run(notificationArgv(body, sound), { timeoutMs: 5000 })
32 .catch(err => $.ui.log(`notification failed: ${String(err)}`, { to: 'debug' }))
33}
34
35// A store that cannot be read must not crash the worker once a second.
36function logFailure($: EngineInterface, err: unknown) {
37 $.ui.log(`tick failed: ${String(err)}`, { to: 'debug' })
38}
39
40// What this session last saw; lets every session toast a phase end, whoever advanced the store.
41type View = { seen: Timer | null }
42
43// One tick: advance the shared timer; one session books and sends the banner, every session toasts.
44async function step($: EngineInterface, cfg: Config, view: View): Promise<Timer> {
45 const now = await $.clock.now()
46 const before = await loadTimer($)
47 const { timer, events } = tick(before, now, cfg.autoBreak)
48 let truth = timer
49 if (events.length > 0) {
50 const fresh = await loadTimer($)
51 // ponytail: read-compare-write without CAS; two sessions in the same ms can both announce
52 if (fresh.phase === before.phase && fresh.endsAt === before.endsAt) {
53 await $.store.set('timer', timer)
54 let stats = await loadStats($)
55 for (const ev of events) if (ev.kind === 'focusEnded') stats = book(stats, ev)
56 await $.store.set('stats', stats)
57 const last = events[events.length - 1]
58 if (last) notifyOs($, cfg, last)
59 } else {
60 truth = tick(fresh, now, cfg.autoBreak).timer
61 }
62 }
63 const seen = view.seen
64 if (seen) {
65 const mine = tick(seen, now, cfg.autoBreak)
66 const last = mine.events[mine.events.length - 1]
67 if (last && truth.seriesId === seen.seriesId && truth.phase === mine.timer.phase) $.ui.toast(`🍅 ${endText(last)}`)
68 }
69 view.seen = truth
70 await update($, hud, () => label(truth, now))
71 return truth
72}
73
74export const register: Register = (on, options) => {
75 const cfg = readConfig(options)
76 const view: View = { seen: null }
77
78 on('session.start', async ($, e, next) => {
79 await $.command.register({
80 name: 'pomodoro',
81 description: 'Pomodoro timer shared across sessions',
82 argumentHint: '[start|next|pause|resume|stop|stats] [focus] [break]',
83 })
84 let isBusy = false
85 $.clock.every(1000, () => {
86 if (isBusy) return
87 isBusy = true
88 void step($, cfg, view)
89 .catch(err => logFailure($, err))
90 .finally(() => {
91 isBusy = false
92 })
93 })
94 await step($, cfg, view).catch(err => logFailure($, err))
95 return next(e)
96 })
97
98 on('command.run', { command: 'pomodoro' }, async ($, e) => {
99 const [sub = '', ...args] = e.args.trim().split(/\s+/).filter(Boolean)
100 const t = await step($, cfg, view)
101 const now = await $.clock.now()
102 if (sub === '' || sub === 'status') return { text: `${label(t, now) || 'No pomodoro running.'}\n${HELP}` }
103 if (sub === 'stats') return { text: formatStats(await loadStats($), now) }
104 if (!isCommandName(sub)) return { text: `Unknown command "${sub}". ${HELP}` }
105 const { timer, text } = apply(t, sub, args, now, cfg.defaults)
106 if (timer !== t) {
107 view.seen = timer
108 await $.store.set('timer', timer)
109 await update($, hud, () => label(timer, now))
110 }
111 return { text }
112 })
113
114 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
115 const text = await read($, hud)
116 return text ? next({ ...e, props: { ...e.props, modes: [...e.props.modes, text] } }) : next(e)
117 })
118
119 on('prompt.submit', async ($, e, next) => {
120 // Only what a person typed is gated; task notices, /loop triggers, peers and -p runs pass.
121 const isTyped = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
122 const text = e.text.trimStart()
123 if (!isTyped || text.startsWith('/') || text.startsWith('!')) return next(e)
124 const hasPrefix = text.startsWith(cfg.bypassPrefix)
125 const rest = hasPrefix ? text.slice(cfg.bypassPrefix.length).trimStart() : e.text
126 if (hasPrefix && rest === '') return { drop: `Nothing to send after ${cfg.bypassPrefix}.` }
127 const now = await $.clock.now()
128 const t = tick(await loadTimer($), now, cfg.autoBreak).timer
129 if (t.phase !== 'break' || hasPrefix || cfg.breakPrompts === 'allow') return next({ ...e, text: rest })
130 const left = formatClock((t.endsAt ?? now) - now)
131 if (cfg.breakPrompts === 'warn') {
132 $.ui.toast(`🍅 On break, ${left} left`)
133 return next(e)
134 }
135 return { drop: `🍅 On break (${left} left). Prefix with ${cfg.bypassPrefix} to send anyway.` }
136 })
137}
138hooks/config.ts 39 lines1import type { PluginOptions } from 'claude-code'
2
3import { SOUNDS } from './notify'
4import type { Durations } from './timer'
5
6export type BreakPrompts = 'allow' | 'warn' | 'block'
7
8export type Config = {
9 defaults: Durations
10 breakPrompts: BreakPrompts
11 bypassPrefix: string
12 notify: boolean
13 focusEndSound: string
14 breakEndSound: string
15 autoBreak: boolean
16}
17
18const minutes = (v: unknown, fallback: number, max: number) => {
19 const n = Number(v)
20 return Number.isInteger(n) && n >= 1 && n <= max ? n : fallback
21}
22
23const sound = (v: unknown, fallback: string) =>
24 v === 'none' || (SOUNDS as readonly unknown[]).includes(v) ? (v as string) : fallback
25
26export function readConfig(o: PluginOptions): Config {
27 const mode = o.breakPrompts
28 const prefix = typeof o.bypassPrefix === 'string' ? o.bypassPrefix.trim() : ''
29 return {
30 defaults: { focusMin: minutes(o.focusMinutes, 25, 180), breakMin: minutes(o.breakMinutes, 5, 60) },
31 breakPrompts: mode === 'allow' || mode === 'block' ? mode : 'warn',
32 bypassPrefix: prefix || '>>',
33 notify: o.notify !== false && o.notify !== 'false',
34 focusEndSound: sound(o.focusEndSound, 'Glass'),
35 breakEndSound: sound(o.breakEndSound, 'Hero'),
36 autoBreak: o.autoBreak === true || o.autoBreak === 'true',
37 }
38}
39hooks/notify.ts 20 lines1import type { TimerEvent } from './timer'
2
3export const SOUNDS = [
4 'Basso', 'Blow', 'Bottle', 'Frog', 'Funk', 'Glass', 'Hero',
5 'Morse', 'Ping', 'Pop', 'Purr', 'Sosumi', 'Submarine', 'Tink',
6] as const
7export const TITLE = '🍅 Pomodoro'
8
9const quote = (s: string) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
10
11export function notificationArgv(body: string, sound: string): string[] {
12 const withSound = (SOUNDS as readonly string[]).includes(sound) ? ` sound name ${quote(sound)}` : ''
13 return ['osascript', '-e', `display notification ${quote(body)} with title ${quote(TITLE)}${withSound}`]
14}
15
16export function endText(ev: TimerEvent): string {
17 if (ev.kind === 'breakEnded') return 'Break over. Run /pomodoro next.'
18 return ev.startsBreak ? `Focus done. Take a ${ev.breakMin} min break.` : 'Focus done. Run /pomodoro next to start your break.'
19}
20hooks/stats.ts 71 lines1export type Day = { pomodoros: number; focusMin: number; series: number }
2export type Stats = { days: Record<string, Day>; totalPomodoros: number; totalSeries: number; longestSeries: number }
3
4export const EMPTY_STATS: Stats = { days: {}, totalPomodoros: 0, totalSeries: 0, longestSeries: 0 }
5const KEEP_DAYS = 365
6const NO_DAY: Day = { pomodoros: 0, focusMin: 0, series: 0 }
7
8const pad = (n: number) => String(n).padStart(2, '0')
9
10export function dateKey(ms: number): string {
11 const d = new Date(ms)
12 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
13}
14
15// Calendar arithmetic, not ms arithmetic, so DST days stay one day long.
16function daysBefore(ms: number, n: number): string {
17 const d = new Date(ms)
18 return dateKey(new Date(d.getFullYear(), d.getMonth(), d.getDate() - n).getTime())
19}
20
21export function book(s: Stats, ev: { at: number; seriesCount: number; focusMin: number }): Stats {
22 const key = dateKey(ev.at)
23 const day = s.days[key] ?? NO_DAY
24 const isNewSeries = ev.seriesCount === 1 ? 1 : 0
25 const cutoff = daysBefore(ev.at, KEEP_DAYS)
26 const days = Object.fromEntries(
27 Object.entries({
28 ...s.days,
29 [key]: { pomodoros: day.pomodoros + 1, focusMin: day.focusMin + ev.focusMin, series: day.series + isNewSeries },
30 }).filter(([k]) => k >= cutoff),
31 )
32 return {
33 days,
34 totalPomodoros: s.totalPomodoros + 1,
35 totalSeries: s.totalSeries + isNewSeries,
36 longestSeries: Math.max(s.longestSeries, ev.seriesCount),
37 }
38}
39
40export function isStats(x: unknown): x is Stats {
41 if (typeof x !== 'object' || x === null) return false
42 const s = x as Record<string, unknown>
43 return (
44 typeof s.days === 'object' && s.days !== null && !Array.isArray(s.days) &&
45 typeof s.totalPomodoros === 'number' &&
46 typeof s.totalSeries === 'number' &&
47 typeof s.longestSeries === 'number'
48 )
49}
50
51const pomodoros = (n: number) => `${n} pomodoro${n === 1 ? '' : 's'}`
52
53export function formatStats(s: Stats, now: number): string {
54 const today = s.days[dateKey(now)] ?? NO_DAY
55 const sinceMonday = (new Date(now).getDay() + 6) % 7
56 let weekPomodoros = 0
57 let weekMinutes = 0
58 for (let i = 0; i <= sinceMonday; i++) {
59 const d = s.days[daysBefore(now, i)]
60 if (d) {
61 weekPomodoros += d.pomodoros
62 weekMinutes += d.focusMin
63 }
64 }
65 return [
66 `Today: ${pomodoros(today.pomodoros)}, ${today.focusMin} min focus, ${today.series} series`,
67 `This week: ${pomodoros(weekPomodoros)}, ${weekMinutes} min focus`,
68 `All time: ${pomodoros(s.totalPomodoros)}, longest series ${s.longestSeries}`,
69 ].join('\n')
70}
71hooks/timer.ts 179 lines1export type Phase = 'idle' | 'focus' | 'done' | 'break' | 'ready' | 'paused'
2type Running = 'focus' | 'break'
3
4export type Timer = {
5 phase: Phase
6 endsAt: number | null
7 pausedPhase: Running | null
8 remainingMs: number | null
9 seriesId: number | null
10 seriesCount: number
11 focusMin: number
12 breakMin: number
13}
14
15export type Durations = { focusMin: number; breakMin: number }
16
17export type TimerEvent =
18 | { kind: 'focusEnded'; at: number; seriesCount: number; focusMin: number; breakMin: number; startsBreak: boolean }
19 | { kind: 'breakEnded'; at: number }
20
21export type CommandName = 'start' | 'next' | 'pause' | 'resume' | 'stop'
22
23export const MINUTE = 60_000
24const MAX_FOCUS = 180
25const MAX_BREAK = 60
26const PHASES: readonly string[] = ['idle', 'focus', 'done', 'break', 'ready', 'paused']
27const COMMANDS: readonly string[] = ['start', 'next', 'pause', 'resume', 'stop']
28
29export const IDLE: Timer = {
30 phase: 'idle',
31 endsAt: null,
32 pausedPhase: null,
33 remainingMs: null,
34 seriesId: null,
35 seriesCount: 0,
36 focusMin: 25,
37 breakMin: 5,
38}
39
40export const HELP =
41 'Commands: /pomodoro start [focus] [break], next [focus] [break], pause, resume, stop, stats'
42
43const EXAMPLE = 'Example: /pomodoro start 50 10'
44
45function toMinutes(raw: string, max: number): number | null {
46 if (!/^\d+$/.test(raw)) return null
47 const n = Number(raw)
48 return n >= 1 && n <= max ? n : null
49}
50
51export function parseDurations(args: readonly string[], base: Durations): Durations | { error: string } {
52 if (args.length > 2) return { error: `Too many values. ${EXAMPLE}` }
53 const [focus, brk] = args
54 const focusMin = focus === undefined ? base.focusMin : toMinutes(focus, MAX_FOCUS)
55 if (focusMin === null) return { error: `Focus must be a whole number of minutes from 1 to ${MAX_FOCUS}. ${EXAMPLE}` }
56 const breakMin = brk === undefined ? base.breakMin : toMinutes(brk, MAX_BREAK)
57 if (breakMin === null) return { error: `Break must be a whole number of minutes from 1 to ${MAX_BREAK}. ${EXAMPLE}` }
58 return { focusMin, breakMin }
59}
60
61export function formatClock(ms: number): string {
62 const s = Math.max(0, Math.ceil(ms / 1000))
63 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
64}
65
66export function label(t: Timer, now: number): string {
67 switch (t.phase) {
68 case 'idle':
69 return ''
70 case 'focus':
71 case 'break':
72 return `🍅 ${t.phase} ${formatClock((t.endsAt ?? now) - now)}`
73 case 'done':
74 return '🍅 done (next)'
75 case 'ready':
76 return '🍅 ready (next)'
77 case 'paused':
78 return `🍅 paused ${formatClock(t.remainingMs ?? 0)}`
79 }
80}
81
82export function isCommandName(s: string): s is CommandName {
83 return COMMANDS.includes(s)
84}
85
86export function isTimer(x: unknown): x is Timer {
87 if (typeof x !== 'object' || x === null) return false
88 const t = x as Record<string, unknown>
89 const numOrNull = (v: unknown) => v === null || typeof v === 'number'
90 return (
91 PHASES.includes(t.phase as string) &&
92 numOrNull(t.endsAt) &&
93 numOrNull(t.remainingMs) &&
94 numOrNull(t.seriesId) &&
95 (t.pausedPhase === null || t.pausedPhase === 'focus' || t.pausedPhase === 'break') &&
96 typeof t.seriesCount === 'number' &&
97 typeof t.focusMin === 'number' &&
98 typeof t.breakMin === 'number'
99 )
100}
101
102export function apply(
103 t: Timer,
104 name: CommandName,
105 args: readonly string[],
106 now: number,
107 defaults: Durations,
108): { timer: Timer; text: string } {
109 const refuse = (text: string) => ({ timer: t, text })
110 switch (name) {
111 case 'start': {
112 const d = parseDurations(args, defaults)
113 if ('error' in d) return refuse(d.error)
114 return {
115 timer: { ...IDLE, ...d, phase: 'focus', endsAt: now + d.focusMin * MINUTE, seriesId: now },
116 text: `🍅 Focus for ${d.focusMin} min, then a ${d.breakMin} min break.`,
117 }
118 }
119 case 'next': {
120 if (t.phase === 'idle') return refuse('No active series. Use /pomodoro start.')
121 if (t.phase === 'paused') return refuse('Paused. Use /pomodoro resume first.')
122 if (t.phase === 'focus') return refuse('Focus is still running. Use /pomodoro stop to end the series.')
123 const d = parseDurations(args, t)
124 if ('error' in d) return refuse(d.error)
125 if (t.phase === 'done') {
126 return {
127 timer: { ...t, ...d, phase: 'break', endsAt: now + d.breakMin * MINUTE },
128 text: `🍅 Break for ${d.breakMin} min.`,
129 }
130 }
131 return {
132 timer: { ...t, ...d, phase: 'focus', endsAt: now + d.focusMin * MINUTE },
133 text: `🍅 Focus for ${d.focusMin} min (pomodoro ${t.seriesCount + 1} of this series).`,
134 }
135 }
136 case 'pause': {
137 if (t.phase !== 'focus' && t.phase !== 'break') return refuse('Nothing to pause.')
138 const remainingMs = Math.max(0, (t.endsAt ?? now) - now)
139 return {
140 timer: { ...t, phase: 'paused', pausedPhase: t.phase, remainingMs, endsAt: null },
141 text: `🍅 Paused with ${formatClock(remainingMs)} left. Use /pomodoro resume.`,
142 }
143 }
144 case 'resume': {
145 if (t.phase !== 'paused' || t.pausedPhase === null) return refuse('Nothing to resume. Use /pomodoro start.')
146 const left = t.remainingMs ?? 0
147 return {
148 timer: { ...t, phase: t.pausedPhase, endsAt: now + left, pausedPhase: null, remainingMs: null },
149 text: `🍅 Resumed ${t.pausedPhase}, ${formatClock(left)} left.`,
150 }
151 }
152 case 'stop': {
153 if (t.phase === 'idle') return refuse('Nothing to stop.')
154 return { timer: { ...IDLE, focusMin: t.focusMin, breakMin: t.breakMin }, text: '🍅 Stopped. Series ended.' }
155 }
156 }
157}
158
159// autoBreak off: a finished focus waits in 'done' until /pomodoro next starts the break.
160export function tick(t: Timer, now: number, autoBreak: boolean): { timer: Timer; events: TimerEvent[] } {
161 const events: TimerEvent[] = []
162 let cur = t
163 for (;;) {
164 if (cur.phase === 'focus' && cur.endsAt !== null && now >= cur.endsAt) {
165 const seriesCount = cur.seriesCount + 1
166 const ended = { kind: 'focusEnded', at: cur.endsAt, seriesCount, focusMin: cur.focusMin, breakMin: cur.breakMin } as const
167 events.push({ ...ended, startsBreak: autoBreak })
168 cur = autoBreak
169 ? { ...cur, phase: 'break', seriesCount, endsAt: cur.endsAt + cur.breakMin * MINUTE }
170 : { ...cur, phase: 'done', seriesCount, endsAt: null }
171 } else if (cur.phase === 'break' && cur.endsAt !== null && now >= cur.endsAt) {
172 events.push({ kind: 'breakEnded', at: cur.endsAt })
173 cur = { ...cur, phase: 'ready', endsAt: null }
174 } else {
175 return { timer: cur, events }
176 }
177 }
178}
179types/index.d.ts 8 lines1export type HudLabel = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'pomodoro': { hud: HudLabel }
6 }
7}
8