screen-pomodoro
v0.5.2
Published
A tiny CLI pomodoro timer that pauses when your screen locks.
Maintainers
Readme
screen-pomodoro
A tiny CLI pomodoro timer that pauses when your screen locks.
- Runnable via
npx— no install step required - Fully typed TypeScript (strict, ESM), tested with Vitest
What is the Pomodoro Technique?
The Pomodoro Technique is a simple time-management method created by Francesco Cirillo in the late 1980s (named after his tomato-shaped kitchen timer — pomodoro is Italian for tomato). The idea:
- Pick one task and focus on it for a fixed interval (traditionally 25 minutes).
- Take a short break (traditionally 5 minutes) to rest and reset.
- After every few focus sessions (traditionally 4), take a longer break (15–30 minutes).
Working in short, deliberate bursts makes it easier to start, stay focused, and avoid burnout — and the regular breaks give your brain a chance to recharge.
screen-pomodoro follows exactly this rhythm out of the box: it loops
Focus → Short break, inserting a Long break after every --cycles
focuses (4 by default), with the classic 25 / 5 / 15 minute defaults.
The twist: it pauses automatically when your screen locks (macOS —
Linux/Windows keep running; support planned; --no-screen-pause opts out),
so a coffee run or a chat never eats into your focus time.
Quick start
# Run without installing
npx screen-pomodoro
# Show usage
npx screen-pomodoro --helpCLI
Usage: screen-pomodoro [options]
A pomodoro timer that pauses when your screen locks.
Options:
-V, --version Print the version number.
--focus <duration> Focus duration (minutes or with s/m/h suffix). (default: "25")
--short <duration> Short break duration (minutes or with s/m/h suffix). (default: "5")
--long <duration> Long break duration (minutes or with s/m/h suffix). (default: "15")
--cycles <n> Focuses per long break (integer >= 1). Loops forever unless --no-loop is given. (default: "4")
--no-loop Stop after the first long break (with the default start, after --cycles focuses) instead of looping forever.
--start <phase> Starting phase: focus, short, or long (aliases short-break, long-break). With --confirm/--notify-confirm, omit to choose at startup.
-q, --quiet Log transitions only, no live countdown.
--timestamp Prefix history lines with the current time ([HH:MM:SS]).
--confirm Awaits y/n on each phase transition (requires interactive stdin).
--notify Send a macOS notification on each phase transition (macOS + terminal-notifier required).
--notify-confirm Answer phase transitions by clicking the notification (click = start next, Restart = redo). Implies the confirm gate; does not require interactive stdin.
--no-screen-pause Do not pause when the screen locks.
--no-bell Disable the terminal bell (\x07) on phase changes and prompts.
--notify-group <id> Notification group for overlapping timers (default shares one toast).
--focus-name <name> Custom label for focus phases. (default: "Focus")
--short-name <name> Custom label for short breaks. (default: "Short break")
--long-name <name> Custom label for long breaks. (default: "Long break")
-h, --help display help for commandCustom names appear in every phase line, pause/resume notice, confirm prompt,
and summary (--focus-name "Deep work" → Deep work 1/4,
Completed 3 Deep work, 2 Coffee, 1 Lunch). Names must be non-empty after trimming, at most 40
characters, with no control characters. The exit summary always reports
completed focuses plus short and long breaks
(Completed 2 focuses, 1 short breaks, 0 long breaks).
With --confirm, each deadline rings once (unless --no-bell) and asks:
Deep work complete. Start Coffee? [y/n] yy advances one phase, n restarts the current phase with a full deadline.
Anything else re-prompts. --confirm needs an interactive terminal
(--confirm requires an interactive terminal otherwise). --no-loop still
exits after the long break without a trailing prompt.
--cycles sets the long-break cadence; without --no-loop the timer loops
forever (--cycles 2 --no-loop runs 2 focuses then exits).
--start <phase> begins in that phase instead of focus: focus (default),
short (short-break), or long (long-break, case-insensitive). With
--confirm and no --start, the timer asks once at startup
(Choose starting phase: 1) Focus 2) Short break 3) Long break [1] —
1/2/3, f/s/l, names, or empty for Focus; anything else re-asks)
and then gates each transition as usual, so opening on a break flows into
the first focus via Short break complete. Start Focus? [y/n]. With
--notify-confirm and no --start, the timer shows one toast with three
actions (the phase labels, custom-name aware) instead — click a button to
start there, click the body for Focus; dismissing re-sends until answered.
An explicit
--start skips the question under either gate.
Without a gate the timer just auto-flows from the start phase;
--no-loop --start long runs one long break then exits.
--timestamp prefixes every history line (phase lines, pause/resume, summary,
confirm prompt) with the current local time ([14:03:22] Focus 1/4 — …).
The ephemeral live countdown stays unstamped — wall-clock seconds and
remaining seconds flip on different boundaries, so stamping it would show
two clocks ticking out of phase. Notifications are unchanged (the OS
already timestamps those).
macOS notifications (terminal-notifier)
macOS only — Linux/Windows use of either flag is a usage error (exit 1). Install the notifier once:
brew install terminal-notifier--notifysends one fire-and-forget toast per transition (startup included) alongside the usual bell + phase line, in both live and quiet modes. Pass--no-bellfor toast + sound only (single chime instead of two). A missed toast never kills the timer (one stderr note, then bell+text). Nothing fires while the screen is locked (skipped, not queued — the next entry replaces in place), and locking withdraws the visible toast.--notify-confirmreplaces the stdin gate: each deadline shows a toast (<current> complete. Start <next>?/<spent> spent. Click for <next> — <upcoming>, Restart <current> to redo.). Click the body to start next, theRestart <current>button to redo. Dismissing the toast re-sends it until answered (same group, no stacking) — the toast equivalent of invalid stdin input. Clearing it from Notification Center (including Clear All) re-sends it the same way, so the timer never waits behind a toast that is no longer there. A prompt owed across a screen lock is likewise re-sent on unlock, so the click still counts exactly once. Without--start, one startup toast offers the three phases first (click = Focus) before the first phase line.--notify-confirmcannot be combined with--confirmor--notify(one source only), and unlike--confirmit works without a TTY.- Overlapping timers: by default all timers share one group, so the second
timer's toast replaces the first's. Pass distinct
--notify-group <id>values (e.g.--notify-group workvs--notify-group stretch) to keep each timer's toasts, prompts, and cleanup isolated. Requires--notifyor--notify-confirm; at most 64 characters, no control characters. - Caveats: Focus/DnD holds the toast (timers stay frozen, as with stdin);
over SSH / launchd-as-root delivery fails (exit 4) →
--notifylogs and continues,--notify-confirmresolves the pending promptfalsewith a stderr note instead of hanging. A comma inside a custom phase name splits the notification buttons (terminal-notifiercomma-splits-actionwith no escaping) — avoid commas in--*-namewhen using--notify-confirm. A toast-body click is reported by an-execute /bin/sleep 0.3click action instead of@ACTIONCLICKED, which keeps the notifier alive past macOS' own activation ofterminal-notifier.appon that click — without it the app has already exited and macOS shows a strayThe application "terminal-notifier.app" is not open anymore.(orcan't be opened. -609) dialog. The click still counts as exactly one answer.
Screen lock (macOS)
macOS only — Linux/Windows keep running; support planned. --no-screen-pause
opts out (no polling at all, quiet mode stays fully idle).
- How it works: polls
/usr/sbin/ioreg -n Root -d1every 2s (execFileonly, no new dependencies). Locked ="IOConsoleLocked" = Yes(or defensiveCGSSessionScreenIsLocked = Yeswhen present); anything else means active. Pause lands within ~2s of locking, resume on unlock — in live and quiet modes, reusing the pause/resume copy with no new strings and no bell. Both modes leave aPaused/Resumedpair in scrollback; live suspends its countdown ticks while paused and commits each history row over the countdown row, so scrollback holds one row per event.-qlogs the pair only (no countdown). - Fail open: probe errors, parse misses, or a missing binary mean "assume active, timer runs" (one stderr note until the next success, never spam). A missed lock overcounts seconds; a false lock would freeze a focus — the costs are asymmetric, so V1 biases toward running.
- Password grace period (Settings → Lock Screen → "Require password after…"): the console is genuinely unlocked until the grace expires, so the timer correctly keeps running (latency = grace + ≤1 poll).
- SSH:
ioregreads kernel state, so a timer in an SSH session correctly pauses when the desk locks. - Password-less screensaver, display sleep without a password, and idle-while-thinking do not pause — "keep running" is the right lock-semantics answer there, and idle-time pause is out of scope.
- Fast-user-switch scoping is unverified and deferred.
- Rapid lock↔unlock within one interval coalesces silently; lock while
--confirmis pending is a no-op and the answer still wins (a pending--notify-confirmtoast is re-sent on unlock so it stays answerable). Starting while locked freezes immediately instead of running until the first poll. - Lid-close sleep freezes the process; on wake a >5s wall-clock jump probes before ticking so a lock-freeze lands before any phase cascade (unlocked- on-wake cascade on no-password machines is a known V1 limitation).
Development
Requires Node.js >= 22.
npm install # also installs git hooks (husky)
npm run dev # run the CLI from source
npm run dev -- --help # …with arguments| Script | What it does |
| ----------------------------------------------- | ----------------------------------------------------------- |
| npm run build | Bundle to dist/ with tsdown (ESM) |
| npm run dev | Run the CLI from source with tsx |
| npm run typecheck | tsc --noEmit |
| npm run lint | ESLint (flat config, type-checked rules) |
| npm run format / format:check | Prettier |
| npm run test / test:watch / test:coverage | Vitest |
| npm run verify | typecheck + lint + format:check + test + build (runs in CI) |
Pre-commit, lint-staged lints and formats staged files via husky.
