claude-multiacc 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@ no binary edits, no install-dir writes, survives `claude update` — the real bi
9
9
  resolved dynamically at exec time.
10
10
 
11
11
  Tested on: macOS (bash 3.2, zsh, Claude Code 2.1.207) and Ubuntu 24.04
12
- (bash 5.2, Claude Code 2.1.201). Test suite: `tests/run-tests.sh` (35 sandboxed tests,
12
+ (bash 5.2, Claude Code 2.1.201). Test suite: `tests/run-tests.sh` (221 sandboxed tests,
13
13
  no network / no quota — passes on both platforms).
14
14
 
15
15
  ---
@@ -31,12 +31,18 @@ repo bin/ first on PATH (rc-file block) /root/claude-multiacc/ (addon repo)
31
31
 
32
32
  1. `CLAUDE_CONFIG_DIR` or `CLAUDE_CODE_OAUTH_TOKEN` already set → pass straight through.
33
33
  2. `CLAUDE_ACCOUNT=acct-NN` → use that account (even if limit-marked — explicit wins).
34
- 3. Among accounts valid **on this machine** (`.credentials.json` or `server.token`
35
- present) and not excluded, pick the one with the **most remaining headroom** (see
36
- below). Ties break randomly to spread load. `CLAUDE_SHIM_SELECT=random` restores plain
37
- uniform-random selection.
38
- 4. If every account is excluded → the highest-headroom valid account anyway + a warning
39
- in `selection.log` (degraded beats down: the 100% rule).
34
+ 3. Drop every account that cannot serve a request — a **dead login** (expired refresh
35
+ token, revoked grant, a run that failed to authenticate) or an **org-blocked** one
36
+ (Claude Code disabled for that account). Those can only fail, so they never enter
37
+ selection.
38
+ 4. Among the accounts that remain valid **on this machine** (`.credentials.json` or
39
+ `server.token` present) and not limit-excluded, pick the one with the **most remaining
40
+ headroom** (see below). Ties break randomly to spread load. `CLAUDE_SHIM_SELECT=random`
41
+ restores plain uniform-random selection.
42
+ 5. If every account is limit-excluded → the highest-headroom *authenticable* account
43
+ anyway + a warning in `selection.log` (degraded beats down: the 100% rule).
44
+ 6. If nothing is usable at all → stock passthrough, with the reason in `selection.log`
45
+ (and, on a terminal, one line naming the accounts to fix).
40
46
 
41
47
  **Which account has "the most headroom"?** A Max subscription has three limits that a
42
48
  Fable request consumes at once: the 5-hour **session** bucket, the **weekly all-models**
@@ -51,6 +57,35 @@ lowest wins.) **Exclusion** still fires on *any* bucket ≥ 90% — a full sessi
51
57
  really does block right now — but that marker expires when the session resets, not days
52
58
  later.
53
59
 
60
+ **Unusable accounts are excluded, not rotated into.** An account that cannot serve a
61
+ request is dropped from selection before anything else is considered — it is not eligible
62
+ as the all-limited fallback either, because a limit-marked account is *degraded* while
63
+ this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
64
+
65
+ - **Dead login** (`EXPIRED`) — refresh token expired, grant revoked, or a real call that
66
+ came back *"OAuth session expired and could not be refreshed"*. Fixed by
67
+ `claude-accounts relogin`.
68
+ - **Org-blocked** (`BLOCKED`) — the account authenticates fine, but the run comes back
69
+ *"Your organization has disabled Claude subscription access for Claude Code"*. Handled
70
+ like any other dead login (`claude-accounts relogin` re-issues the grant and normally
71
+ clears it); if it comes back BLOCKED after a re-login, an admin has to enable Claude
72
+ Code for that account. Telemetry cannot see this — those accounts' usage endpoint
73
+ answers normally — so only a real call (`verify`, or a `-p` run) detects it, and the
74
+ park deliberately survives credential refreshes (a new access token says nothing about
75
+ an org policy).
76
+
77
+ `claude-accounts expired` lists exactly what is excluded, why, and the fix for each.
78
+ Exclusion self-heals, but only against the evidence it was based on: a **credential**
79
+ park (dead login) clears as soon as a newer credential lands — a re-login, or a refresh
80
+ by another process — or when a usage fetch authenticates; a **policy** park (org block)
81
+ ignores both, because a fresh access token says nothing about whether the org re-enabled
82
+ Claude Code, and only a passing real call or a re-login lifts it. Parks the **shim**
83
+ writes from a single failed run additionally carry a `soft_until` stamp and expire on
84
+ their own — the shim's guess never outlives the evidence for it — while parks
85
+ `claude-accounts` writes from proof (an `invalid_grant` refresh response, a failed real
86
+ call) stay until the account works again. If *every* account is unusable the shim still
87
+ fails open into stock `claude`, and logs why to `selection.log`.
88
+
54
89
  The shim prints nothing, logs `timestamp account cwd` (never prompt text) to
55
90
  `selection.log`, and `exec`s the real binary — stdin/stdout/exit codes pass through
56
91
  byte-identically. If anything is missing (no manifest, no accounts, unreadable state,
@@ -86,8 +121,12 @@ then says re-login is needed). Overrides: `CLAUDE_MULTIACC_TOKEN_URL`,
86
121
  `CLAUDE_MULTIACC_CLIENT_ID` (used by the sandboxed tests; defaults are correct for real use).
87
122
 
88
123
  **Auto-retry** (`-p`/`--print` only, default on, `CLAUDE_SHIM_RETRY=0` disables): on an
89
- auth/rate-limit-looking failure the shim marks the account with a 10-minute error
90
- cooldown and retries once on a different account. Output is buffered so a retried call
124
+ auth- or rate-limit-looking failure the shim retries once on a different account and
125
+ marks the failed one a **10-minute cooldown** for a rate limit (it heals on its own),
126
+ or an **`.expired`** park for an authentication failure / org block (they do not). The
127
+ park patterns are deliberately narrow and carry a `soft_until` stamp, because this grep
128
+ also sees the model's own answer: a `-p` run that merely *mentions* a 403 must never cost
129
+ an account, and if one slips through it returns to the pool by itself. Output is buffered so a retried call
91
130
  never double-emits. Only engages when stdin is finite (tty / regular file / `/dev/null`)
92
131
  and ≥2 accounts are eligible; service-spawned pipes take the plain exec path untouched.
93
132
 
@@ -151,10 +190,42 @@ What install does (all reversible, nothing else):
151
190
  ```bash
152
191
  claude-accounts list # pool at a glance
153
192
  claude-accounts status # per-account auth health, per-bucket limits incl. Fable, markers, last pick
193
+ claude-accounts expired # ONLY the accounts that cannot authenticate, and why (exit 1 if any)
194
+ claude-accounts relogin # sign those back in, one at a time (ids or --all to pick your own)
154
195
  ```
155
196
 
156
197
  Every account in the pool is authenticated — `add` cannot register one otherwise.
157
198
 
199
+ ### When an account stops working
200
+
201
+ Subscription logins expire (the refresh token has a finite life), can be revoked from
202
+ claude.ai, and an organization can disable Claude Code for one of its accounts entirely.
203
+ Such an account is **never selected** — `claude` keeps working on the healthy ones — and
204
+ shows up here:
205
+
206
+ ```bash
207
+ claude-accounts expired
208
+ # acct-01 support@example.com EXPIRED refresh token expired 2026-08-05 (2d ago)
209
+ # fix: claude-accounts relogin acct-01
210
+ # acct-08 dev@example.com BLOCKED this account's organization has disabled
211
+ # Claude Code subscription access
212
+ # fix: claude-accounts relogin acct-08
213
+
214
+ claude-accounts relogin # re-authenticate everything a sign-in can fix
215
+ claude-accounts relogin acct-01 # or just one
216
+ claude-accounts relogin acct-01 --token # portable token instead (Mac + server)
217
+ ```
218
+
219
+ `relogin` covers `BLOCKED` accounts too — a fresh sign-in re-issues the grant and usually
220
+ clears the block. Use `claude-accounts verify` (a real `-p` call per account) to re-test
221
+ one at any time; a PASS clears the park automatically.
222
+
223
+ `relogin` runs the same verified sign-in as `login` (it refuses to save a credential for
224
+ the wrong email), one account at a time, and syncs to the server once at the end.
225
+ `expired` exits **1** when anything needs a human, so it drops straight into a cron or
226
+ monitoring check. `list` tags such accounts `EXPIRED-LOGIN`, and `status` says
227
+ `selectable: NO` with the reason.
228
+
158
229
  ### Adopt this machine's existing login (no browser needed)
159
230
 
160
231
  ```bash
@@ -240,7 +311,7 @@ failures are loud and non-zero.
240
311
  ## Verification
241
312
 
242
313
  ```bash
243
- tests/run-tests.sh # 35 sandboxed tests, no quota
314
+ tests/run-tests.sh # 221 sandboxed tests, no quota
244
315
  claude-accounts verify # real matrix: `claude -p "reply OK"` per authed account
245
316
  claude-accounts verify --quick# auth presence/expiry only, no inference
246
317
  claude-accounts limits # live per-bucket usage incl. the Fable bucket
@@ -259,7 +330,14 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
259
330
  that the marked block is the LAST PATH manipulation in your rc file
260
331
  (`grep -A2 'claude-multiacc >>>' ~/.zshrc`).
261
332
  - **An account never gets picked** — `claude-accounts status`: no auth on this machine,
262
- or an active `.limited` marker (shows bucket + minutes to reset).
333
+ a dead login (`selectable: NO`, see `claude-accounts expired`), or an active `.limited`
334
+ marker (shows bucket + minutes to reset).
335
+ - **"Failed to authenticate: OAuth session expired and could not be refreshed"** — the
336
+ login behind that run is dead. Run `claude-accounts expired` to see which, then
337
+ `claude-accounts relogin`. Accounts in that state are excluded from selection
338
+ automatically, so this only surfaces when *every* account needs a re-login (the shim
339
+ then falls back to the machine's own `~/.claude` login) or when it was pinned with
340
+ `CLAUDE_ACCOUNT`.
263
341
  - **Everything marked limited** — the shim still runs (least-utilized fallback);
264
342
  check `selection.log` for `all-limited` lines.
265
343
  - **Sync fails** — `tail ~/.claude-accounts/sync.log`; it's ssh/rsync to the manifest's
package/bin/claude CHANGED
@@ -4,6 +4,9 @@
4
4
  # Self-contained on purpose: no sourcing, so a broken repo file can never break `claude`.
5
5
  # Selection: CLAUDE_CONFIG_DIR/CLAUDE_CODE_OAUTH_TOKEN passthrough > CLAUDE_ACCOUNT pin >
6
6
  # random among limit-eligible accounts > least-utilized fallback (degraded beats down).
7
+ # Accounts whose login is DEAD (expired refresh token, or a `.expired` marker from a
8
+ # failed auth) are never selected — not even as the all-limited fallback — because they
9
+ # fail every call outright; `claude-accounts expired` / `relogin` fix them.
7
10
 
8
11
  set -u
9
12
 
@@ -157,6 +160,77 @@ over_threshold() { # $1 = acct dir
157
160
  # dead account selectable and turn a working stock run into an auth failure).
158
161
  has_auth() { [ -s "$1/.credentials.json" ] || [ -s "$1/server.token" ]; }
159
162
 
163
+ cred_num() { # cred_num <file> <json key> -> integer (epoch ms) or empty
164
+ # The leading quote makes "expiresAt" unambiguous: "refreshTokenExpiresAt" cannot match it.
165
+ LC_ALL=C sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p" "$1" 2>/dev/null | head -1
166
+ }
167
+
168
+ # TRUE when <dir>/.credentials.json exists but can no longer authenticate: the access
169
+ # token is expired AND nothing can renew it (no refresh token, or the refresh token is
170
+ # itself expired). Such an account fails EVERY run with "OAuth session expired and
171
+ # could not be refreshed", so selecting it is strictly worse than not having it.
172
+ # Anything unparseable fails OPEN (treated as alive) — telemetry-free guesswork must
173
+ # never take a working account out of the pool.
174
+ creds_dead() { # $1 = acct dir
175
+ local f="$1/.credentials.json" exp rexp
176
+ [ -s "$f" ] || return 1
177
+ exp="$(cred_num "$f" expiresAt)"
178
+ case "$exp" in ''|*[!0-9]*) exp=0 ;; esac
179
+ [ $((exp / 1000)) -gt "$now" ] && return 1 # access token still valid
180
+ # Access token expired: only a live refresh token can save it.
181
+ grep -q '"refreshToken"[[:space:]]*:[[:space:]]*"[^"]' "$f" 2>/dev/null || return 0
182
+ rexp="$(cred_num "$f" refreshTokenExpiresAt)"
183
+ case "$rexp" in ''|*[!0-9]*) return 1 ;; esac # unknown expiry => fail open
184
+ [ $((rexp / 1000)) -le "$now" ]
185
+ }
186
+
187
+ # `.expired` is the persistent "this account needs a re-login" marker: written by
188
+ # claude-accounts (refresh grant expired/revoked) and by the retry path below when a
189
+ # real call fails with an auth error. It SELF-HEALS: any credential written after the
190
+ # marker (successful re-login, or a refresh by another process) clears it, and a
191
+ # successful `claude-accounts limits` fetch removes it outright.
192
+ # A marker written by the SHIM (a guess from one failed run) also carries
193
+ # `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
194
+ # a misread never costs an account permanently. Markers written by claude-accounts —
195
+ # a refresh grant that answered "revoked", a real verify call — carry no soft_until and
196
+ # stay until the account provably works again.
197
+ expired_marked() { # $1 = acct dir
198
+ local m="$1/.expired" mt f soft reason
199
+ [ -f "$m" ] || return 1
200
+ reason="$(LC_ALL=C sed -n 's/.*reason=\([A-Za-z0-9._-][A-Za-z0-9._-]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
201
+ # CREDENTIAL-scoped parks clear the moment a newer credential lands — that is the
202
+ # evidence they were about. A POLICY park (org-blocked) is about the account, not the
203
+ # credential: refreshing its token does not re-enable Claude Code for it. Letting a
204
+ # credential rewrite clear it silently returned blocked accounts to the pool every
205
+ # few hours (the limits refresher rewrites credentials), so runs kept failing with
206
+ # "Your organization has disabled Claude subscription access".
207
+ if [ "$reason" != "org-blocked" ]; then
208
+ mt="$(file_mtime "$m")"
209
+ for f in "$1/.credentials.json" "$1/server.token"; do
210
+ if [ -f "$f" ] && [ "$(file_mtime "$f")" -gt "$mt" ]; then
211
+ rm -f "$m" 2>/dev/null
212
+ return 1
213
+ fi
214
+ done
215
+ fi
216
+ soft="$(LC_ALL=C sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
217
+ case "$soft" in
218
+ ''|*[!0-9]*) return 0 ;; # no soft stamp => proven dead, keep
219
+ *) [ "$now" -lt "$soft" ] && return 0
220
+ rm -f "$m" 2>/dev/null # soft window elapsed: give it another go
221
+ return 1 ;;
222
+ esac
223
+ }
224
+
225
+ # Auth that cannot work right now. A non-empty server.token is an independent
226
+ # credential (no expiry we can read), so it keeps an account alive even when the
227
+ # OAuth credential beside it is dead — the token is exported instead.
228
+ auth_dead() { # $1 = acct dir
229
+ expired_marked "$1" && return 0
230
+ [ -s "$1/server.token" ] && return 1
231
+ creds_dead "$1"
232
+ }
233
+
160
234
  # Explicit pin wins over everything — markers, and even missing auth: the
161
235
  # add/login ceremony pins to a dir that has no credentials yet, and the login
162
236
  # must land exactly there, never in a randomly selected account's dir.
@@ -177,17 +251,54 @@ fi
177
251
 
178
252
  valid=()
179
253
  eligible=()
254
+ expired=()
180
255
  for d in "$ACC_ROOT"/acct-*; do
181
256
  [ -d "$d" ] || continue
182
257
  has_auth "$d" || continue
258
+ # Expired logins are excluded BEFORE anything else: unlike a limit marker (degraded
259
+ # but working), dead auth guarantees a hard "OAuth session expired" failure, so it
260
+ # can never be the "degraded beats down" fallback either.
261
+ if auth_dead "$d"; then
262
+ expired+=("$d")
263
+ continue
264
+ fi
183
265
  valid+=("$d")
184
266
  marker_active "$d" && continue
185
267
  over_threshold "$d" && continue
186
268
  eligible+=("$d")
187
269
  done
188
270
 
189
- # No usable accounts => stock behavior (fail open, never block work).
190
- [ "${#valid[@]}" -gt 0 ] || exec "$REAL" "$@"
271
+ if [ "${#expired[@]}" -gt 0 ]; then
272
+ ids=""
273
+ for d in "${expired[@]}"; do ids="$ids $(basename "$d")"; done
274
+ sel_log "skipped-expired:$ids (unusable — see: claude-accounts expired)"
275
+ # One actionable line, at most hourly, and only on a terminal — a service-spawned
276
+ # `claude -p` must keep its stderr byte-clean.
277
+ if [ -t 2 ]; then
278
+ n="$ACC_ROOT/.expired-notice"
279
+ last=0
280
+ [ -f "$n" ] && last="$(file_mtime "$n")"
281
+ if [ $((now - last)) -gt 3600 ]; then
282
+ : > "$n" 2>/dev/null || true
283
+ printf 'claude-multiacc: %s account(s) unusable (%s) — see: claude-accounts expired\n' \
284
+ "${#expired[@]}" "${ids# }" >&2
285
+ fi
286
+ fi
287
+ fi
288
+
289
+ # No usable accounts => stock behavior (fail open, never block work), but say WHY when
290
+ # the pool is merely un-authenticated: this is the one case the user can actually fix.
291
+ if [ "${#valid[@]}" -eq 0 ]; then
292
+ if [ "${#expired[@]}" -gt 0 ]; then
293
+ sel_log "all-expired: falling back to the default login (see: claude-accounts expired)"
294
+ # Terminal only: a service-spawned `claude -p` must keep its stderr byte-clean, and
295
+ # the fallback may well succeed on the machine's own login. The reason is always in
296
+ # selection.log, and `claude-accounts expired` spells it out.
297
+ [ -t 2 ] && printf 'claude-multiacc: no pool account is usable (%s) — see: claude-accounts expired, then: claude-accounts relogin\n' \
298
+ "${ids# }" >&2
299
+ fi
300
+ exec "$REAL" "$@"
301
+ fi
191
302
 
192
303
  # Pick the account with the MOST headroom (lowest ranking score = most weekly headroom,
193
304
  # session as tiebreaker). Ties break randomly so equally-idle accounts still spread load.
@@ -242,8 +353,10 @@ fi
242
353
  acct="$(basename "$pick")"
243
354
  sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
244
355
 
245
- acct_token() { # $1 = acct dir; prints token if dir is token-auth (no local creds)
246
- if [ ! -f "$1/.credentials.json" ] && [ -s "$1/server.token" ]; then
356
+ acct_token() { # $1 = acct dir; prints token if the dir must authenticate by token
357
+ # Token-auth dirs (no local creds), and dirs whose OAuth credential is dead but which
358
+ # still carry a portable setup-token — the token is the only thing that can work there.
359
+ if [ -s "$1/server.token" ] && { [ ! -f "$1/.credentials.json" ] || creds_dead "$1"; }; then
247
360
  tr -d '[:space:]' < "$1/server.token"
248
361
  fi
249
362
  }
@@ -310,7 +423,22 @@ if [ ! -t 0 ]; then
310
423
  cat > "$stdin_file" 2>/dev/null || [ -s "$stdin_file" ] || stdin_file=""
311
424
  fi
312
425
 
313
- ERRPAT='rate[ _-]?limit|usage limit|limit (reached|exceeded)|overloaded|"?529"?|401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)|credit balance'
426
+ # ERRPAT decides whether to RETRY at all (deliberately broad, unchanged behavior).
427
+ # The two PARK patterns below decide whether the failed account is also taken out of
428
+ # the pool, and they are deliberately NARROW: this grep also sees the model's own
429
+ # answer on stdout (a `-p` run that merely *discusses* a 403 must not cost an account).
430
+ # PARK_AUTH — the credential is dead: a cooldown would just re-fail, so the account
431
+ # is parked until a re-login / a fetch that authenticates clears it.
432
+ # PARK_ORG — the account's organization turned Claude Code access off; no re-login
433
+ # fixes that.
434
+ # Both shim-written parks carry a soft_until stamp, so even a false positive returns to
435
+ # the pool on its own — the shim's guess must never outlive the evidence for it.
436
+ PARK_AUTH='failed to authenticate|oauth (session|token)[a-z ]{0,20}(expired|invalid|revoked)|could not be refreshed|invalid api key|authentication_error|invalid bearer token|please run /login|run /login to'
437
+ PARK_ORG='organization has disabled|subscription access[a-z ]{0,20}disabl|disabled claude subscription|ask your admin to enable|not authorized to use claude code'
438
+ LIMITPAT='rate[ _-]?limit|usage limit|limit (reached|exceeded)|overloaded|"?529"?|credit balance'
439
+ ERRPAT="$LIMITPAT|$PARK_AUTH|$PARK_ORG"'|401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)'
440
+ PARK_SOFT_AUTH=3600 # 1h: a mis-parked healthy account is back within the hour
441
+ PARK_SOFT_ORG=21600 # 6h: an org policy will not change in minutes
314
442
 
315
443
  attempt=1
316
444
  cur="$pick"
@@ -327,14 +455,39 @@ while :; do
327
455
  exec 3<&-
328
456
  if [ "$rc" -ne 0 ] && [ "$attempt" -eq 1 ] \
329
457
  && grep -qiE "$ERRPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
330
- # Atomic marker write: a reader must never observe a half-written .limited
458
+ # Atomic marker writes: a reader must never observe a half-written marker
331
459
  # (it would parse as garbage and, before, could be deleted as "expired").
332
- {
333
- echo $((now + 600))
334
- echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
335
- } > "$cur/.limited.$$" 2>/dev/null \
336
- && mv -f "$cur/.limited.$$" "$cur/.limited" 2>/dev/null \
337
- || rm -f "$cur/.limited.$$" 2>/dev/null || true
460
+ # A rate limit wins the classification: a limit message that happens to mention an
461
+ # auth word must get the self-expiring cooldown, never a park.
462
+ park_reason=""
463
+ park_soft=0
464
+ if grep -qiE "$LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
465
+ :
466
+ elif grep -qiE "$PARK_ORG" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
467
+ park_reason="org-blocked"
468
+ park_detail="the account's organization has disabled Claude Code subscription access"
469
+ park_soft=$((now + PARK_SOFT_ORG))
470
+ elif grep -qiE "$PARK_AUTH" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
471
+ park_reason="auth-error"
472
+ park_detail="run failed to authenticate"
473
+ park_soft=$((now + PARK_SOFT_AUTH))
474
+ fi
475
+ if [ -n "$park_reason" ]; then
476
+ {
477
+ echo "$now"
478
+ echo "reason=$park_reason soft_until=$park_soft marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
479
+ } > "$cur/.expired.$$" 2>/dev/null \
480
+ && mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
481
+ || rm -f "$cur/.expired.$$" 2>/dev/null || true
482
+ sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: claude-accounts expired"
483
+ else
484
+ {
485
+ echo $((now + 600))
486
+ echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
487
+ } > "$cur/.limited.$$" 2>/dev/null \
488
+ && mv -f "$cur/.limited.$$" "$cur/.limited" 2>/dev/null \
489
+ || rm -f "$cur/.limited.$$" 2>/dev/null || true
490
+ fi
338
491
  next=""
339
492
  n="${#eligible[@]}"
340
493
  start=$((RANDOM % n))