Session countdown with Pomodoro-style legs: status line, hidden time line for Claude, sound and toast at leg boundaries, leg log.

A Claude Code mod for timed work sessions: a session countdown with Pomodoro-style focus/break legs, a status line, a hidden time line Claude reads with each prompt (so it can pace its answers), a sound and toast at leg boundaries, and a per-leg log.
In a terminal Claude Code session:
/plugin install session-timer --marketplace sebastianhuus/claude-session-timer
Mods are not sandboxed. Read hooks/register.ts (one file) before installing.
/session 45 start a 45-minute session
/session 22:00 end at 22:00 (also 10pm, 9:40pm, until 22:00, tomorrow 9:00)
/session 3 f=1 b=1 legs: focus f= and break b= (default 25 / 5); units: 30s, 5m, 1.5h
/session status | stop (or end, quit) | note <text> | help
A session of 25 minutes or less is a single focus leg. Otherwise focus and break legs alternate, the last one truncated; a break that would end the session is folded into the focus leg before it (45 min = 25 focus, 5 break, 15 focus).
focus 14:20 · session 52:10.[session-timer: 21:17 · session ends 22:00, 43 min left · focus leg 1/3, 8 min left] as context for the model. Your typed text is not changed.afplay, plays the system Glass sound in place; nothing bundled), a toast, and a 6 s flash in the status line.~/.claude/session-log.jsonl (at, kind, plannedMin, actualMin, optional note from /session note).State is stored as absolute timestamps in the host's store, so reloads and restarts keep the session. The sound is macOS only (afplay). On Linux and Windows the mod still works and the toast and flash still show, but there is no sound, and on Windows the leg log is probably skipped too (untested; failures are ignored, never fatal).
claude plugin validate .
claude plugin test .
A colored band above the prompt, a desktop notification, a model-callable tool, pausing during breaks, and calibrating realistic work per duration from the log.
hooks/register.ts 248 lines1import type { Engine, Register } from 'claude-code'
2
3// session-timer v1: a session with a hard end and Pomodoro-style legs.
4// State is absolute timestamps in $.store, so reloads and restarts lose nothing.
5
6type Leg = { kind: 'focus' | 'break'; startMs: number; endMs: number; note?: string }
7type Session = { startMs: number; endMs: number; legs: Leg[]; announced: number }
8
9const MIN = 60_000
10// The leg log lives in the user's ~/.claude; HOME is read once from the host.
11let logPath: string | undefined
12const BLINK_MS = 6_000
13const SOUND = '/System/Library/Sounds/Glass.aiff'
14const USAGE = 'Usage: /session 45 | 22:00 | 10pm | until 22:00 | tomorrow 9:00 | status | stop | end | quit | note <text> | help'
15const HELP = [
16 'session-timer commands:',
17 ' /session <minutes | time> [focus=N] [break=M] start (focus default 25, break default 5)',
18 ' shorthands: f=N for focus, b=M (or breaks=M) for break',
19 ' durations take units: 30s, 90s, 5m, 1.5h (a bare number is minutes)',
20 ' examples: /session 45 /session 22:00 /session 10pm /session 3 f=1 b=1',
21 ' /session status | stop (or end, quit) | note <text> | help',
22 'focus/break values must be written with the name; a bare second number is not read.',
23].join('\n')
24
25// Focus/break legs from start to end. A session of one focus length or less is a
26// single leg; no trailing break (a break that would end the session is folded into
27// the focus leg before it). 45 min -> 25 focus, 5 break, 15 focus.
28export function planLegs(startMs: number, endMs: number, focusMin = 25, breakMin = 5): Leg[] {
29 const legs: Leg[] = []
30 let t = startMs
31 while (t < endMs) {
32 let focusEnd = Math.min(t + focusMin * MIN, endMs)
33 if (endMs - focusEnd <= breakMin * MIN) focusEnd = endMs
34 legs.push({ kind: 'focus', startMs: t, endMs: focusEnd })
35 t = focusEnd
36 if (t >= endMs) break
37 legs.push({ kind: 'break', startMs: t, endMs: t + breakMin * MIN })
38 t += breakMin * MIN
39 }
40 return legs
41}
42
43// Where `now` stands in the session, or undefined once it is over.
44export function describe(s: Session, now: number) {
45 if (now >= s.endMs) return undefined
46 const index = Math.max(0, s.legs.findIndex(l => now < l.endMs))
47 const leg = s.legs[index]
48 return {
49 leg,
50 number: index + 1,
51 total: s.legs.length,
52 legLeftMs: leg.endMs - now,
53 sessionLeftMs: s.endMs - now,
54 }
55}
56
57const pad = (n: number) => String(n).padStart(2, '0')
58const clock = (ms: number) => `${pad(Math.floor(ms / MIN))}:${pad(Math.floor((ms % MIN) / 1000))}`
59const hhmm = (ms: number) => `${pad(new Date(ms).getHours())}:${pad(new Date(ms).getMinutes())}`
60
61export const statusText = (s: Session, now: number) => {
62 const d = describe(s, now)
63 return d ? `${d.leg.kind} ${clock(d.legLeftMs)} · session ${clock(d.sessionLeftMs)}` : undefined
64}
65
66export const promptLine = (s: Session, now: number) => {
67 const d = describe(s, now)
68 if (!d) return undefined
69 const left = (ms: number) => Math.ceil(ms / MIN)
70 return (
71 `[session-timer: ${hhmm(now)} · session ends ${hhmm(s.endMs)}, ${left(d.sessionLeftMs)} min left · ` +
72 `${d.leg.kind} leg ${d.number}/${d.total}, ${left(d.legLeftMs)} min left]`
73 )
74}
75
76type Dollar = Engine
77
78const load = async ($: Dollar) => (await $.store.get('session')) as Session | undefined
79
80// One JSONL line per leg: planned vs actual minutes, plus the note, if any.
81async function logLeg($: Dollar, leg: Leg, endedMs: number) {
82 const line = JSON.stringify({
83 at: new Date(endedMs).toISOString(),
84 kind: leg.kind,
85 plannedMin: Math.round((leg.endMs - leg.startMs) / MIN),
86 actualMin: Math.round((Math.min(endedMs, leg.endMs) - leg.startMs) / MIN),
87 note: leg.note,
88 })
89 if (!logPath) {
90 const { stdout } = await $.process.run(['printenv', 'HOME'])
91 logPath = `${stdout.trim()}/.claude/session-log.jsonl`
92 }
93 const old = await $.fs.read(logPath).catch(() => '')
94 await $.fs.write(logPath, `${old}${old && !old.endsWith('\n') ? '\n' : ''}${line}\n`)
95}
96
97// A duration in minutes: "45" and "45m" (minutes), "30s", "1.5h"; units s/sec/seconds,
98// m/min/minutes, h/hr/hours, no space. A bare number is minutes.
99export function parseDuration(text: string): number | undefined {
100 const m = /^(\d+(?:\.\d+)?)\s*(s|secs?|seconds?|m|mins?|minutes?|h|hrs?|hours?)?$/i.exec(text.trim())
101 if (!m) return undefined
102 const n = Number(m[1])
103 const unit = (m[2] ?? 'm')[0].toLowerCase()
104 return unit === 's' ? n / 60 : unit === 'h' ? n * 60 : n
105}
106
107// focus=N / f=N and break=M / breaks=M / b=M, in any position; `spec` is what is left
108// (the duration or target time). Defaults: focus 25, break 5.
109export function parseLegArgs(args: string) {
110 const grab = (names: string) => {
111 const m = new RegExp(`(?:^|\\s)(?:${names})=(\\S+)(?=\\s|$)`, 'i').exec(args)
112 return m ? parseDuration(m[1]) : undefined
113 }
114 const spec = args
115 .replace(/(?:^|\s)(?:focus|f|breaks?|b)=\S+/gi, ' ')
116 .replace(/\s+/g, ' ')
117 .trim()
118 return { focus: grab('focus|f') ?? 25, rest: grab('breaks?|b') ?? 5, spec }
119}
120
121// A target time of day: "22:00", "until 22", "10pm", "9:40 pm", "9.40pm", "tomorrow 21:00".
122// A bare number ("45") is minutes, not a time, so it is not parsed here.
123export function parseTarget(text: string) {
124 const m = /^(until\s+)?(tomorrow\s+)?(\d{1,2})(?:[:.](\d{2}))?\s*(am|pm)?$/i.exec(text.trim())
125 if (!m) return undefined
126 const [, until, tomorrow, h, min, ampm] = m
127 if (!until && !tomorrow && min === undefined && !ampm) return undefined
128 let hh = Number(h)
129 const mm = Number(min ?? 0)
130 if (ampm) {
131 if (hh < 1 || hh > 12) return undefined
132 hh = (hh % 12) + (ampm.toLowerCase() === 'pm' ? 12 : 0)
133 }
134 if (hh > 23 || mm > 59) return undefined
135 return { hh, mm, isTomorrow: Boolean(tomorrow) }
136}
137
138// Next occurrence of HH:MM (local time) after `now`; "tomorrow" is today's HH:MM plus a day.
139export function nextAt(now: number, hh: number, mm: number, isTomorrow = false) {
140 const d = new Date(now)
141 d.setHours(hh, mm, 0, 0)
142 const today = d.getTime()
143 if (isTomorrow) return today + 24 * 60 * MIN
144 return today <= now ? today + 24 * 60 * MIN : today
145}
146
147const MAX_UNTIL_MS = 6 * 60 * MIN
148
149let blinkUntil = 0
150
151async function tick($: Dollar) {
152 const s = await load($)
153 if (!s) return $.ui.status(undefined)
154 const now = await $.clock.now()
155
156 // Announce every leg whose end has passed since the last tick.
157 while (s.announced < s.legs.length && now >= s.legs[s.announced].endMs) {
158 const leg = s.legs[s.announced++]
159 await logLeg($, leg, leg.endMs).catch(() => undefined) // logging must never block the alert
160 const up = s.legs[s.announced]
161 void $.process.run(['afplay', SOUND]).catch(() => undefined) // macOS only; nothing bundled
162 void $.ui.toast(up ? `${up.kind === 'break' ? 'Break' : 'Focus'}: ${Math.round((up.endMs - up.startMs) / MIN)} min` : 'Session over')
163 blinkUntil = now + BLINK_MS
164 await $.store.set('session', s)
165 }
166
167 if (now >= s.endMs) {
168 // Hold a "session over" status for the blink window so the end is visible, then clear.
169 if (now < blinkUntil) return $.ui.status(Math.floor(now / 1000) % 2 === 0 ? '🔔 session over' : 'session over')
170 await $.store.delete('session')
171 return $.ui.status(undefined)
172 }
173
174 const text = statusText(s, now)
175 const isBlinkOn = now < blinkUntil && Math.floor(now / 1000) % 2 === 0
176 $.ui.status(isBlinkOn ? `🔔 ${text}` : text)
177}
178
179export const register: Register = on => {
180 on('session.start', async ($, e, next) => {
181 await $.command.register({
182 name: 'session',
183 description: `Timed session: ${USAGE.replace('Usage: /session ', '')}`,
184 argumentHint: USAGE.replace('Usage: /session ', ''),
185 })
186 $.clock.every(1000, () => void tick($))
187 return next(e)
188 })
189
190 on('command.run', { command: 'session' }, async ($, e) => {
191 const args = e.args.trim()
192 const s = await load($)
193 const now = await $.clock.now()
194
195 if (['help', '?', '-h', '--help'].includes(args.toLowerCase())) return { text: HELP }
196
197 if (args === '' || args === 'status') {
198 const line = s && promptLine(s, now)
199 return { text: line ?? 'No active session. Try /session 45 or /session 22:00. /session help lists everything.' }
200 }
201
202 if (['stop', 'end', 'quit'].includes(args.toLowerCase())) {
203 if (!s) return { text: 'No active session.' }
204 const d = describe(s, now)
205 if (d) await logLeg($, d.leg, now).catch(() => undefined)
206 await $.store.delete('session')
207 $.ui.status(undefined)
208 return { text: 'Session stopped.' }
209 }
210
211 if (args.startsWith('note ')) {
212 const d = s && describe(s, now)
213 if (!s || !d) return { text: 'No active session to attach a note to.' }
214 d.leg.note = args.slice(5).trim()
215 await $.store.set('session', s)
216 return { text: `Noted on ${d.leg.kind} leg ${d.number}.` }
217 }
218
219 // Start: minutes ("45") or a target time ("22:00", "10pm", "until 22:00", "tomorrow 9:00"),
220 // optionally with focus=N break=M.
221 const { focus, rest, spec } = parseLegArgs(args)
222 const target = parseTarget(spec)
223 const minutes = target ? undefined : parseDuration(spec)
224 const endMs = target
225 ? nextAt(now, target.hh, target.mm, target.isTomorrow)
226 : minutes !== undefined
227 ? now + minutes * MIN
228 : undefined
229 if (endMs === undefined) return { text: `Could not read "${args}".\n${HELP}` }
230 if (target && !target.isTomorrow && endMs - now > MAX_UNTIL_MS) {
231 const h = Math.floor((endMs - now) / (60 * MIN))
232 const m = Math.round(((endMs - now) % (60 * MIN)) / MIN)
233 return { text: `That time is ${h}h ${m}m away (it has passed today). Did you mean "tomorrow ${spec.replace(/^until\s+/i, '')}"?` }
234 }
235
236 const session: Session = { startMs: now, endMs, legs: planLegs(now, endMs, focus, rest), announced: 0 }
237 await $.store.set('session', session)
238 return { text: `Session started. ${promptLine(session, now)}` }
239 }).catch(() => ({ text: 'session-timer: that command failed; see the debug log.' }))
240
241 // The model reads the time beside each prompt; the user's typed text is untouched.
242 on('prompt.submit', async ($, e, next) => {
243 const s = await load($)
244 const line = s && promptLine(s, await $.clock.now())
245 return line ? next({ ...e, context: [...(e.context ?? []), line] }) : next(e)
246 }).catch(($, e, next) => next(e)) // a timer bug must never swallow a prompt
247}
248