DEV Community

day
day

Posted on

Claude Code's usage-limit auto-continue doesn't run in Remote Control sessions. Hooks resume them.

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, Stop and StopFailure, with asyncRewake. They wait for the 5-hour reset and wake the session with exit 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_limit StopFailure payload. 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.
Enter fullscreen mode Exit fullscreen mode

Or run it yourself:

git clone https://github.com/ryoshumei/claude-code-usage-guard ~/.claude/usage-guard-src && ~/.claude/usage-guard-src/install.sh
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.$$"
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

Checking that it works

usage prints the cached numbers and a verdict:

python3 ~/.claude/hooks/usage-guard/usage_guard.py usage
Enter fullscreen mode Exit fullscreen mode
weekly 54% (resets 10/08 05:00) · 5-hour 12% (resets 21:10) · read 1 min ago · stop line 85% → ok
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ]
Enter fullscreen mode Exit fullscreen mode

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, gate confirmation, staying paused after the reset) and a real rate_limit StopFailure payload ran only in unit tests, against fake data. The Esc-cancel handling hasn't met a real transcript either (the Automatic continue cancelled text 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 reached near 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 /loop prompt, depends on the model following CLAUDE.md.
  • Freshness. The cache only changes when some session's statusline refreshes (I haven't set refreshInterval), and read N min ago shows its age. UserPromptSubmit also 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 asyncRewake hooks running in the foreground under claude -p come 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 say StopFailure output is ignored, yet an asyncRewake hook still woke the session (observed, not documented).
  • Small things. The wake is labelled Stop hook feedback in 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/python3 3.9.6, jq 1.7.1
  • Not tested: anything but macOS, IDE extensions, the Desktop app

Top comments (0)