@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.
Files changed (3) hide show
  1. package/README.md +47 -0
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dst-justin/relay",
3
- "version": "2.2.6",
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() {
@@ -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
- print(f' {marker} {ncol}{name:<12}{R} {D}{email:<26}{R} {u}')
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
- cred = os.path.join(CREDS_DIR, name + '.json')
912
- current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
913
- if current and os.path.exists(os.path.join(CREDS_DIR, current + '.json')):
914
- live = kc_read()
915
- if live:
916
- with open(os.path.join(CREDS_DIR, current + '.json'), 'w') as f: f.write(live)
917
- with open(CURRENT_FILE, 'w') as f: f.write(name)
918
- content = open(cred).read()
919
- 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)
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 save_cache(c):
1131
+ def save_json_atomic(path, data):
927
1132
  try:
928
- 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)
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
- current = open(CURRENT_FILE).read().strip() if os.path.exists(CURRENT_FILE) else ''
954
- if name == current:
955
- 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
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: return json.load(open(CONFIG_FILE))
1013
- except: return None
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
- candidates = [(n, get_utilization(usage.get(n))) for n in order if n != current]
1065
- under = [(n, u) for n, u in candidates if u is not None and thresholds.get(n) is not None and u < thresholds[n]]
1066
-
1067
- if under:
1068
- target, target_util = under[0]
1069
- else:
1070
- measured = [(n, u) for n, u in candidates if u is not None]
1071
- if not measured:
1072
- time.sleep(sleep_sec); continue
1073
- target, target_util = min(measured, key=lambda x: x[1])
1074
- log_event('all_over_threshold', selected=target, usage=target_util)
1075
- notify('relay', f'All accounts over threshold — switching to {target} ({target_util}%)')
1076
- do_switch(target)
1077
- time.sleep(sleep_sec); continue
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}{"─"*52}{R}')
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
- print(f' {marker} {D}{i:<2}{R}{ncol}{name:<14}{R} {thr_s:<12} {util_s}{next_s}')
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 ;;