claude-multiacc 1.0.13 → 1.0.15

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/bin/claude CHANGED
@@ -12,7 +12,10 @@ set -u
12
12
 
13
13
  # ${HOME:-} guards: with HOME stripped (env -i, some cron/systemd units) the shim
14
14
  # must still fail OPEN into plain passthrough, never abort on an unbound variable.
15
- ACC_ROOT="${CLAUDE_ACCOUNTS_DIR:-${HOME:-/nonexistent}/.claude-accounts}"
15
+ # CLAUDE_ACCOUNTS_ROOT scopes the pool to one app-robot instance; CLAUDE_ACCOUNTS_DIR
16
+ # is the older spelling and still works. Same precedence as lib/common.sh, so the shim
17
+ # and claude-accounts always look at the same pool.
18
+ ACC_ROOT="${CLAUDE_ACCOUNTS_ROOT:-${CLAUDE_ACCOUNTS_DIR:-${HOME:-/nonexistent}/.claude-accounts}}"
16
19
  MANIFEST="$ACC_ROOT/accounts.json"
17
20
 
18
21
  canon_path() {
@@ -81,8 +84,11 @@ now="$(date +%s)"
81
84
  # sed with a safe default, never a JSON parse.
82
85
  if [ -z "${CLAUDE_MULTIACC_THRESHOLD:-}" ]; then
83
86
  CLAUDE_MULTIACC_THRESHOLD="$(sed -n 's/.*"threshold"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$MANIFEST" 2>/dev/null | head -1)"
84
- case "$CLAUDE_MULTIACC_THRESHOLD" in ''|*[!0-9]*) CLAUDE_MULTIACC_THRESHOLD=90 ;; esac
87
+ CLAUDE_MULTIACC_THRESHOLD="$CLAUDE_MULTIACC_THRESHOLD"
85
88
  fi
89
+ # Scraped or handed in by the caller, it has to be a number bash can compare without
90
+ # complaining to stderr.
91
+ case "$CLAUDE_MULTIACC_THRESHOLD" in ''|*[!0-9]*|??????*) CLAUDE_MULTIACC_THRESHOLD=90 ;; esac
86
92
 
87
93
  if [ "$(uname -s)" = "Darwin" ]; then
88
94
  file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
@@ -90,21 +96,28 @@ else
90
96
  file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
91
97
  fi
92
98
 
99
+
100
+ # A number this shim will do ARITHMETIC on: digits only, and short enough that bash
101
+ # cannot go out of range. An over-range value makes `[ x -lt y ]` print
102
+ # "integer expression expected" on stderr — which a service-spawned run must never see —
103
+ # and makes $((x + 1)) wrap negative. Pool state is a file anyone can corrupt, so every
104
+ # scraped number goes through here.
105
+ num_ok() { case "$1" in ''|*[!0-9]*) return 1 ;; esac; [ "${#1}" -le 18 ]; }
106
+
93
107
  sel_log() {
94
- printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$ACC_ROOT/selection.log" 2>/dev/null || true
108
+ printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" 2>/dev/null >> "$ACC_ROOT/selection.log" || true
95
109
  }
96
110
 
97
111
  marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
98
112
  local m="$1/.limited" reset=""
99
113
  [ -f "$m" ] || return 1
100
114
  IFS= read -r reset < "$m" 2>/dev/null || reset=""
101
- case "$reset" in
102
- ''|*[!0-9]*)
103
- # Empty/partial/garbled marker e.g. read during a concurrent rewrite.
104
- # Treat as ACTIVE and never delete: deleting here could destroy a marker
105
- # another process is mid-write. The next limits refresh rewrites or clears it.
106
- return 0 ;;
107
- esac
115
+ if ! num_ok "$reset"; then
116
+ # Empty/partial/garbled/absurd marker — e.g. read during a concurrent rewrite.
117
+ # Treat as ACTIVE and never delete: deleting here could destroy a marker
118
+ # another process is mid-write. The next limits refresh rewrites or clears it.
119
+ return 0
120
+ fi
108
121
  if [ "$now" -ge "$reset" ]; then
109
122
  rm -f "$m" 2>/dev/null
110
123
  return 1
@@ -112,29 +125,105 @@ marker_active() { # true if $1/.limited is still in force; clears cleanly-expire
112
125
  return 0
113
126
  }
114
127
 
115
- STALE_AFTER=900
128
+ # How old telemetry may be and still rank. 900s was below the floor the usage endpoint
129
+ # ITSELF enforces: it answers a caller at most about once an hour (429 + Retry-After
130
+ # 3600), so a 15-minute window declared the data stale for most of every hour even on a
131
+ # perfectly healthy pool — and stale data ranks NEUTRAL, which is the same as not
132
+ # ranking at all. One hour matches what the endpoint is willing to give.
133
+ STALE_AFTER="${CLAUDE_MULTIACC_STALE_AFTER:-3600}"
134
+ case "$STALE_AFTER" in ''|*[!0-9]*|0) STALE_AFTER=3600 ;; esac
116
135
 
117
- fresh_field() { # fresh_field <acct dir> <json key> -> integer if telemetry fresh, else fail
118
- local f="$1/limits.json" fetched v
136
+ # EXCLUSION keeps the old, tight window on purpose. Ranking and the >=90% cutoff are
137
+ # not the same kind of judgement: ranking picks between working accounts and an hour-old
138
+ # number is plenty, while the cutoff decides that an account is UNUSABLE — and an
139
+ # account reading 89% an hour ago may be well past 90% now. Trusting one window for both
140
+ # would have quietly extended a stale "89%" into 45 extra minutes of eligibility.
141
+ EXCLUDE_STALE_AFTER=900
142
+ [ "$EXCLUDE_STALE_AFTER" -gt "$STALE_AFTER" ] && EXCLUDE_STALE_AFTER="$STALE_AFTER"
143
+
144
+ telem_fetched_at() { # $1 = acct dir -> epoch of the last successful fetch, or fail
145
+ local f="$1/limits.json" fetched
119
146
  [ -f "$f" ] || return 1
120
147
  fetched="$(sed -n 's/.*"fetched_at"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
121
- case "$fetched" in ''|*[!0-9]*) return 1 ;; esac
122
- [ $((now - fetched)) -le "$STALE_AFTER" ] || return 1
123
- v="$(sed -n "s/.*\"$2\"[^0-9]*\([0-9][0-9]*\).*/\1/p" "$f" 2>/dev/null | head -1)"
124
- case "$v" in ''|*[!0-9]*) return 1 ;; esac
148
+ num_ok "$fetched" || return 1
149
+ printf '%s\n' "$fetched"
150
+ }
151
+
152
+ limits_field() { # limits_field <acct dir> <json key> -> integer, or fail
153
+ local v
154
+ v="$(sed -n "s/.*\"$2\"[^0-9]*\([0-9][0-9]*\).*/\1/p" "$1/limits.json" 2>/dev/null | head -1)"
155
+ num_ok "$v" || return 1
125
156
  printf '%s\n' "$v"
126
157
  }
127
158
 
159
+ # fresh_field/cutoff_field parse fetched_at inline rather than through
160
+ # telem_fetched_at: they run several times per account on EVERY invocation, and the
161
+ # difference is a fork apiece. (This file is deliberately fork-frugal — see
162
+ # sessions_owned.) telem_fetched_at exists for the once-per-account callers.
163
+ within_window() { # $1 = acct dir, $2 = window seconds
164
+ local f="$1/limits.json" fetched
165
+ [ -f "$f" ] || return 1
166
+ fetched="$(sed -n 's/.*"fetched_at"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
167
+ num_ok "$fetched" || return 1
168
+ [ $((now - fetched)) -le "$2" ]
169
+ }
170
+
171
+ fresh_field() { # fresh_field <acct dir> <json key> -> integer if telemetry fresh, else fail
172
+ within_window "$1" "$STALE_AFTER" || return 1
173
+ limits_field "$1" "$2"
174
+ }
175
+
176
+ # Same, but for the >=90% cutoff, which gets the tighter window (see EXCLUDE_STALE_AFTER).
177
+ cutoff_field() { # $1 = acct dir, $2 = json key
178
+ within_window "$1" "$EXCLUDE_STALE_AFTER" || return 1
179
+ limits_field "$1" "$2"
180
+ }
181
+
182
+ # LAST-RESORT ranking input, used only when NOTHING in the pool is fresh (see the blind
183
+ # guard below). A weekly bucket only rises until its reset, so until that moment an old
184
+ # weekly reading is still a true lower bound on today's usage — strictly more information
185
+ # than the neutral 50 that erases every difference between accounts and turns selection
186
+ # into a coin flip. Once the reset has passed, the number describes a week that is over
187
+ # and is worth exactly nothing, so it is refused.
188
+ stale_weekly() { # $1 = acct dir
189
+ local resets
190
+ resets="$(limits_field "$1" weekly_resets_epoch)" || return 1
191
+ [ "$resets" -gt "$now" ] || return 1
192
+ limits_field "$1" weekly_percent
193
+ }
194
+
195
+ # TRUE when this account contributes nothing to ranking: no in-window telemetry at all.
196
+ # When every candidate is blind, every score is the same neutral constant, pick_best
197
+ # sees one enormous tie, and selection quietly becomes uniform random — the failure
198
+ # this whole file exists to prevent.
199
+ telem_blind() { # $1 = acct dir
200
+ ! within_window "$1" "$STALE_AFTER"
201
+ }
202
+
203
+ # Human age for the warning line: seconds -> "3h" / "11d". Never fails.
204
+ age_human() { # $1 = seconds
205
+ local s="$1"
206
+ if [ "$s" -ge 86400 ]; then printf '%dd\n' $((s / 86400))
207
+ elif [ "$s" -ge 3600 ]; then printf '%dh\n' $((s / 3600))
208
+ else printf '%dm\n' $((s / 60)); fi
209
+ }
210
+
128
211
  # RANKING score — lower is better (more headroom). Weekly headroom dominates: a weekly
129
212
  # bucket only refills on the account's fixed weekly reset (days away), while the 5h
130
213
  # session bucket self-heals, so session is a mild tiebreaker only. (Anthropic's docs
131
214
  # confirm this reset asymmetry — an account whose only near-full bucket is the cheap
132
215
  # session one must NOT rank behind one burning durable weekly headroom.)
133
216
  # score = weekly%*1000 + session% weekly,session in [0,100]
134
- # Stale/unreadable telemetry ranks NEUTRAL (weekly 50, session 50), never "free".
217
+ # Stale/unreadable telemetry ranks NEUTRAL (weekly 50, session 50), never "free"
218
+ # EXCEPT in a blind pool (SEL_DEGRADED=1), where a still-valid stale weekly reading is
219
+ # used instead. Neutral is only the right answer while some other account HAS fresh
220
+ # data to be neutral against; when no account does, neutral is just a coin flip.
221
+ SEL_DEGRADED=0
135
222
  sel_score_of() { # $1 = acct dir
136
223
  local w s
137
- w="$(fresh_field "$1" weekly_percent)" || w="$(fresh_field "$1" max_percent)" || w=50
224
+ if ! w="$(fresh_field "$1" weekly_percent)" && ! w="$(fresh_field "$1" max_percent)"; then
225
+ if [ "$SEL_DEGRADED" = 1 ]; then w="$(stale_weekly "$1")" || w=50; else w=50; fi
226
+ fi
138
227
  s="$(fresh_field "$1" session_percent)" || s=50
139
228
  printf '%s\n' $((w * 1000 + s))
140
229
  }
@@ -152,10 +241,284 @@ util_of() {
152
241
  # (fail open — telemetry must never invent exclusions).
153
242
  over_threshold() { # $1 = acct dir
154
243
  local v
155
- v="$(fresh_field "$1" max_percent)" || return 1
244
+ v="$(cutoff_field "$1" max_percent)" || return 1
156
245
  [ "$v" -ge "${CLAUDE_MULTIACC_THRESHOLD:-90}" ]
157
246
  }
158
247
 
248
+ # ---- client-reported rate limits ---------------------------------------------
249
+ # The usage API is not the only source of truth, and it is the one that fails exactly
250
+ # when it matters: it rate-limits its own callers (429 + Retry-After 3600), so
251
+ # limits.json can be hours or days stale at the very moment an account runs dry.
252
+ # Claude Code itself records every rejection in the session transcript:
253
+ # {..."error":"rate_limit","apiErrorStatus":429,
254
+ # "quotaLimits":{"status":"rejected","resetsAt":<epoch>,"rateLimitType":"five_hour",...}}
255
+ # That record is free, instant, offline, and carries the REAL reset time. Reading it is
256
+ # what lets an INTERACTIVE session take its own account out of the pool: the -p retry
257
+ # path below never sees a TUI run, so before this, a 5h limit hit in tmux left no trace
258
+ # at all and the next `claude` could walk straight back into the same dead account.
259
+ #
260
+ # Transcripts are NOT account-scoped ($acct/projects is a shared symlink by design —
261
+ # lib/common.sh), so the session -> account mapping comes from $acct/sessions/<pid>.json,
262
+ # which the client maintains for the lifetime of every run. sess_index_refresh() harvests
263
+ # those ids while the runs are alive; sel_capture_session() catches the run THIS
264
+ # invocation is about to exec into, so the id outlives the session that recorded the hit.
265
+ # ...and that registry has to be private to the account, or it says nothing about who
266
+ # ran what: one rejection would then mark the whole pool LIMITED.
267
+ sessions_owned() { # $1 acct dir
268
+ # Structural and deliberately FORK-FREE: this runs for every account on every single
269
+ # invocation, and a pair of canon_path calls here cost more than the whole scan.
270
+ # A session tree is this account's own evidence only when neither the account dir nor
271
+ # its sessions dir is a symlink — which is exactly how a shared layout is built
272
+ # (lib/common.sh seeds codex accounts with sessions -> ~/.codex/sessions, and an account
273
+ # dir may itself be a symlink to ~/.codex). Anything shared fails OPEN: no ownership,
274
+ # no exclusion, and the usage endpoint stays the only limit signal for that account.
275
+ [ -d "$1/sessions" ] || return 1
276
+ [ -L "$1/sessions" ] && return 1
277
+ [ -L "$1" ] && return 1
278
+ return 0
279
+ }
280
+
281
+ SESS_INDEX_MAX=12 # session ids remembered per account
282
+ QUOTA_SCAN_BYTES=262144 # transcript tail read per session (records land at the end)
283
+ QUOTA_SCAN_MAX_AGE=21600 # 6h: a 5h window plus slack. A limit older than that has
284
+ # either reset, or been re-recorded by a newer session.
285
+ QUOTA_SCAN_MAX_FILES=3 # hard cap per account: a limit still in force rejects the
286
+ # newest sessions too, so older ones can only repeat the news
287
+
288
+ # Hex and dashes only, and never a LEADING dash: an id like "-e" is inside that class
289
+ # and would be handed to grep as an option, which then eats the file operand and blocks
290
+ # on the shim's own stdin — a hang before exec, the one failure this file may never have.
291
+ # Every grep below also gets `--` so the class is not the only thing standing in the way.
292
+ sess_id_ok() { case "$1" in ''|-*|*[!0-9a-fA-F-]*) return 1 ;; *) return 0 ;; esac; }
293
+
294
+ iso_of_epoch() { # $1 seconds -> UTC ISO8601 ('' when neither date(1) dialect works)
295
+ date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null
296
+ }
297
+
298
+ # Comparable digit string for an ISO timestamp: 2026-08-21T17:07:59.321Z -> 20260821170759.
299
+ # Locale-proof (plain integers), and short enough that num_ok always passes.
300
+ iso_key() { local t="${1%%.*}"; t="$(printf '%s' "$t" | LC_ALL=C tr -cd '0-9')"; printf '%s\n' "${t}"; }
301
+
302
+ # Index line: "<session id> <ISO claim>". The claim is the session's OWN start time, and
303
+ # an id belongs to exactly ONE account.
304
+ # Why both: `claude --continue` resumes the SAME session id under whichever account the
305
+ # pool hands out next (the client only mints a new id with --fork-session), and the
306
+ # transcript is shared — so without a single owner one rejection would mark every account
307
+ # that ever touched that session, and without the claim time the new owner would inherit
308
+ # a rejection the PREVIOUS owner earned. Each rule only ever removes attribution: the
309
+ # failure mode is a missed limit, never an invented one.
310
+ sess_index_add() { # $1 acct dir, $2 session id, $3 start epoch (optional)
311
+ local idx="$1/.sessions-index" tmp oidx claim=""
312
+ sess_id_ok "$2" || return 0
313
+ # `( |$)` also matches a claim-less line from an older build, so such an entry is still
314
+ # deduped and can still be released when another account takes the session over.
315
+ [ -f "$idx" ] && LC_ALL=C grep -qE -- "^$2( |$)" "$idx" 2>/dev/null && return 0
316
+ num_ok "${3:-}" && claim="$(iso_of_epoch "$3")"
317
+ [ -n "$claim" ] || claim="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
318
+ for oidx in "$ACC_ROOT"/acct-*/.sessions-index; do
319
+ [ -f "$oidx" ] || continue
320
+ [ "$oidx" = "$idx" ] && continue
321
+ LC_ALL=C grep -qE -- "^$2( |$)" "$oidx" 2>/dev/null || continue
322
+ # `grep -v` exits 1 when it filters everything out, which is a perfectly good result
323
+ # here — the `true` keeps the emptied index from being thrown away.
324
+ if { LC_ALL=C grep -vE -- "^$2( |$)" "$oidx" 2>/dev/null; true; } 2>/dev/null > "$oidx.$$"; then
325
+ mv -f "$oidx.$$" "$oidx" 2>/dev/null || rm -f "$oidx.$$" 2>/dev/null
326
+ else
327
+ rm -f "$oidx.$$" 2>/dev/null
328
+ fi
329
+ done
330
+ tmp="$idx.$$"
331
+ { [ -f "$idx" ] && cat "$idx" 2>/dev/null; printf '%s %s\n' "$2" "$claim"; } \
332
+ | tail -n "$SESS_INDEX_MAX" 2>/dev/null > "$tmp" \
333
+ && mv -f "$tmp" "$idx" 2>/dev/null || rm -f "$tmp" 2>/dev/null
334
+ return 0
335
+ }
336
+
337
+ sess_index_refresh() { # $1 acct dir — record every run currently live in this account
338
+ local f id started known="" ln
339
+ sessions_owned "$1" || return 0
340
+ # Read the index ONCE with the builtin, so the steady state (every live session already
341
+ # claimed) costs one sed per session and not a grep and a second sed on top.
342
+ if [ -f "$1/.sessions-index" ]; then
343
+ while IFS= read -r ln; do known="$known ${ln%% *}"; done < "$1/.sessions-index"
344
+ fi
345
+ for f in "$1"/sessions/*.json; do
346
+ [ -f "$f" ] || continue
347
+ # A symlinked registry entry would let another pool's session id in under this
348
+ # account's name (second-pass codex-review finding, nested-symlink variant).
349
+ [ -L "$f" ] && continue
350
+ id="$(LC_ALL=C sed -n 's/.*"sessionId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$f" 2>/dev/null | head -1)"
351
+ sess_id_ok "$id" || continue
352
+ case "$known" in *" $id "*|*" $id") continue ;; esac
353
+ # startedAt is epoch MILLIseconds; using the session's real start (not "now") is what
354
+ # lets a session that has been running since before this shim was installed still be
355
+ # attributed correctly.
356
+ started="$(LC_ALL=C sed -n 's/.*"startedAt"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
357
+ num_ok "$started" && started=$((started / 1000)) || started=""
358
+ sess_index_add "$1" "$id" "$started"
359
+ known="$known $id"
360
+ done
361
+ return 0
362
+ }
363
+
364
+ # Two accounts can first-sight the same session id at the same instant and both claim it
365
+ # (sess_index_add releases the id from the others, but two concurrent releases can cross,
366
+ # and neither run repairs it afterwards). Checked HERE, at the one moment it decides
367
+ # something: if any other account holds the id with a claim at least as new as ours, that
368
+ # account owns the session now and the rejection is not ours to answer for. An unreadable
369
+ # rival claim counts as a conflict too — ambiguity means no attribution.
370
+ claim_conflicted() { # $1 acct dir, $2 session id, $3 our ISO claim
371
+ local oidx other ok mk
372
+ mk="$(iso_key "${3:-}")"
373
+ num_ok "$mk" || return 0
374
+ for oidx in "$ACC_ROOT"/acct-*/.sessions-index; do
375
+ [ -f "$oidx" ] || continue
376
+ [ "$oidx" = "$1/.sessions-index" ] && continue
377
+ # EVERY matching line, not just the first: an index can hold the same id twice (an
378
+ # older claim followed by a newer one), and it is the newest rival that decides.
379
+ while IFS= read -r other; do
380
+ [ -n "$other" ] || continue
381
+ case "$other" in *' '*) ok="$(iso_key "${other#* }")" ;; *) return 0 ;; esac
382
+ num_ok "$ok" || return 0
383
+ [ "$ok" -ge "$mk" ] && return 0
384
+ done <<EOF
385
+ $(LC_ALL=C grep -E -- "^$2( |$)" "$oidx" 2>/dev/null)
386
+ EOF
387
+ done
388
+ return 1
389
+ }
390
+
391
+ # Sets SESS_TRANSCRIPT rather than printing it: this is called once per examined index
392
+ # entry, and a command substitution here is a fork per entry per account per run.
393
+ SESS_TRANSCRIPT=""
394
+ sess_transcript() { # $1 acct dir, $2 session id
395
+ local p
396
+ SESS_TRANSCRIPT=""
397
+ for p in "$1"/projects/*/"$2".jsonl; do
398
+ [ -f "$p" ] && { SESS_TRANSCRIPT="$p"; return 0; }
399
+ done
400
+ return 1
401
+ }
402
+
403
+ # Newest still-in-force rejection this account's own sessions recorded.
404
+ # Prints "<reset-epoch> <rateLimitType>"; fails when there is none.
405
+ # This runs on EVERY invocation, so it is bounded on purpose: newest session first,
406
+ # stop at the first in-force rejection, and never read more than QUOTA_SCAN_MAX_FILES
407
+ # transcripts. Missing an older rejection costs nothing — a limit that is still in force
408
+ # rejects the very next request too, and that lands in a newer transcript.
409
+ client_limit_scan() { # $1 acct dir
410
+ local idx="$1/.sessions-index" memo="$1/.client-scan" id p line r t read_n=0 i last=""
411
+ local ln claim ts ck ak ttl
412
+ local ids=() claims=()
413
+ [ "${CLAUDE_MULTIACC_CLIENT_LIMITS:-1}" = "0" ] && return 1
414
+ [ -f "$idx" ] || return 1
415
+ # A CLEAN result is remembered for a few seconds: a tight loop of `claude -p` runs
416
+ # must not re-read the same transcript tails on every single invocation. Only the
417
+ # clean answer is memoized — a rejection becomes a .limited marker, and marker_active
418
+ # short-circuits this scan entirely from then on. Worst case, a limit hit in the last
419
+ # few seconds is noticed one run late.
420
+ if [ -f "$memo" ]; then
421
+ IFS= read -r last < "$memo" 2>/dev/null || last=""
422
+ num_ok "$last" || last=0
423
+ # The TTL is caller-supplied, so it goes through num_ok too: `[ x -lt bogus ]` would
424
+ # print "integer expression expected" on the caller's stderr before exec.
425
+ ttl="${CLAUDE_MULTIACC_CLIENT_SCAN_TTL:-20}"
426
+ num_ok "$ttl" || ttl=20
427
+ [ $((now - last)) -lt "$ttl" ] && return 1
428
+ fi
429
+ while IFS= read -r ln; do
430
+ id="${ln%% *}"
431
+ claim=""
432
+ case "$ln" in *' '*) claim="${ln#* }" ;; esac
433
+ sess_id_ok "$id" && { ids+=("$id"); claims+=("$claim"); }
434
+ done < "$idx"
435
+ # The budget bounds ENTRIES EXAMINED, not just transcripts read: an entry that fails the
436
+ # staleness test still costs a glob and a stat, so a large pool with a full index would
437
+ # otherwise pay for all of them on every single run and never reach a cap at all.
438
+ i=$(( ${#ids[@]} - 1 ))
439
+ while [ "$i" -ge 0 ] && [ "$read_n" -lt "$QUOTA_SCAN_MAX_FILES" ]; do
440
+ id="${ids[$i]}"
441
+ claim="${claims[$i]}"
442
+ i=$((i - 1))
443
+ read_n=$((read_n + 1))
444
+ sess_transcript "$1" "$id" || continue
445
+ p="$SESS_TRANSCRIPT"
446
+ [ $((now - $(file_mtime "$p"))) -le "$QUOTA_SCAN_MAX_AGE" ] || continue
447
+ # One grep, not three: this runs on every invocation, and the two seds below only
448
+ # ever run on a line that already matched. '^{"' drops the partial first line a
449
+ # byte-oriented tail can leave behind.
450
+ line="$(tail -c "$QUOTA_SCAN_BYTES" "$p" 2>/dev/null \
451
+ | LC_ALL=C grep -a '^{".*"error"[[:space:]]*:[[:space:]]*"rate_limit"' \
452
+ | tail -1)"
453
+ [ -n "$line" ] || continue
454
+ case "$line" in *'"status":"rejected"'*|*'"status": "rejected"'*) ;; *) continue ;; esac
455
+ # A rejection recorded BEFORE this account took the session over belongs to whoever
456
+ # was running it then, not to us. Undatable => not attributed (fail open).
457
+ if [ -n "$claim" ]; then
458
+ ts="$(printf '%s' "$line" | LC_ALL=C sed -n 's/.*"timestamp"[[:space:]]*:[[:space:]]*"\([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[^"]*\)".*/\1/p')"
459
+ [ -n "$ts" ] || continue
460
+ ck="$(iso_key "$ts")"; ak="$(iso_key "$claim")"
461
+ num_ok "$ck" || continue
462
+ num_ok "$ak" || continue
463
+ [ "$ck" -lt "$ak" ] && continue
464
+ claim_conflicted "$1" "$id" "$claim" && continue
465
+ fi
466
+ r="$(printf '%s' "$line" | LC_ALL=C sed -n 's/.*"resetsAt"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')"
467
+ num_ok "$r" || continue
468
+ [ "$r" -gt "$now" ] || continue
469
+ t="$(printf '%s' "$line" | LC_ALL=C sed -n 's/.*"rateLimitType"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_.-]*\)".*/\1/p')"
470
+ printf '%s %s\n' "$r" "${t:-unknown}"
471
+ return 0
472
+ done
473
+ printf '%s\n' "$now" 2>/dev/null > "$memo.$$" \
474
+ && mv -f "$memo.$$" "$memo" 2>/dev/null || rm -f "$memo.$$" 2>/dev/null
475
+ return 1
476
+ }
477
+
478
+ mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type
479
+ local m="$1/.limited" cur=""
480
+ # Never shorten a marker that already reaches further out (a weekly park must
481
+ # survive a 5h report), and never rewrite the same one on every invocation.
482
+ if [ -f "$m" ]; then
483
+ IFS= read -r cur < "$m" 2>/dev/null || cur=""
484
+ num_ok "$cur" || cur=0
485
+ [ "$cur" -ge "$2" ] && return 0
486
+ fi
487
+ {
488
+ echo "$2"
489
+ echo "bucket=client:$3 percent=100 marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=client-rate-limit"
490
+ } 2>/dev/null > "$1/.limited.$$" \
491
+ && mv -f "$1/.limited.$$" "$m" 2>/dev/null \
492
+ || rm -f "$1/.limited.$$" 2>/dev/null || true
493
+ sel_log "$(basename "$1") LIMITED by its own session ($3, resets $2) — client-reported"
494
+ return 0
495
+ }
496
+
497
+ # The run this shim is about to BECOME writes $acct/sessions/<pid>.json for its whole
498
+ # lifetime, and exec keeps the pid — so $$ is that file's name. A detached poll records
499
+ # it (and every other live run of the account) in the index, because by the time the
500
+ # user quits a limit-hit session and starts a new one, the client has already deleted
501
+ # its registry file and nothing else can name the transcript that holds the evidence.
502
+ sel_capture_session() { # $1 acct dir
503
+ local d="$1" pid=$$
504
+ [ "${CLAUDE_MULTIACC_CLIENT_LIMITS:-1}" = "0" ] && return 0
505
+ (
506
+ # Ctrl-C / a closed terminal must not kill the capture: it is bounded (60s) and
507
+ # stops the moment the run it is watching is gone.
508
+ trap '' INT HUP TERM QUIT
509
+ i=0
510
+ while [ "$i" -lt 30 ]; do
511
+ kill -0 "$pid" 2>/dev/null || break
512
+ sess_index_refresh "$d"
513
+ [ -f "$d/sessions/$pid.json" ] && break
514
+ sleep 2
515
+ i=$((i + 1))
516
+ done
517
+ sess_index_refresh "$d"
518
+ ) >/dev/null 2>&1 </dev/null &
519
+ return 0
520
+ }
521
+
159
522
  # An empty credentials file is NOT auth (an interrupted write must not make a
160
523
  # dead account selectable and turn a working stock run into an auth failure).
161
524
  has_auth() { [ -s "$1/.credentials.json" ] || [ -s "$1/server.token" ]; }
@@ -231,6 +594,14 @@ auth_dead() { # $1 = acct dir
231
594
  creds_dead "$1"
232
595
  }
233
596
 
597
+ acct_token() { # $1 = acct dir; prints token if the dir must authenticate by token
598
+ # Token-auth dirs (no local creds), and dirs whose OAuth credential is dead but which
599
+ # still carry a portable setup-token — the token is the only thing that can work there.
600
+ if [ -s "$1/server.token" ] && { [ ! -f "$1/.credentials.json" ] || creds_dead "$1"; }; then
601
+ tr -d '[:space:]' < "$1/server.token"
602
+ fi
603
+ }
604
+
234
605
  # Explicit pin wins over everything — markers, and even missing auth: the
235
606
  # add/login ceremony pins to a dir that has no credentials yet, and the login
236
607
  # must land exactly there, never in a randomly selected account's dir.
@@ -240,10 +611,13 @@ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
240
611
  sel_log "$CLAUDE_ACCOUNT pinned pwd=$PWD"
241
612
  export CLAUDE_CONFIG_DIR="$d"
242
613
  export CLAUDE_SHIM_ACTIVE=1
243
- if [ ! -f "$d/.credentials.json" ] && [ -s "$d/server.token" ]; then
244
- CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '[:space:]' < "$d/server.token")"
245
- export CLAUDE_CODE_OAUTH_TOKEN
246
- fi
614
+ # Same rule as every other path (acct_token): a DEAD credential beside a portable
615
+ # token must not shadow the token. Testing only for the credential's absence made a
616
+ # pinned account with a stale login fail outright ("OAuth session expired") while
617
+ # the very same account worked unpinned.
618
+ tok="$(acct_token "$d")"
619
+ [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
620
+ sel_capture_session "$d"
247
621
  exec "$REAL" "$@"
248
622
  fi
249
623
  sel_log "pin-invalid account=$CLAUDE_ACCOUNT (no such dir; random fallback)"
@@ -263,7 +637,18 @@ for d in "$ACC_ROOT"/acct-*; do
263
637
  continue
264
638
  fi
265
639
  valid+=("$d")
640
+ sess_index_refresh "$d"
266
641
  marker_active "$d" && continue
642
+ # The account's own records are consulted BEFORE telemetry: what the server told a real
643
+ # call is first-hand and carries the real reset, while limits.json can be days stale —
644
+ # the usage endpoint rate-limits its own callers. Marking here (rather than lazily, on
645
+ # whichever account happens to be picked) is what makes the marker visible to
646
+ # `claude-accounts status`, to a concurrent run in another terminal, and to sync.
647
+ # The cost is bounded by the scan's own file budget and its clean-result memo.
648
+ if lim="$(client_limit_scan "$d")"; then
649
+ mark_client_limit "$d" "${lim%% *}" "${lim##* }"
650
+ continue
651
+ fi
267
652
  over_threshold "$d" && continue
268
653
  eligible+=("$d")
269
654
  done
@@ -279,7 +664,7 @@ if [ "${#expired[@]}" -gt 0 ]; then
279
664
  last=0
280
665
  [ -f "$n" ] && last="$(file_mtime "$n")"
281
666
  if [ $((now - last)) -gt 3600 ]; then
282
- : > "$n" 2>/dev/null || true
667
+ : 2>/dev/null > "$n" || true
283
668
  printf 'claude-multiacc: %s account(s) unusable (%s) — see: claude-accounts expired\n' \
284
669
  "${#expired[@]}" "${ids# }" >&2
285
670
  fi
@@ -300,6 +685,30 @@ if [ "${#valid[@]}" -eq 0 ]; then
300
685
  exec "$REAL" "$@"
301
686
  fi
302
687
 
688
+ # ---- rotation ----------------------------------------------------------------
689
+ # Deliberately NOT a full least-recently-used order: just "do not hand back the account
690
+ # you were on a moment ago". That is the whole of the bug (quit a session that ran into
691
+ # its limit, start another, land straight back on it), and it is the only part that can
692
+ # be done without serialising selection. Among equally-ranked candidates the most recent
693
+ # pick is dropped and the REST ARE SAMPLED RANDOMLY — so a burst of parallel `claude -p` runs
694
+ # still spreads across the pool instead of every one of them computing the same "oldest"
695
+ # account and piling onto it.
696
+ # The state is one id in one file. A pool root that cannot be written just leaves a stale
697
+ # id there, which costs one avoided account and nothing else — it can never starve one.
698
+ last_pick_id() { # -> id this pool last handed out, or empty
699
+ local v=""
700
+ [ -f "$ACC_ROOT/.last-pick" ] && { IFS= read -r v < "$ACC_ROOT/.last-pick" 2>/dev/null || v=""; }
701
+ case "$v" in acct-[0-9][0-9]) printf '%s\n' "$v" ;; esac
702
+ }
703
+
704
+ remember_pick() { # $1 acct dir — best effort. stderr is silenced BEFORE the redirect, or
705
+ # a read-only pool root prints "Permission denied" on every single run.
706
+ local f="$ACC_ROOT/.last-pick" id="${1##*/}"
707
+ printf '%s\n' "$id" 2>/dev/null > "$f.$$" \
708
+ && mv -f "$f.$$" "$f" 2>/dev/null || rm -f "$f.$$" 2>/dev/null
709
+ return 0
710
+ }
711
+
303
712
  # Pick the account with the MOST headroom (lowest ranking score = most weekly headroom,
304
713
  # session as tiebreaker). Ties break randomly so equally-idle accounts still spread load.
305
714
  # Sets PICK_DIR/PICK_SCORE as globals — it must never touch "$@", which holds the
@@ -307,21 +716,84 @@ fi
307
716
  PICK_DIR=""
308
717
  PICK_SCORE=""
309
718
  pick_best() { # args: candidate dirs
310
- local d v best="" bestv=1000000 ties=1
719
+ local d avoid best="" bestv=1000000 ties=0 i n
720
+ local cand=() score=()
721
+ avoid="$(last_pick_id)"
311
722
  for d in "$@"; do
312
- v="$(sel_score_of "$d")"
313
- if [ "$v" -lt "$bestv" ]; then
314
- bestv="$v"; best="$d"; ties=1
315
- elif [ "$v" -eq "$bestv" ]; then
723
+ cand+=("$d")
724
+ score+=("$(sel_score_of "$d")")
725
+ done
726
+ n=${#cand[@]}
727
+ i=0
728
+ while [ "$i" -lt "$n" ]; do
729
+ [ "${score[$i]}" -lt "$bestv" ] && bestv="${score[$i]}"
730
+ i=$((i + 1))
731
+ done
732
+ # Reservoir-sample among the equally-best, skipping the account just handed out.
733
+ i=0
734
+ while [ "$i" -lt "$n" ]; do
735
+ if [ "${score[$i]}" -eq "$bestv" ] && [ "${cand[$i]##*/}" != "$avoid" ]; then
316
736
  ties=$((ties + 1))
317
- [ $((RANDOM % ties)) -eq 0 ] && best="$d" # reservoir-sample among equals
737
+ [ $((RANDOM % ties)) -eq 0 ] && best="${cand[$i]}"
318
738
  fi
739
+ i=$((i + 1))
319
740
  done
741
+ if [ -z "$best" ]; then
742
+ # The only account at the best score IS the one just used — degraded rotation beats
743
+ # refusing to pick (and in a two-account pool this is the other half of the
744
+ # alternation).
745
+ i=0
746
+ while [ "$i" -lt "$n" ]; do
747
+ if [ "${score[$i]}" -eq "$bestv" ]; then
748
+ ties=$((ties + 1))
749
+ [ $((RANDOM % ties)) -eq 0 ] && best="${cand[$i]}"
750
+ fi
751
+ i=$((i + 1))
752
+ done
753
+ fi
320
754
  PICK_DIR="$best"
321
755
  PICK_SCORE="$bestv"
322
756
  }
323
757
 
758
+ # Telemetry going stale is not a per-run detail, it is a pool-wide outage: with no
759
+ # in-window data ANYWHERE every account scores the identical NEUTRAL value, the tie
760
+ # spans the whole pool, and "pick the account with the most headroom" silently becomes
761
+ # "pick any account at all". That is how a fresh session lands on the one account
762
+ # already at 80% of its weekly limit while `claude-accounts status` still shows a
763
+ # reassuring 2% from eleven days ago. It cost eleven days of blind picks once.
764
+ # Two answers, and the order matters: rank on whatever old readings are still true
765
+ # BEFORE picking, and say out loud which of the two happened.
766
+ blind=1 # 1 = no candidate has in-window telemetry
767
+ blind_age=0 # newest stale reading among the candidates; 0 = never fetched at all
768
+ degraded=0 # 1 = blind, but every candidate had a stale reading still worth using
769
+ assess_telemetry() { # args: the dirs actually being chosen between
770
+ local d f n=0 stale_ok=0
771
+ blind=1; blind_age=0; degraded=0
772
+ for d in "$@"; do
773
+ n=$((n + 1))
774
+ if ! telem_blind "$d"; then blind=0; return 0; fi
775
+ f="$(telem_fetched_at "$d" || echo 0)"
776
+ # The NEWEST stale reading is the honest age of the outage; an account that was
777
+ # never fetched at all must not make the pool look older than it is.
778
+ [ "$f" -gt 0 ] && { [ "$blind_age" -eq 0 ] || [ $((now - f)) -lt "$blind_age" ]; } \
779
+ && blind_age=$((now - f))
780
+ stale_weekly "$d" >/dev/null && stale_ok=$((stale_ok + 1))
781
+ done
782
+ # All or nothing. A candidate whose reading has no horizon — a limits.json written
783
+ # before this field existed, or one whose week has already turned — scores neutral
784
+ # 50, and 50 would beat a NEIGHBOUR's true-but-worse 70. Mixing the two makes the
785
+ # degraded ranking actively wrong, so it is only used when every candidate can be
786
+ # compared on the same footing.
787
+ [ "$n" -gt 0 ] && [ "$stale_ok" -eq "$n" ] && degraded=1
788
+ return 0
789
+ }
790
+
324
791
  if [ "${#eligible[@]}" -gt 0 ]; then
792
+ # Blindness is judged over the accounts actually being chosen between, not over every
793
+ # valid one: a FRESH account sitting behind a .limited marker is not a candidate, and
794
+ # letting it clear the flag would leave the real candidates ranking neutral.
795
+ assess_telemetry "${eligible[@]}"
796
+ [ "$degraded" = 1 ] && SEL_DEGRADED=1
325
797
  if [ "${CLAUDE_SHIM_SELECT:-headroom}" = "random" ]; then
326
798
  PICK_DIR="${eligible[$((RANDOM % ${#eligible[@]}))]}"
327
799
  else
@@ -329,37 +801,78 @@ if [ "${#eligible[@]}" -gt 0 ]; then
329
801
  fi
330
802
  else
331
803
  # Every account is limit-marked: degraded service beats a hard failure (100% rule).
804
+ assess_telemetry "${valid[@]}"
805
+ [ "$degraded" = 1 ] && SEL_DEGRADED=1
332
806
  pick_best "${valid[@]}"
333
- sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
807
+ # Report the number this fallback ACTUALLY ranked on. Asking fresh_field here printed
808
+ # `weekly=?%` even when the pick was made on a perfectly good stale reading, so anyone
809
+ # reading only this event concluded the choice had no usage input at all.
810
+ if [ "$degraded" = 1 ]; then
811
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(stale_weekly "$PICK_DIR" || echo '?')% ranking=DEGRADED"
812
+ elif [ "$blind" = 1 ]; then
813
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=?% ranking=BLIND"
814
+ else
815
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
816
+ fi
334
817
  fi
335
818
  pick="$PICK_DIR"
819
+ # Remember the pick so the NEXT run does not hand back the same account. An explicit
820
+ # CLAUDE_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
821
+ # not a turn in the rotation.
822
+ remember_pick "$pick"
336
823
 
337
- # Opportunistic limits refresh: non-blocking, throttled, backgrounded.
824
+ # Opportunistic limits refresh: non-blocking, throttled, backgrounded. The windows are
825
+ # deliberately wide (10m, matching the 5m scheduled pass): the usage endpoint rate-limits
826
+ # its OWN callers, and a fleet of machines polling one account too eagerly earns a 429
827
+ # with Retry-After 3600 — telemetry then goes stale for an hour at a time, which is
828
+ # exactly how every account ends up scoring NEUTRAL.
338
829
  kick="$ACC_ROOT/.limits-kick"
339
830
  stale=0
340
831
  for d in "${valid[@]}"; do
341
832
  f="$d/limits.json"
342
- if [ ! -f "$f" ] || [ $((now - $(file_mtime "$f"))) -gt 180 ]; then stale=1; break; fi
833
+ if [ ! -f "$f" ] || [ $((now - $(file_mtime "$f"))) -gt 600 ]; then stale=1; break; fi
343
834
  done
344
835
  if [ "$stale" = 1 ] && [ -x "$SELF_DIR/claude-accounts" ]; then
345
836
  last=0
346
837
  [ -f "$kick" ] && last="$(file_mtime "$kick")"
347
- if [ $((now - last)) -gt 120 ]; then
348
- : > "$kick" 2>/dev/null || true
838
+ if [ $((now - last)) -gt 600 ]; then
839
+ : 2>/dev/null > "$kick" || true
349
840
  ( "$SELF_DIR/claude-accounts" limits --quiet >/dev/null 2>&1 & ) >/dev/null 2>&1
350
841
  fi
351
842
  fi
352
843
 
353
844
  acct="$(basename "$pick")"
354
- sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
355
-
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
360
- tr -d '[:space:]' < "$1/server.token"
845
+ if [ "$blind" = 1 ]; then
846
+ # Two genuinely different states, and an operator debugging this needs to know which:
847
+ # DEGRADED still ranks, on old readings that remain true; BLIND cannot rank at all and
848
+ # is a coin flip. Calling both of them "random" would send someone hunting the wrong bug.
849
+ if [ "$degraded" = 1 ]; then
850
+ sel_log "$acct weekly=$(stale_weekly "$pick" || echo '?')% session=?% ranking=DEGRADED telemetry-age=${blind_age}s pwd=$PWD"
851
+ else
852
+ sel_log "$acct weekly=?% session=?% ranking=BLIND telemetry-age=${blind_age}s pwd=$PWD"
361
853
  fi
362
- }
854
+ # Terminal only, at most hourly — a service-spawned `claude -p` must keep its stderr
855
+ # byte-clean, and this is advice, never a failure.
856
+ if [ -t 2 ]; then
857
+ n="$ACC_ROOT/.stale-notice"
858
+ last=0
859
+ [ -f "$n" ] && last="$(file_mtime "$n")"
860
+ if [ $((now - last)) -gt 3600 ]; then
861
+ : 2>/dev/null > "$n" || true
862
+ if [ "$degraded" = 1 ]; then
863
+ printf 'claude-multiacc: usage telemetry is %s old — ranking on the last readings that are still valid, not on current usage. Fix: claude-accounts limits --force, then claude-accounts status\n' \
864
+ "$(age_human "$blind_age")" >&2
865
+ elif [ "$blind_age" -gt 0 ]; then
866
+ printf 'claude-multiacc: usage telemetry is %s old for EVERY account and too old to mean anything — selection is running blind (random, not by headroom). Fix: claude-accounts limits --force, then claude-accounts status\n' \
867
+ "$(age_human "$blind_age")" >&2
868
+ else
869
+ printf 'claude-multiacc: no usage telemetry for ANY account — selection is running blind (random, not by headroom). Fix: claude-accounts limits --force, then claude-accounts status\n' >&2
870
+ fi
871
+ fi
872
+ fi
873
+ else
874
+ sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
875
+ fi
363
876
 
364
877
  export CLAUDE_SHIM_ACTIVE=1
365
878
 
@@ -392,6 +905,7 @@ if [ "$wants_retry" = "0" ]; then
392
905
  export CLAUDE_CONFIG_DIR="$pick"
393
906
  tok="$(acct_token "$pick")"
394
907
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
908
+ sel_capture_session "$pick"
395
909
  exec "$REAL" "$@"
396
910
  fi
397
911
 
@@ -401,6 +915,7 @@ tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
401
915
  export CLAUDE_CONFIG_DIR="$pick"
402
916
  tok="$(acct_token "$pick")"
403
917
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
918
+ sel_capture_session "$pick"
404
919
  exec "$REAL" "$@"
405
920
  }
406
921
  trap 'rm -rf "$tmpd"' EXIT
@@ -412,6 +927,7 @@ if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
412
927
  export CLAUDE_CONFIG_DIR="$pick"
413
928
  tok="$(acct_token "$pick")"
414
929
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
930
+ sel_capture_session "$pick"
415
931
  exec "$REAL" "$@"
416
932
  fi
417
933
 
@@ -476,7 +992,7 @@ while :; do
476
992
  {
477
993
  echo "$now"
478
994
  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 \
995
+ } 2>/dev/null > "$cur/.expired.$$" \
480
996
  && mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
481
997
  || rm -f "$cur/.expired.$$" 2>/dev/null || true
482
998
  sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: claude-accounts expired"
@@ -484,7 +1000,7 @@ while :; do
484
1000
  {
485
1001
  echo $((now + 600))
486
1002
  echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
487
- } > "$cur/.limited.$$" 2>/dev/null \
1003
+ } 2>/dev/null > "$cur/.limited.$$" \
488
1004
  && mv -f "$cur/.limited.$$" "$cur/.limited" 2>/dev/null \
489
1005
  || rm -f "$cur/.limited.$$" 2>/dev/null || true
490
1006
  fi
@@ -500,6 +1016,9 @@ while :; do
500
1016
  if [ -n "$next" ]; then
501
1017
  sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
502
1018
  cur="$next"
1019
+ # The account that actually serves the work is the one the next run should rotate
1020
+ # away from — not the one that bounced.
1021
+ remember_pick "$cur"
503
1022
  attempt=2
504
1023
  continue
505
1024
  fi