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 +32 -6
- package/bin/claude +165 -10
- package/bin/claude-accounts +306 -30
- package/install.sh +25 -4
- package/lib/report.py +81 -2
- package/package.json +1 -1
- package/tests/run-tests.sh +504 -8
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
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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)"
|
|
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="$(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|