TL;DR
- Claude Code's built-in auto-continue (
autoContinueAtUsageLimit, on by default: it waits for the usage limit to reset, then carries on) doesn't start in Remote Control sessions. As far as I found, only the interactive-mode docs page says so, and nothing on screen tells you. In my transcripts, 0 of 10 limit hits started the wait with Remote Control connected, and 8 of 8 did without it. - The fix is a pair of hooks,
StopandStopFailure, withasyncRewake. They wait for the 5-hour reset and wake the session withexit 2, unless the built-in already resumed it. - The same script keeps a weekly reserve (at 85% it blocks the built-in continuation and asks before dispatching) and adds usage numbers to every prompt.
- Checked with two real probe sessions and 35 unit tests. Not tested live: a real 5-hour reset, the 85% paths and a real
rate_limitStopFailurepayload. Those ran only in unit tests, against fake data.
Tested on Claude Code 2.1.289, claude.ai Max plan, macOS.
Install in one step
Everything in this post is packaged at ryoshumei/claude-code-usage-guard. Paste this into Claude Code; it shows you a dry run and waits for your OK:
Install usage-guard from https://github.com/ryoshumei/claude-code-usage-guard
1. Clone it into a new temp dir (git clone --depth 1, target from mktemp -d) and cd into it.
2. Run ./install.sh --dry-run, show me the plan, and wait for my OK. Do not go on until I say yes.
3. After my OK, run ./install.sh. Add --stop N only if I asked for a weekly stop line other than 85.
4. Show me its full output, then run ~/.claude/usage-guard/usage and show that too.
Do not edit any other file. If a step fails, stop and tell me what happened.
Or run it yourself:
git clone https://github.com/ryoshumei/claude-code-usage-guard ~/.claude/usage-guard-src && ~/.claude/usage-guard-src/install.sh
It backs up every file it touches, keeps your statusline (wrapped, same output; no jq needed), and ~/.claude/usage-guard/install.sh --uninstall undoes it. The rest of this post explains what it does and why, using my original hand-made setup.
The symptom
I run Claude Code in conductor mode: one Opus session dispatching Sonnet subagents in parallel (setup). On Oct 3, in an 86-ticket hackathon run with /remote-control on, the session limit hit, no automatic continue started, and I typed "continue" by hand at the reset.
You've hit your session limit · resets 11:40pm (Asia/Tokyo)
It used to continue on its own, so I suspected the subagents. I want every session to resume after the 5-hour reset and to stop autonomous work at 85% of the weekly limit, because I keep the rest for daily life.
The cause
The docs (interactive mode) list the cases where the built-in wait doesn't start:
Claude Code doesn't start the wait on its own in these cases:
- Remote Control and agent team teammate sessions: a person at that terminal can still start one.
The Remote Control page doesn't say it. My transcripts agree. Counting limit hits within 10 minutes of each other as one episode, I have 29. With Remote Control connected (2.1.263–2.1.288) the wait started in 0 of 10; without it (2.1.278–2.1.288), in 8 of 8. Six of the ten have a /remote-control is active line; four older ones (Sep 8–12) have bridge-session records instead. The other 11 aren't counted: five claude -p runs, one Desktop session, three monthly spend limits, one per-model limit, and one hit from before the feature arrived in 2.1.234.
One session flipped by itself: the wait started at 9/25 02:51, 9/25 07:01 and 9/26 08:55; I turned on /remote-control at 9/26 10:33, and none of the next three hits (12:57, 16:23, 19:06) started it. So the switch was Remote Control, not the subagents. The 2.1.289 bundle agrees: the function that arms the wait returns early when Remote Control is connected (an internal, so it can change).
Remote Control can also auto-connect for every session (Enable Remote Control for all sessions in /config, docs), so you may be using it without typing /remote-control.
The fix
I keep the built-in auto-continue on (autoContinueAtUsageLimit: true in my user settings; without it, a project file that has the key switches the feature off, docs) and add four hooks backed by one script, ~/.claude/hooks/usage-guard/usage_guard.py:
| Hook | Command | What it does |
|---|---|---|
Stop |
resume |
The turn ended with the 5-hour window at 100%: wait for the reset, then wake the session |
StopFailure (rate_limit) |
resume |
The limit cut the turn off: same |
UserPromptSubmit |
prompt |
Adds a usage line to every prompt. At weekly 85% or more, blocks the built-in continuation prompt |
PreToolUse (Agent, Task, Workflow, SendMessage) |
gate |
At weekly 85% or more, asks before dispatching an agent or continuing an existing one |
resume waits until 4 minutes after the reset, then reads the transcript. It does nothing if the built-in (or I) already resumed the session, or if I cancelled the built-in countdown with Esc, and it gives a built-in wait up to 20 minutes. Otherwise it checks that weekly usage is under 85%, writes a message to stderr and exits 2. With asyncRewake: true, exit 2 wakes the session with that message, even an idle one (hooks docs). It's on both events because a turn that hits the limit ends as StopFailure if it's cut off and as a normal Stop if it finishes first; a per-session, per-reset marker keeps the second one from waiting too.
My ~/.claude/settings.json, with the other hooks left out:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 $HOME/.claude/hooks/usage-guard/usage_guard.py resume",
"asyncRewake": true,
"timeout": 86400
}
]
}
],
"PreToolUse": [
{
"matcher": "Agent|Task|Workflow|SendMessage",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 $HOME/.claude/hooks/usage-guard/usage_guard.py gate || true",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 $HOME/.claude/hooks/usage-guard/usage_guard.py prompt || true",
"timeout": 5
}
]
}
],
"StopFailure": [
{
"matcher": "rate_limit",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 $HOME/.claude/hooks/usage-guard/usage_guard.py resume",
"asyncRewake": true,
"timeout": 86400
}
]
}
]
},
"autoContinueAtUsageLimit": true
}
The commands call /usr/bin/python3 (on macOS that needs the Xcode Command Line Tools); the script and tests need Python 3.9 or later. resume has timeout 86400 (24 hours) because asyncRewake hooks do get the timeout. The || true on prompt and gate is a safety net: if the script file goes missing, Python exits 2, and per the docs exit 2 on UserPromptSubmit rejects the prompt, so every prompt would be blocked. resume can't have it, because exit 2 is its wake signal.
The numbers come from rate_limits in the JSON Claude Code feeds your statusline script (Pro and Max only). You need jq and a statusLine command in settings.json (docs). These are the first lines of my ~/.claude/statusline-command.sh; if yours already has input=$(cat), add the rest right after it and don't read stdin twice, because the second read is empty and blanks the statusline.
input=$(cat)
# Cache the plan's rate limits (weekly %, 5-hour %) so a long-running session
# can pause itself at a threshold. Account-wide, so any session's value will do.
rl_cache="$HOME/.claude/rate-limits.json"
echo "$input" | jq -c 'select(.rate_limits != null) | {rate_limits, at: (now | floor)}' > "$rl_cache.$$" 2>/dev/null \
&& [ -s "$rl_cache.$$" ] && mv "$rl_cache.$$" "$rl_cache" || rm -f "$rl_cache.$$"
They're account-wide, so any session's refresh writes the same values. My ~/.claude/CLAUDE.md tells the model to end each final reply (main session only) with a usage line and to stop autonomous work at weekly 85%.
To install it, save the script below as ~/.claude/hooks/usage-guard/usage_guard.py and add the two pieces above.
Full usage_guard.py (383 lines)
#!/usr/bin/env python3
"""usage-guard: keep a weekly reserve, and resume sessions after the 5-hour limit resets.
Reads ~/.claude/rate-limits.json, which ~/.claude/statusline-command.sh writes from the
statusline's `rate_limits` field (claude.ai plans only). Subcommands:
usage print the current numbers, for you or the model
prompt UserPromptSubmit hook: add the numbers as context; block the built-in
auto-continue prompt when weekly usage is at or above the stop line
gate PreToolUse hook (Agent|Task|Workflow|SendMessage): ask before dispatching
or continuing an agent at or above it
resume Stop and StopFailure(rate_limit) hook, run with asyncRewake: wait for the
5-hour reset, then exit 2 to wake the session, unless it already resumed,
the built-in wait was cancelled, or weekly usage is at or above the stop line
The built-in autoContinueAtUsageLimit never starts its wait while Remote Control is
active (documented; checked in 2.1.288/2.1.289); `resume` covers those sessions. In
other sessions the built-in usually resumes first and `resume` stands down.
"""
import datetime
import json
import os
import re
import shutil
import subprocess
import sys
import time
STOP = float(os.environ.get("USAGE_GUARD_WEEKLY_STOP", "85"))
CACHE = os.path.expanduser(os.environ.get("USAGE_GUARD_CACHE", "~/.claude/rate-limits.json"))
STATE = os.path.expanduser(os.environ.get("USAGE_GUARD_STATE", "~/.claude/usage-guard"))
# Wake this long after the reset. The built-in auto-continue resumes about 1-2 min
# after it, so by then a session it resumed has already written to its transcript.
DELAY = int(os.environ.get("USAGE_GUARD_RESUME_DELAY", "240"))
# Sleep in short steps and re-check the wall clock, so a Mac that slept through the
# reset still resumes within one step of waking.
POLL = int(os.environ.get("USAGE_GUARD_POLL", "30"))
# When the built-in started its own wait, give it this long after the reset before
# waking the session ourselves (its jitter is server-tunable).
BUILTIN_GRACE = int(os.environ.get("USAGE_GUARD_BUILTIN_GRACE", "1200"))
MAX_WAIT = 5.5 * 3600 # how far out a reset may be; the hook's timeout in settings.json is 24 h
MAX_WAKES_PER_DAY = 6
AUTO_CONTINUE = ("Your claude.ai usage limit has reset", "Your claude.ai usage is available again")
CONTINUATION = "Continue the task you were working on when the limit was reached"
def to_epoch(value):
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value)
except ValueError:
pass
try:
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
return None
def load(now=None):
"""Current usage, or None without a cache. A window whose reset has passed reads as None."""
now = time.time() if now is None else now
try:
with open(CACHE) as f:
data = json.load(f)
except (OSError, ValueError):
return None
limits = data.get("rate_limits") or {}
def window(key):
w = limits.get(key) or {}
used, reset = w.get("used_percentage"), to_epoch(w.get("resets_at"))
if not isinstance(used, (int, float)) or (reset is not None and reset <= now):
used = None
return used, reset
h5, h5_reset = window("five_hour")
wk, wk_reset = window("seven_day")
return {"h5": h5, "h5_reset": h5_reset, "wk": wk, "wk_reset": wk_reset, "at": to_epoch(data.get("at"))}
def pct(value):
return "{:g}".format(round(value, 1))
def clock(ts, with_date=False):
if ts is None:
return "?"
return time.strftime("%m/%d %H:%M" if with_date else "%H:%M", time.localtime(ts))
def summary(u, now=None):
now = time.time() if now is None else now
weekly = "weekly {}% (resets {})".format(pct(u["wk"]), clock(u["wk_reset"], True)) if u["wk"] is not None else "weekly ?"
if u["h5"] is not None:
five = "5-hour {}% (resets {})".format(pct(u["h5"]), clock(u["h5_reset"]))
else:
five = "5-hour: new window, no reading yet"
age = " · read {} min ago".format(int((now - u["at"]) // 60)) if u["at"] else ""
return "{} · {}{}".format(weekly, five, age)
def over_stop(u):
return bool(u) and u["wk"] is not None and u["wk"] >= STOP
def emit(obj):
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
def log(sid, text):
try:
os.makedirs(STATE, exist_ok=True)
path = os.path.join(STATE, "log")
if os.path.exists(path) and os.path.getsize(path) > 512 * 1024:
os.replace(path, path + ".1")
with open(path, "a") as f:
f.write("{} {} {}\n".format(time.strftime("%Y-%m-%d %H:%M:%S"), (sid or "-")[:8], text))
except OSError:
pass
def notify(text):
if os.environ.get("USAGE_GUARD_NOTIFY", "1") == "0":
return
try:
if shutil.which("terminal-notifier"):
cmd = ["terminal-notifier", "-title", "Claude Code — usage-guard", "-message", text]
else:
cmd = ["osascript", "-e", "display notification {} with title \"Claude Code — usage-guard\"".format(
json.dumps(text, ensure_ascii=False))]
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError:
pass
def cmd_usage():
u = load()
if u is None:
print("usage-guard: no reading yet ({} is written by the statusline)".format(CACHE))
return 1
verdict = "STOP autonomous work" if over_stop(u) else "ok"
print("{} · stop line {}% → {}".format(summary(u), pct(STOP), verdict))
return 0
def is_continuation(prompt):
"""The built-in's continuation prompt, also if the server rewords its opening. A longer
message that only quotes the sentence (a report, a question about it) is not one."""
if prompt.startswith(AUTO_CONTINUE):
return True
at = prompt.find(CONTINUATION)
return 0 <= at < 150 and len(prompt) < 400 and not prompt.startswith("<")
def cmd_prompt(inp):
u = load()
if u is None:
return 0
prompt = (inp.get("prompt") or "").lstrip()
if over_stop(u) and is_continuation(prompt):
log(inp.get("session_id"), "blocked the built-in auto-continue: weekly {}%".format(pct(u["wk"])))
notify("Weekly usage {}% ≥ {}%: auto-continue skipped, session stays paused.".format(pct(u["wk"]), pct(STOP)))
emit({"decision": "block", "reason": "usage-guard: weekly usage is {}%, at or above the {}% reserve line, "
"so the automatic continue was stopped. Send a prompt to continue anyway.".format(pct(u["wk"]), pct(STOP))})
return 0
context = "usage-guard: {}. Autonomous work stops at weekly {}%.".format(summary(u), pct(STOP))
if over_stop(u):
context += (" Weekly usage is at or above that line: answer this prompt, but start no new"
" autonomous or fan-out work without asking.")
emit({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": context}})
return 0
def cmd_gate(inp):
u = load()
if not over_stop(u):
return 0
target = str((inp.get("tool_input") or {}).get("to") or "").strip()
if inp.get("tool_name") == "SendMessage" and target == "main":
return 0 # a background agent reporting to the main session
emit({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "usage-guard: weekly usage is {}% (reserve line {}%). Approve to run {} anyway, "
"or deny to keep the reserve.".format(pct(u["wk"]), pct(STOP), inp.get("tool_name") or "it"),
}})
return 0
def classify(message, u):
m = message.lower()
if "session limit" in m:
return "five_hour"
if "weekly" in m:
return "weekly"
if u and u["h5"] is not None and u["h5"] >= 100:
return "five_hour"
if u and u["wk"] is not None and u["wk"] >= 100:
return "weekly"
return "other" # spend limits, per-model limits, unknown
def parse_reset(message, now):
"""'You've hit your session limit · resets 4:40am (Asia/Tokyo)' -> the next 4:40 in that zone."""
m = re.search(r"resets\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*([ap]m)\b(?:\s*\(([^)]+)\))?", message, re.I)
if not m:
return None
hour = int(m.group(1)) % 12 + (12 if m.group(3).lower() == "pm" else 0)
minute = int(m.group(2) or 0)
zone = None
if m.group(4):
try:
from zoneinfo import ZoneInfo
zone = ZoneInfo(m.group(4))
except Exception:
zone = None
base = datetime.datetime.fromtimestamp(now, zone) if zone else datetime.datetime.fromtimestamp(now)
at = base.replace(hour=hour, minute=minute, second=0, microsecond=0)
if at.timestamp() <= now:
at += datetime.timedelta(days=1)
return at.timestamp()
def recent_wakes(sid, now):
"""Count this session's armed resets in the last day, and drop markers older than three days."""
armed = os.path.join(STATE, "armed")
count = 0
try:
for name in os.listdir(armed):
path = os.path.join(armed, name)
age = now - os.path.getmtime(path)
if age > 3 * 86400:
shutil.rmtree(path, ignore_errors=True)
elif name.startswith(sid + "-") and age < 86400:
count += 1
except OSError:
pass
return count
def sleep_until(ts):
while True:
left = ts - time.time()
if left <= 0:
return
time.sleep(min(POLL, left))
def transcript_state(path, reset, armed_at):
"""What the session did since the limit hit, read from the transcript's tail.
resumed: a turn ran after the reset (a typed prompt, the built-in's continuation, a
task notification). Only timestamped turn entries count: Claude Code also appends
untimestamped lines, e.g. when a Remote Control client connects.
cancelled: the built-in wait was cancelled (Esc, or the session moved elsewhere).
builtin_armed: the built-in started its own wait for this limit.
"""
state = {"resumed": False, "cancelled": False, "builtin_armed": False}
try:
with open(path, "rb") as f:
f.seek(0, 2)
f.seek(max(0, f.tell() - 4 * 1024 * 1024))
lines = f.read().splitlines()
except (OSError, TypeError):
return state
for raw in lines:
try:
entry = json.loads(raw)
except ValueError:
continue
ts = to_epoch(entry.get("timestamp")) if isinstance(entry, dict) else None
if ts is None:
continue
kind = entry.get("type")
if kind in ("user", "assistant", "queue-operation") and ts > reset:
state["resumed"] = True
elif kind == "system" and ts >= armed_at - 120:
text = str(entry.get("content") or "")
if text.startswith("Automatic continue cancelled"):
state["cancelled"] = True
elif "continuing automatically at" in text:
state["builtin_armed"] = True
return state
def cmd_resume(inp):
# Hold only interactive CLI sessions. In `claude -p` an asyncRewake hook runs in the
# foreground, so waiting there would hang the run for hours.
if os.environ.get("CLAUDE_CODE_ENTRYPOINT") != "cli":
return 0
event = inp.get("hook_event_name")
sid = inp.get("session_id") or "unknown"
message = inp.get("last_assistant_message") or ""
now = time.time()
u = load(now)
if event == "StopFailure":
if inp.get("error") != "rate_limit":
return 0
kind = classify(message, u)
elif event == "Stop":
if not u or u["h5"] is None or u["h5"] < 100:
return 0 # the usual case: a turn ended below the limit
kind = "five_hour"
else:
return 0
if kind == "weekly":
log(sid, "weekly limit hit: no auto-resume")
notify("Weekly limit reached: this session will not resume on its own.")
return 0
if kind != "five_hour":
log(sid, "rate limit is not the 5-hour window ({}): no auto-resume".format(message.splitlines()[0][:80] if message else "?"))
return 0
reset = u["h5_reset"] if u and u["h5_reset"] and u["h5_reset"] > now else parse_reset(message, now)
if reset is None or reset - now > MAX_WAIT:
log(sid, "no usable 5-hour reset time: no auto-resume")
return 0
if over_stop(u):
log(sid, "weekly {}% >= {}%: staying paused after the reset".format(pct(u["wk"]), pct(STOP)))
notify("Weekly usage {}% ≥ {}%: session paused, no auto-resume.".format(pct(u["wk"]), pct(STOP)))
return 0
if recent_wakes(sid, now) >= MAX_WAKES_PER_DAY:
log(sid, "{} resumes in the last day: giving up".format(MAX_WAKES_PER_DAY))
return 0
try:
os.makedirs(os.path.join(STATE, "armed", "{}-{}".format(sid, int(reset))))
except FileExistsError:
return 0 # another Stop/StopFailure already armed this reset
except OSError:
return 0
armed_at = time.time()
target = reset + DELAY
log(sid, "armed via {}: wake at {}".format(event, clock(target, True)))
transcript = inp.get("transcript_path")
while True:
sleep_until(target)
state = transcript_state(transcript, reset, armed_at)
if state["resumed"]:
log(sid, "already resumed (built-in auto-continue or a prompt): not waking")
return 0
if state["cancelled"]:
log(sid, "the built-in wait was cancelled (Esc, or the session moved): not waking")
return 0
if state["builtin_armed"] and time.time() < reset + BUILTIN_GRACE:
target = min(time.time() + 2 * POLL, reset + BUILTIN_GRACE) # its continuation may still come
continue
break
u = load()
if over_stop(u):
log(sid, "weekly {}% >= {}% after the reset: staying paused".format(pct(u["wk"]), pct(STOP)))
notify("Weekly usage {}% ≥ {}%: session paused, no auto-resume.".format(pct(u["wk"]), pct(STOP)))
return 0
weekly = "{}%".format(pct(u["wk"])) if u and u["wk"] is not None else "unknown"
sys.stderr.write(
"usage-guard: the 5-hour usage limit reset at {}. Weekly usage is {}; autonomous work stops at {}%. "
"Continue the task you were working on when the limit was reached, without redoing finished work. "
"If that task was already done, reply with one short line and stop.\n".format(clock(reset), weekly, pct(STOP)))
log(sid, "woke the session")
return 2
def main():
cmd = sys.argv[1] if len(sys.argv) > 1 else "usage"
if cmd == "usage":
return cmd_usage()
handler = {"prompt": cmd_prompt, "gate": cmd_gate, "resume": cmd_resume}.get(cmd)
if handler is None:
sys.stderr.write("usage: usage_guard.py [usage|prompt|gate|resume]\n")
return 1
try:
inp = json.load(sys.stdin)
return handler(inp if isinstance(inp, dict) else {})
except Exception as exc: # a broken guard must never block a prompt, a tool, or a stop
log("-", "{} failed: {!r}".format(cmd, exc))
return 0
if __name__ == "__main__":
sys.exit(main())
Checking that it works
usage prints the cached numbers and a verdict:
python3 ~/.claude/hooks/usage-guard/usage_guard.py usage
weekly 54% (resets 10/08 05:00) · 5-hour 12% (resets 21:10) · read 1 min ago · stop line 85% → ok
read 1 min ago is the cache's age. With no cache it prints no reading yet and exits 1, so the statusline lines aren't working. To see the stop verdict, set the line below your current usage (mine was 54%):
USAGE_GUARD_WEEKLY_STOP=50 python3 ~/.claude/hooks/usage-guard/usage_guard.py usage
The line then ends with stop line 50% → STOP autonomous work.
The log is ~/.claude/usage-guard/log: a timestamp, the first 8 characters of the session id and a message such as armed via … (a reset wait started), woke the session or already resumed …. The first event creates the file; on my machine nothing has happened yet, so it doesn't exist.
The 35 unit tests use a fake cache and a temporary state directory, never your real files:
bash ~/.claude/hooks/usage-guard/usage_guard.test.sh
They end with passed 35, failed 0 (about 38 seconds).
Full usage_guard.test.sh (164 lines)
#!/usr/bin/env bash
# Tests for usage_guard.py. Uses a fake cache and state dir; never touches the real ones.
set -u
G="$(cd "$(dirname "$0")" && pwd)/usage_guard.py"
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
export USAGE_GUARD_CACHE="$T/rate-limits.json" USAGE_GUARD_STATE="$T/state" USAGE_GUARD_NOTIFY=0
export USAGE_GUARD_RESUME_DELAY=1 USAGE_GUARD_POLL=1
pass=0; fail=0
ok() { pass=$((pass+1)); }
bad() { fail=$((fail+1)); echo "FAIL: $1"; }
check() { if eval "$2"; then ok; else bad "$1"; fi; }
cache() { # h5 h5_reset_offset wk [wk_reset_offset]
local now; now=$(date +%s)
printf '{"rate_limits":{"five_hour":{"used_percentage":%s,"resets_at":%s},"seven_day":{"used_percentage":%s,"resets_at":%s}},"at":%s}' \
"$1" $((now + $2)) "$3" $((now + ${4:-300000})) "$now" > "$USAGE_GUARD_CACHE"
}
hook() { # subcommand json -> sets out, rc
out=$(printf '%s' "$2" | python3 "$G" "$1" 2>"$T/err"); rc=$?; err=$(cat "$T/err")
}
# usage
cache 40 3600 47
out=$(python3 "$G" usage); check "usage prints numbers" '[[ "$out" == *"weekly 47%"* && "$out" == *"5-hour 40%"* && "$out" == *"→ ok"* ]]'
rm -f "$USAGE_GUARD_CACHE"
python3 "$G" usage >/dev/null; check "usage without cache exits 1" '[ $? -eq 1 ]'
# prompt
hook prompt '{"prompt":"hi"}'; check "prompt without cache is silent" '[ $rc -eq 0 ] && [ -z "$out" ]'
cache 40 3600 47
hook prompt '{"prompt":"hi"}'
check "prompt adds context" '[ $rc -eq 0 ] && [[ "$out" == *additionalContext* && "$out" == *"weekly 47%"* ]]'
check "prompt below line has no stop note" '[[ "$out" != *"at or above"* ]]'
hook prompt '{"prompt":"Your claude.ai usage limit has reset. Continue the task you were working on"}'
check "auto-continue passes below the line" '[[ "$out" == *additionalContext* && "$out" != *"\"block\""* ]]'
cache 40 3600 90
hook prompt '{"prompt":"Your claude.ai usage limit has reset. Continue the task you were working on"}'
check "auto-continue blocked at 90%" '[ $rc -eq 0 ] && [[ "$out" == *"\"decision\": \"block\""* ]]'
hook prompt '{"prompt":"Your claude.ai usage is available again before the usage-limit reset."}'
check "early auto-continue blocked at 90%" '[[ "$out" == *"\"block\""* ]]'
hook prompt '{"prompt":"what is 2+2"}'
check "user prompt at 90% passes with a note" '[[ "$out" == *additionalContext* && "$out" == *"at or above"* ]]'
cache 40 3600 90 -10
hook prompt '{"prompt":"Your claude.ai usage limit has reset."}'
check "stale weekly (window over) does not block" '[[ "$out" != *"\"block\""* && "$out" == *"weekly ?"* ]]'
# gate
cache 40 3600 84.9
hook gate '{"tool_name":"Agent"}'; check "gate silent below line" '[ $rc -eq 0 ] && [ -z "$out" ]'
cache 40 3600 85
hook gate '{"tool_name":"Agent"}'; check "gate asks at 85%" '[[ "$out" == *"\"permissionDecision\": \"ask\""* && "$out" == *Agent* ]]'
hook gate '{"tool_name":"SendMessage","tool_input":{"to":"coder-7","message":"fix round 2"}}'
check "gate asks before continuing an agent" '[[ "$out" == *"\"ask\""* ]]'
hook gate '{"tool_name":"SendMessage","tool_input":{"to":"main","message":"done"}}'
check "gate lets an agent report to main" '[ $rc -eq 0 ] && [ -z "$out" ]'
cache 40 3600 90
hook prompt '{"prompt":"[usage] Continue the task you were working on when the limit was reached; do not repeat work."}'
check "reworded continuation still blocked" '[[ "$out" == *"\"block\""* ]]'
long=$(python3 -c 'print("Report: the hook matches \"Continue the task you were working on when the limit was reached\" and " + "x" * 400)')
hook prompt "$(python3 -c 'import json,sys;print(json.dumps({"prompt":sys.argv[1]}))' "$long")"
check "a long report quoting the sentence passes" '[[ "$out" != *"\"block\""* && "$out" == *additionalContext* ]]'
hook prompt '{"prompt":"<task-notification> Continue the task you were working on when the limit was reached </task-notification>"}'
check "a task notification quoting it passes" '[[ "$out" != *"\"block\""* ]]'
# resume: guards
cache 101 3 50
export CLAUDE_CODE_ENTRYPOINT=sdk-cli
hook resume '{"hook_event_name":"Stop","session_id":"s1"}'
check "resume skips non-cli entrypoints" '[ $rc -eq 0 ] && [ ! -d "$USAGE_GUARD_STATE/armed" ]'
export CLAUDE_CODE_ENTRYPOINT=cli
cache 60 3 50
start=$(date +%s); hook resume '{"hook_event_name":"Stop","session_id":"s1"}'
check "Stop below the limit exits at once" '[ $rc -eq 0 ] && [ $(( $(date +%s) - start )) -le 1 ]'
hook resume '{"hook_event_name":"StopFailure","error":"overloaded","session_id":"s1"}'
check "StopFailure other error exits" '[ $rc -eq 0 ] && [ ! -d "$USAGE_GUARD_STATE/armed" ]'
hook resume '{"hook_event_name":"StopFailure","error":"rate_limit","session_id":"s1","last_assistant_message":"You have hit your weekly limit · resets Oct 8, 5am"}'
check "weekly limit is not resumed" '[ $rc -eq 0 ] && grep -q "weekly limit hit" "$USAGE_GUARD_STATE/log"'
cache 60 3 50
hook resume '{"hook_event_name":"StopFailure","error":"rate_limit","session_id":"s1","last_assistant_message":"You have reached your Fable limit."}'
check "per-model limit is not resumed" '[ $rc -eq 0 ] && grep -q "not the 5-hour window" "$USAGE_GUARD_STATE/log"'
cache 101 3 90
hook resume '{"hook_event_name":"Stop","session_id":"s1"}'
check "weekly over the line stays paused" '[ $rc -eq 0 ] && grep -q "staying paused" "$USAGE_GUARD_STATE/log"'
# resume: wakes after the reset
touch "$T/transcript.jsonl"; sleep 1
cache 101 3 50
start=$(date +%s)
hook resume "{\"hook_event_name\":\"Stop\",\"session_id\":\"s2\",\"transcript_path\":\"$T/transcript.jsonl\"}"
took=$(( $(date +%s) - start ))
check "Stop at the limit wakes with exit 2" '[ $rc -eq 2 ] && [[ "$err" == *"5-hour usage limit reset"* && "$err" == *"Weekly usage is 50%"* ]]'
check "it waited for reset + delay" '[ $took -ge 3 ] && [ $took -le 7 ]'
# resume: what the transcript says after the limit hit
entry() { # type [content] -> one timestamped transcript line, like Claude Code writes
python3 -c 'import json,sys,datetime;print(json.dumps({"type":sys.argv[1],"timestamp":datetime.datetime.utcnow().isoformat(timespec="milliseconds")+"Z","content":sys.argv[2]}))' "$1" "${2:-}"
}
sf() { # session_id transcript -> StopFailure input for a session limit
printf '{"hook_event_name":"StopFailure","error":"rate_limit","session_id":"%s","transcript_path":"%s","last_assistant_message":"You'"'"'ve hit your session limit · resets 4:40am (Asia/Tokyo)"}' "$1" "$2"
}
: > "$T/t3.jsonl"; cache 101 2 50
( sleep 2.5; entry user "Your claude.ai usage limit has reset. Continue the task" >> "$T/t3.jsonl" ) &
USAGE_GUARD_RESUME_DELAY=2 hook resume "$(sf s3 "$T/t3.jsonl")"
check "no wake when a turn ran after the reset" '[ $rc -eq 0 ] && grep -q "s3 already resumed" "$USAGE_GUARD_STATE/log"'
: > "$T/t6.jsonl"; cache 101 2 50
( sleep 2.5; echo '{"type":"bridge-session","sessionId":"x"}' >> "$T/t6.jsonl" ) &
USAGE_GUARD_RESUME_DELAY=2 hook resume "$(sf s6 "$T/t6.jsonl")"
check "an untimestamped bridge line does not count as resumed" '[ $rc -eq 2 ]'
: > "$T/t7.jsonl"; cache 101 2 50
( sleep 0.5; entry system "Automatic continue cancelled" >> "$T/t7.jsonl" ) &
USAGE_GUARD_RESUME_DELAY=2 hook resume "$(sf s7 "$T/t7.jsonl")"
check "Esc on the built-in countdown is respected" '[ $rc -eq 0 ] && grep -q "s7 the built-in wait was cancelled" "$USAGE_GUARD_STATE/log"'
: > "$T/t8.jsonl"; cache 101 2 50
( sleep 0.5; entry system "Usage limit reached · continuing automatically at 4:40am · esc to cancel" >> "$T/t8.jsonl" ) &
start=$(date +%s)
USAGE_GUARD_RESUME_DELAY=1 USAGE_GUARD_BUILTIN_GRACE=6 hook resume "$(sf s8 "$T/t8.jsonl")"
took=$(( $(date +%s) - start ))
check "a silent built-in gets its grace, then the guard wakes" '[ $rc -eq 2 ] && [ $took -ge 5 ] && [ $took -le 10 ]'
: > "$T/t9.jsonl"; cache 101 2 50
( sleep 0.5; entry system "Usage limit reached · continuing automatically at 4:40am · esc to cancel" >> "$T/t9.jsonl"; sleep 4; entry user "Your claude.ai usage limit has reset." >> "$T/t9.jsonl" ) &
USAGE_GUARD_RESUME_DELAY=1 USAGE_GUARD_BUILTIN_GRACE=30 hook resume "$(sf s9 "$T/t9.jsonl")"
check "a late built-in continuation makes the guard stand down" '[ $rc -eq 0 ] && grep -q "s9 already resumed" "$USAGE_GUARD_STATE/log"'
# resume: Stop and StopFailure for the same reset arm once
cache 101 3 50
printf '%s' "{\"hook_event_name\":\"StopFailure\",\"error\":\"rate_limit\",\"session_id\":\"s4\",\"last_assistant_message\":\"You've hit your session limit\"}" | python3 "$G" resume 2>/dev/null & p1=$!
sleep 0.5
start=$(date +%s); hook resume '{"hook_event_name":"Stop","session_id":"s4"}'
check "second hook for the same reset exits at once" '[ $rc -eq 0 ] && [ $(( $(date +%s) - start )) -le 1 ]'
wait $p1; check "first hook still wakes" '[ $? -eq 2 ]'
# resume: weekly crossed the line during the wait -> no wake
cache 101 2 50
( sleep 1; cache 101 -60 88 ) &
hook resume '{"hook_event_name":"Stop","session_id":"s5"}'
check "weekly crossing during the wait stays paused" '[ $rc -eq 0 ] && grep -q "after the reset: staying paused" "$USAGE_GUARD_STATE/log"'
# reset time parsed from the message when the cache has none
out=$(USAGE_GUARD_CACHE=/nonexistent python3 - "$G" <<'EOF'
import importlib.util, sys, datetime
spec = importlib.util.spec_from_file_location("g", sys.argv[1]); g = importlib.util.module_from_spec(spec); spec.loader.exec_module(g)
from zoneinfo import ZoneInfo
tz = ZoneInfo("Asia/Tokyo")
now = datetime.datetime(2026, 10, 4, 1, 2, tzinfo=tz).timestamp()
r = g.parse_reset("You've hit your session limit · resets 4:40am (Asia/Tokyo)", now)
print(datetime.datetime.fromtimestamp(r, tz).strftime("%m/%d %H:%M"))
r = g.parse_reset("You've hit your session limit · resets 11:40pm (Asia/Tokyo)", datetime.datetime(2026, 10, 3, 22, 43, tzinfo=tz).timestamp())
print(datetime.datetime.fromtimestamp(r, tz).strftime("%m/%d %H:%M"))
r = g.parse_reset("resets 12:10am (Asia/Tokyo)", datetime.datetime(2026, 10, 3, 23, 0, tzinfo=tz).timestamp())
print(datetime.datetime.fromtimestamp(r, tz).strftime("%m/%d %H:%M"))
print(g.parse_reset("no time here", now))
EOF
)
check "parse_reset reads the limit message" '[ "$out" = "$(printf "10/04 04:40\n10/03 23:40\n10/04 00:10\nNone")" ]'
# malformed input never blocks
out=$(echo 'not json' | python3 "$G" prompt); check "malformed input exits 0" '[ $? -eq 0 ]'
echo "passed $pass, failed $fail"
[ $fail -eq 0 ]
I also checked the wake in two real interactive sessions: a hook that sleeps 20 seconds, writes to stderr and exits 2, once on Stop and once on a StopFailure forced with a nonexistent model (model_not_found). Both times a new turn started within a second of the hook exiting, and the reply came 1–2 seconds later. The StopFailure reply was the same error again, since the model is still missing.
Caveats
-
Not tested live. A real 5-hour reset with the final hooks, the 85% paths (continuation block,
gateconfirmation, staying paused after the reset) and a realrate_limitStopFailurepayload ran only in unit tests, against fake data. The Esc-cancel handling hasn't met a real transcript either (theAutomatic continue cancelledtext is in the docs and the 2.1.289 bundle). -
The 85% brake is partial. By design (untested live) it blocks the continuation prompt and the hook wake, and asks before dispatching or continuing a worker. A short prompt with
Continue the task you were working on when the limit was reachednear its start counts as the continuation and is blocked; a long report that merely quotes it passes. Other autonomous work, like a long turn that dispatches nothing or a/loopprompt, depends on the model followingCLAUDE.md. -
Freshness. The cache only changes when some session's statusline refreshes (I haven't set
refreshInterval), andread N min agoshows its age.UserPromptSubmitalso fires on subagent reports, so the line is added often in conductor mode. -
Internals. The code-level check that skips Remote Control sessions (an early return in the 2.1.289 bundle) and
asyncRewakehooks running in the foreground underclaude -pcome from reading that code; they aren't documented and can change. In my logs the built-in resumed about 1–2 minutes after the reset (71–99 s in seven cases). The docs sayStopFailureoutput is ignored, yet anasyncRewakehook still woke the session (observed, not documented). -
Small things. The wake is labelled
Stop hook feedbackin the UI (cosmetic). Quit Claude Code during the wait and nothing resumes. Weekly limits are never auto-resumed.
Versions tested
- Claude Code 2.1.289: probes, code reading, unit tests
- Limit history: transcripts from 2.1.219 to 2.1.288 (29 episodes)
- claude.ai Max plan, macOS,
/usr/bin/python33.9.6, jq 1.7.1 - Not tested: anything but macOS, IDE extensions, the Desktop app
Top comments (0)