@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.
- package/README.md +7 -0
- package/package.json +1 -1
- 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
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:
|
|
@@ -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:
|
|
1013
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
time.sleep(sleep_sec)
|
|
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}{"─"*
|
|
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
|
-
|
|
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 ;;
|