@dst-justin/relay 2.2.7 → 2.3.1

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