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