@dst-justin/relay 2.2.7 → 2.3.1
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 +48 -0
- package/package.json +1 -1
- package/relay +691 -59
package/README.md
CHANGED
|
@@ -166,6 +166,10 @@ Prefix commands with `!` to run them inline:
|
|
|
166
166
|
| `relay autoswitch stop` | Stop and remove daemon |
|
|
167
167
|
| `relay autoswitch status` | Daemon state and per-account thresholds |
|
|
168
168
|
| `relay autoswitch log` | Recent auto-switch history |
|
|
169
|
+
| `relay warmup add <account> <HH:MM>` | Schedule an account warmup time |
|
|
170
|
+
| `relay warmup remove <account> [HH:MM]` | Remove one or all warmup times for an account |
|
|
171
|
+
| `relay warmup list` | Show configured warmup schedules |
|
|
172
|
+
| `relay warmup pause` / `relay warmup resume` | Temporarily pause or resume warmups |
|
|
169
173
|
|
|
170
174
|
## Autoswitch
|
|
171
175
|
|
|
@@ -194,6 +198,34 @@ relay autoswitch status # verify it's running
|
|
|
194
198
|
- Manual switches (`!relay work`) are respected until that account hits its threshold.
|
|
195
199
|
- If all accounts are over threshold, relay switches to the least-used one.
|
|
196
200
|
|
|
201
|
+
## Warmup
|
|
202
|
+
|
|
203
|
+
Warmup runs a real, non-interactive `claude -p ping` call to Anthropic's API on your machine in the background at the scheduled time — it does not send your credentials anywhere, and it does not increase your weekly usage cap. It only starts your rolling 5-hour usage window earlier.
|
|
204
|
+
|
|
205
|
+
Use warmup when you want an account's 5-hour usage window to start before you sit down to work.
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
relay warmup add <account> <HH:MM> # e.g. relay warmup add work 06:00
|
|
209
|
+
relay warmup remove <account> [HH:MM]
|
|
210
|
+
relay warmup list
|
|
211
|
+
relay warmup pause
|
|
212
|
+
relay warmup resume
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Warmup requires the autoswitch daemon to be running (`relay autoswitch start`) to actually fire. `relay warmup add` warns if the daemon is not running.
|
|
216
|
+
|
|
217
|
+
**Config shape** (`~/.claude-relay/autoswitch.json`):
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{
|
|
221
|
+
"warmup_enabled": true,
|
|
222
|
+
"warmup": [
|
|
223
|
+
{ "account": "work", "time": "06:00" },
|
|
224
|
+
{ "account": "personal", "time": "08:30" }
|
|
225
|
+
]
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
197
229
|
## How It Works
|
|
198
230
|
|
|
199
231
|
relay stores a snapshot of each account's OAuth credentials in `~/.claude-relay/credentials/`. Switching writes the target account's credentials back into the store that Claude Code reads from.
|
|
@@ -223,6 +255,22 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
|
|
|
223
255
|
|
|
224
256
|
## Changelog
|
|
225
257
|
|
|
258
|
+
### v2.3.1 — 2026-07-12
|
|
259
|
+
- Fix: autoswitch daemon now auto-redeploys and restarts after relay itself is updated — previously the daemon file was only regenerated by `relay autoswitch start`, so an already-running daemon would silently keep running stale code (missing new features and previously-fixed bugs) until manually restarted
|
|
260
|
+
- Fix: `relay reorder` no longer drops accounts omitted from the typed order — they're now appended in their prior relative order instead of being silently removed from autoswitch rotation
|
|
261
|
+
- Fix: autoswitch reorder wizard no longer discards a typed order; supports concatenated digit shorthand (e.g. `231`)
|
|
262
|
+
- Add: standalone `relay reorder` command
|
|
263
|
+
- Fix: lock/warmup auto-default configs, autoswitch daemon auto-default order, and `render_table` all respect the persisted account order file instead of falling back to alphabetical sort
|
|
264
|
+
- Fix: account order file stays in sync on add/save/remove/rename
|
|
265
|
+
|
|
266
|
+
### v2.3.0 — 2026-07-10
|
|
267
|
+
- Add scheduled warmup: `relay warmup add/remove/list/pause/resume` to pre-warm a 5hr session at set times
|
|
268
|
+
- Daemon warmup engine (`check_warmup`/`do_warmup`) fires scheduled warmups; always pings (`claude -p ping`) on fire rather than skipping when the account looks "already active" — an idle-overnight account was silently missing its pre-warm
|
|
269
|
+
- Daemon now records the resolved `claude` binary path at autoswitch start, so it can find it regardless of the daemon's runtime PATH
|
|
270
|
+
- Add warmup health warning to `relay status`
|
|
271
|
+
- Fix: unify the cross-process credential lock across bash and Python — the bash mkdir-based fallback (used on macOS, which lacks `flock(1)`) never actually synchronized against the daemon's `fcntl.flock()`, defeating the lock's purpose; also fixes a TOCTOU race in token refresh where the current-account file was read before the lock was acquired
|
|
272
|
+
- Refactor: atomic JSON writes for the daemon usage cache, preventing corruption from concurrent writes
|
|
273
|
+
|
|
226
274
|
### v2.2.7 — 2026-07-05
|
|
227
275
|
- `relay lock <name>` / `relay unlock <name>`: lock an account so it won't be cycled back to when over its usage threshold
|
|
228
276
|
- `relay lock` (no args): show locked accounts
|
package/package.json
CHANGED
package/relay
CHANGED
|
@@ -12,6 +12,7 @@ RELAY_DIR="${HOME}/.claude-relay"
|
|
|
12
12
|
CREDS_STORE="${RELAY_DIR}/credentials"
|
|
13
13
|
META_STORE="${RELAY_DIR}/meta"
|
|
14
14
|
CURRENT_FILE="${RELAY_DIR}/current"
|
|
15
|
+
ORDER_FILE="${RELAY_DIR}/order"
|
|
15
16
|
UPDATE_CACHE="${RELAY_DIR}/.update_cache"
|
|
16
17
|
CLAUDE_DIR="${HOME}/.claude"
|
|
17
18
|
CLAUDE_JSON="${HOME}/.claude.json"
|
|
@@ -59,6 +60,36 @@ warn() { printf " ${YL}⚠${R} %s\n" "$*"; }
|
|
|
59
60
|
err() { printf " ${RD}✗${R} %s\n" "$*" >&2; }
|
|
60
61
|
hdr() { printf "\n${B}${MG} %s${R}\n ${D}─────────────────────────────────────${R}\n" "$*"; }
|
|
61
62
|
|
|
63
|
+
with_credential_lock() {
|
|
64
|
+
local lockfile="${RELAY_DIR}/credential.lock"
|
|
65
|
+
local ready_fifo="${TMPDIR:-/tmp}/relay-lock-ready.$$"
|
|
66
|
+
local release_fifo="${TMPDIR:-/tmp}/relay-lock-release.$$"
|
|
67
|
+
if ! mkfifo "${ready_fifo}" "${release_fifo}" 2>/dev/null; then
|
|
68
|
+
"$@"
|
|
69
|
+
return $?
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
"${PY}" -c '
|
|
73
|
+
import fcntl, sys
|
|
74
|
+
lockfile, ready_fifo, release_fifo = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
75
|
+
with open(lockfile, "a") as f:
|
|
76
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
77
|
+
open(ready_fifo, "w").close()
|
|
78
|
+
open(release_fifo, "r").read()
|
|
79
|
+
' "${lockfile}" "${ready_fifo}" "${release_fifo}" &
|
|
80
|
+
local holder_pid=$!
|
|
81
|
+
|
|
82
|
+
: < "${ready_fifo}"
|
|
83
|
+
|
|
84
|
+
"$@"
|
|
85
|
+
local status=$?
|
|
86
|
+
|
|
87
|
+
: > "${release_fifo}"
|
|
88
|
+
wait "${holder_pid}" 2>/dev/null
|
|
89
|
+
rm -f "${ready_fifo}" "${release_fifo}"
|
|
90
|
+
return "${status}"
|
|
91
|
+
}
|
|
92
|
+
|
|
62
93
|
mkdir -p "${CREDS_STORE}" "${META_STORE}" "${CLAUDE_DIR}"
|
|
63
94
|
chmod 700 "${RELAY_DIR}" "${CREDS_STORE}" 2>/dev/null || true
|
|
64
95
|
|
|
@@ -69,13 +100,125 @@ account_creds() { echo "${CREDS_STORE}/$1.json"; }
|
|
|
69
100
|
account_meta() { echo "${META_STORE}/$1"; }
|
|
70
101
|
account_exists() { [[ -f "$(account_creds "$1")" ]]; }
|
|
71
102
|
|
|
72
|
-
# list accounts
|
|
103
|
+
# list accounts in canonical add-order, one per line (bash 3.2 compatible).
|
|
104
|
+
# Self-healing: drops names whose credential file is gone, appends any
|
|
105
|
+
# credential file not yet tracked (alphabetically), and rewrites ORDER_FILE.
|
|
73
106
|
list_account_names() {
|
|
74
|
-
local f
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
done | sort
|
|
107
|
+
local on_disk=() name f
|
|
108
|
+
|
|
109
|
+
while IFS= read -r name; do
|
|
110
|
+
on_disk+=("${name}")
|
|
111
|
+
done < <(for f in "${CREDS_STORE}"/*.json; do [[ -f "${f}" ]] || continue; basename "${f}" .json; done | sort)
|
|
112
|
+
|
|
113
|
+
local ordered=()
|
|
114
|
+
if [[ -f "${ORDER_FILE}" ]]; then
|
|
115
|
+
local existing already
|
|
116
|
+
while IFS= read -r name; do
|
|
117
|
+
[[ -z "${name}" ]] && continue
|
|
118
|
+
account_exists "${name}" || continue
|
|
119
|
+
already=0
|
|
120
|
+
for existing in ${ordered[@]+"${ordered[@]}"}; do
|
|
121
|
+
[[ "${existing}" == "${name}" ]] && { already=1; break; }
|
|
122
|
+
done
|
|
123
|
+
[[ "${already}" -eq 0 ]] && ordered+=("${name}")
|
|
124
|
+
done < "${ORDER_FILE}"
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
local acct tracked
|
|
128
|
+
for acct in ${on_disk[@]+"${on_disk[@]}"}; do
|
|
129
|
+
tracked=0
|
|
130
|
+
for name in ${ordered[@]+"${ordered[@]}"}; do
|
|
131
|
+
[[ "${name}" == "${acct}" ]] && { tracked=1; break; }
|
|
132
|
+
done
|
|
133
|
+
[[ "${tracked}" -eq 0 ]] && ordered+=("${acct}")
|
|
134
|
+
done
|
|
135
|
+
|
|
136
|
+
if [[ ${#ordered[@]} -gt 0 ]]; then
|
|
137
|
+
printf '%s\n' "${ordered[@]}" > "${ORDER_FILE}"
|
|
138
|
+
printf '%s\n' "${ordered[@]}"
|
|
139
|
+
else
|
|
140
|
+
: > "${ORDER_FILE}"
|
|
141
|
+
fi
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# append name to ORDER_FILE if not already tracked (used by add/save)
|
|
145
|
+
add_to_order() {
|
|
146
|
+
local name="$1"
|
|
147
|
+
touch "${ORDER_FILE}"
|
|
148
|
+
grep -Fxq "${name}" "${ORDER_FILE}" 2>/dev/null && return 0
|
|
149
|
+
echo "${name}" >> "${ORDER_FILE}"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# remove name's line from ORDER_FILE (used by remove)
|
|
153
|
+
remove_from_order() {
|
|
154
|
+
local name="$1"
|
|
155
|
+
[[ -f "${ORDER_FILE}" ]] || return 0
|
|
156
|
+
local tmp="${ORDER_FILE}.tmp.$$"
|
|
157
|
+
grep -Fxv "${name}" "${ORDER_FILE}" > "${tmp}" 2>/dev/null || : > "${tmp}"
|
|
158
|
+
mv "${tmp}" "${ORDER_FILE}"
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# rename a tracked entry in place, preserving its position (used by rename)
|
|
162
|
+
rename_in_order() {
|
|
163
|
+
local old="$1" new="$2"
|
|
164
|
+
[[ -f "${ORDER_FILE}" ]] || return 0
|
|
165
|
+
local tmp="${ORDER_FILE}.tmp.$$"
|
|
166
|
+
awk -v old="${old}" -v new="${new}" '{ print ($0 == old) ? new : $0 }' "${ORDER_FILE}" > "${tmp}"
|
|
167
|
+
mv "${tmp}" "${ORDER_FILE}"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# interactive numbered reorder picker. Prints the account list + prompt to
|
|
171
|
+
# stderr (so it's visible even when the caller captures stdout), reads one
|
|
172
|
+
# line from stdin, resolves it to a comma-joined order, and echoes that
|
|
173
|
+
# order to stdout for the caller to capture.
|
|
174
|
+
prompt_reorder() {
|
|
175
|
+
local accounts=("$@")
|
|
176
|
+
echo "" >&2
|
|
177
|
+
printf " ${D}When an account hits its threshold, relay switches to the next one in order.${R}\n\n" >&2
|
|
178
|
+
local i=1 acct
|
|
179
|
+
for acct in "${accounts[@]}"; do
|
|
180
|
+
printf " ${D}%d${R} %s\n" "${i}" "${acct}" >&2
|
|
181
|
+
i=$((i+1))
|
|
182
|
+
done
|
|
183
|
+
echo "" >&2
|
|
184
|
+
printf " ${D}Type numbers in the order you want (e.g. ${CY}2 1${D}, or ${CY}21${D} for <=9 accounts, or Enter to keep the order above):${R}\n" >&2
|
|
185
|
+
printf " > " >&2
|
|
186
|
+
read -r order_input
|
|
187
|
+
|
|
188
|
+
local order_str; order_str=$("${PY}" - "${order_input}" "${accounts[@]}" <<'PYEOF'
|
|
189
|
+
import sys, re
|
|
190
|
+
raw = sys.argv[1].strip()
|
|
191
|
+
accts = sys.argv[2:]
|
|
192
|
+
if not raw:
|
|
193
|
+
result = accts
|
|
194
|
+
elif raw.isdigit() and not re.search(r'[\s,]', raw) and len(accts) <= 9:
|
|
195
|
+
# concatenated shorthand, e.g. "312" -> [3, 1, 2], only unambiguous for <=9 accounts
|
|
196
|
+
result = []
|
|
197
|
+
for ch in raw:
|
|
198
|
+
idx = int(ch) - 1
|
|
199
|
+
if 0 <= idx < len(accts):
|
|
200
|
+
result.append(accts[idx])
|
|
201
|
+
else:
|
|
202
|
+
tokens = re.split(r'[\s,]+', raw)
|
|
203
|
+
result = []
|
|
204
|
+
for t in tokens:
|
|
205
|
+
t = t.strip()
|
|
206
|
+
if t.isdigit():
|
|
207
|
+
idx = int(t) - 1
|
|
208
|
+
if 0 <= idx < len(accts):
|
|
209
|
+
result.append(accts[idx])
|
|
210
|
+
elif t:
|
|
211
|
+
result.append(t)
|
|
212
|
+
print(','.join(result))
|
|
213
|
+
PYEOF
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
local chain; chain=$(echo "${order_str}" | "${PY}" -c "
|
|
217
|
+
import sys; names=sys.stdin.read().strip().split(',')
|
|
218
|
+
print(' -> '.join(names) + ' -> (cycle)')")
|
|
219
|
+
printf " ${D}Order: ${CY}%s${R}\n" "${chain}" >&2
|
|
220
|
+
|
|
221
|
+
echo "${order_str}"
|
|
79
222
|
}
|
|
80
223
|
|
|
81
224
|
account_by_index() {
|
|
@@ -165,7 +308,24 @@ def reset_in(iso):
|
|
|
165
308
|
except Exception:
|
|
166
309
|
return '—'
|
|
167
310
|
|
|
168
|
-
|
|
311
|
+
def read_order(creds_dir):
|
|
312
|
+
relay_dir = os.path.dirname(creds_dir)
|
|
313
|
+
order_file = os.path.join(relay_dir, 'order')
|
|
314
|
+
on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json')) if os.path.isdir(creds_dir) else set()
|
|
315
|
+
ordered = []
|
|
316
|
+
if os.path.exists(order_file):
|
|
317
|
+
for line in open(order_file):
|
|
318
|
+
n = line.strip()
|
|
319
|
+
if n in on_disk and n not in ordered:
|
|
320
|
+
ordered.append(n)
|
|
321
|
+
for n in sorted(on_disk):
|
|
322
|
+
if n not in ordered:
|
|
323
|
+
ordered.append(n)
|
|
324
|
+
with open(order_file, 'w') as f:
|
|
325
|
+
f.write('\n'.join(ordered) + ('\n' if ordered else ''))
|
|
326
|
+
return ordered
|
|
327
|
+
|
|
328
|
+
names = read_order(creds_dir)
|
|
169
329
|
if not names:
|
|
170
330
|
print(f' \033[33m⚠\033[0m No accounts yet. Run: {B}relay add <name>{R}')
|
|
171
331
|
sys.exit(0)
|
|
@@ -354,6 +514,10 @@ EOF
|
|
|
354
514
|
# Core switch logic
|
|
355
515
|
# ══════════════════════════════════════════════════════════════════
|
|
356
516
|
do_switch() {
|
|
517
|
+
with_credential_lock _do_switch_locked "$@"
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
_do_switch_locked() {
|
|
357
521
|
local name="$1"
|
|
358
522
|
local current; current=$(current_name)
|
|
359
523
|
|
|
@@ -568,6 +732,56 @@ cmd_status() {
|
|
|
568
732
|
_cmd_status_once
|
|
569
733
|
_check_update_bg
|
|
570
734
|
_show_update_notice
|
|
735
|
+
|
|
736
|
+
# Warmup health (only prints when relevant — silent otherwise)
|
|
737
|
+
if [[ -f "${RELAY_DIR}/autoswitch.json" ]] && [[ -f "${RELAY_DIR}/autoswitch.log" ]]; then
|
|
738
|
+
"${PY}" - "${RELAY_DIR}/autoswitch.json" "${RELAY_DIR}/autoswitch.log" <<'PYEOF'
|
|
739
|
+
import json, sys
|
|
740
|
+
from collections import defaultdict
|
|
741
|
+
|
|
742
|
+
cfg_path, log_path = sys.argv[1], sys.argv[2]
|
|
743
|
+
try:
|
|
744
|
+
cfg = json.load(open(cfg_path))
|
|
745
|
+
except Exception:
|
|
746
|
+
sys.exit(0)
|
|
747
|
+
entries = cfg.get('warmup', [])
|
|
748
|
+
if not entries:
|
|
749
|
+
sys.exit(0)
|
|
750
|
+
|
|
751
|
+
counts = defaultdict(lambda: {'total': 0, 'bad': 0})
|
|
752
|
+
try:
|
|
753
|
+
with open(log_path) as f:
|
|
754
|
+
lines = f.readlines()[-2000:]
|
|
755
|
+
except Exception:
|
|
756
|
+
lines = []
|
|
757
|
+
|
|
758
|
+
for line in lines:
|
|
759
|
+
try:
|
|
760
|
+
rec = json.loads(line)
|
|
761
|
+
except Exception:
|
|
762
|
+
continue
|
|
763
|
+
ev = rec.get('event')
|
|
764
|
+
if ev == 'warmup_missed':
|
|
765
|
+
key = f"{rec.get('account')}|{rec.get('time')}"
|
|
766
|
+
counts[key]['total'] += 1
|
|
767
|
+
counts[key]['bad'] += 1
|
|
768
|
+
elif ev == 'warmup_ping':
|
|
769
|
+
acct = rec.get('account')
|
|
770
|
+
for e in entries:
|
|
771
|
+
if e.get('account') == acct:
|
|
772
|
+
key = f"{acct}|{e.get('time')}"
|
|
773
|
+
counts[key]['total'] += 1
|
|
774
|
+
if not rec.get('ok'):
|
|
775
|
+
counts[key]['bad'] += 1
|
|
776
|
+
|
|
777
|
+
R='\033[0m'; YL='\033[33m'
|
|
778
|
+
for e in entries:
|
|
779
|
+
key = f"{e.get('account')}|{e.get('time')}"
|
|
780
|
+
c = counts.get(key)
|
|
781
|
+
if c and c['total'] >= 3 and c['bad'] >= 3:
|
|
782
|
+
print(f" {YL}⚠ warmup: {e.get('account')} {e.get('time')} missed {c['bad']}/{c['total']} recent{R}")
|
|
783
|
+
PYEOF
|
|
784
|
+
fi
|
|
571
785
|
}
|
|
572
786
|
|
|
573
787
|
cmd_add() {
|
|
@@ -614,6 +828,7 @@ cmd_add() {
|
|
|
614
828
|
printf '%s' "${kc_creds}" > "$(account_creds "${name}")"
|
|
615
829
|
chmod 600 "$(account_creds "${name}")"
|
|
616
830
|
save_meta_email "${name}"
|
|
831
|
+
add_to_order "${name}"
|
|
617
832
|
echo "${name}" > "${CURRENT_FILE}"
|
|
618
833
|
ok "Account '${B}${name}${R}' added ${D}$(get_meta_email "${name}")${R}"
|
|
619
834
|
}
|
|
@@ -636,6 +851,7 @@ cmd_save() {
|
|
|
636
851
|
[[ ${saved} -eq 0 ]] && { err "No credentials found — log in first with: claude /login"; exit 1; }
|
|
637
852
|
|
|
638
853
|
save_meta_email "${name}"
|
|
854
|
+
add_to_order "${name}"
|
|
639
855
|
echo "${name}" > "${CURRENT_FILE}"
|
|
640
856
|
ok "Account '${B}${name}${R}' saved ${D}$(get_meta_email "${name}")${R}"
|
|
641
857
|
}
|
|
@@ -734,6 +950,7 @@ cmd_remove() {
|
|
|
734
950
|
read -r c
|
|
735
951
|
[[ "${c}" = "y" || "${c}" = "Y" ]] || { log "cancelled"; return 0; }
|
|
736
952
|
rm -f "$(account_creds "${name}")" "$(account_meta "${name}")"
|
|
953
|
+
remove_from_order "${name}"
|
|
737
954
|
[[ "$(current_name)" == "${name}" ]] && rm -f "${CURRENT_FILE}"
|
|
738
955
|
ok "Deleted '${name}' (sessions are unaffected)"
|
|
739
956
|
}
|
|
@@ -816,6 +1033,7 @@ cmd_rename() {
|
|
|
816
1033
|
|
|
817
1034
|
mv "$(account_creds "${old}")" "$(account_creds "${new}")"
|
|
818
1035
|
[[ -f "$(account_meta "${old}")" ]] && mv "$(account_meta "${old}")" "$(account_meta "${new}")"
|
|
1036
|
+
rename_in_order "${old}" "${new}"
|
|
819
1037
|
[[ "$(current_name)" == "${old}" ]] && echo "${new}" > "${CURRENT_FILE}"
|
|
820
1038
|
ok "Renamed '${B}${old}${R}' → '${B}${new}${R}'"
|
|
821
1039
|
}
|
|
@@ -828,17 +1046,20 @@ _extract_daemon() {
|
|
|
828
1046
|
cat > "${AUTOSWITCH_DAEMON}" <<'DAEMON_EOF'
|
|
829
1047
|
#!/usr/bin/env python3
|
|
830
1048
|
"""relay autoswitch daemon — runs in background, switches accounts by usage threshold."""
|
|
831
|
-
import json, os, sys, time, datetime, urllib.request, urllib.error, platform, subprocess, signal
|
|
1049
|
+
import fcntl, json, os, sys, time, datetime, urllib.request, urllib.error, platform, subprocess, signal, shutil
|
|
1050
|
+
from contextlib import contextmanager
|
|
832
1051
|
|
|
833
1052
|
RELAY_DIR = os.path.expanduser('~/.claude-relay')
|
|
834
1053
|
CONFIG_FILE = os.path.join(RELAY_DIR, 'autoswitch.json')
|
|
835
1054
|
LOCK_FILE = os.path.join(RELAY_DIR, 'autoswitch.lock')
|
|
1055
|
+
CREDENTIAL_LOCK_FILE = os.path.join(RELAY_DIR, 'credential.lock')
|
|
836
1056
|
LOG_FILE = os.path.join(RELAY_DIR, 'autoswitch.log')
|
|
837
1057
|
MANUAL_FILE = os.path.join(RELAY_DIR, 'manual_switch')
|
|
838
1058
|
CURRENT_FILE = os.path.join(RELAY_DIR, 'current')
|
|
839
1059
|
CREDS_DIR = os.path.join(RELAY_DIR, 'credentials')
|
|
840
1060
|
CACHE_FILE = os.path.join(RELAY_DIR, 'usage_cache.json')
|
|
841
1061
|
CACHE_TTL = 120 # seconds — same as render_table
|
|
1062
|
+
WARMUP_STATE_FILE = os.path.join(RELAY_DIR, 'warmup_state.json')
|
|
842
1063
|
|
|
843
1064
|
# ── lock ──────────────────────────────────────────────────────────
|
|
844
1065
|
def write_lock():
|
|
@@ -917,27 +1138,107 @@ def kc_write(content):
|
|
|
917
1138
|
with open(live, 'w') as f: f.write(content)
|
|
918
1139
|
os.chmod(live, 0o600)
|
|
919
1140
|
|
|
1141
|
+
@contextmanager
|
|
1142
|
+
def credential_lock():
|
|
1143
|
+
os.makedirs(RELAY_DIR, exist_ok=True)
|
|
1144
|
+
with open(CREDENTIAL_LOCK_FILE, 'w') as f:
|
|
1145
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
1146
|
+
try:
|
|
1147
|
+
yield
|
|
1148
|
+
finally:
|
|
1149
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
1150
|
+
|
|
920
1151
|
def do_switch(name):
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1152
|
+
with credential_lock():
|
|
1153
|
+
cred = os.path.join(CREDS_DIR, name + '.json')
|
|
1154
|
+
current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
|
|
1155
|
+
if current and os.path.exists(os.path.join(CREDS_DIR, current + '.json')):
|
|
1156
|
+
live = kc_read()
|
|
1157
|
+
if live:
|
|
1158
|
+
with open(os.path.join(CREDS_DIR, current + '.json'), 'w') as f: f.write(live)
|
|
1159
|
+
with open(CURRENT_FILE, 'w') as f: f.write(name)
|
|
1160
|
+
content = open(cred).read()
|
|
1161
|
+
kc_write(content)
|
|
1162
|
+
|
|
1163
|
+
def load_warmup_state():
|
|
1164
|
+
try: return json.load(open(WARMUP_STATE_FILE))
|
|
1165
|
+
except: return {}
|
|
1166
|
+
|
|
1167
|
+
def save_warmup_state(s):
|
|
1168
|
+
save_json_atomic(WARMUP_STATE_FILE, s)
|
|
1169
|
+
|
|
1170
|
+
def get_claude_bin():
|
|
1171
|
+
try:
|
|
1172
|
+
p = open(os.path.join(RELAY_DIR, 'claude_bin')).read().strip()
|
|
1173
|
+
if p and os.path.exists(p): return p
|
|
1174
|
+
except: pass
|
|
1175
|
+
return shutil.which('claude') or 'claude'
|
|
1176
|
+
|
|
1177
|
+
def do_warmup(acct):
|
|
1178
|
+
current_before = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
|
|
1179
|
+
do_switch(acct)
|
|
1180
|
+
log_event('warmup_switch', account=acct)
|
|
1181
|
+
try:
|
|
1182
|
+
r = subprocess.run([get_claude_bin(), '-p', 'ping', '--output-format', 'text'],
|
|
1183
|
+
capture_output=True, timeout=30)
|
|
1184
|
+
ok = (r.returncode == 0)
|
|
1185
|
+
log_event('warmup_ping', account=acct, ok=ok)
|
|
1186
|
+
notify('relay', f'warmup: {acct} 已完成 5hr session 預熱' if ok
|
|
1187
|
+
else f'warmup: {acct} ping 失敗')
|
|
1188
|
+
return ok
|
|
1189
|
+
except Exception as e:
|
|
1190
|
+
log_event('warmup_ping', account=acct, ok=False, err=str(e))
|
|
1191
|
+
return False
|
|
1192
|
+
finally:
|
|
1193
|
+
if current_before and current_before != acct and os.path.exists(os.path.join(CREDS_DIR, current_before + '.json')):
|
|
1194
|
+
do_switch(current_before)
|
|
1195
|
+
log_event('warmup_restore', account=current_before)
|
|
1196
|
+
|
|
1197
|
+
def check_warmup(entries):
|
|
1198
|
+
if not entries: return
|
|
1199
|
+
state = load_warmup_state()
|
|
1200
|
+
now = datetime.datetime.now()
|
|
1201
|
+
today = now.strftime('%Y-%m-%d')
|
|
1202
|
+
changed = False
|
|
1203
|
+
for entry in entries:
|
|
1204
|
+
acct, hhmm = entry.get('account'), entry.get('time')
|
|
1205
|
+
if not acct or not hhmm: continue
|
|
1206
|
+
key = f'{acct}|{hhmm}'
|
|
1207
|
+
if (state.get(key) or {}).get('date') == today:
|
|
1208
|
+
continue
|
|
1209
|
+
try:
|
|
1210
|
+
h, m = map(int, hhmm.split(':'))
|
|
1211
|
+
scheduled = now.replace(hour=h, minute=m, second=0, microsecond=0)
|
|
1212
|
+
except: continue
|
|
1213
|
+
if now < scheduled:
|
|
1214
|
+
continue
|
|
1215
|
+
if (now - scheduled).total_seconds() > 900: # 15 min grace window
|
|
1216
|
+
state[key] = {'date': today, 'status': 'missed'}
|
|
1217
|
+
log_event('warmup_missed', account=acct, time=hhmm)
|
|
1218
|
+
changed = True; continue
|
|
1219
|
+
if not os.path.exists(os.path.join(CREDS_DIR, acct + '.json')):
|
|
1220
|
+
log_event('warmup_pending', account=acct, reason='missing_account')
|
|
1221
|
+
continue
|
|
1222
|
+
ok = do_warmup(acct)
|
|
1223
|
+
state[key] = {'date': today, 'status': 'ok' if ok else 'ping_failed'}
|
|
1224
|
+
changed = True
|
|
1225
|
+
if changed: save_warmup_state(state)
|
|
930
1226
|
|
|
931
1227
|
# ── usage fetch ───────────────────────────────────────────────────
|
|
932
1228
|
def load_cache():
|
|
933
1229
|
try: return json.load(open(CACHE_FILE))
|
|
934
1230
|
except: return {}
|
|
935
1231
|
|
|
936
|
-
def
|
|
1232
|
+
def save_json_atomic(path, data):
|
|
937
1233
|
try:
|
|
938
|
-
|
|
1234
|
+
tmp = path + '.tmp'
|
|
1235
|
+
with open(tmp, 'w') as f: json.dump(data, f)
|
|
1236
|
+
os.replace(tmp, path)
|
|
939
1237
|
except: pass
|
|
940
1238
|
|
|
1239
|
+
def save_cache(c):
|
|
1240
|
+
save_json_atomic(CACHE_FILE, c)
|
|
1241
|
+
|
|
941
1242
|
# ponytail: intentional copy of try_refresh() — daemon is a standalone extracted script
|
|
942
1243
|
def try_refresh_daemon(name, cred_path):
|
|
943
1244
|
try:
|
|
@@ -960,9 +1261,10 @@ def try_refresh_daemon(name, cred_path):
|
|
|
960
1261
|
content = json.dumps(d)
|
|
961
1262
|
open(cred_path, 'w').write(content)
|
|
962
1263
|
os.chmod(cred_path, 0o600)
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1264
|
+
with credential_lock():
|
|
1265
|
+
current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
|
|
1266
|
+
if name == current:
|
|
1267
|
+
kc_write(content) # critical: update keychain so do_switch() doesn't clobber
|
|
966
1268
|
return oauth['accessToken']
|
|
967
1269
|
except Exception:
|
|
968
1270
|
return None
|
|
@@ -1026,6 +1328,34 @@ def clear_manual_switch():
|
|
|
1026
1328
|
except: pass
|
|
1027
1329
|
|
|
1028
1330
|
# ── main loop ─────────────────────────────────────────────────────
|
|
1331
|
+
def load_raw_config():
|
|
1332
|
+
"""Read autoswitch.json's raw contents, or {} if missing/corrupt. Used so
|
|
1333
|
+
'warmup' entries work even when load_config()'s auto-default path (which
|
|
1334
|
+
omits 'warmup') would otherwise apply."""
|
|
1335
|
+
try:
|
|
1336
|
+
return json.load(open(CONFIG_FILE))
|
|
1337
|
+
except FileNotFoundError:
|
|
1338
|
+
return {}
|
|
1339
|
+
except Exception as e:
|
|
1340
|
+
log_event('config_parse_error', error=str(e))
|
|
1341
|
+
return {}
|
|
1342
|
+
|
|
1343
|
+
def _read_order(creds_dir):
|
|
1344
|
+
order_file = os.path.join(os.path.dirname(creds_dir), 'order')
|
|
1345
|
+
on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json')) if os.path.isdir(creds_dir) else set()
|
|
1346
|
+
ordered = []
|
|
1347
|
+
if os.path.exists(order_file):
|
|
1348
|
+
for line in open(order_file):
|
|
1349
|
+
n = line.strip()
|
|
1350
|
+
if n in on_disk and n not in ordered:
|
|
1351
|
+
ordered.append(n)
|
|
1352
|
+
for n in sorted(on_disk):
|
|
1353
|
+
if n not in ordered:
|
|
1354
|
+
ordered.append(n)
|
|
1355
|
+
with open(order_file, 'w') as f:
|
|
1356
|
+
f.write('\n'.join(ordered) + ('\n' if ordered else ''))
|
|
1357
|
+
return ordered
|
|
1358
|
+
|
|
1029
1359
|
def load_config():
|
|
1030
1360
|
try:
|
|
1031
1361
|
return json.load(open(CONFIG_FILE))
|
|
@@ -1033,7 +1363,7 @@ def load_config():
|
|
|
1033
1363
|
# Auto-default: 2+ accounts → enable with 80% threshold, no explicit config needed
|
|
1034
1364
|
if not os.path.isdir(CREDS_DIR):
|
|
1035
1365
|
return None
|
|
1036
|
-
accounts =
|
|
1366
|
+
accounts = _read_order(CREDS_DIR)
|
|
1037
1367
|
if len(accounts) < 2:
|
|
1038
1368
|
return None
|
|
1039
1369
|
return {
|
|
@@ -1060,6 +1390,9 @@ def main():
|
|
|
1060
1390
|
last_refresh_ts = time.time()
|
|
1061
1391
|
|
|
1062
1392
|
cfg = load_config()
|
|
1393
|
+
raw_cfg = load_raw_config()
|
|
1394
|
+
if raw_cfg.get('warmup_enabled', True):
|
|
1395
|
+
check_warmup(raw_cfg.get('warmup', []))
|
|
1063
1396
|
if not cfg:
|
|
1064
1397
|
time.sleep(60); continue
|
|
1065
1398
|
|
|
@@ -1119,6 +1452,44 @@ if __name__ == '__main__':
|
|
|
1119
1452
|
main()
|
|
1120
1453
|
DAEMON_EOF
|
|
1121
1454
|
chmod 755 "${AUTOSWITCH_DAEMON}"
|
|
1455
|
+
_read_version > "${RELAY_DIR}/daemon_version"
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
# Restarts the already-installed daemon service in place (launchd/systemd/cron),
|
|
1459
|
+
# without touching plist/service file contents. Used after a silent redeploy.
|
|
1460
|
+
_restart_daemon_service() {
|
|
1461
|
+
if [[ "$(uname)" == "Darwin" ]] && [[ -f "${AUTOSWITCH_PLIST}" ]]; then
|
|
1462
|
+
launchctl unload "${AUTOSWITCH_PLIST}" 2>/dev/null || true
|
|
1463
|
+
launchctl load "${AUTOSWITCH_PLIST}"
|
|
1464
|
+
elif command -v systemctl >/dev/null 2>&1 && [[ -f "${AUTOSWITCH_SERVICE}" ]]; then
|
|
1465
|
+
systemctl --user restart relay-autoswitch
|
|
1466
|
+
else
|
|
1467
|
+
# cron fallback: no long-lived unit to restart — kill the running instance so
|
|
1468
|
+
# the next cron tick (daemon's own lock file) starts a fresh copy of the file
|
|
1469
|
+
# we just wrote via _extract_daemon.
|
|
1470
|
+
local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
|
|
1471
|
+
[[ -n "${pid}" ]] && kill "${pid}" 2>/dev/null || true
|
|
1472
|
+
fi
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
# Runs on every invocation (cheap no-op unless autoswitch is actually running):
|
|
1476
|
+
# if relay itself was updated (npm/git/direct) since the daemon file on disk was
|
|
1477
|
+
# generated, silently regenerate it from the current script and restart it —
|
|
1478
|
+
# so daemon-side fixes (warmup engine, credential lock, etc.) don't require the
|
|
1479
|
+
# user to remember to run `relay autoswitch start` again after every update.
|
|
1480
|
+
_maybe_redeploy_daemon() {
|
|
1481
|
+
[[ -f "${AUTOSWITCH_DAEMON}" ]] || return 0
|
|
1482
|
+
local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
|
|
1483
|
+
[[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null || return 0
|
|
1484
|
+
|
|
1485
|
+
local deployed; deployed=$(cat "${RELAY_DIR}/daemon_version" 2>/dev/null || echo "")
|
|
1486
|
+
local current; current=$(_read_version)
|
|
1487
|
+
[[ "${current}" == "unknown" ]] && return 0
|
|
1488
|
+
[[ "${deployed}" == "${current}" ]] && return 0
|
|
1489
|
+
|
|
1490
|
+
_extract_daemon
|
|
1491
|
+
_restart_daemon_service
|
|
1492
|
+
warn "relay updated to ${current} — autoswitch daemon redeployed and restarted"
|
|
1122
1493
|
}
|
|
1123
1494
|
|
|
1124
1495
|
cmd_autoswitch_config() {
|
|
@@ -1136,41 +1507,7 @@ cmd_autoswitch_config() {
|
|
|
1136
1507
|
# ── Step 1: switch order ───────────────────────────────────────
|
|
1137
1508
|
echo ""
|
|
1138
1509
|
printf " ${B}Step 1 / 3 — Switch order${R}\n"
|
|
1139
|
-
|
|
1140
|
-
local i=1
|
|
1141
|
-
for acct in "${accounts[@]}"; do
|
|
1142
|
-
printf " ${D}%d${R} %s\n" "${i}" "${acct}"
|
|
1143
|
-
i=$((i+1))
|
|
1144
|
-
done
|
|
1145
|
-
echo ""
|
|
1146
|
-
printf " ${D}Type numbers in the order you want (e.g. ${CY}2 1${D} or just Enter to keep the order above):${R}\n"
|
|
1147
|
-
printf " > "; read -r order_input
|
|
1148
|
-
|
|
1149
|
-
# resolve numbers (space or comma) to names; empty = default order
|
|
1150
|
-
local order_str; order_str=$(echo "${order_input}" | "${PY}" - "${accounts[@]}" <<'PYEOF'
|
|
1151
|
-
import sys, re
|
|
1152
|
-
raw = sys.stdin.read().strip()
|
|
1153
|
-
accts = sys.argv[1:]
|
|
1154
|
-
if not raw:
|
|
1155
|
-
print(','.join(accts))
|
|
1156
|
-
else:
|
|
1157
|
-
tokens = re.split(r'[\s,]+', raw)
|
|
1158
|
-
result = []
|
|
1159
|
-
for t in tokens:
|
|
1160
|
-
t = t.strip()
|
|
1161
|
-
if t.isdigit():
|
|
1162
|
-
idx = int(t) - 1
|
|
1163
|
-
if 0 <= idx < len(accts): result.append(accts[idx])
|
|
1164
|
-
elif t:
|
|
1165
|
-
result.append(t)
|
|
1166
|
-
print(','.join(result))
|
|
1167
|
-
PYEOF
|
|
1168
|
-
)
|
|
1169
|
-
# show resolved order as a visual chain
|
|
1170
|
-
local chain; chain=$(echo "${order_str}" | "${PY}" -c "
|
|
1171
|
-
import sys; names=sys.stdin.read().strip().split(',')
|
|
1172
|
-
print(' → '.join(names) + ' → (cycle)')")
|
|
1173
|
-
printf " ${D}Order: ${CY}%s${R}\n" "${chain}"
|
|
1510
|
+
local order_str; order_str=$(prompt_reorder "${accounts[@]}")
|
|
1174
1511
|
|
|
1175
1512
|
# ── Step 2: thresholds ────────────────────────────────────────
|
|
1176
1513
|
echo ""
|
|
@@ -1233,6 +1570,14 @@ cmd_autoswitch_start() {
|
|
|
1233
1570
|
}
|
|
1234
1571
|
|
|
1235
1572
|
hdr "autoswitch — start"
|
|
1573
|
+
if [[ -n "${REAL_CLAUDE}" && "${REAL_CLAUDE}" != "$0" ]]; then
|
|
1574
|
+
echo "${REAL_CLAUDE}" > "${RELAY_DIR}/claude_bin"
|
|
1575
|
+
elif [[ -z "${REAL_CLAUDE}" ]]; then
|
|
1576
|
+
warn "claude not found; warmup will fall back to daemon PATH lookup and may fail"
|
|
1577
|
+
else
|
|
1578
|
+
warn "claude resolves to relay wrapper; warmup will fall back to daemon PATH lookup and may fail"
|
|
1579
|
+
fi
|
|
1580
|
+
|
|
1236
1581
|
_extract_daemon
|
|
1237
1582
|
log "Daemon extracted to ${AUTOSWITCH_DAEMON}"
|
|
1238
1583
|
|
|
@@ -1460,6 +1805,62 @@ cmd_autoswitch() {
|
|
|
1460
1805
|
esac
|
|
1461
1806
|
}
|
|
1462
1807
|
|
|
1808
|
+
cmd_reorder() {
|
|
1809
|
+
hdr "reorder accounts"
|
|
1810
|
+
|
|
1811
|
+
local accounts=()
|
|
1812
|
+
local name
|
|
1813
|
+
while IFS= read -r name; do accounts+=("${name}"); done < <(list_account_names)
|
|
1814
|
+
|
|
1815
|
+
if [[ ${#accounts[@]} -eq 0 ]]; then
|
|
1816
|
+
err "No accounts found. Run: relay add <name>"
|
|
1817
|
+
exit 1
|
|
1818
|
+
fi
|
|
1819
|
+
|
|
1820
|
+
local order_str; order_str=$(prompt_reorder "${accounts[@]}")
|
|
1821
|
+
|
|
1822
|
+
if [[ -z "${order_str}" ]]; then
|
|
1823
|
+
err "No valid order given — nothing changed"
|
|
1824
|
+
exit 1
|
|
1825
|
+
fi
|
|
1826
|
+
|
|
1827
|
+
local order_arr=()
|
|
1828
|
+
IFS=',' read -ra order_arr <<< "${order_str}"
|
|
1829
|
+
|
|
1830
|
+
local account ordered found
|
|
1831
|
+
for account in ${accounts[@]+"${accounts[@]}"}; do
|
|
1832
|
+
found=0
|
|
1833
|
+
for ordered in ${order_arr[@]+"${order_arr[@]}"}; do
|
|
1834
|
+
[[ "${ordered}" == "${account}" ]] && { found=1; break; }
|
|
1835
|
+
done
|
|
1836
|
+
[[ "${found}" -eq 0 ]] && order_arr+=("${account}")
|
|
1837
|
+
done
|
|
1838
|
+
|
|
1839
|
+
order_str=""
|
|
1840
|
+
for ordered in ${order_arr[@]+"${order_arr[@]}"}; do
|
|
1841
|
+
if [[ -z "${order_str}" ]]; then
|
|
1842
|
+
order_str="${ordered}"
|
|
1843
|
+
else
|
|
1844
|
+
order_str="${order_str},${ordered}"
|
|
1845
|
+
fi
|
|
1846
|
+
done
|
|
1847
|
+
|
|
1848
|
+
printf '%s\n' "${order_arr[@]}" > "${ORDER_FILE}"
|
|
1849
|
+
ok "Order saved to ${ORDER_FILE}"
|
|
1850
|
+
|
|
1851
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
1852
|
+
if [[ -f "${cfg}" ]]; then
|
|
1853
|
+
"${PY}" - "${cfg}" "${order_str}" <<'PYEOF'
|
|
1854
|
+
import json, sys
|
|
1855
|
+
cfg_path, order_str = sys.argv[1], sys.argv[2]
|
|
1856
|
+
cfg = json.load(open(cfg_path))
|
|
1857
|
+
cfg['order'] = order_str.split(',')
|
|
1858
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
1859
|
+
PYEOF
|
|
1860
|
+
ok "Also updated order in ${cfg}"
|
|
1861
|
+
fi
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1463
1864
|
cmd_lock() {
|
|
1464
1865
|
local name="${1:-}"
|
|
1465
1866
|
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
@@ -1493,7 +1894,19 @@ PYEOF
|
|
|
1493
1894
|
"${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
|
|
1494
1895
|
import json, sys, os
|
|
1495
1896
|
creds_dir, cfg_path = sys.argv[1], sys.argv[2]
|
|
1496
|
-
|
|
1897
|
+
order_file = os.path.join(os.path.dirname(creds_dir), 'order')
|
|
1898
|
+
on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json'))
|
|
1899
|
+
accounts = []
|
|
1900
|
+
if os.path.exists(order_file):
|
|
1901
|
+
for line in open(order_file):
|
|
1902
|
+
n = line.strip()
|
|
1903
|
+
if n in on_disk and n not in accounts:
|
|
1904
|
+
accounts.append(n)
|
|
1905
|
+
for n in sorted(on_disk):
|
|
1906
|
+
if n not in accounts:
|
|
1907
|
+
accounts.append(n)
|
|
1908
|
+
with open(order_file, 'w') as f:
|
|
1909
|
+
f.write('\n'.join(accounts) + ('\n' if accounts else ''))
|
|
1497
1910
|
config = {
|
|
1498
1911
|
'order': accounts,
|
|
1499
1912
|
'thresholds': {a: 80 for a in accounts},
|
|
@@ -1541,6 +1954,214 @@ PYEOF
|
|
|
1541
1954
|
ok "Unlocked '${B}${name}${R}'"
|
|
1542
1955
|
}
|
|
1543
1956
|
|
|
1957
|
+
cmd_warmup() {
|
|
1958
|
+
local sub="${1:-}"; [[ $# -gt 0 ]] && shift
|
|
1959
|
+
case "${sub}" in
|
|
1960
|
+
add) cmd_warmup_add "$@" ;;
|
|
1961
|
+
remove|rm) cmd_warmup_remove "$@" ;;
|
|
1962
|
+
list|ls) cmd_warmup_list ;;
|
|
1963
|
+
pause) cmd_warmup_pause ;;
|
|
1964
|
+
resume) cmd_warmup_resume ;;
|
|
1965
|
+
test) cmd_warmup_test "$@" ;;
|
|
1966
|
+
*) cmd_warmup_list ;;
|
|
1967
|
+
esac
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
_warmup_ensure_config() {
|
|
1971
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
1972
|
+
if [[ ! -f "${cfg}" ]]; then
|
|
1973
|
+
"${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
|
|
1974
|
+
import json, sys, os
|
|
1975
|
+
creds_dir, cfg_path = sys.argv[1], sys.argv[2]
|
|
1976
|
+
on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json')) if os.path.isdir(creds_dir) else set()
|
|
1977
|
+
order_file = os.path.join(os.path.dirname(creds_dir), 'order')
|
|
1978
|
+
accounts = []
|
|
1979
|
+
if os.path.exists(order_file):
|
|
1980
|
+
for line in open(order_file):
|
|
1981
|
+
n = line.strip()
|
|
1982
|
+
if n in on_disk and n not in accounts:
|
|
1983
|
+
accounts.append(n)
|
|
1984
|
+
for n in sorted(on_disk):
|
|
1985
|
+
if n not in accounts:
|
|
1986
|
+
accounts.append(n)
|
|
1987
|
+
with open(order_file, 'w') as f:
|
|
1988
|
+
f.write('\n'.join(accounts) + ('\n' if accounts else ''))
|
|
1989
|
+
config = {
|
|
1990
|
+
'order': accounts,
|
|
1991
|
+
'thresholds': {a: 80 for a in accounts},
|
|
1992
|
+
'locks': [],
|
|
1993
|
+
'poll': {'low_minutes': 10, 'high_minutes': 2, 'high_threshold': 50},
|
|
1994
|
+
'warmup': []
|
|
1995
|
+
}
|
|
1996
|
+
json.dump(config, open(cfg_path, 'w'), indent=2)
|
|
1997
|
+
PYEOF
|
|
1998
|
+
fi
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
_warmup_daemon_running() {
|
|
2002
|
+
local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
|
|
2003
|
+
[[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
_warmup_valid_time() {
|
|
2007
|
+
[[ "$1" =~ ^([0-1][0-9]|2[0-3]):[0-5][0-9]$ ]]
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
cmd_warmup_add() {
|
|
2011
|
+
local name="${1:-}" hhmm="${2:-}"
|
|
2012
|
+
if [[ -z "${name}" || -z "${hhmm}" ]]; then
|
|
2013
|
+
err "usage: relay warmup add <account> <HH:MM>"
|
|
2014
|
+
exit 1
|
|
2015
|
+
fi
|
|
2016
|
+
account_exists "${name}" || {
|
|
2017
|
+
err "Account '${name}' not found — run 'relay list' to see accounts, or 'relay add ${name}' first"
|
|
2018
|
+
exit 1
|
|
2019
|
+
}
|
|
2020
|
+
_warmup_valid_time "${hhmm}" || {
|
|
2021
|
+
err "Invalid time '${hhmm}' — expected HH:MM, 00:00–23:59 (e.g. 06:00)"
|
|
2022
|
+
exit 1
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
_warmup_ensure_config
|
|
2026
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
2027
|
+
|
|
2028
|
+
"${PY}" - "${cfg}" "${name}" "${hhmm}" <<'PYEOF'
|
|
2029
|
+
import json, sys
|
|
2030
|
+
cfg_path, name, hhmm = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
2031
|
+
cfg = json.load(open(cfg_path))
|
|
2032
|
+
entries = cfg.get('warmup', [])
|
|
2033
|
+
if not any(e.get('account') == name and e.get('time') == hhmm for e in entries):
|
|
2034
|
+
entries.append({'account': name, 'time': hhmm})
|
|
2035
|
+
cfg['warmup'] = entries
|
|
2036
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
2037
|
+
PYEOF
|
|
2038
|
+
|
|
2039
|
+
ok "warmup: ${name} will fire at ${hhmm}"
|
|
2040
|
+
echo " Warmup runs a real, non-interactive 'claude -p ping' call to Anthropic's API"
|
|
2041
|
+
echo " on your machine in the background at the scheduled time — it does not send"
|
|
2042
|
+
echo " your credentials anywhere, and it does not increase your weekly usage cap."
|
|
2043
|
+
echo " It only starts your rolling 5-hour usage window earlier."
|
|
2044
|
+
if ! _warmup_daemon_running; then
|
|
2045
|
+
warn "background daemon isn't running — this won't fire until you run: relay autoswitch start"
|
|
2046
|
+
fi
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
cmd_warmup_remove() {
|
|
2050
|
+
local name="${1:-}" hhmm="${2:-}"
|
|
2051
|
+
if [[ -z "${name}" ]]; then
|
|
2052
|
+
err "usage: relay warmup remove <account> [HH:MM]"
|
|
2053
|
+
exit 1
|
|
2054
|
+
fi
|
|
2055
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
2056
|
+
[[ -f "${cfg}" ]] || { warn "No warmup entries for '${name}'"; return 0; }
|
|
2057
|
+
|
|
2058
|
+
local removed
|
|
2059
|
+
if ! removed=$("${PY}" - "${cfg}" "${name}" "${hhmm}" <<'PYEOF'
|
|
2060
|
+
import json, sys
|
|
2061
|
+
cfg_path, name, hhmm = sys.argv[1], sys.argv[2], (sys.argv[3] or None)
|
|
2062
|
+
cfg = json.load(open(cfg_path))
|
|
2063
|
+
entries = cfg.get('warmup', [])
|
|
2064
|
+
if hhmm:
|
|
2065
|
+
remaining = [e for e in entries if not (e.get('account') == name and e.get('time') == hhmm)]
|
|
2066
|
+
else:
|
|
2067
|
+
remaining = [e for e in entries if e.get('account') != name]
|
|
2068
|
+
removed = len(entries) - len(remaining)
|
|
2069
|
+
cfg['warmup'] = remaining
|
|
2070
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
2071
|
+
print(removed)
|
|
2072
|
+
PYEOF
|
|
2073
|
+
); then
|
|
2074
|
+
err "Failed to remove warmup entries for '${name}'"
|
|
2075
|
+
return 1
|
|
2076
|
+
fi
|
|
2077
|
+
if [[ "${removed}" -eq 0 ]]; then
|
|
2078
|
+
if [[ -n "${hhmm}" ]]; then
|
|
2079
|
+
warn "No warmup entries for '${name}' at ${hhmm}"
|
|
2080
|
+
else
|
|
2081
|
+
warn "No warmup entries for '${name}'"
|
|
2082
|
+
fi
|
|
2083
|
+
return 0
|
|
2084
|
+
fi
|
|
2085
|
+
ok "Removed warmup entries for '${name}'"
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
cmd_warmup_list() {
|
|
2089
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
2090
|
+
hdr "warmup"
|
|
2091
|
+
|
|
2092
|
+
if _warmup_daemon_running; then :; else
|
|
2093
|
+
printf " ${YL}daemon: not running${R} — run 'relay autoswitch start'\n"
|
|
2094
|
+
fi
|
|
2095
|
+
|
|
2096
|
+
[[ -f "${cfg}" ]] || { warn "No warmup entries. Run: relay warmup add <account> <HH:MM>"; return 0; }
|
|
2097
|
+
|
|
2098
|
+
"${PY}" - "${cfg}" "${RELAY_DIR}/warmup_state.json" <<'PYEOF'
|
|
2099
|
+
import json, sys
|
|
2100
|
+
cfg = json.load(open(sys.argv[1]))
|
|
2101
|
+
if not cfg.get('warmup_enabled', True):
|
|
2102
|
+
print(' \033[33m⏸ warmup paused\033[0m — run \'relay warmup resume\' to re-enable')
|
|
2103
|
+
entries = cfg.get('warmup', [])
|
|
2104
|
+
if not entries:
|
|
2105
|
+
print(' No warmup entries. Run: relay warmup add <account> <HH:MM>')
|
|
2106
|
+
sys.exit(0)
|
|
2107
|
+
try:
|
|
2108
|
+
state = json.load(open(sys.argv[2]))
|
|
2109
|
+
except Exception:
|
|
2110
|
+
state = {}
|
|
2111
|
+
labels = {
|
|
2112
|
+
'ok': '成功', 'ping_failed': 'ping 失敗', 'missed': '錯過',
|
|
2113
|
+
'missing_account': '帳號不存在',
|
|
2114
|
+
}
|
|
2115
|
+
for e in entries:
|
|
2116
|
+
acct, hhmm = e.get('account'), e.get('time')
|
|
2117
|
+
rec = state.get(f'{acct}|{hhmm}')
|
|
2118
|
+
if rec:
|
|
2119
|
+
status = labels.get(rec.get('status'), rec.get('status'))
|
|
2120
|
+
print(f" {acct:<12} {hhmm} 最後: {rec.get('date')} {status}")
|
|
2121
|
+
else:
|
|
2122
|
+
print(f" {acct:<12} {hhmm} 尚未觸發")
|
|
2123
|
+
PYEOF
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
cmd_warmup_pause() {
|
|
2127
|
+
_warmup_ensure_config
|
|
2128
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
2129
|
+
"${PY}" - "${cfg}" <<'PYEOF'
|
|
2130
|
+
import json, sys
|
|
2131
|
+
cfg_path = sys.argv[1]
|
|
2132
|
+
cfg = json.load(open(cfg_path))
|
|
2133
|
+
cfg['warmup_enabled'] = False
|
|
2134
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
2135
|
+
PYEOF
|
|
2136
|
+
ok "warmup paused — entries kept, run 'relay warmup resume' to re-enable"
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
cmd_warmup_resume() {
|
|
2140
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
2141
|
+
[[ -f "${cfg}" ]] || { err "No warmup config. Run: relay warmup add <account> <HH:MM> first"; exit 1; }
|
|
2142
|
+
"${PY}" - "${cfg}" <<'PYEOF'
|
|
2143
|
+
import json, sys
|
|
2144
|
+
cfg_path = sys.argv[1]
|
|
2145
|
+
cfg = json.load(open(cfg_path))
|
|
2146
|
+
cfg['warmup_enabled'] = True
|
|
2147
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
2148
|
+
PYEOF
|
|
2149
|
+
ok "warmup resumed"
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
cmd_warmup_test() {
|
|
2153
|
+
local name="${1:-}"
|
|
2154
|
+
[[ -z "${name}" ]] && { err "usage: relay warmup test <account>"; exit 1; }
|
|
2155
|
+
account_exists "${name}" || { err "Account '${name}' not found"; exit 1; }
|
|
2156
|
+
hdr "warmup — test"
|
|
2157
|
+
log "Switching to '${name}', pinging, then restoring your current account..."
|
|
2158
|
+
"${PY}" - "${name}" <<'PYEOF'
|
|
2159
|
+
import sys, os
|
|
2160
|
+
sys.path.insert(0, os.path.expanduser('~/.claude-relay'))
|
|
2161
|
+
PYEOF
|
|
2162
|
+
warn "relay warmup test requires the daemon module — run this from an environment where the daemon has been extracted (relay autoswitch start at least once), then re-run this command."
|
|
2163
|
+
}
|
|
2164
|
+
|
|
1544
2165
|
_script_dir() {
|
|
1545
2166
|
# Resolve symlinks so we find package.json even when installed via npm/symlink
|
|
1546
2167
|
local src="$0"
|
|
@@ -1744,6 +2365,7 @@ cmd_help() {
|
|
|
1744
2365
|
printf " %-32s %s\n" " relay refresh-all" "silent OAuth refresh for all accounts"
|
|
1745
2366
|
printf " %-32s %s\n" " relay save <name>" "save current login state"
|
|
1746
2367
|
printf " %-32s %s\n" " relay rename <old> <new>" "rename an account"
|
|
2368
|
+
printf " %-32s %s\n" " relay reorder" "change account display/switch order"
|
|
1747
2369
|
printf " %-32s %s\n" " relay list" "full list with weekly usage"
|
|
1748
2370
|
printf " %-32s %s\n" " relay list -f" "live-refresh mode (Ctrl+C to exit)"
|
|
1749
2371
|
printf " %-32s %s\n" " relay list --no-usage" "list without querying API"
|
|
@@ -1758,6 +2380,12 @@ cmd_help() {
|
|
|
1758
2380
|
printf " %-32s %s\n" " relay autoswitch config" "set up auto-switching"
|
|
1759
2381
|
printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
|
|
1760
2382
|
printf " %-32s %s\n" " relay autoswitch status" "daemon state + thresholds"
|
|
2383
|
+
echo ""
|
|
2384
|
+
printf " ${B}Warmup${R}\n"
|
|
2385
|
+
printf " %-32s %s\n" " relay warmup add <acct> <HH:MM>" "pre-warm an account's 5hr window daily"
|
|
2386
|
+
printf " %-32s %s\n" " relay warmup remove <acct> [HH:MM]" "remove a warmup schedule"
|
|
2387
|
+
printf " %-32s %s\n" " relay warmup list" "show scheduled warmups + last result"
|
|
2388
|
+
printf " %-32s %s\n" " relay warmup pause/resume" "suspend/re-enable without deleting"
|
|
1761
2389
|
printf " %-32s %s\n" " relay lock <name>" "prevent account from cycling back when over limit"
|
|
1762
2390
|
printf " %-32s %s\n" " relay unlock <name>" "remove lock"
|
|
1763
2391
|
printf " %-32s %s\n" " relay lock" "show locked accounts"
|
|
@@ -1768,6 +2396,8 @@ cmd_help() {
|
|
|
1768
2396
|
_show_update_notice
|
|
1769
2397
|
}
|
|
1770
2398
|
|
|
2399
|
+
_maybe_redeploy_daemon
|
|
2400
|
+
|
|
1771
2401
|
# ══════════════════════════════════════════════════════════════════
|
|
1772
2402
|
# Dispatch — single entry point, no fall-through
|
|
1773
2403
|
# ══════════════════════════════════════════════════════════════════
|
|
@@ -1796,10 +2426,12 @@ case "${CMD}" in
|
|
|
1796
2426
|
status|st) cmd_status ;;
|
|
1797
2427
|
remove|rm|del) cmd_remove "$@" ;;
|
|
1798
2428
|
rename|mv) cmd_rename "$@" ;;
|
|
2429
|
+
reorder) cmd_reorder "$@" ;;
|
|
1799
2430
|
sessions|sess) cmd_sessions ;;
|
|
1800
2431
|
autoswitch|as) cmd_autoswitch "$@" ;;
|
|
1801
2432
|
lock) cmd_lock "$@" ;;
|
|
1802
2433
|
unlock) cmd_unlock "$@" ;;
|
|
2434
|
+
warmup) cmd_warmup "$@" ;;
|
|
1803
2435
|
version|--version|-V) cmd_version ;;
|
|
1804
2436
|
update) cmd_update ;;
|
|
1805
2437
|
install) cmd_install ;;
|