DEV Community

Lily
Lily

Posted on Originally published at dev.to

6 Hours, 15 Jobs, Zero Alerts: A Circuit Breaker for Claude Code's Weekly Quota

Nothing crashed. Nothing paged me. The 15 LaunchAgent jobs behind my ¥1.2M/month autonomous stack hit the weekly quota ceiling and then spent six hours swallowing errors and pretending to work. I found out the next morning, by checking by hand.

Why this setup works

The cost of not knowing you're down

When automation breaks, it breaks in one of two ways: the failure that crashes and screams, or the failure that disappears quietly.

Claude Code has a weekly usage limit. When you hit it, API calls come back with an error message containing weekly usage limit. That's where the real problem starts: automation scripts running every 5 minutes or every hour under LaunchAgent mostly just emit exit 1 and terminate when they receive that error. launchd records exit 1 as "the job failed," but then tries to run it again on the next cycle as if nothing happened. And it fails again.

That loop can run Monday, Tuesday, Wednesday — and nobody notices, because no dashboard exists. Auto-generating social posts, AI-processing thumbnails, ingesting conversation logs into the knowledge base: all of it is gone. In my environment 15 Claude-invoking jobs run in parallel, so hitting the limit skips all of them at once. 6 hours × 15 jobs = 90 jobs' worth of work vanishing in silence.

Where LaunchAgent and Claude Code don't fit together

Let me dig into why this is hard to detect automatically.

macOS launchd does not notify you by default when a job fails. You can control the retry interval with ThrottleInterval, but there is no way to express "hit the quota → don't run at all until the next limit reset" in a plist. And Claude Code's own exit code varies between 0, 1, and other values depending on the situation, so "just look at the exit code" isn't a simple answer either.

What makes it worse is that the error message goes to stderr. LaunchAgent dumps stdout/stderr into logs under /tmp, but almost nobody watches those logs continuously. Very few solo developers have a habit of running grep -r "weekly usage limit" ~/Library/Logs/ first thing in the morning.

Solving it at the environment level

Back in 2025, when I was a university student earning ¥100K a month, my daily routine included discovering "huh, why didn't yesterday's post go up?" through manual checks. Even after juggling gigs up to ¥600K a month, the same problem kept recurring. Then I was laid off and went back to zero, spent half a year rebuilding my Claude Code autonomous environment from scratch, and got to today's ¥1.2M/month. One thing I learned along the way: if you leave "not knowing you're down" unaddressed, what disappears isn't the revenue — it's the trust.

The core of the mechanism is three things: the moment it fires, stop, notify, and persist. When you hit the quota, immediately block every subsequent Claude invocation (exit 75), notify Discord, and persist the circuit state to a JSON file. Instead of going to check manually, you let the environment scream at you.

The idea is the same as an electrical circuit breaker. The instant excess current flows, the breaker trips. Once things recover, you reset it by hand. It prevents jobs from "hammering pointlessly and piling up errors," and it stops in a way that's observable from the outside.


How it all fits together

Architecture overview

LaunchAgent (定期ジョブ群 × 15本)
      │
      │  CLAUDE=~/.claude/scripts/claude-quota-guard.py
      ▼
claude-quota-guard.py
      │
      ├─[CLOSED]─▶ ~/.local/bin/claude (本物) を実行
      │                    │
      │              stdout + stderr の末尾 128KB をスキャン
      │                    │
      │           "weekly usage limit" 等 7パターンに一致?
      │                    │
      │              YES ──┤
      │                    ▼
      │         open_until = now + 21600s (6時間)
      │         reason = "quota-message"
      │         ~/.claude/state/claude-quota-circuit.json に原子書き込み
      │         ~/.discord/notify.sh "alerts" へ即時通知
      │
      └─[OPEN]──▶ exit 75 で即ブロック(直接呼び出し時)
                  exit 0 でスキップ(--job モード時)

復旧フロー:
  制限リセット後 ─▶ claude-quota-guard.py --reset ─▶ CLOSED に戻る
  または open_until を過ぎると自動で CLOSED(normalize_expired)
Enter fullscreen mode Exit fullscreen mode

The circuit state is persisted to ~/.claude/state/claude-quota-circuit.json with atomic writes. So that the file isn't corrupted when multiple jobs read and write the state simultaneously, updates take a file lock with fcntl.LOCK_EX first, then swap the file in atomically with os.replace().

Detecting quota patterns

There are seven strings that claude-quota-guard.py treats as a quota hit.

QUOTA_PATTERNS = (
    r"weekly (?:usage )?limit",
    r"usage limit",
    r"rate limit",
    r"quota (?:exceeded|limit|reached)",
    r"(?:you(?:'ve| have) )?hit your limit",
    r"limit reached",
    r"resets? (?:at|in|on|tomorrow)",
)
Enter fullscreen mode Exit fullscreen mode

Matching is case-insensitive (re.IGNORECASE), and the scan target is the last 131,072 bytes (128KB) of Claude's stdout and stderr combined.

combined = (result.stdout + b"\n" + result.stderr)[-131072:].decode("utf-8", errors="replace")
record_claude_result(result.returncode, combined)
Enter fullscreen mode Exit fullscreen mode

There's a clear reason for slicing off the tail: it skips past the normal early-run logs of long-running jobs and efficiently inspects only the tail, where errors are most likely. A side benefit is not having to expand a huge stdout entirely into memory.

Two triggers that open the circuit

Besides quota-message detection, there's one more trigger. Three consecutive exit 1s within 10 minutes forces OPEN.

cooldown  = int(os.environ.get("CLAUDE_GUARD_COOLDOWN_SECONDS", "21600"))
window    = int(os.environ.get("CLAUDE_GUARD_FAILURE_WINDOW_SECONDS", "600"))
threshold = int(os.environ.get("CLAUDE_GUARD_FAILURE_THRESHOLD", "3"))
Enter fullscreen mode Exit fullscreen mode

The defaults are cooldown=21600 (6 hours), window=600 (10 minutes), and threshold=3 (3 times). Claude's weekly limit often resets in the early morning Japan time, and the rule of thumb that the limit is likely lifted six hours later is where these values come from. They can be overridden via environment variables, so you can tune them to your own reset timing.

A reason field is recorded per trigger: "quota-message" for quota-message detection, "repeated-exit-1" for consecutive failures. That distinction matters later, when deciding how to recover.

Behavior when OPEN — fail quietly

When called while the circuit is OPEN, run_claude immediately returns exit 75.

EXIT_CIRCUIT_OPEN = 75

def run_claude(arguments: list[str]) -> int:
    status = circuit_status()
    if status["is_open"]:
        print(
            "CLAUDE_QUOTA_CIRCUIT_OPEN "
            f"reason={status['reason']} remaining={status['remaining_seconds']}s",
            file=sys.stderr,
        )
        return EXIT_CIRCUIT_OPEN
    # 以下、本物の claude を実行する処理
Enter fullscreen mode Exit fullscreen mode

Exit 75 corresponds to the POSIX convention EX_TEMPFAIL (temporary failure), meaning "not now, but you can try later." In a launchd plist you can set <key>SuccessfulExit</key><true/> to treat only exit 0 as success, but by combining exit 75 with AbandonProcessGroup you can explicitly control "give up quietly while quota-limited" behavior from the plist side.

--job mode is gentler still. When OPEN it returns return 0 (normal exit) and skips the job. The job script terminates without even knowing Claude was ever called.

def run_job(label: str, command: list[str]) -> int:
    status = circuit_status()
    if status["is_open"]:
        print(
            "CLAUDE_QUOTA_JOB_SKIPPED "
            f"job={label} reason={status['reason']} remaining={status['remaining_seconds']}s",
            file=sys.stderr,
        )
        return 0
    # 以下、CLAUDE_AUTOMATION_GUARD=1 を env に渡してジョブを実行
Enter fullscreen mode Exit fullscreen mode

Discord notifications — learning from the outside that you've stopped

The moment the circuit goes OPEN, notify_circuit_open() invokes ~/.discord/notify.sh.

def notify_circuit_open(reason: str, cooldown: int) -> None:
    """circuit OPENは15job一斉の6hサイレント停止になる — 必ずDiscordへ可視化する。"""
    script = Path.home() / ".discord" / "notify.sh"
    if not script.exists():
        return
    hours = round(cooldown / 3600, 1)
    subprocess.run(
        [str(script), "alerts",
         f"🚨 claude-quota-guard circuit OPEN (reason={reason}) "
         f"— Claude生成ジョブを{hours}hスキップします。"
         f"復旧済みなら `claude-quota-guard.py --reset`"],
        check=False, timeout=10, capture_output=True,
    )
Enter fullscreen mode Exit fullscreen mode

The comment saying "a 6h silent stop across all 15 jobs at once" is exactly what my environment looks like. Rounding the cooldown seconds into hours (round(cooldown / 3600, 1)) and embedding it in the message means that the instant I see the Discord notification on my phone, I know how many hours I have to wait. timeout=10 is there so that even if the Discord notification itself stalls, it doesn't hold up the whole job.

Integration with the health check

automation-health.sh checks the circuit state every run in section 1.5, "Claude生成quota circuit."

quota_guard="$CLAUDE/scripts/claude-quota-guard.py"
quota_state=$("$quota_guard" --status 2>/dev/null || true)
quota_open=$(printf '%s' "$quota_state" | jq -r '.is_open // false')
if [ "$quota_open" = "true" ]; then
    quota_reason=$(printf '%s' "$quota_state" | jq -r '.reason // "unknown"')
    quota_remaining=$(printf '%s' "$quota_state" | jq -r '.remaining_seconds // 0')
    wn "OPEN: Claude生成15jobをskip中 / reason=$quota_reason / remaining=${quota_remaining}s"
elif [ -n "$quota_state" ]; then
    ok "CLOSED: Claude生成jobは実行可能"
else
    ng "quota guard status の取得失敗"
fi
Enter fullscreen mode Exit fullscreen mode

--status prints the current JSON to stdout as-is. is_open and remaining_seconds are computed at runtime — derived every time by subtracting the current time from the open_until stored in the file.

def circuit_status() -> dict:
    timestamp = now()
    with locked_state() as state:
        normalize_expired(state, timestamp)
        result = dict(state)
    result["is_open"] = int(result.get("open_until", 0) or 0) > timestamp
    result["remaining_seconds"] = max(0, int(result.get("open_until", 0) or 0) - timestamp)
    return result
Enter fullscreen mode Exit fullscreen mode

normalize_expired() automatically returns the circuit to CLOSED if open_until is in the past. In other words, even without running --reset, the circuit closes naturally after six hours.

The actual state file

Here is the circuit state as of writing this article.

{
  "consecutive_failures": [],
  "last_success": 1785280668,
  "open_until": 1785470423,
  "opened_at": 1785448823,
  "reason": "quota-message",
  "version": 1
}
Enter fullscreen mode Exit fullscreen mode

Subtracting opened_at from open_until gives exactly 21,600 seconds (6 hours). reason is "quota-message", which means one of the seven patterns was present in the output Claude itself returned. last_success is the timestamp of the last successful Claude invocation — the staler it gets, the stronger the sign that "jobs have been skipped for a long time." consecutive_failures is an empty array because, when the circuit opens via quota-message detection, the consecutive-failure counter is reset by design.

if quota_message(output):
    state["open_until"] = timestamp + cooldown
    state["reason"] = "quota-message"
    state["opened_at"] = timestamp
    state["consecutive_failures"] = []   # ← 連続失敗カウンターをクリア
    opened_reason = "quota-message"
Enter fullscreen mode Exit fullscreen mode

The two triggers never run simultaneously; the design evaluates the quota message with priority.

Implementation details

File locking and atomic writes — 15 jobs can hit it at once without breaking

The locked_state() context manager is what prevents the worst case of a corrupted JSON when 15 parallel jobs read and write claude-quota-circuit.json at the same time. Python's threading.Lock only controls things within a single process, so this uses an OS-level file lock (fcntl.LOCK_EX) that separates distinct processes.

@contextmanager
def locked_state():
    path = state_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    lock_path = path.with_suffix(path.suffix + ".lock")
    with lock_path.open("a+") as lock:
        fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
        state = default_state()
        if path.exists():
            try:
                loaded = json.loads(path.read_text(encoding="utf-8"))
                if isinstance(loaded, dict):
                    state.update(loaded)
            except (OSError, ValueError):
                pass
        yield state
        # ... 書き込みフェーズ
        fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
Enter fullscreen mode Exit fullscreen mode

There are three design points.

The lock is taken on a sidecar file, .json.lock. Locking the main JSON directly leaves room for another process to read a half-written byte sequence. Separating out a dedicated lock file keeps all reads and writes of the main JSON inside the safe window where the lock is held. path.with_suffix(path.suffix + ".lock") generates the path claude-quota-circuit.json.lock.

Writes are atomic via os.replace(). A temporary file is created in the same directory with tempfile.mkstemp, written with json.dump, then swapped in with os.replace(temp_name, path). A rename within the same filesystem is atomic under POSIX, so a partially-read JSON never occurs.

fd, temp_name = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
try:
    with os.fdopen(fd, "w", encoding="utf-8") as temp:
        json.dump(state, temp, ensure_ascii=False, sort_keys=True)
        temp.write("\n")
    os.replace(temp_name, path)
finally:
    if os.path.exists(temp_name):
        os.unlink(temp_name)
Enter fullscreen mode Exit fullscreen mode

The finally also cleans up leftover temporaries, so that even if os.replace fails, no temp file lingers.

State is based on default_state() and filled in with update(). Whether it's the first run with no file present, or a corrupted JSON that would otherwise raise KeyError, update() is applied to a dict that already holds default values, so no KeyError occurs. OSError and ValueError from reading a corrupted JSON are silently swallowed with pass, and the defaults overwrite it. For a circuit breaker, "if it's broken, fall back to defaults" is more robust than "if it's broken, do nothing."

Finding the real claude — the self-reference guard

From the shell's point of view, claude-quota-guard.py is a wrapper placed as a stand-in for the claude command. Calling the real binary is the job of the real_claude() function.

def real_claude() -> str:
    configured = os.environ.get("CLAUDE_REAL_BIN")
    if configured:
        return str(Path(configured).expanduser())
    candidate = Path.home() / ".local" / "bin" / "claude"
    try:
        resolved = candidate.resolve(strict=True)
    except OSError:
        return str(candidate)
    if resolved == Path(__file__).resolve():
        raise RuntimeError("CLAUDE_REAL_BIN points back to claude-quota-guard.py")
    return str(resolved)
Enter fullscreen mode Exit fullscreen mode

If the CLAUDE_REAL_BIN environment variable is set it takes priority; otherwise the symlink at ~/.local/bin/claude is resolved to its real target with resolve(strict=True). Then it compares whether the resolved target matches the script itself. Path(__file__).resolve() is the script's own absolute path. On a match it raises RuntimeError and halts immediately. This is a countermeasure for a fork bomb I actually walked into — more on that below.

Environment variable injection in run_job()

In --job LABEL -- COMMAND [ARG...] mode, three environment variables are injected before running the wrapped command.

env = os.environ.copy()
env["CLAUDE_AUTOMATION_GUARD"] = "1"
env["CLAUDE"] = guard       # claude-quota-guard.py の絶対パス
env["CLAUDE_BIN"] = guard
Enter fullscreen mode Exit fullscreen mode

CLAUDE_AUTOMATION_GUARD=1 is a flag the job script uses to determine that it was "called from automation." Overwriting CLAUDE and CLAUDE_BIN with the guard's own path means that when the job internally calls $CLAUDE or $CLAUDE_BIN, it goes back through the guard. However many dozens of times a job calls claude, every invocation passes the circuit check by design. That said, this injection has no effect if claude on $PATH is called directly. In my environment, the claude on $PATH is also unified as a symlink to the guard.

Time injection for testing — CLAUDE_GUARD_NOW

To verify the "circuit automatically goes CLOSED after six hours" behavior without actually waiting six hours, the now() function consults the CLAUDE_GUARD_NOW environment variable.

def now() -> int:
    return int(os.environ.get("CLAUDE_GUARD_NOW", str(int(time.time()))))
Enter fullscreen mode Exit fullscreen mode

The current open_until in claude-quota-circuit.json is 1785470423. Running the following simulates "the instant one second past it."

CLAUDE_GUARD_NOW=1785470424 \
  ~/.claude/scripts/claude-quota-guard.py --status | jq .
Enter fullscreen mode Exit fullscreen mode

It returns is_open: false and remaining_seconds: 0. Since you can move time without rewriting the production state file, you can test safely even in the context where launchd actually invokes it. Combined with the --open SECONDS REASON subcommand, you can construct arbitrary states and then advance time with CLAUDE_GUARD_NOW in a single test sequence.


Where I got stuck

1. The "fork bomb" — the wrapper kept calling itself

During the initial setup, I rewrote ~/.local/bin/claude into a symlink to the guard script without first checking the path of the existing binary.

Symptom: The terminal froze the moment I ran claude. Checking ps aux | grep python in another window showed dozens of copies of the same script running in parallel. In macOS Activity Monitor, Python processes avalanched upward within 1–2 seconds and CPU usage pinned at 100%.

Cause: Because I created the link ~/.local/bin/claude → claude-quota-guard.py, when real_claude() resolved ~/.local/bin/claude it came right back to itself (claude-quota-guard.py). It launched itself as a child process, and that child launched itself again — a fork bomb. The resolve(strict=True) self-reference check was a countermeasure added later; at the time there was no such check, so the infinite loop wouldn't stop.

Fix: Force-kill the processes with killall python3, then just set CLAUDE_REAL_BIN explicitly.

# ~/.zshrc に追記してシェルを再起動
export CLAUDE_REAL_BIN="$HOME/.nvm/versions/node/v24.13.0/bin/claude"
Enter fullscreen mode Exit fullscreen mode

The self-reference check in the current code was added after walking into this failure. If you set CLAUDE_REAL_BIN from the start you never go through that check at all, so making this the very first step of the setup procedure is the most reliable approach.

2. "Lock file leftovers" — the morning every job hung

Symptom: One morning, section 1.5 "Claude生成quota circuit" of automation-health.sh stopped returning for tens of seconds. Running quota_guard --status by hand also hung.

Cause: The night before, when I force-killed a process with kill -9, a stale PID was left behind in /tmp/automation-health.lock/pid, which is used by automation-health.sh's duplicate-launch guard.

# automation-health.sh の多重起動ガード抜粋
if ! mkdir "$_ah_lock" 2>/dev/null; then
  if kill -0 "$(cat "$_ah_lock/pid" 2>/dev/null)" 2>/dev/null; then
    echo "automation-health: 別インスタンス稼働中のためスキップ" >&2
    exit 0   # ← ここで黙って終了していた
  fi
  rm -rf "$_ah_lock"
  ...
fi
Enter fullscreen mode Exit fullscreen mode

kill -0 is an existence check for a process. If the PID of a process killed with kill -9 gets reused by a completely different process started afterward, kill -0 succeeds. It misidentifies this as "another instance running" and exits quietly with exit 0, so from the caller's perspective it looks like "the health check finished instantly and returned OK." In reality it never ran once. This is the worst case: "everything looks like it's running fine, but nobody has actually checked."

Fix: Just delete /tmp/automation-health.lock manually.

rm -rf /tmp/automation-health.lock
Enter fullscreen mode Exit fullscreen mode

I stopped using kill -9 and now always terminate with Ctrl+C (SIGINT) or kill -TERM. If trap EXIT runs, rm -rf "$_ah_lock" releases the lock. kill -9 is a last resort only for "when it absolutely won't stop," and after using it I always clean up /tmp/automation-health.lock by hand.

3. "False repeated-exit-1 firing" — flaky Wi-Fi opened the circuit

A case where the circuit went OPEN in a situation completely unrelated to the quota, and 15 jobs were skipped until the next morning.

Symptom: A Discord notification arrived with reason=repeated-exit-1. At that point I should have had plenty of weekly quota left.

Cause: That night I had restarted the Wi-Fi router. If Claude invocations fail with exit 1 three times (CLAUDE_GUARD_FAILURE_THRESHOLD=3) within 10 minutes (CLAUDE_GUARD_FAILURE_WINDOW_SECONDS=600), the circuit opens regardless of the reason.

failures = [
    int(value)
    for value in state.get("consecutive_failures", [])
    if timestamp - int(value) <= window   # 10分以内の失敗だけ残す
]
failures.append(timestamp)
state["consecutive_failures"] = failures
if len(failures) >= threshold:            # 3回以上でOPEN
    state["open_until"] = timestamp + cooldown
    state["reason"] = "repeated-exit-1"
Enter fullscreen mode Exit fullscreen mode

Network failures return exit 1. The moment 3 of the 15 jobs hit that condition first and opened the circuit, the remaining 12 switched to job-skip (exit 0).

Fix: Since reason=repeated-exit-1 just means you can fire off --reset the next morning as soon as you know it wasn't a quota limit, I considered varying the Discord message wording between repeated-exit-1 and quota-message. In the end, though, I unified the operating rule as "regardless of the reason, when it goes OPEN a human decides and runs --reset." Making --reset automatic would mean it keeps trying to call Claude during a network outage and the failure loop continues. The notification message already says 復旧済みなら claude-quota-guard.py --reset.

If you want fewer false firings, you can raise the threshold via environment variable.

export CLAUDE_GUARD_FAILURE_THRESHOLD=5
Enter fullscreen mode Exit fullscreen mode

In my environment, though, I've kept it at 3 on the judgment that "five or more consecutive exit 1s = something really is wrong."

4. "I forgot --reset and skipped a whole day"

A failure where I left things alone without running --reset even after the quota limit lifted.

Symptom: Running automation-health.sh late that night showed OPEN: Claude生成15jobをskip中 / remaining=3600s. A full day's worth of jobs was gone.

Cause: I had misunderstood the open_until calculation. The quota reset timing and the 6-hour cooldown are completely independent. If the circuit opens at 22:00 Sunday, open_until is 04:00 Monday. Even if the weekly quota resets at 03:00 Monday, the circuit stays OPEN until 04:00. It is not the case that "when the quota lifts, the circuit comes back automatically." normalize_expired() will automatically return it to CLOSED once open_until has passed, but that open_until is nothing more than opened_at + 21600.

Fix: I registered a recovery one-liner as an alias in .zshrc.

alias claude-recover='~/.claude/scripts/claude-quota-guard.py --reset && \
  bash ~/.claude/scripts/automation-health.sh'
Enter fullscreen mode Exit fullscreen mode

The workflow that stuck: when I get the quota-limit notification on Discord, I set a reminder in the thread right there, and run claude-recover the next morning. Running automation-health.sh as a set immediately after --reset lets me confirm in one shot both that it's back to CLOSED and that the 15 launchd jobs are running normally.

Since the remaining_seconds value is readable from Discord on my phone, I also set up a shortcut that converts the remaining seconds directly into a reminder in my calendar app when I detect OPEN. Not "check back in 21,600 seconds" but "alarm at 4 AM Monday." Knowing how long the automation has been down lets you decide what work to cover manually.

Gotchas (the list of landmines I stepped on in production)

Above I covered four: the fork bomb, lock file leftovers, false repeated-exit-1 firing, and forgetting --reset. Here I'll cover the rest of the landmines I hit in production.

1. Forgetting the -- separator in --job wipes everything out silently with exit 2

If you forget the -- in claude-quota-guard.py --job LABEL -- COMMAND [ARG...] and write --job note-autolike bash /path/to/run.sh, the in-code check arguments[2] != "--" becomes True and it exits immediately with 2.

if len(arguments) < 4 or arguments[2] != "--":
    print("usage: claude-quota-guard.py --job LABEL -- COMMAND [ARG...]", file=sys.stderr)
    return 2
Enter fullscreen mode Exit fullscreen mode

launchd merely records last exit=2 and tries to run it again on the next cycle. Section 1 of automation-health.sh (launchd batch) catches it as RED, but "why exit 2?" isn't clear until you open /tmp/com.shun.xxxx.stderr.log directly. Without the habit of running a new plist manually once locally and checking the exit code before launchctl load, you get wiped out from day one of the deployment.

2. launchd doesn't read ~/.zshrc, so $CLAUDE comes out empty

run_job() overwrites CLAUDE and CLAUDE_BIN in the child process with the guard's own path. But that means nothing if $CLAUDE isn't passed to the parent process (the launchd job itself). Even if you export CLAUDE=~/.claude/scripts/claude-quota-guard.py in ~/.zshrc, a launchd-started job doesn't see that setting. A shell script that expands an empty $CLAUDE tries to run $CLAUDE --print "..." and exits 127 → three of those within 10 minutes and the circuit opens with repeated-exit-1. A false firing that has nothing to do with the quota. Writing them directly into the <key>EnvironmentVariables</key> section of every plist is the only sure solution.

<key>EnvironmentVariables</key>
<dict>
  <key>CLAUDE_REAL_BIN</key>
  <string>~/.nvm/versions/node/v24.13.0/bin/claude</string>
  <key>CLAUDE</key>
  <string>~/.claude/scripts/claude-quota-guard.py</string>
  <key>CLAUDE_BIN</key>
  <string>~/.claude/scripts/claude-quota-guard.py</string>
</dict>
Enter fullscreen mode Exit fullscreen mode

3. On jobs with large stdout, the quota message gets pushed outside the 128KB tail

What record_claude_result scans is the last 131,072 bytes (128KB) of stdout and stderr combined.

combined = (result.stdout + b"\n" + result.stderr)[-131072:].decode("utf-8", errors="replace")
Enter fullscreen mode Exit fullscreen mode

For jobs that pipe files with tens of thousands of lines straight into --print, or that emit enormous documents, normal output can exceed 128KB and push the quota message out of the tail. In that case it isn't detected as quota-message; it only opens as repeated-exit-1 after repeating exit 1 three times. The two jobs in between exist as a period of "failing silently." For jobs whose input exceeds 100KB, I switched to splitting them into roughly 50KB chunks, or writing to an intermediate file before handing it to Claude.

4. If jq isn't installed, the health check emits a false RED

Section 1.5 of automation-health.sh parses JSON with jq.

quota_open=$(printf '%s' "$quota_state" | jq -r '.is_open // false' 2>/dev/null)
Enter fullscreen mode Exit fullscreen mode

Without jq, quota_open becomes an empty string, ng "quota guard status の取得失敗" fires, and it exits 1. That creates the confusion of health going RED while the guard itself is working fine. brew install jq fixes it instantly, but before you misread it as "failing to retrieve the circuit state" and redo your setup, check which jq.

5. If ~/.discord/notify.sh doesn't exist, the circuit opening is completely silent

def notify_circuit_open(reason: str, cooldown: int) -> None:
    script = Path.home() / ".discord" / "notify.sh"
    if not script.exists():
        return  # エラーなし・ログなし・通知なし
Enter fullscreen mode Exit fullscreen mode

If notify.sh doesn't exist, the function returns doing nothing. The circuit still opens correctly and is recorded in the state file, but you have zero means of knowing from the outside. Half the point of building this mechanism disappears. I neglected this setup for the first two weeks, and three times I didn't notice the previous night's quota overrun until I ran automation-health.sh in the morning. If you don't have a ready-made script, a single line of curl to a webhook URL is enough. Setting up a Discord webhook takes 15 minutes. Do it on the day you build the environment.

6. Setting CLAUDE_GUARD_COOLDOWN_SECONDS too short closes the circuit prematurely

If you set CLAUDE_GUARD_COOLDOWN_SECONDS=3600, a circuit that opens at 22:00 Sunday goes back to CLOSED at midnight Monday. In an environment where the weekly quota resets at 03:00 Monday, jobs re-run between 0:00 and 3:00, repeat their failures, and enter a loop of going OPEN again with repeated-exit-1. Checking the current circuit state file, the difference between opened_at=1785448823 and open_until=1785470423 is exactly 21,600 seconds (6 hours). That value was decided after measuring the reset timing over a week of observation. If you're going to change the default 21,600 seconds, go through the same process.

7. Leaving CLAUDE_GUARD_NOW in .zshrc after testing breaks time evaluation in production

If you write export CLAUDE_GUARD_NOW=1785470424 in .zshrc for testing and forget to remove it, every now() call keeps returning that timestamp.

def now() -> int:
    return int(os.environ.get("CLAUDE_GUARD_NOW", str(int(time.time()))))
Enter fullscreen mode Exit fullscreen mode

With a future value the circuit never opens (comparison against open_until always evaluates to CLOSED); with a past value normalize_expired() constantly returns it to CLOSED. Always unset CLAUDE_GUARD_NOW after testing. The safe way to manipulate time is to pass it as a command prefix.

CLAUDE_GUARD_NOW=1785470424 ~/.claude/scripts/claude-quota-guard.py --status | jq .
Enter fullscreen mode Exit fullscreen mode

This form leaves nothing behind in .zshrc.

8. I manually ran a job without a CLAUDE_AUTOMATION_GUARD=1 check and overwrote production data

The CLAUDE_AUTOMATION_GUARD=1 that run_job() injects means nothing unless the job script itself checks for it. Running bash run.sh directly for debugging behaves identically to going through launchd. When I actually debug-ran the note-autolike script, 40 production auto-likes went out. At the top of every important job script I now always put:

[ "${CLAUDE_AUTOMATION_GUARD:-0}" = "1" ] || { echo "自動実行専用です" >&2; exit 1; }
Enter fullscreen mode Exit fullscreen mode

I apply this to every job involving social posting, API writes, or file overwrites.

9. Missing a long skip because I didn't notice how stale last_success was

When the circuit opens via repeated-exit-1, consecutive_failures is reset but last_success is retained as-is. Checking the current state file, last_success=1785280668 and opened_at=1785448823 differ by 168,155 seconds — about 46.7 hours. That means there hadn't been a single successful Claude exit for roughly two days before the circuit opened. If you monitored this value regularly, you could notice in advance that "something was already wrong before the circuit opened." Since it's available via --status, I plan to add it as the next improvement to automation-health.sh.


Best practices

1. The first step of setup is configuring CLAUDE_REAL_BIN

There's exactly one preventive measure for the fork bomb. Before you rewrite ~/.local/bin/claude into a symlink to the guard, always set CLAUDE_REAL_BIN. It has to be written in both ~/.zshrc and the EnvironmentVariables of every plist, or it won't take effect in launchd jobs.

2. Write every dependent variable directly into the plist's EnvironmentVariables

CLAUDE_REAL_BIN, CLAUDE, CLAUDE_BIN, CLAUDE_GUARD_COOLDOWN_SECONDS, CLAUDE_GUARD_FAILURE_THRESHOLD. Writing these in ~/.zshrc alone doesn't get them to launchd jobs. Going through a shell wrapper works too, but that adds dependencies and failure points. Writing them straight into the plist is the most reliable.

3. Check that jq is installed first

Make which jq || brew install jq the first step of your environment setup procedure. All of automation-health.sh's JSON parsing depends on jq.

4. Set up Discord notifications on day one

If ~/.discord/notify.sh doesn't exist, circuit openings are completely silent. A single line of curl to a webhook URL is enough to make it work. Putting this off returns you to the original problem: "not knowing you're down."

5. Put a CLAUDE_AUTOMATION_GUARD=1 check at the top of job scripts

[ "${CLAUDE_AUTOMATION_GUARD:-0}" = "1" ] || { echo "自動実行専用です" >&2; exit 1; }
Enter fullscreen mode Exit fullscreen mode

This prevents production accidents from manual debug runs. Apply it to every job involving API writes, social posting, or file overwrites.

6. Test --job plists manually and locally before launchctl load

Run python3 ~/.claude/scripts/claude-quota-guard.py --job LABEL -- bash script.sh manually once and check the exit code. A missing --, a typo in the label, and wrong paths can all be caught here. Exit 2 means a syntax mistake.

7. Reduce the morning check to one line with a claude-status alias

alias claude-status='~/.claude/scripts/claude-quota-guard.py --status | \
  jq -r "if .is_open then \"🔴 OPEN reason=\(.reason) remaining=\(.remaining_seconds | . / 3600 | floor)h\" else \"🟢 CLOSED\" end"'
Enter fullscreen mode Exit fullscreen mode

Build it into your morning check routine as insurance for the case where the Discord notification didn't go out.

8. Make claude-recover a set of --reset plus automation-health.sh

--reset on its own can't confirm both "did it go CLOSED?" and "did all 15 jobs resume normally?"

alias claude-recover='~/.claude/scripts/claude-quota-guard.py --reset && \
  bash ~/.claude/scripts/automation-health.sh'
Enter fullscreen mode Exit fullscreen mode

This one alias handles recovery and verification in a single go.

9. Tune CLAUDE_GUARD_FAILURE_THRESHOLD to your network environment

The default 3 (three exit 1s within 10 minutes) is prone to false firing in unstable Wi-Fi environments. If you get frequent VPN disconnects, consider CLAUDE_GUARD_FAILURE_THRESHOLD=5, or narrowing the window to CLAUDE_GUARD_FAILURE_WINDOW_SECONDS=300 (5 minutes). But lowering the sensitivity too far delays detection of a genuine quota overrun. Observe your own environment's failure patterns for a week before adjusting.

10. Decide the cooldown value from measurement

Claude Code's weekly quota reset timing isn't officially documented. Record the times the circuit opened and the times of the next normal response for a week, take the average, and then set CLAUDE_GUARD_COOLDOWN_SECONDS. If you're going to change the default 21,600 seconds (6 hours), change it with evidence.

11. Monitor how stale last_success gets

If last_success hasn't been updated in over 24 hours, that's a sign something is wrong. Adding the following check to automation-health.sh gives you advance detection.

last_success=$(printf '%s' "$quota_state" | jq -r '.last_success // 0')
gap=$(( $(date +%s) - last_success ))
[ "$gap" -gt 86400 ] && wn "last_success が $((gap/3600))h 前 — Claude呼び出し成功が24h以上ない"
Enter fullscreen mode Exit fullscreen mode

12. Split large-input jobs to avoid the 128KB constraint

If the quota message gets pushed outside the 128KB tail, you end up with a repeated-exit-1 verdict. For jobs whose input file exceeds 100KB, split it into 50KB pieces, or switch to a two-stage summary→detail process to keep each call's output size down.

13. Pass CLAUDE_GUARD_NOW as a command prefix — never write it in .zshrc

Pass test-time injection in the form CLAUDE_GUARD_NOW=xxx ~/.claude/scripts/claude-quota-guard.py --status. Writing it in .zshrc leaves it behind in your production environment and throws off the circuit's time evaluation.

14. Run automation-health.sh itself on a schedule via launchd every morning

Relying on habit for manual checks is risky. Run it at 6 AM daily as com.shun.daily-health-check.plist, and wrap it so that any RED goes to Discord — then missed checks drop to zero. In my environment the health check results also flow into the alerts channel.

15. Check the state before running --reset

Before you run --reset, check reason and remaining_seconds with claude-status (or --status | jq .). If reason=quota-message and thousands of seconds remain, you need to judge whether the quota has genuinely lifted before firing it. That bit of extra care avoids the loop of a too-early --reset → all jobs retrying at once → consecutive exit 1s → OPEN again with repeated-exit-1.


Summary

The whole point of this mechanism is creating a state where, when you hit the quota, the environment screams at you about what's happening.

claude-quota-guard.py detects the quota message and opens the circuit. notify_circuit_open() calls ~/.discord/notify.sh and posts to the alerts channel. The next morning, automation-health.sh picks it up as a WARN in section 1.5. The claude-recover alias runs --reset and the health check together to confirm recovery. Once that flow is established, the state of "not knowing you're down" ceases to exist.

The current circuit state file (reason=quota-message, opened_at=1785448823, open_until=1785470423) is evidence that this mechanism actually fired. The gap from last_success=1785280668 to opened_at is about 46.7 hours, which shows that "something was already quietly wrong before the circuit opened." That observation itself points to the next improvement.

A ¥1.2M/month automation stack doesn't earn on the premise that it's "running." It earns on a structure that "detects the stop immediately and recovers in the shortest path." You will hit the quota. The question is what happens when you do.


I've put the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure into a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)