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

> Source: <https://dev.to/bokuwalily/6-hours-15-jobs-zero-alerts-a-circuit-breaker-for-claude-codes-weekly-quota-b2c>
> Published: 2026-08-23 00:00:16+00:00

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.

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.

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.

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.

```
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）
```

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()`

.

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)",
)
```

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)
```

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.

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"))
```

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.

When called while the circuit is OPEN, `run_claude`

immediately returns exit 75.

``` php
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 を実行する処理
```

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.

``` php
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 に渡してジョブを実行
```

The moment the circuit goes OPEN, `notify_circuit_open()`

invokes `~/.discord/notify.sh`

.

``` php
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,
    )
```

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.

`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
```

`--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.

``` php
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
```

`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.

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
}
```

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"
```

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

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.

``` python
@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)
```

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)
```

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."`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.

``` php
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)
```

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.

`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
```

`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.

`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.

``` php
def now() -> int:
    return int(os.environ.get("CLAUDE_GUARD_NOW", str(int(time.time()))))
```

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 .
```

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.

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"
```

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.

**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
```

`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
```

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.

`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"
```

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
```

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

`--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'
```

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.

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
```

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>
```

**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")
```

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)
```

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**

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

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.

``` php
def now() -> int:
    return int(os.environ.get("CLAUDE_GUARD_NOW", str(int(time.time()))))
```

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 .
```

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; }
```

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`

.

**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; }
```

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"'
```

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'
```

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以上ない"
```

**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`

.

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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
