@dst-justin/relay 2.3.0 → 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 +8 -0
  2. package/package.json +1 -1
  3. package/relay +244 -39
package/README.md CHANGED
@@ -255,6 +255,14 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
255
255
 
256
256
  ## Changelog
257
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
+
258
266
  ### v2.3.0 — 2026-07-10
259
267
  - Add scheduled warmup: `relay warmup add/remove/list/pause/resume` to pre-warm a 5hr session at set times
260
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dst-justin/relay",
3
- "version": "2.3.0",
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
@@ -141,6 +141,86 @@ list_account_names() {
141
141
  fi
142
142
  }
143
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}"
222
+ }
223
+
144
224
  account_by_index() {
145
225
  local idx="$1" i=1 name
146
226
  while IFS= read -r name; do
@@ -228,7 +308,24 @@ def reset_in(iso):
228
308
  except Exception:
229
309
  return '—'
230
310
 
231
- 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)
232
329
  if not names:
233
330
  print(f' \033[33m⚠\033[0m No accounts yet. Run: {B}relay add <name>{R}')
234
331
  sys.exit(0)
@@ -731,6 +828,7 @@ cmd_add() {
731
828
  printf '%s' "${kc_creds}" > "$(account_creds "${name}")"
732
829
  chmod 600 "$(account_creds "${name}")"
733
830
  save_meta_email "${name}"
831
+ add_to_order "${name}"
734
832
  echo "${name}" > "${CURRENT_FILE}"
735
833
  ok "Account '${B}${name}${R}' added ${D}$(get_meta_email "${name}")${R}"
736
834
  }
@@ -753,6 +851,7 @@ cmd_save() {
753
851
  [[ ${saved} -eq 0 ]] && { err "No credentials found — log in first with: claude /login"; exit 1; }
754
852
 
755
853
  save_meta_email "${name}"
854
+ add_to_order "${name}"
756
855
  echo "${name}" > "${CURRENT_FILE}"
757
856
  ok "Account '${B}${name}${R}' saved ${D}$(get_meta_email "${name}")${R}"
758
857
  }
@@ -851,6 +950,7 @@ cmd_remove() {
851
950
  read -r c
852
951
  [[ "${c}" = "y" || "${c}" = "Y" ]] || { log "cancelled"; return 0; }
853
952
  rm -f "$(account_creds "${name}")" "$(account_meta "${name}")"
953
+ remove_from_order "${name}"
854
954
  [[ "$(current_name)" == "${name}" ]] && rm -f "${CURRENT_FILE}"
855
955
  ok "Deleted '${name}' (sessions are unaffected)"
856
956
  }
@@ -933,6 +1033,7 @@ cmd_rename() {
933
1033
 
934
1034
  mv "$(account_creds "${old}")" "$(account_creds "${new}")"
935
1035
  [[ -f "$(account_meta "${old}")" ]] && mv "$(account_meta "${old}")" "$(account_meta "${new}")"
1036
+ rename_in_order "${old}" "${new}"
936
1037
  [[ "$(current_name)" == "${old}" ]] && echo "${new}" > "${CURRENT_FILE}"
937
1038
  ok "Renamed '${B}${old}${R}' → '${B}${new}${R}'"
938
1039
  }
@@ -1239,6 +1340,22 @@ def load_raw_config():
1239
1340
  log_event('config_parse_error', error=str(e))
1240
1341
  return {}
1241
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
+
1242
1359
  def load_config():
1243
1360
  try:
1244
1361
  return json.load(open(CONFIG_FILE))
@@ -1246,7 +1363,7 @@ def load_config():
1246
1363
  # Auto-default: 2+ accounts → enable with 80% threshold, no explicit config needed
1247
1364
  if not os.path.isdir(CREDS_DIR):
1248
1365
  return None
1249
- accounts = sorted(f[:-5] for f in os.listdir(CREDS_DIR) if f.endswith('.json'))
1366
+ accounts = _read_order(CREDS_DIR)
1250
1367
  if len(accounts) < 2:
1251
1368
  return None
1252
1369
  return {
@@ -1335,6 +1452,44 @@ if __name__ == '__main__':
1335
1452
  main()
1336
1453
  DAEMON_EOF
1337
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"
1338
1493
  }
1339
1494
 
1340
1495
  cmd_autoswitch_config() {
@@ -1352,41 +1507,7 @@ cmd_autoswitch_config() {
1352
1507
  # ── Step 1: switch order ───────────────────────────────────────
1353
1508
  echo ""
1354
1509
  printf " ${B}Step 1 / 3 — Switch order${R}\n"
1355
- printf " ${D}When an account hits its threshold, relay switches to the next one in order.${R}\n\n"
1356
- local i=1
1357
- for acct in "${accounts[@]}"; do
1358
- printf " ${D}%d${R} %s\n" "${i}" "${acct}"
1359
- i=$((i+1))
1360
- done
1361
- echo ""
1362
- 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"
1363
- printf " > "; read -r order_input
1364
-
1365
- # resolve numbers (space or comma) to names; empty = default order
1366
- local order_str; order_str=$(echo "${order_input}" | "${PY}" - "${accounts[@]}" <<'PYEOF'
1367
- import sys, re
1368
- raw = sys.stdin.read().strip()
1369
- accts = sys.argv[1:]
1370
- if not raw:
1371
- print(','.join(accts))
1372
- else:
1373
- tokens = re.split(r'[\s,]+', raw)
1374
- result = []
1375
- for t in tokens:
1376
- t = t.strip()
1377
- if t.isdigit():
1378
- idx = int(t) - 1
1379
- if 0 <= idx < len(accts): result.append(accts[idx])
1380
- elif t:
1381
- result.append(t)
1382
- print(','.join(result))
1383
- PYEOF
1384
- )
1385
- # show resolved order as a visual chain
1386
- local chain; chain=$(echo "${order_str}" | "${PY}" -c "
1387
- import sys; names=sys.stdin.read().strip().split(',')
1388
- print(' → '.join(names) + ' → (cycle)')")
1389
- printf " ${D}Order: ${CY}%s${R}\n" "${chain}"
1510
+ local order_str; order_str=$(prompt_reorder "${accounts[@]}")
1390
1511
 
1391
1512
  # ── Step 2: thresholds ────────────────────────────────────────
1392
1513
  echo ""
@@ -1684,6 +1805,62 @@ cmd_autoswitch() {
1684
1805
  esac
1685
1806
  }
1686
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
+
1687
1864
  cmd_lock() {
1688
1865
  local name="${1:-}"
1689
1866
  local cfg="${RELAY_DIR}/autoswitch.json"
@@ -1717,7 +1894,19 @@ PYEOF
1717
1894
  "${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
1718
1895
  import json, sys, os
1719
1896
  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'))
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 ''))
1721
1910
  config = {
1722
1911
  'order': accounts,
1723
1912
  'thresholds': {a: 80 for a in accounts},
@@ -1784,7 +1973,19 @@ _warmup_ensure_config() {
1784
1973
  "${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
1785
1974
  import json, sys, os
1786
1975
  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 []
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 ''))
1788
1989
  config = {
1789
1990
  'order': accounts,
1790
1991
  'thresholds': {a: 80 for a in accounts},
@@ -2164,6 +2365,7 @@ cmd_help() {
2164
2365
  printf " %-32s %s\n" " relay refresh-all" "silent OAuth refresh for all accounts"
2165
2366
  printf " %-32s %s\n" " relay save <name>" "save current login state"
2166
2367
  printf " %-32s %s\n" " relay rename <old> <new>" "rename an account"
2368
+ printf " %-32s %s\n" " relay reorder" "change account display/switch order"
2167
2369
  printf " %-32s %s\n" " relay list" "full list with weekly usage"
2168
2370
  printf " %-32s %s\n" " relay list -f" "live-refresh mode (Ctrl+C to exit)"
2169
2371
  printf " %-32s %s\n" " relay list --no-usage" "list without querying API"
@@ -2194,6 +2396,8 @@ cmd_help() {
2194
2396
  _show_update_notice
2195
2397
  }
2196
2398
 
2399
+ _maybe_redeploy_daemon
2400
+
2197
2401
  # ══════════════════════════════════════════════════════════════════
2198
2402
  # Dispatch — single entry point, no fall-through
2199
2403
  # ══════════════════════════════════════════════════════════════════
@@ -2222,6 +2426,7 @@ case "${CMD}" in
2222
2426
  status|st) cmd_status ;;
2223
2427
  remove|rm|del) cmd_remove "$@" ;;
2224
2428
  rename|mv) cmd_rename "$@" ;;
2429
+ reorder) cmd_reorder "$@" ;;
2225
2430
  sessions|sess) cmd_sessions ;;
2226
2431
  autoswitch|as) cmd_autoswitch "$@" ;;
2227
2432
  lock) cmd_lock "$@" ;;