claude-multiacc 1.0.14 → 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/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
 
@@ -679,7 +755,45 @@ pick_best() { # args: candidate dirs
679
755
  PICK_SCORE="$bestv"
680
756
  }
681
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
+
682
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
683
797
  if [ "${CLAUDE_SHIM_SELECT:-headroom}" = "random" ]; then
684
798
  PICK_DIR="${eligible[$((RANDOM % ${#eligible[@]}))]}"
685
799
  else
@@ -687,8 +801,19 @@ if [ "${#eligible[@]}" -gt 0 ]; then
687
801
  fi
688
802
  else
689
803
  # Every account is limit-marked: degraded service beats a hard failure (100% rule).
804
+ assess_telemetry "${valid[@]}"
805
+ [ "$degraded" = 1 ] && SEL_DEGRADED=1
690
806
  pick_best "${valid[@]}"
691
- 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
692
817
  fi
693
818
  pick="$PICK_DIR"
694
819
  # Remember the pick so the NEXT run does not hand back the same account. An explicit
@@ -717,7 +842,37 @@ if [ "$stale" = 1 ] && [ -x "$SELF_DIR/claude-accounts" ]; then
717
842
  fi
718
843
 
719
844
  acct="$(basename "$pick")"
720
- sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
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"
853
+ fi
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
721
876
 
722
877
  export CLAUDE_SHIM_ACTIVE=1
723
878