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/README.md +276 -15
- package/bin/claude +565 -46
- package/bin/claude-accounts +447 -75
- package/bin/codex +254 -26
- package/bin/codex-accounts +157 -52
- package/install.sh +196 -69
- package/lib/common.sh +262 -2
- package/lib/credential.py +336 -0
- package/lib/report.py +434 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +1556 -8
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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)"
|
|
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="$(
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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="$
|
|
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
|
-
|
|
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
|
|
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
|
|
348
|
-
: > "$kick"
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
#
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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.$$"
|
|
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.$$"
|
|
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
|