Summary
The usage-limit path (waiting state, hours-scale reset) depends entirely on screen-scraping the banner out of the pane tail. The session-limit banner is emitted by Claude Code as an inline, one-shot isApiErrorMessage turn in response to a submission — it is not a persistently-redrawn banner — so whether any 5s poll happens to catch it is a coin-flip. When the poll loses that race, detection never fires, the tool never sends, the banner is never re-emitted, and the session sits idle until the user returns manually.
Meanwhile the StopFailure hook already fires for usage limits with error: "rate_limit" and carries the session_id — a reliable, timestamped, non-scraping signal that is currently only used to drive the overload (seconds-scale backoff) path, never the wait-until-reset path.
Proposed fix: treat a rate_limit StopFailure marker as a first-class usage-limit trigger — read the reset time from that session's transcript (via the session_id the marker already carries), enter the existing waiting state until the reset, then send the retry. This removes the scrape from the critical path for usage limits entirely.
Relationship to #38
This overlaps #38 (the fixed RATE_LIMIT_TAIL_LINES = 12 window) but is more fundamental, not a duplicate. #38 makes the scrape miss less often by widening the window. The event-driven path proposed here removes the scrape from the usage-limit critical path entirely, so the banner's on-screen position stops mattering at all — it can't be pushed out of a window that's no longer consulted. In that sense this supersedes #38 for usage-limit detection when the hook is installed.
#38 still stands on its own, because the scrape doesn't go away completely: it remains for the /rate-limit-options menu detection (isRateLimitOptionsPrompt / menuStepsToWaitOption), overload detection, and as the fallback when the hook isn't installed. So the two are complementary — this issue makes usage-limit detection reliable via the event channel; #38 hardens the scrape that's still needed elsewhere.
Evidence (two concurrent sessions, same account-wide limit, v0.5.1)
Two panes (%0, %32) hit the same session limit within ~3 minutes. Both fired the StopFailure hook — the markers, straight off disk:
// events/_0.json
{ "pane": "%0", "error": "rate_limit", "session_id": "…", "ts": … } // written 10:33:57
// events/_32.json
{ "pane": "%32", "error": "rate_limit", "session_id": "…", "ts": … } // written 10:36:27
error: "rate_limit" confirms a usage/session limit raises StopFailure and passes isRetryableError() (events.js). The banner text lives in each session's transcript:
You've hit your session limit · resets 12:10pm (Australia/Melbourne)
Outcome:
%0 recovered — but via the scraping path getting lucky (log shows Rate limit detected … → menu select → wait → Sent retry message (attempt 1) → User already continued). Its hook marker was never consumed (still on disk).
%32 did not recover — its monitor (confirmed alive throughout) logged nothing but its startup line, sent nothing, and the session sat idle from 10:36 until manual resume ~2h later. Its hook marker was also never consumed.
So the hook fired for both panes and rescued neither; the one recovery came from a scrape that happened to win the race. On a prior night the same setup lost the race on the interactive pane and idled for 9 hours.
The two code-level gaps
- Hook markers for usage limits are effectively ignored.
readEvent is only reached in the monitoring branch (processOneTick, monitor.js ~L295–311). A marker written while the monitor is in waiting (or outside the eventMaxAgeSeconds window) is never consumed and goes stale on disk — which is what happened to both markers above.
- Even when consumed, a hook event is routed to the wrong machine. The
readEvent branch sets status = 'overload' and schedules nextOverloadWaitMs (seconds), then relies on a subsequent scrape (isRateLimited, monitor.js ~L196/L220) to hand off to enterUsageWait. It never parses the reset time from the event, so a rate_limit event backs off in seconds against a limit that resets in hours.
Proposed fix (sketch)
- In the
monitoring (and waiting) handling, check for a rate_limit StopFailure marker before the overload routing.
- On such a marker, resolve the transcript path from
session_id (~/.claude/projects/<cwd-slug>/<session_id>.jsonl), read the most recent isApiErrorMessage text, and feed it through the existing parseResetTime / calculateWaitMs → enter waiting until the reset, then the existing retry send.
- Keep the scrape as a fallback for cases where the hook isn't installed.
This reuses the entire existing wait-and-retry mechanism; the change is only the trigger (reliable event instead of scrape) and the reset-time source (transcript instead of on-screen banner).
Environment
claude-auto-retry v0.5.1 (global npm install)
- Claude Code 2.1.205
- macOS (Apple Silicon), tmux, zsh
StopFailure hook installed via claude-auto-retry install-hook
Happy to open a PR (with tests) if the approach looks right.
Summary
The usage-limit path (
waitingstate, hours-scale reset) depends entirely on screen-scraping the banner out of the pane tail. The session-limit banner is emitted by Claude Code as an inline, one-shotisApiErrorMessageturn in response to a submission — it is not a persistently-redrawn banner — so whether any 5s poll happens to catch it is a coin-flip. When the poll loses that race, detection never fires, the tool never sends, the banner is never re-emitted, and the session sits idle until the user returns manually.Meanwhile the
StopFailurehook already fires for usage limits witherror: "rate_limit"and carries thesession_id— a reliable, timestamped, non-scraping signal that is currently only used to drive the overload (seconds-scale backoff) path, never the wait-until-reset path.Proposed fix: treat a
rate_limitStopFailuremarker as a first-class usage-limit trigger — read the reset time from that session's transcript (via thesession_idthe marker already carries), enter the existingwaitingstate until the reset, then send the retry. This removes the scrape from the critical path for usage limits entirely.Relationship to #38
This overlaps #38 (the fixed
RATE_LIMIT_TAIL_LINES = 12window) but is more fundamental, not a duplicate. #38 makes the scrape miss less often by widening the window. The event-driven path proposed here removes the scrape from the usage-limit critical path entirely, so the banner's on-screen position stops mattering at all — it can't be pushed out of a window that's no longer consulted. In that sense this supersedes #38 for usage-limit detection when the hook is installed.#38 still stands on its own, because the scrape doesn't go away completely: it remains for the
/rate-limit-optionsmenu detection (isRateLimitOptionsPrompt/menuStepsToWaitOption), overload detection, and as the fallback when the hook isn't installed. So the two are complementary — this issue makes usage-limit detection reliable via the event channel; #38 hardens the scrape that's still needed elsewhere.Evidence (two concurrent sessions, same account-wide limit, v0.5.1)
Two panes (
%0,%32) hit the same session limit within ~3 minutes. Both fired theStopFailurehook — the markers, straight off disk:error: "rate_limit"confirms a usage/session limit raisesStopFailureand passesisRetryableError()(events.js). The banner text lives in each session's transcript:Outcome:
%0recovered — but via the scraping path getting lucky (log showsRate limit detected …→ menu select → wait →Sent retry message (attempt 1)→User already continued). Its hook marker was never consumed (still on disk).%32did not recover — its monitor (confirmed alive throughout) logged nothing but its startup line, sent nothing, and the session sat idle from 10:36 until manual resume ~2h later. Its hook marker was also never consumed.So the hook fired for both panes and rescued neither; the one recovery came from a scrape that happened to win the race. On a prior night the same setup lost the race on the interactive pane and idled for 9 hours.
The two code-level gaps
readEventis only reached in themonitoringbranch (processOneTick, monitor.js ~L295–311). A marker written while the monitor is inwaiting(or outside theeventMaxAgeSecondswindow) is never consumed and goes stale on disk — which is what happened to both markers above.readEventbranch setsstatus = 'overload'and schedulesnextOverloadWaitMs(seconds), then relies on a subsequent scrape (isRateLimited, monitor.js ~L196/L220) to hand off toenterUsageWait. It never parses the reset time from the event, so arate_limitevent backs off in seconds against a limit that resets in hours.Proposed fix (sketch)
monitoring(andwaiting) handling, check for arate_limitStopFailuremarker before the overload routing.session_id(~/.claude/projects/<cwd-slug>/<session_id>.jsonl), read the most recentisApiErrorMessagetext, and feed it through the existingparseResetTime/calculateWaitMs→ enterwaitinguntil the reset, then the existing retry send.This reuses the entire existing wait-and-retry mechanism; the change is only the trigger (reliable event instead of scrape) and the reset-time source (transcript instead of on-screen banner).
Environment
claude-auto-retryv0.5.1 (global npm install)StopFailurehook installed viaclaude-auto-retry install-hookHappy to open a PR (with tests) if the approach looks right.