@dst-justin/relay 2.2.5 → 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.
- package/README.md +10 -0
- package/package.json +1 -1
- package/relay +172 -24
package/README.md
CHANGED
|
@@ -223,6 +223,16 @@ 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
|
+
|
|
233
|
+
### v2.2.6 — 2026-07-04
|
|
234
|
+
- `relay status -f` / `relay status --follow`: live-refresh current account status, same 30-second interval as `relay list -f`
|
|
235
|
+
|
|
226
236
|
### v2.2.5 — 2026-07-04
|
|
227
237
|
- `relay list -f` / `relay list --follow`: live-refresh mode — clears the screen and redraws the account table every 30 seconds, Ctrl+C to exit
|
|
228
238
|
|
package/package.json
CHANGED
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
|
-
|
|
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:
|
|
@@ -432,7 +442,7 @@ cmd_list() {
|
|
|
432
442
|
_show_update_notice
|
|
433
443
|
}
|
|
434
444
|
|
|
435
|
-
|
|
445
|
+
_cmd_status_once() {
|
|
436
446
|
_sync_current_creds
|
|
437
447
|
local current; current=$(current_name)
|
|
438
448
|
hdr "Current Status"
|
|
@@ -533,6 +543,29 @@ EOF
|
|
|
533
543
|
[[ -d "${CLAUDE_DIR}/projects" ]] && \
|
|
534
544
|
n=$(find "${CLAUDE_DIR}/projects" -name "*.jsonl" 2>/dev/null | wc -l | tr -d ' ')
|
|
535
545
|
printf "\n ${B}Sessions:${R} %s (shared across all accounts in ~/.claude/projects/)\n" "${n}"
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
cmd_status() {
|
|
549
|
+
local follow=0
|
|
550
|
+
for arg in "$@"; do
|
|
551
|
+
[[ "${arg}" == "-f" || "${arg}" == "--follow" ]] && follow=1
|
|
552
|
+
done
|
|
553
|
+
|
|
554
|
+
if [[ "${follow}" -eq 1 ]]; then
|
|
555
|
+
local interval=30
|
|
556
|
+
trap 'tput cnorm 2>/dev/null; printf "\n \033[2mExited relay status -f\033[0m\n\n"; exit 0' INT TERM
|
|
557
|
+
tput civis 2>/dev/null || true
|
|
558
|
+
while true; do
|
|
559
|
+
printf '\033[2J\033[H'
|
|
560
|
+
printf " ${D}live · refreshes every ${interval}s · Ctrl+C to exit${R}\n\n"
|
|
561
|
+
_cmd_status_once
|
|
562
|
+
printf "\n ${D}updated $(date '+%H:%M:%S')${R}\n"
|
|
563
|
+
sleep "${interval}"
|
|
564
|
+
done
|
|
565
|
+
return
|
|
566
|
+
fi
|
|
567
|
+
|
|
568
|
+
_cmd_status_once
|
|
536
569
|
_check_update_bg
|
|
537
570
|
_show_update_notice
|
|
538
571
|
}
|
|
@@ -975,6 +1008,14 @@ def get_utilization(usage_data):
|
|
|
975
1008
|
u = fh.get('utilization')
|
|
976
1009
|
return int(u) if u is not None else None
|
|
977
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
|
+
|
|
978
1019
|
# ── manual switch protection ───────────────────────────────────────
|
|
979
1020
|
def get_manual_switch():
|
|
980
1021
|
try: return json.load(open(MANUAL_FILE))
|
|
@@ -986,8 +1027,21 @@ def clear_manual_switch():
|
|
|
986
1027
|
|
|
987
1028
|
# ── main loop ─────────────────────────────────────────────────────
|
|
988
1029
|
def load_config():
|
|
989
|
-
try:
|
|
990
|
-
|
|
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
|
+
}
|
|
991
1045
|
|
|
992
1046
|
def main():
|
|
993
1047
|
check_single_instance()
|
|
@@ -1011,6 +1065,7 @@ def main():
|
|
|
1011
1065
|
|
|
1012
1066
|
order = cfg.get('order', [])
|
|
1013
1067
|
thresholds = cfg.get('thresholds', {})
|
|
1068
|
+
locks = cfg.get('locks', [])
|
|
1014
1069
|
poll = cfg.get('poll', {})
|
|
1015
1070
|
low_min = int(poll.get('low_minutes', 10))
|
|
1016
1071
|
high_min = int(poll.get('high_minutes', 2))
|
|
@@ -1038,21 +1093,23 @@ def main():
|
|
|
1038
1093
|
if cur_threshold is None or cur_util is None or cur_util < cur_threshold:
|
|
1039
1094
|
time.sleep(sleep_sec); continue
|
|
1040
1095
|
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
time.sleep(sleep_sec)
|
|
1055
|
-
|
|
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))
|
|
1056
1113
|
log_event('switch', frm=current, to=target, usage=cur_util)
|
|
1057
1114
|
notify('relay', f'switched {current} → {target} ({current} at {cur_util}%)')
|
|
1058
1115
|
do_switch(target)
|
|
@@ -1291,6 +1348,7 @@ cfg = json.load(open(sys.argv[1]))
|
|
|
1291
1348
|
current = sys.argv[2]
|
|
1292
1349
|
order = cfg.get('order', [])
|
|
1293
1350
|
thresholds = cfg.get('thresholds', {})
|
|
1351
|
+
locks = cfg.get('locks', [])
|
|
1294
1352
|
relay_dir = os.path.expanduser('~/.claude-relay')
|
|
1295
1353
|
cache_file = os.path.join(relay_dir, 'usage_cache.json')
|
|
1296
1354
|
|
|
@@ -1309,8 +1367,8 @@ def get_util(name):
|
|
|
1309
1367
|
u = fh.get('utilization')
|
|
1310
1368
|
return int(u) if u is not None else None
|
|
1311
1369
|
|
|
1312
|
-
print(f' {B}{"order":<4} {"account":<14} {"threshold":<12} {"cached usage":<14}{R}')
|
|
1313
|
-
print(f' {D}{"─"*
|
|
1370
|
+
print(f' {B}{"order":<4} {"account":<14} {"threshold":<12} {"cached usage":<14} {"lock":<6}{R}')
|
|
1371
|
+
print(f' {D}{"─"*58}{R}')
|
|
1314
1372
|
for i, name in enumerate(order, 1):
|
|
1315
1373
|
cur = name == current
|
|
1316
1374
|
marker = f'{GR}●{R}' if cur else ' '
|
|
@@ -1330,7 +1388,8 @@ for i, name in enumerate(order, 1):
|
|
|
1330
1388
|
for j in range(i-1)
|
|
1331
1389
|
)
|
|
1332
1390
|
if prev_over: next_s = f' {CY}← next{R}'
|
|
1333
|
-
|
|
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}')
|
|
1334
1393
|
|
|
1335
1394
|
log_file = os.path.join(os.path.dirname(sys.argv[1]), 'autoswitch.log')
|
|
1336
1395
|
try:
|
|
@@ -1349,6 +1408,8 @@ EOF
|
|
|
1349
1408
|
printf " %-34s %s\n" " ${CY}relay autoswitch stop${R}" "stop daemon"
|
|
1350
1409
|
printf " %-34s %s\n" " ${CY}relay autoswitch config${R}" "edit settings"
|
|
1351
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"
|
|
1352
1413
|
_show_update_notice
|
|
1353
1414
|
}
|
|
1354
1415
|
|
|
@@ -1399,6 +1460,87 @@ cmd_autoswitch() {
|
|
|
1399
1460
|
esac
|
|
1400
1461
|
}
|
|
1401
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
|
+
|
|
1402
1544
|
_script_dir() {
|
|
1403
1545
|
# Resolve symlinks so we find package.json even when installed via npm/symlink
|
|
1404
1546
|
local src="$0"
|
|
@@ -1603,8 +1745,9 @@ cmd_help() {
|
|
|
1603
1745
|
printf " %-32s %s\n" " relay save <name>" "save current login state"
|
|
1604
1746
|
printf " %-32s %s\n" " relay rename <old> <new>" "rename an account"
|
|
1605
1747
|
printf " %-32s %s\n" " relay list" "full list with weekly usage"
|
|
1606
|
-
printf " %-32s %s\n" " relay list -f" "live-refresh
|
|
1748
|
+
printf " %-32s %s\n" " relay list -f" "live-refresh mode (Ctrl+C to exit)"
|
|
1607
1749
|
printf " %-32s %s\n" " relay list --no-usage" "list without querying API"
|
|
1750
|
+
printf " %-32s %s\n" " relay status -f" "live-refresh current account status"
|
|
1608
1751
|
printf " %-32s %s\n" " relay remove <name>" "delete an account"
|
|
1609
1752
|
printf " %-32s %s\n" " relay sessions" "show all sessions"
|
|
1610
1753
|
printf " %-32s %s\n" " relay version" "show current version"
|
|
@@ -1615,6 +1758,9 @@ cmd_help() {
|
|
|
1615
1758
|
printf " %-32s %s\n" " relay autoswitch config" "set up auto-switching"
|
|
1616
1759
|
printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
|
|
1617
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"
|
|
1618
1764
|
echo ""
|
|
1619
1765
|
printf " ${D}switches the OAuth credential (macOS Keychain / Linux ~/.claude/.credentials.json); sessions are shared${R}\n"
|
|
1620
1766
|
printf " ${D}after switching: claude -c to resume, claude --resume <id> for a specific session${R}\n\n"
|
|
@@ -1652,6 +1798,8 @@ case "${CMD}" in
|
|
|
1652
1798
|
rename|mv) cmd_rename "$@" ;;
|
|
1653
1799
|
sessions|sess) cmd_sessions ;;
|
|
1654
1800
|
autoswitch|as) cmd_autoswitch "$@" ;;
|
|
1801
|
+
lock) cmd_lock "$@" ;;
|
|
1802
|
+
unlock) cmd_unlock "$@" ;;
|
|
1655
1803
|
version|--version|-V) cmd_version ;;
|
|
1656
1804
|
update) cmd_update ;;
|
|
1657
1805
|
install) cmd_install ;;
|