claude-multiacc 1.0.13 → 1.0.14

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
@@ -118,10 +131,10 @@ fresh_field() { # fresh_field <acct dir> <json key> -> integer if telemetry fres
118
131
  local f="$1/limits.json" fetched v
119
132
  [ -f "$f" ] || return 1
120
133
  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
134
+ num_ok "$fetched" || return 1
122
135
  [ $((now - fetched)) -le "$STALE_AFTER" ] || return 1
123
136
  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
137
+ num_ok "$v" || return 1
125
138
  printf '%s\n' "$v"
126
139
  }
127
140
 
@@ -156,6 +169,280 @@ over_threshold() { # $1 = acct dir
156
169
  [ "$v" -ge "${CLAUDE_MULTIACC_THRESHOLD:-90}" ]
157
170
  }
158
171
 
172
+ # ---- client-reported rate limits ---------------------------------------------
173
+ # The usage API is not the only source of truth, and it is the one that fails exactly
174
+ # when it matters: it rate-limits its own callers (429 + Retry-After 3600), so
175
+ # limits.json can be hours or days stale at the very moment an account runs dry.
176
+ # Claude Code itself records every rejection in the session transcript:
177
+ # {..."error":"rate_limit","apiErrorStatus":429,
178
+ # "quotaLimits":{"status":"rejected","resetsAt":<epoch>,"rateLimitType":"five_hour",...}}
179
+ # That record is free, instant, offline, and carries the REAL reset time. Reading it is
180
+ # what lets an INTERACTIVE session take its own account out of the pool: the -p retry
181
+ # path below never sees a TUI run, so before this, a 5h limit hit in tmux left no trace
182
+ # at all and the next `claude` could walk straight back into the same dead account.
183
+ #
184
+ # Transcripts are NOT account-scoped ($acct/projects is a shared symlink by design —
185
+ # lib/common.sh), so the session -> account mapping comes from $acct/sessions/<pid>.json,
186
+ # which the client maintains for the lifetime of every run. sess_index_refresh() harvests
187
+ # those ids while the runs are alive; sel_capture_session() catches the run THIS
188
+ # invocation is about to exec into, so the id outlives the session that recorded the hit.
189
+ # ...and that registry has to be private to the account, or it says nothing about who
190
+ # ran what: one rejection would then mark the whole pool LIMITED.
191
+ sessions_owned() { # $1 acct dir
192
+ # Structural and deliberately FORK-FREE: this runs for every account on every single
193
+ # invocation, and a pair of canon_path calls here cost more than the whole scan.
194
+ # A session tree is this account's own evidence only when neither the account dir nor
195
+ # its sessions dir is a symlink — which is exactly how a shared layout is built
196
+ # (lib/common.sh seeds codex accounts with sessions -> ~/.codex/sessions, and an account
197
+ # dir may itself be a symlink to ~/.codex). Anything shared fails OPEN: no ownership,
198
+ # no exclusion, and the usage endpoint stays the only limit signal for that account.
199
+ [ -d "$1/sessions" ] || return 1
200
+ [ -L "$1/sessions" ] && return 1
201
+ [ -L "$1" ] && return 1
202
+ return 0
203
+ }
204
+
205
+ SESS_INDEX_MAX=12 # session ids remembered per account
206
+ QUOTA_SCAN_BYTES=262144 # transcript tail read per session (records land at the end)
207
+ QUOTA_SCAN_MAX_AGE=21600 # 6h: a 5h window plus slack. A limit older than that has
208
+ # either reset, or been re-recorded by a newer session.
209
+ QUOTA_SCAN_MAX_FILES=3 # hard cap per account: a limit still in force rejects the
210
+ # newest sessions too, so older ones can only repeat the news
211
+
212
+ # Hex and dashes only, and never a LEADING dash: an id like "-e" is inside that class
213
+ # and would be handed to grep as an option, which then eats the file operand and blocks
214
+ # on the shim's own stdin — a hang before exec, the one failure this file may never have.
215
+ # Every grep below also gets `--` so the class is not the only thing standing in the way.
216
+ sess_id_ok() { case "$1" in ''|-*|*[!0-9a-fA-F-]*) return 1 ;; *) return 0 ;; esac; }
217
+
218
+ iso_of_epoch() { # $1 seconds -> UTC ISO8601 ('' when neither date(1) dialect works)
219
+ 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
220
+ }
221
+
222
+ # Comparable digit string for an ISO timestamp: 2026-08-21T17:07:59.321Z -> 20260821170759.
223
+ # Locale-proof (plain integers), and short enough that num_ok always passes.
224
+ iso_key() { local t="${1%%.*}"; t="$(printf '%s' "$t" | LC_ALL=C tr -cd '0-9')"; printf '%s\n' "${t}"; }
225
+
226
+ # Index line: "<session id> <ISO claim>". The claim is the session's OWN start time, and
227
+ # an id belongs to exactly ONE account.
228
+ # Why both: `claude --continue` resumes the SAME session id under whichever account the
229
+ # pool hands out next (the client only mints a new id with --fork-session), and the
230
+ # transcript is shared — so without a single owner one rejection would mark every account
231
+ # that ever touched that session, and without the claim time the new owner would inherit
232
+ # a rejection the PREVIOUS owner earned. Each rule only ever removes attribution: the
233
+ # failure mode is a missed limit, never an invented one.
234
+ sess_index_add() { # $1 acct dir, $2 session id, $3 start epoch (optional)
235
+ local idx="$1/.sessions-index" tmp oidx claim=""
236
+ sess_id_ok "$2" || return 0
237
+ # `( |$)` also matches a claim-less line from an older build, so such an entry is still
238
+ # deduped and can still be released when another account takes the session over.
239
+ [ -f "$idx" ] && LC_ALL=C grep -qE -- "^$2( |$)" "$idx" 2>/dev/null && return 0
240
+ num_ok "${3:-}" && claim="$(iso_of_epoch "$3")"
241
+ [ -n "$claim" ] || claim="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
242
+ for oidx in "$ACC_ROOT"/acct-*/.sessions-index; do
243
+ [ -f "$oidx" ] || continue
244
+ [ "$oidx" = "$idx" ] && continue
245
+ LC_ALL=C grep -qE -- "^$2( |$)" "$oidx" 2>/dev/null || continue
246
+ # `grep -v` exits 1 when it filters everything out, which is a perfectly good result
247
+ # here — the `true` keeps the emptied index from being thrown away.
248
+ if { LC_ALL=C grep -vE -- "^$2( |$)" "$oidx" 2>/dev/null; true; } 2>/dev/null > "$oidx.$$"; then
249
+ mv -f "$oidx.$$" "$oidx" 2>/dev/null || rm -f "$oidx.$$" 2>/dev/null
250
+ else
251
+ rm -f "$oidx.$$" 2>/dev/null
252
+ fi
253
+ done
254
+ tmp="$idx.$$"
255
+ { [ -f "$idx" ] && cat "$idx" 2>/dev/null; printf '%s %s\n' "$2" "$claim"; } \
256
+ | tail -n "$SESS_INDEX_MAX" 2>/dev/null > "$tmp" \
257
+ && mv -f "$tmp" "$idx" 2>/dev/null || rm -f "$tmp" 2>/dev/null
258
+ return 0
259
+ }
260
+
261
+ sess_index_refresh() { # $1 acct dir — record every run currently live in this account
262
+ local f id started known="" ln
263
+ sessions_owned "$1" || return 0
264
+ # Read the index ONCE with the builtin, so the steady state (every live session already
265
+ # claimed) costs one sed per session and not a grep and a second sed on top.
266
+ if [ -f "$1/.sessions-index" ]; then
267
+ while IFS= read -r ln; do known="$known ${ln%% *}"; done < "$1/.sessions-index"
268
+ fi
269
+ for f in "$1"/sessions/*.json; do
270
+ [ -f "$f" ] || continue
271
+ # A symlinked registry entry would let another pool's session id in under this
272
+ # account's name (second-pass codex-review finding, nested-symlink variant).
273
+ [ -L "$f" ] && continue
274
+ id="$(LC_ALL=C sed -n 's/.*"sessionId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$f" 2>/dev/null | head -1)"
275
+ sess_id_ok "$id" || continue
276
+ case "$known" in *" $id "*|*" $id") continue ;; esac
277
+ # startedAt is epoch MILLIseconds; using the session's real start (not "now") is what
278
+ # lets a session that has been running since before this shim was installed still be
279
+ # attributed correctly.
280
+ started="$(LC_ALL=C sed -n 's/.*"startedAt"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
281
+ num_ok "$started" && started=$((started / 1000)) || started=""
282
+ sess_index_add "$1" "$id" "$started"
283
+ known="$known $id"
284
+ done
285
+ return 0
286
+ }
287
+
288
+ # Two accounts can first-sight the same session id at the same instant and both claim it
289
+ # (sess_index_add releases the id from the others, but two concurrent releases can cross,
290
+ # and neither run repairs it afterwards). Checked HERE, at the one moment it decides
291
+ # something: if any other account holds the id with a claim at least as new as ours, that
292
+ # account owns the session now and the rejection is not ours to answer for. An unreadable
293
+ # rival claim counts as a conflict too — ambiguity means no attribution.
294
+ claim_conflicted() { # $1 acct dir, $2 session id, $3 our ISO claim
295
+ local oidx other ok mk
296
+ mk="$(iso_key "${3:-}")"
297
+ num_ok "$mk" || return 0
298
+ for oidx in "$ACC_ROOT"/acct-*/.sessions-index; do
299
+ [ -f "$oidx" ] || continue
300
+ [ "$oidx" = "$1/.sessions-index" ] && continue
301
+ # EVERY matching line, not just the first: an index can hold the same id twice (an
302
+ # older claim followed by a newer one), and it is the newest rival that decides.
303
+ while IFS= read -r other; do
304
+ [ -n "$other" ] || continue
305
+ case "$other" in *' '*) ok="$(iso_key "${other#* }")" ;; *) return 0 ;; esac
306
+ num_ok "$ok" || return 0
307
+ [ "$ok" -ge "$mk" ] && return 0
308
+ done <<EOF
309
+ $(LC_ALL=C grep -E -- "^$2( |$)" "$oidx" 2>/dev/null)
310
+ EOF
311
+ done
312
+ return 1
313
+ }
314
+
315
+ # Sets SESS_TRANSCRIPT rather than printing it: this is called once per examined index
316
+ # entry, and a command substitution here is a fork per entry per account per run.
317
+ SESS_TRANSCRIPT=""
318
+ sess_transcript() { # $1 acct dir, $2 session id
319
+ local p
320
+ SESS_TRANSCRIPT=""
321
+ for p in "$1"/projects/*/"$2".jsonl; do
322
+ [ -f "$p" ] && { SESS_TRANSCRIPT="$p"; return 0; }
323
+ done
324
+ return 1
325
+ }
326
+
327
+ # Newest still-in-force rejection this account's own sessions recorded.
328
+ # Prints "<reset-epoch> <rateLimitType>"; fails when there is none.
329
+ # This runs on EVERY invocation, so it is bounded on purpose: newest session first,
330
+ # stop at the first in-force rejection, and never read more than QUOTA_SCAN_MAX_FILES
331
+ # transcripts. Missing an older rejection costs nothing — a limit that is still in force
332
+ # rejects the very next request too, and that lands in a newer transcript.
333
+ client_limit_scan() { # $1 acct dir
334
+ local idx="$1/.sessions-index" memo="$1/.client-scan" id p line r t read_n=0 i last=""
335
+ local ln claim ts ck ak ttl
336
+ local ids=() claims=()
337
+ [ "${CLAUDE_MULTIACC_CLIENT_LIMITS:-1}" = "0" ] && return 1
338
+ [ -f "$idx" ] || return 1
339
+ # A CLEAN result is remembered for a few seconds: a tight loop of `claude -p` runs
340
+ # must not re-read the same transcript tails on every single invocation. Only the
341
+ # clean answer is memoized — a rejection becomes a .limited marker, and marker_active
342
+ # short-circuits this scan entirely from then on. Worst case, a limit hit in the last
343
+ # few seconds is noticed one run late.
344
+ if [ -f "$memo" ]; then
345
+ IFS= read -r last < "$memo" 2>/dev/null || last=""
346
+ num_ok "$last" || last=0
347
+ # The TTL is caller-supplied, so it goes through num_ok too: `[ x -lt bogus ]` would
348
+ # print "integer expression expected" on the caller's stderr before exec.
349
+ ttl="${CLAUDE_MULTIACC_CLIENT_SCAN_TTL:-20}"
350
+ num_ok "$ttl" || ttl=20
351
+ [ $((now - last)) -lt "$ttl" ] && return 1
352
+ fi
353
+ while IFS= read -r ln; do
354
+ id="${ln%% *}"
355
+ claim=""
356
+ case "$ln" in *' '*) claim="${ln#* }" ;; esac
357
+ sess_id_ok "$id" && { ids+=("$id"); claims+=("$claim"); }
358
+ done < "$idx"
359
+ # The budget bounds ENTRIES EXAMINED, not just transcripts read: an entry that fails the
360
+ # staleness test still costs a glob and a stat, so a large pool with a full index would
361
+ # otherwise pay for all of them on every single run and never reach a cap at all.
362
+ i=$(( ${#ids[@]} - 1 ))
363
+ while [ "$i" -ge 0 ] && [ "$read_n" -lt "$QUOTA_SCAN_MAX_FILES" ]; do
364
+ id="${ids[$i]}"
365
+ claim="${claims[$i]}"
366
+ i=$((i - 1))
367
+ read_n=$((read_n + 1))
368
+ sess_transcript "$1" "$id" || continue
369
+ p="$SESS_TRANSCRIPT"
370
+ [ $((now - $(file_mtime "$p"))) -le "$QUOTA_SCAN_MAX_AGE" ] || continue
371
+ # One grep, not three: this runs on every invocation, and the two seds below only
372
+ # ever run on a line that already matched. '^{"' drops the partial first line a
373
+ # byte-oriented tail can leave behind.
374
+ line="$(tail -c "$QUOTA_SCAN_BYTES" "$p" 2>/dev/null \
375
+ | LC_ALL=C grep -a '^{".*"error"[[:space:]]*:[[:space:]]*"rate_limit"' \
376
+ | tail -1)"
377
+ [ -n "$line" ] || continue
378
+ case "$line" in *'"status":"rejected"'*|*'"status": "rejected"'*) ;; *) continue ;; esac
379
+ # A rejection recorded BEFORE this account took the session over belongs to whoever
380
+ # was running it then, not to us. Undatable => not attributed (fail open).
381
+ if [ -n "$claim" ]; then
382
+ 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')"
383
+ [ -n "$ts" ] || continue
384
+ ck="$(iso_key "$ts")"; ak="$(iso_key "$claim")"
385
+ num_ok "$ck" || continue
386
+ num_ok "$ak" || continue
387
+ [ "$ck" -lt "$ak" ] && continue
388
+ claim_conflicted "$1" "$id" "$claim" && continue
389
+ fi
390
+ r="$(printf '%s' "$line" | LC_ALL=C sed -n 's/.*"resetsAt"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')"
391
+ num_ok "$r" || continue
392
+ [ "$r" -gt "$now" ] || continue
393
+ t="$(printf '%s' "$line" | LC_ALL=C sed -n 's/.*"rateLimitType"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_.-]*\)".*/\1/p')"
394
+ printf '%s %s\n' "$r" "${t:-unknown}"
395
+ return 0
396
+ done
397
+ printf '%s\n' "$now" 2>/dev/null > "$memo.$$" \
398
+ && mv -f "$memo.$$" "$memo" 2>/dev/null || rm -f "$memo.$$" 2>/dev/null
399
+ return 1
400
+ }
401
+
402
+ mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type
403
+ local m="$1/.limited" cur=""
404
+ # Never shorten a marker that already reaches further out (a weekly park must
405
+ # survive a 5h report), and never rewrite the same one on every invocation.
406
+ if [ -f "$m" ]; then
407
+ IFS= read -r cur < "$m" 2>/dev/null || cur=""
408
+ num_ok "$cur" || cur=0
409
+ [ "$cur" -ge "$2" ] && return 0
410
+ fi
411
+ {
412
+ echo "$2"
413
+ echo "bucket=client:$3 percent=100 marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=client-rate-limit"
414
+ } 2>/dev/null > "$1/.limited.$$" \
415
+ && mv -f "$1/.limited.$$" "$m" 2>/dev/null \
416
+ || rm -f "$1/.limited.$$" 2>/dev/null || true
417
+ sel_log "$(basename "$1") LIMITED by its own session ($3, resets $2) — client-reported"
418
+ return 0
419
+ }
420
+
421
+ # The run this shim is about to BECOME writes $acct/sessions/<pid>.json for its whole
422
+ # lifetime, and exec keeps the pid — so $$ is that file's name. A detached poll records
423
+ # it (and every other live run of the account) in the index, because by the time the
424
+ # user quits a limit-hit session and starts a new one, the client has already deleted
425
+ # its registry file and nothing else can name the transcript that holds the evidence.
426
+ sel_capture_session() { # $1 acct dir
427
+ local d="$1" pid=$$
428
+ [ "${CLAUDE_MULTIACC_CLIENT_LIMITS:-1}" = "0" ] && return 0
429
+ (
430
+ # Ctrl-C / a closed terminal must not kill the capture: it is bounded (60s) and
431
+ # stops the moment the run it is watching is gone.
432
+ trap '' INT HUP TERM QUIT
433
+ i=0
434
+ while [ "$i" -lt 30 ]; do
435
+ kill -0 "$pid" 2>/dev/null || break
436
+ sess_index_refresh "$d"
437
+ [ -f "$d/sessions/$pid.json" ] && break
438
+ sleep 2
439
+ i=$((i + 1))
440
+ done
441
+ sess_index_refresh "$d"
442
+ ) >/dev/null 2>&1 </dev/null &
443
+ return 0
444
+ }
445
+
159
446
  # An empty credentials file is NOT auth (an interrupted write must not make a
160
447
  # dead account selectable and turn a working stock run into an auth failure).
161
448
  has_auth() { [ -s "$1/.credentials.json" ] || [ -s "$1/server.token" ]; }
@@ -231,6 +518,14 @@ auth_dead() { # $1 = acct dir
231
518
  creds_dead "$1"
232
519
  }
233
520
 
521
+ acct_token() { # $1 = acct dir; prints token if the dir must authenticate by token
522
+ # Token-auth dirs (no local creds), and dirs whose OAuth credential is dead but which
523
+ # still carry a portable setup-token — the token is the only thing that can work there.
524
+ if [ -s "$1/server.token" ] && { [ ! -f "$1/.credentials.json" ] || creds_dead "$1"; }; then
525
+ tr -d '[:space:]' < "$1/server.token"
526
+ fi
527
+ }
528
+
234
529
  # Explicit pin wins over everything — markers, and even missing auth: the
235
530
  # add/login ceremony pins to a dir that has no credentials yet, and the login
236
531
  # must land exactly there, never in a randomly selected account's dir.
@@ -240,10 +535,13 @@ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
240
535
  sel_log "$CLAUDE_ACCOUNT pinned pwd=$PWD"
241
536
  export CLAUDE_CONFIG_DIR="$d"
242
537
  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
538
+ # Same rule as every other path (acct_token): a DEAD credential beside a portable
539
+ # token must not shadow the token. Testing only for the credential's absence made a
540
+ # pinned account with a stale login fail outright ("OAuth session expired") while
541
+ # the very same account worked unpinned.
542
+ tok="$(acct_token "$d")"
543
+ [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
544
+ sel_capture_session "$d"
247
545
  exec "$REAL" "$@"
248
546
  fi
249
547
  sel_log "pin-invalid account=$CLAUDE_ACCOUNT (no such dir; random fallback)"
@@ -263,7 +561,18 @@ for d in "$ACC_ROOT"/acct-*; do
263
561
  continue
264
562
  fi
265
563
  valid+=("$d")
564
+ sess_index_refresh "$d"
266
565
  marker_active "$d" && continue
566
+ # The account's own records are consulted BEFORE telemetry: what the server told a real
567
+ # call is first-hand and carries the real reset, while limits.json can be days stale —
568
+ # the usage endpoint rate-limits its own callers. Marking here (rather than lazily, on
569
+ # whichever account happens to be picked) is what makes the marker visible to
570
+ # `claude-accounts status`, to a concurrent run in another terminal, and to sync.
571
+ # The cost is bounded by the scan's own file budget and its clean-result memo.
572
+ if lim="$(client_limit_scan "$d")"; then
573
+ mark_client_limit "$d" "${lim%% *}" "${lim##* }"
574
+ continue
575
+ fi
267
576
  over_threshold "$d" && continue
268
577
  eligible+=("$d")
269
578
  done
@@ -279,7 +588,7 @@ if [ "${#expired[@]}" -gt 0 ]; then
279
588
  last=0
280
589
  [ -f "$n" ] && last="$(file_mtime "$n")"
281
590
  if [ $((now - last)) -gt 3600 ]; then
282
- : > "$n" 2>/dev/null || true
591
+ : 2>/dev/null > "$n" || true
283
592
  printf 'claude-multiacc: %s account(s) unusable (%s) — see: claude-accounts expired\n' \
284
593
  "${#expired[@]}" "${ids# }" >&2
285
594
  fi
@@ -300,6 +609,30 @@ if [ "${#valid[@]}" -eq 0 ]; then
300
609
  exec "$REAL" "$@"
301
610
  fi
302
611
 
612
+ # ---- rotation ----------------------------------------------------------------
613
+ # Deliberately NOT a full least-recently-used order: just "do not hand back the account
614
+ # you were on a moment ago". That is the whole of the bug (quit a session that ran into
615
+ # its limit, start another, land straight back on it), and it is the only part that can
616
+ # be done without serialising selection. Among equally-ranked candidates the most recent
617
+ # pick is dropped and the REST ARE SAMPLED RANDOMLY — so a burst of parallel `claude -p` runs
618
+ # still spreads across the pool instead of every one of them computing the same "oldest"
619
+ # account and piling onto it.
620
+ # The state is one id in one file. A pool root that cannot be written just leaves a stale
621
+ # id there, which costs one avoided account and nothing else — it can never starve one.
622
+ last_pick_id() { # -> id this pool last handed out, or empty
623
+ local v=""
624
+ [ -f "$ACC_ROOT/.last-pick" ] && { IFS= read -r v < "$ACC_ROOT/.last-pick" 2>/dev/null || v=""; }
625
+ case "$v" in acct-[0-9][0-9]) printf '%s\n' "$v" ;; esac
626
+ }
627
+
628
+ remember_pick() { # $1 acct dir — best effort. stderr is silenced BEFORE the redirect, or
629
+ # a read-only pool root prints "Permission denied" on every single run.
630
+ local f="$ACC_ROOT/.last-pick" id="${1##*/}"
631
+ printf '%s\n' "$id" 2>/dev/null > "$f.$$" \
632
+ && mv -f "$f.$$" "$f" 2>/dev/null || rm -f "$f.$$" 2>/dev/null
633
+ return 0
634
+ }
635
+
303
636
  # Pick the account with the MOST headroom (lowest ranking score = most weekly headroom,
304
637
  # session as tiebreaker). Ties break randomly so equally-idle accounts still spread load.
305
638
  # Sets PICK_DIR/PICK_SCORE as globals — it must never touch "$@", which holds the
@@ -307,16 +640,41 @@ fi
307
640
  PICK_DIR=""
308
641
  PICK_SCORE=""
309
642
  pick_best() { # args: candidate dirs
310
- local d v best="" bestv=1000000 ties=1
643
+ local d avoid best="" bestv=1000000 ties=0 i n
644
+ local cand=() score=()
645
+ avoid="$(last_pick_id)"
311
646
  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
647
+ cand+=("$d")
648
+ score+=("$(sel_score_of "$d")")
649
+ done
650
+ n=${#cand[@]}
651
+ i=0
652
+ while [ "$i" -lt "$n" ]; do
653
+ [ "${score[$i]}" -lt "$bestv" ] && bestv="${score[$i]}"
654
+ i=$((i + 1))
655
+ done
656
+ # Reservoir-sample among the equally-best, skipping the account just handed out.
657
+ i=0
658
+ while [ "$i" -lt "$n" ]; do
659
+ if [ "${score[$i]}" -eq "$bestv" ] && [ "${cand[$i]##*/}" != "$avoid" ]; then
316
660
  ties=$((ties + 1))
317
- [ $((RANDOM % ties)) -eq 0 ] && best="$d" # reservoir-sample among equals
661
+ [ $((RANDOM % ties)) -eq 0 ] && best="${cand[$i]}"
318
662
  fi
663
+ i=$((i + 1))
319
664
  done
665
+ if [ -z "$best" ]; then
666
+ # The only account at the best score IS the one just used — degraded rotation beats
667
+ # refusing to pick (and in a two-account pool this is the other half of the
668
+ # alternation).
669
+ i=0
670
+ while [ "$i" -lt "$n" ]; do
671
+ if [ "${score[$i]}" -eq "$bestv" ]; then
672
+ ties=$((ties + 1))
673
+ [ $((RANDOM % ties)) -eq 0 ] && best="${cand[$i]}"
674
+ fi
675
+ i=$((i + 1))
676
+ done
677
+ fi
320
678
  PICK_DIR="$best"
321
679
  PICK_SCORE="$bestv"
322
680
  }
@@ -333,19 +691,27 @@ else
333
691
  sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
334
692
  fi
335
693
  pick="$PICK_DIR"
336
-
337
- # Opportunistic limits refresh: non-blocking, throttled, backgrounded.
694
+ # Remember the pick so the NEXT run does not hand back the same account. An explicit
695
+ # CLAUDE_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
696
+ # not a turn in the rotation.
697
+ remember_pick "$pick"
698
+
699
+ # Opportunistic limits refresh: non-blocking, throttled, backgrounded. The windows are
700
+ # deliberately wide (10m, matching the 5m scheduled pass): the usage endpoint rate-limits
701
+ # its OWN callers, and a fleet of machines polling one account too eagerly earns a 429
702
+ # with Retry-After 3600 — telemetry then goes stale for an hour at a time, which is
703
+ # exactly how every account ends up scoring NEUTRAL.
338
704
  kick="$ACC_ROOT/.limits-kick"
339
705
  stale=0
340
706
  for d in "${valid[@]}"; do
341
707
  f="$d/limits.json"
342
- if [ ! -f "$f" ] || [ $((now - $(file_mtime "$f"))) -gt 180 ]; then stale=1; break; fi
708
+ if [ ! -f "$f" ] || [ $((now - $(file_mtime "$f"))) -gt 600 ]; then stale=1; break; fi
343
709
  done
344
710
  if [ "$stale" = 1 ] && [ -x "$SELF_DIR/claude-accounts" ]; then
345
711
  last=0
346
712
  [ -f "$kick" ] && last="$(file_mtime "$kick")"
347
- if [ $((now - last)) -gt 120 ]; then
348
- : > "$kick" 2>/dev/null || true
713
+ if [ $((now - last)) -gt 600 ]; then
714
+ : 2>/dev/null > "$kick" || true
349
715
  ( "$SELF_DIR/claude-accounts" limits --quiet >/dev/null 2>&1 & ) >/dev/null 2>&1
350
716
  fi
351
717
  fi
@@ -353,14 +719,6 @@ fi
353
719
  acct="$(basename "$pick")"
354
720
  sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
355
721
 
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"
361
- fi
362
- }
363
-
364
722
  export CLAUDE_SHIM_ACTIVE=1
365
723
 
366
724
  # Auto-retry applies only to -p/--print runs with an alternative account available,
@@ -392,6 +750,7 @@ if [ "$wants_retry" = "0" ]; then
392
750
  export CLAUDE_CONFIG_DIR="$pick"
393
751
  tok="$(acct_token "$pick")"
394
752
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
753
+ sel_capture_session "$pick"
395
754
  exec "$REAL" "$@"
396
755
  fi
397
756
 
@@ -401,6 +760,7 @@ tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
401
760
  export CLAUDE_CONFIG_DIR="$pick"
402
761
  tok="$(acct_token "$pick")"
403
762
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
763
+ sel_capture_session "$pick"
404
764
  exec "$REAL" "$@"
405
765
  }
406
766
  trap 'rm -rf "$tmpd"' EXIT
@@ -412,6 +772,7 @@ if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
412
772
  export CLAUDE_CONFIG_DIR="$pick"
413
773
  tok="$(acct_token "$pick")"
414
774
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
775
+ sel_capture_session "$pick"
415
776
  exec "$REAL" "$@"
416
777
  fi
417
778
 
@@ -476,7 +837,7 @@ while :; do
476
837
  {
477
838
  echo "$now"
478
839
  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 \
840
+ } 2>/dev/null > "$cur/.expired.$$" \
480
841
  && mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
481
842
  || rm -f "$cur/.expired.$$" 2>/dev/null || true
482
843
  sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: claude-accounts expired"
@@ -484,7 +845,7 @@ while :; do
484
845
  {
485
846
  echo $((now + 600))
486
847
  echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
487
- } > "$cur/.limited.$$" 2>/dev/null \
848
+ } 2>/dev/null > "$cur/.limited.$$" \
488
849
  && mv -f "$cur/.limited.$$" "$cur/.limited" 2>/dev/null \
489
850
  || rm -f "$cur/.limited.$$" 2>/dev/null || true
490
851
  fi
@@ -500,6 +861,9 @@ while :; do
500
861
  if [ -n "$next" ]; then
501
862
  sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
502
863
  cur="$next"
864
+ # The account that actually serves the work is the one the next run should rotate
865
+ # away from — not the one that bounced.
866
+ remember_pick "$cur"
503
867
  attempt=2
504
868
  continue
505
869
  fi