If you use Claude Code for long agentic sessions — code generation, refactoring, automated research — you have almost certainly hit this screen:
You're out of extra usage · resets 7:30pm (Asia/Calcutta)
> continue > exit without saving
Claude's rate limits reset on a rolling 5-hour and 7-day window. When either window fills, the CLI drops you into an exit menu. If you pick continue, it asks you the same question again. Eventually you just have to quit, wait, and come back later.
The problem: "come back later" is vague. You don't know if "later" is 12 minutes or 4 hours. You have to manually resume the session with the right UUID. If you forget, context is lost.
I got tired of babysitting this, so I built Smart Resume — a 350-line bash/zsh wrapper that handles the whole lifecycle automatically.
What It Does (30-second version)
╭────────────────────────────────────────────────────────────────────╮
│ Smart Resume for Claude Code · Karthikeyan N · MIT License │
╰────────────────────────────────────────────────────────────────────╯
⚡ Rate limit hit
──────────────────────────────────────────────────────────────────────
Session "rl-2026-04-12-projects-myapp"
Resets 00:30:00 IST (2026-04-13)
Waking 00:31:00 IST (+60s buffer)
──────────────────────────────────────────────────────────────────────
Press Ctrl-C to cancel
Waiting until reset. Remaining: 4 min 23s
╭──────────────────────────────────────────────────╮
│ ✓ Resuming "rl-2026-04-12-projects-myapp" │
╰──────────────────────────────────────────────────╯
You type claude as usual. When a rate limit hits, instead of leaving you stranded at a menu, the wrapper:
- Intercepts Claude's exit
- Parses the exact reset timestamp from Claude's session JSONL
- Displays a countdown in a single updating terminal line
- Sleeps until the reset (plus a 60-second buffer)
- Resumes the same session automatically with
claude --resume <uuid>
If the resumed session hits another rate limit, the whole cycle repeats. You can leave it running overnight in a tmux session and come back to a fully-resumed Claude context in the morning.
Architecture: Three Components
The system has three pieces that work together.
1. statusline.sh — the sensor
Claude Code supports a statusLine hook in ~/.claude/settings.json — a command that Claude calls after every response, receiving a JSON payload that includes rate limit usage percentages and reset timestamps.
{
"statusLine": {
"type": "command",
"command": "/home/karthik/.claude/statusline.sh"
}
}
The statusline.sh script reads this payload via jq and renders a custom terminal status bar. But more importantly for Smart Resume, when usage hits 90%, it writes a flag file:
# From statusline.sh
if [ "$rl_5h_int" -ge 90 ] || [ "$rl_7d_int" -ge 90 ]; then
printf '5h_pct=%s\n5h_reset=%s\n7d_pct=%s\n7d_reset=%s\n' \
"$rl_5h_int" "${rl_5h_rst:-0}" \
"$rl_7d_int" "${rl_7d_rst:-0}" > "$rl_warn_flag"
fi
The flag file at ~/.claude/.rl_warn stores pre-computed Unix epochs for the reset time. When the wrapper wakes up after Claude exits, it can read the reset epoch directly — no timestamp parsing needed.
2. claude-smart-resume.sh — the watcher and scheduler
This is the main wrapper. It lives at ~/.claude/claude-smart-resume.sh and is installed as a shell alias:
alias claude="$HOME/.claude/claude-smart-resume.sh"
Because the alias shadows the claude name, every invocation — including shorthand aliases like cc or ccam — transparently goes through the wrapper. The real binary is called by its absolute path to avoid recursion.
3. The flag file — ~/.claude/.rl_warn
A simple key=value file that acts as the inter-process communication channel between the statusline sensor and the resume wrapper. Classic Unix filesystem-as-message-bus.
The Tricky Parts
Building this involved several non-obvious problems. Here are the ones I found most interesting.
Running Claude in the Foreground — for real
The most important design constraint: Claude must run as a direct foreground child of the wrapper script. This sounds obvious but it rules out several approaches.
Why not (claude &); fg?
When the script runs as a child of an interactive shell (as it does via an alias), the parent shell owns the terminal session. Calling fg or tcsetpgrp from a child process that isn't the session leader fails silently or throws an error — only the session leader can reassign the foreground process group.
Why not a subshell with exec?
exec replaces the current process image, which means the wrapper ceases to exist and can't do anything after Claude exits.
The solution: run Claude directly in the foreground as a simple child call:
_run_claude() {
# ... start watcher in background ...
trap 'true' INT # absorb Ctrl-C — don't let it kill the wrapper
"$CLAUDE_BIN" "$@"
trap - INT
# ... cleanup watcher ...
}
By calling the binary directly (not via subshell or exec), Claude inherits the terminal naturally. The trap ensures that Ctrl-C reaches Claude but doesn't kill the wrapper script — the wrapper only exits when Claude itself exits.
Finding Claude's PID Without Polling the Process Table
The background watcher needs to send SIGINT to Claude when it detects a rate-limit line in the session JSONL. But at the moment the watcher starts, Claude hasn't launched yet — there's a tiny race window.
The naive approach is pgrep -f claude, but that's fragile (matches any process with "claude" in its name) and races with Claude's startup time.
The clean approach: read Claude's PID from /proc:
# In the background watcher subshell:
read -r raw < "/proc/${my_pid}/task/${my_pid}/children" 2>/dev/null
/proc/<pid>/task/<pid>/children lists the immediate child PIDs of a given process. Because the watcher knows the wrapper's PID ($$), and Claude is spawned as a direct child of the wrapper, this gives a clean, race-free PID with no grepping the process table.
The watcher polls this file in a tight loop (50 ms intervals) until Claude appears, then hands the PID off to the JSONL poller.
local claude_pid='' i=0
while (( i++ < 200 )) && [[ -z "$claude_pid" ]]; do
read -r raw < "/proc/${my_pid}/task/${my_pid}/children" 2>/dev/null \
|| raw=$(pgrep -d' ' -P "$my_pid" 2>/dev/null) || true
for pid in $raw; do
[[ "$pid" == "$watcher_self" ]] && continue # skip the watcher itself
claude_pid=$pid; break
done
[[ -z "$claude_pid" ]] && sleep 0.05
done
There's a fallback to pgrep -P for systems where /proc layout differs (or on macOS, which doesn't have the same /proc structure).
Parsing Timestamps That Come in Multiple Formats
Claude's rate-limit message can express the reset time in several forms:
-
7:30pm (Asia/Calcutta)— time only, today (or tomorrow if it's already past) -
Apr 26 7:30pm (America/New_York)— date + time, no year -
Apr 26, 2026 7:30pm (UTC)— full date with year
GNU date -d handles all of these when passed the right timezone:
parse_reset_epoch() {
local reset_time="$1" reset_tz="$2"
reset_epoch=$(TZ="$reset_tz" date -d "$reset_time" +%s 2>/dev/null) || return 1
now_epoch=$(date +%s)
if (( reset_epoch <= now_epoch )); then
# Only roll over to "tomorrow" for time-only strings.
# A full date string that's in the past means something went wrong — bail.
if [[ "${reset_time,,}" =~ ^[0-9]+:[0-9]+[apm]+$ ]]; then
reset_epoch=$(( reset_epoch + 86400 ))
else
return 1
fi
fi
echo "$reset_epoch"
}
The +86400 rollover only applies to bare time strings like 7:30pm. A full date string (Apr 26 7:30pm) already resolves to the correct future epoch — adding a day would overshoot by 24 hours. This distinction is a CLAUDE.md rule in the repo because it's easy to break.
On macOS, BSD date doesn't support -d. The macOS variant of the script shells out to python3 (always available on macOS 12.3+) for epoch parsing instead.
Never Use strings on JSONL Files
Claude's session files are JSONL — one JSON object per line. To extract the session name or the rate-limit reset time, the obvious temptation is strings <file> | grep "resets".
Don't. strings is a binary analysis tool that extracts printable ASCII runs by byte. On multi-byte UTF-8 content (which JSONL often contains), it can split a single JSON object across multiple lines, or merge adjacent objects, causing silent misses.
The correct approach:
# Get session name from JSONL
grep -F '"type":"custom-title"' "$session_file" \
| tail -1 \
| grep -oP '"customTitle":"\K[^"]+'
grep -F treats the pattern as a literal string (no regex interpretation), which is both correct and faster for fixed-string matching on JSON.
All Output Goes to stderr
This one is subtle. Claude Code's --print flag makes the CLI emit its response to stdout for piping into other tools. If the wrapper writes any diagnostic output to stdout, it silently corrupts the pipe.
Every printf that produces user-visible output in the wrapper ends with >&2:
_bold() { printf '\e[1m%s\e[0m' "$*" >&2; }
_yellow() { printf '\e[33m%s\e[0m' "$*" >&2; }
_nl() { printf '\n' >&2; }
All of the countdown, banners, and status messages write to stderr. Stdout is reserved exclusively for Claude's actual output. The watcher subshell is doubly-isolated:
(
exec >/dev/null 2>/dev/null # silence everything in the watcher
# ... polling logic ...
) > /dev/null 2>/dev/null &
The redirect appears both inside (on exec) and outside (on the subshell itself) as belt-and-suspenders, because some shells inherit file descriptors through subshell boundaries in non-obvious ways.
Avoiding Zsh's local Quirk at Script Top-Level
In zsh, a bare local varname declaration at the script's top level (outside any function) echoes the variable's current value on each evaluation. Inside a while true loop, this means the variable gets printed to the terminal on every iteration.
The fix is simple: wrap main() in a function, so all local declarations are properly function-scoped:
main() {
local resume_id="" # safe — inside a function now
while true; do
# ...
done
}
main "$@"
This works correctly in both bash and zsh. The CLAUDE.md rule reads: "Wrapping in a function prevents that spurious output and works correctly in both bash and zsh."
The Idempotent Installer
The installer (install.sh) does six things: checks dependencies, detects the claude binary path, copies scripts to ~/.claude/, patches the binary path into the wrapper, offers to add the shell alias, and registers the statusLine hook in ~/.claude/settings.json.
Every step is idempotent — running the installer twice is safe:
- Binary detection rejects the wrapper itself (by comparing paths) so it doesn't recurse
- Alias injection checks for the wrapper path before appending
- Settings patching reads the existing value and skips if it's already correct
The installer also never runs sudo. If a required package is missing, it prints the exact install command and exits — the user runs the command, then re-runs ./install.sh.
The Test Suite
The project has 43 unit tests in src/test-smart-resume.zsh. Rather than running the scripts end-to-end (which would require a real Claude binary), the tests source just the function definitions from each script:
source_functions() {
local script="$1"
CLAUDE_BIN="/bin/true"
source <(awk '/^resume_id=/{exit} {print}' "$script") 2>/dev/null
}
The awk command stops reading at the line resume_id= — the first line of the main execution loop. Everything before that is pure function definitions, which can be safely sourced and tested in isolation.
Tests cover:
- Session name extraction (
get_session_name) — including thestrings-vs-grepcorrectness case - Reset time parsing for all timestamp formats — time-only, date+time, full datetime
- The epoch rollover logic — verifying
+86400only applies to time-only strings - JSONL line-baseline logic — confirming the post-resume loop never re-matches old entries
- Flag file parsing — both 5h and 7d paths, including tie-breaking when both are near limits
- WSL function equivalence vs the Linux baseline
- macOS
sed -Eparser andpython3epoch parser - Installer dependency checking with fake PATH stubs
Tests 40–43 are Linux/WSL-only (require /proc).
Installation
git clone https://github.com/karthiknitt/smart_resume.git
cd smart_resume
./install.sh
The installer handles everything. After it finishes, the cloned repo directory is no longer needed — the scripts live in ~/.claude/.
Recommended: Run in tmux
Because Smart Resume sleeps for potentially hours between a rate limit hit and the resume, the process needs to stay alive the whole time. Running inside a tmux session means you can detach, close your laptop, reconnect later, and find the session already resumed:
tmux new-session -s claude
claude # wrapper takes over on RL hit
# Ctrl-b d to detach; reattach with: tmux attach -t claude
Platforms
| Platform | Status |
|---|---|
| Linux | Available — v0.1+ |
| Windows (WSL) | Available — v0.3 |
| macOS | Available — v0.3 |
The WSL version is identical to Linux (WSL runs a full Linux kernel). The macOS version substitutes BSD-compatible tooling: ls -t instead of find -printf, python3 instead of GNU date -d, sed -E instead of grep -oP, and pgrep -P instead of /proc/<pid>/children.
Wrapping Up
This project started as a frustration fix — I was losing hours to manual rate-limit management. It turned into a deeper dive into Unix process management, terminal I/O, and the quirks of writing portable shell code that runs correctly in both bash and zsh.
The key lessons:
- The filesystem is a perfectly good IPC mechanism for low-frequency signaling. The flag file approach is simpler and more debuggable than a pipe or socket would be.
-
/proc/<pid>/task/<pid>/childrenis underrated. It gives you a child PID cleanly without grepping the entire process table. -
stdout purity matters more than you think. Adding
>&2to every diagnostic printf is tedious, but it's the only way to stay composable with pipes. -
Shell portability bugs are usually subtle. The zsh
local-at-top-level quirk, thestrings-vs-grepdifference, the epoch rollover logic — none of these are obvious from a quick read of the code.
The project is MIT-licensed and open for contributions.
GitHub: karthiknitt/smart_resume
Smart Resume is not affiliated with or endorsed by Anthropic. Claude Code is a product of Anthropic, PBC.
Top comments (0)