Sentinel watches Claude Code activity on this machine, computes exactly when your 5-hour usage window resets, and hands delivery off to the cloud the instant it knows — so the notification arrives on time whether your laptop is open, asleep, or off.
./install.sh
[!TIP] The in-app/CLI countdown is always the source of truth, not Sentinel. If they ever disagree, run
correct five_hour <timestamp>to reconcile.
Anthropic doesn't publish a "your limit just reset" webhook — the only ground truth is built for humans to read, not machines to poll. Sentinel closes that gap:
pip install, ever.Claude Code hook → claude_usage_watcher.py → Upstash QStash → Telegram
(this Mac) (computes reset_at) (cloud alarm) (your phone)
Claude-Efficiency/
├── README.md this file
├── SYSTEMDESIGN.md in-depth technical explanation of how this was built
├── FUTUREWORK.md possible future improvements/integrations
├── .gitignore
├── claude_usage_watcher.py the tool itself (record / hit-limit / check / correct / status)
├── install.sh deploys the script to ~/bin (see "Why two copies" below)
├── test.sh local, side-effect-free test pass (no real network calls)
├── hooks.snippet.json the two hook entries to merge into ~/.claude/settings.json
├── claude_usage_watcher_HANDOFF.md original planning brief this tool was built from, kept for reference
└── launchd/
└── com.claude-usage-watcher.plist LaunchAgent that runs `check` every 5 min (fallback only)
All commands below are run from the repo root.
This repo is the source of truth and where you edit the script. But the
hooks and the launchd job don't execute it from here — they execute a
deployed copy at ~/bin/claude_usage_watcher.py. Reason: ~/Desktop (and
~/Documents, ~/Downloads) are TCC-protected on macOS. A background
launchd process trying to read a script under ~/Desktop/... fails with
a silent Operation not permitted, even though your interactive Terminal
reads the same path fine — this bit us once already setting this up.
~/bin isn't a protected location, so it sidesteps the issue entirely.
After editing claude_usage_watcher.py, run ./install.sh to redeploy
it to ~/bin — the two copies are not auto-synced.
All four credentials (Telegram bot token, Telegram chat ID, QStash token,
QStash base URL) load from ~/.claude-usage-watcher/secrets.env
(chmod 600), read by the script itself at startup — not from
environment variables set in the plist or hooks. Reason: Claude Code hooks
and launchd don't reliably share the same environment, so relying on
either one to carry secrets is fragile. A fixed local file the script reads
directly works the same way no matter what invoked it.
# ~/.claude-usage-watcher/secrets.env
CLAUDE_NOTIFIER_TELEGRAM_BOT_TOKEN=123456789:AA...
CLAUDE_NOTIFIER_TELEGRAM_CHAT_ID=987654321
CLAUDE_NOTIFIER_QSTASH_TOKEN=eyJ...
CLAUDE_NOTIFIER_QSTASH_URL=https://qstash-us-east-1.upstash.io
This means the checked-in launchd/*.plist template needs no
EnvironmentVariables block and no secret substitution at all — the
installed copy in ~/Library/LaunchAgents/ can be an exact, byte-for-byte
copy of the repo template.
State (separate from secrets) also lives outside this repo, at
~/.claude-usage-watcher/:
| File | Contents |
|---|---|
state.json | current five_hour / weekly tracked windows, including each one's active qstash_message_id |
state.lock | empty lock file (flock) serializing concurrent reads/writes of state.json — safe to ignore |
secrets.env | the four credentials above, chmod 600 |
stop_failure_events.jsonl | every raw StopFailure payload ever seen, one JSON object per line |
launchd.out.log / launchd.err.log | stdout/stderr from the scheduled check runs |
hooks.snippet.json and the launchd plist ship with a
/Users/YOUR_USERNAME placeholder — launchd and Claude Code hooks both
need absolute paths, so they can't use ~ or $HOME themselves. One
command fills in your home directory everywhere it's needed:
sed -i '' "s|/Users/YOUR_USERNAME|$HOME|g" hooks.snippet.json launchd/com.claude-usage-watcher.plist
Run it once after cloning; every path below then matches your system.
~/bin./install.sh
See Why two copies of the script exist
above. Re-run this any time you edit claude_usage_watcher.py.
./test.sh
Confirms fresh-window inference, check --dry-run previewing without
mutating state, and hit-limit logging + confirmed_blocked_at — against
a scratch state file and with secrets deliberately unset, so it never
touches real state or makes a real network call regardless of what's
configured on the machine running it.
Merge the two hook entries from hooks.snippet.json into
~/.claude/settings.json under its hooks key. Merge, don't overwrite —
your settings.json likely has other config (MCP servers, effortLevel,
etc.) that must survive untouched.
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [ { "type": "command",
"command": "/usr/bin/python3 /Users/YOUR_USERNAME/bin/claude_usage_watcher.py record" } ] }
],
"StopFailure": [
{ "matcher": "rate_limit",
"hooks": [ { "type": "command",
"command": "/usr/bin/python3 /Users/YOUR_USERNAME/bin/claude_usage_watcher.py hit-limit" } ] }
]
}
}
Note the absolute /usr/bin/python3 rather than a bare python3 — see
Known gaps for why that matters.

/newbot → follow the prompts.
You'll get back a bot token that looks like 123456789:AA....curl -s "https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates" | python3 -m json.tool
Look for result[0].message.chat.id.QSTASH_TOKEN and the
region-specific QSTASH_URL shown on that page. Use the region-specific
URL, not the generic qstash.upstash.io gateway — the generic one
returned a "user not found in this region" error for a fresh account in
testing here.mkdir -p ~/.claude-usage-watcher
cat > ~/.claude-usage-watcher/secrets.env <<'EOF'
CLAUDE_NOTIFIER_TELEGRAM_BOT_TOKEN=<your bot token>
CLAUDE_NOTIFIER_TELEGRAM_CHAT_ID=<your chat id>
CLAUDE_NOTIFIER_QSTASH_TOKEN=<your qstash token>
CLAUDE_NOTIFIER_QSTASH_URL=<your qstash region url>
EOF
chmod 600 ~/.claude-usage-watcher/secrets.env
Never commit this file or paste real values into this repo — it's
already covered by .gitignore (.env), but secrets.env itself lives
entirely outside the repo, under ~/.claude-usage-watcher/, by design.
cp launchd/com.claude-usage-watcher.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claude-usage-watcher.plist
launchctl list | grep claude-usage-watcher # should show it loaded, exit status 0
No secret substitution needed — see
Secrets live in one local file
above. (launchctl load still works but is the deprecated form;
bootstrap is the modern equivalent.)
To reload after editing the plist:
launchctl bootout gui/$(id -u)/com.claude-usage-watcher 2>/dev/null
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claude-usage-watcher.plist
/usr/bin/python3 claude_usage_watcher.py correct five_hour "$(/usr/bin/python3 -c "from datetime import datetime,timedelta,timezone;print((datetime.now(timezone.utc)+timedelta(minutes=2)).isoformat())")"
Check ~/.claude-usage-watcher/state.json for a populated
qstash_message_id — that confirms the cloud alarm was scheduled
successfully. A real push should land on your phone within ~2 minutes,
independent of launchd (you can launchctl bootout the job first to
prove it — that's how this was verified originally).
| Command | When it runs | What it does |
|---|---|---|
record | UserPromptSubmit hook | Starts a new 5-hour window if none is active and schedules its QStash alarm. No-op otherwise — usage volume never moves the reset time. |
hit-limit | StopFailure(rate_limit) hook | Logs the raw payload, marks confirmed_blocked_at; if it recognizes a real reset field, cancels the old alarm and schedules a new one. |
check [--dry-run] | launchd, every 5 min | Fallback + status sync — confirms actual delivery via QStash's logs API before marking notified=True; sends directly if delivery fails/times out or no alarm was ever scheduled. --dry-run previews without mutating state. |
correct <five_hour|weekly> <ISO8601> | manual, whenever you see a real value | Overwrites the tracked reset time with ground truth; cancels any existing alarm and schedules a new one. |
status | manual | Human-readable dump of both tracked windows. |
Running status displays a clean, human-readable summary of your tracked windows, or you can check the raw state.json file:

StopFailure payload schema is unconfirmed. hit-limit searches the
whole payload recursively (any nesting depth, case/hyphen-insensitive)
for reset_at / resets_at / reset_time / retry_after_ms /
retry_after_seconds / retry_after-style keys, and accepts either ISO
8601 strings or Unix epoch numbers — but the real field names are still
a guess. The next time a real rate limit hits during normal use, read
stop_failure_events.jsonl (or the "no recognized field" note it prints
to stderr, which lists every key it saw), find the real field names, and
update RESET_TIMESTAMP_KEYS/RETRY_AFTER_*_KEYS in
claude_usage_watcher.py.correct weekly;
never inferred. Feed it real observed values over time to eventually
figure out whether it's rolling or fixed-boundary.python3 can silently break HTTPS calls. A python.org
installer build of Python (as opposed to Apple's /usr/bin/python3) ships
with its own cert bundle that isn't initialized until you run its
Install Certificates.command — until then, any urllib HTTPS call
fails with CERTIFICATE_VERIFY_FAILED. This bit test.sh during
development. Every invocation in this repo (hooks, plist, test.sh) is
pinned to /usr/bin/python3 specifically to avoid depending on whatever
python3 happens to resolve first on $PATH.python3 -c "..." one-liners can fail under a TCC-protected working
directory, even when running the actual script from the same place works
fine. Python inserts different things into sys.path[0] depending on
invocation style: running a script file inserts that script's
directory, but python3 -c "..." inserts the current working directory.
If your shell's cwd happens to be under ~/Desktop/~/Documents/
~/Downloads when you run an inline one-liner (like the timestamp
helpers in this README's setup steps), Python's own startup import scan
can hit the same TCC wall described above — even though hooks and
launchd are unaffected (they always invoke the real .py file, never
-c). If a copy-pasted one-liner throws a PermissionError during
import, cd somewhere outside those three folders (e.g. /tmp) first.# is the job loaded?
launchctl list | grep claude-usage-watcher
# force a run right now, bypassing the 5-min interval
launchctl start com.claude-usage-watcher
# check what actually happened on the last few ticks
tail -f ~/.claude-usage-watcher/launchd.out.log ~/.claude-usage-watcher/launchd.err.log
# current tracked state, including qstash_message_id per window
cat ~/.claude-usage-watcher/state.json
/usr/bin/python3 claude_usage_watcher.py status
No qstash_message_id in state.json after record/correct:
scheduling failed silently from the caller's perspective (hooks shouldn't
crash your Claude Code session) but logs a warning to stderr — check
~/.claude-usage-watcher/launchd.err.log or run the command manually in a
terminal to see it. Usually a missing/wrong value in secrets.env, or the
generic (non-regional) QStash URL — see step 5 above.
launchd.err.log shows Operation not permitted opening the script:
you're pointing the plist at a script under ~/Desktop, ~/Documents, or
~/Downloads — all TCC-protected on macOS, silently blocking background
processes even though Terminal can read the same file fine. Point
ProgramArguments at the ~/bin deployed copy instead (run ./install.sh
first if you haven't).
launchd.err.log shows CERTIFICATE_VERIFY_FAILED: see the bare
python3 note in Known gaps — make sure ProgramArguments
uses /usr/bin/python3, not a bare python3.
Push arrives but shows no popup — check the app is backgrounded, not foregrounded when testing (Telegram, like most apps, doesn't banner its own foreground chat). If it still doesn't show as a real notification with the app closed, check the OS notification permission for the Telegram app specifically, and any Focus/Do Not Disturb modes.
launchctl bootout gui/$(id -u)/com.claude-usage-watcher
rm ~/Library/LaunchAgents/com.claude-usage-watcher.plist
rm ~/bin/claude_usage_watcher.py
# then remove the two hook entries from ~/.claude/settings.json by hand
rm -rf ~/.claude-usage-watcher # also removes secrets.env
Also worth deleting the QStash token from the Upstash console and revoking
the Telegram bot (message @BotFather → /deletebot) if you're tearing this
down for good.
This tool went through two real pivots worth knowing about if you're extending it:
launchd polling check every 5 minutes and sending
directly. That only works while the Mac is awake — a closed laptop meant
the notification arrived late, whenever the Mac was next opened,
undercutting the entire point of the tool. Moved delivery to Upstash
QStash: record/correct/hit-limit schedule a one-shot delayed
message straight to the Telegram Bot API at the exact reset moment, so
delivery no longer depends on this machine's power state at all. Local
check was demoted to a fallback (for when scheduling failed) plus
local status bookkeeping, deliberately structured so it never fires the
same notification QStash already sent.Python
88.1%
Shell
11.9%
Sentinel watches Claude Code activity on this machine, computes exactly when your 5-hour usage window resets, and hands delivery off to the cloud the instant it knows — so the notification arrives on time whether your laptop is open, asleep, or off.
./install.sh
[!TIP] The in-app/CLI countdown is always the source of truth, not Sentinel. If they ever disagree, run
correct five_hour <timestamp>to reconcile.
Anthropic doesn't publish a "your limit just reset" webhook — the only ground truth is built for humans to read, not machines to poll. Sentinel closes that gap:
pip install, ever.Claude Code hook → claude_usage_watcher.py → Upstash QStash → Telegram
(this Mac) (computes reset_at) (cloud alarm) (your phone)
Claude-Efficiency/
├── README.md this file
├── SYSTEMDESIGN.md in-depth technical explanation of how this was built
├── FUTUREWORK.md possible future improvements/integrations
├── .gitignore
├── claude_usage_watcher.py the tool itself (record / hit-limit / check / correct / status)
├── install.sh deploys the script to ~/bin (see "Why two copies" below)
├── test.sh local, side-effect-free test pass (no real network calls)
├── hooks.snippet.json the two hook entries to merge into ~/.claude/settings.json
├── claude_usage_watcher_HANDOFF.md original planning brief this tool was built from, kept for reference
└── launchd/
└── com.claude-usage-watcher.plist LaunchAgent that runs `check` every 5 min (fallback only)
All commands below are run from the repo root.
This repo is the source of truth and where you edit the script. But the
hooks and the launchd job don't execute it from here — they execute a
deployed copy at ~/bin/claude_usage_watcher.py. Reason: ~/Desktop (and
~/Documents, ~/Downloads) are TCC-protected on macOS. A background
launchd process trying to read a script under ~/Desktop/... fails with
a silent Operation not permitted, even though your interactive Terminal
reads the same path fine — this bit us once already setting this up.
~/bin isn't a protected location, so it sidesteps the issue entirely.
After editing claude_usage_watcher.py, run ./install.sh to redeploy
it to ~/bin — the two copies are not auto-synced.
All four credentials (Telegram bot token, Telegram chat ID, QStash token,
QStash base URL) load from ~/.claude-usage-watcher/secrets.env
(chmod 600), read by the script itself at startup — not from
environment variables set in the plist or hooks. Reason: Claude Code hooks
and launchd don't reliably share the same environment, so relying on
either one to carry secrets is fragile. A fixed local file the script reads
directly works the same way no matter what invoked it.
# ~/.claude-usage-watcher/secrets.env
CLAUDE_NOTIFIER_TELEGRAM_BOT_TOKEN=123456789:AA...
CLAUDE_NOTIFIER_TELEGRAM_CHAT_ID=987654321
CLAUDE_NOTIFIER_QSTASH_TOKEN=eyJ...
CLAUDE_NOTIFIER_QSTASH_URL=https://qstash-us-east-1.upstash.io
This means the checked-in launchd/*.plist template needs no
EnvironmentVariables block and no secret substitution at all — the
installed copy in ~/Library/LaunchAgents/ can be an exact, byte-for-byte
copy of the repo template.
State (separate from secrets) also lives outside this repo, at
~/.claude-usage-watcher/:
| File | Contents |
|---|---|
state.json | current five_hour / weekly tracked windows, including each one's active qstash_message_id |
state.lock | empty lock file (flock) serializing concurrent reads/writes of state.json — safe to ignore |
secrets.env | the four credentials above, chmod 600 |
stop_failure_events.jsonl | every raw StopFailure payload ever seen, one JSON object per line |
launchd.out.log / launchd.err.log | stdout/stderr from the scheduled check runs |
hooks.snippet.json and the launchd plist ship with a
/Users/YOUR_USERNAME placeholder — launchd and Claude Code hooks both
need absolute paths, so they can't use ~ or $HOME themselves. One
command fills in your home directory everywhere it's needed:
sed -i '' "s|/Users/YOUR_USERNAME|$HOME|g" hooks.snippet.json launchd/com.claude-usage-watcher.plist
Run it once after cloning; every path below then matches your system.
~/bin./install.sh
See Why two copies of the script exist
above. Re-run this any time you edit claude_usage_watcher.py.
./test.sh
Confirms fresh-window inference, check --dry-run previewing without
mutating state, and hit-limit logging + confirmed_blocked_at — against
a scratch state file and with secrets deliberately unset, so it never
touches real state or makes a real network call regardless of what's
configured on the machine running it.
Merge the two hook entries from hooks.snippet.json into
~/.claude/settings.json under its hooks key. Merge, don't overwrite —
your settings.json likely has other config (MCP servers, effortLevel,
etc.) that must survive untouched.
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [ { "type": "command",
"command": "/usr/bin/python3 /Users/YOUR_USERNAME/bin/claude_usage_watcher.py record" } ] }
],
"StopFailure": [
{ "matcher": "rate_limit",
"hooks": [ { "type": "command",
"command": "/usr/bin/python3 /Users/YOUR_USERNAME/bin/claude_usage_watcher.py hit-limit" } ] }
]
}
}
Note the absolute /usr/bin/python3 rather than a bare python3 — see
Known gaps for why that matters.

/newbot → follow the prompts.
You'll get back a bot token that looks like 123456789:AA....curl -s "https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates" | python3 -m json.tool
Look for result[0].message.chat.id.QSTASH_TOKEN and the
region-specific QSTASH_URL shown on that page. Use the region-specific
URL, not the generic qstash.upstash.io gateway — the generic one
returned a "user not found in this region" error for a fresh account in
testing here.mkdir -p ~/.claude-usage-watcher
cat > ~/.claude-usage-watcher/secrets.env <<'EOF'
CLAUDE_NOTIFIER_TELEGRAM_BOT_TOKEN=<your bot token>
CLAUDE_NOTIFIER_TELEGRAM_CHAT_ID=<your chat id>
CLAUDE_NOTIFIER_QSTASH_TOKEN=<your qstash token>
CLAUDE_NOTIFIER_QSTASH_URL=<your qstash region url>
EOF
chmod 600 ~/.claude-usage-watcher/secrets.env
Never commit this file or paste real values into this repo — it's
already covered by .gitignore (.env), but secrets.env itself lives
entirely outside the repo, under ~/.claude-usage-watcher/, by design.
cp launchd/com.claude-usage-watcher.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claude-usage-watcher.plist
launchctl list | grep claude-usage-watcher # should show it loaded, exit status 0
No secret substitution needed — see
Secrets live in one local file
above. (launchctl load still works but is the deprecated form;
bootstrap is the modern equivalent.)
To reload after editing the plist:
launchctl bootout gui/$(id -u)/com.claude-usage-watcher 2>/dev/null
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claude-usage-watcher.plist
/usr/bin/python3 claude_usage_watcher.py correct five_hour "$(/usr/bin/python3 -c "from datetime import datetime,timedelta,timezone;print((datetime.now(timezone.utc)+timedelta(minutes=2)).isoformat())")"
Check ~/.claude-usage-watcher/state.json for a populated
qstash_message_id — that confirms the cloud alarm was scheduled
successfully. A real push should land on your phone within ~2 minutes,
independent of launchd (you can launchctl bootout the job first to
prove it — that's how this was verified originally).
| Command | When it runs | What it does |
|---|---|---|
record | UserPromptSubmit hook | Starts a new 5-hour window if none is active and schedules its QStash alarm. No-op otherwise — usage volume never moves the reset time. |
hit-limit | StopFailure(rate_limit) hook | Logs the raw payload, marks confirmed_blocked_at; if it recognizes a real reset field, cancels the old alarm and schedules a new one. |
check [--dry-run] | launchd, every 5 min | Fallback + status sync — confirms actual delivery via QStash's logs API before marking notified=True; sends directly if delivery fails/times out or no alarm was ever scheduled. --dry-run previews without mutating state. |
correct <five_hour|weekly> <ISO8601> | manual, whenever you see a real value | Overwrites the tracked reset time with ground truth; cancels any existing alarm and schedules a new one. |
status | manual | Human-readable dump of both tracked windows. |
Running status displays a clean, human-readable summary of your tracked windows, or you can check the raw state.json file:

StopFailure payload schema is unconfirmed. hit-limit searches the
whole payload recursively (any nesting depth, case/hyphen-insensitive)
for reset_at / resets_at / reset_time / retry_after_ms /
retry_after_seconds / retry_after-style keys, and accepts either ISO
8601 strings or Unix epoch numbers — but the real field names are still
a guess. The next time a real rate limit hits during normal use, read
stop_failure_events.jsonl (or the "no recognized field" note it prints
to stderr, which lists every key it saw), find the real field names, and
update RESET_TIMESTAMP_KEYS/RETRY_AFTER_*_KEYS in
claude_usage_watcher.py.correct weekly;
never inferred. Feed it real observed values over time to eventually
figure out whether it's rolling or fixed-boundary.python3 can silently break HTTPS calls. A python.org
installer build of Python (as opposed to Apple's /usr/bin/python3) ships
with its own cert bundle that isn't initialized until you run its
Install Certificates.command — until then, any urllib HTTPS call
fails with CERTIFICATE_VERIFY_FAILED. This bit test.sh during
development. Every invocation in this repo (hooks, plist, test.sh) is
pinned to /usr/bin/python3 specifically to avoid depending on whatever
python3 happens to resolve first on $PATH.python3 -c "..." one-liners can fail under a TCC-protected working
directory, even when running the actual script from the same place works
fine. Python inserts different things into sys.path[0] depending on
invocation style: running a script file inserts that script's
directory, but python3 -c "..." inserts the current working directory.
If your shell's cwd happens to be under ~/Desktop/~/Documents/
~/Downloads when you run an inline one-liner (like the timestamp
helpers in this README's setup steps), Python's own startup import scan
can hit the same TCC wall described above — even though hooks and
launchd are unaffected (they always invoke the real .py file, never
-c). If a copy-pasted one-liner throws a PermissionError during
import, cd somewhere outside those three folders (e.g. /tmp) first.# is the job loaded?
launchctl list | grep claude-usage-watcher
# force a run right now, bypassing the 5-min interval
launchctl start com.claude-usage-watcher
# check what actually happened on the last few ticks
tail -f ~/.claude-usage-watcher/launchd.out.log ~/.claude-usage-watcher/launchd.err.log
# current tracked state, including qstash_message_id per window
cat ~/.claude-usage-watcher/state.json
/usr/bin/python3 claude_usage_watcher.py status
No qstash_message_id in state.json after record/correct:
scheduling failed silently from the caller's perspective (hooks shouldn't
crash your Claude Code session) but logs a warning to stderr — check
~/.claude-usage-watcher/launchd.err.log or run the command manually in a
terminal to see it. Usually a missing/wrong value in secrets.env, or the
generic (non-regional) QStash URL — see step 5 above.
launchd.err.log shows Operation not permitted opening the script:
you're pointing the plist at a script under ~/Desktop, ~/Documents, or
~/Downloads — all TCC-protected on macOS, silently blocking background
processes even though Terminal can read the same file fine. Point
ProgramArguments at the ~/bin deployed copy instead (run ./install.sh
first if you haven't).
launchd.err.log shows CERTIFICATE_VERIFY_FAILED: see the bare
python3 note in Known gaps — make sure ProgramArguments
uses /usr/bin/python3, not a bare python3.
Push arrives but shows no popup — check the app is backgrounded, not foregrounded when testing (Telegram, like most apps, doesn't banner its own foreground chat). If it still doesn't show as a real notification with the app closed, check the OS notification permission for the Telegram app specifically, and any Focus/Do Not Disturb modes.
launchctl bootout gui/$(id -u)/com.claude-usage-watcher
rm ~/Library/LaunchAgents/com.claude-usage-watcher.plist
rm ~/bin/claude_usage_watcher.py
# then remove the two hook entries from ~/.claude/settings.json by hand
rm -rf ~/.claude-usage-watcher # also removes secrets.env
Also worth deleting the QStash token from the Upstash console and revoking
the Telegram bot (message @BotFather → /deletebot) if you're tearing this
down for good.
This tool went through two real pivots worth knowing about if you're extending it:
launchd polling check every 5 minutes and sending
directly. That only works while the Mac is awake — a closed laptop meant
the notification arrived late, whenever the Mac was next opened,
undercutting the entire point of the tool. Moved delivery to Upstash
QStash: record/correct/hit-limit schedule a one-shot delayed
message straight to the Telegram Bot API at the exact reset moment, so
delivery no longer depends on this machine's power state at all. Local
check was demoted to a fallback (for when scheduling failed) plus
local status bookkeeping, deliberately structured so it never fires the
same notification QStash already sent.Python
88.1%
Shell
11.9%