claude-multiacc 1.0.0
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/CLAUDE_ACCS_TASK.md +236 -0
- package/README.md +263 -0
- package/bin/claude +359 -0
- package/bin/claude-accounts +1127 -0
- package/bin/cli.mjs +112 -0
- package/install.sh +277 -0
- package/lib/common.sh +200 -0
- package/package.json +55 -0
- package/scripts/postinstall.mjs +32 -0
- package/tests/run-tests.sh +643 -0
package/bin/claude
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# claude-multiacc-shim — PATH-shadows the real `claude` binary; never replaces or edits it.
|
|
3
|
+
# Every invocation runs under a randomly picked subscription account with limit headroom.
|
|
4
|
+
# Self-contained on purpose: no sourcing, so a broken repo file can never break `claude`.
|
|
5
|
+
# Selection: CLAUDE_CONFIG_DIR/CLAUDE_CODE_OAUTH_TOKEN passthrough > CLAUDE_ACCOUNT pin >
|
|
6
|
+
# random among limit-eligible accounts > least-utilized fallback (degraded beats down).
|
|
7
|
+
|
|
8
|
+
set -u
|
|
9
|
+
|
|
10
|
+
# ${HOME:-} guards: with HOME stripped (env -i, some cron/systemd units) the shim
|
|
11
|
+
# must still fail OPEN into plain passthrough, never abort on an unbound variable.
|
|
12
|
+
ACC_ROOT="${CLAUDE_ACCOUNTS_DIR:-${HOME:-/nonexistent}/.claude-accounts}"
|
|
13
|
+
MANIFEST="$ACC_ROOT/accounts.json"
|
|
14
|
+
|
|
15
|
+
canon_path() {
|
|
16
|
+
local p="$1" t i=0 d b
|
|
17
|
+
case "$p" in /*) ;; *) p="$PWD/$p" ;; esac
|
|
18
|
+
while [ -L "$p" ] && [ "$i" -lt 40 ]; do
|
|
19
|
+
t="$(readlink "$p")" || break
|
|
20
|
+
case "$t" in /*) p="$t" ;; *) p="$(dirname "$p")/$t" ;; esac
|
|
21
|
+
i=$((i+1))
|
|
22
|
+
done
|
|
23
|
+
d="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)" || { printf '%s\n' "$p"; return 0; }
|
|
24
|
+
b="$(basename "$p")"
|
|
25
|
+
if [ "$d" = "/" ]; then printf '/%s\n' "$b"; else printf '%s/%s\n' "$d" "$b"; fi
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
is_shim_file() { head -c 300 "$1" 2>/dev/null | grep -q claude-multiacc-shim; }
|
|
29
|
+
|
|
30
|
+
SELF="$(canon_path "$0")"
|
|
31
|
+
SELF_DIR="$(dirname "$SELF")"
|
|
32
|
+
|
|
33
|
+
find_real() {
|
|
34
|
+
local cand c d
|
|
35
|
+
local oldifs="$IFS"
|
|
36
|
+
IFS=':'; set -f
|
|
37
|
+
# shellcheck disable=SC2086
|
|
38
|
+
set -- $PATH
|
|
39
|
+
IFS="$oldifs"; set +f
|
|
40
|
+
for d in "$@"; do
|
|
41
|
+
[ -n "$d" ] || continue
|
|
42
|
+
cand="$d/claude"
|
|
43
|
+
[ -f "$cand" ] && [ -x "$cand" ] || continue
|
|
44
|
+
c="$(canon_path "$cand")"
|
|
45
|
+
[ "$c" = "$SELF" ] && continue
|
|
46
|
+
case "$c" in "$ACC_ROOT"/*) continue ;; esac
|
|
47
|
+
is_shim_file "$c" && continue
|
|
48
|
+
printf '%s\n' "$cand"; return 0
|
|
49
|
+
done
|
|
50
|
+
# Fallbacks: resolved dynamically at exec time, so `claude update`/reinstalls keep working.
|
|
51
|
+
for cand in "${HOME:-/nonexistent}/.local/bin/claude" /usr/local/bin/claude /opt/homebrew/bin/claude /usr/bin/claude; do
|
|
52
|
+
[ -f "$cand" ] && [ -x "$cand" ] || continue
|
|
53
|
+
c="$(canon_path "$cand")"
|
|
54
|
+
[ "$c" = "$SELF" ] && continue
|
|
55
|
+
is_shim_file "$c" && continue
|
|
56
|
+
printf '%s\n' "$cand"; return 0
|
|
57
|
+
done
|
|
58
|
+
return 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
REAL="$(find_real)" || {
|
|
62
|
+
printf 'claude-multiacc shim: real claude binary not found (PATH or fallback locations)\n' >&2
|
|
63
|
+
exit 127
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Fast passthrough: caller pinned a config dir or token, addon disabled, recursion
|
|
67
|
+
# guard, or no account data yet. Byte-identical behavior to stock claude.
|
|
68
|
+
if [ -n "${CLAUDE_CONFIG_DIR:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] \
|
|
69
|
+
|| [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CLAUDE_SHIM_ACTIVE:-}" ] \
|
|
70
|
+
|| [ ! -f "$MANIFEST" ]; then
|
|
71
|
+
exec "$REAL" "$@"
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
now="$(date +%s)"
|
|
75
|
+
|
|
76
|
+
# Threshold used by the telemetry backstop below; the manifest is the source of
|
|
77
|
+
# truth, but a corrupt/unreadable manifest must never break selection => plain
|
|
78
|
+
# sed with a safe default, never a JSON parse.
|
|
79
|
+
if [ -z "${CLAUDE_MULTIACC_THRESHOLD:-}" ]; then
|
|
80
|
+
CLAUDE_MULTIACC_THRESHOLD="$(sed -n 's/.*"threshold"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$MANIFEST" 2>/dev/null | head -1)"
|
|
81
|
+
case "$CLAUDE_MULTIACC_THRESHOLD" in ''|*[!0-9]*) CLAUDE_MULTIACC_THRESHOLD=90 ;; esac
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
if [ "$(uname -s)" = "Darwin" ]; then
|
|
85
|
+
file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
|
|
86
|
+
else
|
|
87
|
+
file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
sel_log() {
|
|
91
|
+
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$ACC_ROOT/selection.log" 2>/dev/null || true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
|
|
95
|
+
local m="$1/.limited" reset=""
|
|
96
|
+
[ -f "$m" ] || return 1
|
|
97
|
+
IFS= read -r reset < "$m" 2>/dev/null || reset=""
|
|
98
|
+
case "$reset" in
|
|
99
|
+
''|*[!0-9]*)
|
|
100
|
+
# Empty/partial/garbled marker — e.g. read during a concurrent rewrite.
|
|
101
|
+
# Treat as ACTIVE and never delete: deleting here could destroy a marker
|
|
102
|
+
# another process is mid-write. The next limits refresh rewrites or clears it.
|
|
103
|
+
return 0 ;;
|
|
104
|
+
esac
|
|
105
|
+
if [ "$now" -ge "$reset" ]; then
|
|
106
|
+
rm -f "$m" 2>/dev/null
|
|
107
|
+
return 1
|
|
108
|
+
fi
|
|
109
|
+
return 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
STALE_AFTER=900
|
|
113
|
+
|
|
114
|
+
fresh_field() { # fresh_field <acct dir> <json key> -> integer if telemetry fresh, else fail
|
|
115
|
+
local f="$1/limits.json" fetched v
|
|
116
|
+
[ -f "$f" ] || return 1
|
|
117
|
+
fetched="$(sed -n 's/.*"fetched_at"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
|
|
118
|
+
case "$fetched" in ''|*[!0-9]*) return 1 ;; esac
|
|
119
|
+
[ $((now - fetched)) -le "$STALE_AFTER" ] || return 1
|
|
120
|
+
v="$(sed -n "s/.*\"$2\"[^0-9]*\([0-9][0-9]*\).*/\1/p" "$f" 2>/dev/null | head -1)"
|
|
121
|
+
case "$v" in ''|*[!0-9]*) return 1 ;; esac
|
|
122
|
+
printf '%s\n' "$v"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# RANKING score — lower is better (more headroom). Weekly headroom dominates: a weekly
|
|
126
|
+
# bucket only refills on the account's fixed weekly reset (days away), while the 5h
|
|
127
|
+
# session bucket self-heals, so session is a mild tiebreaker only. (Anthropic's docs
|
|
128
|
+
# confirm this reset asymmetry — an account whose only near-full bucket is the cheap
|
|
129
|
+
# session one must NOT rank behind one burning durable weekly headroom.)
|
|
130
|
+
# score = weekly%*1000 + session% weekly,session in [0,100]
|
|
131
|
+
# Stale/unreadable telemetry ranks NEUTRAL (weekly 50, session 50), never "free".
|
|
132
|
+
sel_score_of() { # $1 = acct dir
|
|
133
|
+
local w s
|
|
134
|
+
w="$(fresh_field "$1" weekly_percent)" || w="$(fresh_field "$1" max_percent)" || w=50
|
|
135
|
+
s="$(fresh_field "$1" session_percent)" || s=50
|
|
136
|
+
printf '%s\n' $((w * 1000 + s))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# Peak of ALL buckets (session included) — the EXCLUSION signal. Stale/unknown => 50.
|
|
140
|
+
util_of() {
|
|
141
|
+
local v
|
|
142
|
+
v="$(fresh_field "$1" max_percent)" || v=50
|
|
143
|
+
printf '%s\n' "$v"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Backstop for a lost/failed marker write: fresh telemetry with ANY bucket at/over the
|
|
147
|
+
# threshold excludes the account even if .limited is missing (a full session bucket
|
|
148
|
+
# really blocks now; its marker just expires soon). Stale/unreadable => not over
|
|
149
|
+
# (fail open — telemetry must never invent exclusions).
|
|
150
|
+
over_threshold() { # $1 = acct dir
|
|
151
|
+
local v
|
|
152
|
+
v="$(fresh_field "$1" max_percent)" || return 1
|
|
153
|
+
[ "$v" -ge "${CLAUDE_MULTIACC_THRESHOLD:-90}" ]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# An empty credentials file is NOT auth (an interrupted write must not make a
|
|
157
|
+
# dead account selectable and turn a working stock run into an auth failure).
|
|
158
|
+
has_auth() { [ -s "$1/.credentials.json" ] || [ -s "$1/server.token" ]; }
|
|
159
|
+
|
|
160
|
+
# Explicit pin wins over everything — markers, and even missing auth: the
|
|
161
|
+
# add/login ceremony pins to a dir that has no credentials yet, and the login
|
|
162
|
+
# must land exactly there, never in a randomly selected account's dir.
|
|
163
|
+
if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
|
|
164
|
+
d="$ACC_ROOT/$CLAUDE_ACCOUNT"
|
|
165
|
+
if [ -d "$d" ]; then
|
|
166
|
+
sel_log "$CLAUDE_ACCOUNT pinned pwd=$PWD"
|
|
167
|
+
export CLAUDE_CONFIG_DIR="$d"
|
|
168
|
+
export CLAUDE_SHIM_ACTIVE=1
|
|
169
|
+
if [ ! -f "$d/.credentials.json" ] && [ -s "$d/server.token" ]; then
|
|
170
|
+
CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '[:space:]' < "$d/server.token")"
|
|
171
|
+
export CLAUDE_CODE_OAUTH_TOKEN
|
|
172
|
+
fi
|
|
173
|
+
exec "$REAL" "$@"
|
|
174
|
+
fi
|
|
175
|
+
sel_log "pin-invalid account=$CLAUDE_ACCOUNT (no such dir; random fallback)"
|
|
176
|
+
fi
|
|
177
|
+
|
|
178
|
+
valid=()
|
|
179
|
+
eligible=()
|
|
180
|
+
for d in "$ACC_ROOT"/acct-*; do
|
|
181
|
+
[ -d "$d" ] || continue
|
|
182
|
+
has_auth "$d" || continue
|
|
183
|
+
valid+=("$d")
|
|
184
|
+
marker_active "$d" && continue
|
|
185
|
+
over_threshold "$d" && continue
|
|
186
|
+
eligible+=("$d")
|
|
187
|
+
done
|
|
188
|
+
|
|
189
|
+
# No usable accounts => stock behavior (fail open, never block work).
|
|
190
|
+
[ "${#valid[@]}" -gt 0 ] || exec "$REAL" "$@"
|
|
191
|
+
|
|
192
|
+
# Pick the account with the MOST headroom (lowest ranking score = most weekly headroom,
|
|
193
|
+
# session as tiebreaker). Ties break randomly so equally-idle accounts still spread load.
|
|
194
|
+
# Sets PICK_DIR/PICK_SCORE as globals — it must never touch "$@", which holds the
|
|
195
|
+
# user's claude arguments.
|
|
196
|
+
PICK_DIR=""
|
|
197
|
+
PICK_SCORE=""
|
|
198
|
+
pick_best() { # args: candidate dirs
|
|
199
|
+
local d v best="" bestv=1000000 ties=1
|
|
200
|
+
for d in "$@"; do
|
|
201
|
+
v="$(sel_score_of "$d")"
|
|
202
|
+
if [ "$v" -lt "$bestv" ]; then
|
|
203
|
+
bestv="$v"; best="$d"; ties=1
|
|
204
|
+
elif [ "$v" -eq "$bestv" ]; then
|
|
205
|
+
ties=$((ties + 1))
|
|
206
|
+
[ $((RANDOM % ties)) -eq 0 ] && best="$d" # reservoir-sample among equals
|
|
207
|
+
fi
|
|
208
|
+
done
|
|
209
|
+
PICK_DIR="$best"
|
|
210
|
+
PICK_SCORE="$bestv"
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if [ "${#eligible[@]}" -gt 0 ]; then
|
|
214
|
+
if [ "${CLAUDE_SHIM_SELECT:-headroom}" = "random" ]; then
|
|
215
|
+
PICK_DIR="${eligible[$((RANDOM % ${#eligible[@]}))]}"
|
|
216
|
+
else
|
|
217
|
+
pick_best "${eligible[@]}"
|
|
218
|
+
fi
|
|
219
|
+
else
|
|
220
|
+
# Every account is limit-marked: degraded service beats a hard failure (100% rule).
|
|
221
|
+
pick_best "${valid[@]}"
|
|
222
|
+
sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
|
|
223
|
+
fi
|
|
224
|
+
pick="$PICK_DIR"
|
|
225
|
+
|
|
226
|
+
# Opportunistic limits refresh: non-blocking, throttled, backgrounded.
|
|
227
|
+
kick="$ACC_ROOT/.limits-kick"
|
|
228
|
+
stale=0
|
|
229
|
+
for d in "${valid[@]}"; do
|
|
230
|
+
f="$d/limits.json"
|
|
231
|
+
if [ ! -f "$f" ] || [ $((now - $(file_mtime "$f"))) -gt 180 ]; then stale=1; break; fi
|
|
232
|
+
done
|
|
233
|
+
if [ "$stale" = 1 ] && [ -x "$SELF_DIR/claude-accounts" ]; then
|
|
234
|
+
last=0
|
|
235
|
+
[ -f "$kick" ] && last="$(file_mtime "$kick")"
|
|
236
|
+
if [ $((now - last)) -gt 120 ]; then
|
|
237
|
+
: > "$kick" 2>/dev/null || true
|
|
238
|
+
( "$SELF_DIR/claude-accounts" limits --quiet >/dev/null 2>&1 & ) >/dev/null 2>&1
|
|
239
|
+
fi
|
|
240
|
+
fi
|
|
241
|
+
|
|
242
|
+
acct="$(basename "$pick")"
|
|
243
|
+
sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
|
|
244
|
+
|
|
245
|
+
acct_token() { # $1 = acct dir; prints token if dir is token-auth (no local creds)
|
|
246
|
+
if [ ! -f "$1/.credentials.json" ] && [ -s "$1/server.token" ]; then
|
|
247
|
+
tr -d '[:space:]' < "$1/server.token"
|
|
248
|
+
fi
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export CLAUDE_SHIM_ACTIVE=1
|
|
252
|
+
|
|
253
|
+
# Auto-retry applies only to -p/--print runs with an alternative account available,
|
|
254
|
+
# and only when stdin is finite (tty, regular file, or char device like /dev/null).
|
|
255
|
+
# A service-spawned pipe that never EOFs must take the plain exec path, or the
|
|
256
|
+
# stdin pre-buffering below would hang the call.
|
|
257
|
+
wants_retry=0
|
|
258
|
+
if [ "${CLAUDE_SHIM_RETRY:-1}" != "0" ] && [ "${#eligible[@]}" -ge 2 ]; then
|
|
259
|
+
for a in "$@"; do
|
|
260
|
+
case "$a" in -p|--print) wants_retry=1; break ;; esac
|
|
261
|
+
done
|
|
262
|
+
if [ "$wants_retry" = "1" ]; then
|
|
263
|
+
# A TTY cannot be buffered or replayed: `claude -p` with no prompt argument reads
|
|
264
|
+
# the terminal, and the retry path would hand it /dev/null. Plain exec instead —
|
|
265
|
+
# stdin is inherited untouched. A pipe that never EOFs would hang the pre-buffer,
|
|
266
|
+
# so only finite stdin (regular file, /dev/null-style char device) takes the retry
|
|
267
|
+
# path; everything else execs directly.
|
|
268
|
+
if [ -t 0 ]; then
|
|
269
|
+
wants_retry=0
|
|
270
|
+
elif [ -f /dev/fd/0 ] || [ -c /dev/fd/0 ]; then
|
|
271
|
+
:
|
|
272
|
+
else
|
|
273
|
+
wants_retry=0
|
|
274
|
+
fi
|
|
275
|
+
fi
|
|
276
|
+
fi
|
|
277
|
+
|
|
278
|
+
if [ "$wants_retry" = "0" ]; then
|
|
279
|
+
export CLAUDE_CONFIG_DIR="$pick"
|
|
280
|
+
tok="$(acct_token "$pick")"
|
|
281
|
+
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
282
|
+
exec "$REAL" "$@"
|
|
283
|
+
fi
|
|
284
|
+
|
|
285
|
+
# Retry path: buffer stdio so a retried call never double-emits partial output.
|
|
286
|
+
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
|
|
287
|
+
tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
|
|
288
|
+
export CLAUDE_CONFIG_DIR="$pick"
|
|
289
|
+
tok="$(acct_token "$pick")"
|
|
290
|
+
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
291
|
+
exec "$REAL" "$@"
|
|
292
|
+
}
|
|
293
|
+
trap 'rm -rf "$tmpd"' EXIT
|
|
294
|
+
|
|
295
|
+
# The output buffers must be writable BEFORE the run: if redirection failed at exec
|
|
296
|
+
# time (disk full), the real binary would never launch and the shim would exit
|
|
297
|
+
# nonzero — a hard failure. Verify now, fall back to plain exec if we cannot.
|
|
298
|
+
if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
|
|
299
|
+
export CLAUDE_CONFIG_DIR="$pick"
|
|
300
|
+
tok="$(acct_token "$pick")"
|
|
301
|
+
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
302
|
+
exec "$REAL" "$@"
|
|
303
|
+
fi
|
|
304
|
+
|
|
305
|
+
stdin_file=""
|
|
306
|
+
if [ ! -t 0 ]; then
|
|
307
|
+
stdin_file="$tmpd/in"
|
|
308
|
+
# If buffering fails midway (disk full) stdin is already partly consumed and cannot
|
|
309
|
+
# be rewound — keep whatever landed rather than silently substituting /dev/null.
|
|
310
|
+
cat > "$stdin_file" 2>/dev/null || [ -s "$stdin_file" ] || stdin_file=""
|
|
311
|
+
fi
|
|
312
|
+
|
|
313
|
+
ERRPAT='rate[ _-]?limit|usage limit|limit (reached|exceeded)|overloaded|"?529"?|401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)|credit balance'
|
|
314
|
+
|
|
315
|
+
attempt=1
|
|
316
|
+
cur="$pick"
|
|
317
|
+
rc=0
|
|
318
|
+
while :; do
|
|
319
|
+
tok="$(acct_token "$cur")"
|
|
320
|
+
if [ -n "$stdin_file" ]; then exec 3< "$stdin_file"; else exec 3< /dev/null; fi
|
|
321
|
+
if [ -n "$tok" ]; then
|
|
322
|
+
CLAUDE_CONFIG_DIR="$cur" CLAUDE_CODE_OAUTH_TOKEN="$tok" "$REAL" "$@" <&3 > "$tmpd/out" 2> "$tmpd/err"
|
|
323
|
+
else
|
|
324
|
+
CLAUDE_CONFIG_DIR="$cur" "$REAL" "$@" <&3 > "$tmpd/out" 2> "$tmpd/err"
|
|
325
|
+
fi
|
|
326
|
+
rc=$?
|
|
327
|
+
exec 3<&-
|
|
328
|
+
if [ "$rc" -ne 0 ] && [ "$attempt" -eq 1 ] \
|
|
329
|
+
&& grep -qiE "$ERRPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
330
|
+
# Atomic marker write: a reader must never observe a half-written .limited
|
|
331
|
+
# (it would parse as garbage and, before, could be deleted as "expired").
|
|
332
|
+
{
|
|
333
|
+
echo $((now + 600))
|
|
334
|
+
echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
|
|
335
|
+
} > "$cur/.limited.$$" 2>/dev/null \
|
|
336
|
+
&& mv -f "$cur/.limited.$$" "$cur/.limited" 2>/dev/null \
|
|
337
|
+
|| rm -f "$cur/.limited.$$" 2>/dev/null || true
|
|
338
|
+
next=""
|
|
339
|
+
n="${#eligible[@]}"
|
|
340
|
+
start=$((RANDOM % n))
|
|
341
|
+
i=0
|
|
342
|
+
while [ "$i" -lt "$n" ]; do
|
|
343
|
+
c="${eligible[$(((start + i) % n))]}"
|
|
344
|
+
if [ "$c" != "$cur" ]; then next="$c"; break; fi
|
|
345
|
+
i=$((i+1))
|
|
346
|
+
done
|
|
347
|
+
if [ -n "$next" ]; then
|
|
348
|
+
sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
|
|
349
|
+
cur="$next"
|
|
350
|
+
attempt=2
|
|
351
|
+
continue
|
|
352
|
+
fi
|
|
353
|
+
fi
|
|
354
|
+
break
|
|
355
|
+
done
|
|
356
|
+
|
|
357
|
+
cat "$tmpd/out"
|
|
358
|
+
cat "$tmpd/err" >&2
|
|
359
|
+
exit "$rc"
|