@dst-justin/relay 2.2.6 → 2.3.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/README.md +47 -0
- package/package.json +1 -1
- package/relay +594 -43
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,21 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
|
|
|
223
255
|
|
|
224
256
|
## Changelog
|
|
225
257
|
|
|
258
|
+
### v2.3.0 — 2026-07-10
|
|
259
|
+
- Add scheduled warmup: `relay warmup add/remove/list/pause/resume` to pre-warm a 5hr session at set times
|
|
260
|
+
- 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
|
|
261
|
+
- Daemon now records the resolved `claude` binary path at autoswitch start, so it can find it regardless of the daemon's runtime PATH
|
|
262
|
+
- Add warmup health warning to `relay status`
|
|
263
|
+
- 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
|
|
264
|
+
- Refactor: atomic JSON writes for the daemon usage cache, preventing corruption from concurrent writes
|
|
265
|
+
|
|
266
|
+
### v2.2.7 — 2026-07-05
|
|
267
|
+
- `relay lock <name>` / `relay unlock <name>`: lock an account so it won't be cycled back to when over its usage threshold
|
|
268
|
+
- `relay lock` (no args): show locked accounts
|
|
269
|
+
- Autoswitch daemon now auto-enables with default 80% threshold when 2+ accounts exist — no config required
|
|
270
|
+
- Cycling follows `order` sequence; locked+over-threshold accounts are skipped; if all candidates are blocked, stays on current account and notifies
|
|
271
|
+
- Lock badge (🔒) shown in `relay list` and `relay autoswitch status`
|
|
272
|
+
|
|
226
273
|
### v2.2.6 — 2026-07-04
|
|
227
274
|
- `relay status -f` / `relay status --follow`: live-refresh current account status, same 30-second interval as `relay list -f`
|
|
228
275
|
|
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,45 @@ 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
|
|
79
142
|
}
|
|
80
143
|
|
|
81
144
|
account_by_index() {
|
|
@@ -136,6 +199,14 @@ mode, creds_dir, meta_dir, current = sys.argv[1], sys.argv[2], sys.argv[3], sys.
|
|
|
136
199
|
no_usage = '--no-usage' in sys.argv
|
|
137
200
|
relay_dir = os.path.dirname(creds_dir)
|
|
138
201
|
cache_file = os.path.join(relay_dir, 'usage_cache.json')
|
|
202
|
+
|
|
203
|
+
# Load locks from autoswitch config (best-effort, no error if missing)
|
|
204
|
+
_locks = []
|
|
205
|
+
try:
|
|
206
|
+
_as_cfg = json.load(open(os.path.join(relay_dir, 'autoswitch.json')))
|
|
207
|
+
_locks = _as_cfg.get('locks', [])
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
139
210
|
CACHE_TTL = 120 # seconds
|
|
140
211
|
|
|
141
212
|
R='\033[0m'; B='\033[1m'; D='\033[2m'
|
|
@@ -316,7 +387,8 @@ if mode == 'quick':
|
|
|
316
387
|
ncol = GR + B if cur else B
|
|
317
388
|
email = get_email(name)
|
|
318
389
|
u = u5_str(usage.get(name)) if not no_usage else ''
|
|
319
|
-
|
|
390
|
+
lock_badge = f' {YL}🔒{R}' if name in _locks else ''
|
|
391
|
+
print(f' {marker} {ncol}{name:<12}{R} {D}{email:<26}{R} {u}{lock_badge}')
|
|
320
392
|
print()
|
|
321
393
|
print(f' {D}switch:{R} {CY}!relay <index or name>{R} {D}details:{R} {CY}!relay status{R}')
|
|
322
394
|
print()
|
|
@@ -331,8 +403,9 @@ else:
|
|
|
331
403
|
d = usage.get(name)
|
|
332
404
|
u5 = u5_str(d) if not no_usage else '—'
|
|
333
405
|
u7 = u7_str(d, name) if not no_usage else '—'
|
|
406
|
+
lock_badge = f' {YL}🔒{R}' if name in _locks else ''
|
|
334
407
|
# ANSI codes don't consume display width — pad manually for alignment
|
|
335
|
-
print(f' {marker} {D}{i:<2}{R}{ncol}{name:<12}{R} {email:<28} {u5:<52} {u7}')
|
|
408
|
+
print(f' {marker} {D}{i:<2}{R}{ncol}{name:<12}{R} {email:<28} {u5:<52} {u7}{lock_badge}')
|
|
336
409
|
print()
|
|
337
410
|
n_warn = sum(1 for d in usage.values() if isinstance(d, dict) and (d.get('five_hour') or {}).get('utilization', 0) >= 80)
|
|
338
411
|
if n_warn:
|
|
@@ -344,6 +417,10 @@ EOF
|
|
|
344
417
|
# Core switch logic
|
|
345
418
|
# ══════════════════════════════════════════════════════════════════
|
|
346
419
|
do_switch() {
|
|
420
|
+
with_credential_lock _do_switch_locked "$@"
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
_do_switch_locked() {
|
|
347
424
|
local name="$1"
|
|
348
425
|
local current; current=$(current_name)
|
|
349
426
|
|
|
@@ -558,6 +635,56 @@ cmd_status() {
|
|
|
558
635
|
_cmd_status_once
|
|
559
636
|
_check_update_bg
|
|
560
637
|
_show_update_notice
|
|
638
|
+
|
|
639
|
+
# Warmup health (only prints when relevant — silent otherwise)
|
|
640
|
+
if [[ -f "${RELAY_DIR}/autoswitch.json" ]] && [[ -f "${RELAY_DIR}/autoswitch.log" ]]; then
|
|
641
|
+
"${PY}" - "${RELAY_DIR}/autoswitch.json" "${RELAY_DIR}/autoswitch.log" <<'PYEOF'
|
|
642
|
+
import json, sys
|
|
643
|
+
from collections import defaultdict
|
|
644
|
+
|
|
645
|
+
cfg_path, log_path = sys.argv[1], sys.argv[2]
|
|
646
|
+
try:
|
|
647
|
+
cfg = json.load(open(cfg_path))
|
|
648
|
+
except Exception:
|
|
649
|
+
sys.exit(0)
|
|
650
|
+
entries = cfg.get('warmup', [])
|
|
651
|
+
if not entries:
|
|
652
|
+
sys.exit(0)
|
|
653
|
+
|
|
654
|
+
counts = defaultdict(lambda: {'total': 0, 'bad': 0})
|
|
655
|
+
try:
|
|
656
|
+
with open(log_path) as f:
|
|
657
|
+
lines = f.readlines()[-2000:]
|
|
658
|
+
except Exception:
|
|
659
|
+
lines = []
|
|
660
|
+
|
|
661
|
+
for line in lines:
|
|
662
|
+
try:
|
|
663
|
+
rec = json.loads(line)
|
|
664
|
+
except Exception:
|
|
665
|
+
continue
|
|
666
|
+
ev = rec.get('event')
|
|
667
|
+
if ev == 'warmup_missed':
|
|
668
|
+
key = f"{rec.get('account')}|{rec.get('time')}"
|
|
669
|
+
counts[key]['total'] += 1
|
|
670
|
+
counts[key]['bad'] += 1
|
|
671
|
+
elif ev == 'warmup_ping':
|
|
672
|
+
acct = rec.get('account')
|
|
673
|
+
for e in entries:
|
|
674
|
+
if e.get('account') == acct:
|
|
675
|
+
key = f"{acct}|{e.get('time')}"
|
|
676
|
+
counts[key]['total'] += 1
|
|
677
|
+
if not rec.get('ok'):
|
|
678
|
+
counts[key]['bad'] += 1
|
|
679
|
+
|
|
680
|
+
R='\033[0m'; YL='\033[33m'
|
|
681
|
+
for e in entries:
|
|
682
|
+
key = f"{e.get('account')}|{e.get('time')}"
|
|
683
|
+
c = counts.get(key)
|
|
684
|
+
if c and c['total'] >= 3 and c['bad'] >= 3:
|
|
685
|
+
print(f" {YL}⚠ warmup: {e.get('account')} {e.get('time')} missed {c['bad']}/{c['total']} recent{R}")
|
|
686
|
+
PYEOF
|
|
687
|
+
fi
|
|
561
688
|
}
|
|
562
689
|
|
|
563
690
|
cmd_add() {
|
|
@@ -818,17 +945,20 @@ _extract_daemon() {
|
|
|
818
945
|
cat > "${AUTOSWITCH_DAEMON}" <<'DAEMON_EOF'
|
|
819
946
|
#!/usr/bin/env python3
|
|
820
947
|
"""relay autoswitch daemon — runs in background, switches accounts by usage threshold."""
|
|
821
|
-
import json, os, sys, time, datetime, urllib.request, urllib.error, platform, subprocess, signal
|
|
948
|
+
import fcntl, json, os, sys, time, datetime, urllib.request, urllib.error, platform, subprocess, signal, shutil
|
|
949
|
+
from contextlib import contextmanager
|
|
822
950
|
|
|
823
951
|
RELAY_DIR = os.path.expanduser('~/.claude-relay')
|
|
824
952
|
CONFIG_FILE = os.path.join(RELAY_DIR, 'autoswitch.json')
|
|
825
953
|
LOCK_FILE = os.path.join(RELAY_DIR, 'autoswitch.lock')
|
|
954
|
+
CREDENTIAL_LOCK_FILE = os.path.join(RELAY_DIR, 'credential.lock')
|
|
826
955
|
LOG_FILE = os.path.join(RELAY_DIR, 'autoswitch.log')
|
|
827
956
|
MANUAL_FILE = os.path.join(RELAY_DIR, 'manual_switch')
|
|
828
957
|
CURRENT_FILE = os.path.join(RELAY_DIR, 'current')
|
|
829
958
|
CREDS_DIR = os.path.join(RELAY_DIR, 'credentials')
|
|
830
959
|
CACHE_FILE = os.path.join(RELAY_DIR, 'usage_cache.json')
|
|
831
960
|
CACHE_TTL = 120 # seconds — same as render_table
|
|
961
|
+
WARMUP_STATE_FILE = os.path.join(RELAY_DIR, 'warmup_state.json')
|
|
832
962
|
|
|
833
963
|
# ── lock ──────────────────────────────────────────────────────────
|
|
834
964
|
def write_lock():
|
|
@@ -907,27 +1037,107 @@ def kc_write(content):
|
|
|
907
1037
|
with open(live, 'w') as f: f.write(content)
|
|
908
1038
|
os.chmod(live, 0o600)
|
|
909
1039
|
|
|
1040
|
+
@contextmanager
|
|
1041
|
+
def credential_lock():
|
|
1042
|
+
os.makedirs(RELAY_DIR, exist_ok=True)
|
|
1043
|
+
with open(CREDENTIAL_LOCK_FILE, 'w') as f:
|
|
1044
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
1045
|
+
try:
|
|
1046
|
+
yield
|
|
1047
|
+
finally:
|
|
1048
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
1049
|
+
|
|
910
1050
|
def do_switch(name):
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1051
|
+
with credential_lock():
|
|
1052
|
+
cred = os.path.join(CREDS_DIR, name + '.json')
|
|
1053
|
+
current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
|
|
1054
|
+
if current and os.path.exists(os.path.join(CREDS_DIR, current + '.json')):
|
|
1055
|
+
live = kc_read()
|
|
1056
|
+
if live:
|
|
1057
|
+
with open(os.path.join(CREDS_DIR, current + '.json'), 'w') as f: f.write(live)
|
|
1058
|
+
with open(CURRENT_FILE, 'w') as f: f.write(name)
|
|
1059
|
+
content = open(cred).read()
|
|
1060
|
+
kc_write(content)
|
|
1061
|
+
|
|
1062
|
+
def load_warmup_state():
|
|
1063
|
+
try: return json.load(open(WARMUP_STATE_FILE))
|
|
1064
|
+
except: return {}
|
|
1065
|
+
|
|
1066
|
+
def save_warmup_state(s):
|
|
1067
|
+
save_json_atomic(WARMUP_STATE_FILE, s)
|
|
1068
|
+
|
|
1069
|
+
def get_claude_bin():
|
|
1070
|
+
try:
|
|
1071
|
+
p = open(os.path.join(RELAY_DIR, 'claude_bin')).read().strip()
|
|
1072
|
+
if p and os.path.exists(p): return p
|
|
1073
|
+
except: pass
|
|
1074
|
+
return shutil.which('claude') or 'claude'
|
|
1075
|
+
|
|
1076
|
+
def do_warmup(acct):
|
|
1077
|
+
current_before = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
|
|
1078
|
+
do_switch(acct)
|
|
1079
|
+
log_event('warmup_switch', account=acct)
|
|
1080
|
+
try:
|
|
1081
|
+
r = subprocess.run([get_claude_bin(), '-p', 'ping', '--output-format', 'text'],
|
|
1082
|
+
capture_output=True, timeout=30)
|
|
1083
|
+
ok = (r.returncode == 0)
|
|
1084
|
+
log_event('warmup_ping', account=acct, ok=ok)
|
|
1085
|
+
notify('relay', f'warmup: {acct} 已完成 5hr session 預熱' if ok
|
|
1086
|
+
else f'warmup: {acct} ping 失敗')
|
|
1087
|
+
return ok
|
|
1088
|
+
except Exception as e:
|
|
1089
|
+
log_event('warmup_ping', account=acct, ok=False, err=str(e))
|
|
1090
|
+
return False
|
|
1091
|
+
finally:
|
|
1092
|
+
if current_before and current_before != acct and os.path.exists(os.path.join(CREDS_DIR, current_before + '.json')):
|
|
1093
|
+
do_switch(current_before)
|
|
1094
|
+
log_event('warmup_restore', account=current_before)
|
|
1095
|
+
|
|
1096
|
+
def check_warmup(entries):
|
|
1097
|
+
if not entries: return
|
|
1098
|
+
state = load_warmup_state()
|
|
1099
|
+
now = datetime.datetime.now()
|
|
1100
|
+
today = now.strftime('%Y-%m-%d')
|
|
1101
|
+
changed = False
|
|
1102
|
+
for entry in entries:
|
|
1103
|
+
acct, hhmm = entry.get('account'), entry.get('time')
|
|
1104
|
+
if not acct or not hhmm: continue
|
|
1105
|
+
key = f'{acct}|{hhmm}'
|
|
1106
|
+
if (state.get(key) or {}).get('date') == today:
|
|
1107
|
+
continue
|
|
1108
|
+
try:
|
|
1109
|
+
h, m = map(int, hhmm.split(':'))
|
|
1110
|
+
scheduled = now.replace(hour=h, minute=m, second=0, microsecond=0)
|
|
1111
|
+
except: continue
|
|
1112
|
+
if now < scheduled:
|
|
1113
|
+
continue
|
|
1114
|
+
if (now - scheduled).total_seconds() > 900: # 15 min grace window
|
|
1115
|
+
state[key] = {'date': today, 'status': 'missed'}
|
|
1116
|
+
log_event('warmup_missed', account=acct, time=hhmm)
|
|
1117
|
+
changed = True; continue
|
|
1118
|
+
if not os.path.exists(os.path.join(CREDS_DIR, acct + '.json')):
|
|
1119
|
+
log_event('warmup_pending', account=acct, reason='missing_account')
|
|
1120
|
+
continue
|
|
1121
|
+
ok = do_warmup(acct)
|
|
1122
|
+
state[key] = {'date': today, 'status': 'ok' if ok else 'ping_failed'}
|
|
1123
|
+
changed = True
|
|
1124
|
+
if changed: save_warmup_state(state)
|
|
920
1125
|
|
|
921
1126
|
# ── usage fetch ───────────────────────────────────────────────────
|
|
922
1127
|
def load_cache():
|
|
923
1128
|
try: return json.load(open(CACHE_FILE))
|
|
924
1129
|
except: return {}
|
|
925
1130
|
|
|
926
|
-
def
|
|
1131
|
+
def save_json_atomic(path, data):
|
|
927
1132
|
try:
|
|
928
|
-
|
|
1133
|
+
tmp = path + '.tmp'
|
|
1134
|
+
with open(tmp, 'w') as f: json.dump(data, f)
|
|
1135
|
+
os.replace(tmp, path)
|
|
929
1136
|
except: pass
|
|
930
1137
|
|
|
1138
|
+
def save_cache(c):
|
|
1139
|
+
save_json_atomic(CACHE_FILE, c)
|
|
1140
|
+
|
|
931
1141
|
# ponytail: intentional copy of try_refresh() — daemon is a standalone extracted script
|
|
932
1142
|
def try_refresh_daemon(name, cred_path):
|
|
933
1143
|
try:
|
|
@@ -950,9 +1160,10 @@ def try_refresh_daemon(name, cred_path):
|
|
|
950
1160
|
content = json.dumps(d)
|
|
951
1161
|
open(cred_path, 'w').write(content)
|
|
952
1162
|
os.chmod(cred_path, 0o600)
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1163
|
+
with credential_lock():
|
|
1164
|
+
current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
|
|
1165
|
+
if name == current:
|
|
1166
|
+
kc_write(content) # critical: update keychain so do_switch() doesn't clobber
|
|
956
1167
|
return oauth['accessToken']
|
|
957
1168
|
except Exception:
|
|
958
1169
|
return None
|
|
@@ -998,6 +1209,14 @@ def get_utilization(usage_data):
|
|
|
998
1209
|
u = fh.get('utilization')
|
|
999
1210
|
return int(u) if u is not None else None
|
|
1000
1211
|
|
|
1212
|
+
def is_blocked(name, thr_map, locks_list, usage_map):
|
|
1213
|
+
"""True if account is locked AND at or over its threshold — skip as switch target."""
|
|
1214
|
+
if name not in locks_list:
|
|
1215
|
+
return False
|
|
1216
|
+
util = get_utilization(usage_map.get(name))
|
|
1217
|
+
threshold = thr_map.get(name, 80)
|
|
1218
|
+
return util is not None and util >= threshold
|
|
1219
|
+
|
|
1001
1220
|
# ── manual switch protection ───────────────────────────────────────
|
|
1002
1221
|
def get_manual_switch():
|
|
1003
1222
|
try: return json.load(open(MANUAL_FILE))
|
|
@@ -1008,9 +1227,34 @@ def clear_manual_switch():
|
|
|
1008
1227
|
except: pass
|
|
1009
1228
|
|
|
1010
1229
|
# ── main loop ─────────────────────────────────────────────────────
|
|
1230
|
+
def load_raw_config():
|
|
1231
|
+
"""Read autoswitch.json's raw contents, or {} if missing/corrupt. Used so
|
|
1232
|
+
'warmup' entries work even when load_config()'s auto-default path (which
|
|
1233
|
+
omits 'warmup') would otherwise apply."""
|
|
1234
|
+
try:
|
|
1235
|
+
return json.load(open(CONFIG_FILE))
|
|
1236
|
+
except FileNotFoundError:
|
|
1237
|
+
return {}
|
|
1238
|
+
except Exception as e:
|
|
1239
|
+
log_event('config_parse_error', error=str(e))
|
|
1240
|
+
return {}
|
|
1241
|
+
|
|
1011
1242
|
def load_config():
|
|
1012
|
-
try:
|
|
1013
|
-
|
|
1243
|
+
try:
|
|
1244
|
+
return json.load(open(CONFIG_FILE))
|
|
1245
|
+
except:
|
|
1246
|
+
# Auto-default: 2+ accounts → enable with 80% threshold, no explicit config needed
|
|
1247
|
+
if not os.path.isdir(CREDS_DIR):
|
|
1248
|
+
return None
|
|
1249
|
+
accounts = sorted(f[:-5] for f in os.listdir(CREDS_DIR) if f.endswith('.json'))
|
|
1250
|
+
if len(accounts) < 2:
|
|
1251
|
+
return None
|
|
1252
|
+
return {
|
|
1253
|
+
'order': accounts,
|
|
1254
|
+
'thresholds': {a: 80 for a in accounts},
|
|
1255
|
+
'locks': [],
|
|
1256
|
+
'poll': {'low_minutes': 10, 'high_minutes': 2, 'high_threshold': 50}
|
|
1257
|
+
}
|
|
1014
1258
|
|
|
1015
1259
|
def main():
|
|
1016
1260
|
check_single_instance()
|
|
@@ -1029,11 +1273,15 @@ def main():
|
|
|
1029
1273
|
last_refresh_ts = time.time()
|
|
1030
1274
|
|
|
1031
1275
|
cfg = load_config()
|
|
1276
|
+
raw_cfg = load_raw_config()
|
|
1277
|
+
if raw_cfg.get('warmup_enabled', True):
|
|
1278
|
+
check_warmup(raw_cfg.get('warmup', []))
|
|
1032
1279
|
if not cfg:
|
|
1033
1280
|
time.sleep(60); continue
|
|
1034
1281
|
|
|
1035
1282
|
order = cfg.get('order', [])
|
|
1036
1283
|
thresholds = cfg.get('thresholds', {})
|
|
1284
|
+
locks = cfg.get('locks', [])
|
|
1037
1285
|
poll = cfg.get('poll', {})
|
|
1038
1286
|
low_min = int(poll.get('low_minutes', 10))
|
|
1039
1287
|
high_min = int(poll.get('high_minutes', 2))
|
|
@@ -1061,21 +1309,23 @@ def main():
|
|
|
1061
1309
|
if cur_threshold is None or cur_util is None or cur_util < cur_threshold:
|
|
1062
1310
|
time.sleep(sleep_sec); continue
|
|
1063
1311
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
time.sleep(sleep_sec)
|
|
1078
|
-
|
|
1312
|
+
# Ordered cycling: walk order[] from current position, skip blocked accounts
|
|
1313
|
+
idx = order.index(current) if current in order else 0
|
|
1314
|
+
target = None
|
|
1315
|
+
for i in range(1, len(order)):
|
|
1316
|
+
candidate = order[(idx + i) % len(order)]
|
|
1317
|
+
if not is_blocked(candidate, thresholds, locks, usage):
|
|
1318
|
+
target = candidate
|
|
1319
|
+
break
|
|
1320
|
+
|
|
1321
|
+
if target is None:
|
|
1322
|
+
# All candidates are locked + over threshold — stay put
|
|
1323
|
+
log_event('all_blocked', current=current)
|
|
1324
|
+
notify('relay', 'All accounts at limit — staying on current account')
|
|
1325
|
+
time.sleep(sleep_sec)
|
|
1326
|
+
continue
|
|
1327
|
+
|
|
1328
|
+
target_util = get_utilization(usage.get(target))
|
|
1079
1329
|
log_event('switch', frm=current, to=target, usage=cur_util)
|
|
1080
1330
|
notify('relay', f'switched {current} → {target} ({current} at {cur_util}%)')
|
|
1081
1331
|
do_switch(target)
|
|
@@ -1199,6 +1449,14 @@ cmd_autoswitch_start() {
|
|
|
1199
1449
|
}
|
|
1200
1450
|
|
|
1201
1451
|
hdr "autoswitch — start"
|
|
1452
|
+
if [[ -n "${REAL_CLAUDE}" && "${REAL_CLAUDE}" != "$0" ]]; then
|
|
1453
|
+
echo "${REAL_CLAUDE}" > "${RELAY_DIR}/claude_bin"
|
|
1454
|
+
elif [[ -z "${REAL_CLAUDE}" ]]; then
|
|
1455
|
+
warn "claude not found; warmup will fall back to daemon PATH lookup and may fail"
|
|
1456
|
+
else
|
|
1457
|
+
warn "claude resolves to relay wrapper; warmup will fall back to daemon PATH lookup and may fail"
|
|
1458
|
+
fi
|
|
1459
|
+
|
|
1202
1460
|
_extract_daemon
|
|
1203
1461
|
log "Daemon extracted to ${AUTOSWITCH_DAEMON}"
|
|
1204
1462
|
|
|
@@ -1314,6 +1572,7 @@ cfg = json.load(open(sys.argv[1]))
|
|
|
1314
1572
|
current = sys.argv[2]
|
|
1315
1573
|
order = cfg.get('order', [])
|
|
1316
1574
|
thresholds = cfg.get('thresholds', {})
|
|
1575
|
+
locks = cfg.get('locks', [])
|
|
1317
1576
|
relay_dir = os.path.expanduser('~/.claude-relay')
|
|
1318
1577
|
cache_file = os.path.join(relay_dir, 'usage_cache.json')
|
|
1319
1578
|
|
|
@@ -1332,8 +1591,8 @@ def get_util(name):
|
|
|
1332
1591
|
u = fh.get('utilization')
|
|
1333
1592
|
return int(u) if u is not None else None
|
|
1334
1593
|
|
|
1335
|
-
print(f' {B}{"order":<4} {"account":<14} {"threshold":<12} {"cached usage":<14}{R}')
|
|
1336
|
-
print(f' {D}{"─"*
|
|
1594
|
+
print(f' {B}{"order":<4} {"account":<14} {"threshold":<12} {"cached usage":<14} {"lock":<6}{R}')
|
|
1595
|
+
print(f' {D}{"─"*58}{R}')
|
|
1337
1596
|
for i, name in enumerate(order, 1):
|
|
1338
1597
|
cur = name == current
|
|
1339
1598
|
marker = f'{GR}●{R}' if cur else ' '
|
|
@@ -1353,7 +1612,8 @@ for i, name in enumerate(order, 1):
|
|
|
1353
1612
|
for j in range(i-1)
|
|
1354
1613
|
)
|
|
1355
1614
|
if prev_over: next_s = f' {CY}← next{R}'
|
|
1356
|
-
|
|
1615
|
+
lock_s = f' {YL}🔒{R}' if name in locks else ''
|
|
1616
|
+
print(f' {marker} {D}{i:<2}{R}{ncol}{name:<14}{R} {thr_s:<12} {util_s}{next_s}{lock_s}')
|
|
1357
1617
|
|
|
1358
1618
|
log_file = os.path.join(os.path.dirname(sys.argv[1]), 'autoswitch.log')
|
|
1359
1619
|
try:
|
|
@@ -1372,6 +1632,8 @@ EOF
|
|
|
1372
1632
|
printf " %-34s %s\n" " ${CY}relay autoswitch stop${R}" "stop daemon"
|
|
1373
1633
|
printf " %-34s %s\n" " ${CY}relay autoswitch config${R}" "edit settings"
|
|
1374
1634
|
printf " %-34s %s\n" " ${CY}relay autoswitch log${R}" "switch history"
|
|
1635
|
+
printf " %-34s %s\n" " ${CY}relay lock <name>${R}" "lock account (skip when over threshold)"
|
|
1636
|
+
printf " %-34s %s\n" " ${CY}relay unlock <name>${R}" "remove lock"
|
|
1375
1637
|
_show_update_notice
|
|
1376
1638
|
}
|
|
1377
1639
|
|
|
@@ -1422,6 +1684,283 @@ cmd_autoswitch() {
|
|
|
1422
1684
|
esac
|
|
1423
1685
|
}
|
|
1424
1686
|
|
|
1687
|
+
cmd_lock() {
|
|
1688
|
+
local name="${1:-}"
|
|
1689
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
1690
|
+
|
|
1691
|
+
# No argument: show lock status
|
|
1692
|
+
if [[ -z "${name}" ]]; then
|
|
1693
|
+
hdr "Account locks"
|
|
1694
|
+
if [[ ! -f "${cfg}" ]]; then
|
|
1695
|
+
warn "No autoswitch config. Run: relay autoswitch config"
|
|
1696
|
+
return 0
|
|
1697
|
+
fi
|
|
1698
|
+
"${PY}" - "${cfg}" <<'PYEOF'
|
|
1699
|
+
import json, sys
|
|
1700
|
+
cfg = json.load(open(sys.argv[1]))
|
|
1701
|
+
locks = cfg.get('locks', [])
|
|
1702
|
+
R='\033[0m'; B='\033[1m'; D='\033[2m'; YL='\033[33m'
|
|
1703
|
+
if not locks:
|
|
1704
|
+
print(f' {D}No accounts locked.{R}')
|
|
1705
|
+
else:
|
|
1706
|
+
for name in locks:
|
|
1707
|
+
print(f' {YL}🔒{R} {B}{name}{R}')
|
|
1708
|
+
print()
|
|
1709
|
+
PYEOF
|
|
1710
|
+
return 0
|
|
1711
|
+
fi
|
|
1712
|
+
|
|
1713
|
+
account_exists "${name}" || { err "Account '${name}' not found"; exit 1; }
|
|
1714
|
+
|
|
1715
|
+
# Ensure config file exists (create auto-default if missing)
|
|
1716
|
+
if [[ ! -f "${cfg}" ]]; then
|
|
1717
|
+
"${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
|
|
1718
|
+
import json, sys, os
|
|
1719
|
+
creds_dir, cfg_path = sys.argv[1], sys.argv[2]
|
|
1720
|
+
accounts = sorted(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json'))
|
|
1721
|
+
config = {
|
|
1722
|
+
'order': accounts,
|
|
1723
|
+
'thresholds': {a: 80 for a in accounts},
|
|
1724
|
+
'locks': [],
|
|
1725
|
+
'poll': {'low_minutes': 10, 'high_minutes': 2, 'high_threshold': 50}
|
|
1726
|
+
}
|
|
1727
|
+
json.dump(config, open(cfg_path, 'w'), indent=2)
|
|
1728
|
+
PYEOF
|
|
1729
|
+
ok "Created default autoswitch config: ${cfg}"
|
|
1730
|
+
fi
|
|
1731
|
+
|
|
1732
|
+
"${PY}" - "${cfg}" "${name}" <<'PYEOF'
|
|
1733
|
+
import json, sys
|
|
1734
|
+
cfg_path, name = sys.argv[1], sys.argv[2]
|
|
1735
|
+
cfg = json.load(open(cfg_path))
|
|
1736
|
+
locks = cfg.get('locks', [])
|
|
1737
|
+
if name in locks:
|
|
1738
|
+
print(f' already locked: {name}')
|
|
1739
|
+
sys.exit(0)
|
|
1740
|
+
locks.append(name)
|
|
1741
|
+
cfg['locks'] = locks
|
|
1742
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
1743
|
+
PYEOF
|
|
1744
|
+
ok "Locked '${B}${name}${R}' — won't be switched back to when over threshold"
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
cmd_unlock() {
|
|
1748
|
+
local name="${1:-}"
|
|
1749
|
+
[[ -z "${name}" ]] && { err "usage: relay unlock <name>"; exit 1; }
|
|
1750
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
1751
|
+
[[ ! -f "${cfg}" ]] && { warn "No autoswitch config — nothing to unlock"; return 0; }
|
|
1752
|
+
|
|
1753
|
+
"${PY}" - "${cfg}" "${name}" <<'PYEOF'
|
|
1754
|
+
import json, sys
|
|
1755
|
+
cfg_path, name = sys.argv[1], sys.argv[2]
|
|
1756
|
+
cfg = json.load(open(cfg_path))
|
|
1757
|
+
locks = cfg.get('locks', [])
|
|
1758
|
+
if name not in locks:
|
|
1759
|
+
print(f' not locked: {name}')
|
|
1760
|
+
sys.exit(0)
|
|
1761
|
+
locks.remove(name)
|
|
1762
|
+
cfg['locks'] = locks
|
|
1763
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
1764
|
+
PYEOF
|
|
1765
|
+
ok "Unlocked '${B}${name}${R}'"
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
cmd_warmup() {
|
|
1769
|
+
local sub="${1:-}"; [[ $# -gt 0 ]] && shift
|
|
1770
|
+
case "${sub}" in
|
|
1771
|
+
add) cmd_warmup_add "$@" ;;
|
|
1772
|
+
remove|rm) cmd_warmup_remove "$@" ;;
|
|
1773
|
+
list|ls) cmd_warmup_list ;;
|
|
1774
|
+
pause) cmd_warmup_pause ;;
|
|
1775
|
+
resume) cmd_warmup_resume ;;
|
|
1776
|
+
test) cmd_warmup_test "$@" ;;
|
|
1777
|
+
*) cmd_warmup_list ;;
|
|
1778
|
+
esac
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
_warmup_ensure_config() {
|
|
1782
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
1783
|
+
if [[ ! -f "${cfg}" ]]; then
|
|
1784
|
+
"${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
|
|
1785
|
+
import json, sys, os
|
|
1786
|
+
creds_dir, cfg_path = sys.argv[1], sys.argv[2]
|
|
1787
|
+
accounts = sorted(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json')) if os.path.isdir(creds_dir) else []
|
|
1788
|
+
config = {
|
|
1789
|
+
'order': accounts,
|
|
1790
|
+
'thresholds': {a: 80 for a in accounts},
|
|
1791
|
+
'locks': [],
|
|
1792
|
+
'poll': {'low_minutes': 10, 'high_minutes': 2, 'high_threshold': 50},
|
|
1793
|
+
'warmup': []
|
|
1794
|
+
}
|
|
1795
|
+
json.dump(config, open(cfg_path, 'w'), indent=2)
|
|
1796
|
+
PYEOF
|
|
1797
|
+
fi
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
_warmup_daemon_running() {
|
|
1801
|
+
local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
|
|
1802
|
+
[[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
_warmup_valid_time() {
|
|
1806
|
+
[[ "$1" =~ ^([0-1][0-9]|2[0-3]):[0-5][0-9]$ ]]
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
cmd_warmup_add() {
|
|
1810
|
+
local name="${1:-}" hhmm="${2:-}"
|
|
1811
|
+
if [[ -z "${name}" || -z "${hhmm}" ]]; then
|
|
1812
|
+
err "usage: relay warmup add <account> <HH:MM>"
|
|
1813
|
+
exit 1
|
|
1814
|
+
fi
|
|
1815
|
+
account_exists "${name}" || {
|
|
1816
|
+
err "Account '${name}' not found — run 'relay list' to see accounts, or 'relay add ${name}' first"
|
|
1817
|
+
exit 1
|
|
1818
|
+
}
|
|
1819
|
+
_warmup_valid_time "${hhmm}" || {
|
|
1820
|
+
err "Invalid time '${hhmm}' — expected HH:MM, 00:00–23:59 (e.g. 06:00)"
|
|
1821
|
+
exit 1
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
_warmup_ensure_config
|
|
1825
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
1826
|
+
|
|
1827
|
+
"${PY}" - "${cfg}" "${name}" "${hhmm}" <<'PYEOF'
|
|
1828
|
+
import json, sys
|
|
1829
|
+
cfg_path, name, hhmm = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
1830
|
+
cfg = json.load(open(cfg_path))
|
|
1831
|
+
entries = cfg.get('warmup', [])
|
|
1832
|
+
if not any(e.get('account') == name and e.get('time') == hhmm for e in entries):
|
|
1833
|
+
entries.append({'account': name, 'time': hhmm})
|
|
1834
|
+
cfg['warmup'] = entries
|
|
1835
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
1836
|
+
PYEOF
|
|
1837
|
+
|
|
1838
|
+
ok "warmup: ${name} will fire at ${hhmm}"
|
|
1839
|
+
echo " Warmup runs a real, non-interactive 'claude -p ping' call to Anthropic's API"
|
|
1840
|
+
echo " on your machine in the background at the scheduled time — it does not send"
|
|
1841
|
+
echo " your credentials anywhere, and it does not increase your weekly usage cap."
|
|
1842
|
+
echo " It only starts your rolling 5-hour usage window earlier."
|
|
1843
|
+
if ! _warmup_daemon_running; then
|
|
1844
|
+
warn "background daemon isn't running — this won't fire until you run: relay autoswitch start"
|
|
1845
|
+
fi
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
cmd_warmup_remove() {
|
|
1849
|
+
local name="${1:-}" hhmm="${2:-}"
|
|
1850
|
+
if [[ -z "${name}" ]]; then
|
|
1851
|
+
err "usage: relay warmup remove <account> [HH:MM]"
|
|
1852
|
+
exit 1
|
|
1853
|
+
fi
|
|
1854
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
1855
|
+
[[ -f "${cfg}" ]] || { warn "No warmup entries for '${name}'"; return 0; }
|
|
1856
|
+
|
|
1857
|
+
local removed
|
|
1858
|
+
if ! removed=$("${PY}" - "${cfg}" "${name}" "${hhmm}" <<'PYEOF'
|
|
1859
|
+
import json, sys
|
|
1860
|
+
cfg_path, name, hhmm = sys.argv[1], sys.argv[2], (sys.argv[3] or None)
|
|
1861
|
+
cfg = json.load(open(cfg_path))
|
|
1862
|
+
entries = cfg.get('warmup', [])
|
|
1863
|
+
if hhmm:
|
|
1864
|
+
remaining = [e for e in entries if not (e.get('account') == name and e.get('time') == hhmm)]
|
|
1865
|
+
else:
|
|
1866
|
+
remaining = [e for e in entries if e.get('account') != name]
|
|
1867
|
+
removed = len(entries) - len(remaining)
|
|
1868
|
+
cfg['warmup'] = remaining
|
|
1869
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
1870
|
+
print(removed)
|
|
1871
|
+
PYEOF
|
|
1872
|
+
); then
|
|
1873
|
+
err "Failed to remove warmup entries for '${name}'"
|
|
1874
|
+
return 1
|
|
1875
|
+
fi
|
|
1876
|
+
if [[ "${removed}" -eq 0 ]]; then
|
|
1877
|
+
if [[ -n "${hhmm}" ]]; then
|
|
1878
|
+
warn "No warmup entries for '${name}' at ${hhmm}"
|
|
1879
|
+
else
|
|
1880
|
+
warn "No warmup entries for '${name}'"
|
|
1881
|
+
fi
|
|
1882
|
+
return 0
|
|
1883
|
+
fi
|
|
1884
|
+
ok "Removed warmup entries for '${name}'"
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
cmd_warmup_list() {
|
|
1888
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
1889
|
+
hdr "warmup"
|
|
1890
|
+
|
|
1891
|
+
if _warmup_daemon_running; then :; else
|
|
1892
|
+
printf " ${YL}daemon: not running${R} — run 'relay autoswitch start'\n"
|
|
1893
|
+
fi
|
|
1894
|
+
|
|
1895
|
+
[[ -f "${cfg}" ]] || { warn "No warmup entries. Run: relay warmup add <account> <HH:MM>"; return 0; }
|
|
1896
|
+
|
|
1897
|
+
"${PY}" - "${cfg}" "${RELAY_DIR}/warmup_state.json" <<'PYEOF'
|
|
1898
|
+
import json, sys
|
|
1899
|
+
cfg = json.load(open(sys.argv[1]))
|
|
1900
|
+
if not cfg.get('warmup_enabled', True):
|
|
1901
|
+
print(' \033[33m⏸ warmup paused\033[0m — run \'relay warmup resume\' to re-enable')
|
|
1902
|
+
entries = cfg.get('warmup', [])
|
|
1903
|
+
if not entries:
|
|
1904
|
+
print(' No warmup entries. Run: relay warmup add <account> <HH:MM>')
|
|
1905
|
+
sys.exit(0)
|
|
1906
|
+
try:
|
|
1907
|
+
state = json.load(open(sys.argv[2]))
|
|
1908
|
+
except Exception:
|
|
1909
|
+
state = {}
|
|
1910
|
+
labels = {
|
|
1911
|
+
'ok': '成功', 'ping_failed': 'ping 失敗', 'missed': '錯過',
|
|
1912
|
+
'missing_account': '帳號不存在',
|
|
1913
|
+
}
|
|
1914
|
+
for e in entries:
|
|
1915
|
+
acct, hhmm = e.get('account'), e.get('time')
|
|
1916
|
+
rec = state.get(f'{acct}|{hhmm}')
|
|
1917
|
+
if rec:
|
|
1918
|
+
status = labels.get(rec.get('status'), rec.get('status'))
|
|
1919
|
+
print(f" {acct:<12} {hhmm} 最後: {rec.get('date')} {status}")
|
|
1920
|
+
else:
|
|
1921
|
+
print(f" {acct:<12} {hhmm} 尚未觸發")
|
|
1922
|
+
PYEOF
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
cmd_warmup_pause() {
|
|
1926
|
+
_warmup_ensure_config
|
|
1927
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
1928
|
+
"${PY}" - "${cfg}" <<'PYEOF'
|
|
1929
|
+
import json, sys
|
|
1930
|
+
cfg_path = sys.argv[1]
|
|
1931
|
+
cfg = json.load(open(cfg_path))
|
|
1932
|
+
cfg['warmup_enabled'] = False
|
|
1933
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
1934
|
+
PYEOF
|
|
1935
|
+
ok "warmup paused — entries kept, run 'relay warmup resume' to re-enable"
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
cmd_warmup_resume() {
|
|
1939
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
1940
|
+
[[ -f "${cfg}" ]] || { err "No warmup config. Run: relay warmup add <account> <HH:MM> first"; exit 1; }
|
|
1941
|
+
"${PY}" - "${cfg}" <<'PYEOF'
|
|
1942
|
+
import json, sys
|
|
1943
|
+
cfg_path = sys.argv[1]
|
|
1944
|
+
cfg = json.load(open(cfg_path))
|
|
1945
|
+
cfg['warmup_enabled'] = True
|
|
1946
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
1947
|
+
PYEOF
|
|
1948
|
+
ok "warmup resumed"
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
cmd_warmup_test() {
|
|
1952
|
+
local name="${1:-}"
|
|
1953
|
+
[[ -z "${name}" ]] && { err "usage: relay warmup test <account>"; exit 1; }
|
|
1954
|
+
account_exists "${name}" || { err "Account '${name}' not found"; exit 1; }
|
|
1955
|
+
hdr "warmup — test"
|
|
1956
|
+
log "Switching to '${name}', pinging, then restoring your current account..."
|
|
1957
|
+
"${PY}" - "${name}" <<'PYEOF'
|
|
1958
|
+
import sys, os
|
|
1959
|
+
sys.path.insert(0, os.path.expanduser('~/.claude-relay'))
|
|
1960
|
+
PYEOF
|
|
1961
|
+
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."
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1425
1964
|
_script_dir() {
|
|
1426
1965
|
# Resolve symlinks so we find package.json even when installed via npm/symlink
|
|
1427
1966
|
local src="$0"
|
|
@@ -1640,6 +2179,15 @@ cmd_help() {
|
|
|
1640
2179
|
printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
|
|
1641
2180
|
printf " %-32s %s\n" " relay autoswitch status" "daemon state + thresholds"
|
|
1642
2181
|
echo ""
|
|
2182
|
+
printf " ${B}Warmup${R}\n"
|
|
2183
|
+
printf " %-32s %s\n" " relay warmup add <acct> <HH:MM>" "pre-warm an account's 5hr window daily"
|
|
2184
|
+
printf " %-32s %s\n" " relay warmup remove <acct> [HH:MM]" "remove a warmup schedule"
|
|
2185
|
+
printf " %-32s %s\n" " relay warmup list" "show scheduled warmups + last result"
|
|
2186
|
+
printf " %-32s %s\n" " relay warmup pause/resume" "suspend/re-enable without deleting"
|
|
2187
|
+
printf " %-32s %s\n" " relay lock <name>" "prevent account from cycling back when over limit"
|
|
2188
|
+
printf " %-32s %s\n" " relay unlock <name>" "remove lock"
|
|
2189
|
+
printf " %-32s %s\n" " relay lock" "show locked accounts"
|
|
2190
|
+
echo ""
|
|
1643
2191
|
printf " ${D}switches the OAuth credential (macOS Keychain / Linux ~/.claude/.credentials.json); sessions are shared${R}\n"
|
|
1644
2192
|
printf " ${D}after switching: claude -c to resume, claude --resume <id> for a specific session${R}\n\n"
|
|
1645
2193
|
_check_update_bg
|
|
@@ -1676,6 +2224,9 @@ case "${CMD}" in
|
|
|
1676
2224
|
rename|mv) cmd_rename "$@" ;;
|
|
1677
2225
|
sessions|sess) cmd_sessions ;;
|
|
1678
2226
|
autoswitch|as) cmd_autoswitch "$@" ;;
|
|
2227
|
+
lock) cmd_lock "$@" ;;
|
|
2228
|
+
unlock) cmd_unlock "$@" ;;
|
|
2229
|
+
warmup) cmd_warmup "$@" ;;
|
|
1679
2230
|
version|--version|-V) cmd_version ;;
|
|
1680
2231
|
update) cmd_update ;;
|
|
1681
2232
|
install) cmd_install ;;
|