Back to the library

Auto-Resume Claude After a Usage Limit

guide

Note: The library → github.com/amegally/claude-limit-runner One bash file. You give it a task, it runs Claude Code headless, catches the usage limit, waits for the reset, checks the limit actually lifted, then picks the work back up. It holds your Mac awake while it waits.

I wanted to point Claude Code at a long job, go to bed, and find it still working in the morning. There's no setting for that. You hit the 5-hour or the weekly limit, it stops, it tells you what time it resets, and there it sits until you come back and press go.

So I wrapped it in a bash script. Below is the prompt I started from, and then every gotcha I hit after it. The prompt already has the fixes folded in, so you don't have to rediscover them the way I did.

The first one killed two overnight runs before I noticed.


The starter prompt

Paste this into Claude Code. One file, no build step.

Write a bash script `keep-running.sh` that keeps a long Claude Code task alive
across usage limits.

It runs `claude -p --output-format json` in a loop: on a usage limit, wait for
the reset, confirm it lifted, resume the work. Stop when the task is actually
done. One file, macOS/Linux, `jq` is fine.

Watch out for:

- A limit looks like a success. A real 429 returns `subtype: "success"` with
  `is_error: true` and `terminal_reason: "api_error"`, and the reason is prose in
  `result`. Check `api_error_status == 429` first. Only accept success when
  `subtype == "success"` and `is_error == false`.
- The reset time isn't in the JSON, only in that prose. Treat it as a hint —
  always send one cheap probe call before resuming, using the task's model
  (weekly limits are per-model, so a Haiku probe lies about Opus).
- Don't resume with `--resume`. It replays the whole transcript and every leg
  re-pays for it. Start a fresh session with a short brief built locally from
  `git log` and the uncommitted diff.
- Sort the stop reasons. `max_turns` = resume for free. `budget_exhausted` /
  `prompt_too_long` = stop, retrying can't help. Permission denied = loud log
  line, since headless Claude can't show a prompt.
- A finished session isn't a finished task. Take `--done-when '<shell cmd>'`
  that exits 0 only when the work is really done. If nothing changes across two
  iterations, stop and say "stalled".
- Traps don't break loops — bash runs the handler and keeps going. Set a flag
  and exit.
- `caffeinate -i -s -w <pid>` to hold the Mac awake while it waits.

Test it with a stub `claude` on PATH emitting canned JSON, so I never need a real
limit to test the loop.

The gotchas

These are the real ones, from v0.1 to v0.2.2 across two days (21–22 July 2026).

1. The limit shows up dressed as a success

A real 429 comes back looking like this:

"subtype": "success",
"is_error": true,
"terminal_reason": "api_error",
"api_error_status": 429,
"result": "You've hit your session limit · resets 5:40am"

My first version checked subtype first, saw "success", logged "task completed", exited 0. Two overnight runs died that way and both got written down as wins.

Check api_error_status == 429 before anything else. Only call it a success when subtype == "success" and is_error == false.

2. You can't test the thing without a real limit

I shipped v0.1 with "limit detection unverified against a real limit" sitting right there in the README, which is exactly how #1 got to run overnight. Catch one real 429 envelope, save it as a fixture, and put a stub claude on your PATH that emits canned JSON. Now the whole loop is testable in about five seconds instead of once every five hours.

3. The reset time is prose, not data

It never appears as a field. It's in the result text, as English ("resets 5:40am"). Parse it if you want, but treat it as a hint — the only thing that actually tells you the limit lifted is a probe call that goes through.

4. Probe with the task's model

Weekly limits are per model. I probed with Haiku while the work ran on Opus, so the probe came back clear, the runner resumed, and the task hit the wall again immediately. One wasted iteration every round, forever.

5. --resume re-pays for the whole conversation

It replays the entire prior transcript, so every later leg pays again for the whole prefix. On an overnight run that is the single biggest cost, by a lot.

What I do instead: start a fresh session carrying a short brief assembled locally — the original task, the commits so far marked do not redo these, the uncommitted working-tree changes, and the tail of the last session's transcript. It's built from git and the transcript file, so it costs no extra API call. It's lossy on purpose. If your task hangs on subtle in-session reasoning, replay the transcript and pay for it.

6. Sort out why it stopped before you react

max_turns means resume, and that one is free. budget_exhausted and prompt_too_long mean stop — retrying cannot help. Permission denials are the sneaky one: the run looks fine and no work happened (one of mine racked up 48). Order the checks limit → fatal → continuable → success.

7. A finished session is not a finished task

Models wrap up early on long task lists. --done-when '<shell command>' makes "done" mechanical: if the check fails, hand Claude the failure and send it back to work. Two iterations with nothing changing means stalled, so stop and say so.

8. Resume was looking up the wrong session id

The recovery brief went hunting for the transcript after the session id had already rotated, found nothing, and so context recovery never ran at all. A test passed against that broken wiring, which was its own small lesson.

9. Two that cost me a night each

  • A trap on SIGTERM doesn't break the loop. Bash runs your handler and then carries right on looping. Set a flag and exit.
  • --max-wait should budget time spent blocked, not wall clock since launch. Mine counted productive runtime, so a task that worked for hours and then hit a limit gave up instantly.

And if you want it to survive the night: caffeinate -i -s -w <pid> while it waits, lid open and plugged in. A closed lid still sleeps.


Or skip the build and use mine

git clone https://github.com/amegally/claude-limit-runner.git
cd claude-limit-runner
./install.sh
# foreground, watch it work
claude-limit-runner "refactor the parser in src/parse.py and keep the tests green"

# the overnight shape: detached, with a mechanical definition of done
claude-limit-runner on "work through every task in docs/plan.md" \
  --done-when '! grep -q "^- \[ \]" docs/plan.md' \
  --max-wait 12h --notify \
  -- --permission-mode acceptEdits

claude-limit-runner status
claude-limit-runner logs -f
claude-limit-runner off

Installing also drops a /keep-going skill into ~/.claude/skills/, so from inside a Claude Code session you can just say:

/keep-going finish the migration in src/db, then run the tests

The flags that matter:

Flag Default What it does
--done-when <cmd> shell command that exits 0 only when the task is really done
--resume-mode <mode> summary summary = fresh session + written brief; fork = replay the whole transcript
--max-wait <dur> 8h budget for time spent blocked on limits (not wall clock since launch)
--retry-interval <dur> 30m how often to check whether the limit has lifted
--notify off macOS notification on every state transition

Everything after -- goes straight through to claude. It's a scheduling wrapper and nothing else — it has no opinion about what Claude is allowed to do.

Get the next guide when it's ready.

You're on the list.