@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.
Files changed (3) hide show
  1. package/README.md +40 -0
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dst-justin/relay",
3
- "version": "2.2.7",
3
+ "version": "2.3.0",
4
4
  "description": "Multi-account switcher for Claude Code — instant credential swap across macOS, Linux, and Windows",
5
5
  "bin": {
6
6
  "relay": "./relay.js"
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 sorted, one per line (bash 3.2 compatible)
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
- for f in "${CREDS_STORE}"/*.json; do
76
- [[ -f "${f}" ]] || continue
77
- basename "${f}" .json
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
- cred = os.path.join(CREDS_DIR, name + '.json')
922
- current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
923
- if current and os.path.exists(os.path.join(CREDS_DIR, current + '.json')):
924
- live = kc_read()
925
- if live:
926
- with open(os.path.join(CREDS_DIR, current + '.json'), 'w') as f: f.write(live)
927
- with open(CURRENT_FILE, 'w') as f: f.write(name)
928
- content = open(cred).read()
929
- kc_write(content)
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 save_cache(c):
1131
+ def save_json_atomic(path, data):
937
1132
  try:
938
- with open(CACHE_FILE, 'w') as f: json.dump(c, f)
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
- current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
964
- if name == current:
965
- kc_write(content) # critical: update keychain so do_switch() doesn't clobber
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 ;;