@dst-justin/relay 2.2.6 → 2.2.7

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 +7 -0
  2. package/package.json +1 -1
  3. package/relay +146 -22
package/README.md CHANGED
@@ -223,6 +223,13 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
223
223
 
224
224
  ## Changelog
225
225
 
226
+ ### v2.2.7 — 2026-07-05
227
+ - `relay lock <name>` / `relay unlock <name>`: lock an account so it won't be cycled back to when over its usage threshold
228
+ - `relay lock` (no args): show locked accounts
229
+ - Autoswitch daemon now auto-enables with default 80% threshold when 2+ accounts exist — no config required
230
+ - Cycling follows `order` sequence; locked+over-threshold accounts are skipped; if all candidates are blocked, stays on current account and notifies
231
+ - Lock badge (🔒) shown in `relay list` and `relay autoswitch status`
232
+
226
233
  ### v2.2.6 — 2026-07-04
227
234
  - `relay status -f` / `relay status --follow`: live-refresh current account status, same 30-second interval as `relay list -f`
228
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dst-justin/relay",
3
- "version": "2.2.6",
3
+ "version": "2.2.7",
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
@@ -136,6 +136,14 @@ mode, creds_dir, meta_dir, current = sys.argv[1], sys.argv[2], sys.argv[3], sys.
136
136
  no_usage = '--no-usage' in sys.argv
137
137
  relay_dir = os.path.dirname(creds_dir)
138
138
  cache_file = os.path.join(relay_dir, 'usage_cache.json')
139
+
140
+ # Load locks from autoswitch config (best-effort, no error if missing)
141
+ _locks = []
142
+ try:
143
+ _as_cfg = json.load(open(os.path.join(relay_dir, 'autoswitch.json')))
144
+ _locks = _as_cfg.get('locks', [])
145
+ except Exception:
146
+ pass
139
147
  CACHE_TTL = 120 # seconds
140
148
 
141
149
  R='\033[0m'; B='\033[1m'; D='\033[2m'
@@ -316,7 +324,8 @@ if mode == 'quick':
316
324
  ncol = GR + B if cur else B
317
325
  email = get_email(name)
318
326
  u = u5_str(usage.get(name)) if not no_usage else ''
319
- print(f' {marker} {ncol}{name:<12}{R} {D}{email:<26}{R} {u}')
327
+ lock_badge = f' {YL}🔒{R}' if name in _locks else ''
328
+ print(f' {marker} {ncol}{name:<12}{R} {D}{email:<26}{R} {u}{lock_badge}')
320
329
  print()
321
330
  print(f' {D}switch:{R} {CY}!relay <index or name>{R} {D}details:{R} {CY}!relay status{R}')
322
331
  print()
@@ -331,8 +340,9 @@ else:
331
340
  d = usage.get(name)
332
341
  u5 = u5_str(d) if not no_usage else '—'
333
342
  u7 = u7_str(d, name) if not no_usage else '—'
343
+ lock_badge = f' {YL}🔒{R}' if name in _locks else ''
334
344
  # 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}')
345
+ print(f' {marker} {D}{i:<2}{R}{ncol}{name:<12}{R} {email:<28} {u5:<52} {u7}{lock_badge}')
336
346
  print()
337
347
  n_warn = sum(1 for d in usage.values() if isinstance(d, dict) and (d.get('five_hour') or {}).get('utilization', 0) >= 80)
338
348
  if n_warn:
@@ -998,6 +1008,14 @@ def get_utilization(usage_data):
998
1008
  u = fh.get('utilization')
999
1009
  return int(u) if u is not None else None
1000
1010
 
1011
+ def is_blocked(name, thr_map, locks_list, usage_map):
1012
+ """True if account is locked AND at or over its threshold — skip as switch target."""
1013
+ if name not in locks_list:
1014
+ return False
1015
+ util = get_utilization(usage_map.get(name))
1016
+ threshold = thr_map.get(name, 80)
1017
+ return util is not None and util >= threshold
1018
+
1001
1019
  # ── manual switch protection ───────────────────────────────────────
1002
1020
  def get_manual_switch():
1003
1021
  try: return json.load(open(MANUAL_FILE))
@@ -1009,8 +1027,21 @@ def clear_manual_switch():
1009
1027
 
1010
1028
  # ── main loop ─────────────────────────────────────────────────────
1011
1029
  def load_config():
1012
- try: return json.load(open(CONFIG_FILE))
1013
- except: return None
1030
+ try:
1031
+ return json.load(open(CONFIG_FILE))
1032
+ except:
1033
+ # Auto-default: 2+ accounts → enable with 80% threshold, no explicit config needed
1034
+ if not os.path.isdir(CREDS_DIR):
1035
+ return None
1036
+ accounts = sorted(f[:-5] for f in os.listdir(CREDS_DIR) if f.endswith('.json'))
1037
+ if len(accounts) < 2:
1038
+ return None
1039
+ return {
1040
+ 'order': accounts,
1041
+ 'thresholds': {a: 80 for a in accounts},
1042
+ 'locks': [],
1043
+ 'poll': {'low_minutes': 10, 'high_minutes': 2, 'high_threshold': 50}
1044
+ }
1014
1045
 
1015
1046
  def main():
1016
1047
  check_single_instance()
@@ -1034,6 +1065,7 @@ def main():
1034
1065
 
1035
1066
  order = cfg.get('order', [])
1036
1067
  thresholds = cfg.get('thresholds', {})
1068
+ locks = cfg.get('locks', [])
1037
1069
  poll = cfg.get('poll', {})
1038
1070
  low_min = int(poll.get('low_minutes', 10))
1039
1071
  high_min = int(poll.get('high_minutes', 2))
@@ -1061,21 +1093,23 @@ def main():
1061
1093
  if cur_threshold is None or cur_util is None or cur_util < cur_threshold:
1062
1094
  time.sleep(sleep_sec); continue
1063
1095
 
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
-
1096
+ # Ordered cycling: walk order[] from current position, skip blocked accounts
1097
+ idx = order.index(current) if current in order else 0
1098
+ target = None
1099
+ for i in range(1, len(order)):
1100
+ candidate = order[(idx + i) % len(order)]
1101
+ if not is_blocked(candidate, thresholds, locks, usage):
1102
+ target = candidate
1103
+ break
1104
+
1105
+ if target is None:
1106
+ # All candidates are locked + over threshold — stay put
1107
+ log_event('all_blocked', current=current)
1108
+ notify('relay', 'All accounts at limit — staying on current account')
1109
+ time.sleep(sleep_sec)
1110
+ continue
1111
+
1112
+ target_util = get_utilization(usage.get(target))
1079
1113
  log_event('switch', frm=current, to=target, usage=cur_util)
1080
1114
  notify('relay', f'switched {current} → {target} ({current} at {cur_util}%)')
1081
1115
  do_switch(target)
@@ -1314,6 +1348,7 @@ cfg = json.load(open(sys.argv[1]))
1314
1348
  current = sys.argv[2]
1315
1349
  order = cfg.get('order', [])
1316
1350
  thresholds = cfg.get('thresholds', {})
1351
+ locks = cfg.get('locks', [])
1317
1352
  relay_dir = os.path.expanduser('~/.claude-relay')
1318
1353
  cache_file = os.path.join(relay_dir, 'usage_cache.json')
1319
1354
 
@@ -1332,8 +1367,8 @@ def get_util(name):
1332
1367
  u = fh.get('utilization')
1333
1368
  return int(u) if u is not None else None
1334
1369
 
1335
- print(f' {B}{"order":<4} {"account":<14} {"threshold":<12} {"cached usage":<14}{R}')
1336
- print(f' {D}{"─"*52}{R}')
1370
+ print(f' {B}{"order":<4} {"account":<14} {"threshold":<12} {"cached usage":<14} {"lock":<6}{R}')
1371
+ print(f' {D}{"─"*58}{R}')
1337
1372
  for i, name in enumerate(order, 1):
1338
1373
  cur = name == current
1339
1374
  marker = f'{GR}●{R}' if cur else ' '
@@ -1353,7 +1388,8 @@ for i, name in enumerate(order, 1):
1353
1388
  for j in range(i-1)
1354
1389
  )
1355
1390
  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}')
1391
+ lock_s = f' {YL}🔒{R}' if name in locks else ''
1392
+ print(f' {marker} {D}{i:<2}{R}{ncol}{name:<14}{R} {thr_s:<12} {util_s}{next_s}{lock_s}')
1357
1393
 
1358
1394
  log_file = os.path.join(os.path.dirname(sys.argv[1]), 'autoswitch.log')
1359
1395
  try:
@@ -1372,6 +1408,8 @@ EOF
1372
1408
  printf " %-34s %s\n" " ${CY}relay autoswitch stop${R}" "stop daemon"
1373
1409
  printf " %-34s %s\n" " ${CY}relay autoswitch config${R}" "edit settings"
1374
1410
  printf " %-34s %s\n" " ${CY}relay autoswitch log${R}" "switch history"
1411
+ printf " %-34s %s\n" " ${CY}relay lock <name>${R}" "lock account (skip when over threshold)"
1412
+ printf " %-34s %s\n" " ${CY}relay unlock <name>${R}" "remove lock"
1375
1413
  _show_update_notice
1376
1414
  }
1377
1415
 
@@ -1422,6 +1460,87 @@ cmd_autoswitch() {
1422
1460
  esac
1423
1461
  }
1424
1462
 
1463
+ cmd_lock() {
1464
+ local name="${1:-}"
1465
+ local cfg="${RELAY_DIR}/autoswitch.json"
1466
+
1467
+ # No argument: show lock status
1468
+ if [[ -z "${name}" ]]; then
1469
+ hdr "Account locks"
1470
+ if [[ ! -f "${cfg}" ]]; then
1471
+ warn "No autoswitch config. Run: relay autoswitch config"
1472
+ return 0
1473
+ fi
1474
+ "${PY}" - "${cfg}" <<'PYEOF'
1475
+ import json, sys
1476
+ cfg = json.load(open(sys.argv[1]))
1477
+ locks = cfg.get('locks', [])
1478
+ R='\033[0m'; B='\033[1m'; D='\033[2m'; YL='\033[33m'
1479
+ if not locks:
1480
+ print(f' {D}No accounts locked.{R}')
1481
+ else:
1482
+ for name in locks:
1483
+ print(f' {YL}🔒{R} {B}{name}{R}')
1484
+ print()
1485
+ PYEOF
1486
+ return 0
1487
+ fi
1488
+
1489
+ account_exists "${name}" || { err "Account '${name}' not found"; exit 1; }
1490
+
1491
+ # Ensure config file exists (create auto-default if missing)
1492
+ if [[ ! -f "${cfg}" ]]; then
1493
+ "${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
1494
+ import json, sys, os
1495
+ 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'))
1497
+ config = {
1498
+ 'order': accounts,
1499
+ 'thresholds': {a: 80 for a in accounts},
1500
+ 'locks': [],
1501
+ 'poll': {'low_minutes': 10, 'high_minutes': 2, 'high_threshold': 50}
1502
+ }
1503
+ json.dump(config, open(cfg_path, 'w'), indent=2)
1504
+ PYEOF
1505
+ ok "Created default autoswitch config: ${cfg}"
1506
+ fi
1507
+
1508
+ "${PY}" - "${cfg}" "${name}" <<'PYEOF'
1509
+ import json, sys
1510
+ cfg_path, name = sys.argv[1], sys.argv[2]
1511
+ cfg = json.load(open(cfg_path))
1512
+ locks = cfg.get('locks', [])
1513
+ if name in locks:
1514
+ print(f' already locked: {name}')
1515
+ sys.exit(0)
1516
+ locks.append(name)
1517
+ cfg['locks'] = locks
1518
+ json.dump(cfg, open(cfg_path, 'w'), indent=2)
1519
+ PYEOF
1520
+ ok "Locked '${B}${name}${R}' — won't be switched back to when over threshold"
1521
+ }
1522
+
1523
+ cmd_unlock() {
1524
+ local name="${1:-}"
1525
+ [[ -z "${name}" ]] && { err "usage: relay unlock <name>"; exit 1; }
1526
+ local cfg="${RELAY_DIR}/autoswitch.json"
1527
+ [[ ! -f "${cfg}" ]] && { warn "No autoswitch config — nothing to unlock"; return 0; }
1528
+
1529
+ "${PY}" - "${cfg}" "${name}" <<'PYEOF'
1530
+ import json, sys
1531
+ cfg_path, name = sys.argv[1], sys.argv[2]
1532
+ cfg = json.load(open(cfg_path))
1533
+ locks = cfg.get('locks', [])
1534
+ if name not in locks:
1535
+ print(f' not locked: {name}')
1536
+ sys.exit(0)
1537
+ locks.remove(name)
1538
+ cfg['locks'] = locks
1539
+ json.dump(cfg, open(cfg_path, 'w'), indent=2)
1540
+ PYEOF
1541
+ ok "Unlocked '${B}${name}${R}'"
1542
+ }
1543
+
1425
1544
  _script_dir() {
1426
1545
  # Resolve symlinks so we find package.json even when installed via npm/symlink
1427
1546
  local src="$0"
@@ -1639,6 +1758,9 @@ cmd_help() {
1639
1758
  printf " %-32s %s\n" " relay autoswitch config" "set up auto-switching"
1640
1759
  printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
1641
1760
  printf " %-32s %s\n" " relay autoswitch status" "daemon state + thresholds"
1761
+ printf " %-32s %s\n" " relay lock <name>" "prevent account from cycling back when over limit"
1762
+ printf " %-32s %s\n" " relay unlock <name>" "remove lock"
1763
+ printf " %-32s %s\n" " relay lock" "show locked accounts"
1642
1764
  echo ""
1643
1765
  printf " ${D}switches the OAuth credential (macOS Keychain / Linux ~/.claude/.credentials.json); sessions are shared${R}\n"
1644
1766
  printf " ${D}after switching: claude -c to resume, claude --resume <id> for a specific session${R}\n\n"
@@ -1676,6 +1798,8 @@ case "${CMD}" in
1676
1798
  rename|mv) cmd_rename "$@" ;;
1677
1799
  sessions|sess) cmd_sessions ;;
1678
1800
  autoswitch|as) cmd_autoswitch "$@" ;;
1801
+ lock) cmd_lock "$@" ;;
1802
+ unlock) cmd_unlock "$@" ;;
1679
1803
  version|--version|-V) cmd_version ;;
1680
1804
  update) cmd_update ;;
1681
1805
  install) cmd_install ;;