claude-multiacc 1.0.14 → 1.0.16

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
@@ -101,7 +101,7 @@ The shim prints nothing, logs `timestamp account cwd` (never prompt text) to
101
101
  byte-identically. If anything is missing (no manifest, no accounts, unreadable state,
102
102
  even an unset `HOME`) it fails **open** into plain passthrough.
103
103
 
104
- **Limit-aware marking.** `claude-accounts limits` (every 5 min via launchd on the Mac,
104
+ **Limit-aware marking.** `claude-accounts limits` (every 15 min via launchd on the Mac,
105
105
  cron on the server, plus an opportunistic non-blocking kick from the shim when data is
106
106
  >10 min stale) reads each account's own OAuth usage endpoint — the same data `/usage`
107
107
  shows. Every bucket the endpoint returns is tracked separately: `session`, `weekly_all`,
@@ -118,11 +118,37 @@ code cannot read at all degrades that one account (fail open), never the run.
118
118
 
119
119
  Telemetry failures never block work: no fresh data ⇒ account treated as available. The
120
120
  endpoint rate-limits per account, so the refresher skips accounts fetched in the last 4 min
121
- and backs off exponentially (honoring `Retry-After`) on a 429 — `limits --force` overrides
122
- both. The cadence is deliberately unhurried: several machines polling the same accounts
123
- every minute earns a 429 with `Retry-After: 3600`, and telemetry then goes stale for an
124
- hour at a time which is exactly when every account starts scoring *neutral* and the
125
- picker loses its ability to tell them apart. If an account's OAuth access token has been expired for a while (idle account,
121
+ and backs off on **every** non-2xx, not just a 429 — honoring `Retry-After`, and parking
122
+ for 6 h on a refusal the server marks `x-should-retry: false`. `limits --force` overrides
123
+ all of it. The cadence is deliberately unhurried: several machines polling the same
124
+ accounts every minute earns a 429 with `Retry-After: 3600`, and telemetry then goes stale
125
+ for an hour at a time which is exactly when every account starts scoring *neutral* and
126
+ the picker loses its ability to tell them apart.
127
+
128
+ > **The usage endpoint needs an OAuth login, not a setup token.** A portable
129
+ > `server.token` (`sk-ant-oat01-…`) authenticates *inference* forever, but the usage
130
+ > endpoint refuses it with `403 — OAuth token does not meet scope requirement
131
+ > user:profile`: setup tokens are minted without that scope. So an account whose
132
+ > `.credentials.json` grant has lapsed keeps working perfectly while going **permanently
133
+ > dark for telemetry**, and a pool where that happens to every account ranks everything
134
+ > neutral and picks at random. `claude-accounts status` says `RANKING IS BLIND` when the
135
+ > pool is in that state, and the shim prints an hourly warning on a terminal. The fix is
136
+ > a real sign-in on the machine that polls (`claude-accounts login <acct-NN>`); since
137
+ > `limits.json` is one of the things `sync` pushes, only the **source** machine needs it —
138
+ > the server and peers inherit the telemetry.
139
+
140
+ **Two freshness windows, on purpose.** Ranking trusts telemetry for an hour
141
+ (`CLAUDE_MULTIACC_STALE_AFTER`, default 3600 — matching the `Retry-After: 3600` the
142
+ endpoint itself hands out, so a healthy pool is not "stale" for 45 minutes of every
143
+ hour). The ≥90% *exclusion* keeps the tighter 15-minute window: ranking chooses between
144
+ working accounts, while the cutoff declares one unusable, and an account reading 89% an
145
+ hour ago may be well past 90% now. When nothing is in-window at all, a stale weekly
146
+ reading is still used **if its bucket has not reset yet** (a weekly bucket only rises
147
+ until then, so the number remains a true lower bound) — logged as `ranking=DEGRADED`.
148
+ Only when even that is unavailable does selection fall back to neutral, logged as
149
+ `ranking=BLIND`.
150
+
151
+ If an account's OAuth access token has been expired for a while (idle account,
126
152
  nothing ran claude under it for hours), the refresher renews it directly via the OAuth
127
153
  **refresh-token grant** — the same endpoint and public client id Claude Code itself uses —
128
154
  and atomically persists the rotated credential (0600) back to that account's
package/bin/claude CHANGED
@@ -125,29 +125,105 @@ marker_active() { # true if $1/.limited is still in force; clears cleanly-expire
125
125
  return 0
126
126
  }
127
127
 
128
- 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
129
135
 
130
- fresh_field() { # fresh_field <acct dir> <json key> -> integer if telemetry fresh, else fail
131
- 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
132
146
  [ -f "$f" ] || return 1
133
147
  fetched="$(sed -n 's/.*"fetched_at"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
134
148
  num_ok "$fetched" || return 1
135
- [ $((now - fetched)) -le "$STALE_AFTER" ] || return 1
136
- v="$(sed -n "s/.*\"$2\"[^0-9]*\([0-9][0-9]*\).*/\1/p" "$f" 2>/dev/null | head -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)"
137
155
  num_ok "$v" || return 1
138
156
  printf '%s\n' "$v"
139
157
  }
140
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
+
141
211
  # RANKING score — lower is better (more headroom). Weekly headroom dominates: a weekly
142
212
  # bucket only refills on the account's fixed weekly reset (days away), while the 5h
143
213
  # session bucket self-heals, so session is a mild tiebreaker only. (Anthropic's docs
144
214
  # confirm this reset asymmetry — an account whose only near-full bucket is the cheap
145
215
  # session one must NOT rank behind one burning durable weekly headroom.)
146
216
  # score = weekly%*1000 + session% weekly,session in [0,100]
147
- # 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
148
222
  sel_score_of() { # $1 = acct dir
149
223
  local w s
150
- 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
151
227
  s="$(fresh_field "$1" session_percent)" || s=50
152
228
  printf '%s\n' $((w * 1000 + s))
153
229
  }
@@ -165,7 +241,7 @@ util_of() {
165
241
  # (fail open — telemetry must never invent exclusions).
166
242
  over_threshold() { # $1 = acct dir
167
243
  local v
168
- v="$(fresh_field "$1" max_percent)" || return 1
244
+ v="$(cutoff_field "$1" max_percent)" || return 1
169
245
  [ "$v" -ge "${CLAUDE_MULTIACC_THRESHOLD:-90}" ]
170
246
  }
171
247
 
@@ -399,6 +475,48 @@ client_limit_scan() { # $1 acct dir
399
475
  return 1
400
476
  }
401
477
 
478
+ # A TUI auth failure cannot use the -p retry path because the client owns the terminal.
479
+ # Harvest the same account-owned transcripts used for quota detection so the next launch
480
+ # parks a rejected setup-token instead of selecting it again.
481
+ client_auth_scan() { # $1 acct dir
482
+ local idx="$1/.sessions-index" ln id claim p line ts ck ak read_n=0 i
483
+ local ids=() claims=()
484
+ [ -f "$idx" ] || return 1
485
+ while IFS= read -r ln; do
486
+ id="${ln%% *}"; claim=""
487
+ case "$ln" in *' '*) claim="${ln#* }" ;; esac
488
+ sess_id_ok "$id" && { ids+=("$id"); claims+=("$claim"); }
489
+ done < "$idx"
490
+ i=$(( ${#ids[@]} - 1 ))
491
+ while [ "$i" -ge 0 ] && [ "$read_n" -lt "$QUOTA_SCAN_MAX_FILES" ]; do
492
+ id="${ids[$i]}"; claim="${claims[$i]}"; i=$((i - 1)); read_n=$((read_n + 1))
493
+ sess_transcript "$1" "$id" || continue
494
+ p="$SESS_TRANSCRIPT"
495
+ [ $((now - $(file_mtime "$p"))) -le "$QUOTA_SCAN_MAX_AGE" ] || continue
496
+ line="$(tail -c "$QUOTA_SCAN_BYTES" "$p" 2>/dev/null \
497
+ | LC_ALL=C grep -a '"error"[[:space:]]*:[[:space:]]*"authentication_failed"' | tail -1)"
498
+ [ -n "$line" ] || continue
499
+ [ -n "$claim" ] || continue
500
+ ts="$(printf '%s' "$line" | LC_ALL=C sed -n 's/.*"timestamp"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
501
+ ck="$(iso_key "$ts")"; ak="$(iso_key "$claim")"
502
+ num_ok "$ck" || continue; num_ok "$ak" || continue
503
+ [ "$ck" -lt "$ak" ] && continue
504
+ claim_conflicted "$1" "$id" "$claim" && continue
505
+ return 0
506
+ done
507
+ return 1
508
+ }
509
+
510
+ mark_client_auth_dead() { # $1 acct dir
511
+ local soft=$((now + 3600)) marked
512
+ marked="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
513
+ { echo "$now"; echo "reason=auth-error soft_until=$soft marked_at=$marked" \
514
+ "detail=client session failed to authenticate"; } \
515
+ 2>/dev/null > "$1/.expired.$$" && mv -f "$1/.expired.$$" "$1/.expired" 2>/dev/null \
516
+ || rm -f "$1/.expired.$$" 2>/dev/null || true
517
+ sel_log "$(basename "$1") parked (auth-error until $soft) — client-reported"
518
+ }
519
+
402
520
  mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type
403
521
  local m="$1/.limited" cur=""
404
522
  # Never shorten a marker that already reaches further out (a weekly park must
@@ -431,11 +549,11 @@ sel_capture_session() { # $1 acct dir
431
549
  # stops the moment the run it is watching is gone.
432
550
  trap '' INT HUP TERM QUIT
433
551
  i=0
434
- while [ "$i" -lt 30 ]; do
552
+ while [ "$i" -lt 600 ]; do
435
553
  kill -0 "$pid" 2>/dev/null || break
436
554
  sess_index_refresh "$d"
437
555
  [ -f "$d/sessions/$pid.json" ] && break
438
- sleep 2
556
+ sleep 0.1
439
557
  i=$((i + 1))
440
558
  done
441
559
  sess_index_refresh "$d"
@@ -562,6 +680,12 @@ for d in "$ACC_ROOT"/acct-*; do
562
680
  fi
563
681
  valid+=("$d")
564
682
  sess_index_refresh "$d"
683
+ if client_auth_scan "$d"; then
684
+ mark_client_auth_dead "$d"
685
+ valid=("${valid[@]:0:${#valid[@]}-1}")
686
+ expired+=("$d")
687
+ continue
688
+ fi
565
689
  marker_active "$d" && continue
566
690
  # The account's own records are consulted BEFORE telemetry: what the server told a real
567
691
  # call is first-hand and carries the real reset, while limits.json can be days stale —
@@ -679,7 +803,45 @@ pick_best() { # args: candidate dirs
679
803
  PICK_SCORE="$bestv"
680
804
  }
681
805
 
806
+ # Telemetry going stale is not a per-run detail, it is a pool-wide outage: with no
807
+ # in-window data ANYWHERE every account scores the identical NEUTRAL value, the tie
808
+ # spans the whole pool, and "pick the account with the most headroom" silently becomes
809
+ # "pick any account at all". That is how a fresh session lands on the one account
810
+ # already at 80% of its weekly limit while `claude-accounts status` still shows a
811
+ # reassuring 2% from eleven days ago. It cost eleven days of blind picks once.
812
+ # Two answers, and the order matters: rank on whatever old readings are still true
813
+ # BEFORE picking, and say out loud which of the two happened.
814
+ blind=1 # 1 = no candidate has in-window telemetry
815
+ blind_age=0 # newest stale reading among the candidates; 0 = never fetched at all
816
+ degraded=0 # 1 = blind, but every candidate had a stale reading still worth using
817
+ assess_telemetry() { # args: the dirs actually being chosen between
818
+ local d f n=0 stale_ok=0
819
+ blind=1; blind_age=0; degraded=0
820
+ for d in "$@"; do
821
+ n=$((n + 1))
822
+ if ! telem_blind "$d"; then blind=0; return 0; fi
823
+ f="$(telem_fetched_at "$d" || echo 0)"
824
+ # The NEWEST stale reading is the honest age of the outage; an account that was
825
+ # never fetched at all must not make the pool look older than it is.
826
+ [ "$f" -gt 0 ] && { [ "$blind_age" -eq 0 ] || [ $((now - f)) -lt "$blind_age" ]; } \
827
+ && blind_age=$((now - f))
828
+ stale_weekly "$d" >/dev/null && stale_ok=$((stale_ok + 1))
829
+ done
830
+ # All or nothing. A candidate whose reading has no horizon — a limits.json written
831
+ # before this field existed, or one whose week has already turned — scores neutral
832
+ # 50, and 50 would beat a NEIGHBOUR's true-but-worse 70. Mixing the two makes the
833
+ # degraded ranking actively wrong, so it is only used when every candidate can be
834
+ # compared on the same footing.
835
+ [ "$n" -gt 0 ] && [ "$stale_ok" -eq "$n" ] && degraded=1
836
+ return 0
837
+ }
838
+
682
839
  if [ "${#eligible[@]}" -gt 0 ]; then
840
+ # Blindness is judged over the accounts actually being chosen between, not over every
841
+ # valid one: a FRESH account sitting behind a .limited marker is not a candidate, and
842
+ # letting it clear the flag would leave the real candidates ranking neutral.
843
+ assess_telemetry "${eligible[@]}"
844
+ [ "$degraded" = 1 ] && SEL_DEGRADED=1
683
845
  if [ "${CLAUDE_SHIM_SELECT:-headroom}" = "random" ]; then
684
846
  PICK_DIR="${eligible[$((RANDOM % ${#eligible[@]}))]}"
685
847
  else
@@ -687,8 +849,19 @@ if [ "${#eligible[@]}" -gt 0 ]; then
687
849
  fi
688
850
  else
689
851
  # Every account is limit-marked: degraded service beats a hard failure (100% rule).
852
+ assess_telemetry "${valid[@]}"
853
+ [ "$degraded" = 1 ] && SEL_DEGRADED=1
690
854
  pick_best "${valid[@]}"
691
- sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
855
+ # Report the number this fallback ACTUALLY ranked on. Asking fresh_field here printed
856
+ # `weekly=?%` even when the pick was made on a perfectly good stale reading, so anyone
857
+ # reading only this event concluded the choice had no usage input at all.
858
+ if [ "$degraded" = 1 ]; then
859
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(stale_weekly "$PICK_DIR" || echo '?')% ranking=DEGRADED"
860
+ elif [ "$blind" = 1 ]; then
861
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=?% ranking=BLIND"
862
+ else
863
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
864
+ fi
692
865
  fi
693
866
  pick="$PICK_DIR"
694
867
  # Remember the pick so the NEXT run does not hand back the same account. An explicit
@@ -717,7 +890,37 @@ if [ "$stale" = 1 ] && [ -x "$SELF_DIR/claude-accounts" ]; then
717
890
  fi
718
891
 
719
892
  acct="$(basename "$pick")"
720
- sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
893
+ if [ "$blind" = 1 ]; then
894
+ # Two genuinely different states, and an operator debugging this needs to know which:
895
+ # DEGRADED still ranks, on old readings that remain true; BLIND cannot rank at all and
896
+ # is a coin flip. Calling both of them "random" would send someone hunting the wrong bug.
897
+ if [ "$degraded" = 1 ]; then
898
+ sel_log "$acct weekly=$(stale_weekly "$pick" || echo '?')% session=?% ranking=DEGRADED telemetry-age=${blind_age}s pwd=$PWD"
899
+ else
900
+ sel_log "$acct weekly=?% session=?% ranking=BLIND telemetry-age=${blind_age}s pwd=$PWD"
901
+ fi
902
+ # Terminal only, at most hourly — a service-spawned `claude -p` must keep its stderr
903
+ # byte-clean, and this is advice, never a failure.
904
+ if [ -t 2 ]; then
905
+ n="$ACC_ROOT/.stale-notice"
906
+ last=0
907
+ [ -f "$n" ] && last="$(file_mtime "$n")"
908
+ if [ $((now - last)) -gt 3600 ]; then
909
+ : 2>/dev/null > "$n" || true
910
+ if [ "$degraded" = 1 ]; then
911
+ 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' \
912
+ "$(age_human "$blind_age")" >&2
913
+ elif [ "$blind_age" -gt 0 ]; then
914
+ 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' \
915
+ "$(age_human "$blind_age")" >&2
916
+ else
917
+ 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
918
+ fi
919
+ fi
920
+ fi
921
+ else
922
+ sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
923
+ fi
721
924
 
722
925
  export CLAUDE_SHIM_ACTIVE=1
723
926