#!/usr/bin/env bash
# claude-accounts — manage the claude-multiacc account pool.
# Subcommands: list status add import remove mint sync verify limits post-sync health
#              expired relogin export-credential import-credential
set -u
# lib/audit.py is imported by several subcommands; keep the install tree free of
# __pycache__ (it may be root-owned, read-only, or an npm global prefix).
export PYTHONDONTWRITEBYTECODE=1

_self="$0"
while [ -L "$_self" ]; do
  _t="$(readlink "$_self")"
  case "$_t" in /*) _self="$_t" ;; *) _self="$(dirname "$_self")/$_t" ;; esac
done
BIN_DIR="$(cd "$(dirname "$_self")" && pwd -P)"
REPO_DIR="$(dirname "$BIN_DIR")"
# Pin the provider here, the way codex-accounts pins codex: lib/common.sh defaults to
# claude only when the variable is UNSET, and codex-accounts exports it — so a
# claude-accounts started by codex-accounts (the `mcp --provider both` hop) inherited
# `codex`, became a second codex CLI, hopped to "its sibling" claude-accounts again, and
# forked itself to death ("fork: Resource temporarily unavailable").
export MULTIACC_PROVIDER=claude
# shellcheck source=lib/common.sh
. "$REPO_DIR/lib/common.sh"

usage() {
  cat <<'EOF'
claude-accounts — multi-account pool manager for claude-multiacc

USAGE
  claude-accounts list [--json]             brief account list (--json: machine-readable)
  claude-accounts status [--json]           full health: auth, per-bucket limits, markers,
                                            and whether fresh login shells reach the shim
  claude-accounts add [email] [--token] [--force]
      login-FIRST: runs the full Claude Code login (`claude auth login` — the normal
      browser sign-in, no long-lived-token step-up), then registers only after the
      signed-in email is read back. Email is OPTIONAL (derived from the sign-in).
      Stores an auto-refreshing login valid on THIS machine (.credentials.json, or
      the macOS Keychain when the session can open it). Duplicates refused.
      --token instead mints a portable setup-token (works on Mac AND server; needs a
      recent sign-in — use it when you want the account usable on the server too).
  claude-accounts login <acct-NN> [--token] [--force]
      complete/refresh auth for an existing account (full login, or --token)
  claude-accounts expired [--quiet]
      which accounts CANNOT authenticate (expired refresh token, revoked grant, no
      login on this machine) and why. These are excluded from selection — `claude`
      never runs under them. Exits 1 when any account needs a human. --quiet prints
      bare ids for scripts.
  claude-accounts relogin [acct-NN ...] [--all] [--token] [--yes]
      sign in again, one account at a time. With no arguments it re-authenticates
      exactly what `expired` lists; --all covers every account. Syncs once at the end.
  claude-accounts import <email> [opts]     register an account, optionally with credentials
      --id acct-NN          explicit id (default: next free)
      --home mac|server     which machine owns the OAuth grant (default: this one)
      --creds PATH          existing .credentials.json to adopt
      --mode copy|move|link how to adopt --creds (default copy)
      --token-file PATH|-   long-lived token for server use (- reads stdin)
      --no-sync             skip the automatic server sync
  claude-accounts adopt <acct-NN>           make acct-NN THIS machine's existing
                                            default ~/.claude login (dir symlink —
                                            single credential file, no grant fork)
  claude-accounts export-credential <acct-NN> [--out PATH] [--identity-only]
      print a self-contained JSON blob (credential + identity metadata) for a
      PORTABLE account, i.e. one with a setup-token. REFUSES a machine-local OAuth
      credential (exit 3) — those cannot be copied; sign in on the machine that
      needs them, or run 'mint <acct-NN>' once to make the account portable.
      --identity-only exports registry metadata with NO credential material.
      Exit: 0 ok, 3 machine-local, 4 no credential, 5 unusable material.
  claude-accounts import-credential [acct-NN] [--in PATH|-] [--home mac|server]
                                    [--force] [--no-sync]
      install a blob from export-credential (stdin by default): creates the account
      dir + manifest entry as needed, or refreshes the credential of the account
      that already owns that email. Non-interactive — this is how a daemon
      distributes accounts to a machine.
  claude-accounts remove <acct-NN> [--yes]  delete account (propagates to server)
  claude-accounts dedupe [--yes]            remove any account registered twice
                                            (same email), keeping one per email
  claude-accounts mint <acct-NN>            mint server token via `claude setup-token`
      --paste               paste an already-minted token instead of running setup-token
  claude-accounts sync [--no-server]        push manifest+tokens to the sync target AND
                                            any manifest 'peers' (extra machines). Mac
                                            only. A pool with a 'sync-role' file saying
                                            'replica' never pushes (it receives).
                                            --no-server (or a pool whose target is
                                            'none') keeps sync LOCAL: validate + seed +
                                            fix perms, push nowhere — for pools a panel
                                            or runner daemon distributes.
  claude-accounts verify [--quick]          auth matrix; full mode runs `-p "reply OK"` per account
  claude-accounts limits [--quiet] [--force] [--json]
      refresh usage buckets, apply >=90% markers. When the pool has parked an
      account on a window its Claude Code limit reset (/limit-reset) refills,
      redeems it with an idempotent request — unless every park lifts on its own
      within the hour.
      Auto-refreshes long-expired
      OAuth access tokens via the refresh-token grant (rotated credential is
      persisted), so idle accounts keep fresh telemetry and stay selectable.
      Skips accounts fetched in the last 45s and honors 429/refresh backoff;
      --force ignores all three.
  claude-accounts health                    limits + full verify + shim-on-PATH probe; logs to health.log
  claude-accounts self-update               update this npm/git install; logs to update.log
  claude-accounts post-sync                 (server side) seed dirs, fix perms, quick verify
  claude-accounts mcp add <name> [--provider claude|codex|both] [--scope user|project]
                          [--project PATH] [-e KEY=VAL]... [-H 'Header: v']...
                          [--transport stdio|http] -- <command> [args...]   (or a URL)
      register an MCP server for EVERY account: writes the pool's mcp-servers.json
      registry, reconciles every account dir, syncs the registry to the server and
      peers (their post-sync applies it). Default --provider both: the same server is
      registered in the codex pool too (MCP servers are not provider-specific).
      A stock `claude mcp add|add-json|remove` run under the shim is mirrored into
      the registry the same way — it no longer lands in one random account.
      -e takes ONE variable per flag (KEY=VAL; write $HOME/..., not ~/..., in a value).
      Exit 3 = registry saved, but an account could not be reconciled (it is named on
      stderr): the change still syncs; fix the account and run `mcp apply`.
      On a replica pool the change lands in the machine-local overlay instead of the
      synced registry — make registry changes on the source machine.
  claude-accounts mcp add-json <name> '<json>' [--provider ...] [--scope ...]
  claude-accounts mcp remove <name> [--provider ...] [--user-only] [--scope project --project PATH]
      unregister AND retire EVERYWHERE: every account (fleet-wide after sync) drops the
      server from user scope and from every project entry, even one that was hand-added
      to a single account. --user-only spares project-local copies.
  claude-accounts mcp list [--json]         effective registry (+ machine-local overlay)
  claude-accounts mcp apply [--account-dir DIR]... [--all]
                                            re-apply the registry now (repair drift)
  claude-accounts mcp import-local --owner NAME   (stdin JSON) machine-local overlay for
                                            a runner daemon; applied here, never synced

ENV
  CLAUDE_ACCOUNTS_ROOT  pool root, overriding ~/.claude-accounts — one isolated pool
                        per app-robot instance on a shared machine (legacy spelling
                        CLAUDE_ACCOUNTS_DIR still works)
  CLAUDE_MULTIACC_SYNC_TARGET  sync target (user@host), overriding the manifest;
                        'none' = local-only, nothing is pushed anywhere
  CLAUDE_MULTIACC_SYNC_ROOT / _SYNC_REPO   remote pool root / addon repo for it
  CLAUDE_ACCOUNT        pin the shim to one account
  CLAUDE_SHIM_RETRY=0   disable -p auto-retry
  CLAUDE_MULTIACC_DISABLE=1  bypass the shim entirely
  CLAUDE_MULTIACC_AUTO_RESET=0  disable automatic limit-reset redemption
  CLAUDE_MULTIACC_MCP=0  disable the MCP registry (no reconcile at seed/launch, no mirror)
  CLAUDE_MULTIACC_RESET_MIN_HORIZON  a reset is held while every park lifts on
                        its own within this many seconds (default 3600)
EOF
}

require_manifest() { [ -f "$MANIFEST" ] || die "no manifest at $MANIFEST — run claude-multiacc install first"; }

next_id() {
  local n=1 id
  while :; do
    id="$(printf 'acct-%02d' "$n")"
    if [ ! -d "$ACC_ROOT/$id" ] && ! account_ids | grep -qx "$id"; then
      printf '%s\n' "$id"
      return 0
    fi
    n=$((n+1))
    [ "$n" -gt 99 ] && die "no free account slot"
  done
}

manifest_add_account() { # id email home [added_at]
  "$PYBIN" - "$MANIFEST" "$1" "$2" "$3" "${4:-}" <<'PYEOF'
import json, sys, time
path, aid, email, home, added_at = sys.argv[1:6]
doc = json.load(open(path))
accounts = [a for a in doc.get('accounts', []) if a['id'] != aid]
accounts.append({
    'id': aid,
    'email': email,
    'home': home,
    # An imported account keeps the added_at it was registered with on the machine it
    # came from, so the same account reads identically across the fleet.
    'added_at': added_at or time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
})
accounts.sort(key=lambda a: a['id'])
doc['accounts'] = accounts
import os
with open(path + '.tmp', 'w') as f:
    json.dump(doc, f, indent=2)
    f.write('\n')
os.replace(path + '.tmp', path)
PYEOF
}

email_owner() { # prints the id that owns <email>, empty if unregistered
  [ -f "$MANIFEST" ] || return 0
  "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
import json, sys
for a in json.load(open(sys.argv[1])).get('accounts', []):
    if a.get('email', '').lower() == sys.argv[2].lower():
        print(a['id'])
        break
PYEOF
}

manifest_email_of() { # prints the email registered for <id>, empty if unknown
  [ -f "$MANIFEST" ] || return 0
  "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
import json, sys
for a in json.load(open(sys.argv[1])).get('accounts', []):
    if isinstance(a, dict) and a.get('id') == sys.argv[2]:
        print(a.get('email', ''))
        break
PYEOF
}

manifest_del_account() { # id
  "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
import json, sys
path, aid = sys.argv[1:3]
doc = json.load(open(path))
doc['accounts'] = [a for a in doc.get('accounts', []) if a['id'] != aid]
import os
with open(path + '.tmp', 'w') as f:
    json.dump(doc, f, indent=2)
    f.write('\n')
os.replace(path + '.tmp', path)
PYEOF
}

auto_sync() { # best effort after mutations, Mac only, loud on failure
  [ "${CLAUDE_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
  # Local-only pool (target 'none'): there is nothing to push — the panel/runner
  # daemon distributes accounts — so a mutation must not warn about a missing server.
  sync_target_is_local "$(sync_target)" && return 0
  [ "$(machine_kind)" = "mac" ] || return 0
  # A replica pool never pushes (the source machine owns the account set) —
  # silently, so every mutation on a replica does not nag about it.
  sync_is_replica && return 0
  # subshell: cmd_sync exits on failure and must not take the CLI down with it
  ( cmd_sync ) || warn "server sync failed — run 'claude-accounts sync' manually"
}

cmd_list() {
  require_manifest
  local json=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --json) json=1; shift ;;
      *) die "unknown option: $1 (usage: claude-accounts list [--json])" ;;
    esac
  done
  if [ "$json" = "1" ]; then emit_report_json list; return $?; fi
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
import json, os, re, sys
doc = json.load(open(sys.argv[1]))
root = sys.argv[2]
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
from audit import audit_account   # noqa: E402  (shared with the shim's rule)


def superseded_by_reset(d):
    """lib/report.py's rule (and the shim's reset_supersedes_marker): a park written
    before a confirmed limit reset, for a window it refilled, no longer binds."""
    try:
        import claude_reset, time as _time
        with open(os.path.join(d, '.limited'), errors='replace') as handle:
            text = handle.read(4096)
        try:
            ldoc = json.load(open(os.path.join(d, 'limits.json')))
        except Exception:
            ldoc = {}
        return claude_reset.marker_superseded(text, claude_reset.record_from(ldoc, _time.time()))
    except Exception:
        return False
machine = sys.argv[4]
# Only render well-formed ids — a hand-edited manifest must not surface a traversal id.
accounts = [a for a in doc.get('accounts', [])
            if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
if not accounts:
    print('(no accounts yet)')
for a in accounts:
    d = os.path.join(root, a['id'])
    auth = []
    st = audit_account(root, a, machine=machine)
    if st.get('store') == 'file':
        auth.append('oauth')
    elif st.get('store') == 'keychain':
        auth.append('keychain' if st['state'] != 'locked' else 'keychain(locked)')
    if os.path.getsize(os.path.join(d, 'server.token')) > 0 if os.path.isfile(os.path.join(d, 'server.token')) else False:
        auth.append('token')
    limited = os.path.isfile(os.path.join(d, '.limited'))
    if limited and superseded_by_reset(d):
        limited = False   # written before a confirmed limit reset; the shim lifts it
    flags = []
    if st['state'] == 'expired':
        flags.append('EXPIRED-LOGIN')
    elif st['state'] == 'token-invalid':
        flags.append('INVALID-TOKEN')
    elif st['state'] == 'blocked':
        flags.append('ORG-BLOCKED')
    elif st['state'] == 'missing':
        flags.append('NO-LOGIN')
    elif st['state'] == 'locked':
        flags.append('KEYCHAIN-LOCKED-HERE')
    if limited:
        flags.append('LIMITED')
    print(f"{a['id']}  {a['email']:<28} home={a.get('home','?'):<7} "
          f"auth={'+'.join(auth) or 'NONE':<11} {' '.join(flags)}")
bad = [a for a in accounts
       if audit_account(root, a, machine=machine)['state']
       in ('expired', 'token-invalid', 'blocked', 'missing')]
if bad:
    print()
    print(f"{len(bad)} account(s) are NOT usable — details: claude-accounts expired")
seen = {}
for a in accounts:
    seen.setdefault(a.get('email', '').lower(), []).append(a['id'])
dups = {e: ids for e, ids in seen.items() if len(ids) > 1}
if dups:
    print()
    for e, ids in dups.items():
        print(f"WARNING: {e} is registered {len(ids)}x ({', '.join(ids)}) — run 'claude-accounts dedupe'")
PYEOF
}

# A fresh login shell that never reaches the shim is the one failure the pool cannot
# see from the inside — the launch simply happens elsewhere (my-mini 2026-09-17: an
# interrupted ~/.zshrc, and every `bash -l`, resolved the real binary and opened on the
# un-pooled ~/.claude login). lib/shim_path.py starts each login shell with this user's
# rc files and asks. Off with CLAUDE_MULTIACC_PATH_PROBE=0 — the sandboxed suites run
# under the operator's real HOME and must not depend on its rc files.
shim_path_report() {
  # The module honours the kill switch itself and says so in its output, so a status
  # or health.log read with the probe off never looks like a probe that passed.
  "$PYBIN" "$LIB_DIR/shim_path.py" "$REPO_DIR"
}

cmd_status() {
  require_manifest
  local json=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --json) json=1; shift ;;
      *) die "unknown option: $1 (usage: claude-accounts status [--json])" ;;
    esac
  done
  if [ "$json" = "1" ]; then emit_report_json status; return $?; fi
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
import json, os, sys, time
doc = json.load(open(sys.argv[1]))
root = sys.argv[2]
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
from audit import audit_account, oauth_login   # noqa: E402  (shared with the shim's rule)


def superseded_by_reset(d):
    """lib/report.py's rule (and the shim's reset_supersedes_marker): a park written
    before a confirmed limit reset, for a window it refilled, no longer binds."""
    try:
        import claude_reset, time as _time
        with open(os.path.join(d, '.limited'), errors='replace') as handle:
            text = handle.read(4096)
        try:
            ldoc = json.load(open(os.path.join(d, 'limits.json')))
        except Exception:
            ldoc = {}
        return claude_reset.marker_superseded(text, claude_reset.record_from(ldoc, _time.time()))
    except Exception:
        return False
machine = sys.argv[4]
now = time.time()
# Must match the shim's window (bin/claude), or status would call data "fresh" that
# selection has already stopped ranking on. Same fallback as the shim for a garbled
# override: a bad env var must never be the thing that stops status from printing.
try:
    STALE_AFTER = int(os.environ.get('CLAUDE_MULTIACC_STALE_AFTER') or 3600)
except ValueError:
    STALE_AFTER = 3600
if STALE_AFTER <= 0:
    STALE_AFTER = 3600

def telem_fetched_at(aid):
    """Epoch of aid's last SUCCESSFUL usage fetch, or 0. Never raises: this file is
    hand-editable and syncs between machines, and one corrupt copy must not stop
    status from printing the pool-wide verdict it exists to show."""
    try:
        v = json.load(open(os.path.join(root, aid, 'limits.json'))).get('fetched_at', 0)
    except Exception:
        return 0
    return v if isinstance(v, (int, float)) and not isinstance(v, bool) and v > 0 else 0

def last_pick(aid):
    path = os.path.join(root, 'selection.log')
    if not os.path.isfile(path):
        return '-'
    last = '-'
    try:
        with open(path, errors='replace') as f:
            for line in f:
                parts = line.split()
                if len(parts) >= 2 and parts[1] == aid:
                    last = parts[0]
    except Exception:
        pass
    return last

print(f"pool root : {root}")
print(f"server    : {doc.get('server','-')}  (root: {doc.get('server_root','-')})")
print(f"threshold : {doc.get('threshold', 90)}% (any bucket at/above => account excluded)")
print()
needs_login = []
selectable_ids = []
for a in doc.get('accounts', []):
    aid = a['id']
    d = os.path.join(root, aid)
    st = audit_account(root, a, machine=machine)
    if st['state'] in ('expired', 'token-invalid', 'blocked', 'missing'):
        needs_login.append(aid)
    else:
        selectable_ids.append(aid)
    banner = {'ok': '', 'remote': '  [not logged in here — grant lives elsewhere]',
              'missing': '  ** NO LOGIN — claude-accounts relogin %s **' % aid,
              'expired': '  ** LOGIN EXPIRED — claude-accounts relogin %s **' % aid,
              'token-invalid': ('  ** SETUP-TOKEN INVALID — '
                                'claude-accounts login %s --token **' % aid),
              'blocked': '  ** ORG BLOCKED — Claude Code disabled for this account **',
              'locked': '  [login is in the macOS Keychain — locked for THIS session, fine from the Mac itself]',
              }[st['state']]
    print(f"{aid}  {a['email']}  [home={a.get('home','?')}]{banner}")
    print(f"  selectable  : {'yes' if st['state'] == 'ok' else 'NO — ' + st['reason']}")
    cpath = os.path.join(d, '.credentials.json')
    login = oauth_login(d, now)
    if login['store'] == 'file' or login['doc'] is not None:
        try:
            c = (login['doc'] or json.load(open(cpath))).get('claudeAiOauth', {})
            exp = c.get('expiresAt', 0) / 1000.0
            rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
            state = 'fresh' if exp > now else 'stale (auto-refreshes on use)'
            rstate = 'ok' if rexp > now else 'EXPIRED — re-login needed'
            where = 'macOS Keychain' if login['store'] == 'keychain' else '.credentials.json'
            print(f"  oauth creds : {state}; refresh token {rstate} (until {time.strftime('%Y-%m-%d', time.gmtime(rexp)) if rexp else '?'}) [{where}]")
        except Exception as e:
            print(f"  oauth creds : unreadable ({e})")
    elif login['store'] == 'keychain':
        print(f"  oauth creds : in the macOS Keychain — {login['reason']}")
    else:
        print("  oauth creds : none on this machine")
    tpath = os.path.join(d, 'server.token')
    if os.path.isfile(tpath) and os.path.getsize(tpath) > 0:
        age_d = int((now - os.path.getmtime(tpath)) / 86400)
        note = ' — NEARING 1y LIFETIME, re-mint soon' if age_d > 330 else ''
        print(f"  server token: present (minted ~{age_d}d ago){note}")
    else:
        print("  server token: none")
    lpath = os.path.join(d, 'limits.json')
    if os.path.isfile(lpath):
        try:
            lim = json.load(open(lpath))
            if not isinstance(lim, dict):
                raise ValueError('not an object')
            fetched = telem_fetched_at(aid)
            age = int(now - fetched) if fetched else None
            parts = [f"{b['name']}={b['percent']}%" for b in lim.get('buckets', [])]
            if age is None:
                shown = 'never fetched'
            elif age >= 86400:
                shown = f'{age // 86400}d {age % 86400 // 3600}h old'
            elif age >= 3600:
                shown = f'{age // 3600}h {age % 3600 // 60}m old'
            else:
                shown = f'{age}s old'
            # Loud, because stale numbers do not look stale: they look like a healthy
            # account sitting at 2%. Past the ranking window the shim ignores them
            # entirely and picks at random, so the reading below is decoration.
            stale = age is None or age > STALE_AFTER
            flag = '  << STALE — NOT USED FOR RANKING' if stale else ''
            # A no_data document (all-zero buckets with no reset windows, 2026-09-04)
            # carries no percent fields at all. Printing "max None%" would read like a
            # healthy account sitting at zero — the exact misreading that handed 31 of
            # ~60 picks to two exhausted accounts — so name the state instead.
            peak = lim.get('max_percent')
            usable = isinstance(peak, (int, float)) and not isinstance(peak, bool)
            head = f'max {peak}%' if usable else 'NO USABLE TELEMETRY — ranks as unknown'
            print(f"  limits      : {'  '.join(parts) or '(none)'}  [{shown}, {head}]{flag}")
            err = lim.get('last_error')
            if isinstance(err, str) and err:
                when = lim.get('last_error_at')
                ago = f' ({int(now - when)}s ago)' if isinstance(when, (int, float)) else ''
                print(f"  telemetry   : last fetch FAILED{ago}: {err}")
                retry = lim.get('retry_after', 0)
                if isinstance(retry, (int, float)) and retry > now:
                    print(f"                retrying in {int(retry - now)}s")
        except Exception as e:
            print(f"  limits      : unreadable ({e})")
    else:
        print("  limits      : never fetched")
    mpath = os.path.join(d, '.limited')
    if os.path.isfile(mpath) and superseded_by_reset(d):
        print("  marker      : lifted — written before a confirmed limit reset (eligible)")
    elif os.path.isfile(mpath):
        try:
            lines = open(mpath).read().splitlines()
            reset = int(lines[0]) if lines and lines[0].isdigit() else 0
            detail = lines[1] if len(lines) > 1 else ''
            mins = max(0, int((reset - now) / 60))
            print(f"  marker      : LIMITED ({detail}) — clears in ~{mins}m")
        except Exception:
            print("  marker      : LIMITED (unreadable marker)")
    else:
        print("  marker      : none (eligible)")
    print(f"  last picked : {last_pick(aid)}")
    print()
if needs_login:
    print(f"{len(needs_login)} account(s) are EXCLUDED from selection: {', '.join(needs_login)}")
    print("What each one needs: claude-accounts expired")
# Pool-wide verdict. Per-account ages are easy to skim past; "the pool is picking at
# random" is not. This is the line that would have caught an eleven-day outage on the
# first day someone ran status.
# The verdict comes from lib/report.py so this text, `--json`, and the shim's own
# ranking can never drift apart — a status that disagrees with selection is worse than
# no status at all.
try:
    from report import telemetry_state, build   # noqa: E402
    doc_rows = build(root, 'claude', machine, 'status')['accounts']
    verdict = telemetry_state(doc_rows, now)
except Exception as e:
    verdict = 'unknown'
    verdict_err = str(e)[:200]
if verdict == 'unknown':
    # Silence here would recreate the observability half of the incident: a blind pool
    # that says nothing. Say the verdict could not be computed, and why.
    print(f"RANKING STATE UNKNOWN: could not compute the pool-wide telemetry verdict "
          f"({verdict_err}). Check the per-account ages above by hand.")
elif verdict == 'blind':
    # Two ways to be blind, and they take OPPOSITE advice. STALE: the fetches stopped,
    # so fetch again and, if that keeps failing, log in — the eleven-day 2026-08 outage.
    # CURRENT-BUT-UNUSABLE: the endpoint answered inside the window and said nothing
    # rankable (a no_data document — all-zero buckets with no reset window, 2026-09-04 —
    # or a reading carrying only one of the two percentages). There the credential is
    # working perfectly; telling the operator to re-login sends them after a fault that
    # does not exist, and --force just re-asks for the same emptiness. The candidate
    # rule below is telemetry_state's own, so this text can never name a state the
    # verdict did not come from.
    usable_rows = [a for a in doc_rows if a.get('status') in ('active', 'limited')]
    cands = [a for a in usable_rows if a.get('status') == 'active'] or usable_rows
    current = [a for a in cands if (a.get('usage') or {})
               and not (a.get('usage') or {}).get('stale')]
    nodata = [a['id'] for a in current if (a.get('usage') or {}).get('no_data')]
    if current:
        print("RANKING IS BLIND: usage telemetry is INSIDE the "
              f"{STALE_AFTER}s window but carries no reading the shim can rank on, so "
              "every account scores the same and `claude` picks at RANDOM — including "
              "accounts that are nearly out of weekly headroom.")
        if nodata:
            print("  why : the usage endpoint returned no usable data for "
                  f"{', '.join(nodata)} — all-zero buckets with no reset window, which "
                  'the writer records as "no_data": true rather than as 0% usage. '
                  "Those fetches authenticated; a re-login does NOT fix this.")
        else:
            print("  why : the readings are incomplete — ranking needs BOTH a weekly and "
                  "a session percentage (see the 'limits' lines above); an account with "
                  "only one of them is unknown to selection, exactly as if it had none.")
        print("  fix : nothing local to repair — the endpoint has to answer with real "
              "buckets again. `claude-accounts limits --force` re-asks; while it keeps "
              "answering this way, selection stays random.")
    else:
        print("RANKING IS BLIND: no account has usage telemetry inside the "
              f"{STALE_AFTER}s window, and the last readings are too old to mean anything, "
              "so every account scores the same and `claude` picks at RANDOM — including "
              "accounts that are nearly out of weekly headroom.")
        print("  why : see the 'telemetry' lines above (a setup token cannot read the usage "
              "endpoint — it has no user:profile scope; only an OAuth login on this machine can)")
        print("  fix : claude-accounts limits --force   # then, if it still fails:")
        print("        claude-accounts login <acct-NN>  # per account, on THIS machine")
elif verdict == 'degraded':
    print("RANKING IS DEGRADED: no account has telemetry inside the "
          f"{STALE_AFTER}s window, so `claude` is ranking on the last readings whose "
          "weekly bucket has not reset yet. Better than random, but it cannot see usage "
          "since those readings were taken.")
    print("  fix : claude-accounts limits --force   # then, if it still fails:")
    print("        claude-accounts login <acct-NN>  # per account, on THIS machine")
PYEOF
  echo
  shim_path_report || true
}

cmd_add() {
  # Login-FIRST: the account is registered (and synced) only after sign-in succeeds
  # and the authenticated email is read back from the account itself. The email
  # argument is OPTIONAL — omit it and it's derived from whoever you sign in as.
  # An aborted or failed sign-in leaves zero traces.
  require_manifest
  local email="" force=0 token=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --force) force=1; shift ;;
      --token) token=1; shift ;;   # portable setup-token instead of full login (server use)
      --tui) shift ;;              # accepted for compatibility; full login is now the default
      --*) die "unknown option: $1" ;;
      *)
        if [ -z "$email" ]; then email="$1"; shift
        else die "unexpected argument: $1 (usage: claude-accounts add [email] [--token] [--force])"; fi ;;
    esac
  done
  # If an email was named and it's already in the pool, SKIP before any sign-in — no
  # duplicate is ever created. (A graceful skip, exit 0: adding an existing account is
  # a no-op, not an error. Re-auth an existing account with 'login'.)
  local owner
  if [ -n "$email" ]; then
    owner="$(email_owner "$email")"
    if [ -n "$owner" ]; then
      echo "$email is already added as $owner — skipping (nothing to do; run 'claude-accounts login $owner' to re-authenticate it)."
      return 0
    fi
  fi
  # A setup token reports no identity, so an unnamed --token add can never attribute the
  # account. Refuse BEFORE the ceremony: running it first would mint a real 1-year grant
  # on the account's behalf and then throw the token away, leaving a live credential
  # issued for nothing.
  if [ "$token" = "1" ] && [ -z "$email" ]; then
    die "a setup token carries no identity, so the account cannot be read back — name it: claude-accounts add <email> --token"
  fi
  if [ ! -t 0 ] && [ -z "${CLAUDE_MULTIACC_FORCE_TTY:-}" ]; then
    die "add is interactive (it completes sign-in before registering) — run it from a terminal"
  fi
  local real
  real="$(find_real_claude "$_self")" || die "real claude binary not found"

  # Reserve a unique id under a BRIEF lock (creating the dir claims the id, so a parallel
  # add gets the next one). The long browser sign-in below runs WITHOUT the lock, so
  # several `add` in different terminals proceed in parallel. RESERVED_DIR drives a trap
  # that removes the half-made dir on any failure/abort BEFORE registration.
  local id d got elabel
  elabel="${email:-the account you sign in as}"
  RESERVED_DIR=""
  trap 'add_cleanup_reserved; exit 130' INT TERM
  trap 'add_cleanup_reserved' EXIT
  mutate_lock || die "could not acquire the account lock (another op is stuck?) — try again"
  id="$(next_id)"
  d="$ACC_ROOT/$id"
  seed_account_dir "$d"
  RESERVED_DIR="$d"
  mutate_unlock

  if [ "$token" = "1" ]; then
    # Portable setup-token variant (works on Mac AND server; needs a recent sign-in).
    echo "Preparing $id for $elabel (portable token) — NOTHING is registered until sign-in completes."
    run_token_ceremony "$d" || die "sign-in failed or aborted — nothing was created${CEREMONY_LAST_WORDS:+ — the client said: $CEREMONY_LAST_WORDS}${CEREMONY_TRANSCRIPT:+ (transcript: $CEREMONY_TRANSCRIPT)}"
    commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
    got="$(token_email "$CEREMONY_TOKEN")"
  else
    # Default: full Claude Code login (full scopes, no long-lived-token step-up).
    # Leaves an auto-refreshing login valid on THIS machine — .credentials.json, or
    # the macOS Keychain when this session can open it (see lib/keychain.py).
    echo "Preparing $id for $elabel — NOTHING is registered until login completes."
    run_login_ceremony "$d" "$email" || die "login failed or aborted — nothing was created"
    got="$(config_dir_email "$d")"
  fi
  if [ -z "$got" ]; then
    if [ "$token" = "1" ] && [ -n "$email" ]; then
      # A setup token NEVER reports an identity (see token_email) — that is the grant's
      # design, not a read that failed, so demanding --force here demanded a ceremony
      # nobody can perform: it made the documented `add <email> --token` path die every
      # single time. The named email is all there is; say so plainly and register it.
      warn "a setup token carries no identity — registering as $email, the account you were asked to approve as"
      got="$email"
    elif [ -n "$email" ] && [ "$force" = "1" ]; then
      warn "identity unverified — registering as $email because --force was given"
      got="$email"
    else
      die "signed in, but the account identity could not be read back — nothing was created (retry; or 'add <email> --force' to trust a named email)"
    fi
  fi
  # Dedup + register under a BRIEF lock, re-reading the manifest (a parallel add may have
  # registered the same email meanwhile — the loser skips gracefully). This is the
  # guarantee that a code pasted for an already-added account never yields a second entry.
  mutate_lock || die "could not acquire the account lock — try again"
  owner="$(email_owner "$got")"
  if [ -n "$owner" ]; then
    mutate_unlock
    echo "$got is already added as $owner — skipping (nothing added)."
    return 0   # RESERVED_DIR still set -> trap removes the temp dir
  fi
  if [ -n "$email" ] && [ "$got" != "$email" ]; then
    warn "signed in as $got (you named $email) — registering the account that actually authenticated"
  fi
  manifest_add_account "$id" "$got" "$(machine_kind)"
  clear_auth_markers "$d"
  mutate_unlock
  RESERVED_DIR=""            # committed — the trap must not delete it now
  trap - EXIT INT TERM
  # Only the full-login path actually READ the identity back. Saying "verified" for a
  # setup token would relaunder the very assumption this flow just warned about.
  local verdict="sign-in verified"
  if [ "$token" = "1" ]; then
    verdict="identity unverifiable — trusted as the name you gave"
    log_to ops.log "add $id $got (token; identity unverifiable)"
  else
    log_to ops.log "add $id $got (auth-verified)"
  fi
  auto_sync
  cat <<EOF
Registered $id for $got ($verdict) — usable immediately.
Optional:
  claude-accounts verify       # confirm the 100% matrix
EOF
}

add_cleanup_reserved() {
  # EXIT trap for cmd_add: remove a reserved-but-uncommitted account dir (and, in case
  # we died mid-critical-section, release the lock). A ceremony interrupted mid-way
  # must also hand the terminal back at its own width.
  restore_ceremony_tty 2>/dev/null || true
  [ -n "${RESERVED_DIR:-}" ] && rm -rf "$RESERVED_DIR" 2>/dev/null
  mutate_unlock 2>/dev/null || true
}

cmd_import() {
  require_manifest
  local email="${1:-}"
  [ -n "$email" ] || die "usage: claude-accounts import <email> [--id acct-NN] [--home mac|server] [--creds PATH] [--mode copy|move|link] [--token-file PATH|-] [--no-sync]"
  shift
  local id="" home="" creds="" mode="copy" token_file="" no_sync=0 force=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --id) id="$2"; shift 2 ;;
      --home) home="$2"; shift 2 ;;
      --creds) creds="$2"; shift 2 ;;
      --mode) mode="$2"; shift 2 ;;
      --token-file) token_file="$2"; shift 2 ;;
      --no-sync) no_sync=1; shift ;;
      --force) force=1; shift ;;
      *) die "unknown option: $1" ;;
    esac
  done
  [ -n "$id" ] || id="$(next_id)"
  [ -n "$home" ] || home="$(machine_kind)"
  case "$id" in acct-[0-9][0-9]) ;; *) die "id must look like acct-NN" ;; esac
  local owner
  owner="$(email_owner "$email")"
  if [ -n "$owner" ] && [ "$owner" != "$id" ] && [ "$force" != "1" ]; then
    die "$email is already registered as $owner — use --id $owner to update it, or --force to register a duplicate"
  fi
  # Validate ALL inputs before creating anything, so a bad token/creds path leaves no
  # half-made account dir behind.
  [ -n "$creds" ] && { [ -f "$creds" ] || die "credentials file not found: $creds"; }
  case "$mode" in copy|move|link) ;; *) die "mode must be copy|move|link" ;; esac
  local tok=""
  if [ -n "$token_file" ]; then
    if [ "$token_file" = "-" ]; then
      tok="$(tr -d '[:space:]')"
    else
      [ -f "$token_file" ] || die "token file not found: $token_file"
      tok="$(tr -d '[:space:]' < "$token_file")"
    fi
    valid_subscription_token "$tok" \
      || die "not a subscription setup-token (sk-ant-oat...). API keys are not supported."
  fi
  local d="$ACC_ROOT/$id"
  seed_account_dir "$d"
  if [ -n "$creds" ]; then
    case "$mode" in
      copy) cp "$creds" "$d/.credentials.json" ;;
      move) mv "$creds" "$d/.credentials.json" ;;
      link) ln -sf "$(canon_path "$creds")" "$d/.credentials.json" ;;
    esac
    chmod 600 "$d/.credentials.json" 2>/dev/null || true
  fi
  if [ -n "$tok" ]; then
    ( umask 077; printf '%s' "$tok" > "$d/server.token" )
    chmod 600 "$d/server.token"
  fi
  manifest_add_account "$id" "$email" "$home"
  log_to ops.log "import $id $email home=$home creds=${creds:+yes} mode=$mode token=${token_file:+yes}"
  echo "Imported $id ($email, home=$home)."
  [ "$no_sync" = "1" ] || auto_sync
}

# Prints duplicate account ids to REMOVE, one per line: for every email that appears
# more than once, keep exactly one (prefer an account that has auth on this machine,
# then the lowest id) and list the rest. Empty output => pool is already clean.
dup_ids_to_remove() {
  [ -f "$MANIFEST" ] || return 0
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" <<'PYEOF' 2>/dev/null
import json, os, re, sys
manifest, root = sys.argv[1], sys.argv[2]
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
import keychain  # noqa: E402  (macOS Keychain-held logins; a no-op elsewhere)
accts = [a for a in json.load(open(manifest)).get('accounts', [])
         if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
def has_auth(aid):
    d = os.path.join(root, aid)
    c = os.path.join(d, '.credentials.json')
    t = os.path.join(d, 'server.token')
    if (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0):
        return True
    # A login the client moved into the macOS Keychain still counts — deduping must
    # not throw away the one duplicate that actually holds the grant.
    try:
        return keychain.probe(d)['state'] in ('present', 'locked', 'corrupt')
    except Exception:
        return False
by_email = {}
for a in accts:
    by_email.setdefault(a.get('email', '').lower(), []).append(a['id'])
for email, ids in by_email.items():
    if len(ids) < 2:
        continue
    # keep: authed first, then lowest id
    keep = sorted(ids, key=lambda i: (not has_auth(i), i))[0]
    for i in ids:
        if i != keep:
            print(i)
PYEOF
}

cmd_dedupe() {
  require_manifest
  local yes=0
  [ "${1:-}" = "--yes" ] && yes=1
  local dups
  dups="$(dup_ids_to_remove)"
  if [ -z "$dups" ]; then
    echo "No duplicate accounts — every email appears once."
    return 0
  fi
  echo "Duplicate accounts (same email registered more than once):"
  local id email
  for id in $dups; do
    email="$("$PYBIN" - "$MANIFEST" "$id" <<'PYEOF'
import json, sys
for a in json.load(open(sys.argv[1])).get('accounts', []):
    if a.get('id') == sys.argv[2]: print(a.get('email', '')); break
PYEOF
)"
    echo "  will remove $id ($email)"
  done
  if [ "$yes" != "1" ]; then
    printf 'Remove these duplicates (keeps one per email)? [y/N] '
    read -r ans
    case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
  fi
  for id in $dups; do
    if [ -L "$ACC_ROOT/$id" ]; then
      rm -f "${ACC_ROOT:?}/${id:?}"
    else
      # Same rule as cmd_remove: a removed dir's Keychain login must go with it.
      keychain_forget "$ACC_ROOT/$id"
      rm -rf "${ACC_ROOT:?}/${id:?}"
    fi
    manifest_del_account "$id"
    log_to ops.log "dedupe removed $id"
  done
  echo "Removed $(printf '%s\n' "$dups" | grep -c .) duplicate account(s)."
  auto_sync
}

cmd_adopt() {
  # Make <acct-NN> THIS machine's existing default login (~/.claude) without
  # forking its OAuth grant: the account dir becomes a symlink to ~/.claude, so
  # there is exactly one credential file no matter which path refreshes it.
  require_manifest
  local id="${1:-}"
  [ -n "$id" ] || die "usage: claude-accounts adopt <acct-NN>"
  valid_acct_id "$id" || die "not a valid account id: $id"
  account_ids | grep -qx "$id" || die "unknown account: $id (import it first)"
  local d="$ACC_ROOT/$id" default="$HOME/.claude"
  [ -f "$default/.credentials.json" ] || die "no default login at $default to adopt"
  # Seed the config-dir state file inside ~/.claude (CLAUDE_CONFIG_DIR mode reads
  # <dir>/.claude.json, while default mode uses ~/.claude.json at HOME level).
  if [ ! -f "$default/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
    "$PYBIN" - "$HOME/.claude.json" "$default/.claude.json" <<'PYEOF'
import json, sys
doc = json.load(open(sys.argv[1]))
for k in ('oauthAccount', 'userID'):
    doc.pop(k, None)
import os
with open(sys.argv[2] + '.tmp', 'w') as f:
    json.dump(doc, f)
os.replace(sys.argv[2] + '.tmp', sys.argv[2])
PYEOF
  fi
  if [ -L "$d" ]; then
    echo "$id already adopted ($(readlink "$d"))"
    return 0
  fi
  if [ -d "$d" ]; then
    [ -f "$d/.credentials.json" ] && die "$id already has its own credentials — refusing to replace with adopt"
    rm -rf "${ACC_ROOT:?}/${id:?}"
  fi
  ln -s "$default" "$d"
  log_to ops.log "adopt $id -> $default"
  echo "$id now runs the default $default login (symlinked, single credential file)."
}

cmd_remove() {
  require_manifest
  local id="${1:-}" yes="${2:-}"
  [ -n "$id" ] || die "usage: claude-accounts remove <acct-NN> [--yes]"
  valid_acct_id "$id" || die "not a valid account id: $id"
  account_ids | grep -qx "$id" || die "unknown account: $id"
  if [ "$yes" != "--yes" ]; then
    printf 'Remove %s and propagate deletion to the server? [y/N] ' "$id"
    read -r ans
    case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
  fi
  if [ -L "$ACC_ROOT/$id" ]; then
    rm -f "${ACC_ROOT:?}/${id:?}"   # adopted account: remove the symlink, never the target
  else
    # The client may hold this dir's login in the macOS Keychain (keyed by the dir
    # path): drop it with the dir, or a removed account leaves a live grant behind
    # that the next account to reuse this id would silently inherit.
    keychain_forget "$ACC_ROOT/$id"
    rm -rf "${ACC_ROOT:?}/${id:?}"
  fi
  manifest_del_account "$id"
  log_to ops.log "remove $id"
  auto_sync
  echo "Removed $id."
}

# Interactive sign-in ceremony: runs `claude setup-token` attached to the user's
# terminal — it prints a clickable sign-in link, the user completes OAuth in a
# browser and pastes the code back HERE, and the resulting long-lived token is
# captured. Sets CEREMONY_TOKEN on success (no stdout capture: the UI must stay
# visible and interactive).
# A subscription setup-token, and nothing else. API keys (sk-ant-api...) are rejected
# everywhere: this addon is subscription-only by design (spec requirement).
valid_subscription_token() {
  case "$1" in
    sk-ant-oat[0-9][0-9]-*) [ "${#1}" -ge 50 ] ;;
    *) return 1 ;;
  esac
}

# A complete subscription setup-token is 108 characters (sk-ant-oat01- + 95). The
# ceremony scrapes it from a terminal transcript, and the client's TUI HARD-WRAPS at
# the pty's width — an unsized pty (the panel's) renders as 80 columns. Every
# panel-driven mint of 2026-08-28 therefore saved the first 79 characters of a
# 108-character token; the fleet rejected all seven with 401 while the minting Mac,
# still holding its OAuth login, looked healthy. Two guards, belt and braces: the
# terminal is widened for the ceremony, and the capture must pass a real inference
# before it is saved anywhere.
SETUP_TOKEN_FULL_LEN=108
CEREMONY_TTY_MIN_COLS=160
CEREMONY_TTY_COLS=400
CEREMONY_TTY_ORIG=""

widen_ceremony_tty() {
  CEREMONY_TTY_ORIG=""
  [ -t 0 ] || return 0
  local size rows cols
  size="$(stty size 2>/dev/null)" || return 0
  rows="${size%% *}"; cols="${size##* }"
  case "$cols" in ''|*[!0-9]*) return 0 ;; esac
  case "$rows" in ''|*[!0-9]*) rows=0 ;; esac
  [ "$cols" -ge "$CEREMONY_TTY_MIN_COLS" ] && return 0
  CEREMONY_TTY_ORIG="$rows $cols"
  # `script` copies stdin's window size to the pty it opens for the client, so
  # widening here is what the client sees. A zero row count is an unsized pty too.
  stty cols "$CEREMONY_TTY_COLS" rows "$([ "$rows" -gt 0 ] && echo "$rows" || echo 50)" 2>/dev/null \
    || CEREMONY_TTY_ORIG=""
}

restore_ceremony_tty() {
  [ -n "$CEREMONY_TTY_ORIG" ] || return 0
  stty rows "${CEREMONY_TTY_ORIG%% *}" cols "${CEREMONY_TTY_ORIG##* }" 2>/dev/null || true
  CEREMONY_TTY_ORIG=""
}

token_digest() { # $1 token; prints a non-secret sha256 digest (same as the shim's)
  local h=""
  if command -v shasum >/dev/null 2>&1; then
    h="$(printf '%s' "$1" | shasum -a 256 2>/dev/null)"
  elif command -v sha256sum >/dev/null 2>&1; then
    h="$(printf '%s' "$1" | sha256sum 2>/dev/null)"
  fi
  printf '%s' "$h" | cut -d ' ' -f1
}

record_token_verified() { # $1 acct dir, $2 token — the shim's own proof marker (token_preflight)
  local digest
  digest="$(token_digest "$2")"
  [ -n "$digest" ] || return 0
  { umask 077; printf '%s\n' "$digest" > "$1/.server-token-verified.$$"; } 2>/dev/null \
    && mv -f "$1/.server-token-verified.$$" "$1/.server-token-verified" 2>/dev/null \
    || rm -f "$1/.server-token-verified.$$" 2>/dev/null || true
}

# Prove a captured token with ONE real inference before it is saved. rc 0 = Claude
# answered with it; rc 1 = Claude REJECTED it (401: revoked, wrong account's grant,
# or captured incomplete); rc 2 = inconclusive (network, 429, timeout). The probe runs
# in an EMPTY config dir: the account dir may hold an OAuth login the client would
# silently prefer, and this must exercise the captured token and nothing else. The
# token travels by environment, never argv.
CEREMONY_CHECK_DETAIL=""
ceremony_probe() { # $1 = real claude, $2 = empty config dir; token in CEREMONY_TOKEN_UNDER_TEST
  "$PYBIN" - "$1" "$2" "$ACC_ROOT" <<'PYEOF'
import os, re, subprocess, sys
real, cfg, root = sys.argv[1], sys.argv[2], sys.argv[3]
env = {k: v for k, v in os.environ.items()
       if k not in ('ANTHROPIC_API_KEY', 'CLAUDE_ACCOUNT', 'CEREMONY_TOKEN_UNDER_TEST')}
env['CLAUDE_CONFIG_DIR'] = cfg
env['CLAUDE_CODE_OAUTH_TOKEN'] = os.environ['CEREMONY_TOKEN_UNDER_TEST']
env['CLAUDE_SHIM_ACTIVE'] = '1'
# The shim's own vocabulary for a rejected token (bin/claude token_preflight).
AUTH = re.compile(r'failed to authenticate|oauth (access )?token is invalid|oauth session expired'
                  r'|please run /login|invalid bearer token|authentication_error|\b401\b', re.I)
try:
    r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
                       env=env, capture_output=True, text=True, timeout=180,
                       input='Reply with exactly: OK\n', cwd=root)
except subprocess.TimeoutExpired:
    print('inconclusive\ttimed out after 180s'); sys.exit(0)
except OSError as exc:
    print(f'inconclusive\t{exc}'); sys.exit(0)
out, err = (r.stdout or '').strip(), (r.stderr or '').strip()
if r.returncode == 0 and 'ok' in out.lower():
    print('ok\t'); sys.exit(0)
if AUTH.search(out) or AUTH.search(err):
    print('rejected\t' + (err or out)[:160].replace('\n', ' ')); sys.exit(0)
print('inconclusive\t' + f'rc={r.returncode} ' + (err or out)[:160].replace('\n', ' '))
PYEOF
}

ceremony_token_check() { # $1 = token
  local tok="$1" real tmpd verdict
  CEREMONY_CHECK_DETAIL=""
  real="$(find_real_claude "$_self")" || { CEREMONY_CHECK_DETAIL="real claude binary not found"; return 2; }
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
  tmpd="$(mktemp -d "$ACC_ROOT/tmp/mint-check.XXXXXX" 2>/dev/null)" \
    || { CEREMONY_CHECK_DETAIL="cannot create a scratch config dir"; return 2; }
  chmod 700 "$tmpd" 2>/dev/null || true
  verdict="$(CEREMONY_TOKEN_UNDER_TEST="$tok" ceremony_probe "$real" "$tmpd")"
  rm -rf "$tmpd" 2>/dev/null || true
  case "$verdict" in
    ok*) return 0 ;;
    rejected*) CEREMONY_CHECK_DETAIL="${verdict#rejected	}"; return 1 ;;
    inconclusive*) CEREMONY_CHECK_DETAIL="${verdict#inconclusive	}"; return 2 ;;
    *) CEREMONY_CHECK_DETAIL="no verdict"; return 2 ;;
  esac
}

# The ONE place a ceremony's token is written: only after ceremony_token_check, so a
# token Claude rejects is never saved, uploaded, or distributed. $1 = acct dir,
# $2 = token, $3 = acct id (messages only).
commit_ceremony_token() {
  local d="$1" tok="$2" id="$3" src="${4:-captured}" rc=0
  ceremony_token_check "$tok" || rc=$?
  # A short token is an incomplete one unless Claude itself has just answered with it:
  # the length alone convicts it, so an inconclusive probe (429, network) must not let
  # it through as merely "unverified" — that is the exact token the fleet then rejects.
  # The cause named depends on where the token came from: a CAPTURED one was wrapped by
  # the ceremony's terminal; a PASTED one was cut before it reached the clipboard.
  if [ "$rc" -ne 0 ] && [ "${#tok}" -lt "$SETUP_TOKEN_FULL_LEN" ]; then
    if [ "$src" = "pasted" ]; then
      die "the pasted token is ${#tok} characters and a complete setup-token is $SETUP_TOKEN_FULL_LEN (${CEREMONY_CHECK_DETAIL:-not proven by a real call}) — it was cut before it reached the clipboard (a narrow terminal wraps the token when it is shown). Nothing saved for $id. Copy the WHOLE token and paste again."
    fi
    die "the captured token is ${#tok} characters and a complete setup-token is $SETUP_TOKEN_FULL_LEN (${CEREMONY_CHECK_DETAIL:-not proven by a real call}): the sign-in terminal wrapped it and only its first line was captured. Nothing saved for $id. Mint again from a terminal at least $CEREMONY_TTY_MIN_COLS columns wide, or from an updated panel."
  fi
  if [ "$rc" -eq 1 ]; then
    die "Claude rejected the captured token (${CEREMONY_CHECK_DETAIL:-401}) — nothing saved for $id; sign in again as the right account and retry"
  fi
  ( umask 077; printf '%s' "$tok" > "$d/server.token" )
  chmod 600 "$d/server.token"
  if [ "$rc" -eq 0 ]; then
    record_token_verified "$d" "$tok"
    echo "Token verified by a real inference."
  else
    rm -f "$d/.server-token-verified" 2>/dev/null || true
    warn "the token could not be verified right now (${CEREMONY_CHECK_DETAIL:-no answer}) — saved as UNVERIFIED; the shim proves it on first use, or run: claude-accounts verify"
  fi
}

# When a ceremony ends without a token, the CLIENT said why — "Your account is on
# hold", a policy refusal, a subscription it could not find — and that sentence is
# the only thing that lets anyone fix it. It used to vanish with the capture file,
# leaving "no token captured" (operator, 2026-08-29). The last visible lines are
# kept (ANSI-stripped, spinners/logo/URL/prompt dropped, any sk-ant-… redacted) and
# the whole redacted transcript is written beside the pool for a closer look.
CEREMONY_LAST_WORDS=""
CEREMONY_TRANSCRIPT=""
ceremony_debrief() { # $1 = capture file, $2 = redacted transcript to write; prints the last words
  "$PYBIN" "$LIB_DIR/ceremony.py" debrief "$1" "$2" 2>/dev/null
}

# The token, out of the transcript. `claude setup-token` is a TUI: its renderer places
# words with absolute cursor moves, so the bytes are terminal OPERATIONS and the token
# exists only in the RENDERED result. Stripping escapes reassembles it wrongly — a
# `sk-ant-\x1b[10Gat01-…` stream loses the `o` and three mints failed as "no token
# captured" seconds after the client said the token was created (2026-08-29). lib/
# ceremony.py replays the transcript onto a virtual screen and reads what the operator
# saw; the real inference in commit_ceremony_token then proves whatever came out.
ceremony_extract() { # $1 = capture file -> the setup-token, or nothing
  "$PYBIN" "$LIB_DIR/ceremony.py" extract "$1" 2>/dev/null
}

CEREMONY_TOKEN=""
run_token_ceremony() { # $1 = config dir
  CEREMONY_TOKEN=""
  CEREMONY_LAST_WORDS=""
  CEREMONY_TRANSCRIPT=""
  local d="$1" real cap old_umask
  real="$(find_real_claude "$_self")" || { warn "real claude binary not found"; return 1; }
  mkdir -p "$ACC_ROOT/tmp"
  cap="$ACC_ROOT/tmp/mint.$$.log"
  old_umask="$(umask)"
  umask 077   # the capture file briefly holds the raw token (removed inline below).
  # NB: no EXIT trap here — cmd_add owns the EXIT trap for its mutation lock, and a
  # second EXIT trap would clobber it and leak the lock.
  cat <<'TIP'
A sign-in link will appear below. For a smooth grant (avoids the "Sign in again
to continue" step-up screen):
  1. Open a fresh incognito/private window.
  2. Sign in at claude.ai to the EXACT account you're adding — do this FIRST.
  3. THEN paste the link below into that same window and approve
     ("Contribute to your Claude subscription usage" is the correct permission).
  4. Copy the code it shows and paste it back here.
If you do see "Sign in again to continue", that is Claude's security step, not an
error — just sign in to that account and approve; the code still appears.
TIP
  if [ -t 0 ]; then
    # The client's TUI hard-wraps at the pty's width, and the token is scraped from
    # the transcript below — see SETUP_TOKEN_FULL_LEN for the 79-character tokens
    # this produced. `script` copies the (widened) window size to the client's pty.
    widen_ceremony_tty
    if [ "$(machine_kind)" = "mac" ]; then
      script -q "$cap" env CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token
    else
      script -q -c "CLAUDE_CONFIG_DIR='$d' CLAUDE_SHIM_ACTIVE=1 '$real' setup-token" "$cap"
    fi
    restore_ceremony_tty
  else
    # Headless (tests / piped code): capture into the 0600 file only. Never tee the
    # raw token to stdout — a redirected run would write the secret to a plain log.
    CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token > "$cap" 2>&1
    # Every credential shape, not just setup-tokens: a Console session mints an API key.
    sed -E 's/sk-ant-[A-Za-z0-9]+-[A-Za-z0-9_-]*/sk-ant-***-<redacted>/g' "$cap"
  fi
  umask "$old_umask"
  CEREMONY_TOKEN="$(ceremony_extract "$cap" 2>/dev/null)"
  if [ -z "$CEREMONY_TOKEN" ]; then
    local stamp rawkeep
    stamp="$(date -u +%Y%m%dT%H%M%SZ)"
    CEREMONY_TRANSCRIPT="$ACC_ROOT/tmp/mint-failed.$stamp.log"
    rawkeep="$ACC_ROOT/tmp/mint-failed.$stamp.raw"
    CEREMONY_LAST_WORDS="$(ceremony_debrief "$cap" "$CEREMONY_TRANSCRIPT" 2>/dev/null)"
    [ -s "$CEREMONY_TRANSCRIPT" ] || CEREMONY_TRANSCRIPT=""
    # The raw bytes are the only record of what the renderer actually emitted, and may
    # hold a token this extractor did not recognise: kept 0600 beside the pool, like
    # the tokens themselves, so the next unknown shape can be read instead of guessed.
    ( umask 077; cp "$cap" "$rawkeep" ) 2>/dev/null || true
    rm -f "$cap"
    [ -n "$CEREMONY_LAST_WORDS" ] && warn "the sign-in ended without a token — the client said: $CEREMONY_LAST_WORDS"
    [ -n "$CEREMONY_TRANSCRIPT" ] && warn "redacted transcript kept at $CEREMONY_TRANSCRIPT"
    return 1
  fi
  rm -f "$cap"
  valid_subscription_token "$CEREMONY_TOKEN" || {
    CEREMONY_TOKEN=""
    warn "captured credential is not a subscription setup-token"
    return 1
  }
}

account_email() { # $1 = acct id; prints the email the manifest holds for it
  "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
import json, sys
for a in json.load(open(sys.argv[1])).get('accounts', []):
    if a['id'] == sys.argv[2]:
        print(a.get('email', ''))
        break
PYEOF
}

token_email() { # $1 = token; prints the authenticated email — EMPTY for a setup token
  # A setup token is minted with scope user:inference ALONE, so `claude auth status`
  # answers {loggedIn, authMethod, apiProvider} and nothing else; an OAuth login also
  # answers email/orgId/subscriptionType. There is therefore no way to learn which
  # account approved a setup-token grant. Callers must read "" as "unknowable by
  # design" — never as a transient failure worth retrying — and guard the ceremony by
  # NAMING the expected account up front instead.
  local real
  real="$(find_real_claude "$_self")" || { echo ""; return 0; }
  CLAUDE_CODE_OAUTH_TOKEN="$1" CLAUDE_SHIM_ACTIVE=1 "$real" auth status 2>/dev/null | "$PYBIN" -c '
import json, sys
try:
    doc = json.loads(sys.stdin.read())
    print(doc.get("email", "") if doc.get("loggedIn") else "")
except Exception:
    print("")'
}

config_dir_email() { # $1 = config dir; prints the logged-in email or empty
  local real
  real="$(find_real_claude "$_self")" || { echo ""; return 0; }
  CLAUDE_CONFIG_DIR="$1" CLAUDE_SHIM_ACTIVE=1 "$real" auth status 2>/dev/null | "$PYBIN" -c '
import json, sys
try:
    doc = json.loads(sys.stdin.read())
    print(doc.get("email", "") if doc.get("loggedIn") else "")
except Exception:
    print("")'
}

# Full subscription login: `claude auth login --claudeai`, the SAME flow the
# interactive app uses (claude.com/cai/oauth/authorize, full Claude Code scopes,
# normal sign-in — no long-lived-token step-up wall). Leaves an auto-refreshing login
# in <dir>/.credentials.json — or, on macOS from a session that can open the login
# Keychain, in a Keychain item (lib/keychain.py). Returns success iff a credential
# that can authenticate landed in either place. $2 = optional email hint.
run_login_ceremony() { # $1 = config dir, $2 = email hint
  local d="$1" hint="${2:-}" real
  real="$(find_real_claude "$_self")" || { warn "real claude binary not found"; return 1; }
  cat <<'TIP'
A browser will open (or a sign-in link will be printed) for the FULL Claude Code
login. Sign in as the account you want to add and approve; if a code is shown,
paste it after the "Paste code here" prompt and press Enter. This is the normal
login — it draws on the subscription, no API keys, and refreshes itself over time.
TIP
  # Run claude auth login DIRECTLY on the user's terminal (no `script` PTY wrapper):
  # we capture nothing here (the client stores the credential itself), and a PTY
  # layer can suppress the echo of the pasted code. Direct invocation behaves
  # exactly like running `claude auth login` yourself, so the code you paste is shown.
  if [ -n "$hint" ]; then
    CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai --email "$hint" || true
  else
    CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai || true
  fi
  # Success is a credential that can AUTHENTICATE — not merely one that exists.
  # Re-login targets already have a (dead) credential in place, so a bare existence
  # test would call an aborted sign-in a success, clear the dead-auth marker, and
  # hand the account straight back to the pool. creds_alive looks in both places
  # the client writes to (.credentials.json, macOS Keychain): demanding the FILE
  # here turned every Keychain-backed sign-in into "login failed", deleted the
  # reserved dir, and left the fresh grant orphaned in the Keychain.
  creds_alive "$d"
}

cmd_mint() {
  # The minted token is the ONLY credential that reaches the server and every peer, so a
  # mint under the wrong browser session hands this slot another account's subscription —
  # and nothing downstream can notice (a setup token reports no identity, see
  # token_email). Naming the expected account before the ceremony opens is the only guard
  # that exists; the old prompt said "THIS account" and named nobody.
  require_manifest
  local id="" paste=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --paste) paste=1; shift ;;
      --*) die "unknown option: $1" ;;
      *) if [ -z "$id" ]; then id="$1"; shift
         else die "unexpected argument: $1 (usage: claude-accounts mint <acct-NN> [--paste])"; fi ;;
    esac
  done
  [ -n "$id" ] || die "usage: claude-accounts mint <acct-NN> [--paste]"
  valid_acct_id "$id" || die "not a valid account id: $id"
  # A bare directory is NOT enough: removed accounts and killed `add` runs leave acct-NN
  # dirs behind (this Mac carries nine), and minting into one binds a live token to a slot
  # the manifest cannot name — which is exactly the unattributable state this guards.
  account_ids | grep -qx "$id" \
    || die "unknown account: $id — mint binds a token to a REGISTERED account ('claude-accounts list' shows them)"
  local d="$ACC_ROOT/$id"
  seed_account_dir "$d"
  local email tok="" got
  email="$(account_email "$id")"
  [ -n "$email" ] || die "$id has no email in the manifest — refusing to mint a token nobody could attribute"
  trap 'restore_ceremony_tty; exit 130' INT TERM
  trap 'restore_ceremony_tty' EXIT
  if [ "$paste" = "1" ]; then
    printf 'Paste the sk-ant-oat... token for %s (%s): ' "$id" "${email:-unknown email}"
    read -r tok
    tok="$(printf '%s' "$tok" | tr -d '[:space:]')"
  else
    echo "Running 'claude setup-token' for $id — approve in a browser signed in as ${email:-THIS account}."
    run_token_ceremony "$d" || die "no token captured — mint failed${CEREMONY_LAST_WORDS:+ — the client said: $CEREMONY_LAST_WORDS}${CEREMONY_TRANSCRIPT:+ (transcript: $CEREMONY_TRANSCRIPT)}"
    tok="$CEREMONY_TOKEN"
  fi
  [ -n "$tok" ] || die "no token captured — mint failed"
  valid_subscription_token "$tok" \
    || die "that is not a subscription setup-token (sk-ant-oat...). API keys are not supported."
  got="$(token_email "$tok")"
  if [ -n "$got" ] && [ -n "$email" ] && [ "$got" != "$email" ]; then
    die "that token authenticates as $got but $id is $email — nothing saved"
  fi
  commit_ceremony_token "$d" "$tok" "$id" "$([ "$paste" = "1" ] && echo pasted || echo captured)"
  clear_auth_markers "$d"
  log_to ops.log "mint $id"
  echo "Token saved to $d/server.token"
  [ -n "$got" ] || warn "a setup token carries no identity — $id now runs whichever account approved that grant${email:+, trusted to be $email}"
  auto_sync
}

cmd_login() {
  # Complete (or refresh) auth for an EXISTING account. Default: full Claude Code login
  # (auto-refreshing, this machine — .credentials.json or the macOS Keychain). --token:
  # portable setup-token (Mac + server). Verifies the signed-in email matches the manifest.
  require_manifest
  local id="" force=0 token=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --force) force=1; shift ;;
      --token) token=1; shift ;;
      --*) die "unknown option: $1" ;;
      *) if [ -z "$id" ]; then id="$1"; shift; else die "unexpected argument: $1"; fi ;;
    esac
  done
  [ -n "$id" ] || die "usage: claude-accounts login <acct-NN> [--token] [--force]"
  valid_acct_id "$id" || die "not a valid account id: $id"
  account_ids | grep -qx "$id" || die "unknown account: $id"
  local d="$ACC_ROOT/$id" email got
  seed_account_dir "$d"
  email="$(account_email "$id")"
  echo "Sign in as $email for $id."
  if [ "$token" = "1" ]; then
    trap 'restore_ceremony_tty; exit 130' INT TERM
    trap 'restore_ceremony_tty' EXIT
    run_token_ceremony "$d" || die "sign-in failed or aborted — nothing changed${CEREMONY_LAST_WORDS:+ — the client said: $CEREMONY_LAST_WORDS}${CEREMONY_TRANSCRIPT:+ (transcript: $CEREMONY_TRANSCRIPT)}"
    got="$(token_email "$CEREMONY_TOKEN")"
    if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
      die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
    fi
    commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
    clear_auth_markers "$d"
    [ -n "$got" ] || warn "a setup token carries no identity — $id is trusted to hold $email because that is who you approved as"
    echo "$id token saved (portable — works on Mac and server)."
  else
    run_login_ceremony "$d" "$email" \
      || die "login failed or aborted (no working credential landed) — nothing changed"
    got="$(config_dir_email "$d")"
    if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
      die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
    fi
    if [ -z "$got" ] && [ "$force" != "1" ]; then
      die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
    fi
    clear_auth_markers "$d" keep-token-park
    if [ -s "$d/.credentials.json" ]; then
      echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
    else
      echo "$id login saved (macOS Keychain, this machine, auto-refreshing — sessions without keychain access, e.g. ssh, cannot use it)."
    fi
  fi
  log_to ops.log "login $id verified=${got:-unverified} mode=$([ "$token" = 1 ] && echo token || echo login)"
  auto_sync
}

cmd_expired() {
  # Accounts proven dead plus token-only accounts that have never passed a real call.
  # Presence/shape is not authentication: without strict token evidence, `expired`
  # falsely rendered a revoked setup-token as healthy.
  require_manifest
  local quiet=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --quiet|--ids) quiet=1; shift ;;   # ids only, for scripts
      *) die "unknown option: $1 (usage: claude-accounts expired [--quiet])" ;;
    esac
  done
  local rows bad
  rows="$(account_audit strict-tokens)" \
    || die "the account audit failed — cannot say which logins are dead"
  if [ -z "$rows" ]; then
    # No rows at all: either the pool is genuinely empty, or the manifest lost its
    # accounts. Never render that as "all clear".
    if [ -z "$(account_ids)" ]; then
      echo "No accounts registered yet — add one with: claude-accounts add"
      return 0
    fi
    die "the manifest lists accounts but none could be audited — check $MANIFEST"
  fi
  bad="$(printf '%s\n' "$rows" \
    | awk -F'\t' '
      NF >= 4 && $1 != "" && $4 ~ /^(expired|token-invalid|blocked|missing|unverified)$/ {
        print $1
      }')"
  if [ "$quiet" = "1" ]; then
    [ -n "$bad" ] || return 0
    printf '%s\n' "$bad"
    return 1
  fi
  printf '%s\n' "$rows" | awk -F'\t' '
    BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0; retoken = 0; unverified = 0 }
    NF < 4 || $1 == "" { next }          # never invent an account from a blank line
    $4 == "ok"     { ok++; next }
    $4 == "remote" { remote++; rem = rem sprintf("  %-9s %-28s %s\n", $1, $2, $6); next }
    $4 == "unverified" {
      bad++
      unverified++
      printf "  %-9s %-28s %-9s %s\n", $1, $2, $5, $6
      printf "  %-9s %-28s %-9s fix: %s\n", "", "", "", $7
      next
    }
    $4 == "token-invalid" {
      bad++
      retoken++
      printf "  %-9s %-28s %-13s %s\n", $1, $2, $5, $6
      printf "  %-9s %-28s %-13s fix: %s\n", "", "", "", $7
      next
    }
    {
      bad++
      relogin++
      printf "  %-9s %-28s %-9s %s\n", $1, $2, $5, $6
      printf "  %-9s %-28s %-9s fix: %s\n", "", "", "", $7
    }
    END {
      if (bad == 0) printf "All %d account(s) with auth on this machine can authenticate (verified).\n", ok
      else printf "\n%d account(s) are dead or unverified, %d verified.\n", bad, ok
      if (remote > 0) {
        printf "\nNot logged in here on purpose (another machine owns the grant):\n"
        printf "%s", rem
      }
      if (unverified > 0) {
        printf "\nVerify portable tokens:\n"
        printf "  claude-accounts verify\n"
      }
      if (retoken > 0) {
        printf "\nReplace invalid portable setup-tokens:\n"
        printf "  claude-accounts login acct-NN --token\n"
      }
      if (relogin > 0) {
        printf "\nRe-authenticate them:\n"
        printf "  claude-accounts relogin            # every account that needs it\n"
        printf "  claude-accounts relogin acct-NN    # just one\n"
      }
    }'
  # Exit 1 when something needs attention, so cron/health checks can alert on it.
  [ -z "$bad" ]
}

cmd_relogin() {
  # Re-authenticate accounts whose login died. With no arguments it targets exactly
  # what `expired` lists; ids (or --all) override that. Runs the same verified login
  # ceremony as `login`, one account at a time, and syncs ONCE at the end.
  require_manifest
  local all=0 yes=0 token=0 ids=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --all) all=1; shift ;;
      --yes|-y) yes=1; shift ;;
      --token) token=1; shift ;;
      --*) die "unknown option: $1" ;;
      *)
        valid_acct_id "$1" || die "not a valid account id: $1"
        account_ids | grep -qx "$1" || die "unknown account: $1"
        ids="$ids $1"; shift ;;
    esac
  done
  if [ -n "$ids" ] && [ "$all" = "1" ]; then
    die "give account ids OR --all, not both"
  fi
  if [ -z "$ids" ]; then
    if [ "$all" = "1" ]; then
      ids="$(account_ids | tr '\n' ' ')"
    else
      # Only what a sign-in can actually fix — org-blocked accounts are listed by
      # `expired` but signing into them again would fail exactly the same way.
      # A FAILED audit must not read as "nothing to do".
      account_audit >/dev/null || die "the account audit failed — refusing to guess what needs a re-login"
      ids="$(accounts_needing_login | tr '\n' ' ')"
    fi
  fi
  ids="$(printf '%s' "$ids" | tr -s ' ' | sed 's/^ //; s/ $//')"
  if [ -z "$ids" ]; then
    echo "Nothing to re-authenticate — every account on this machine can be used."
    return 0
  fi
  local count rows
  count="$(printf '%s\n' "$ids" | tr ' ' '\n' | grep -c .)"
  rows="$(account_audit)"
  echo "Accounts to re-authenticate ($count):"
  local id
  for id in $ids; do
    printf '  %s  %s\n' "$id" \
      "$(printf '%s\n' "$rows" | awk -F'\t' -v i="$id" '$1 == i { print $2 "  (" $5 ")" }')"
  done
  if [ "$yes" != "1" ]; then
    if [ ! -t 0 ]; then
      die "relogin is interactive (each account needs a browser sign-in) — run it from a terminal, or pass --yes"
    fi
    printf 'Sign in to each of them now? [y/N] '
    read -r ans
    case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
  fi
  # One sync at the end instead of one per account: each auto_sync is an ssh round trip.
  local prev_no_sync="${CLAUDE_MULTIACC_NO_SYNC:-0}" failed="" done_ok=0
  export CLAUDE_MULTIACC_NO_SYNC=1
  for id in $ids; do
    echo
    echo "=== $id ==============================================================="
    if [ "$token" = "1" ]; then
      ( cmd_login "$id" --token ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
    else
      ( cmd_login "$id" ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
    fi
  done
  export CLAUDE_MULTIACC_NO_SYNC="$prev_no_sync"
  [ "$prev_no_sync" = "0" ] && unset CLAUDE_MULTIACC_NO_SYNC
  echo
  echo "re-authenticated $done_ok of $count account(s)."
  if [ -n "$failed" ]; then
    warn "still failing:$failed (re-run: claude-accounts relogin$failed)"
  fi
  [ "$done_ok" -gt 0 ] && auto_sync
  [ -z "$failed" ]
}

# Hand freshly fetched telemetry to every machine that cannot fetch its own.
#
# Only ONE machine in a pool can read the usage endpoint: it needs an OAuth grant
# carrying the user:profile scope, and a setup token is minted WITHOUT it. Every
# other machine fetches, is refused, and therefore knows nothing about which
# accounts are drained. gas-mini ranked its whole pool BLIND for 5.7 hours and
# handed two tasks to an account sitting at 100% — its newest telemetry had arrived
# at 16:38 on a mutation-triggered sync, and nothing refreshed it afterwards.
#
# So distribution rides the REFRESH rather than the mutation: whoever can fetch
# hands the answer to everyone who cannot, every pass. One rsync per target carries
# every account (a few KB). Strictly best effort with hard timeouts and BatchMode —
# a peer that is asleep, rebooting or off the tailnet must never fail a refresh, and
# must never hang the 15-minute job long enough to collide with the next one.
# The push is DETACHED. A refresh that waits on ssh is a refresh that blocks the
# 15-minute job for as long as a sleeping peer takes to time out, and telemetry
# that arrives late is the whole problem we are fixing — delaying the NEXT fetch to
# deliver this one trades the fault for itself. It also made four time-window tests
# fail, which is the same defect wearing a smaller hat: seconds spent here move
# every deadline downstream.
#
# One at a time: the lock means a slow or unreachable target can never stack pushes
# up faster than they drain.
limits_distribute() {
  sync_is_replica && return 0
  command -v rsync >/dev/null 2>&1 || return 0
  [ "${CLAUDE_MULTIACC_NO_DISTRIBUTE:-0}" = "1" ] && return 0
  # In its OWN session, not merely backgrounded. launchd tears down the job's
  # whole process group the moment `limits` exits, and a `( … & )` subshell is
  # still in that group: every scheduled pass spawned its push and launchd killed
  # it before one rsync had finished, with nothing logged — while this machine
  # refreshed every 15 minutes, the seven runner Macs ranked on readings last
  # pushed at 21:13Z until 01:10Z on 2026-09-09 (a foreground pass delivered them
  # in seconds). setsid puts the push outside the group; install.sh also marks
  # the agent AbandonProcessGroup, and either one alone is enough.
  "$PYBIN" -c '
import os, sys
if os.fork():
    sys.exit(0)
os.setsid()
os.execv(sys.argv[1], [sys.argv[1], "limits-distribute-now"])
' "$BIN_DIR/claude-accounts" >/dev/null 2>&1 </dev/null &
  return 0
}

limits_distribute_now() {
  local lock="$ACC_ROOT/tmp/limits-push.lock"
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
  if ! mkdir "$lock" 2>/dev/null; then
    # A DETACHED push that is killed (logout, reboot, pkill) never runs its EXIT trap,
    # and mkdir can never take a lock dir nobody will remove: one stranded lock
    # silently stopped ALL telemetry distribution on the live pool from 2026-09-03
    # 00:29 until it was deleted by hand on 2026-09-04 — 32 hours in which every peer
    # ranked on whatever limits.json it happened to already have, which is the exact
    # blindness this push exists to prevent, and nothing anywhere said so. A push is
    # seconds of rsync under hard timeouts (--timeout=20, ConnectTimeout=10), so a
    # lock older than ten minutes belongs to a process that is gone: break it and
    # retake it. If the retake still fails, a live pusher owns it and this pass skips,
    # exactly as before.
    [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 600 ] || return 0
    rm -rf "$lock" 2>/dev/null
    mkdir "$lock" 2>/dev/null || return 0
    log_to sync.log "stale limits-push lock broken (older than 600s); distributing"
  fi
  trap 'rmdir "$lock" 2>/dev/null || true' EXIT
  local server sroot list id d
  list="$ACC_ROOT/tmp/limits-push.$$"
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
  : > "$list" 2>/dev/null || return 0
  for id in $(account_ids); do
    d="$ACC_ROOT/$id"
    [ -f "$d/limits.json" ] && printf '%s/limits.json\n' "$id" >> "$list"
    # A marker is pushed but never un-pushed: the shim expires a cleanly-reset
    # .limited on its own, so a stale one costs an account some eligibility and
    # never grants any. Erring toward exclusion is the safe direction here.
    [ -f "$d/.limited" ] && printf '%s/.limited\n' "$id" >> "$list"
  done
  # The MCP registry rides along: a server the shim mirrored from a stock `claude mcp
  # add` reaches every peer within one limits cadence, with no explicit sync. The
  # machine-local overlay stays home (see sync_push_target).
  [ -f "$ACC_ROOT/mcp-servers.json" ] && printf 'mcp-servers.json\n' >> "$list"
  if [ ! -s "$list" ]; then rm -f "$list"; return 0; fi

  local pushed=0 failed=0
  server="$(sync_target)"
  sroot="$(sync_target_root)"
  if [ -n "$server" ] && ! sync_target_is_local "$server" \
     && valid_ssh_target "$server" && valid_remote_path "$sroot"; then
    if limits_push_to "$server" "$sroot" "$list"; then pushed=$((pushed + 1)); else failed=$((failed + 1)); fi
  fi
  # Peers are the OTHER Macs — the ones actually running tasks, and so the ones
  # whose selection goes blind without this. Read through process substitution,
  # not a pipe: the counters below must survive the loop.
  while IFS="$(printf '\t')" read -r pt pr pp; do
    [ "$pt" = "MALFORMED" ] && continue
    [ -n "$pt" ] && [ -n "$pr" ] || continue
    valid_ssh_target "$pt" || continue
    valid_remote_path "$pr" || continue
    if limits_push_to "$pt" "$pr" "$list"; then pushed=$((pushed + 1)); else failed=$((failed + 1)); fi
  done < <(manifest_peers)
  # Every pass writes its outcome: a silent push is indistinguishable from a
  # killed one, which is exactly how the 2026-09-08 outage went unnoticed.
  log_to sync.log "limits distributed to $pushed target(s), $failed failed"
  rm -f "$list"
  rmdir "$lock" 2>/dev/null || true
  trap - EXIT
  return 0
}

limits_push_to() { # $1 target, $2 remote root, $3 file list
  rsync -az --timeout=20 \
    -e 'ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new' \
    --files-from="$3" "$ACC_ROOT/" "$1:$2/" >>"$ACC_ROOT/sync.log" 2>&1 \
    || { log_to sync.log "limits push to $1 failed (telemetry there will age)"; return 1; }
}

cmd_limits() {
  require_manifest
  local quiet=0 force=0 json=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --quiet) quiet=1; shift ;;
      --force) force=1; shift ;;   # ignore freshness/backoff (manual override)
      --json) json=1; quiet=1; shift ;;   # refresh silently, then emit the report
      *) die "unknown option: $1" ;;
    esac
  done
  local lock="$ACC_ROOT/.locks/limits"
  mkdir -p "$ACC_ROOT/.locks"
  if ! mkdir "$lock" 2>/dev/null; then
    local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
    if [ "$age" -lt 120 ]; then
      # A --json caller still gets the document (built from the state on disk) —
      # a machine-readable verb must never answer a concurrent run with silence.
      if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
      [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
      return 0
    fi
    rm -rf "$lock"
    if ! mkdir "$lock" 2>/dev/null; then
      if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
      return 0
    fi
  fi
  LIMITS_LOCK="$lock"
  trap limits_lock_release EXIT
  rotate_log limits.log
  # NB: expired OAuth access tokens are refreshed inside the Python below via the
  # refresh-token grant. (`claude auth status` was tried for this and does NOT
  # refresh credentials — it only reports the on-disk state.)
  # The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
  # never loosen it, or an account could sit at 95% and still be selected.
  local threshold
  threshold="$(manifest_get threshold 90)"
  case "$threshold" in ''|*[!0-9]*) threshold=90 ;; esac
  [ "$threshold" -gt 90 ] && threshold=90
  [ "$threshold" -lt 1 ] && threshold=90
  # Claude Code's own User-Agent (with the REAL installed version) is what makes the
  # usage endpoint answer the limit-reset status for this account; the writer learns the
  # version from the real binary (lib/claude_reset.py cli_version, bounded by a timeout).
  local real_claude
  real_claude="$(find_real_claude "$_self" 2>/dev/null)" || real_claude=""
  "$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" "$LIB_DIR" "$real_claude" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
import hashlib, json, os, sys, time, urllib.request
sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
import keychain  # noqa: E402  (macOS Keychain-held logins; a no-op elsewhere)
from audit import creds_doc_state  # noqa: E402  (one rule for "can this credential work")
import claude_reset  # noqa: E402  (automatic limit-reset redemption, the codex_reset twin)

root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
USER_AGENT = claude_reset.user_agent(claude_reset.cli_version(sys.argv[7] if len(sys.argv) > 7 else ''))
now = time.time()
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '240'))

# OAuth refresh-token grant — the same endpoint + public client id Claude Code
# itself uses to keep .credentials.json alive. An account that sits idle past its
# access-token TTL would otherwise drop out of telemetry forever (stale data ranks
# neutral, so truly-idle accounts lose selection to busy-but-fresh ones).
TOKEN_URL = os.environ.get('CLAUDE_MULTIACC_TOKEN_URL',
                           'https://console.anthropic.com/v1/oauth/token')
CLIENT_ID = os.environ.get('CLAUDE_MULTIACC_CLIENT_ID',
                           '9d1c250a-e61b-44d9-88ed-5944d1962f5e')
# Only refresh a token that has been expired for a while: a LIVE session refreshes
# its own credential within moments of expiry, so a long-expired one proves no
# other writer is active (refresh tokens rotate; two racing refreshers strand one).
REFRESH_MIN_EXPIRED = 300
REFRESH_FAIL_BACKOFF = 600     # transient (network/5xx/429): retry in 10 min
REFRESH_DENIED_BACKOFF = 21600  # 4xx = grant likely revoked: 6h; re-login needed anyway
# A usage fetch the server says will never succeed (x-should-retry: false — e.g. the
# 403 "does not meet scope requirement user:profile" that a setup token ALWAYS gets)
# is not a hiccup to retry every pass. Retrying it is what manufactured the 429s that
# then hid the real cause for days, so a definitive refusal parks for 6h and says
# exactly which ceremony fixes it.
USAGE_DENIED_BACKOFF = 21600
USAGE_FAIL_BACKOFF = 900       # anything else non-2xx: 15 min, doubling to 30
# A real client 429 beats an immediately-following usage response, which may be cached.
# A later successful response under the threshold is newer first-hand evidence and must
# release the account instead of preserving a false marker until a days-away reset.
try:
    CLIENT_LIMIT_CONFIRM_DELAY = max(
        0, min(3600, int(os.environ.get('CLAUDE_MULTIACC_CLIENT_LIMIT_CONFIRM_DELAY', '300'))))
except ValueError:
    CLIENT_LIMIT_CONFIRM_DELAY = 300

def say(msg):
    if not quiet:
        print(msg)
    with open(os.path.join(root, 'limits.log'), 'a') as f:
        f.write(time.strftime('%Y-%m-%dT%H:%M:%SZ ', time.gmtime()) + msg + '\n')

def parse_iso(s):
    if not s:
        return None
    import datetime
    try:
        return datetime.datetime.fromisoformat(s.replace('Z', '+00:00')).timestamp()
    except Exception:
        return None

# The ONE rule for deleting a `.limited` marker on a clean pass. It is the writer half
# of the shim's client_marker_recovered and has to be the SAME rule: the scheduled
# limits pass runs every 15 minutes, so a writer that clears more freely than the shim
# just undoes the shim's fix on its own timer. Before 2026-09-04 it did exactly that —
# a truthful client:seven_day marker was deleted 300s after it was written, on a pass
# whose every bucket said `percent 0, resets_at null`, i.e. on nothing at all.
#   * a pass with NO informative bucket proves nothing, so it clears nothing;
#   * a client rejection naming a WEEKLY window outlives every reading until its own
#     reset — a weekly bucket cannot fall from the server-proven 100% that wrote the
#     marker to under the threshold while that window is still open, so a reading
#     that says it did is wrong by construction;
#   * a client rejection naming a session/5h window still clears once the pass is
#     informative and at least CLIENT_LIMIT_CONFIRM_DELAY newer than the marker (#22,
#     2026-09-03: 5h markers stranded accounts sitting at 0% usage for days);
#   * error-cooldown is untouched: it keeps its own window out, exactly as before.
def marker_bucket(txt):
    for part in txt.split():
        if part.startswith('bucket='):
            return part[7:]
    return ''

def session_marker(txt):
    # The self-healing 5h/session window, named the way each client writes it:
    # claude's own type is `five_hour`, codex labels a short window `client:5h`, and a
    # bare `session` covers the writer's own marker. Anything else (a weekly name, an
    # unparseable line) is NOT a session marker and keeps its existing handling.
    b = marker_bucket(txt).lower()
    return 'five_hour' in b or '5h' in b or 'session' in b


def weekly_marker(txt):
    # Matched the way both shims match it (bin/claude ~303, same token list): the
    # claude client writes client:seven_day / client:seven_day_opus, the codex client
    # writes client:7d, and any future weekly* name is caught too.
    b = marker_bucket(txt).lower()
    return 'seven_day' in b or '7d' in b or 'weekly' in b

# `.expired` — the persistent "this account cannot authenticate" marker the shim
# honors. Written only for a PROVEN dead grant (expired/absent refresh token, or a
# 4xx from the refresh endpoint), never for a transient network/5xx/429 hiccup.
def mark_expired(d, slug, detail=''):
    mpath = os.path.join(d, '.expired')
    # An org-blocked marker is the strongest statement there is about an account and
    # only `verify` or a re-login may lift it (see clear_expired). Overwriting its
    # REASON with a weaker one is how it gets lifted by accident: the next successful
    # fetch sees a reason clear_expired is willing to drop, and the block vanishes.
    try:
        if 'reason=org-blocked' in open(mpath, errors='replace').read():
            return
    except OSError:
        pass
    try:
        with open(mpath + '.tmp', 'w') as f:
            f.write(f'{int(now)}\n')
            f.write(f"reason={slug} marked_at="
                    f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
        os.replace(mpath + '.tmp', mpath)
    except Exception:
        pass

def clear_expired(d):
    """A usage fetch that succeeded PROVES the bearer works — drop any dead-auth mark.
    EXCEPT:
    - org-blocked: those accounts authenticate perfectly (telemetry works), they are
      just barred from Claude Code inference, so telemetry says nothing about them.
    - setup-token-invalid: the bearer behind a usage fetch or a refresh grant is the
      machine-local OAUTH login, and its success says nothing about the PORTABLE
      token a real inference rejected. On the one Mac holding both, this probe
      un-parked every dead-token account minutes after the shim parked it, and every
      fresh `claude` walked back into the same 401 all day (2026-08-29). Only a NEW
      token (the shim's newer-file rule) or a real call that used the token lifts it.
    Only a passing `verify` (a real call) or a re-login lifts the excepted ones."""
    mpath = os.path.join(d, '.expired')
    try:
        body = open(mpath, errors='replace').read()
        if 'reason=org-blocked' in body or 'reason=setup-token-invalid' in body:
            return False
    except OSError:
        return False
    try:
        os.remove(mpath)
        return True
    except OSError:
        return False

try:
    manifest = json.load(open(os.path.join(root, 'accounts.json')))
except Exception as e:
    sys.exit(f'cannot read manifest: {e}')

def token_digest(path):
    """Stable, non-secret identifier for a credential file's CONTENTS. Used to
    remember which setup token this endpoint refused, so a re-minted one still gets a
    try while the refused one is never spent again. Truncated: this only has to
    distinguish credentials, and a full hash of a secret is not something to write
    into a file that syncs between machines."""
    try:
        with open(path, 'rb') as f:
            return hashlib.sha256(f.read()).hexdigest()[:16]
    except OSError:
        return None


def park_dead_grant(aid, d, slug, detail):
    """Park an account whose OAuth grant is dead — UNLESS it also has a portable setup
    token, in which case the grant being dead proves nothing about the account: the
    token still authenticates every real call. Parking it would take a perfectly
    working account out of the pool over a credential the pool does not need for work,
    only for telemetry. (The shim's auth_dead() checks the .expired marker BEFORE
    server.token, so a marker written here really would remove it.)"""
    try:
        portable = os.path.getsize(os.path.join(d, 'server.token')) > 0
    except OSError:
        portable = False
    if portable:
        say(f'{aid}: OAuth grant is dead ({slug}) but its setup token still works — '
            f'staying in the pool. TELEMETRY is dead for it until it has an OAuth login '
            f'here: claude-accounts login {aid}')
        return
    mark_expired(d, slug, detail)
    say(f'{aid}: {detail} — re-login needed (claude-accounts relogin {aid})')


class FileStore:
    """The OAuth credential as `.credentials.json`: atomic 0600 writes."""
    kind = 'file'

    def __init__(self, path):
        self.path = path

    def read(self):
        return json.load(open(self.path))

    def write(self, doc):
        fd = os.open(self.path + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, 'w') as f:
            json.dump(doc, f)
            f.flush()
            os.fsync(f.fileno())
        os.replace(self.path + '.tmp', self.path)


class KeychainStore:
    """The same credential, held in the macOS Keychain by the client itself (see
    lib/keychain.py). Read and written in place — NEVER copied out to a file: a file
    beside a Keychain item is a second copy of a ROTATING refresh grant, and the
    client reads the Keychain first, so the two would drift apart and strand one."""
    kind = 'keychain'

    def __init__(self, d, probe):
        self.d = d
        self.account = probe.get('account')

    def read(self):
        p = keychain.probe(self.d)
        if p['state'] != 'present':
            raise ValueError(f'keychain credential {p["state"]}')
        return p['doc']

    def write(self, doc):
        if not keychain.write(self.d, doc, account=self.account):
            raise OSError('keychain write refused')


def oauth_store(d):
    """Where <d>'s OAuth login lives for THIS process: a FileStore, a KeychainStore,
    the string 'locked' (Keychain item exists but this session cannot open it), or
    None. The file wins when both exist — same rule as lib/audit.oauth_login."""
    cpath = os.path.join(d, '.credentials.json')
    if os.path.isfile(cpath):
        return FileStore(cpath)
    p = keychain.probe(d)
    if p['state'] == 'present':
        return KeychainStore(d, p)
    if p['state'] == 'locked':
        return 'locked'
    return None


def refresh_oauth(aid, d, store):
    """Refresh a long-expired OAuth access token via the refresh-token grant and
    persist the ROTATED credential atomically (0600 file, or the Keychain item it
    came from). Returns the new bearer, or None (fail open: the stored credential
    is never touched on failure). Failures back off via <dir>/.oauth-refresh.json —
    a side file, NOT limits.json, because telemetry state must only ever reflect
    real usage fetches."""
    spath = os.path.join(d, '.oauth-refresh.json')
    try:
        doc = store.read()
        o = doc.get('claudeAiOauth', {})
        # Present-but-null/non-object claudeAiOauth (interrupted or reset credential
        # write) must degrade THIS account only, like every other malformed input.
        if not isinstance(doc, dict) or not isinstance(o, dict):
            return None
    except Exception:
        return None
    # NB: no "must have an accessToken" gate. A credential whose access token was
    # cleared but whose REFRESH token is alive is exactly the shape a grant is supposed
    # to recover from; requiring the dead half to be present meant such an account could
    # never come back, and (with a setup token beside it) went dark for telemetry
    # forever. The expiresAt gate below is what protects a live session's credential.
    if not o.get('refreshToken'):
        park_dead_grant(aid, d, 'no-refresh-token',
                        'credential has no refresh token and its access token expired')
        return None
    if o.get('expiresAt', 0) / 1000.0 > now - REFRESH_MIN_EXPIRED:
        return None  # not expired long enough to prove no live session owns it
    if o.get('refreshTokenExpiresAt', 0) / 1000.0 <= now:
        # Nothing can revive this account: park it so the shim stops selecting it
        # (every run under it would fail with "OAuth session expired").
        park_dead_grant(aid, d, 'refresh-token-expired',
                        'the refresh token itself expired; only a re-login can fix it')
        return None
    if not force:
        try:
            if json.load(open(spath)).get('retry_after', 0) > now:
                return None  # earlier refresh failure still backing off
        except Exception:
            pass

    def back_off(wait, why, denials=0):
        try:
            with open(spath + '.tmp', 'w') as f:
                json.dump({'retry_after': int(now + wait), 'error': why,
                           'denials': denials,
                           'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, f)
            os.replace(spath + '.tmp', spath)
        except Exception:
            pass
        say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')

    started_with = o['refreshToken']
    body = json.dumps({'grant_type': 'refresh_token',
                       'refresh_token': o['refreshToken'],
                       'client_id': CLIENT_ID}).encode()
    req = urllib.request.Request(TOKEN_URL, data=body, headers={
        'Content-Type': 'application/json',
        'User-Agent': 'claude-multiacc/1.0',
    })
    try:
        data = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
    except urllib.error.HTTPError as e:
        if e.code in (400, 401, 403):
            # A 4xx from the TOKEN endpoint is only proof of a dead grant when the
            # server says so (OAuth's invalid_grant). Everything else 4xx — a bad
            # client_id, an endpoint change, a WAF page, a provider incident — would
            # hit EVERY account at once, so it must never park the whole pool on the
            # first try: back off, and only park after this account has been refused
            # repeatedly.
            body = ''
            try:
                body = e.read().decode('utf-8', 'replace')[:400]
            except Exception:
                pass
            denials = 1
            try:
                denials = int(json.load(open(spath)).get('denials', 0)) + 1
            except Exception:
                pass
            if 'invalid_grant' in body:
                park_dead_grant(aid, d, f'refresh-denied-http-{e.code}',
                                'the refresh grant was refused as invalid_grant (revoked or rotated away)')
            elif denials >= 3:
                park_dead_grant(aid, d, f'refresh-denied-http-{e.code}',
                                f'the refresh grant was refused {denials} times in a row')
            back_off(REFRESH_DENIED_BACKOFF,
                     f'HTTP {e.code} — refresh token may be revoked; re-login needed',
                     denials=denials)
        else:
            back_off(REFRESH_FAIL_BACKOFF, f'HTTP {e.code}')
        return None
    except Exception as e:
        back_off(REFRESH_FAIL_BACKOFF, str(e)[:200])
        return None
    tok = data.get('access_token') if isinstance(data, dict) else None
    if not tok:
        back_off(REFRESH_DENIED_BACKOFF, 'no access_token in response')
        return None
    o['accessToken'] = tok
    # The grant ROTATES the refresh token: persist it (and both expiries) or the
    # account is stranded — hence atomic write, and a loud message if it fails.
    if data.get('refresh_token'):
        o['refreshToken'] = data['refresh_token']
    if data.get('expires_in'):
        o['expiresAt'] = int((now + float(data['expires_in'])) * 1000)
    else:
        # No expires_in in the response: assume a conservative 1h. Leaving the old
        # (past) expiresAt would make every later pass re-run the grant in a loop.
        o['expiresAt'] = int((now + 3600) * 1000)
    if data.get('refresh_token_expires_in'):
        o['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
    doc['claudeAiOauth'] = o
    # CHECK-AND-SET. The grant rotates, and a live claude session refreshes the same
    # file. REFRESH_MIN_EXPIRED makes that unlikely, not impossible — and losing the
    # race by overwriting means the session's newer credential is destroyed. If the
    # on-disk refresh token is no longer the one this grant was issued against, the
    # other writer won: keep its result, discard ours.
    try:
        disk = store.read().get('claudeAiOauth', {})
        if isinstance(disk, dict) and disk.get('refreshToken') != started_with:
            say(f'{aid}: credential was refreshed by something else mid-flight — '
                f'keeping the newer one in the {store.kind}')
            return None
    except Exception:
        pass
    try:
        store.write(doc)
    except Exception as e:
        say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
        return None
    try:
        os.remove(spath)
    except OSError:
        pass
    # The grant answered: whatever parked this account before, it authenticates now.
    if clear_expired(d):
        say(f'{aid}: dead-auth marker cleared (refresh grant works again)')
    say(f'{aid}: oauth access token refreshed via refresh-token grant')
    return tok

for acct in manifest.get('accounts', []):
    aid = acct['id']
    d = os.path.join(root, aid)
    if not os.path.isdir(d):
        continue

    # The usage endpoint rate-limits per account. Several callers can fire at once
    # (60s cron + the shim's opportunistic kick + a manual run), so skip a fetch when
    # this account's data is already fresh, and honor any backoff a 429 set earlier.
    # Checked FIRST so a skipped account never burns an oauth refresh for nothing.
    lpath = os.path.join(d, 'limits.json')
    prev = {}
    if os.path.isfile(lpath):
        try:
            prev = json.load(open(lpath))
        except Exception:
            prev = {}
    # Valid JSON is not the same as a usable document: `[]`, `null` and `"broken"` all
    # parse, and every prev.get() below would then raise OUTSIDE the try — killing the
    # loop and starving every account after this one of telemetry. One corrupt file
    # must cost exactly one account.
    if not isinstance(prev, dict):
        prev = {}

    def num(key, default=0):
        """A field of the wrong type is the same as an absent one. limits.json is
        hand-editable, syncs between machines, and is written by more than one
        version of this tool at once."""
        v = prev.get(key, default)
        return v if isinstance(v, (int, float)) and not isinstance(v, bool) else default

    if not force:
        age = now - num('fetched_at')
        if age < MIN_FETCH_INTERVAL:
            continue
        retry_at = num('retry_after')
        if retry_at > now:
            # Name the ACTUAL error. Reporting every park as a 429 is what let an
            # unauthorized account read as merely rate-limited for eleven days.
            why = prev.get('last_error')
            why = why if isinstance(why, str) and why else 'a failed fetch'
            say(f'{aid}: backing off after {why} ({int(retry_at - now)}s left); limits left as-is')
            continue

    # BEARER ORDER MATTERS, and it is not the obvious one. A setup token authenticates
    # inference forever but is minted WITHOUT the user:profile scope this endpoint
    # requires, so it can only ever produce a 403 here. Trying it before the OAuth
    # refresh grant — which is what this did — killed telemetry for accounts whose
    # refresh token was still perfectly good, purely because a server.token sat beside
    # it. OAuth first, refresh second, token only as a genuine last resort.
    bearer = None
    source = None
    tpath = os.path.join(d, 'server.token')
    # The login may sit in .credentials.json or in the macOS Keychain (the client
    # moves it there from any session that can open the keychain — a launchd agent
    # like the one running this probe can, an ssh session cannot).
    try:
        store = oauth_store(d)
    except Exception as e:
        say(f'{aid}: could not locate the oauth credential ({str(e)[:120]}); failing open')
        store = None
    locked = store == 'locked'
    if locked:
        store = None
    # "This account has an OAuth login that could still work." A Keychain-held one
    # only the Mac's own launchd probe can open counts: it is unusable HERE, not
    # broken. A provably DEAD grant (no refresh token, or a refresh token that has
    # itself expired) does not count — for that account the setup token really is
    # the only bearer left, and it must still get its one try.
    oauth_alive = locked
    if store is not None:
        try:
            oauth_alive = creds_doc_state(store.read(), now)[0] == 'ok'
        except Exception:
            oauth_alive = True   # unreadable is not proof of death: do not spend the token
    has_oauth = oauth_alive
    if store is not None:
        try:
            c = store.read().get('claudeAiOauth', {})
            if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
                bearer, source = c['accessToken'], 'oauth'
        except Exception:
            pass
    if not bearer and store is not None:
        # Hard fail-open guard: NOTHING a single account's refresh does may abort
        # the loop — every account after it would silently starve of telemetry.
        try:
            tok = refresh_oauth(aid, d, store)
        except Exception as e:
            say(f'{aid}: oauth refresh failed unexpectedly ({str(e)[:200]}); failing open')
            tok = None
        if tok:
            bearer, source = tok, 'oauth'
    # A setup token is a LAST RESORT and only for an account that has no OAuth
    # credential at all. Where one exists but could not be used this pass — its
    # access token expired moments ago (refresh_oauth waits REFRESH_MIN_EXPIRED to
    # prove no live session owns it), a refresh backoff, or a Keychain this session
    # cannot open — spending the token is guaranteed to earn a 403 it can never not
    # earn, and that 403 used to park the whole ACCOUNT for six hours. Telemetry then
    # froze for an account whose OAuth would have worked on the very next pass, and
    # the shim ranked the pool on hour-old readings. Waiting for the next pass costs
    # minutes; the token costs six hours and answers nothing.
    if not bearer and has_oauth:
        say(f'{aid}: oauth credential not usable this pass; NOT spending the setup '
            f'token on a usage endpoint that always refuses it — retrying next pass')
    elif not bearer and os.path.isfile(tpath):
        # Once this endpoint has refused THIS token file for lacking a scope, asking
        # again is guaranteed to fail and only spends the account's hourly budget —
        # which is how a permanent authorization problem disguised itself as a rate
        # limit. Keyed on the file's mtime, so re-minting the token retries it.
        # Keyed on a non-secret digest of the token itself, not its mtime: `sync`
        # pushes tokens with rsync -a (mtimes preserved) and two different tokens can
        # land on the same whole second, either of which would skip a credential that
        # was never actually refused.
        denied_digest = prev.get('token_scope_denied')
        tdigest = token_digest(tpath)
        if not force and tdigest and denied_digest == tdigest:
            say(f'{aid}: setup token cannot read usage (no user:profile scope) and there '
                f'is no OAuth login here — telemetry stays dark until: '
                f'claude-accounts login {aid}')
            continue
        t = open(tpath).read().strip()
        if t:
            bearer, source = t, 'token'
    if not bearer:
        # Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
        if locked:
            say(f'{aid}: oauth login is in the macOS Keychain, which this session cannot '
                f'open (ssh/background) — its telemetry comes from the Mac\'s own launchd '
                f'probe; limits left as-is')
        else:
            say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
        continue

    headers = {
        'Authorization': 'Bearer ' + bearer,
        'anthropic-beta': 'oauth-2025-04-20',
        'Content-Type': 'application/json',
        'User-Agent': USER_AGENT,
    }
    req = urllib.request.Request(url, headers=headers)
    try:
        resp = urllib.request.urlopen(req, timeout=15)
        data = json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        body = ''
        try:
            body = e.read().decode('utf-8', 'replace')[:400]
        except Exception:
            pass

        def save_prev():
            """Persist the account's telemetry state as-is (no backoff, no invented
            freshness) — used when what failed says nothing about the ACCOUNT."""
            tmp = lpath + '.tmp'
            with open(tmp, 'w') as f:
                json.dump(prev, f, indent=1)
            os.replace(tmp, lpath)

        def park(wait, note):
            """Record a failed fetch WITHOUT inventing freshness: fetched_at is left
            exactly as it was, so a parked account still reads as stale everywhere."""
            prev['retry_after'] = int(now + wait)
            prev['backoff'] = wait
            prev['last_error'] = note
            prev['last_error_at'] = int(now)
            save_prev()

        if e.code == 429:
            # Respect Retry-After; otherwise exponential backoff capped at 30 min.
            try:
                wait = int(e.headers.get('Retry-After') or 0)
            except (TypeError, ValueError):
                wait = 0
            if wait <= 0:
                wait = min(1800, max(120, int(num('backoff', 60)) * 2))
            park(wait, f'HTTP 429 (rate limited, source={source})')
            say(f'{aid}: rate limited (429); backing off {wait}s; failing open')
        else:
            # EVERY non-2xx now backs off. Before this only a 429 did, so a permanent
            # refusal was re-issued on every scheduled pass from every machine in the
            # fleet — and those retries are what earned the 429s that made an
            # unauthorized account look merely rate-limited. Telemetry then sat frozen
            # while the shim ranked the whole pool NEUTRAL, i.e. picked at random.
            denied = (str(e.headers.get('x-should-retry') or '').lower() == 'false'
                      or 'scope requirement' in body)
            # A setup token can NEVER grow the user:profile scope, so that refusal is
            # permanent and parks for hours. Any other "do not retry" may well be a
            # policy or endpoint issue someone fixes in minutes — park it for an hour,
            # not six, so recovery does not wait on a human running --force.
            scope_denied = denied and 'scope requirement' in body
            if scope_denied:
                wait = USAGE_DENIED_BACKOFF
            elif denied:
                wait = USAGE_DENIED_BACKOFF // 6
            else:
                wait = min(1800, max(USAGE_FAIL_BACKOFF,
                                     int(num('backoff', USAGE_FAIL_BACKOFF // 2)) * 2))
            if scope_denied and source == 'token':
                # Remember WHICH token was refused, so this credential is never spent
                # on this endpoint again — but a freshly minted one still gets a try.
                tdigest = token_digest(tpath)
                if tdigest:
                    prev['token_scope_denied'] = tdigest
            if scope_denied and source == 'token':
                # A refusal of the TOKEN is a fact about that credential, not about
                # the account: parking the account here also blocked the OAuth path,
                # so an account that merely missed one refresh went dark for six
                # hours. The digest above already stops this token being spent again;
                # leave the account free to try its OAuth login next pass.
                save_prev()
            else:
                park(wait, f'HTTP {e.code} (source={source})'
                           + (' — permanent, server said do not retry' if denied else ''))
            if scope_denied and source == 'token':
                # The exact shape of this outage: a setup token is minted WITHOUT the
                # user:profile scope the usage endpoint requires, so an account whose
                # OAuth grant lapsed keeps working for inference and goes permanently
                # dark for telemetry. Only a sign-in ON THIS MACHINE restores it.
                say(f'{aid}: usage endpoint refuses the setup token (HTTP {e.code} — a '
                    f'setup token has no user:profile scope). Telemetry is DEAD for this '
                    f'account until it has an OAuth login here: claude-accounts login {aid}. '
                    f'This token will not be offered again; the ACCOUNT is not parked, so '
                    f'an OAuth login works the moment it lands.')
            elif denied:
                say(f'{aid}: usage fetch refused for good (HTTP {e.code}, source={source}); '
                    f'backing off {wait}s — re-login needed: claude-accounts login {aid}')
            else:
                say(f'{aid}: usage fetch failed (HTTP {e.code}); backing off {wait}s; failing open')
        continue
    except Exception as e:
        # Network/parse trouble is transient by nature, but it still must not be retried
        # every 5 minutes forever — that is how a fleet talks itself into a 429.
        wait = min(1800, max(USAGE_FAIL_BACKOFF,
                             int(num('backoff', USAGE_FAIL_BACKOFF // 2)) * 2))
        try:
            prev['retry_after'] = int(now + wait)
            prev['backoff'] = wait
            prev['last_error'] = str(e)[:200]
            prev['last_error_at'] = int(now)
            tmp = lpath + '.tmp'
            with open(tmp, 'w') as f:
                json.dump(prev, f, indent=1)
            os.replace(tmp, lpath)
        except Exception:
            pass
        say(f'{aid}: usage fetch failed ({e}); backing off {wait}s; failing open')
        continue

    def pct_of(v):
        try:
            return max(0, min(100, int(round(float(v)))))
        except (TypeError, ValueError):
            return None

    def classify_group(kind):
        # session (5h, self-healing) vs weekly (multi-day, expensive) vs monthly.
        # Unknown durable buckets default to 'weekly' so they are never under-weighted.
        k = kind.lower()
        if k.startswith('session') or k in ('five_hour', 'fivehour', '5h'):
            return 'session'
        if 'month' in k:
            return 'monthly'
        return 'weekly'

    # Shape-agnostic bucket extraction: every entry in limits[] becomes a bucket
    # named kind[:model]. If Anthropic drops the per-model (Fable) separation,
    # renames kinds, or reshapes scope, whatever buckets remain are still tracked
    # and the >=90% rule keeps working. Entries we cannot parse are skipped, and
    # any per-account surprise degrades that account only (fail open), never the run.
    buckets = []
    try:
        for lim in (data.get('limits') or []):
            if not isinstance(lim, dict):
                continue
            pct = pct_of(lim.get('percent'))
            if pct is None:
                continue
            kind = str(lim.get('kind') or 'unknown')
            name = kind
            scope = lim.get('scope') or {}
            model = None
            if isinstance(scope, dict):
                m = scope.get('model')
                if isinstance(m, dict):
                    model = m.get('display_name') or m.get('id')
            if model:
                name = f'{name}:{model}'
            resets = lim.get('resets_at')
            buckets.append({
                'name': name,
                'kind': kind,
                'group': str(lim.get('group') or classify_group(kind)),
                'percent': pct,
                'resets_at': resets,
                'resets_epoch': int(parse_iso(resets) or now + 3600),
                # Present only for a bucket that belongs to ONE model: such a
                # bucket parks that model, never the account (see the marker
                # choice below and the shim's scoped_blocks_run).
                'model': model,
            })
        if not buckets:
            # Fallback for a payload with no limits[] array: scan EVERY top-level object
            # carrying a utilization, so per-model buckets (seven_day_opus,
            # seven_day_fable, ...) are picked up too — not just five_hour/seven_day.
            # Missing one would let an exhausted model bucket go unnoticed.
            for k, b in sorted(data.items()):
                if not isinstance(b, dict) or k == 'extra_usage':
                    continue
                pct = pct_of(b.get('utilization'))
                if pct is None:
                    continue
                buckets.append({
                    'name': k,
                    'kind': k,
                    'group': classify_group(k),
                    'percent': pct,
                    'resets_at': b.get('resets_at'),
                    'resets_epoch': int(parse_iso(b.get('resets_at')) or now + 3600),
                })
    except Exception as e:
        say(f'{aid}: unexpected usage payload shape ({e}); failing open')
        continue
    # Successful fetch: fresh buckets replace everything, backoff state is dropped.
    # THREE selection signals, per the documented reset asymmetry:
    #   max_percent    — peak of ALL buckets; drives >=90% EXCLUSION (a full session
    #                    bucket really does block, but its marker expires in ~5h).
    #   weekly_percent — peak of the durable (weekly/monthly) buckets; the PRIMARY
    #                    ranking signal, because weekly headroom only returns on the
    #                    account's fixed weekly reset (days away).
    #   session_percent— peak of the self-healing 5h bucket; the session GATE's input:
    #                    the shim ranks only accounts at/under CLAUDE_MULTIACC_SESSION_GATE
    #                    (default 50) while any clear it. (A soft tiebreaker until
    #                    2026-09-03 — the operator asked for session FIRST, then weekly.)
    # A bucket only feeds those three signals if it SAID something. An INFORMATIVE
    # bucket has a percent above 0, or a parseable reset window. 2026-09-04: for
    # acct-13/acct-14 the usage endpoint answered EVERY bucket `percent: 0,
    # resets_at: null` while Claude Code was being rejected on those same accounts
    # with "You've hit your weekly limit · resets Sep 8"; this writer recorded the
    # zeros verbatim, which made two provably exhausted accounts the leaders of the
    # weekly band and handed them 31 of the last ~60 picks. A truthful bucket ALWAYS
    # carries the window it resets in, so 0% with no window is NO DATA, not an empty
    # account. 0% WITH a real window stays informative — a genuinely fresh account
    # must still rank as empty — and one uninformative bucket beside real ones (the
    # acct-16 shape: `weekly_scoped:Fable` 0/null next to a real session and
    # weekly_all) leaves the real buckets ranking exactly as they do today.
    def informative(b):
        return b['percent'] > 0 or parse_iso(b.get('resets_at')) is not None

    live = [b for b in buckets if informative(b)]
    weekly = [b['percent'] for b in live if b['group'] != 'session']
    session = [b['percent'] for b in live if b['group'] == 'session']
    # PER SIGNAL, never borrowed from another one. Each of the three answers a
    # different question, so each is written only when a bucket of ITS OWN kind said
    # something. Until 2026-09-04 weekly_percent fell back to the overall peak and
    # session_percent to 0: an account whose weekly buckets were all uninformative
    # while its 5h bucket read 40% was recorded as 40% WEEKLY — a number no bucket
    # ever reported, and the signal the band ranks on — and the mirror image
    # (informative weekly, silent session) was recorded as session 0%, which walks
    # straight through the session gate. A signal nobody reported must be ABSENT so
    # the shim reads it as unknown; inventing one is the same mistake as recording a
    # fake zero, one layer up.
    maxp = max([b['percent'] for b in live] or [0])
    weeklyp = max(weekly) if weekly else None
    sessionp = max(session) if session else None
    # ...but only the WEEKLY half of that rule is about a missing answer. The two kinds
    # of bucket go silent for opposite reasons, and 2026-09-22 cost a day of picks to
    # the difference. A weekly window always exists — it is a fixed calendar week,
    # running whether or not the account is — so a weekly bucket with nothing to say is
    # an endpoint that DECLINED, and inventing a number for it is the 2026-09-04 bug.
    # The 5h SESSION window only exists while it is OPEN: leave an account alone for
    # five hours and there is no window left to describe, so the endpoint answers
    # `percent: 0, resets_at: null` — not silence but "nothing has been used". An
    # exhausted session is never silent: a spent 5h bucket ALWAYS carries the window it
    # resets in (the live pool on 2026-09-22 — every session bucket above 0% had a real
    # resets_at, every silent one belonged to an idle account), so a silent session
    # cannot be hiding a full one. Recording it as UNKNOWN is what broke selection:
    # pick_best's quota_known rule needs BOTH readings, so an IDLE account — precisely
    # the one with the most headroom — was scored weekly=100 and dropped out of the
    # band, leaving only the busy accounts that still held an open 5h window. From
    # selection.log at 2026-09-22T12:37:36Z: band-count=1, and that one band member was
    # acct-17 at 95% weekly, while acct-13 and acct-14 sat at 0% and unrankable.
    # So: a session bucket that said nothing inside a document that DID answer reads 0.
    # A document where NOTHING answered still writes no signals at all (see below) —
    # the 2026-09-04 all-zero shape stays unknown, which is the whole point.
    session_measured = bool(session)
    if sessionp is None and live:
        sessionp = 0
    # How long weekly_percent keeps meaning something. A weekly bucket only ever RISES
    # until its reset, so before that moment a stale percent is still a valid lower
    # bound and the shim can rank on it when nothing fresher exists; after it, the
    # number describes a week that is over and says nothing at all. Recording the
    # horizon here keeps the shim from having to parse buckets[] on every invocation.
    # It must come from the bucket weekly_percent actually CAME FROM: a low monthly
    # bucket resetting in an hour says nothing about an 80% weekly one that resets in
    # five days, and taking the minimum over all of them would throw the 80% away.
    wresets = [int(b['resets_epoch']) for b in live
               if b['group'] != 'session' and b['percent'] == weeklyp
               and isinstance(b.get('resets_epoch'), int)]
    # Limit resets ride on this very response (the usage URL asks for the cedar_ember
    # status block), so reading the allowance costs no call against the endpoint's
    # hourly budget. Same policy as codex: a limit the grant refills at >=95%, or one
    # the server reports exhausted, spends the grant. Only an OAuth login can claim —
    # the same credential this fetch just proved works. Fail OPEN like everything else.
    reset_result, reset_view = {'status': 'not_eligible'}, {}
    if source == 'oauth':
        try:
            reset_result, reset_view = claude_reset.refresh_reset_credits(
                d, aid, data, url, headers, say, int(now), threshold)
        except Exception as e:
            say(f'{aid}: limit reset automation failed unexpectedly ({type(e).__name__}); '
                f'failing open')
            reset_result, reset_view = {'status': 'error'}, {}
    redeemed = reset_result.get('status') == 'redeemed'
    out = {'fetched_at': int(now), 'source': source}
    out.update(reset_view)
    if live:
        out['max_percent'] = maxp
    if weeklyp is not None:
        out['weekly_percent'] = weeklyp
    if sessionp is not None:
        out['session_percent'] = sessionp
    if sessionp is not None and not session_measured:
        # Ranking may use this 0 (that is the whole point — an idle account has to get
        # back into the band), but NOTHING may treat it as proof of recovery. 2026-09-22,
        # acct-14: a client:five_hour marker written from a real 429 at 13:06:31Z, and
        # telemetry fetched at 13:04:16Z — WHILE the account was being rejected — still
        # reported the session bucket `percent: 0, resets_at: null`. So a silent session
        # bucket does NOT prove an idle account; the endpoint simply may not report the
        # 5h window at all. The marker was cleared anyway, `--resume` landed straight on
        # it, and the run was rejected inside a minute. Recovery has to be MEASURED.
        out['session_inferred'] = True
    if weeklyp is not None:
        # Written with weekly_percent or not at all: the horizon describes THAT
        # reading, and the shim's degraded path needs both or neither.
        out['weekly_resets_epoch'] = min(wresets) if wresets else 0
    if not live:
        # Nothing usable in the entire payload. Keep the diagnostics (fetched_at,
        # source, the raw buckets) and write NONE of the three percent signals: a
        # missing field makes the shim's fresh_field/cutoff_field reads fail, so the
        # account is UNKNOWN to both selection cuts — never the weekly band's leader,
        # never inside the session gate, and never able to clear a client-rate-limit
        # marker. Unknown is the honest reading; "0%" is what re-admitted two provably
        # exhausted accounts on 2026-09-04 (see informative() above).
        out['no_data'] = True
        say(f'{aid}: usage endpoint returned all-zero buckets with no reset windows — '
            f'no usable telemetry (account ranks as unknown, not as empty)')
    out['buckets'] = buckets
    reset_record = None
    reset_status = claude_reset.parse_status(data) if source == 'oauth' else None
    if reset_status is not None:
        out['reset_grants_seen'] = claude_reset.grants_seen(
            reset_status, now, reset_result if redeemed else None)
    elif isinstance(prev.get('reset_grants_seen'), dict):
        out['reset_grants_seen'] = prev['reset_grants_seen']
    observed = None
    if redeemed:
        # The claim response proves the limits refilled, but this usage GET happened
        # BEFORE it. Make the snapshot stale at once so it cannot re-exclude the account
        # (the codex writer's rule), and take the pre-reset numbers OUT of it: several
        # readers ignore freshness — the shim's forced-fallback check reads buckets
        # directly, the degraded path trusts a stale weekly until its horizon, and the
        # app-robot panel reads session/weekly percentages with no age test at all — and
        # every one of them would keep parking an account that was just refilled. The
        # buckets the reset did not touch stay; the next pass reads server truth.
        cleared = set(reset_result.get('cleared') or [])
        out['fetched_at'] = 0
        for key in ('weekly_resets_epoch', 'max_percent', 'weekly_percent',
                    'session_percent', 'session_inferred', 'no_data'):
            out.pop(key, None)
        out['buckets'] = [b for b in buckets
                          if claude_reset.bucket_limit_type(b['name']) not in cleared]
        out['auto_reset'] = reset_result
        reset_record = (int(reset_result['redeemed_at']), sorted(cleared))
    else:
        # A reset is honoured fleet-wide through THIS document (peers never see a marker
        # deleted), so the record rides along on every later pass until nothing it could
        # supersede can still exist. A reset this machine did not claim (a peer's, or a
        # human's /limit-reset) enters it here, from the grant's own count dropping; this
        # GET already came after it, so its numbers stand.
        observed = claude_reset.observed_reset(prev, reset_status, now)
        if observed:
            # Stamped when SEEN, which is later than the reset itself: every park
            # written in between counts as "before" it. A weekly window cannot
            # re-exhaust in that gap, but a 5h one can — so the 5h refill only counts
            # when this very (post-reset) reading MEASURED the session window; a silent
            # one proves nothing (the acct-14 shape). One second back, so a park this
            # pass writes can never tie with the stamp.
            kept = [t for t in observed[1]
                    if session_measured or t not in claude_reset.SESSION_LIMITS]
            observed = (observed[0] - 1, kept) if kept else None
        reset_record = claude_reset.latest(claude_reset.record_from(prev, now), observed,
                                           claude_reset.local_record(d, now))
        if observed and reset_record is observed:
            say(f"{aid}: limit reset observed (a grant's count dropped; "
                f"refilled {', '.join(observed[1])}) — lifting older parks")
        else:
            observed = None
    if reset_record:
        out['reset_redeemed_at'] = reset_record[0]
        # The same moment as a UTC YYYYmmddHHMMSS key: the shim compares marked_at
        # against it with builtins, no date(1) fork on the per-invocation path.
        out['reset_redeemed_key'] = time.strftime('%Y%m%d%H%M%S', time.gmtime(reset_record[0]))
        out['reset_cleared'] = ','.join(reset_record[1])
    tmp = lpath + '.tmp'
    with open(tmp, 'w') as f:
        json.dump(out, f, indent=1)
    os.replace(tmp, lpath)
    # The fetch went through with this account's own bearer => its auth is alive.
    if clear_expired(d):
        say(f'{aid}: dead-auth marker cleared (authenticated successfully)')
    if redeemed or observed:
        # The shim's transcript scan would otherwise re-create a park from the very
        # rejection the reset just answered — whether or not a marker exists right now
        # and whatever wrote it. Never move the watermark backwards.
        cleared_path = os.path.join(d, '.client-limit-cleared')
        try:
            prior = int(open(cleared_path).read().split()[0])
        except Exception:
            prior = 0
        with open(cleared_path + '.tmp', 'w') as f:
            f.write(f'{max(prior, reset_record[0])}\n')
        os.replace(cleared_path + '.tmp', cleared_path)
    offenders = [] if redeemed else [b for b in buckets if b['percent'] >= threshold]
    mpath = os.path.join(d, '.limited')
    if offenders:
        # ONE bucket, described consistently: the marker's epoch is the reset of
        # the bucket its detail line names. It used to pair the highest PERCENT
        # with the LATEST reset over every offender, and the two came from
        # different buckets: a 100% session bucket resetting in a minute was
        # written with a Fable weekly bucket's epoch six days out, and everything
        # that trusts the marker (this shim, app-robot's fleet verdict on every
        # Mac) parked the whole account for six days over a five-hour window
        # (2026-08-29, acct-05). An account-level bucket wins over a model-scoped
        # one — the latter parks a model, not the account (scoped_blocks_run) —
        # and among those, the longest-lived exclusion, since any bucket over the
        # threshold stays there until its own reset.
        account_level = [b for b in offenders if not b.get('model')]
        worst = max(account_level or offenders,
                    key=lambda b: (int(b['resets_epoch']), b['percent']))
        reset_epoch = int(worst['resets_epoch'])
        # Never SHORTEN an active client-rate-limit marker. A client rejection is
        # first-hand server evidence with its own reset; this pass's worst offender
        # can be a mere session bucket an hour from resetting, and overwriting the
        # marker with that shorter horizon re-admits a provably exhausted account
        # early (codex review, 2026-09-04: a client:seven_day four days out replaced
        # by a 95% session bucket +1h). A LATER reset may still extend the exclusion.
        keep_client = False
        try:
            cur = open(mpath).read()
            first = cur.splitlines()[0] if cur else ''
            # The shims' own validity rule (num_ok: digits only, bounded length): a
            # signed/padded/absurd first line is a GARBLED marker to them, and a
            # garbled marker must be rewritten here, not preserved.
            if first.isdigit() and len(first) <= 18:
                cur_reset = int(first)
                keep_client = ('reason=client-rate-limit' in cur
                               and cur_reset > now and cur_reset >= reset_epoch
                               and not claude_reset.marker_superseded(cur, reset_record))
        except Exception:
            pass
        if keep_client:
            say(f"{aid}: keeping the client-reported marker (its reset reaches further "
                f"than this pass's worst offender)")
        else:
            # Atomic: a concurrent shim must never read a half-written marker.
            with open(mpath + '.tmp', 'w') as f:
                f.write(f'{reset_epoch}\n')
                f.write(f"bucket={worst['name']} percent={worst['percent']} "
                        f"marked_at={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
                        f"reason=limits resets_at={worst['resets_at']}\n")
            os.replace(mpath + '.tmp', mpath)
            say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
    else:
        if os.path.exists(mpath):
            # A shim-written marker outlives a clean limits pass while its own window
            # is still open:
            #   error-cooldown     — the account failed a real call moments ago.
            #   client-rate-limit  — Claude Code itself was REJECTED on this account.
            #                        Preserve that over a possibly-cached response during
            #                        a short grace period. A later successful low-usage
            #                        response disproves it; otherwise false/shared-session
            #                        markers strand recovered accounts for days.
            keep = False
            client_recovered = False
            txt = ''
            try:
                txt = open(mpath).read()
                first = txt.splitlines()[0] if txt else ''
                active = first.isdigit() and int(first) > now
                marked_at = next((part[10:] for part in txt.split()
                                  if part.startswith('marked_at=')), '')
                marked_epoch = parse_iso(marked_at)
                if marked_epoch is None:
                    marked_epoch = os.path.getmtime(mpath)
                recent_client = now - marked_epoch < CLIENT_LIMIT_CONFIRM_DELAY
                is_client = 'reason=client-rate-limit' in txt
                # See weekly_marker() above for why these three come first and in
                # this order; the shim applies the identical test per invocation.
                if redeemed or claude_reset.marker_superseded(txt, reset_record):
                    # A confirmed reset is first-hand proof the limits refilled — the
                    # one thing that outranks even a client-reported weekly rejection
                    # (the codex writer's identical rule). A marker written before it,
                    # for a window it refilled, describes a state that no longer exists.
                    keep = False
                elif active and not live:
                    keep = True
                elif active and is_client and weekly_marker(txt):
                    keep = True
                elif active and is_client and session_marker(txt) and not session_measured:
                    # Same rule as the weekly one, one window down: a rejection the CLIENT
                    # reported is only disproved by telemetry that actually looked at the
                    # window it names. `max_percent` here comes from the WEEKLY buckets
                    # when the session bucket is silent, and clearing a 5h marker on it is
                    # the cross-signal borrow the aggregation above refuses to make.
                    keep = True
                elif active and ('reason=error-cooldown' in txt
                                 or (is_client and recent_client)):
                    keep = True
                client_recovered = active and is_client and not keep
            except Exception:
                pass
            if not keep:
                os.remove(mpath)
                if client_recovered:
                    cleared = os.path.join(d, '.client-limit-cleared')
                    try:
                        prior = int(open(cleared).read().split()[0])
                    except Exception:
                        prior = 0
                    # Never backwards: a reset confirmed earlier in this same pass has
                    # already stamped a later moment than the pass's start.
                    with open(cleared + '.tmp', 'w') as f:
                        f.write(f'{max(prior, int(now))}\n')
                    os.replace(cleared + '.tmp', cleared)
                # On a no-data pass `live` is empty and maxp is 0 only because
                # nothing was reported (see informative() above). Log that, instead
                # of a "0%" that reads like a proven-empty account — the exact
                # misreading behind the 2026-09-04 incident.
                seen = f'max {maxp}%' if live else 'no usable telemetry'
                if redeemed:
                    seen = 'limit reset confirmed'
                elif claude_reset.marker_superseded(txt, reset_record):
                    seen = 'written before a confirmed limit reset'
                say(f'{aid}: marker cleared ({seen})')
            elif not live:
                # One line per account, so a no-data pass is legible in limits.log:
                # the marker was not re-confirmed here, it was merely not disproved.
                say(f'{aid}: marker kept (no usable telemetry)')
        if not quiet:
            detail = '  '.join(f"{b['name']}={b['percent']}%" for b in buckets)
            print(f'{aid}: ok  {detail}')
PYEOF
  local refresh_rc=$?
  # Release the lock BEFORE the report: a --json caller must not hold the refresh
  # lock while a consumer reads its output.
  limits_lock_release
  trap - EXIT
  # Whoever just refreshed is the only machine that CAN — pass it on before the
  # report, so a blind peer stops picking drained accounts within one cycle.
  limits_distribute
  # The refresher fails open per account; a NON-zero status means the pass itself
  # broke (unreadable manifest, dead python). Report the state anyway — stale data
  # beats silence — but hand the caller the failure, exactly as before --json existed.
  if [ "$json" = "1" ]; then
    emit_report_json limits || return $?
  fi
  return "$refresh_rc"
}

cmd_verify() {
  require_manifest
  local quick=0
  [ "${1:-}" = "--quick" ] && quick=1
  local real=""
  if [ "$quick" = "0" ]; then
    real="$(find_real_claude "$_self")" || die "real claude binary not found"
  fi
  "$PYBIN" - "$ACC_ROOT" "$quick" "$real" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
import hashlib, json, os, re, subprocess, sys, time

root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
from audit import audit_account, creds_state, creds_doc_state   # noqa: E402  (shared with the shim's rule)
import keychain  # noqa: E402
machine = sys.argv[5]
now = time.time()
manifest = json.load(open(os.path.join(root, 'accounts.json')))
failures = 0
tested = 0

# Same failure vocabulary the shim retries on (bin/claude: AUTHPAT / ORGPAT).
AUTH_ERR = re.compile(
    r'401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)'
    r'|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)|session expired'
    r'|could not be refreshed|please (run|sign in|log ?in)|re-?authenticate', re.I)
ORG_ERR = re.compile(
    r'organization has disabled|subscription access.*disabl|disabled claude subscription'
    r'|ask your admin to enable|not authorized to use claude code', re.I)
# EXCULPATORY, tested FIRST — the twin of BROKEN_CLI in bin/codex-accounts, and
# it matters MORE here: AUTH_ERR above matches a bare `401` or `403`, so any
# machine fault whose output happens to contain those three digits parks a live
# login. `mark_expired` writes no `soft_until`, so that verdict is permanent
# until a person clears it. These patterns say the CLI never reached the API,
# which is a fact about the machine and never about the login.
# See health.log 2026-08-24: `env: node: No such file or directory` failed
# every account in one pass.
BROKEN_CLI = re.compile(
    r'missing optional dependency|unsupported (platform|target triple):'
    r'|cannot find module|ERR_MODULE_NOT_FOUND|ERR_DLOPEN_FAILED'
    r'|env: node: no such file or directory|node: command not found'
    r'|spawn .{0,80}(ENOENT|EACCES|EPERM)|^dyld\[[0-9]+\]:'
    r'|cannot execute binary file|bad CPU type in executable|exec format error',
    re.I | re.M)

def mark_expired(d, slug, detail=''):
    """Park an account the shim must stop selecting. Verify is the strongest signal
    there is — a real inference call that came back 'not authenticated'."""
    mpath = os.path.join(d, '.expired')
    # Same rule as the limits path: an org block outranks every other reason and only
    # a PASSING verify or a re-login may lift it. Rewriting its reason with a weaker
    # one is how it gets lifted by accident later.
    try:
        if 'reason=org-blocked' in open(mpath, errors='replace').read():
            return
    except OSError:
        pass
    try:
        with open(mpath + '.tmp', 'w') as f:
            f.write(f'{int(time.time())}\n')
            f.write(f"reason={slug} marked_at="
                    f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
        os.replace(mpath + '.tmp', mpath)
    except Exception:
        pass

for acct in manifest.get('accounts', []):
    aid = acct['id']
    d = os.path.join(root, aid)
    cpath = os.path.join(d, '.credentials.json')
    tpath = os.path.join(d, 'server.token')
    has_creds = os.path.isfile(cpath)
    has_token = os.path.isfile(tpath) and os.path.getsize(tpath) > 0
    cred_doc = None
    cred_state = None
    if not has_creds:
        # The login may be in the macOS Keychain (lib/keychain.py). A Keychain this
        # session cannot open is not a failure of the ACCOUNT: the real call below
        # would fail for the session, not the grant, so it is a skip with a reason.
        kc = keychain.probe(d)
        if kc['state'] == 'present':
            has_creds, cred_doc = True, kc['doc']
        elif kc['state'] == 'locked' and not has_token:
            print(f'{aid} {acct["email"]}: SKIP (login is in the macOS Keychain, locked '
                  f'for this session — run verify from the Mac\'s own session)')
            continue
    if not has_creds and not has_token:
        print(f'{aid} {acct["email"]}: SKIP (no auth on this machine)')
        continue
    tested += 1
    if has_creds:
        try:
            c = (cred_doc or json.load(open(cpath))).get('claudeAiOauth', {})
            cred_state = creds_doc_state(cred_doc, now)[0] if cred_doc is not None \
                else creds_state(cpath, now)[0]
            rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
            if rexp and rexp < now and not has_token:
                mark_expired(d, 'refresh-token-expired',
                             'the refresh token itself expired; only a re-login can fix it')
                print(f'{aid} {acct["email"]}: FAIL (refresh token expired — '
                      f'run: claude-accounts relogin {aid})')
                failures += 1
                continue
        except Exception as e:
            print(f'{aid} {acct["email"]}: FAIL (unreadable credentials: {e})')
            failures += 1
            continue
    if quick:
        # Quick mode must agree with what the shim will actually do — presence of a
        # credential file is not proof it can authenticate.
        st = audit_account(root, acct, machine=machine)
        kind = 'oauth' if has_creds else 'token'
        if st['state'] == 'ok':
            print(f'{aid} {acct["email"]}: OK (quick, {kind} present)')
        else:
            print(f'{aid} {acct["email"]}: FAIL ({st["label"]} — {st["reason"]})'
                  + (f'; fix: {st["fix"]}' if st['fix'] else ''))
            failures += 1
        continue
    env = dict(os.environ)
    env['CLAUDE_CONFIG_DIR'] = d
    env.pop('ANTHROPIC_API_KEY', None)
    env.pop('CLAUDE_CODE_OAUTH_TOKEN', None)
    env.pop('CLAUDE_ACCOUNT', None)
    env['CLAUDE_SHIM_ACTIVE'] = '1'
    # Mirror the shim's acct_token(): a portable token is what actually authenticates
    # whenever there is no credential OR the credential beside it is dead. Testing such
    # an account with the dead credential would fail it — and park a healthy account.
    if has_token and (not has_creds or cred_state != 'ok'):
        env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
    t0 = time.time()
    uses_token = 'CLAUDE_CODE_OAUTH_TOKEN' in env
    try:
        r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
                           env=env, capture_output=True, text=True, timeout=240,
                           input='Reply with exactly: OK\n', cwd=root)
    except subprocess.TimeoutExpired:
        print(f'{aid} {acct["email"]}: FAIL (timeout after 240s)')
        failures += 1
        continue
    dt = time.time() - t0
    out = (r.stdout or '').strip()
    if r.returncode == 0 and 'ok' in out.lower():
        # A real call succeeded: this account is definitively alive — but WHICH
        # credential answered matters. A pass on the OAuth login says nothing about a
        # portable token a real call rejected; clearing that park here re-opened the
        # 401 loop the marker exists to stop (the shim re-picked the account, exported
        # the dead token, parked it again — 2026-08-29).
        try:
            marker = open(os.path.join(d, '.expired'), errors='replace').read()
        except OSError:
            marker = ''
        if uses_token or 'reason=setup-token-invalid' not in marker:
            try:
                os.remove(os.path.join(d, '.expired'))
            except OSError:
                pass
        if uses_token:
            try:
                digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
                temp = os.path.join(d, '.server-token-verified.tmp')
                with open(temp, 'w') as f:
                    f.write(digest + '\n')
                os.chmod(temp, 0o600)
                os.replace(temp, os.path.join(d, '.server-token-verified'))
            except OSError:
                pass
        print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
    else:
        raw = (r.stderr or '').strip()
        err = raw[:200]
        hint = ''
        # STDERR only, and the WHOLE of it: stdout is the model's own answer,
        # and a session that merely discusses "Cannot find module" must not be
        # able to excuse a real dead login.
        if BROKEN_CLI.search(raw):
            hint = (' — the Claude CLI cannot run on this machine; not a login '
                    'problem, nothing was parked')
        elif ORG_ERR.search(out) or ORG_ERR.search(err):
            # Not an auth problem: the account authenticates fine, its organization
            # has simply turned Claude Code subscription access off. Park it — a
            # re-login changes nothing — and say what actually helps.
            mark_expired(d, 'org-blocked',
                         "the account's organization has disabled Claude Code access")
            hint = (f' — ORG BLOCKED, excluded from the pool; '
                    f'try: claude-accounts relogin {aid}')
        elif AUTH_ERR.search(out) or AUTH_ERR.search(err):
            try:
                os.remove(os.path.join(d, '.server-token-verified'))
            except OSError:
                pass
            if uses_token:
                mark_expired(d, 'setup-token-invalid',
                             'portable setup-token failed a real inference')
                hint = (f' — portable setup-token is invalid, run: '
                        f'claude-accounts login {aid} --token')
            else:
                mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
                hint = f' — login is dead, run: claude-accounts relogin {aid}'
        print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} '
              f'out={out[:120]!r} err={err!r}{hint}')
        failures += 1

print()
print(f'verified {tested} account(s), {failures} failure(s)')
sys.exit(1 if failures else 0)
PYEOF
}

# Push the pool to ONE remote target. $1 = user@host, $2 = remote pool root,
# $3 = remote addon repo (for the post-sync hook). Uses the caller's slog/fail.
# A target can be the Linux server (token-authenticated) or a peer Mac (which has
# its own machine-local OAuth logins) — the pushed material is valid on both:
# manifest, portable server.token files, seeds, and advisory limit state.
# Credentials (.credentials.json) are NEVER pushed anywhere.
sync_push_target() {
  local server="$1" sroot="$2" srepo="$3"
  slog "push -> $server:$sroot"

  ssh -o BatchMode=yes -o ConnectTimeout=10 "$server" "mkdir -p '$sroot'" >>"$ACC_ROOT/sync.log" 2>&1 \
    || fail "cannot reach $server"
  rsync -az "$MANIFEST" "$server:$sroot/accounts.json" >>"$ACC_ROOT/sync.log" 2>&1 \
    || fail "manifest push to $server failed"
  # The pool's MCP registry rides with the manifest; the target's post-sync reconciles it
  # into every account dir there. A missing local registry leaves the target's alone,
  # and the machine-local overlay (mcp-servers.local.json, a runner daemon's own view
  # of THIS Mac) never travels.
  if [ -f "$ACC_ROOT/mcp-servers.json" ]; then
    rsync -az "$ACC_ROOT/mcp-servers.json" "$server:$sroot/mcp-servers.json" >>"$ACC_ROOT/sync.log" 2>&1 \
      || fail "MCP registry push to $server failed"
  fi

  local id d
  for id in $(account_ids); do
    d="$ACC_ROOT/$id"
    [ -d "$d" ] || continue
    ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
      || fail "mkdir $id on $server failed"
    if [ -s "$d/server.token" ]; then
      # NB: no --chmod — macOS 26 ships openrsync, which rejects it (the push would
      # fail outright). The mode is fixed with an explicit remote chmod instead.
      rsync -az "$d/server.token" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
        || fail "token push for $id to $server failed"
      ssh -o BatchMode=yes "$server" "chmod 600 '$sroot/$id/server.token'" \
        >>"$ACC_ROOT/sync.log" 2>&1 || fail "token chmod for $id on $server failed"
    fi
    local seed
    for seed in .claude.json settings.json; do
      if [ -f "$d/$seed" ]; then
        rsync -az --ignore-existing "$d/$seed" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
          || fail "seed push for $id/$seed to $server failed"
      fi
    done
    # Advisory limit state for accounts the target may lack a bearer for.
    local extra
    for extra in limits.json .limited; do
      if [ -f "$d/$extra" ]; then
        rsync -az "$d/$extra" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 || true
      fi
    done
  done

  # Removal propagation: any target acct dir not in the manifest gets deleted.
  # Safety: an EMPTY id list (parse error, or a truly emptied pool) never deletes —
  # wiping every target credential must be an explicit manual act, not a side effect.
  local ids_list ids_spaced
  ids_list="$(account_ids)"
  if [ -z "$ids_list" ]; then
    slog "removal propagation skipped: empty account list (safety guard)"
  else
    ids_spaced=" $(printf '%s' "$ids_list" | tr '\n' ' ') "
    ssh -o BatchMode=yes "$server" "cd '$sroot' 2>/dev/null || exit 0
for dd in acct-*; do
  [ -d \"\$dd\" ] || continue
  case '$ids_spaced' in
    *\" \$dd \"*) ;;
    *) rm -rf -- \"\$dd\" ;;
  esac
done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation on $server failed"
  fi

  # Post-sync hook: the target seeds dirs and re-runs its quick verification matrix.
  # Non-fatal: a hook that is absent (not bootstrapped) or exits nonzero (accounts
  # awaiting a login THERE) must not fail the push that just succeeded.
  ssh -o BatchMode=yes "$server" "[ -x '$srepo/bin/claude-accounts' ] && '$srepo/bin/claude-accounts' post-sync" \
    >>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook on $server unavailable or unhappy (see its pool state)"
}

# Prints manifest peers (extra sync targets, e.g. a second Mac), one per line:
# target<TAB>root<TAB>repo. Empty output = no peers.
manifest_peers() {
  [ -f "$MANIFEST" ] || return 0
  "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null
import json, sys
peers = json.load(open(sys.argv[1])).get('peers')
if peers is None:
    sys.exit(0)
if not isinstance(peers, list):
    print('MALFORMED\t\t')
    sys.exit(0)
for p in peers:
    if not isinstance(p, dict):
        print('MALFORMED\t\t')
        continue
    print('%s\t%s\t%s' % (p.get('target', ''), p.get('root', ''), p.get('repo', '')))
PYEOF
}

# True when this pool is a sync REPLICA: it receives pushes from the source
# machine and must never push back (two writers racing = last-writer-wins chaos).
# The marker is a machine-local side file — deliberately NOT in the manifest,
# because the manifest itself is what gets pushed to replicas.
sync_is_replica() {
  [ -f "$ACC_ROOT/sync-role" ] || return 1
  # Whole-line match: only a line saying exactly 'replica' counts — a value like
  # 'not-replica' must never silently disable sync.
  LC_ALL=C grep -qixE '[[:space:]]*replica[[:space:]]*' "$ACC_ROOT/sync-role"
}

cmd_sync() {
  require_manifest
  local allow_empty=0 no_server=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --allow-empty) allow_empty=1; shift ;;
      --no-server) no_server=1; shift ;;   # this run pushes nowhere, whatever the manifest says
      *) die "unknown option: $1 (usage: claude-accounts sync [--no-server] [--allow-empty])" ;;
    esac
  done
  local server sroot srepo
  server="$(sync_target)"
  sroot="$(sync_target_root)"
  srepo="$(sync_target_repo)"
  # LOCAL-ONLY: no ssh target at all, because a panel/runner daemon distributes this
  # pool. Validate + fix up locally and stop — the same verb keeps working, it simply
  # has nowhere to push. (Not Mac-gated: a local pool is legitimate on any host.)
  # This mode NARROWS the replica rule, it never widens it: a replica pushes nothing,
  # and a local-only pool pushes nothing whether or not it is a replica. The marker is
  # still honored and still reported, so a replica can never start pushing by having
  # its sync target changed.
  if [ "$no_server" = "1" ] || sync_target_is_local "$server"; then
    rotate_log sync.log
    manifest_well_formed || { log_to sync.log "FAIL: manifest malformed (local sync)"; \
      die "manifest is not valid JSON or has no well-formed accounts — fix $MANIFEST"; }
    local role="source"
    sync_is_replica && role="replica"
    log_to sync.log "sync (local-only, role=$role): no server target${no_server:+ (--no-server)}"
    local_pool_fixup
    if [ "$role" = "replica" ]; then
      echo "sync ok (local-only, and this pool is a sync replica — nothing pushed either way)"
    else
      echo "sync ok (local-only: pool at $ACC_ROOT validated and re-seeded; nothing pushed)"
    fi
    return 0
  fi
  [ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
  if sync_is_replica; then
    echo "this pool is a sync replica — the source machine pushes here; nothing sent"
    return 0
  fi
  # These land inside remote shell commands — anything but a plain target/path is a
  # command-injection vector from a corrupted or hand-edited manifest. EVERY target
  # (primary and peers) is validated before anything is pushed anywhere.
  valid_ssh_target "$server" || die "manifest 'server' is not a plain user@host: $server"
  valid_remote_path "$sroot" || die "manifest 'server_root' is not a plain absolute path: $sroot"
  valid_remote_path "$srepo" || die "manifest 'server_repo' is not a plain absolute path: $srepo"
  local peers pt pr pp tab
  tab="$(printf '\t')"
  peers="$(manifest_peers)"
  if [ -n "$peers" ]; then
    while IFS="$tab" read -r pt pr pp; do
      [ -n "$pt$pr$pp" ] || continue   # blank line only — a partial entry is fatal below
      [ "$pt" = "MALFORMED" ] && die "manifest 'peers' contains a malformed entry — each peer needs target, root and repo"
      { [ -n "$pt" ] && [ -n "$pr" ] && [ -n "$pp" ]; } \
        || die "manifest peer entry is incomplete (target, root and repo are all required): target='$pt' root='$pr' repo='$pp'"
      valid_ssh_target "$pt" || die "manifest peer target is not a plain user@host: $pt"
      valid_remote_path "$pr" || die "manifest peer root is not a plain absolute path: $pr"
      valid_remote_path "$pp" || die "manifest peer repo is not a plain absolute path: $pp"
    done <<EOF
$peers
EOF
  fi
  rotate_log sync.log
  slog() { log_to sync.log "$*"; }
  fail() { slog "FAIL: $*"; printf 'claude-accounts sync: FAILED: %s\n' "$*" >&2; exit 1; }
  slog "sync start -> $server:$sroot${peers:+ (+ peers)}"

  # Hard validation before anything destructive: a corrupt or accountless manifest must
  # never be pushed (it would blank the target pools), and must never make the removal
  # propagation wipe a target's credentials. Emptying the pool on purpose is possible
  # via `sync --allow-empty`, so this can never happen by accident.
  manifest_well_formed \
    || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
  if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
    fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
  fi

  sync_push_target "$server" "$sroot" "$srepo"
  local npeers=0
  if [ -n "$peers" ]; then
    while IFS="$tab" read -r pt pr pp; do
      [ -n "$pt" ] || continue
      # </dev/null: the ssh/rsync calls inside read STDIN, and stdin here is the
      # peer list itself — the first peer's ssh swallowed every later line, so sync
      # reached gas-mini (first in the list) and never mini-3..mini-8 (peers since
      # 2026-09-04; the limits fan-out, which reads its list through process
      # substitution, was the only thing that ever reached them).
      sync_push_target "$pt" "$pr" "$pp" </dev/null
      npeers=$((npeers + 1))
    done <<EOF
$peers
EOF
  fi

  slog "sync ok"
  if [ "$npeers" -gt 0 ]; then
    echo "sync ok -> $server:$sroot + $npeers peer(s)"
  else
    echo "sync ok -> $server:$sroot"
  fi
}

# MCP servers for EVERY account (lib/mcp_registry.py). `add`/`add-json`/`remove` edit
# this pool's registry under the mutate lock, reconcile every account, auto-sync, and
# by default (--provider both) make the same change in the sibling provider's pool —
# an MCP server is not provider-specific, and the operator wants it in both CLIs.
# `list`/`apply`/`import-local` act on this pool alone; the overlay `import-local`
# writes is machine-local by definition, so it is applied here and never synced.
mcp_sibling_root() { # the other provider's pool root, by the same env precedence
  if [ "$MULTIACC_PROVIDER" = "codex" ]; then
    printf '%s\n' "${CLAUDE_ACCOUNTS_ROOT:-${CLAUDE_ACCOUNTS_DIR:-$HOME/.claude-accounts}}"
  else
    printf '%s\n' "${CODEX_ACCOUNTS_ROOT:-${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}}"
  fi
}
cmd_mcp() {
  require_manifest
  local sub="${1:-}" provider=both before_dd=1 a rc=0 sibling sibling_root
  local args=()
  case "$sub" in
    add|add-json|remove|list|apply|import-local) shift ;;
    "") usage; return 1 ;;
    *) die "unknown mcp subcommand: $sub (add, add-json, remove, list, apply, import-local)" ;;
  esac
  [ -f "$LIB_DIR/mcp_registry.py" ] || die "lib/mcp_registry.py is missing from this install"
  case "$sub" in
    list|apply|import-local)
      "$PYBIN" "$LIB_DIR/mcp_registry.py" --root "$ACC_ROOT" --provider "$MULTIACC_PROVIDER" "$sub" "$@"
      return $? ;;
  esac
  # --provider is OUR option: strip it, but only before a literal `--` — after it every
  # word belongs to the MCP server's own command line and is passed through untouched.
  while [ $# -gt 0 ]; do
    a="$1"
    if [ "$before_dd" = 1 ]; then
      case "$a" in
        --) before_dd=0 ;;
        --provider)
          [ $# -ge 2 ] || die "--provider needs a value: claude, codex or both"
          provider="$2"; shift 2; continue ;;
        --provider=*) provider="${a#--provider=}"; shift; continue ;;
      esac
    fi
    args+=("$a"); shift
  done
  case "$provider" in
    claude|codex|both) ;;
    *) die "--provider must be claude, codex or both (got '$provider')" ;;
  esac
  if [ "$provider" = "$MULTIACC_PROVIDER" ] || [ "$provider" = both ]; then
    mutate_lock || die "could not acquire the account lock — try again"
    trap mutate_unlock EXIT
    "$PYBIN" "$LIB_DIR/mcp_registry.py" --root "$ACC_ROOT" --provider "$MULTIACC_PROVIDER" "$sub" \
      ${args[@]+"${args[@]}"}
    rc=$?
    mutate_unlock
    trap - EXIT
    # 3 = the registry WAS saved and some account could not be reconciled (a corrupt
    # config, a layout the TOML editor refuses): the change still has to reach the
    # server, the peers and the sibling pool, and `mcp apply` repairs the account once
    # it is fixed. 1/2 = nothing was saved: stop here.
    case "$rc" in
      0) ;;
      3) printf '%s: registry saved; some accounts were not reconciled — run: %s mcp apply\n' \
           "$PROVIDER_CLI" "$PROVIDER_CLI" >&2 ;;
      *) return "$rc" ;;
    esac
    auto_sync
  fi
  if [ "$provider" != "$MULTIACC_PROVIDER" ]; then
    if [ "$MULTIACC_PROVIDER" = "codex" ]; then sibling=claude; else sibling=codex; fi
    sibling_root="$(mcp_sibling_root)"
    # With the DEFAULT (both), a machine without the sibling pool or CLI has nothing to
    # mirror, and silently so — a codex-only Mac must not warn on every claude
    # change. An EXPLICIT --provider codex asked for that pool by name: refuse loudly.
    if [ ! -f "$sibling_root/accounts.json" ]; then
      [ "$provider" = both ] && return "$rc"
      die "no $sibling pool on this machine ($sibling_root/accounts.json is missing)"
    fi
    if [ ! -x "$BIN_DIR/$sibling-accounts" ]; then
      [ "$provider" = both ] && return "$rc"
      die "$sibling-accounts is not installed beside $PROVIDER_CLI"
    fi
    # The caller's no-sync request travels with the hop, in the sibling's spelling.
    if [ "${CLAUDE_MULTIACC_NO_SYNC:-0}" = "1" ]; then export CODEX_MULTIACC_NO_SYNC=1; fi
    local sib_rc=0
    MULTIACC_PROVIDER="$sibling" "$BIN_DIR/$sibling-accounts" mcp "$sub" --provider "$sibling" \
      ${args[@]+"${args[@]}"} || sib_rc=$?
    [ "$rc" -eq 0 ] && rc="$sib_rc"
  fi
  return "$rc"
}

cmd_post_sync() {
  require_manifest
  local_pool_fixup
  log_to sync.log "post-sync: seeded $(account_ids | wc -l | tr -d ' ') account dirs"
  ( cmd_limits --quiet ) || true   # subshell: release the limits lock before verify
  cmd_verify --quick
}

# Version of the copy at $1, read from its package.json on disk — the only answer
# that describes what will actually execute.
cmd_self_update() {
  # Update the addon in place. npm global install => npm i -g @latest (its postinstall
  # re-runs install.sh). git checkout => git pull + ./install.sh. Anything else is a
  # no-op with a hint. Best-effort and fully logged; never disrupts a running claude.
  local quiet=0
  [ "${1:-}" = "--quiet" ] && quiet=1
  # The update log is appended to with a plain redirect below, so the pool root has to
  # exist: on a machine where this provider's pool was never initialised the redirect
  # itself failed, and the update was reported as "npm update FAILED" with nothing in
  # the log to say why.
  mkdir -p "$ACC_ROOT" 2>/dev/null || true
  rotate_log update.log
  ulog() { log_to update.log "$*"; [ "$quiet" = "1" ] || echo "$*"; }
  case "$REPO_DIR" in
    */node_modules/claude-multiacc|*/node_modules/claude-multiacc/*)
      local cur lat prefix after npm_bin
      prefix="${REPO_DIR%/lib/node_modules/*}"
      npm_bin="$(find_npm "$prefix")" || {
        ulog "self-update: no npm found (looked in $prefix/bin, /opt/homebrew/bin, /usr/local/bin, /usr/bin, PATH); skipping"
        return 0
      }
      # Finding npm is not the same as being able to RUN it: npm ships as
      # `#!/usr/bin/env node`, so an agent PATH without the node that owns it makes
      # every invocation die with "env: node: No such file or directory" — and npm
      # exits 0 on that, so the version probe just came back empty and the update
      # "failed" with nothing to explain it. node lives beside npm, so every npm call
      # below runs with that directory in front (command-scoped: nothing else in this
      # process has its PATH changed underneath it).
      local npm_path
      npm_path="$(dirname "$npm_bin"):$PATH"
      # Update THE COPY THAT IS RUNNING, not whichever one the ambient npm prefix
      # happens to point at. my-mini had two global installs — homebrew's on PATH and
      # nvm's under `npm root -g` — and self-update kept upgrading the nvm one and
      # reporting success while every `claude` invocation ran the stale homebrew copy.
      # It sat eleven versions behind for weeks and said "already latest" throughout.
      # A deploy nobody runs is not a deploy, and one that announces success is worse
      # than one that fails.
      cur="$(pkg_version_at "$REPO_DIR")"
      lat="$(PATH="$npm_path" "$npm_bin" view claude-multiacc version 2>/dev/null)"
      if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
        ulog "self-update: already latest ($cur)"
        return 0
      fi
      ulog "self-update: npm $cur -> ${lat:-latest} (prefix $prefix, npm $npm_bin)"
      if PATH="$npm_path" "$npm_bin" install -g --prefix "$prefix" claude-multiacc@latest \
           >>"$ACC_ROOT/update.log" 2>&1; then
        # Verify by re-reading the file on disk. npm reporting success says nothing
        # about which tree it wrote to.
        after="$(pkg_version_at "$REPO_DIR")"
        if [ -n "$lat" ] && [ "$after" != "$lat" ]; then
          ulog "self-update: npm reported success but $REPO_DIR is still $after, not $lat — this install is NOT being updated"
          return 1
        fi
        ulog "self-update: npm update ok ($after)"
      else
        ulog "self-update: npm update FAILED (see update.log)"
        return 1
      fi
      ;;
    *)
      if [ -d "$REPO_DIR/.git" ] && command -v git >/dev/null 2>&1; then
        ulog "self-update: git pull in $REPO_DIR"
        if git -C "$REPO_DIR" pull --ff-only >>"$ACC_ROOT/update.log" 2>&1; then
          "$REPO_DIR/install.sh" >>"$ACC_ROOT/update.log" 2>&1 \
            && ulog "self-update: git update + reinstall ok" \
            || { ulog "self-update: reinstall FAILED"; return 1; }
        else
          ulog "self-update: git pull FAILED (local changes? see update.log)"
          return 1
        fi
      else
        ulog "self-update: not an npm or git install ($REPO_DIR) — update manually"
      fi
      ;;
  esac
}

cmd_health() {
  require_manifest
  rotate_log health.log
  local out rc=0
  # verify first: its real claude runs refresh any expired OAuth creds, so the
  # limits pass that follows always has fresh bearers.
  out="$( { echo "== verify =="; cmd_verify; echo; echo "== limits =="; cmd_limits; } 2>&1 )" || rc=1
  # The shim being reachable at all is part of the pool's health: a login shell that
  # resolves the real binary hands every `claude` typed there to ~/.claude, whatever
  # the accounts above say. Reported (and failed) here, where the weekly check pages.
  local shim_out
  shim_out="$( { echo; echo "== shim =="; shim_path_report; } 2>&1 )" || rc=1
  out="$out
$shim_out"
  printf '%s\n' "$out"
  printf '%s health rc=%s\n%s\n' "$(ts_utc)" "$rc" "$out" >> "$ACC_ROOT/health.log"
  if [ "$rc" -ne 0 ] && [ "$(machine_kind)" = "mac" ]; then
    osascript -e 'display notification "claude-multiacc health check FAILED — run claude-accounts status" with title "claude-multiacc"' 2>/dev/null || true
  fi
  return $rc
}

# Every KNOWN subcommand answers `--help`/`-h` with the usage text and exit 0. This is not
# a nicety: app-robot's runner probes for a verb with `<verb> --help` and reads a non-zero
# exit as "this build predates the verb", which silently parked panel-to-Mac credential
# distribution for as long as the arg loops rejected the flag. An UNKNOWN verb must still
# FAIL, or the probe stops meaning what it says — so this list must hold exactly the verbs
# the dispatcher below implements, and a test pins that both ways.
_KNOWN_VERBS="list status add import export-credential export-cred import-credential import-cred adopt dedupe remove mint login expired relogin re-login sync verify limits post-sync health self-update mcp"
case " $_KNOWN_VERBS " in
  *" ${1:-help} "*)
    # ...but never past a literal `--`: from there on the words belong to an MCP
    # server's own command line (`mcp add … -- node s.js -h 127.0.0.1`).
    for _arg in "$@"; do
      case "$_arg" in --) break ;; --help|-h) usage; exit 0 ;; esac
    done ;;
esac

case "${1:-help}" in
  list) shift; cmd_list "$@" ;;
  status) shift; cmd_status "$@" ;;
  add) shift; cmd_add "$@" ;;
  import) shift; cmd_import "$@" ;;
  export-credential|export-cred) shift; cmd_export_credential "$@" ;;
  import-credential|import-cred) shift; cmd_import_credential "$@" ;;
  adopt) shift; cmd_adopt "$@" ;;
  dedupe) shift; cmd_dedupe "$@" ;;
  remove) shift; cmd_remove "$@" ;;
  mint) shift; cmd_mint "$@" ;;
  login) shift; cmd_login "$@" ;;
  expired) shift; cmd_expired "$@" ;;
  relogin|re-login) shift; cmd_relogin "$@" ;;
  sync) shift; cmd_sync "$@" ;;
  verify) shift; cmd_verify "$@" ;;
  limits) shift; cmd_limits "$@" ;;
  # Internal: the detached telemetry push `limits` starts in its own session.
  limits-distribute-now) shift; limits_distribute_now ;;
  post-sync) shift; cmd_post_sync "$@" ;;
  health) shift; cmd_health "$@" ;;
  self-update) shift; cmd_self_update "$@" ;;
  mcp) shift; cmd_mcp "$@" ;;
  help|--help|-h) usage ;;
  *) usage; exit 1 ;;
esac
