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.
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
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
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,
)
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.
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.
@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.
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.
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.
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.
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
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.
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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*