Skip to main content

Codex Reset Watch

PyPI

Published on PyPI as codex-reset-watch — uv tool install codex-reset-watch (see §2).

Cross-platform (macOS/Linux/Windows) monitor for codex-resets.com, with Telegram notifications sent directly via the Bot API. The Telegram layer is telegram_kit, a separate stdlib-only package this project depends on and other projects can too.

Tip: crw is a short alias for codex-reset-watch — every command works with either name (crw check = codex-reset-watch check). This README uses crw.

Unofficial. Not affiliated with codex-resets.com — an independent client built on its public API. All reset data and forecasts come from there.

When a public reset signal is found, crw check sends its status and type, estimated reset time and countdown, source message and announcement link, Codex Resets link, and check time. The notification also makes clear that this is a third-party public forecast: an individual Codex quota may reset at a different time.

New-reset and upcoming-reset notices arrive as one photo message: the text is the caption of an image picked at random from src/codex_reset_watch/assets/reset/ or assets/upcoming/. To add more images, drop .jpeg/.jpg/.png files into those folders. Other notices stay text-only.

Example reset-signal notification:

Codex Reset Watch Telegram notification showing a scheduled regular reset

Example Telegram configuration (crw --config):

crw --config Telegram notification example

Everything is tuned through one keyboard-driven menu, crw config. It opens on the Basic tab (the settings most people touch); Tab switches to Advanced (every setting). Both tabs are always on screen with their row counts, and the rule underlines the one you are on. Full reference: §4.

Windows PowerShell and cmd use plain signs such as [OK], [ERROR], *, ASCII arrows and separators in all console messages and menus. This also works with legacy console code pages; unsupported text is escaped rather than crashing. macOS/Linux keep the original emoji and symbols. Telegram messages and JSON exports keep their original Unicode on every platform. Powerline glyphs in your shell prompt are controlled by your shell theme, outside crw.

crw config — Basic mode

This version is uv-native:

  • project/dependency metadata: pyproject.toml
  • locked resolution: uv.lock
  • preferred Python: .python-version (3.13)
  • runtime: uv-managed Python
  • installed CLI: uv tool install codex-reset-watch from PyPI
  • CLI entry points: codex-reset-watch and crw — crw is a short alias that works exactly the same (crw check = codex-reset-watch check), and --help says so
  • the OS scheduler calls the installed uv-tool executable directly; it does not depend on shell activation or .venv

Native OS scheduling backend, chosen automatically by scripts/install.py:

OS Scheduler Renderer
macOS launchd scripts/render_launchd.py
Linux systemd --user timers scripts/render_systemd.py
Windows Task Scheduler (schtasks) scripts/schtasks.py

Installed paths

Purpose macOS Linux Windows
Config ~/Library/Application Support/codex-reset-watch/config.json $XDG_CONFIG_HOME/codex-reset-watch/config.json (default ~/.config/...) %APPDATA%\codex-reset-watch\config.json
State ~/Library/Application Support/codex-reset-watch/state.json $XDG_STATE_HOME/codex-reset-watch/state.json (default ~/.local/state/...) %LOCALAPPDATA%\codex-reset-watch\state.json
Logs ~/Library/Logs/codex-reset-watch/ $XDG_STATE_HOME/codex-reset-watch/log/ %LOCALAPPDATA%\codex-reset-watch\Logs\
Scheduler units ~/Library/LaunchAgents/codex-reset-watch.{daily,monitor}.plist ~/.config/systemd/user/codex-reset-watch-{daily,monitor}.{service,timer} Task Scheduler tasks CodexResetWatchDaily / CodexResetWatchMonitor
CLI ~/.local/bin/{codex-reset-watch,crw} ~/.local/bin/{codex-reset-watch,crw} %USERPROFILE%\.local\bin\{codex-reset-watch,crw}.exe

Every Telegram notice names the device that sent it (e.g. 🖥️ Mac mini · a1b2********; the id is derived from the MAC address, so only its first 4 hex chars are shown; replace the whole line with your own text via the device_label setting). On macOS/Linux, the device-label editor accepts Unicode text and emoji, such as 💻 Work laptop or 👩🏽‍💻 Workstation, up to 64 Unicode code points (a combined emoji can count as several code points). Scheduled notices then end with the job that sent them and the absolute path to events.jsonl in the configured log folder — launchd: codex-reset-watch.monitor on macOS, systemd: codex-reset-watch-monitor.timer on Linux, Task Scheduler: CodexResetWatchMonitor on Windows (or the daily equivalent).

Resolution logic lives in src/codex_reset_watch/paths.py (defaults) and config.py (overrides). Override any of the three with the env vars CRW_CONFIG, CRW_STATE_DIR, CRW_LOG_DIR, or set state/log folder via crw config (the state_dir/log_dir settings — env vars win if both are set). CRW_CONFIG has no config-file equivalent, since it names the config file itself.

~ is used in documentation and user-facing output. Scheduler job files/tasks contain expanded absolute paths — none of launchd/systemd/Task Scheduler expand ~.

1. Install uv

If uv is already installed, skip this section.

macOS (Homebrew):

brew install uv

Linux/macOS (Astral's official installer):

curl -LsSf https://astral.sh/uv/install.sh | sh

Windows (PowerShell):

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Verify:

uv --version

2. Install Codex Reset Watch

The distribution is codex-reset-watch on PyPI; it installs both commands, codex-reset-watch and crw. The same commands work on macOS, Linux and Windows:

uv tool install codex-reset-watch            # latest release
uv tool install codex-reset-watch==X.Y.Z     # pin to a specific version
uv tool update-shell                         # first install only: put ~/.local/bin on PATH

Replace X.Y.Z with the release version you want; re-running either install command switches an existing install to that version. Then open a new terminal and finish the setup the source installer below would otherwise do for you:

crw config           # Telegram bot token + chat id (Basic tab), schedule, language
crw apply-schedule   # register the launchd / systemd --user / Task Scheduler jobs
crw doctor           # every line should be ✅
crw check            # first real run

crw config re-applies the schedule by itself when you change a schedule setting there; crw apply-schedule is the explicit step for a first install that keeps the defaults.

From source (installer script)

One command, identical on macOS, Linux and Windows — run it from a clone of the repository (or the extracted source archive of a GitHub release). The installer asks for your Telegram bot token and chat id interactively partway through (see below); nothing needs to be exported beforehand.

uv run python scripts/install.py

That single command installs the CLI from that folder, puts it on your PATH, stores the Telegram credentials and registers the OS scheduler — there is no separate step to forget. Then open a new terminal (the PATH entry only applies to shells started afterwards) and verify:

crw doctor      # every line should be ✅
crw check       # first real run

make install / make test are thin wrappers over the same script, available on macOS and Linux if you prefer them; Windows has no make and does not need it.

Step 3: the Telegram prompt

Set up Telegram notifications now?
  You'll need a bot token from @BotFather and the chat id it should message.
  Set up now? [Y/n]: y
  Telegram chat id: -1001234567890
  Telegram bot token (hidden):
  ✅ Saved. Bot token stored in macOS Keychain; chat id in ~/Library/Application Support/codex-reset-watch/config.json.

The token is typed with echo off — nothing appears on screen as you type it, the same way a terminal hides a sudo password. This works identically on macOS, Linux and Windows because it's one call to Python's standard getpass module rather than a hand-rolled stty -echo / read -s script: getpass already knows how to suppress terminal echo on all three (via termios on POSIX, msvcrt on Windows) and always restores it afterward, Ctrl-C included, with no trap/cleanup code of our own to get wrong.

The prompt is skipped automatically — silently, so re-running the installer is a no-op here — when either credential is already reachable (stored from a previous install, or set as TG_BOT_TOKEN/TG_CHAT_ID in the environment) or when stdin isn't a terminal (CI, a piped install: there's no one to answer it). Configure or change it anytime afterward with crw config (see §4) or §8 for the environment-variable alternative.

scripts/install.py also:

  1. copies the project to $CRW_INSTALL_DIR (default ~/Documents/Workspace/codex-reset-watch)
  2. runs uv python install 3.13
  3. installs the package as a persistent isolated uv tool
  4. puts codex-reset-watch and crw in uv's own bin directory (~/.local/bin, or $UV_TOOL_BIN_DIR/$XDG_BIN_HOME if you set either) and runs uv tool update-shell so that directory is on the PATH of new shells
  5. creates config/state/log directories
  6. registers the native scheduler job for the current OS (launchd / systemd --user timers / Task Scheduler)
  7. warns if anything else already answers to the name crw / codex-reset-watch — a shell alias or function in your startup files (.zshrc, .bashrc, fish config, PowerShell profile), or another executable earlier on PATH. Those win over step 4, so a leftover alias from an older setup makes crw fail with something like zsh: no such file or directory: …/crw even though the install succeeded. Delete the line the warning points at; don't add an alias of your own — step 4 already covers it.

Expected uv tool list entry:

codex-reset-watch v0.1.0
- codex-reset-watch
- crw

3. Common commands

Immediate API check + terminal output + Telegram notification:

crw check

Example output:

crw check result output example

Alias of check:

crw update

Check without Telegram:

crw check --no-notify

Health check:

crw doctor

Recent logs:

crw logs -n 50

Force the daily path for testing:

crw daily --force

Run monitor path manually:

crw monitor

Adjust timing, notifications, Telegram, or paths (see §4):

crw config

Print the running version:

crw --version

-- is optional everywhere

Every subcommand may be written with or without leading dashes, and so may every one of its flags. These pairs are identical:

Either Or
crw config crw --config
crw config --list crw config list
crw config --set daily_time=09:00 crw config set daily_time=09:00
crw config --export ~/crw.json crw config export ~/crw.json
crw check --no-notify crw check no-notify
crw daily --force crw daily force
crw logs --lines 50 crw logs lines 50
crw config --token-stdin crw config token-stdin
crw --help crw help (also crw config help)
crw --version crw version

A bare word is only read as a flag after the subcommand that actually declares it, so crw config set export still sets a key literally named export, and anything after a bare -- is passed through exactly as typed.

4. Configuration (crw config)

Every tunable value — whether the daily notification runs at all and at what time, how often the background scan runs, notification toggles, Telegram credentials, API endpoints/timeouts, interface language, and config/state/log folder locations — lives in one schema (src/codex_reset_watch/config.py) and is editable without hand-editing JSON:

crw config                     # interactive menu (arrow keys)
crw config --list              # print current settings and exit
crw config --set scan_interval_minutes=45m --set daily_time=09:30
crw config --export ~/crw-settings.json    # portable backup, local identifiers excluded
crw config --import ~/crw-settings.json    # apply a backup, all-or-nothing

Remember that -- is optional: crw --config, crw config list and crw config export FILE all work too (see §3).

The interactive menu

crw config with no flags opens a keyboard-driven menu on a real terminal. It adapts to the window: the panel is as wide as the terminal allows (72–129 columns), a big CODEX RESET WATCH banner shows on wide windows (≥ 131 columns, ≥ 30 rows), a compact 3-row one on narrower ones (≥ 24 rows), and none on short ones. Resizing redraws the whole screen rather than leaving a broken frame behind.

Key Does
↑ ↓ move between rows
← → change a toggle (On/Off) or step through a choice (Language)
Enter toggle a switch, or open an inline editor for a typed value
Esc cancel the current edit (or quit from the row list)
Tab switch Basic ↔ Advanced mode (m in the numbered fallback menu)
a apply the OS schedule now
d reset the highlighted row to its default (asks y to confirm)
D restore every default (asks y to confirm; d in the numbered fallback menu)
e / i export / import settings — type a path, Enter
q / Ctrl-C quit

The selected row is a full-width highlighted band marked with a ▸ cursor, and it ends in the key that acts on it — ←→ when the row cycles, ⏎ when it opens an editor. That column is reserved on every row, so moving the cursor never shifts the value column. While the menu waits for a key, that cursor breathes: the glyph stays put and only its colour ramps up and down, so the animation cannot move a column. It runs on a real terminal only — piped or redirected output never animates, and stays plain text. The row's help text appears below the list, and the bottom-right counter (e.g. 4/11 in Basic mode, 4/23 in Advanced) says where you are; on a short terminal the list scrolls with ▴ N more / ▾ N more markers rather than silently hiding rows.

Each change saves immediately, and a schedule-relevant change re-applies the OS schedule on quit automatically. "Change" means the value actually differs from the one the schedule was built from — re-typing the same time, or toggling a row off and back on, re-applies nothing. The same holds for crw config --set (use --apply-schedule to force one).

Basic and Advanced mode

The menu shows a curated Basic subset by default — the settings most people actually touch (scheduling, notification toggles, Telegram, language). Advanced adds everything else: the API/HTTP tuning and the storage-path rows.

Both modes sit in the header as two tabs, with how many rows each one holds, and the panel's hairline thickens under the active one — so which view you are in, what the other one would give you, and the key that gets you there are all on screen at once:

   Basic 11   Advanced 23                                ⇥ Tab to switch
 ──━━━━━━━━──────────────────────────────────────────────────────────────

Tab switches (m in the numbered fallback menu, which shows a badge instead — there is no key to press there). The underline marks the active tab even with NO_COLOR or piped output. The choice is just another setting (ui_mode), so it is remembered across runs; a fresh install starts in Basic — see the screenshot at the top of this README.

Colour is dropped whenever stdout isn't a real terminal or NO_COLOR is set. On the classic cmd.exe/conhost window it stays on: the first colour print flips that console into ANSI mode via SetConsoleMode (ui._enable_windows_vt), one-time and best-effort, so codes render instead of printing literally. Windows Terminal and PowerShell already do this on their own.

Input is constrained at the keystroke, not just validated on save

A field only accepts what it can legally hold, so an invalid value cannot be typed in the first place — the schema check on commit is the backstop, not the first line of defence:

Field kind What the editor accepts
Time (daily_time) Digits only; the : is inserted after the hour; a lone 9 becomes 09:; an hour past 23 or a minute past 59 is refused as you type; stops at 4 digits
Interval (scan_interval_minutes) Digits, then at most one unit (m/h/d) — 2h5 and 2hd are impossible
Integers Digits only, capped to as many digits as the maximum needs and clamped to it live (99 in a 1–10 field shows 10)
Choice (language) / toggles Not typed at all — ←→ steps through the allowed values
Secret (telegram_bot_token) Any character; opens empty, and committing it empty keeps the stored token

While an editor is open, the help line shows the accepted format rather than the setting's description — HH:MM · digits only, the colon is added for you (00:00–23:59), Digits, then a unit: 30m · 2h · 1d, Whole number, 1–10.

When stdin or stdout is not a terminal (a pipe, CI, a test), the same schema is served by a numbered prompt instead — type the row number, Enter — so crw config never hangs waiting for a keypress that cannot arrive.

crw config --list (values below are an example, not your real settings):

 ◆ Codex Reset Watch · Settings                    Advanced mode v0.19.1
   ~/Library/Application Support/codex-reset-watch/config.json
 ────────────────────────────────────────────────────────────────────────

 ▍ Scheduling
    1 Daily notification ········································· On
    2 Daily time ·············································· 09:30
    3 Background scan ············································ On
    4 Scan interval ······································ 45 minutes
    5 Timezone ·········································· Asia/Taipei

 ▍ Notifications
    6 New reset events ··········································· On
    7 Upcoming reset signals ····································· On
    8 Notify on unchanged scan ··································· On
    9 Notify on unchanged day ···································· On
   10 Device label ······················· 🖥️ Mac mini · a1b2********

 ▍ Telegram
   11 Bot token ··········································· (not set)
   12 Chat ID ········································ -1001234567890

 ▍ API
   13 API base ····························· https://codex-resets.com
   14 status path ···································· /api/v1/status
   15 resets path ················ /api/v1/resets?limit=20&order=desc
   16 Timeout (seconds) ·········································· 15
   17 Retries ····················································· 3
   18 Blind alert after ··········································· 3
   19 User-Agent ·· codex-reset-watch/1.0 (+https://codex-resets.com…

 ▍ Storage
   20 State folder ······························· (platform default)
   21 Log folder ································· (platform default)
   22 Log size cap ············································ 2 MiB
   23 Log backups ················································· 3

 ▍ Interface
   24 Language ············································· English
   25 Mode ···················································· Basic
   26 Check for updates ··········································· On

 Times shown in Asia/Taipei; the OS fires each job in its own local time.

--list always shows the full (Advanced) schema, regardless of the stored mode — the badge and 25 Mode row make that explicit.

Settings

Setting Meaning Examples
daily_enabled Run the daily notification at all on / off
daily_time Wall-clock time for the daily job 10:00, 9:30
monitor_enabled Run the background scan at all on / off
scan_interval_minutes How often the background scan runs — min 1 minute, max 1 day 30m, 2h, 1d, or a bare number of minutes
timezone Timezone daily_time and rendered timestamps use UTC+8, UTC-05:30, UTC, local, or an IANA name (Asia/Taipei)
notify_new_reset_events / notify_upcoming_reset Which event types trigger a Telegram push on / off
monitor_notify_when_unchanged / daily_notify_when_unchanged Push even when nothing changed since last check on / off
device_label Device line at the end of every notice — blank = automatic (emoji, computer name, masked id); max 64 characters; never exported. The menu shows the automatic label while blank, your text once set, and r names the label it resets to Office Mac, blank
telegram_bot_token Bot API token — stored in the OS keychain, never in a file (see §8) masked as ********WXYZ
telegram_chat_id Chat that receives notifications -1001234567890
api_base, status_path, resets_path codex-resets.com endpoints —
request_timeout_seconds, request_retries HTTP client tuning —
blind_alert_after Send one Telegram notice after this many scheduled scans in a row fail, or return a payload with no readable reset event; the next one only after a scan succeeds. 0 = off. At the default 2-hour scan, 3 means about 6 hours blind 3, 0
state_dir, log_dir Override the platform-default state/log folders blank = platform default
max_log_bytes, log_backups Application log rotation —
language Menu, doctor, and Telegram notification language en, zh-TW
ui_mode Which settings crw config shows — Basic (curated) or Advanced (everything) basic, advanced
update_check After a command run in a terminal, ask whether to upgrade from PyPI when a newer release exists (see below) on / off

Update check. When a newer GitHub release exists, a command run on a keyboard-capable terminal ends with a prompt — Update now (runs the uv tool install --force codex-reset-watch==X.Y.Z && crw apply-schedule steps, installing that release from PyPI — this also moves an older git- or source-folder install over to PyPI), Skip (ask again next run), or Skip until next version — drawn with the same cursor and selection style as crw config, with a link to the release notes on GitHub. Off a keyboard terminal (piped output, CI) it stays the old two-line hint on stderr, plus the release-notes link, instead. The GitHub request runs in the background while the command works, at most once every 10 minutes (cached as update-check.json in the state folder, 0.8 s timeout); offline, piped output and the scheduled jobs stay silent, and the exit code never changes. Turn it off with crw config --set update_check=off. Every way of running crw / codex-reset-watch gets this — any subcommand, --help, --version, no arguments, or a mistyped command.

Language

The menu, help text, validation errors, the doctor summary, and Telegram/console notification bodies are all available in English and Traditional Chinese — no third "auto" setting. The default, until you pick one, follows the system locale at first use — zh_TW, zh_HK, zh_Hant and zh_MO resolve to 繁體中文, everything else (including no match at all) to English. Change it explicitly with:

crw config set language=zh-TW

CRW_LANG=zh-TW crw config --list overrides it for a single run without touching the config.

Export and import

crw config export ~/crw-settings.json   # or `-` for stdout, to pipe it somewhere
crw config import ~/crw-settings.json

An export contains portable settings only. It excludes the bot token, chat ID, local paths and API endpoint settings. The output file is created with owner-only access; - writes JSON to stdout, where the receiving command controls access. Set local values again after import.

An import is all-or-nothing and reports what it refused:

  • Local values are never imported. This includes the token and chat ID, so importing a file cannot redirect notifications or replace the local API endpoint or filesystem paths.
  • Control characters are rejected in setting values, including paths used by the scheduler.
  • Unknown keys are left alone rather than stored back as an unvalidated blob.
  • One invalid value aborts the whole import before the first write, so a half-applied config can never be the outcome.

crw config --set validates every value the same way the menu does (rejects an out-of-range interval, a malformed HH:MM, etc.) and reports which key failed. Changing any setting that affects the OS scheduler (daily_enabled, daily_time, monitor_enabled, scan_interval_minutes, timezone, log_dir) automatically re-renders and re-registers the scheduler job(s) for the current OS on exit/save — the same effect as running:

crw apply-schedule

which you can also run directly any time (e.g. after editing config.json by hand). This is the config-driven counterpart to scripts/render_launchd.py / render_systemd.py / schtasks.py, which scripts/install.py also calls on first install — see §7.

Config file location, in priority order: $CRW_CONFIG, else the platform default from the Installed paths table above. config.example.json documents every file-backed key with its default value — the bot token is deliberately absent from it.

5. uv project commands

For development inside the project:

cd ~/Documents/Workspace/codex-reset-watch
uv sync
uv run crw --help
uv run crw check --no-notify
uv run python -m unittest discover -s tests -v

Inspect uv-managed tool locations:

uv tool list
uv tool dir
uv tool dir --bin
uv python list --only-installed

The installer deliberately installs into uv's own default bin directory, so the stable paths used by launchd/systemd/Task Scheduler are:

~/.local/bin/codex-reset-watch
~/.local/bin/crw

It used to override UV_TOOL_BIN_DIR to a private ~/scripts instead, which silently broke the scheduler: uv records each entrypoint's absolute path in its receipt and deletes the recorded ones on every reinstall, so a later plain uv tool install/uv tool upgrade — run without that same override — moved the CLI to ~/.local/bin and left the scheduler jobs invoking a path that no longer existed. Matching uv's default keeps the two in sync no matter how the tool is reinstalled. crw doctor's Scheduled CLI line asserts exactly this.

6. Upgrade or reinstall

PyPI install — upgrade to the latest release, then re-register the scheduler jobs:

uv tool upgrade codex-reset-watch
crw apply-schedule

uv tool upgrade keeps an install's original source, so an install made from a source folder or git URL stays there; switch it to PyPI once with uv tool install --force codex-reset-watch (the in-app Update now does the same).

Source install, after source changes — recommended:

cd ~/Documents/Workspace/codex-reset-watch
make install

Or reinstall only the uv tool executable/environment:

cd ~/Documents/Workspace/codex-reset-watch
make tool-reinstall

make install (or uv run python scripts/install.py on Windows) is preferred when scheduler configuration or installer files also changed.

7. Scheduler jobs

Both the daily time and the monitor interval are read from config at render time (see §4) — defaults below are what ships in config.example.json. Either job can also be turned off entirely (daily_enabled / monitor_enabled), which removes its scheduler entry instead of leaving a disabled stub. All rendering lives in src/codex_reset_watch/scheduler.py, shared by scripts/install.py (first install) and crw config / crw apply-schedule (re-apply after a settings change) — the per-OS scripts under scripts/ are thin wrappers around it. These are per-user scheduled jobs, not system daemons. To restart/re-register them from current settings on macOS, Linux, or Windows, run crw apply-schedule. From this checkout on macOS/Linux, make restart-schedule does the same thing. The interactive update flow calls crw apply-schedule after a successful upgrade; schedule-related crw --config changes also re-apply automatically.

Daily job

  • default: 10:00, in the configured timezone (timezone setting; converted to the machine's own local time at render time, since every OS scheduler fires on system local time)
  • runs immediately at login/boot too (RunAtLoad/Persistent), so a missed run (machine asleep/off) catches up
  • Python-side daily gate (last_daily_date in state) prevents duplicate daily work even if the OS runs it more than once

Monitor job

Runs every scan_interval_minutes (default 120 = every 2 hours; min 1 minute, max 1440 = 1 day), as a rolling interval from when the job last fired — not a fixed set of wall-clock minutes.

The program uses a cross-platform file lock (src/codex_reset_watch/filelock.py) so overlapping daily/monitor invocations do not corrupt state or duplicate work.

macOS — launchd

  • ~/Library/LaunchAgents/codex-reset-watch.daily.plist (StartCalendarInterval)
  • ~/Library/LaunchAgents/codex-reset-watch.monitor.plist (StartInterval, in seconds)
  • installer/crw apply-schedule runs launchctl bootout (both, to clear a disabled job) then bootstrap/enable for each enabled job

Linux — systemd --user timers

  • ~/.config/systemd/user/codex-reset-watch-daily.{service,timer} (OnCalendar=*-*-* HH:MM:00)
  • ~/.config/systemd/user/codex-reset-watch-monitor.{service,timer} (OnBootSec=/OnUnitActiveSec=<N>min)
  • installer/crw apply-schedule runs systemctl --user daemon-reload, then enable and restart on each enabled timer (and disable --now a job that was just turned off), so a changed interval takes effect immediately
  • requires a systemd user instance (lingering, if you want jobs to run without an active login session: loginctl enable-linger $USER)

Windows — Task Scheduler

  • CodexResetWatchDaily (/SC DAILY /ST HH:MM)
  • CodexResetWatchMonitor: /SC HOURLY /MO <hours> when the interval is a whole number of hours (e.g. the 2-hour default), /SC MINUTE /MO <minutes> otherwise, /SC DAILY for a full 1-day interval
  • Task arguments and the Windows user environment registry do not receive credentials; scheduled runs read the local config and DPAPI store

8. Telegram

Sends directly through the Telegram Bot API via telegram_kit, a separate package (PyPI) — no external script dependency. Other projects can use it with service="codex-reset-watch" to reuse the token crw config stored; its API is documented in its own repository. There are two ways to supply the credentials, and what crw config stored wins. The installer (§2) already walks through Option A on first install with the token entered echo-off; this section is for setting it up later or changing it.

crw config          # rows 10 and 11, under "Telegram"

or non-interactively:

crw config --token-stdin   # reads the token from stdin without a command-line argument
crw config set telegram_chat_id=-1001234567890

The bot token is never written in plaintext by this app. It goes into the operating system's credential store; on Windows the DPAPI-encrypted blob is in an owner-only file. The config file, exports and generated scheduler jobs never contain the token:

OS Where the token is kept Via
macOS Keychain (codex-reset-watch generic password) security
Linux Secret Service — GNOME Keyring / KWallet secret-tool (libsecret)
Windows DPAPI, encrypted for your user account PowerShell
none of the above nothing is stored —

With no credential store available, crw refuses to persist the token. Environment variables can still supply credentials to a manual run, but cannot be used for scheduled notifications; set up a supported credential store for scheduled runs.

If the store is locked or refuses a token that someone hand-wrote into config.json, that run keeps using it and the file is left untouched rather than failing every command. crw doctor shows where the token came from. Saving other settings contacts the store only when the token actually changed, so a locked keychain does not block unrelated edits.

Wherever it is displayed the token is masked (********WXYZ). The installer and terminal menu hide input; --token-stdin supports automation. Passing a nonempty token through --set is rejected because the shell and process list can retain command-line arguments. Credential helpers read the value on stdin, and event logs do not contain it.

Saving never deletes it. Every other setting is persisted by rewriting the whole config, so a save runs constantly — on each menu edit, each --set, each import. A save that is handed a config without a token (a DEFAULTS dict, a briefly unreadable keychain, a partial config) leaves the stored item exactly as it is; it is not read as "remove the credential". Restoring defaults keeps the token for the same reason: a token has no default to restore to, only an item to destroy. Removing one is an explicit act:

crw config --set telegram_bot_token=      # names the key, asks for it to be empty

Until that distinction existed, make test was enough to wipe the stored token — the suite saves real configs, and a save with no token in it deleted the developer's own keychain item, so the token had to be retyped after every release. See §12.

If the token row is empty but notifications still work, TG_BOT_TOKEN is set in your environment (Option B) and is being used; the row shows only what the keychain holds, and says so.

To inspect or remove the stored item yourself on macOS:

security find-generic-password -s codex-reset-watch -a telegram_bot_token   # metadata only
security delete-generic-password -s codex-reset-watch -a telegram_bot_token

Option B — environment variables

export TG_BOT_TOKEN="..."
export TG_CHAT_ID="..."

These fill in whatever Option A has not stored for manual runs. They do not override a stored value: a TG_BOT_TOKEN forgotten in a shell profile must not keep notifying through a bot you already replaced in crw config.

Scheduled jobs never embed these variables in generated plist or unit files. crw apply-schedule migrates credentials found in older app-owned jobs into the local credential store and replaces those jobs. If the store refuses the migration, it attempts to stop the old jobs and removes the credentials from their files. A failed stop is reported explicitly because a running job may retain its old environment. Values supplied only by the current shell are not persisted for scheduled runs. In that case the error is reported before any running job is stopped, so the existing schedule keeps running.

Rotate a migrated token. Older installs wrote TG_BOT_TOKEN into world-readable (0644) plist or unit files, and backups or other local users may have copied them. After migrating, revoke the old token with @BotFather (/revoke), then store the new one with crw config --token-stdin.

Older Windows installs may have left TG_BOT_TOKEN in HKCU\Environment; crw doctor and the installer detect this without printing it. After confirming other applications do not use that variable, remove the legacy value in PowerShell:

[Environment]::SetEnvironmentVariable('TG_BOT_TOKEN', $null, 'User')
[Environment]::GetEnvironmentVariable('TG_BOT_TOKEN', 'User') -eq $null

crw doctor reports which store is in use and where each credential came from, showing the token masked:

✅ Secret store: macOS Keychain
✅ Telegram bot token: ********QrSt (macOS Keychain)
✅ Telegram chat id: -1001234567890 (environment)

Then:

crw check

9. Timezone and countdown

Upcoming reset information is rendered in the configured timezone (default UTC+8 — see §4) and includes remaining time with:

  • maximum unit: Day
  • minimum unit: minutes

Example:

🕒 預測窗口截止:2026-09-21 23:09 UTC+8
⏳ 距離現在:2 Days 1 hour 35 minutes

Example upcoming-event result:

Upcoming reset result

Reset times already past (latest reset, new-reset notice) add how long ago they were, largest unit Day, smallest unit the API's own precision — a date-only value shows days, an hour-only value hours, and anything finer is cut at minutes; zero-value units are left out:

🕒 Time: 2026-09-27 02:17 UTC+8 (23 hours 16 minutes ago)

Example current-reset-event result (new-reset notice):

Current reset event result

10. Event parsing resilience

event_from_dict tolerates upstream API schema drift instead of assuming one fixed shape:

  • Timestamp and source-URL keys match both snake_case and camelCase variants (created_at/createdAt, source_url/sourceUrl, etc.), searched up to 3 levels deep so a nested source: {url: ...} object resolves correctly.
  • If no timestamp field is present or parseable, the event ID or source URL is checked for an embedded X/Twitter Snowflake post ID, which encodes its own creation time. This keeps historical reset times available even if the upstream schema changes or omits its timestamp field. API-provided timestamps always take priority over this fallback.
  • Upcoming signals come from any known container (scheduled_reset, upcoming_reset, forecast, …) including the tracker's active_watch, whose expires_at is shown as the forecast window close and drops the signal once passed. crw check also prints stats.avg_interval_days as "Avg. reset interval" when the API provides it.
  • An explicit scheduled_reset (status scheduled) always wins over a forecast or active_watch, even while its time is still to be announced. Once its scheduled_for passes it stays on screen as "waiting for the reset to land" — per the API docs, a passed time does not mean it happened.
  • When tolerance runs out, the monitor says so instead of going quiet. A scheduled scan whose status request fails, or whose payload yields no reset event at all, counts as blind. After blind_alert_after blind scans in a row, one "can't see the API" notice goes out; a healthy scan resets the count.

11. Logs and disk usage

Application logs:

~/Library/Logs/codex-reset-watch/events.jsonl
~/Library/Logs/codex-reset-watch/api.jsonl

Default config limits each rotated application/API log to roughly 2 MiB with 3 backups:

{
  "max_log_bytes": 2097152,
  "log_backups": 3
}

launchd stdout/stderr logs are separately trimmed to 256 KiB by the installer when they exceed 512 KiB (macOS only — install.trim_launchd_logs). systemd/journald and Windows Task Scheduler manage their own job-output retention.

12. Tests

Run everything through uv (macOS/Linux):

make test

Windows:

uv run python -m unittest discover -s tests -t . -v

uv run pytest runs the same suite with pytest, which is a dev dependency installed by uv sync.

Individual groups (macOS/Linux):

make test-unit
make test-integration

The integration tests (tests/test_integration.py, tests/test_run_check.py, tests/test_cli_config.py, tests/test_cli_syntax.py, tests/test_install_telegram.py) start a local HTTP server and exercise the real HTTP client, response normalization, and run_check/crw config/installer-prompt flows without contacting the production API or a real terminal. The full suite runs unmodified on macOS, Linux, and Windows in CI (see .github/workflows/ci.yml).

Three families of side effect are mocked everywhere, for the same reason — a unit test must not touch what this machine actually has:

  • The OS scheduler. Every test that exercises crw config / crw apply-schedule mocks scheduler.apply (tests/test_ui.py, tests/test_ui_menu.py, tests/test_cli_config.py, tests/test_cli_syntax.py). The scheduler rendering logic itself (tests/test_scheduler.py, tests/test_launchd.py, tests/test_systemd.py) is exercised fully, always against a throwaway output directory.
  • The credential store. tests/__init__.py closes the OS boundary for the whole suite: the backend probe reports "no credential store" and secrets_store._run raises if anything still tries to shell out. Nothing else in the suite can reach the machine's real keychain — which it used to, every run, deleting the developer's own bot token. tests/test_secrets_store.py replaces the single subprocess funnel, so the macOS, Linux and Windows code paths are all covered on whatever machine runs the suite. RoundTripTests is the one deliberate exception: it lifts that fence (tests.real_credential_store) to store and re-read a throwaway _roundtrip_probe item in the machine's real credential store — its own account name, never the real token — deleted again in cleanup, and skipped where there is no store. Mocking that funnel is precisely what once hid an inert write — add-generic-password -w prompts /dev/tty rather than reading stdin, so it stored nothing and still exited 0 while every mocked assertion passed. A contract test cannot catch a tool ignoring the contract.
  • The terminal. The keyboard menu's brain (ui.step) is a pure function of state and keypress, so tests/test_ui_menu.py drives the whole interaction with synthetic KeyEvents, and tests/test_keys.py feeds raw escape-sequence bytes through a fake byte source — no TTY, no raw mode, nothing to restore.

Language-dependent assertions always pin the language explicitly (lang="en" / CRW_LANG=zh-TW), so the suite gives the same result on a machine with any locale.

13. Scheduler status

macOS:

make launch-status
# or
launchctl print gui/$(id -u)/codex-reset-watch.daily
launchctl print gui/$(id -u)/codex-reset-watch.monitor

Linux:

systemctl --user status codex-reset-watch-daily.timer codex-reset-watch-monitor.timer
systemctl --user list-timers 'codex-reset-watch-*'

Windows:

schtasks /Query /TN CodexResetWatchDaily /V /FO LIST
schtasks /Query /TN CodexResetWatchMonitor /V /FO LIST

14. Uninstall

PyPI install (any OS) — remove the scheduler jobs first, then the tool:

uvx --from codex-reset-watch python -c "from codex_reset_watch import scheduler; scheduler.remove()"
uv tool uninstall codex-reset-watch

The first line runs the same scheduler.remove() as scripts/uninstall.py, so no clone is needed. Config, state, and logs are kept.

Source install, macOS/Linux:

cd ~/Documents/Workspace/codex-reset-watch
make uninstall

Windows:

uv run python scripts/uninstall.py

This removes the scheduler job(s) for the current OS and uninstalls the uv tool. Project files, config, state, and logs are intentionally retained (path printed by the uninstaller).

15. CI notifications

.github/workflows/ci.yml runs the test matrix (macOS/Linux/Windows) on every push and PR, then a separate notify-telegram job sends a Telegram message only when the test job fails on a push (never on green runs, never on pull_request, to avoid pinging on forks/external PRs). Configure it once per repo:

gh secret set TELEGRAM_BOT_TOKEN
gh secret set TELEGRAM_CHAT_ID

Both are independent of this project's own runtime TG_BOT_TOKEN/TG_CHAT_ID — the CI ones only ever see a failure alert with the repo/branch/commit and a link to the run; they never touch the app's monitoring data.

Releasing

.github/workflows/release.yml runs on a v* tag. It repeats the compile/test/build pass, installs the wheel into a throwaway venv, and refuses to upload unless that wheel reports the version being tagged — so a tag that disagrees with pyproject.toml fails before anything reaches PyPI. It then publishes with Trusted Publishing (OIDC — there is no API token stored in this repo; the job runs in the pypi GitHub environment, whose required reviewer approves each upload) and attaches the wheel, sdist and SHA256SUMS to the GitHub release.

The GitHub release is created after the PyPI upload on purpose: the in-app update check reads the latest GitHub release and then installs that version from PyPI, so a release must never be visible before it is installable. Checksums are generated after publishing too: uv publish uploads everything in dist/, and SHA256SUMS is not a distribution.

A tag runs the workflow file as it existed at that tag, so a release that failed to publish can't be repaired by re-running the old tag — the fix isn't in that tree. Bump the version and tag again.

Why the installer does not call uv run for scheduled jobs

uv run is ideal for project development because it discovers the project, syncs the project environment when needed, and executes within that environment. For a long-running background setup, this project instead installs the CLI with uv tool install and lets the OS scheduler call the installed executable directly. That keeps each scheduled invocation small and avoids depending on the current working directory, shell startup files, PATH activation, or project .venv state.

Acknowledgements

Thanks to codex-resets.com for tracking Codex quota resets and publishing the public API this project is built on.

Metadata

Release files for codex-reset-watch 0.20.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for codex-reset-watch 0.20.0
File Size Uploaded
codex_reset_watch-0.20.0.tar.gz 6.7 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for codex-reset-watch 0.20.0
File Interpreter ABI Platform
codex_reset_watch-0.20.0-py3-none-any.whl Python 3 none any Details

Total release size: 12.6 MB

Release files / codex_reset_watch-0.20.0.tar.gz

Download URL codex_reset_watch-0.20.0.tar.gz
Size 6.7 MB
Tags Source
SHA-256 checksum
How to use checksums
e6feb8580da1343712fd4aefc49ca30a383de68598e450171cae8746c3f2eaed
BLAKE2b-256 checksum
How to use checksums
20ccfac18b1cdb1a1eb455154f699fd4f52cfb64b9b89ab4366c2ff40e0210a1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / codex_reset_watch-0.20.0-py3-none-any.whl

Download URL codex_reset_watch-0.20.0-py3-none-any.whl
Size 5.8 MB
Tags Python 3
SHA-256 checksum
How to use checksums
855938c52ce7c3f243fc90bc907d5e1a484efd4db9f8ece5b764e69255f5ba60
BLAKE2b-256 checksum
How to use checksums
982abbeb06a892d5ad9b9b98055da75f8ae7a9baabc8060408d7a34ed0786160
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.20.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page