@dst-justin/relay 2.2.7 → 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 +40 -0
- package/package.json +1 -1
- package/relay +448 -21
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,14 @@ 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
|
+
|
|
226
266
|
### v2.2.7 — 2026-07-05
|
|
227
267
|
- `relay lock <name>` / `relay unlock <name>`: lock an account so it won't be cycled back to when over its usage threshold
|
|
228
268
|
- `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,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() {
|
|
@@ -354,6 +417,10 @@ EOF
|
|
|
354
417
|
# Core switch logic
|
|
355
418
|
# ══════════════════════════════════════════════════════════════════
|
|
356
419
|
do_switch() {
|
|
420
|
+
with_credential_lock _do_switch_locked "$@"
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
_do_switch_locked() {
|
|
357
424
|
local name="$1"
|
|
358
425
|
local current; current=$(current_name)
|
|
359
426
|
|
|
@@ -568,6 +635,56 @@ cmd_status() {
|
|
|
568
635
|
_cmd_status_once
|
|
569
636
|
_check_update_bg
|
|
570
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
|
|
571
688
|
}
|
|
572
689
|
|
|
573
690
|
cmd_add() {
|
|
@@ -828,17 +945,20 @@ _extract_daemon() {
|
|
|
828
945
|
cat > "${AUTOSWITCH_DAEMON}" <<'DAEMON_EOF'
|
|
829
946
|
#!/usr/bin/env python3
|
|
830
947
|
"""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
|
|
948
|
+
import fcntl, json, os, sys, time, datetime, urllib.request, urllib.error, platform, subprocess, signal, shutil
|
|
949
|
+
from contextlib import contextmanager
|
|
832
950
|
|
|
833
951
|
RELAY_DIR = os.path.expanduser('~/.claude-relay')
|
|
834
952
|
CONFIG_FILE = os.path.join(RELAY_DIR, 'autoswitch.json')
|
|
835
953
|
LOCK_FILE = os.path.join(RELAY_DIR, 'autoswitch.lock')
|
|
954
|
+
CREDENTIAL_LOCK_FILE = os.path.join(RELAY_DIR, 'credential.lock')
|
|
836
955
|
LOG_FILE = os.path.join(RELAY_DIR, 'autoswitch.log')
|
|
837
956
|
MANUAL_FILE = os.path.join(RELAY_DIR, 'manual_switch')
|
|
838
957
|
CURRENT_FILE = os.path.join(RELAY_DIR, 'current')
|
|
839
958
|
CREDS_DIR = os.path.join(RELAY_DIR, 'credentials')
|
|
840
959
|
CACHE_FILE = os.path.join(RELAY_DIR, 'usage_cache.json')
|
|
841
960
|
CACHE_TTL = 120 # seconds — same as render_table
|
|
961
|
+
WARMUP_STATE_FILE = os.path.join(RELAY_DIR, 'warmup_state.json')
|
|
842
962
|
|
|
843
963
|
# ── lock ──────────────────────────────────────────────────────────
|
|
844
964
|
def write_lock():
|
|
@@ -917,27 +1037,107 @@ def kc_write(content):
|
|
|
917
1037
|
with open(live, 'w') as f: f.write(content)
|
|
918
1038
|
os.chmod(live, 0o600)
|
|
919
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
|
+
|
|
920
1050
|
def do_switch(name):
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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)
|
|
930
1125
|
|
|
931
1126
|
# ── usage fetch ───────────────────────────────────────────────────
|
|
932
1127
|
def load_cache():
|
|
933
1128
|
try: return json.load(open(CACHE_FILE))
|
|
934
1129
|
except: return {}
|
|
935
1130
|
|
|
936
|
-
def
|
|
1131
|
+
def save_json_atomic(path, data):
|
|
937
1132
|
try:
|
|
938
|
-
|
|
1133
|
+
tmp = path + '.tmp'
|
|
1134
|
+
with open(tmp, 'w') as f: json.dump(data, f)
|
|
1135
|
+
os.replace(tmp, path)
|
|
939
1136
|
except: pass
|
|
940
1137
|
|
|
1138
|
+
def save_cache(c):
|
|
1139
|
+
save_json_atomic(CACHE_FILE, c)
|
|
1140
|
+
|
|
941
1141
|
# ponytail: intentional copy of try_refresh() — daemon is a standalone extracted script
|
|
942
1142
|
def try_refresh_daemon(name, cred_path):
|
|
943
1143
|
try:
|
|
@@ -960,9 +1160,10 @@ def try_refresh_daemon(name, cred_path):
|
|
|
960
1160
|
content = json.dumps(d)
|
|
961
1161
|
open(cred_path, 'w').write(content)
|
|
962
1162
|
os.chmod(cred_path, 0o600)
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
|
966
1167
|
return oauth['accessToken']
|
|
967
1168
|
except Exception:
|
|
968
1169
|
return None
|
|
@@ -1026,6 +1227,18 @@ def clear_manual_switch():
|
|
|
1026
1227
|
except: pass
|
|
1027
1228
|
|
|
1028
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
|
+
|
|
1029
1242
|
def load_config():
|
|
1030
1243
|
try:
|
|
1031
1244
|
return json.load(open(CONFIG_FILE))
|
|
@@ -1060,6 +1273,9 @@ def main():
|
|
|
1060
1273
|
last_refresh_ts = time.time()
|
|
1061
1274
|
|
|
1062
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', []))
|
|
1063
1279
|
if not cfg:
|
|
1064
1280
|
time.sleep(60); continue
|
|
1065
1281
|
|
|
@@ -1233,6 +1449,14 @@ cmd_autoswitch_start() {
|
|
|
1233
1449
|
}
|
|
1234
1450
|
|
|
1235
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
|
+
|
|
1236
1460
|
_extract_daemon
|
|
1237
1461
|
log "Daemon extracted to ${AUTOSWITCH_DAEMON}"
|
|
1238
1462
|
|
|
@@ -1541,6 +1765,202 @@ PYEOF
|
|
|
1541
1765
|
ok "Unlocked '${B}${name}${R}'"
|
|
1542
1766
|
}
|
|
1543
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
|
+
|
|
1544
1964
|
_script_dir() {
|
|
1545
1965
|
# Resolve symlinks so we find package.json even when installed via npm/symlink
|
|
1546
1966
|
local src="$0"
|
|
@@ -1758,6 +2178,12 @@ cmd_help() {
|
|
|
1758
2178
|
printf " %-32s %s\n" " relay autoswitch config" "set up auto-switching"
|
|
1759
2179
|
printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
|
|
1760
2180
|
printf " %-32s %s\n" " relay autoswitch status" "daemon state + thresholds"
|
|
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"
|
|
1761
2187
|
printf " %-32s %s\n" " relay lock <name>" "prevent account from cycling back when over limit"
|
|
1762
2188
|
printf " %-32s %s\n" " relay unlock <name>" "remove lock"
|
|
1763
2189
|
printf " %-32s %s\n" " relay lock" "show locked accounts"
|
|
@@ -1800,6 +2226,7 @@ case "${CMD}" in
|
|
|
1800
2226
|
autoswitch|as) cmd_autoswitch "$@" ;;
|
|
1801
2227
|
lock) cmd_lock "$@" ;;
|
|
1802
2228
|
unlock) cmd_unlock "$@" ;;
|
|
2229
|
+
warmup) cmd_warmup "$@" ;;
|
|
1803
2230
|
version|--version|-V) cmd_version ;;
|
|
1804
2231
|
update) cmd_update ;;
|
|
1805
2232
|
install) cmd_install ;;
|